├── .gitignore ├── README.md ├── go.mod ├── go.sum ├── main.go ├── socks5.go └── tunnel.go /.gitignore: -------------------------------------------------------------------------------- 1 | sockssh 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | sockssh 2 | ==== 3 | 4 | When you have a bunch of public facing servers to monitor, rather than opening several ports with complex authentication, a simpler way would be to rely on the handy tool you trust and use everyday - SSH. You probably heard or got used to `ssh -L`, i.e., [SSH tunneling](https://www.ssh.com/ssh/tunneling/example) for casual tasks, but how can your monitoring tool like Prometheus use it to fetch metrics from your thousands of servers? 5 | 6 | This is how [sockssh](https://github.com/getlantern/sockssh) comes into play. It's a SOCKS5 server listening on the local port. When new proxy requests come in, it creates SSH connection to the destination server (if not already exists), extracts the port on the server to which you intend to connect as the SOCKS5 username, and establish a tunnel. The remote user and key file to authenticate is supplied as command line options. 7 | 8 | # Usage 9 | 10 | ```sh 11 | # Starts sockssh on the background 12 | sockssh -socks5-port=8000 -ssh-user=ubuntu -ssh-key-file=/home//.ssh/id_rsa & 13 | # Fetchs goroutine profile which serves on 127.0.0.1:4000 on the remote server 14 | curl -x socks5://4000@localhost:8000 :22/debug/pprof/goroutine?debug=1 15 | 16 | # If clients doesn't support SOCKS5 authentication, setting remote port as command line option 17 | sockssh -socks5-port=8000 -ssh-user=ubuntu -ssh-key-file=/home//.ssh/id_rsa -remote-port=4000 & 18 | # Note the remote port is omitted 19 | curl -x socks5://localhost:8000 :22/debug/pprof/goroutine?debug=1 20 | ``` 21 | 22 | It closes the SSH connections idled for 24 hours, but you can change it via the `-idle-close` option. 23 | 24 | # License 25 | 26 | Apache License 2.0 27 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/getlantern/sockssh 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 7 | github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de 8 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 9 | golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 2 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 3 | github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de h1:fkw+7JkxF3U1GzQoX9h69Wvtvxajo5Rbzy6+YMMzPIg= 4 | github.com/vharitonsky/iniflags v0.0.0-20180513140207-a33cd0b5f3de/go.mod h1:irMhzlTz8+fVFj6CH2AN2i+WI5S6wWFtK3MBCIxIpyI= 5 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 6 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472 h1:Gv7RPwsi3eZ2Fgewe3CBsuOebPwO27PoXzRpJPsvSSM= 7 | golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 8 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 9 | golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM= 10 | golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 11 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 12 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 13 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 15 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net" 7 | "sync" 8 | "time" 9 | 10 | "github.com/vharitonsky/iniflags" 11 | ) 12 | 13 | var ( 14 | socks5Port = flag.String("socks5-port", "8080", "The port on which the local SOCKS5 server is listen.") 15 | remotePort = flag.String("remote-port", "", "The port to access on the remote servers. Leave it empty to allow the remote port being passed as SOCKS5 user name on a per-request basis.") 16 | sshUser = flag.String("ssh-user", "", "User name on the remote servers.") 17 | sshKeyFile = flag.String("ssh-key-file", "", "The path of the private key file to authenticate the user on the remote servers.") 18 | idleClose = flag.Duration("idle-close", 24*time.Hour, "The period of silence before closing the SSH connection to the remote server. It usually won't be hit unless we no longer care about the remote server.") 19 | ) 20 | 21 | func main() { 22 | var mx sync.Mutex 23 | remoteServers := make(map[string]*remoteServer) 24 | 25 | iniflags.Parse() 26 | s := socks{ 27 | Dial: func(ctx context.Context, network, addr string) (conn net.Conn, err error) { 28 | mx.Lock() 29 | s, exists := remoteServers[addr] 30 | if !exists { 31 | s, err = NewRemoteServer(addr, *sshUser, *sshKeyFile) 32 | if err != nil { 33 | mx.Unlock() 34 | return 35 | } 36 | remoteServers[addr] = s 37 | } 38 | mx.Unlock() 39 | // Passed as username in SOCKS5 request 40 | remotePort := ctx.Value(ctxKeyRemotePort).(string) 41 | return s.ForwardTo(net.JoinHostPort("127.0.0.1", remotePort), *idleClose) 42 | }, 43 | remotePort: *remotePort, 44 | } 45 | s.Serve(net.JoinHostPort("127.0.0.1", *socks5Port)) 46 | } 47 | -------------------------------------------------------------------------------- /socks5.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | 9 | "github.com/armon/go-socks5" 10 | ) 11 | 12 | const ctxKeyRemotePort = "RemotePort" 13 | 14 | type socks struct { 15 | Dial func(ctx context.Context, net, addr string) (net.Conn, error) 16 | remotePort string 17 | } 18 | 19 | func (s *socks) Serve(addr string) error { 20 | l, err := net.Listen("tcp", addr) 21 | if err != nil { 22 | return fmt.Errorf("Unable to listen: %q", err) 23 | } 24 | conf := &socks5.Config{ 25 | Dial: s.Dial, 26 | Rewriter: s, 27 | } 28 | if s.remotePort == "" { 29 | // Retrieve SOCKS5 user name as remote port on a per request basis 30 | conf.Credentials = alwaysValid{} 31 | } 32 | server, err := socks5.New(conf) 33 | if err != nil { 34 | return fmt.Errorf("Unable to create SOCKS5 server: %v", err) 35 | } 36 | 37 | log.Printf("About to start SOCKS5 client proxy at %v", addr) 38 | return server.Serve(l) 39 | } 40 | 41 | func (s *socks) Rewrite(ctx context.Context, request *socks5.Request) (context.Context, *socks5.AddrSpec) { 42 | remotePort := s.remotePort 43 | if remotePort == "" { 44 | remotePort = request.AuthContext.Payload["Username"] 45 | } 46 | ctx = context.WithValue(ctx, ctxKeyRemotePort, remotePort) 47 | return ctx, request.DestAddr 48 | } 49 | 50 | type alwaysValid struct{} 51 | 52 | func (s alwaysValid) Valid(user, password string) bool { 53 | return true 54 | } 55 | -------------------------------------------------------------------------------- /tunnel.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net" 7 | "sync/atomic" 8 | "time" 9 | 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | type remoteServer struct { 14 | config *ssh.ClientConfig 15 | addr string 16 | client atomic.Value // *ssh.Client 17 | closeTimer *time.Timer 18 | } 19 | 20 | func NewRemoteServer( 21 | addr string, 22 | user string, 23 | keyFile string) (*remoteServer, error) { 24 | key, err := ioutil.ReadFile(keyFile) 25 | if err != nil { 26 | return nil, err 27 | } 28 | signer, err := ssh.ParsePrivateKey(key) 29 | if err != nil { 30 | return nil, err 31 | } 32 | config := &ssh.ClientConfig{ 33 | User: user, 34 | Auth: []ssh.AuthMethod{ 35 | ssh.PublicKeys(signer), 36 | }, 37 | HostKeyCallback: ssh.InsecureIgnoreHostKey(), 38 | } 39 | return &remoteServer{config: config, addr: addr, closeTimer: time.NewTimer(0)}, nil 40 | } 41 | 42 | func (s *remoteServer) ForwardTo(addr string, sshIdleClose time.Duration) (conn net.Conn, err error) { 43 | if !s.closeTimer.Stop() { 44 | // We do not drain the channel here to avoid race condition with the 45 | // goroutine created below. This branch is very unlikely to be hit in 46 | // reality though. 47 | } 48 | s.closeTimer.Reset(sshIdleClose) 49 | var redialSSH bool 50 | for i := 0; i < 2; i++ { 51 | client := s.client.Load() 52 | if client == nil || redialSSH { 53 | log.Printf("Creating new SSH connection to %s", s.addr) 54 | client, err = ssh.Dial("tcp", s.addr, s.config) 55 | if err != nil { 56 | return 57 | } 58 | go func() { 59 | _ = <-s.closeTimer.C 60 | log.Printf("Closing SSH connection to %s", s.addr) 61 | client.(*ssh.Client).Close() 62 | }() 63 | s.client.Store(client) 64 | } 65 | conn, err = client.(*ssh.Client).Dial("tcp", addr) 66 | if err != nil { 67 | redialSSH = true 68 | continue 69 | } 70 | return conn, nil 71 | } 72 | return nil, err 73 | } 74 | --------------------------------------------------------------------------------