├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── auth.go ├── client.go ├── client_test.go ├── doc.go ├── logger.go ├── net.go ├── net_test.go ├── pty.go ├── ratelimit.go └── terminal.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | notifications: 4 | email: false 5 | 6 | install: 7 | - go get -v github.com/GeertJohan/fgt github.com/golang/lint/golint ./... 8 | 9 | script: 10 | - fgt golint 11 | - make test 12 | 13 | go: 14 | - 1.4 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Andrey Petrov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test ./... 3 | golint ./... 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoDoc](https://godoc.org/github.com/shazow/go-sshkit?status.svg)](https://godoc.org/github.com/shazow/go-sshkit) 2 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/shazow/go-sshkit/master/LICENSE) 3 | [![Build Status](https://travis-ci.org/shazow/go-sshkit.svg?branch=master)](https://travis-ci.org/shazow/go-sshkit) 4 | 5 | # go-sshkit 6 | 7 | Toolkit for building SSH servers and clients in Go. 8 | 9 | Based on code from [ssh-chat](https://github.com/shazow/ssh-chat). 10 | 11 | 12 | # License 13 | 14 | MIT. 15 | -------------------------------------------------------------------------------- /auth.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/base64" 6 | "errors" 7 | "net" 8 | 9 | "golang.org/x/crypto/ssh" 10 | ) 11 | 12 | // Auth is used to authenticate connections based on public keys. 13 | type Auth interface { 14 | // Whether to allow connections without a public key. 15 | AllowAnonymous() bool 16 | // Given address and public key, return if the connection should be permitted. 17 | Check(net.Addr, ssh.PublicKey) (bool, error) 18 | } 19 | 20 | // MakeAuth makes an ssh.ServerConfig which performs authentication against an Auth implementation. 21 | func MakeAuth(auth Auth) *ssh.ServerConfig { 22 | config := ssh.ServerConfig{ 23 | NoClientAuth: false, 24 | // Auth-related things should be constant-time to avoid timing attacks. 25 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 26 | ok, err := auth.Check(conn.RemoteAddr(), key) 27 | if !ok { 28 | return nil, err 29 | } 30 | perm := &ssh.Permissions{Extensions: map[string]string{ 31 | "pubkey": string(key.Marshal()), 32 | }} 33 | return perm, nil 34 | }, 35 | KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { 36 | if !auth.AllowAnonymous() { 37 | return nil, errors.New("public key authentication required") 38 | } 39 | _, err := auth.Check(conn.RemoteAddr(), nil) 40 | return nil, err 41 | }, 42 | } 43 | 44 | return &config 45 | } 46 | 47 | // MakeNoAuth makes a simple ssh.ServerConfig which allows all connections. 48 | // Primarily used for testing. 49 | func MakeNoAuth() *ssh.ServerConfig { 50 | config := ssh.ServerConfig{ 51 | NoClientAuth: false, 52 | // Auth-related things should be constant-time to avoid timing attacks. 53 | PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { 54 | perm := &ssh.Permissions{Extensions: map[string]string{ 55 | "pubkey": string(key.Marshal()), 56 | }} 57 | return perm, nil 58 | }, 59 | KeyboardInteractiveCallback: func(conn ssh.ConnMetadata, challenge ssh.KeyboardInteractiveChallenge) (*ssh.Permissions, error) { 60 | return nil, nil 61 | }, 62 | } 63 | 64 | return &config 65 | } 66 | 67 | // Fingerprint performs a SHA256 BASE64 fingerprint of the PublicKey, similar to OpenSSH. 68 | // See: https://anongit.mindrot.org/openssh.git/commit/?id=56d1c83cdd1ac 69 | func Fingerprint(k ssh.PublicKey) string { 70 | hash := sha256.Sum256(k.Marshal()) 71 | return base64.StdEncoding.EncodeToString(hash[:]) 72 | } 73 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "io" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | // NewRandomSigner generates a random key of a desired bit length. 12 | func NewRandomSigner(bits int) (ssh.Signer, error) { 13 | key, err := rsa.GenerateKey(rand.Reader, bits) 14 | if err != nil { 15 | return nil, err 16 | } 17 | return ssh.NewSignerFromKey(key) 18 | } 19 | 20 | // NewClientConfig creates a barebones ssh.ClientConfig to be used with ssh.Dial. 21 | func NewClientConfig(name string) *ssh.ClientConfig { 22 | return &ssh.ClientConfig{ 23 | User: name, 24 | Auth: []ssh.AuthMethod{ 25 | ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) (answers []string, err error) { 26 | return 27 | }), 28 | }, 29 | } 30 | } 31 | 32 | // ConnectShell makes a barebones SSH client session, used for testing. 33 | func ConnectShell(host string, name string, handler func(r io.Reader, w io.WriteCloser)) error { 34 | config := NewClientConfig(name) 35 | conn, err := ssh.Dial("tcp", host, config) 36 | if err != nil { 37 | return err 38 | } 39 | defer conn.Close() 40 | 41 | session, err := conn.NewSession() 42 | if err != nil { 43 | return err 44 | } 45 | defer session.Close() 46 | 47 | in, err := session.StdinPipe() 48 | if err != nil { 49 | return err 50 | } 51 | 52 | out, err := session.StdoutPipe() 53 | if err != nil { 54 | return err 55 | } 56 | 57 | /* FIXME: Do we want to request a PTY? 58 | err = session.RequestPty("xterm", 80, 40, ssh.TerminalModes{}) 59 | if err != nil { 60 | return err 61 | } 62 | */ 63 | 64 | err = session.Shell() 65 | if err != nil { 66 | return err 67 | } 68 | 69 | handler(out, in) 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "testing" 7 | 8 | "golang.org/x/crypto/ssh" 9 | ) 10 | 11 | var errRejectAuth = errors.New("not welcome here") 12 | 13 | type RejectAuth struct{} 14 | 15 | func (a RejectAuth) AllowAnonymous() bool { 16 | return false 17 | } 18 | func (a RejectAuth) Check(net.Addr, ssh.PublicKey) (bool, error) { 19 | return false, errRejectAuth 20 | } 21 | 22 | func consume(ch <-chan *Terminal) { 23 | for _ = range ch { 24 | } 25 | } 26 | 27 | func TestClientReject(t *testing.T) { 28 | signer, err := NewRandomSigner(512) 29 | config := MakeAuth(RejectAuth{}) 30 | config.AddHostKey(signer) 31 | 32 | s, err := ListenSSH(":0", config) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | defer s.Close() 37 | 38 | go consume(s.ServeTerminal()) 39 | 40 | conn, err := ssh.Dial("tcp", s.Addr().String(), NewClientConfig("foo")) 41 | if err == nil { 42 | defer conn.Close() 43 | t.Error("Failed to reject conncetion") 44 | } 45 | t.Log(err) 46 | } 47 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | /* 4 | 5 | signer, err := ssh.ParsePrivateKey(privateKey) 6 | 7 | config := MakeNoAuth() 8 | config.AddHostKey(signer) 9 | 10 | s, err := ListenSSH("0.0.0.0:2022", config) 11 | if err != nil { 12 | // Handle opening socket error 13 | } 14 | defer s.Close() 15 | 16 | terminals := s.ServeTerminal() 17 | 18 | for term := range terminals { 19 | go func() { 20 | defer term.Close() 21 | term.SetPrompt("...") 22 | term.AutoCompleteCallback = nil // ... 23 | 24 | for { 25 | line, err := term.ReadLine() 26 | if err != nil { 27 | break 28 | } 29 | term.Write(...) 30 | } 31 | 32 | }() 33 | } 34 | */ 35 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import "io" 4 | import stdlog "log" 5 | 6 | var logger *stdlog.Logger 7 | 8 | // SetLogger replaces the library's log writer 9 | func SetLogger(w io.Writer) { 10 | flags := stdlog.Flags() 11 | prefix := "[sshkit] " 12 | logger = stdlog.New(w, prefix, flags) 13 | } 14 | 15 | type nullWriter struct{} 16 | 17 | func (nullWriter) Write(data []byte) (int, error) { 18 | return len(data), nil 19 | } 20 | 21 | func init() { 22 | SetLogger(nullWriter{}) 23 | } 24 | -------------------------------------------------------------------------------- /net.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "net" 5 | 6 | "github.com/shazow/rateio" 7 | "golang.org/x/crypto/ssh" 8 | ) 9 | 10 | // SSHListener is a container for the connection and ssh-related configuration 11 | type SSHListener struct { 12 | net.Listener 13 | config *ssh.ServerConfig 14 | RateLimit func() rateio.Limiter 15 | } 16 | 17 | // ListenSSH makes an SSH listener socket 18 | func ListenSSH(laddr string, config *ssh.ServerConfig) (*SSHListener, error) { 19 | socket, err := net.Listen("tcp", laddr) 20 | if err != nil { 21 | return nil, err 22 | } 23 | l := SSHListener{Listener: socket, config: config} 24 | return &l, nil 25 | } 26 | 27 | func (l *SSHListener) handleConn(conn net.Conn) (*Terminal, error) { 28 | if l.RateLimit != nil { 29 | // TODO: Configurable Limiter? 30 | conn = ReadLimitConn(conn, l.RateLimit()) 31 | } 32 | 33 | // Upgrade TCP connection to SSH connection 34 | sshConn, channels, requests, err := ssh.NewServerConn(conn, l.config) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | // FIXME: Disconnect if too many faulty requests? (Avoid DoS.) 40 | go ssh.DiscardRequests(requests) 41 | return NewSession(sshConn, channels) 42 | } 43 | 44 | // ServeTerminal accepts incoming connections as terminal requests and yield them 45 | func (l *SSHListener) ServeTerminal() <-chan *Terminal { 46 | ch := make(chan *Terminal) 47 | 48 | go func() { 49 | defer l.Close() 50 | defer close(ch) 51 | 52 | for { 53 | conn, err := l.Accept() 54 | 55 | if err != nil { 56 | logger.Printf("Failed to accept connection: %v", err) 57 | return 58 | } 59 | 60 | // Goroutineify to resume accepting sockets early 61 | go func() { 62 | term, err := l.handleConn(conn) 63 | if err != nil { 64 | logger.Printf("Failed to handshake: %v", err) 65 | return 66 | } 67 | ch <- term 68 | }() 69 | } 70 | }() 71 | 72 | return ch 73 | } 74 | -------------------------------------------------------------------------------- /net_test.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "testing" 7 | ) 8 | 9 | func TestServerInit(t *testing.T) { 10 | config := MakeNoAuth() 11 | s, err := ListenSSH(":badport", config) 12 | if err == nil { 13 | t.Fatal("should fail on bad port") 14 | } 15 | 16 | s, err = ListenSSH(":0", config) 17 | if err != nil { 18 | t.Error(err) 19 | } 20 | 21 | err = s.Close() 22 | if err != nil { 23 | t.Error(err) 24 | } 25 | } 26 | 27 | func TestServeTerminals(t *testing.T) { 28 | signer, err := NewRandomSigner(512) 29 | config := MakeNoAuth() 30 | config.AddHostKey(signer) 31 | 32 | s, err := ListenSSH(":0", config) 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | terminals := s.ServeTerminal() 38 | 39 | go func() { 40 | // Accept one terminal, read from it, echo back, close. 41 | term := <-terminals 42 | term.SetPrompt("> ") 43 | 44 | line, err := term.ReadLine() 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | _, err = term.Write([]byte("echo: " + line + "\r\n")) 49 | if err != nil { 50 | t.Error(err) 51 | } 52 | 53 | term.Close() 54 | }() 55 | 56 | host := s.Addr().String() 57 | name := "foo" 58 | 59 | err = ConnectShell(host, name, func(r io.Reader, w io.WriteCloser) { 60 | // Consume if there is anything 61 | buf := new(bytes.Buffer) 62 | w.Write([]byte("hello\r\n")) 63 | 64 | buf.Reset() 65 | _, err := io.Copy(buf, r) 66 | if err != nil { 67 | t.Error(err) 68 | } 69 | 70 | expected := "> hello\r\necho: hello\r\n" 71 | actual := buf.String() 72 | if actual != expected { 73 | t.Errorf("Got %q; expected %q", actual, expected) 74 | } 75 | s.Close() 76 | }) 77 | 78 | if err != nil { 79 | t.Fatal(err) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pty.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import "encoding/binary" 4 | 5 | // Helpers below are borrowed from go.crypto circa 2011: 6 | 7 | // parsePtyRequest parses the payload of the pty-req message and extracts the 8 | // dimensions of the terminal. See RFC 4254, section 6.2. 9 | func parsePtyRequest(s []byte) (width, height int, ok bool) { 10 | _, s, ok = parseString(s) 11 | if !ok { 12 | return 13 | } 14 | width32, s, ok := parseUint32(s) 15 | if !ok { 16 | return 17 | } 18 | height32, _, ok := parseUint32(s) 19 | width = int(width32) 20 | height = int(height32) 21 | if width < 1 { 22 | ok = false 23 | } 24 | if height < 1 { 25 | ok = false 26 | } 27 | return 28 | } 29 | 30 | func parseWinchRequest(s []byte) (width, height int, ok bool) { 31 | width32, s, ok := parseUint32(s) 32 | if !ok { 33 | return 34 | } 35 | height32, s, ok := parseUint32(s) 36 | if !ok { 37 | return 38 | } 39 | 40 | width = int(width32) 41 | height = int(height32) 42 | if width < 1 { 43 | ok = false 44 | } 45 | if height < 1 { 46 | ok = false 47 | } 48 | return 49 | } 50 | 51 | func parseString(in []byte) (out string, rest []byte, ok bool) { 52 | if len(in) < 4 { 53 | return 54 | } 55 | length := binary.BigEndian.Uint32(in) 56 | if uint32(len(in)) < 4+length { 57 | return 58 | } 59 | out = string(in[4 : 4+length]) 60 | rest = in[4+length:] 61 | ok = true 62 | return 63 | } 64 | 65 | func parseUint32(in []byte) (uint32, []byte, bool) { 66 | if len(in) < 4 { 67 | return 0, nil, false 68 | } 69 | return binary.BigEndian.Uint32(in), in[4:], true 70 | } 71 | -------------------------------------------------------------------------------- /ratelimit.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "io" 5 | "net" 6 | "time" 7 | 8 | "github.com/shazow/rateio" 9 | ) 10 | 11 | type limitedConn struct { 12 | net.Conn 13 | io.Reader // Our rate-limited io.Reader for net.Conn 14 | } 15 | 16 | func (r *limitedConn) Read(p []byte) (n int, err error) { 17 | return r.Reader.Read(p) 18 | } 19 | 20 | // ReadLimitConn returns a net.Conn whose io.Reader interface is rate-limited by limiter. 21 | func ReadLimitConn(conn net.Conn, limiter rateio.Limiter) net.Conn { 22 | return &limitedConn{ 23 | Conn: conn, 24 | Reader: rateio.NewReader(conn, limiter), 25 | } 26 | } 27 | 28 | // Count each read as 1 unless it exceeds some number of bytes. 29 | type inputLimiter struct { 30 | // TODO: Could do all kinds of fancy things here, like be more forgiving of 31 | // connections that have been around for a while. 32 | 33 | Amount int 34 | Frequency time.Duration 35 | 36 | remaining int 37 | readCap int 38 | numRead int 39 | timeRead time.Time 40 | } 41 | 42 | // NewInputLimiter returns a rateio.Limiter with sensible defaults for 43 | // differentiating between humans typing and bots spamming. 44 | func NewInputLimiter() rateio.Limiter { 45 | grace := time.Second * 3 46 | return &inputLimiter{ 47 | Amount: 200 * 4 * 2, // Assume fairly high typing rate + margin for copypasta of links. 48 | Frequency: time.Minute * 2, 49 | readCap: 128, // Allow up to 128 bytes per read (anecdotally, 1 character = 52 bytes over ssh) 50 | numRead: -1024 * 1024, // Start with a 1mb grace 51 | timeRead: time.Now().Add(grace), 52 | } 53 | } 54 | 55 | // Count applies 1 if n limit.Amount { 68 | return rateio.ErrRateExceeded 69 | } 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /terminal.go: -------------------------------------------------------------------------------- 1 | package sshkit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | 8 | "golang.org/x/crypto/ssh" 9 | "golang.org/x/crypto/ssh/terminal" 10 | ) 11 | 12 | // Connection is an interface with fields necessary to operate an sshkit host. 13 | type Connection interface { 14 | PublicKey() ssh.PublicKey 15 | RemoteAddr() net.Addr 16 | Name() string 17 | Close() error 18 | } 19 | 20 | type sshConn struct { 21 | *ssh.ServerConn 22 | } 23 | 24 | func (c sshConn) PublicKey() ssh.PublicKey { 25 | if c.Permissions == nil { 26 | return nil 27 | } 28 | 29 | s, ok := c.Permissions.Extensions["pubkey"] 30 | if !ok { 31 | return nil 32 | } 33 | 34 | key, err := ssh.ParsePublicKey([]byte(s)) 35 | if err != nil { 36 | return nil 37 | } 38 | 39 | return key 40 | } 41 | 42 | func (c sshConn) Name() string { 43 | return c.User() 44 | } 45 | 46 | // Terminal is extending ssh/terminal to include a closer interface 47 | type Terminal struct { 48 | terminal.Terminal 49 | Conn Connection 50 | Channel ssh.Channel 51 | } 52 | 53 | // NewTerminal creates a Terminal from a session channel 54 | func NewTerminal(conn *ssh.ServerConn, ch ssh.NewChannel) (*Terminal, error) { 55 | if ch.ChannelType() != "session" { 56 | return nil, errors.New("terminal requires session channel") 57 | } 58 | channel, requests, err := ch.Accept() 59 | if err != nil { 60 | return nil, err 61 | } 62 | term := Terminal{ 63 | *terminal.NewTerminal(channel, "Connecting..."), 64 | sshConn{conn}, 65 | channel, 66 | } 67 | 68 | go term.listen(requests) 69 | go func() { 70 | // FIXME: Is this necessary? 71 | conn.Wait() 72 | channel.Close() 73 | }() 74 | 75 | return &term, nil 76 | } 77 | 78 | // NewSession finds a session channel and makes a Terminal from it 79 | func NewSession(conn *ssh.ServerConn, channels <-chan ssh.NewChannel) (term *Terminal, err error) { 80 | for ch := range channels { 81 | if t := ch.ChannelType(); t != "session" { 82 | ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) 83 | continue 84 | } 85 | 86 | term, err = NewTerminal(conn, ch) 87 | if err == nil { 88 | break 89 | } 90 | } 91 | 92 | if term != nil { 93 | // Reject the rest. 94 | // FIXME: Do we need this? 95 | go func() { 96 | for ch := range channels { 97 | ch.Reject(ssh.Prohibited, "only one session allowed") 98 | } 99 | }() 100 | } 101 | 102 | return term, err 103 | } 104 | 105 | // Close terminal and ssh connection 106 | func (t *Terminal) Close() error { 107 | return t.Conn.Close() 108 | } 109 | 110 | // Negotiate terminal type and settings 111 | func (t *Terminal) listen(requests <-chan *ssh.Request) { 112 | hasShell := false 113 | 114 | for req := range requests { 115 | var width, height int 116 | var ok bool 117 | 118 | switch req.Type { 119 | case "shell": 120 | if !hasShell { 121 | ok = true 122 | hasShell = true 123 | } 124 | case "pty-req": 125 | width, height, ok = parsePtyRequest(req.Payload) 126 | if ok { 127 | // TODO: Hardcode width to 100000? 128 | err := t.SetSize(width, height) 129 | ok = err == nil 130 | } 131 | case "window-change": 132 | width, height, ok = parseWinchRequest(req.Payload) 133 | if ok { 134 | // TODO: Hardcode width to 100000? 135 | err := t.SetSize(width, height) 136 | ok = err == nil 137 | } 138 | } 139 | 140 | if req.WantReply { 141 | req.Reply(ok, nil) 142 | } 143 | } 144 | } 145 | --------------------------------------------------------------------------------