package microvm import ( "context" "crypto/ed25519" "crypto/rand" "encoding/pem" "fmt" "io" "net/http" "os" "path/filepath" "time" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/gliderlabs/ssh" gossh "golang.org/x/crypto/ssh" "tangled.org/core/api/tangled" "tangled.org/core/hostutil" ) // debug ssh: terminates ssh here, bridges a pty into a live (failed) microVM // over the guest's agent conn. the guest stays keyless. access mirrors git // push: the offered key goes to the repo's knot // (sh.tangled.repo.checkPushAllowed) and is accepted only if it can push to the // job's repo. const debugAuthTimeout = 5 * time.Second func (e *Engine) serveDebugSSH(ctx context.Context) { dbg := e.cfg.MicroVMPipelines.DebugSSH if !dbg.Enabled || dbg.ListenAddr == "" { return } addr := dbg.ListenAddr httpc := &http.Client{Timeout: debugAuthTimeout} srv := &ssh.Server{ Addr: addr, Handler: e.debugHandle, PublicKeyHandler: func(c ssh.Context, key ssh.PublicKey) bool { return e.checkDebugAuth(c, c.User(), key, httpc) }, } keyPath := e.cfg.MicroVMPipelines.DebugSSH.HostKeyPath if keyPath == "" { keyPath = filepath.Join(filepath.Dir(e.cfg.Server.DBPath), "debug_ssh_host_key") } if err := ensureDebugHostKey(keyPath); err != nil { e.l.Error("debug ssh: ensure host key", "path", keyPath, "err", err) return } if err := srv.SetOption(ssh.HostKeyFile(keyPath)); err != nil { e.l.Error("debug ssh: load host key", "path", keyPath, "err", err) return } go func() { <-ctx.Done() _ = srv.Close() }() e.l.Info("starting debug ssh server", "address", addr) if err := srv.ListenAndServe(); err != nil && err != ssh.ErrServerClosed { e.l.Error("debug ssh server stopped", "err", err) } } func ensureDebugHostKey(path string) error { if _, err := os.Stat(path); err == nil { return nil } else if !os.IsNotExist(err) { return fmt.Errorf("stat host key: %w", err) } _, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return fmt.Errorf("generate host key: %w", err) } block, err := gossh.MarshalPrivateKey(priv, "") if err != nil { return fmt.Errorf("marshal host key: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return fmt.Errorf("create host key dir: %w", err) } if err := os.WriteFile(path, pem.EncodeToMemory(block), 0o600); err != nil { return fmt.Errorf("write host key: %w", err) } return nil } func (e *Engine) debugHandle(sess ssh.Session) { ptyReq, winCh, isPty := sess.Pty() if !isPty { io.WriteString(sess.Stderr(), "error: no terminal allocated; use `ssh -t`\n") _ = sess.Exit(1) return } jobID := sess.User() l := e.l.With("component", "debugssh", "job", jobID) debug, err := e.OpenDebugSession(sess.Context(), jobID, ptyReq.Term, ptyReq.Window.Height, ptyReq.Window.Width) if err != nil { fmt.Fprintf(sess.Stderr(), "error: %v\n", err) _ = sess.Exit(1) return } defer debug.Close() l.Info("debug shell opened") go func() { for win := range winCh { if err := debug.Resize(win.Height, win.Width); err != nil { l.Debug("debug ssh resize failed", "error", err) } } }() // keyboard -> shell, runs until the client hangs up go func() { _, _ = io.Copy(debug, sess) }() // shell -> client, returns when the shell exits (Read hits EOF) _, _ = io.Copy(sess, debug) code := debug.ExitCode() l.Info("debug shell closed", "exitCode", code) // the user is done; let retention tear the VM down now instead of waiting // out the rest of the grace period e.releaseDebugTarget(jobID) _ = sess.Exit(code) } func (e *Engine) checkDebugAuth(ctx context.Context, jobID string, key ssh.PublicKey, httpc *http.Client) bool { l := e.l.With("component", "debugssh", "job", jobID, "keyType", key.Type()) knot, repoDid, ok := e.RepoForJob(jobID) if !ok { l.Warn("debug ssh: no live job / unknown repo") return false } host, noSSL, err := hostutil.ParseHostname(knot) if err != nil { l.Error("debug ssh: bad knot host", "knot", knot, "error", err) return false } scheme := "https" if noSSL { scheme = "http" } xc := &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host), Client: httpc} reqCtx, cancel := context.WithTimeout(ctx, debugAuthTimeout) defer cancel() out, err := tangled.RepoCheckPushAllowed(reqCtx, xc, string(gossh.MarshalAuthorizedKey(key)), repoDid) if err != nil { l.Error("debug ssh: push-allowed check failed", "knot", knot, "repo", repoDid, "error", err) return false } if !out.Allowed { l.Warn("debug ssh: key not allowed to push", "knot", knot, "repo", repoDid) return false } if out.Did != nil { l.Info("debug ssh: authorized", "did", *out.Did, "knot", knot, "repo", repoDid) } return true }