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/cmd/sketch/main.go b/cmd/sketch/main.go
index 7b0c34a..94f7559 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -48,6 +48,9 @@
verbose := flag.Bool("verbose", false, "enable verbose output")
version := flag.Bool("version", false, "print the version and exit")
workingDir := flag.String("C", "", "when set, change to this directory before running")
+ sshServerIdentity := flag.String("ssh_server_identity", "", "location of the file containing the private key that the container's ssh server will use to identify itself")
+ sshAuthorizedKeys := flag.String("ssh_authorized_keys", "", "location of the file containing the public keys that the container's ssh server will authorize")
+ sshPort := flag.Int("ssh_port", 2022, "the host port number that the container's ssh server will listen on")
// Flags geared towards sketch developers or sketch internals:
gitUsername := flag.String("git-username", "", "(internal) username for git commits")
@@ -179,6 +182,21 @@
outbuf, errbuf = &bytes.Buffer{}, &bytes.Buffer{}
stdout, stderr = outbuf, errbuf
}
+
+ var authorizedKeys, serverIdentity []byte
+ if *sshAuthorizedKeys != "" {
+ authorizedKeys, err = os.ReadFile(*sshAuthorizedKeys)
+ if err != nil {
+ return fmt.Errorf("reading ssh_authorized_keys from %s: %w", *sshAuthorizedKeys, err)
+ }
+ }
+ if *sshServerIdentity != "" {
+ serverIdentity, err = os.ReadFile(*sshServerIdentity)
+ if err != nil {
+ return fmt.Errorf("reading ssh_id_rsa from %s: %w", *sshServerIdentity, err)
+ }
+ }
+
fmt.Printf("launching container...\n")
config := dockerimg.ContainerConfig{
SessionID: *sessionID,
@@ -194,6 +212,9 @@
ContainerLogDest: *containerLogDest,
SketchBinaryLinux: *sketchBinaryLinux,
SketchPubKey: pubKey,
+ SSHServerIdentity: serverIdentity,
+ SSHAuthorizedKeys: authorizedKeys,
+ SSHPort: *sshPort,
ForceRebuild: false,
HostHostname: getHostname(),
HostOS: runtime.GOOS,
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 6043c23..6ef95ee 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -20,6 +20,7 @@
"strings"
"time"
+ "sketch.dev/loop/server"
"sketch.dev/loop/webui"
"sketch.dev/skribe"
)
@@ -68,6 +69,15 @@
// Sketch client public key.
SketchPubKey string
+ // Host port for the container's ssh server
+ SSHPort int
+
+ // Public keys authorized to connect to the container's ssh server
+ SSHAuthorizedKeys []byte
+
+ // Private key used to identify the container's ssh server
+ SSHServerIdentity []byte
+
// Host information to pass to the container
HostHostname string
HostOS string
@@ -251,7 +261,7 @@
// the scrollback (which is not good, but also not fatal). I can't see why it does this
// though, since none of the calls in postContainerInitConfig obviously write to stdout
// or stderr.
- if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort, gitSrv.pass); err != nil {
+ if err := postContainerInitConfig(ctx, localAddr, commit, gitSrv.gitPort, gitSrv.pass, config.SSHServerIdentity, config.SSHAuthorizedKeys); err != nil {
slog.ErrorContext(ctx, "LaunchContainer.postContainerInitConfig", slog.String("err", err.Error()))
errCh <- appendInternalErr(err)
}
@@ -368,6 +378,9 @@
if config.SketchPubKey != "" {
cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
}
+ if config.SSHPort != 0 {
+ cmdArgs = append(cmdArgs, "-p", fmt.Sprintf("%d:2022", config.SSHPort)) // forward container ssh port to host ssh port
+ }
if relPath != "." {
cmdArgs = append(cmdArgs, "-w", "/app/"+relPath)
}
@@ -471,13 +484,17 @@
}
// Contact the container and configure it.
-func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort, gitPass string) error {
+func postContainerInitConfig(ctx context.Context, localAddr, commit, gitPort, gitPass string, sshServerIdentity, sshAuthorizedKeys []byte) error {
localURL := "http://" + localAddr
- initMsg, err := json.Marshal(map[string]string{
- "commit": commit,
- "git_remote_addr": fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
- "host_addr": localAddr,
- })
+
+ initMsg, err := json.Marshal(
+ server.InitRequest{
+ Commit: commit,
+ GitRemoteAddr: fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
+ HostAddr: localAddr,
+ SSHAuthorizedKeys: sshAuthorizedKeys,
+ SSHServerIdentity: sshServerIdentity,
+ })
if err != nil {
return fmt.Errorf("init msg: %w", err)
}
diff --git a/go.mod b/go.mod
index 738d758..6d2bb2d 100644
--- a/go.mod
+++ b/go.mod
@@ -8,18 +8,21 @@
github.com/fatih/color v1.18.0
github.com/google/go-cmp v0.7.0
github.com/richardlehane/crock32 v1.0.1
+ go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a
+ golang.org/x/crypto v0.37.0
golang.org/x/net v0.38.0
- golang.org/x/sync v0.12.0
- golang.org/x/term v0.30.0
+ golang.org/x/sync v0.13.0
+ golang.org/x/term v0.31.0
golang.org/x/tools v0.31.0
mvdan.cc/sh/v3 v3.11.0
)
require (
+ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+ github.com/gliderlabs/ssh v0.3.8 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
- go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a // indirect
golang.org/x/mod v0.24.0 // indirect
- golang.org/x/sys v0.31.0 // indirect
- golang.org/x/text v0.23.0 // indirect
+ golang.org/x/sys v0.32.0 // indirect
+ golang.org/x/text v0.24.0 // indirect
)
diff --git a/go.sum b/go.sum
index a854e69..2741a4a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,15 @@
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
+github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/evanw/esbuild v0.25.2 h1:ublSEmZSjzOc6jLO1OTQy/vHc1wiqyDF4oB3hz5sM6s=
github.com/evanw/esbuild v0.25.2/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
+github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
+github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -17,28 +23,36 @@
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/richardlehane/crock32 v1.0.1 h1:GV9EqtAr7RminQ8oGrDt3gYXkzDDPJ5fROaO1Mux14g=
github.com/richardlehane/crock32 v1.0.1/go.mod h1:xUIlLABtHBgs1bNIBdUQR9F2xtRzS0TujtbR68hmEWU=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a h1:XqDi+8oE4eakFiXZXmQlsPaZTTdsPOy54jP3my6lIcU=
go.skia.org/infra v0.0.0-20250421160028-59e18403fd4a/go.mod h1:itQeLiwIYtXPJJEqdxRpOlS77LNv/quHjkyy+SaXrkw=
+golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
+golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
-golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
+golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
-golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
-golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
-golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
+golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
+golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw=
mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg=
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)
+ }
+}