This repository has no description
1package microvm
2
3import (
4 "context"
5 "crypto/ed25519"
6 "crypto/rand"
7 "encoding/pem"
8 "fmt"
9 "io"
10 "net/http"
11 "os"
12 "path/filepath"
13 "time"
14
15 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
16 "github.com/gliderlabs/ssh"
17 gossh "golang.org/x/crypto/ssh"
18 "tangled.org/core/api/tangled"
19 "tangled.org/core/hostutil"
20)
21
22// debug ssh: terminates ssh here, bridges a pty into a live (failed) microVM
23// over the guest's agent conn. the guest stays keyless. access mirrors git
24// push: the offered key goes to the repo's knot
25// (sh.tangled.repo.checkPushAllowed) and is accepted only if it can push to the
26// job's repo.
27
28const debugAuthTimeout = 5 * time.Second
29
30func (e *Engine) serveDebugSSH(ctx context.Context) {
31 dbg := e.cfg.MicroVMPipelines.DebugSSH
32 if !dbg.Enabled || dbg.ListenAddr == "" {
33 return
34 }
35 addr := dbg.ListenAddr
36 httpc := &http.Client{Timeout: debugAuthTimeout}
37
38 srv := &ssh.Server{
39 Addr: addr,
40 Handler: e.debugHandle,
41 PublicKeyHandler: func(c ssh.Context, key ssh.PublicKey) bool {
42 return e.checkDebugAuth(c, c.User(), key, httpc)
43 },
44 }
45 keyPath := e.cfg.MicroVMPipelines.DebugSSH.HostKeyPath
46 if keyPath == "" {
47 keyPath = filepath.Join(filepath.Dir(e.cfg.Server.DBPath), "debug_ssh_host_key")
48 }
49 if err := ensureDebugHostKey(keyPath); err != nil {
50 e.l.Error("debug ssh: ensure host key", "path", keyPath, "err", err)
51 return
52 }
53 if err := srv.SetOption(ssh.HostKeyFile(keyPath)); err != nil {
54 e.l.Error("debug ssh: load host key", "path", keyPath, "err", err)
55 return
56 }
57
58 go func() {
59 <-ctx.Done()
60 _ = srv.Close()
61 }()
62
63 e.l.Info("starting debug ssh server", "address", addr)
64 if err := srv.ListenAndServe(); err != nil && err != ssh.ErrServerClosed {
65 e.l.Error("debug ssh server stopped", "err", err)
66 }
67}
68
69func ensureDebugHostKey(path string) error {
70 if _, err := os.Stat(path); err == nil {
71 return nil
72 } else if !os.IsNotExist(err) {
73 return fmt.Errorf("stat host key: %w", err)
74 }
75
76 _, priv, err := ed25519.GenerateKey(rand.Reader)
77 if err != nil {
78 return fmt.Errorf("generate host key: %w", err)
79 }
80 block, err := gossh.MarshalPrivateKey(priv, "")
81 if err != nil {
82 return fmt.Errorf("marshal host key: %w", err)
83 }
84
85 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
86 return fmt.Errorf("create host key dir: %w", err)
87 }
88 if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil {
89 return fmt.Errorf("write host key: %w", err)
90 }
91 return nil
92}
93
94func (e *Engine) debugHandle(sess ssh.Session) {
95 ptyReq, winCh, isPty := sess.Pty()
96 if !isPty {
97 io.WriteString(sess.Stderr(), "error: no terminal allocated; use `ssh -t`\n")
98 _ = sess.Exit(1)
99 return
100 }
101
102 jobID := sess.User()
103 l := e.l.With("component", "debugssh", "job", jobID)
104
105 debug, err := e.OpenDebugSession(sess.Context(), jobID, ptyReq.Term, ptyReq.Window.Height, ptyReq.Window.Width)
106 if err != nil {
107 fmt.Fprintf(sess.Stderr(), "error: %v\n", err)
108 _ = sess.Exit(1)
109 return
110 }
111 defer debug.Close()
112 l.Info("debug shell opened")
113
114 go func() {
115 for win := range winCh {
116 if err := debug.Resize(win.Height, win.Width); err != nil {
117 l.Debug("debug ssh resize failed", "error", err)
118 }
119 }
120 }()
121
122 // keyboard -> shell, runs until the client hangs up
123 go func() { _, _ = io.Copy(debug, sess) }()
124 // shell -> client, returns when the shell exits (Read hits EOF)
125 _, _ = io.Copy(sess, debug)
126
127 code := debug.ExitCode()
128 l.Info("debug shell closed", "exitCode", code)
129 // the user is done; let retention tear the VM down now instead of waiting
130 // out the rest of the grace period
131 e.releaseDebugTarget(jobID)
132 _ = sess.Exit(code)
133}
134
135func (e *Engine) checkDebugAuth(ctx context.Context, jobID string, key ssh.PublicKey, httpc *http.Client) bool {
136 l := e.l.With("component", "debugssh", "job", jobID, "keyType", key.Type())
137
138 knot, repoDid, ok := e.RepoForJob(jobID)
139 if !ok {
140 l.Warn("debug ssh: no live job / unknown repo")
141 return false
142 }
143
144 host, noSSL, err := hostutil.ParseHostname(knot)
145 if err != nil {
146 l.Error("debug ssh: bad knot host", "knot", knot, "error", err)
147 return false
148 }
149 scheme := "https"
150 if noSSL {
151 scheme = "http"
152 }
153 xc := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host), Client: httpc}
154
155 reqCtx, cancel := context.WithTimeout(ctx, debugAuthTimeout)
156 defer cancel()
157
158 out, err := tangled.RepoCheckPushAllowed(reqCtx, xc, string(gossh.MarshalAuthorizedKey(key)), repoDid)
159 if err != nil {
160 l.Error("debug ssh: push-allowed check failed", "knot", knot, "repo", repoDid, "error", err)
161 return false
162 }
163 if !out.Allowed {
164 l.Warn("debug ssh: key not allowed to push", "knot", knot, "repo", repoDid)
165 return false
166 }
167 if out.Did != nil {
168 l.Info("debug ssh: authorized", "did", *out.Did, "knot", knot, "repo", repoDid)
169 }
170 return true
171}