├── lambda ├── .gitignore ├── bin │ └── curl ├── invoke.sh ├── package.json └── index.js ├── .gitignore ├── template.yaml ├── server ├── pty.go └── server.go ├── readme.md ├── tunnel └── tunnel.go └── main.go /lambda/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | build/ 3 | tiny_ssh 4 | -------------------------------------------------------------------------------- /lambda/bin/curl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smithclay/faassh/HEAD/lambda/bin/curl -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | id_rsa 3 | id_rsa.pub 4 | faassh 5 | .aws-sam 6 | packaged.yaml 7 | -------------------------------------------------------------------------------- /lambda/invoke.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | 3 | aws lambda invoke --invocation-type RequestResponse --function-name SshFunction --payload '' --region us-west-2 --log-type Tail output.txt 4 | -------------------------------------------------------------------------------- /lambda/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "faassh-function", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "Clay Smith", 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /template.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Transform: AWS::Serverless-2016-10-31 3 | Description: faassh, an SSH Server on AWS Lambda 4 | Resources: 5 | FaasshLayer: 6 | Type: AWS::Serverless::LayerVersion 7 | Properties: 8 | LayerName: faassh-net-overlay 9 | Description: faash server 10 | ContentUri: ./layer 11 | CompatibleRuntimes: 12 | - nodejs8.10 13 | LicenseInfo: 'MIT' 14 | RetentionPolicy: Retain 15 | 16 | FaaServer: 17 | Type: AWS::Serverless::Function 18 | Properties: 19 | Handler: index.handler 20 | Runtime: nodejs8.10 21 | Timeout: 120 22 | ReservedConcurrentExecutions: 1 23 | CodeUri: ./lambda 24 | Environment: 25 | Variables: 26 | PORT: 2200 27 | JUMP_HOST: 0.tcp.ngrok.io 28 | JUMP_HOST_PORT: 15303 29 | Layers: 30 | - !Ref FaasshLayer 31 | -------------------------------------------------------------------------------- /lambda/index.js: -------------------------------------------------------------------------------- 1 | // Inspired from Apex: https://github.com/apex/apex/blob/master/shim/index.js 2 | console.log('[shim] start function'); 3 | var child = require('child_process'); 4 | 5 | const port = process.env.PORT || '2200'; 6 | const jh = process.env.JUMP_HOST || '0.tcp.ngrok.io'; 7 | const jhPort = process.env.JUMP_HOST_PORT || '15303'; 8 | const jhUser = process.env.JUMP_HOST_USER|| 'csmith'; 9 | 10 | const proc = child.spawn('./faassh', 11 | `-port ${port} tunnel -jh ${jh} -jh-user ${jhUser} -jh-port ${jhPort} -tunnel-port 5001`.split(' ')); 12 | 13 | proc.on('error', function(err){ 14 | console.error('[shim] error: %s', err) 15 | process.exit(1) 16 | }) 17 | 18 | proc.on('exit', function(code, signal){ 19 | console.error('[shim] exit: code=%s signal=%s', code, signal) 20 | process.exit(1) 21 | }); 22 | 23 | proc.stderr.on('data', function(line){ 24 | console.error('[faassh] data from faassh: `%s`', line) 25 | }); 26 | 27 | proc.stdout.on('data', function(line){ 28 | console.log('[faassh] data from faassh: `%s`', line) 29 | }); 30 | 31 | exports.handler = (event, context, callback) => { 32 | context.callbackWaitsForEmptyEventLoop = false; 33 | // TODO: Don't kill the process, just close the session. 34 | setInterval(() => { 35 | var timeRemaining = context.getRemainingTimeInMillis(); 36 | if (timeRemaining < 2000) { 37 | console.log(`Less than ${timeRemaining}ms left before timeout. Shutting down...`); 38 | proc.kill('SIGINT'); 39 | } 40 | }, 500); 41 | }; 42 | -------------------------------------------------------------------------------- /server/pty.go: -------------------------------------------------------------------------------- 1 | package server 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 parsePtyReq(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 parseWindowChangeReq(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 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # faassh 2 | ### simple go SSH server designed for running in cloud functions 3 | 4 | ![image](https://cloud.githubusercontent.com/assets/27153/25602411/819d0b02-2ea8-11e7-9f64-157226b2d4cb.png) 5 | 6 | This is just for fun. It's a simple SSH server and tunnel-er that allows you to SSH into a running lambda function—until it times out. 7 | 8 | Developed for my [dotScale](https://dotscale.io) 2017 talk, "Searching for the Server in Serverless". Slides [here](http://speakerdeck.com/smithclay/searching-for-the-server-in-serverless). 9 | 10 | ## building 11 | 12 | This project uses the [Serverless Application Model](https://aws.amazon.com/serverless/sam/) for packaging and deploying. 13 | 14 | ```sh 15 | $ sam build 16 | $ sam package --s3-bucket > packaged.yaml 17 | $ sam deploy --template-file packaged.yaml --stack-name --capabilities CAPABILITY_IAM 18 | ``` 19 | 20 | ## usage 21 | 22 | ``` 23 | faassh -i ./path_to_private_rsa_host_key -p port_number 24 | ``` 25 | 26 | ## example 27 | 28 | See the example node.js lambda function in the `lambda/` directory. 29 | 30 | * Generate RSA keys for the Lambda function and bundle inside the `lambda` directory (`ssh-keygen -t rsa -f ./id_rsa`) 31 | * Set the envionment variables to point to your SSH jump host with the correct username. 32 | 33 | If you'd like to test it on your local laptop that's behind (hopefully) a NAT/firewall, I like the TCP forwarding available on [ngrok](https://ngrok.com/). You can create a tunnel to your local SSH server for the other end of the tunnel endpoint, you just run: `ngrok tcp 22`. 34 | 35 | ## other interesting/related projects 36 | 37 | * [lambdash](https://github.com/alestic/lambdash) - another approach for running commands in Lambda 38 | * [awslambdaproxy](https://github.com/dan-v/awslambdaproxy) - An AWS Lambda powered HTTP/SOCKS web proxy 39 | 40 | ## todo 41 | 42 | - better authentication support 43 | - other cloud providers 44 | - connection cleanup 45 | - terraform/cloudformation helper 46 | - multiple connections 47 | - tests and docs :) 48 | -------------------------------------------------------------------------------- /tunnel/tunnel.go: -------------------------------------------------------------------------------- 1 | package tunnel 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net" 9 | 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | type Endpoint struct { 14 | HostPort string 15 | User string 16 | } 17 | 18 | type SSHtunnel struct { 19 | Local *Endpoint 20 | Server *Endpoint 21 | Remote *Endpoint 22 | tunnelClient *ssh.Client 23 | Config *ssh.ClientConfig 24 | } 25 | 26 | func (t *SSHtunnel) Stop() error { 27 | err := t.tunnelClient.Close() 28 | return err 29 | } 30 | 31 | func (t *SSHtunnel) Start() error { 32 | log.Printf("Creating tunnel to %v with user %v...", t.Server.HostPort, t.Config.User) 33 | conn, err := ssh.Dial("tcp", t.Server.HostPort, t.Config) 34 | if err != nil { 35 | log.Fatalf("unable to connect to remote server: %v", err) 36 | } 37 | defer conn.Close() 38 | 39 | t.tunnelClient = conn 40 | 41 | log.Printf("Registering tcp forward on %v", t.Remote.HostPort) 42 | remoteListener, err := conn.Listen("tcp", t.Remote.HostPort) 43 | if err != nil { 44 | log.Fatalf("unable to register tcp forward: %v", err) 45 | } 46 | defer remoteListener.Close() 47 | log.Printf("TCP forward listening on: %v", remoteListener.Addr()) 48 | 49 | for { 50 | r, err := remoteListener.Accept() 51 | if err != nil { 52 | log.Fatalf("listen.Accept failed: %v", err) 53 | } 54 | 55 | go t.forward(r) 56 | } 57 | } 58 | 59 | func (t *SSHtunnel) forward(remoteConn net.Conn) { 60 | log.Printf("Registering local tcp forward on %v", t.Local.HostPort) 61 | localConn, err := net.Dial("tcp", t.Local.HostPort) 62 | if err != nil { 63 | log.Fatalf("local: unable to register tcp forward: %v", err) 64 | } 65 | 66 | copyConn := func(writer, reader net.Conn) { 67 | _, err := io.Copy(writer, reader) 68 | if err != nil { 69 | fmt.Printf("io.Copy error: %s", err) 70 | } 71 | } 72 | 73 | go copyConn(localConn, remoteConn) 74 | go copyConn(remoteConn, localConn) 75 | } 76 | 77 | func SSHAgent(keyfile string) ssh.AuthMethod { 78 | key, err := ioutil.ReadFile(keyfile) 79 | if err != nil { 80 | log.Fatalf("unable to read private key: %v", err) 81 | } 82 | signer, err := ssh.ParsePrivateKey(key) 83 | 84 | if err != nil { 85 | log.Fatalf("unable to parse private key: %v", err) 86 | } 87 | return ssh.PublicKeys(signer) 88 | } 89 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "net" 7 | "os" 8 | "time" 9 | 10 | "github.com/smithclay/faassh/server" 11 | "github.com/smithclay/faassh/tunnel" 12 | "golang.org/x/crypto/ssh" 13 | ) 14 | 15 | var ( 16 | tunnelCommand = flag.NewFlagSet("tunnel", flag.ExitOnError) 17 | ) 18 | 19 | var ( 20 | sshdPort = flag.String("port", "2200", "Port number for ssh server (non-priviliged)") 21 | hostPrivateKey = flag.String("i", "", "Path to RSA host private key") 22 | jumpHost = tunnelCommand.String("jh", "localhost", "Jump host") 23 | jumpHostPort = tunnelCommand.String("jh-port", "22", "Jump host SSH port number") 24 | jumpHostUser = tunnelCommand.String("jh-user", "ec2-user", "Jump host SSH user") 25 | jumpHostTunnelPort = tunnelCommand.String("tunnel-port", "0", "Jump host tunnel port") 26 | ) 27 | 28 | // Only key authentication is supported at this point. 29 | // This will accept connections from any remote host. 30 | func hostKeyCallback(hostname string, remote net.Addr, key ssh.PublicKey) error { 31 | return nil 32 | } 33 | 34 | func createTunnel(localPort string, jumpHost string, jumpHostPort string, jumpHostUser string, jumpHostTunnelPort string) *tunnel.SSHtunnel { 35 | // Create SSH Tunnel 36 | // Example: 127.0.0.1:2200 37 | localEndpoint := &tunnel.Endpoint{ 38 | HostPort: net.JoinHostPort("127.0.0.1", localPort), 39 | } 40 | // Jump Host Endpoint 41 | // Example: 0.tcp.ngrok.io:15303 42 | jumpEndpoint := &tunnel.Endpoint{ 43 | HostPort: net.JoinHostPort(jumpHost, jumpHostPort), 44 | User: jumpHostUser, 45 | } 46 | 47 | // With the '0' default, an open port on the host will be chosen automatically. 48 | // This is the endpoint the client (i.e. dev laptop) actually connects to. 49 | // Example: 127.0.0.1:5001 50 | // Then, `ssh -p 5001 foo@127.0.0.1` to connect to the function. 51 | remoteEndpoint := &tunnel.Endpoint{ 52 | HostPort: net.JoinHostPort("127.0.0.1", jumpHostTunnelPort), 53 | } 54 | 55 | sshTunnelConfig := &ssh.ClientConfig{ 56 | User: jumpEndpoint.User, 57 | Auth: []ssh.AuthMethod{ 58 | tunnel.SSHAgent(*hostPrivateKey), 59 | }, 60 | Timeout: time.Second * 10, 61 | HostKeyCallback: hostKeyCallback, 62 | } 63 | 64 | return &tunnel.SSHtunnel{ 65 | Config: sshTunnelConfig, 66 | Local: localEndpoint, 67 | Server: jumpEndpoint, 68 | Remote: remoteEndpoint, 69 | } 70 | } 71 | 72 | func main() { 73 | flag.Parse() 74 | 75 | if *hostPrivateKey == "" { 76 | fmt.Println("Error: Please supply the host private key using the -i option.") 77 | os.Exit(1) 78 | } 79 | 80 | // Create SSH Server with Dumb Terminal 81 | s := &server.SecureServer{ 82 | User: "foo", 83 | Password: "bar", 84 | HostKey: *hostPrivateKey, 85 | Port: *sshdPort, 86 | } 87 | 88 | if tunnelCommand.Parsed() { 89 | t := createTunnel(*sshdPort, *jumpHost, *jumpHostPort, *jumpHostUser, *jumpHostTunnelPort) 90 | go t.Start() 91 | } 92 | 93 | s.Start() 94 | } 95 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "log" 8 | "net" 9 | "os/exec" 10 | "strings" 11 | 12 | "golang.org/x/crypto/ssh" 13 | "golang.org/x/crypto/ssh/terminal" 14 | ) 15 | 16 | type SecureServer struct { 17 | User string 18 | Password string 19 | HostKey string 20 | Port string 21 | } 22 | 23 | func (s *SecureServer) Stop() error { 24 | // TODO: Close all connections 25 | return nil 26 | } 27 | 28 | func (s *SecureServer) Start() error { 29 | // In the latest version of crypto/ssh (after Go 1.3), the SSH server type has been removed 30 | // in favour of an SSH connection type. A ssh.ServerConn is created by passing an existing 31 | // net.Conn and a ssh.ServerConfig to ssh.NewServerConn, in effect, upgrading the net.Conn 32 | // into an ssh.ServerConn 33 | 34 | config := &ssh.ServerConfig{ 35 | //Define a function to run when a client attempts a password login 36 | PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) { 37 | // Should use constant-time compare (or better, salt+hash) in a production setting. 38 | if c.User() == s.User && string(pass) == s.Password { 39 | return nil, nil 40 | } 41 | return nil, fmt.Errorf("password rejected for %q", c.User()) 42 | }, 43 | // You may also explicitly allow anonymous client authentication, though anon bash 44 | // sessions may not be a wise idea 45 | // NoClientAuth: true, 46 | } 47 | 48 | // You can generate a keypair with 'ssh-keygen -t rsa' 49 | privateBytes, err := ioutil.ReadFile(s.HostKey) 50 | if err != nil { 51 | log.Fatal(fmt.Sprintf("Failed to load private key (%s): %s", s.HostKey, err)) 52 | } 53 | 54 | private, err := ssh.ParsePrivateKey(privateBytes) 55 | if err != nil { 56 | log.Fatal("Failed to parse private key") 57 | } 58 | 59 | config.AddHostKey(private) 60 | 61 | // Once a ServerConfig has been configured, connections can be accepted. 62 | listener, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%s", s.Port)) 63 | if err != nil { 64 | log.Fatalf("Failed to listen on %s (%s)", s.Port, err) 65 | } 66 | log.Print(fmt.Sprintf("Listening on %s...", s.Port)) 67 | 68 | for { 69 | tcpConn, err := listener.Accept() 70 | if err != nil { 71 | log.Printf("Failed to accept incoming connection (%s)", err) 72 | continue 73 | } 74 | // Before use, a handshake must be performed on the incoming net.Conn. 75 | sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, config) 76 | if err != nil { 77 | log.Printf("Failed to handshake (%s)", err) 78 | continue 79 | } 80 | 81 | log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion()) 82 | // Discard all global out-of-band Requests 83 | go ssh.DiscardRequests(reqs) 84 | 85 | // Accept all channels 86 | for newChannel := range chans { 87 | go s.handleChannel(newChannel) 88 | } 89 | } 90 | } 91 | 92 | func (s *SecureServer) handleChannel(newChannel ssh.NewChannel) { 93 | // Since we're handling a shell, we expect a 94 | // channel type of "session". The also describes 95 | // "x11", "direct-tcpip" and "forwarded-tcpip" 96 | // channel types. 97 | if t := newChannel.ChannelType(); t != "session" { 98 | newChannel.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t)) 99 | return 100 | } 101 | 102 | // At this point, we have the opportunity to reject the client's 103 | // request for another logical connection 104 | connection, requests, err := newChannel.Accept() 105 | if err != nil { 106 | log.Printf("Could not accept channel (%s)", err) 107 | return 108 | } 109 | 110 | // TODO: extract into own file 111 | // Terminal creation code inspired by this: 112 | // https://github.com/antha-lang/antha/blob/master/bvendor/golang.org/x/net/http2/h2i/h2i.go 113 | t := terminal.NewTerminal(connection, "λ > ") 114 | go func() { 115 | for { 116 | line, err := t.ReadLine() 117 | if err == io.EOF { 118 | return 119 | } 120 | if err != nil { 121 | log.Printf("terminal.ReadLine: %v", err) 122 | } 123 | f := strings.Fields(line) 124 | if len(f) == 0 { 125 | continue 126 | } 127 | 128 | if f[0] == "exit" { 129 | // TODO: close session 130 | connection.Close() 131 | return 132 | } 133 | cmd := exec.Command("bash", append([]string{"-c"}, strings.Join(f[:], " "))...) 134 | output, err := cmd.CombinedOutput() 135 | if err != nil { 136 | fmt.Fprintf(t, string(output)) 137 | fmt.Fprintf(t, "%v\n", err) 138 | continue 139 | } 140 | 141 | fmt.Fprintf(t, string(output)) 142 | } 143 | }() 144 | 145 | // TODO: ASCII art, version number 146 | t.Write([]byte("Welcome to Lambda Shell!\n")) 147 | 148 | go s.processRequests(t, requests) 149 | } 150 | 151 | // Sessions have out-of-band requests such as "shell", "pty-req" and "env" 152 | // Good reference: https://github.com/ilowe/cmd/blob/72efdd2f2e6192e86adf67703a6f54b8bf3afc0c/sshpit/main.go 153 | func (s *SecureServer) processRequests(t *terminal.Terminal, requests <-chan *ssh.Request) { 154 | var hasShell bool 155 | for req := range requests { 156 | var width, height int 157 | var ok bool 158 | switch req.Type { 159 | case "shell": 160 | if !hasShell { 161 | ok = true 162 | hasShell = true 163 | } 164 | case "exec": 165 | ok = true 166 | case "pty-req": 167 | width, height, ok = parsePtyReq(req.Payload) 168 | if ok { 169 | err := t.SetSize(width, height) 170 | ok = err == nil 171 | } 172 | case "window-change": 173 | width, height, ok = parseWindowChangeReq(req.Payload) 174 | if ok { 175 | err := t.SetSize(width, height) 176 | ok = err == nil 177 | } 178 | } 179 | 180 | if req.WantReply { 181 | req.Reply(ok, nil) 182 | } 183 | } 184 | } 185 | --------------------------------------------------------------------------------