| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 1 | package server |
| 2 | |
| 3 | import ( |
| Sean McCullough | ae3480f | 2025-04-23 15:28:20 -0700 | [diff] [blame] | 4 | "bytes" |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 5 | "context" |
| 6 | "fmt" |
| 7 | "io" |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 8 | "log/slog" |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 9 | "os" |
| 10 | "os/exec" |
| 11 | "syscall" |
| 12 | "unsafe" |
| 13 | |
| 14 | "github.com/creack/pty" |
| 15 | "github.com/gliderlabs/ssh" |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 16 | "github.com/pkg/sftp" |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 17 | gossh "golang.org/x/crypto/ssh" |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 18 | ) |
| 19 | |
| 20 | func setWinsize(f *os.File, w, h int) { |
| 21 | syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ), |
| 22 | uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0}))) |
| 23 | } |
| 24 | |
| 25 | func (s *Server) ServeSSH(ctx context.Context, hostKey, authorizedKeys []byte) error { |
| Sean McCullough | ae3480f | 2025-04-23 15:28:20 -0700 | [diff] [blame] | 26 | // Parse all authorized keys |
| 27 | allowedKeys := make([]ssh.PublicKey, 0) |
| 28 | rest := authorizedKeys |
| 29 | var err error |
| 30 | |
| 31 | // Continue parsing as long as there are bytes left |
| 32 | for len(rest) > 0 { |
| 33 | var key ssh.PublicKey |
| 34 | key, _, _, rest, err = ssh.ParseAuthorizedKey(rest) |
| 35 | if err != nil { |
| 36 | // If we hit an error, check if we have more lines to try |
| 37 | if i := bytes.IndexByte(rest, '\n'); i >= 0 { |
| 38 | // Skip to the next line and continue |
| 39 | rest = rest[i+1:] |
| 40 | continue |
| 41 | } |
| 42 | // No more lines and we hit an error, so stop parsing |
| 43 | break |
| 44 | } |
| 45 | allowedKeys = append(allowedKeys, key) |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 46 | } |
| Sean McCullough | ae3480f | 2025-04-23 15:28:20 -0700 | [diff] [blame] | 47 | if len(allowedKeys) == 0 { |
| 48 | return fmt.Errorf("ServeSSH: no valid authorized keys found") |
| 49 | } |
| 50 | |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 51 | signer, err := gossh.ParsePrivateKey(hostKey) |
| 52 | if err != nil { |
| 53 | return fmt.Errorf("ServeSSH: failed to parse host private key, err: %w", err) |
| 54 | } |
| Sean McCullough | 01ed5be | 2025-04-24 22:46:53 -0700 | [diff] [blame] | 55 | forwardHandler := &ssh.ForwardedTCPHandler{} |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 56 | |
| 57 | server := ssh.Server{ |
| 58 | LocalPortForwardingCallback: ssh.LocalPortForwardingCallback(func(ctx ssh.Context, dhost string, dport uint32) bool { |
| 59 | slog.DebugContext(ctx, "Accepted forward", slog.Any("dhost", dhost), slog.Any("dport", dport)) |
| 60 | return true |
| 61 | }), |
| 62 | Addr: ":22", |
| 63 | ChannelHandlers: ssh.DefaultChannelHandlers, |
| 64 | Handler: ssh.Handler(func(s ssh.Session) { |
| 65 | ptyReq, winCh, isPty := s.Pty() |
| 66 | if isPty { |
| 67 | handlePTYSession(ctx, s, ptyReq, winCh) |
| 68 | } else { |
| 69 | handleSession(ctx, s) |
| 70 | } |
| 71 | }), |
| Sean McCullough | 01ed5be | 2025-04-24 22:46:53 -0700 | [diff] [blame] | 72 | RequestHandlers: map[string]ssh.RequestHandler{ |
| 73 | "tcpip-forward": forwardHandler.HandleSSHRequest, |
| 74 | "cancel-tcpip-forward": forwardHandler.HandleSSHRequest, |
| 75 | }, |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 76 | SubsystemHandlers: map[string]ssh.SubsystemHandler{ |
| Sean McCullough | bdfb126 | 2025-05-03 20:15:41 -0700 | [diff] [blame] | 77 | "sftp": func(s ssh.Session) { |
| 78 | handleSftp(ctx, s) |
| 79 | }, |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 80 | }, |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 81 | HostSigners: []ssh.Signer{signer}, |
| 82 | PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool { |
| Sean McCullough | ae3480f | 2025-04-23 15:28:20 -0700 | [diff] [blame] | 83 | // Check if the provided key matches any of our allowed keys |
| 84 | for _, allowedKey := range allowedKeys { |
| 85 | if ssh.KeysEqual(key, allowedKey) { |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 86 | slog.DebugContext(ctx, "ServeSSH: allow key", slog.String("key", string(key.Marshal()))) |
| Sean McCullough | ae3480f | 2025-04-23 15:28:20 -0700 | [diff] [blame] | 87 | return true |
| 88 | } |
| 89 | } |
| 90 | return false |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 91 | }, |
| 92 | } |
| 93 | |
| 94 | // This ChannelHandler is necessary for vscode's Remote-SSH connections to work. |
| 95 | // Without it the new VSC window will open, but you'll get an error that says something |
| 96 | // like "Failed to set up dynamic port forwarding connection over SSH to the VS Code Server." |
| 97 | server.ChannelHandlers["direct-tcpip"] = ssh.DirectTCPIPHandler |
| 98 | |
| 99 | return server.ListenAndServe() |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 100 | } |
| 101 | |
| Sean McCullough | bdfb126 | 2025-05-03 20:15:41 -0700 | [diff] [blame] | 102 | func handleSftp(ctx context.Context, sess ssh.Session) { |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 103 | debugStream := io.Discard |
| 104 | serverOptions := []sftp.ServerOption{ |
| 105 | sftp.WithDebug(debugStream), |
| 106 | } |
| 107 | server, err := sftp.NewServer( |
| 108 | sess, |
| 109 | serverOptions..., |
| 110 | ) |
| 111 | if err != nil { |
| Sean McCullough | bdfb126 | 2025-05-03 20:15:41 -0700 | [diff] [blame] | 112 | slog.ErrorContext(ctx, "sftp server init error", slog.Any("err", err)) |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 113 | return |
| 114 | } |
| 115 | if err := server.Serve(); err == io.EOF { |
| 116 | server.Close() |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 117 | } else if err != nil { |
| Sean McCullough | bdfb126 | 2025-05-03 20:15:41 -0700 | [diff] [blame] | 118 | slog.ErrorContext(ctx, "sftp server completed with error", slog.Any("err", err)) |
| Sean McCullough | cf291fa | 2025-05-03 17:55:48 -0700 | [diff] [blame] | 119 | } |
| 120 | } |
| 121 | |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 122 | func handlePTYSession(ctx context.Context, s ssh.Session, ptyReq ssh.Pty, winCh <-chan ssh.Window) { |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 123 | cmd := exec.CommandContext(ctx, "/bin/bash") |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 124 | slog.DebugContext(ctx, "handlePTYSession", slog.Any("ptyReq", ptyReq)) |
| 125 | |
| Sean McCullough | 22bd8eb | 2025-04-28 10:36:37 -0700 | [diff] [blame] | 126 | cmd.Env = append(os.Environ(), fmt.Sprintf("TERM=%s", ptyReq.Term)) |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 127 | f, err := pty.Start(cmd) |
| 128 | if err != nil { |
| 129 | fmt.Fprintf(s, "PTY requested, but unable to start due to error: %v", err) |
| 130 | s.Exit(1) |
| 131 | return |
| 132 | } |
| 133 | |
| 134 | go func() { |
| 135 | for win := range winCh { |
| 136 | setWinsize(f, win.Width, win.Height) |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 137 | } |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 138 | }() |
| 139 | go func() { |
| 140 | io.Copy(f, s) // stdin |
| 141 | }() |
| 142 | io.Copy(s, f) // stdout |
| 143 | |
| 144 | // TODO: double check, do we need a sync.WaitGroup here, to make sure we finish |
| 145 | // the pipe I/O before we call cmd.Wait? |
| 146 | if err := cmd.Wait(); err != nil { |
| 147 | slog.ErrorContext(ctx, "handlePTYSession: cmd.Wait", slog.String("err", err.Error())) |
| 148 | s.Exit(1) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func handleSession(ctx context.Context, s ssh.Session) { |
| 153 | var cmd *exec.Cmd |
| 154 | slog.DebugContext(ctx, "handleSession", slog.Any("s.Command", s.Command())) |
| 155 | if len(s.Command()) == 0 { |
| 156 | cmd = exec.CommandContext(ctx, "/bin/bash") |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 157 | } else { |
| Sean McCullough | 1d06132 | 2025-04-24 09:52:56 -0700 | [diff] [blame] | 158 | cmd = exec.CommandContext(ctx, s.Command()[0], s.Command()[1:]...) |
| 159 | } |
| 160 | stdinPipe, err := cmd.StdinPipe() |
| 161 | if err != nil { |
| 162 | slog.ErrorContext(ctx, "handleSession: cmd.StdinPipe", slog.Any("err", err.Error())) |
| 163 | fmt.Fprintf(s, "cmd.StdinPipe error: %v", err) |
| 164 | |
| 165 | s.Exit(1) |
| 166 | return |
| 167 | } |
| 168 | defer stdinPipe.Close() |
| 169 | |
| 170 | stdoutPipe, err := cmd.StdoutPipe() |
| 171 | if err != nil { |
| 172 | slog.ErrorContext(ctx, "handleSession: cmd.StdoutPipe", slog.Any("err", err.Error())) |
| 173 | fmt.Fprintf(s, "cmd.StdoutPipe error: %v", err) |
| 174 | s.Exit(1) |
| 175 | return |
| 176 | } |
| 177 | defer stdoutPipe.Close() |
| 178 | |
| 179 | stderrPipe, err := cmd.StderrPipe() |
| 180 | if err != nil { |
| 181 | slog.ErrorContext(ctx, "handleSession: cmd.StderrPipe", slog.Any("err", err.Error())) |
| 182 | fmt.Fprintf(s, "cmd.StderrPipe error: %v", err) |
| 183 | s.Exit(1) |
| 184 | return |
| 185 | } |
| 186 | defer stderrPipe.Close() |
| 187 | |
| 188 | if err := cmd.Start(); err != nil { |
| 189 | slog.ErrorContext(ctx, "handleSession: cmd.Start", slog.Any("err", err.Error())) |
| 190 | fmt.Fprintf(s, "cmd.Start error: %v", err) |
| 191 | s.Exit(1) |
| 192 | return |
| 193 | } |
| 194 | |
| 195 | // TODO: double check, do we need a sync.WaitGroup here, to make sure we finish |
| 196 | // the pipe I/O before we call cmd.Wait? |
| 197 | go func() { |
| 198 | io.Copy(s, stderrPipe) |
| 199 | }() |
| 200 | go func() { |
| 201 | io.Copy(s, stdoutPipe) |
| 202 | }() |
| 203 | io.Copy(stdinPipe, s) |
| 204 | |
| 205 | if err := cmd.Wait(); err != nil { |
| 206 | slog.ErrorContext(ctx, "handleSession: cmd.Wait", slog.Any("err", err.Error())) |
| 207 | fmt.Fprintf(s, "cmd.Wait error: %v", err) |
| Sean McCullough | baa2b59 | 2025-04-23 10:40:08 -0700 | [diff] [blame] | 208 | s.Exit(1) |
| 209 | } |
| 210 | } |