sketch: initial container ssh support (#15)
Adds an in-process ssh server to the sketch agent running inside
the container.
The ssh server implementation uses https://github.com/gliderlabs/ssh/
This change does not automatically generate any keys (this may come later).
You specify the server identity private key and the user's authorized public
keys on the sketch command line.
The host sketch process reads these files from the cli flags at startup. Once
the container is launched, it passes these keys to to the container
sketch process via new /init POST body fields.
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index a814551..d83d8cf 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -2,6 +2,7 @@
package server
import (
+ "context"
"encoding/base64"
"encoding/json"
"fmt"
@@ -66,6 +67,14 @@
RuntimeWorkingDir string `json:"runtime_working_dir,omitempty"`
}
+type InitRequest struct {
+ HostAddr string `json:"host_addr"`
+ GitRemoteAddr string `json:"git_remote_addr"`
+ Commit string `json:"commit"`
+ SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
+ SSHServerIdentity []byte `json:"ssh_server_identity"`
+}
+
// Server serves sketch HTTP. Server implements http.Handler.
type Server struct {
mux *http.ServeMux
@@ -146,20 +155,29 @@
http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
return
}
- m := make(map[string]string)
- if err := json.Unmarshal(body, &m); err != nil {
+
+ m := &InitRequest{}
+ if err := json.Unmarshal(body, m); err != nil {
http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
return
}
- hostAddr := m["host_addr"]
- gitRemoteAddr := m["git_remote_addr"]
- commit := m["commit"]
+
+ // Start the SSH server if the init request included ssh keys.
+ if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
+ go func() {
+ ctx := context.Background()
+ if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys); err != nil {
+ slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
+ }
+ }()
+ }
+
ini := loop.AgentInit{
WorkingDir: "/app",
InDocker: true,
- Commit: commit,
- GitRemoteAddr: gitRemoteAddr,
- HostAddr: hostAddr,
+ Commit: m.Commit,
+ GitRemoteAddr: m.GitRemoteAddr,
+ HostAddr: m.HostAddr,
}
if err := agent.Init(ini); err != nil {
http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
diff --git a/loop/server/sshserver.go b/loop/server/sshserver.go
new file mode 100644
index 0000000..2b1f9e8
--- /dev/null
+++ b/loop/server/sshserver.go
@@ -0,0 +1,60 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "syscall"
+ "unsafe"
+
+ "github.com/creack/pty"
+ "github.com/gliderlabs/ssh"
+)
+
+func setWinsize(f *os.File, w, h int) {
+ syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(syscall.TIOCSWINSZ),
+ uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
+}
+
+func (s *Server) ServeSSH(ctx context.Context, hostKey, authorizedKeys []byte) error {
+ allowed, _, _, _, err := ssh.ParseAuthorizedKey(authorizedKeys)
+ if err != nil {
+ return fmt.Errorf("ServeSSH: couldn't parse authorized keys: %w", err)
+ }
+ return ssh.ListenAndServe(":2022",
+ func(s ssh.Session) {
+ handleSessionfunc(ctx, s)
+ },
+ ssh.HostKeyPEM(hostKey),
+ ssh.PublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
+ return ssh.KeysEqual(key, allowed)
+ }),
+ )
+}
+
+func handleSessionfunc(ctx context.Context, s ssh.Session) {
+ cmd := exec.CommandContext(ctx, "/bin/bash")
+ ptyReq, winCh, isPty := s.Pty()
+ if isPty {
+ cmd.Env = append(cmd.Env, fmt.Sprintf("TERM=%s", ptyReq.Term))
+ f, err := pty.Start(cmd)
+ if err != nil {
+ panic(err)
+ }
+ go func() {
+ for win := range winCh {
+ setWinsize(f, win.Width, win.Height)
+ }
+ }()
+ go func() {
+ io.Copy(f, s) // stdin
+ }()
+ io.Copy(s, f) // stdout
+ cmd.Wait()
+ } else {
+ io.WriteString(s, "No PTY requested.\n")
+ s.Exit(1)
+ }
+}