From 8a37355bb6d2f6e66e2afce5ecfad684462b5e40 Mon Sep 17 00:00:00 2001 From: Anders Pitman Date: Fri, 2 Oct 2020 16:57:09 -0600 Subject: [PATCH] Implement openssh key management I had been moving in the direction of implementing a custom SSH server in golang. That would be pretty easy if using a custom application protocol, but I want to support tcpip-forward which looks like it would be a lot more work. It also would be nice to support generic CLI clients like OpenSSH. The point of using SSH in the first place is that it's known to be a solid tunneling solution. To that end, I've decided to rely on OpenSSH for now, since that program may have tunneled more bits than any other since the dawn of time. This requires a bit of hackery to generate SSH keys and place them in authorized_keys (as well as shipping the private key to the client), but I think this will work well for now. Plus OpenSSH is already installed on pretty much every server I'd expect to run boringproxy. --- api.go | 107 ++++++++++++++++++++++++++++++++++++++++++++++ boringproxy.go | 11 +++-- tunnel_manager.go | 98 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 api.go diff --git a/api.go b/api.go new file mode 100644 index 0000000..af1ce6e --- /dev/null +++ b/api.go @@ -0,0 +1,107 @@ +package main + +import ( + //"fmt" + //"strings" + "net/http" + "io" + "encoding/json" +) + +type Api struct { + auth *Auth + tunMan *TunnelManager + mux *http.ServeMux +} + +type CreateTunnelResponse struct { + ServerAddress string `json:"server_address"` + ServerPort int `json:"server_port"` + ServerPublicKey string `json:"server_public_key"` + TunnelPort int `json:"tunnel_port"` + TunnelPrivateKey string `json:"tunnel_private_key"` +} + + +func NewApi(auth *Auth, tunMan *TunnelManager) *Api { + + api := &Api{auth, tunMan, nil} + + mux := http.NewServeMux() + + mux.Handle("/tunnels", http.StripPrefix("/tunnels", http.HandlerFunc(api.handleTunnels))) + + api.mux = mux + + return api +} + + +func (a *Api) ServeHTTP(w http.ResponseWriter, r *http.Request) { + a.mux.ServeHTTP(w, r) +} + +func (a *Api) handleTunnels(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case "POST": + a.validateSession(http.HandlerFunc(a.handleCreateTunnel)).ServeHTTP(w, r) + default: + w.WriteHeader(405) + w.Write([]byte("Invalid method for /tunnels")) + } +} + +func (a *Api) handleCreateTunnel(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + if len(query["domain"]) != 1 { + w.WriteHeader(400) + w.Write([]byte("Invalid domain parameter")) + return + } + domain := query["domain"][0] + + port, privKey, err := a.tunMan.CreateTunnel(domain) + if err != nil { + w.WriteHeader(400) + io.WriteString(w, err.Error()) + return + } + + response := &CreateTunnelResponse{ + ServerAddress: "anders.boringproxy.io", + ServerPort: 22, + ServerPublicKey: "", + TunnelPort: port, + TunnelPrivateKey: privKey, + } + + responseJson, err := json.MarshalIndent(response, "", " ") + if err != nil { + w.WriteHeader(500) + io.WriteString(w, "Error encoding response") + return + } + + w.Write(responseJson) +} + +func (a *Api) validateSession(h http.Handler) http.Handler { + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token, err := extractToken("access_token", r) + if err != nil { + w.WriteHeader(401) + w.Write([]byte("No token provided")) + return + } + + if !a.auth.Authorized(token) { + w.WriteHeader(403) + w.Write([]byte("Not authorized")) + return + } + + h.ServeHTTP(w, r) + }) +} diff --git a/boringproxy.go b/boringproxy.go index 6401e83..38ec1e7 100644 --- a/boringproxy.go +++ b/boringproxy.go @@ -34,7 +34,6 @@ type BoringProxy struct { tunMan *TunnelManager adminListener *AdminListener certConfig *certmagic.Config - sshServer *SshServer } func NewBoringProxy() *BoringProxy { @@ -68,11 +67,15 @@ func NewBoringProxy() *BoringProxy { auth := NewAuth() - sshServer := NewSshServer() + p := &BoringProxy{config, auth, tunMan, adminListener, certConfig} - p := &BoringProxy{config, auth, tunMan, adminListener, certConfig, sshServer} + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + p.handleAdminRequest(w, r) + }) + + api := NewApi(auth, tunMan) + http.Handle("/api/", http.StripPrefix("/api", api)) - http.HandleFunc("/", p.handleAdminRequest) go http.Serve(adminListener, nil) log.Println("BoringProxy ready") diff --git a/tunnel_manager.go b/tunnel_manager.go index 9e500a9..f4ac771 100644 --- a/tunnel_manager.go +++ b/tunnel_manager.go @@ -1,12 +1,19 @@ package main import ( + "fmt" + "strings" "encoding/json" "errors" "github.com/caddyserver/certmagic" "io/ioutil" "log" "sync" + "crypto/rsa" + "crypto/rand" + "encoding/pem" + "crypto/x509" + "golang.org/x/crypto/ssh" ) type Tunnel struct { @@ -20,6 +27,7 @@ func NewTunnels() Tunnels { } type TunnelManager struct { + nextPort int tunnels Tunnels mutex *sync.Mutex certConfig *certmagic.Config @@ -49,8 +57,10 @@ func NewTunnelManager(certConfig *certmagic.Config) *TunnelManager { } } + nextPort := 9001 + mutex := &sync.Mutex{} - return &TunnelManager{tunnels, mutex, certConfig} + return &TunnelManager{nextPort, tunnels, mutex, certConfig} } func (m *TunnelManager) SetTunnel(host string, port int) error { @@ -69,6 +79,35 @@ func (m *TunnelManager) SetTunnel(host string, port int) error { return nil } +func (m *TunnelManager) CreateTunnel(domain string) (int, string, error) { + err := m.certConfig.ManageSync([]string{domain}) + if err != nil { + log.Println(err) + return 0, "", errors.New("Failed to get cert") + } + + m.mutex.Lock() + defer m.mutex.Unlock() + + _, exists := m.tunnels[domain] + if exists { + return 0, "", errors.New("Tunnel exists for domain " + domain) + } + + port := m.nextPort + m.nextPort += 1 + tunnel := &Tunnel{port} + m.tunnels[domain] = tunnel + saveJson(m.tunnels, "tunnels.json") + + privKey, err := m.addToAuthorizedKeys(port) + if err != nil { + return 0, "", err + } + + return port, privKey, nil +} + func (m *TunnelManager) DeleteTunnel(host string) { m.mutex.Lock() delete(m.tunnels, host) @@ -87,3 +126,60 @@ func (m *TunnelManager) GetPort(serverName string) (int, error) { return tunnel.Port, nil } + +func (m *TunnelManager) addToAuthorizedKeys(port int) (string, error) { + + akBytes, err := ioutil.ReadFile("/home/anders/.ssh/authorized_keys") + if err != nil { + return "", err + } + + akStr := string(akBytes) + + pubKey, privKey, err := MakeSSHKeyPair() + if err != nil { + return "", err + } + + options := fmt.Sprintf(`command="echo This key permits tunnels only",permitopen="fakehost:1",permitlisten="localhost:%d"`, port) + + pubKeyNoNewline := pubKey[:len(pubKey) - 1] + newAk := fmt.Sprintf("%s%s %s %s%d\n", akStr, options, pubKeyNoNewline, "boringproxy-", port) + + err = ioutil.WriteFile("/home/anders/.ssh/authorized_keys", []byte(newAk), 0600) + if err != nil { + return "", err + } + + return privKey, nil +} + +// Adapted from https://stackoverflow.com/a/34347463/943814 +// MakeSSHKeyPair make a pair of public and private keys for SSH access. +// Public key is encoded in the format for inclusion in an OpenSSH authorized_keys file. +// Private Key generated is PEM encoded +func MakeSSHKeyPair() (string, string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + return "", "", err + } + + // generate and write private key as PEM + var privKeyBuf strings.Builder + + privateKeyPEM := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)} + if err := pem.Encode(&privKeyBuf, privateKeyPEM); err != nil { + return "", "", err + } + + // generate and write public key + pub, err := ssh.NewPublicKey(&privateKey.PublicKey) + if err != nil { + return "", "", err + } + + var pubKeyBuf strings.Builder + pubKeyBuf.Write(ssh.MarshalAuthorizedKey(pub)) + + return pubKeyBuf.String(), privKeyBuf.String(), nil +}