package main // The session lane: an interactive // terminal that runs ON this machine and is streamed to the host over the SAME reverse // WebSocket workers the harness lane uses. Nothing new on the network — no inbound // port, no SSH server — the tunnel already exists and the box dialled it. // // Wire contract (one WebSocket = one session; every frame is a BINARY message whose // first byte is the tag): // // host -> box 'S' + JSON sessionSpec first frame, opens the session // host -> box 'I' + bytes keyboard input for the pty // host -> box 'R' + JSON {"cols":N,"rows":N} terminal resize // box -> host 'O' + bytes pty output // box -> host 'E' + text a plain-words error or notice (may precede X) // box -> host 'X' + decimal exit code the session is over; the socket closes after it // // One more op rides the same S frame: session:root:exec runs ONE command as root, // with no terminal, under a standing grant the person gave at this machine // (rootexec.go has its contract and its walls). It answers 'J' + JSON result, then X. // // Exit codes on X: the shell's own exit status when it ended by itself; 126 when the // box REFUSED (no grant, denied by grant, root without step-up, caps) — the same shape // an ungranted mgmt op returns, so the host reads both the same way; 1 for a bad spec // or a box that cannot do this (Windows today); 124 when the box closed it on a timer // (idle or hard cap), echoing the coreutils `timeout` convention. // // Gating: a session is a management op. The pod passes the user's SIGNED mgmt grant // (the same record the host's management-op gate enforces) in the spec; the box re-verifies it against // the entity key it pinned at first dcn_grant_sync and refuses unless the grant lists // the op for this server and does not deny it. The box is its own wall: a pod that // forges or widens a grant gets 126 here regardless of what it decided for itself. // // Privilege: session:open runs as an unprivileged user when the daemon is root // (privatae-session if that account exists, else nobody); as the daemon's own user // otherwise. session:root runs as root only when the daemon is root, the grant lists // session:root, AND the user approved THIS session on this machine with the enrolled // second factor (stepup.go). The pod never sees the code. // // Every session is written to /sessions/.log (0600; output // only — input echoes, passwords do not) and recorded in the pairing's audit.jsonl // with start, stop, and exit. Closing the socket kills the shell's whole process group. import ( "context" "crypto/ed25519" "crypto/tls" "encoding/base64" "encoding/json" "fmt" "io" "net" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync/atomic" "time" "github.com/gorilla/websocket" ) // sessionWorkerCount is the session pool per pairing: four parked sockets, so four // terminals can be open on one box at once without touching the harness workers. const sessionWorkerCount = 4 // runSessionWorkers parks n workers on the host's /bastion-harness endpoint with the // hello line `BEARER lane=session`, so the host can hand S frames only to // these and H frames only to the plain harness workers. Same reverse-dial shape as // runHarnessWorkers: the host cannot reach the box, so the box parks here. // // pctx is the pairing's context: revoking a pairing must end its terminals and stop // the re-dial, exactly as it does for the harness and mgmt lanes (run.go supervisor). // self is the pairing this pool belongs to, for the caps gate on an anchored box. func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls.Config, dialHost func(ctx context.Context) (net.Conn, error), bearer string, n int, senv sessionEnv) { dialer := &websocket.Dialer{ NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { return dialHost(ctx) }, TLSClientConfig: tlsCfg, HandshakeTimeout: 20 * time.Second, } for i := 0; i < n; i++ { go func() { hold := laneHoldFor(self) for pctx.Err() == nil { if !hold.waitClear(pctx) { return } ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) cancel() if err != nil { sleepCtx(pctx, 3*time.Second) continue } if err := ws.WriteMessage(websocket.TextMessage, []byte("BEARER "+bearer+" lane=session")); err != nil { _ = ws.Close() sleepCtx(pctx, time.Second) continue } held := watchHeldClose(ws) stopKA := armWSKeepalive(ws) // A revoked pairing closes the socket under the session, which ends the // shell through the same path a host disconnect takes. unpark := context.AfterFunc(pctx, func() { _ = ws.Close() }) handleSessionWorkerConn(ws, self, senv) // blocks for the life of one session unpark() stopKA() _ = ws.Close() if held() { hold.refused(self) // the Host holds this machine (lanehold.go) } } }() } } // handleSessionWorkerConn reads the first frame on a session worker: S opens a // session; an H frame is refused with a plain line (harness jobs belong on the // harness workers) so a host that routes wrongly learns it at once. func handleSessionWorkerConn(ws *websocket.Conn, self string, senv sessionEnv) { // The spec is read up to sessionSpecMaxBytes (lane_bounds.go) and refused // past it; a frame of any other tag is not read at all. tag, body, tooBig, err := readLaneJob(ws, func(t byte) int64 { if t == 'S' { return sessionSpecMaxBytes } return 0 }) if err != nil { return } switch tag { case 'S': if tooBig { sendSession(ws, 'E', []byte(fmt.Sprintf("the session spec is more than the %d KiB a session may ask with; nothing of it was read\n", sessionSpecMaxBytes>>10))) sendSessionExit(ws, 1) return } handleSessionConn(ws, append([]byte{'S'}, body...), self, senv) case 'H': sendSession(ws, 'E', []byte("this is a session worker (lane=session); harness jobs go to a harness worker\n")) sendSessionExit(ws, 1) } } const ( sessionOpOpen = "session:open" sessionOpRoot = "session:root" ) // sessionOps is what the enforcing report advertises for this lane (enforcing.go // folds it into mgmt_ops) so a grant can name them. They are NOT in handlerOps: they // ride the harness workers, not the C-frame mgmt lane. var sessionOps = []string{sessionOpOpen, sessionOpRoot, sessionOpRootExec} // Timers. Idle = no keyboard input from the host; a session someone is watching but // not typing into still ends at 30 minutes, because an unattended root shell is the // risk this exists to bound. Both are package vars so tests can shorten them. var ( sessionIdleTimeout = 30 * time.Minute sessionHardCap = 8 * time.Hour sessionKillGrace = 3 * time.Second sessionIdleCheck = 15 * time.Second // how often the idle clock is looked at ) // sessionSpec is the S-frame body. type sessionSpec struct { Op string `json:"op"` // session:open | session:root Shell string `json:"shell"` // "" = the session user's login shell, else must be listed in /etc/shells Cols int `json:"cols"` // initial window; 0 = 80 Rows int `json:"rows"` // initial window; 0 = 24 GrantB64 string `json:"grant_terms_b64"` // the EXACT signed bytes of the user's mgmt grant SigB64 string `json:"grant_sig_b64"` // b64 ed25519 signature over those bytes SessionID string `json:"session_id"` // host-minted id; names the transcript + audit rows // The host's OWN entity signing key, so a box that has never synced a DCN // store grant can still pin it and verify the grant above. TOFU, first use // only — see sessionVerifyGrant. Absent on an older host; then the box falls // back to whatever a DCN sync pinned, which is exactly today's behaviour. VerifyPubkey string `json:"verify_pubkey"` Approval string `json:"approval"` // session:root only: the step-up approval id issued on this box // session:root:exec only (rootexec.go): the command, and the host's asks // for its deadline and output size, each held to this box's ceiling. Command string `json:"command,omitempty"` DeadlineS int `json:"deadline_s,omitempty"` MaxOutputBytes int `json:"max_output_bytes,omitempty"` } type sessionResize struct { Cols int `json:"cols"` Rows int `json:"rows"` } // sessionEnv is what the worker hands the handler: where this pairing keeps its // state, which host it serves, and what this machine is called in grants. type sessionEnv struct { CtxRoot string // transcripts live under /sessions DcnDir string // pinned entity key + audit.jsonl + step-up state HostName string // the pairing (host) this worker serves — for the caps hook Server string // this machine's hostname, the `server` a grant names } // sessionGrantTerms is parsed FROM the verified signed bytes only — the shape the // host signs when it approves a mgmt grant: {entity, server, ops, deny, intent, expires}. type sessionGrantTerms struct { Entity int64 `json:"entity"` Server string `json:"server"` Ops []string `json:"ops"` Deny []string `json:"deny"` Expires string `json:"expires"` } // sessionProc is a running shell behind a pty. The real one (session_unix.go) wraps // creack/pty; tests substitute a fake through sessionSpawn. type sessionProc interface { io.Reader // pty output io.Writer // pty input Resize(cols, rows int) error Wait() int // blocks until the shell exits; returns its exit code Kill() // ends the whole process group; idempotent User() string Shell() string } // sessionSpawn is the platform spawner (session_unix.go / session_windows.go). A var // so tests can stand in a fake process. var sessionSpawn = spawnSessionProc // sessionAllowedByCaps applies the pairing caps an anchor granted this host // (anchor.go capsAllow, merged with the anchoring lane). It returns the box's own // refusal words, or "" when the pairing may open a session here. // // Two walls, not one: this says whether the ANCHOR let this host have terminals on // this machine at all, and sessionVerifyGrant below says whether the host's own user // signed a grant listing the op. An unanchored LOCAL box has no anchor to ask, so // capsAllow returns "" unless the person at the machine has switched `session` // off on it (localdeny.go) — otherwise the signed grant is the only wall, the // same shape the mgmt lane uses. An unanchored NETWORKED box is refused here outright from // 0.24.0: capsAllow carries the gate, so session:open never reaches the grant // check and session:root never reaches the step-up. func sessionAllowedByCaps(pairingHostName string) string { return capsAllow(pairingHostName, sessionOpOpen) } var sessionIDRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{7,63}$`) func sendSession(ws *websocket.Conn, tag byte, payload []byte) { _ = ws.WriteMessage(websocket.BinaryMessage, append([]byte{tag}, payload...)) } func sendSessionExit(ws *websocket.Conn, code int) { sendSession(ws, 'X', []byte(strconv.Itoa(code))) } // sessionRefuse is the refusal shape: E + why, then X 126 — read by the host exactly // like an ungranted mgmt op. func sessionRefuse(ws *websocket.Conn, env sessionEnv, spec sessionSpec, why string) { sessionAudit(env.DcnDir, spec.Op, spec.SessionID, "", "refused", 126, why) sendSession(ws, 'E', []byte(why+"\n")) sendSessionExit(ws, 126) } // sessionAudit appends one row to the pairing's audit.jsonl — the same file every // dcn/stepup decision lands in, with the fields a session needs (who it ran as, which // event, the exit). Best-effort like dcnAudit: a refusal stands whether or not the // line lands. func sessionAudit(dir, op, sessionID, user, event string, exit int, detail string) { if dir == "" { return } rec, _ := json.Marshal(map[string]any{ "ts": time.Now().UTC().Format(time.RFC3339), "op": op, "session_id": sessionID, "user": user, "event": event, "exit": exit, "decision": detail, }) auditToSystemLog(auditFilePairingD, auditPairingOfDir(dir), rec) fh, err := os.OpenFile(filepath.Join(dir, "audit.jsonl"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return } defer fh.Close() _, _ = fh.Write(append(rec, '\n')) } // sessionPinOfferedKey is the pin half of sessionVerifyGrant on its own, for a // session frame that carries no grant (0.39.0): the host's offered key is // pinned under the same rules (pinVerifyKey — first use only on a local box, // never over the anchoring pin on an anchored one), and a key that disagrees // with the pinned one refuses the session. "" when there is nothing to pin // or nothing to refuse. func sessionPinOfferedKey(dcnDir, pairing, offeredKey string) string { if dcnDir == "" || strings.TrimSpace(offeredKey) == "" { return "" } f, err := dcnLoadFile(dcnDir) if err != nil { return "grant store unreadable" } pinned, why := pinVerifyKey(f, pairing, offeredKey) if why != "" { return why } if pinned { if e := dcnSaveFile(dcnDir, f); e != nil { return "could not pin the host key: " + e.Error() } } return "" } // sessionVerifyGrant re-verifies the pod-supplied grant against the entity key this // box pinned, then applies the same decision rule the host applies: the // server must match (or be '*'), a deny of the op or '*' beats everything, and the // op must be listed. Returns the parsed terms on success, a plain-words reason on // refusal. Since 0.39.0 it runs only for a frame that PRESENTS a grant // (handleSessionConn); a frame without one goes through sessionPinOfferedKey. func sessionVerifyGrant(dcnDir, pairing, server, op, termsB64, sigB64, offeredKey string) (*sessionGrantTerms, error) { if dcnDir == "" { return nil, fmt.Errorf("no grant state on this box (default-deny)") } f, err := dcnLoadFile(dcnDir) if err != nil { return nil, fmt.Errorf("grant store unreadable") } // TOFU-PIN THE HOST KEY FROM THE SESSION FRAME ITSELF. // // Until 0.24.3 the only writer of this field was a DCN store sync // (dcn.go), so a terminal required a FOLDER // GRANT on the machine first — two different permissions welded together by // where the key happened to live. Measured across one deployment: 19 of 20 // online machines held zero DCN grants, so the host could not open a terminal // on any of them, and the refusal said "sync a grant first" — which reads as advice // and is really a coupling. // // The trust basis is identical to the DCN pin and this is deliberately the // same shape: the lane is mTLS + bearer to the host this box enrolled with, // the box only ever talks to that host, and a key that disagrees with one // already pinned is REFUSED rather than re-pinned. So this widens nothing — // it lets the same pin arrive by the lane that needs it. // On an ANCHORED box the key comes from anchoring, never from this offer — // pinVerifyKey (dcn.go) holds both rules in one place. pinned, why := pinVerifyKey(f, pairing, offeredKey) if why != "" { return nil, fmt.Errorf("%s", why) } if pinned { if e := dcnSaveFile(dcnDir, f); e != nil { return nil, fmt.Errorf("could not pin the host key: %v", e) } } if f.VerifyPubkey == "" { return nil, fmt.Errorf("no entity key pinned on this box yet (default-deny) — this host is too old to offer one; sync a grant first") } pub, err := base64.StdEncoding.DecodeString(f.VerifyPubkey) if err != nil || len(pub) != ed25519.PublicKeySize { return nil, fmt.Errorf("bad pinned verify key") } if termsB64 == "" || sigB64 == "" { return nil, fmt.Errorf("no signed grant presented (default-deny)") } canonical, err := base64.StdEncoding.DecodeString(termsB64) if err != nil { return nil, fmt.Errorf("bad grant encoding") } sig, err := base64.StdEncoding.DecodeString(sigB64) if err != nil { return nil, fmt.Errorf("bad grant signature encoding") } if !ed25519.Verify(ed25519.PublicKey(pub), canonical, sig) { return nil, fmt.Errorf("grant signature does not verify on this box") } var t sessionGrantTerms if err := json.Unmarshal(canonical, &t); err != nil { return nil, fmt.Errorf("signed grant unparsable") } // A MANAGEMENT grant carries no `kind` (the host has never signed one // with it), while every other record the entity key signs does — a store // grant, a move plan, a reach grant. So a record that declares ANY kind is // not a management grant and cannot open a terminal, whatever else it says. var kinded struct { Kind string `json:"kind"` } if json.Unmarshal(canonical, &kinded) == nil && kinded.Kind != "" { return nil, fmt.Errorf("signed record is a %q, not a management grant", kinded.Kind) } // NO OPEN-ENDED TERMS (dcn.go dcnVerifyRec says why). exp, err := time.Parse(time.RFC3339, t.Expires) if err != nil { return nil, fmt.Errorf("grant states no expiry (open-ended terms are refused)") } if !exp.After(time.Now()) { return nil, fmt.Errorf("grant expired") } if t.Server != "*" && t.Server != server { return nil, fmt.Errorf("grant is for server '%s', this machine is '%s'", t.Server, server) } for _, d := range t.Deny { if d == "*" || d == op { return nil, fmt.Errorf("op '%s' is denied by your grant", op) } } for _, o := range t.Ops { if o == op { return &t, nil } } return nil, fmt.Errorf("op '%s' is not in your grant for this server (default-deny)", op) } // purposeForSessionRoot is the step-up purpose a root session binds to: this server, // this session id. One approval opens one root shell. func purposeForSessionRoot(server, sessionID string) (string, error) { if err := purposeBad([]string{server, sessionID}, nil); err != nil { return "", err } if server == "" || sessionID == "" { return "", fmt.Errorf("a root session needs a server and a session id") } return "session_root|" + server + "|" + sessionID, nil } // sessionTranscriptPath is /sessions/.log. The id is already shape-checked // (sessionIDRe), so it cannot carry a separator; cleanRel is the second wall. func sessionTranscriptPath(ctxRoot, sessionID string) (string, bool) { if ctxRoot == "" { return "", false } return cleanRel(ctxRoot, filepath.Join("sessions", sessionID+".log")) } // sessionOpenTranscript creates /sessions/.log, 0600, refusing to reuse // a name (O_EXCL): one session id, one transcript, ever. func sessionOpenTranscript(tpath string) (*os.File, error) { if err := os.MkdirAll(filepath.Dir(tpath), 0o700); err != nil { return nil, err } return os.OpenFile(tpath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) } // sessionDaemonIsRoot is processIsRoot, held in a variable so a test can // assert the root session's walls whatever euid the suite runs under. Nothing // outside tests assigns it. var sessionDaemonIsRoot = processIsRoot // handleSessionConn serves ONE session on a harness worker socket. `first` is the // whole S frame (tag included) the worker already read. Returns when the session is // over and X has been sent; the worker closes the socket. func handleSessionConn(ws *websocket.Conn, first []byte, self string, env sessionEnv) { var spec sessionSpec if len(first) < 2 || first[0] != 'S' || json.Unmarshal(first[1:], &spec) != nil { sendSession(ws, 'E', []byte("bad session spec\n")) sendSessionExit(ws, 1) return } if !sessionIDRe.MatchString(spec.SessionID) { if spec.Op == sessionOpRootExec { // A root command leaves a row whatever its outcome (rootexec.go); // the id is the host's text, so it goes in bounded. bad := spec if len(bad.SessionID) > 64 { bad.SessionID = bad.SessionID[:64] } bad.SessionID = strings.ToValidUTF8(bad.SessionID, "�") rootExecAuditFailed(env, bad, self, "bad_request", "bad session spec: session_id must be 8-64 chars of [A-Za-z0-9._-]") } sendSession(ws, 'E', []byte("bad session spec: session_id must be 8-64 chars of [A-Za-z0-9._-]\n")) sendSessionExit(ws, 1) return } if spec.Op == sessionOpRootExec { handleRootExec(ws, spec, self, env) return } if spec.Op != sessionOpOpen && spec.Op != sessionOpRoot { sessionRefuse(ws, env, spec, "refused: op '"+spec.Op+"' is not a session op") return } if why := sessionAllowedByCaps(self); why != "" { sessionRefuse(ws, env, spec, "refused: "+why) return } // THE HOST-SIGNED MANAGEMENT GRANT IS NO LONGER REQUIRED (0.39.0). A // terminal is the PERSON'S own act in their Terminal app, and the authority // for it is what this box checks below, not a standing grant the host // holds: root is approved here with // the code; session:open on an anchored box takes the person's approval // from their device or this console, bound to this one session; on a local // box the pairing's caps (above) are the authorization, as for every other // write on a person's own machine. A grant that IS presented is still // verified as before — an older host sends one, and a deny in it must // still refuse — and the key the frame offers is still pinned, because // that is how a box with no store grant learns the host key at all. if spec.GrantB64 != "" || spec.SigB64 != "" { if _, err := sessionVerifyGrant(env.DcnDir, env.HostName, env.Server, spec.Op, spec.GrantB64, spec.SigB64, spec.VerifyPubkey); err != nil { sessionRefuse(ws, env, spec, "refused: "+err.Error()) return } } else if why := sessionPinOfferedKey(env.DcnDir, env.HostName, spec.VerifyPubkey); why != "" { sessionRefuse(ws, env, spec, "refused: "+why) return } // Root: the daemon must BE root, and the user must have approved this very session // on this machine. No enrolled factor = no root shell, ever; there is no "unenrolled // passes" here the way stepupGate allows for a grant sync. // THE APPROVAL IS SPENT HERE, before the shell exists (stepupConsumeApproval // / consumeApproval): four session workers can carry the same frame, and a // check here with a burn after the spawn let two of them open on one tap. spent := false if spec.Op == sessionOpRoot { if !sessionDaemonIsRoot() { sessionRefuse(ws, env, spec, "refused: session:root needs the bastion to run as root, and it does not") return } // ROOT IS APPROVED AT THE MACHINE WITH THE CODE, and only there. A // device tap opens a shell; it does not open root. The factor enrolled // here is required, as it always was — a device pinned on this box // does not stand in for it. if !stepupEnrolled(env.DcnDir) { sessionRefuse(ws, env, spec, "refused: session:root needs a second factor enrolled on this machine (privatae-bastion stepup-enroll) — root is approved here with the code, never from a device") return } p, err := purposeForSessionRoot(env.Server, spec.SessionID) if err != nil { sessionRefuse(ws, env, spec, "refused: "+err.Error()) return } if isDeviceApproval(spec.Approval) { sessionRefuse(ws, env, spec, "refused: session:root is approved at this machine with the code, not from a device") return } if err := stepupConsumeApproval(env.DcnDir, spec.Approval, p); err != nil { sessionRefuse(ws, env, spec, "refused: session:root — "+err.Error()) return } spent = true } else if currentAnchor() != nil { // session:open on an ANCHORED box is a shell on a fleet machine — // a write in every sense that matters (writes.go). It takes the same // per-run approval a management write does, bound to this session id, // so an approval opens exactly one shell. A local box keeps today's // rule: the signed grant above is the authorization there. p := mgmtOpPurpose(sessionOpOpen, env.Server, []string{spec.SessionID}) if _, err := boxConsume(env.DcnDir, spec.Approval, p); err != nil { sessionRefuse(ws, env, spec, "refused: session:open "+err.Error()) return } spent = true } _ = spent tpath, ok := sessionTranscriptPath(env.CtxRoot, spec.SessionID) if !ok { sessionRefuse(ws, env, spec, "refused: context store not configured, so the transcript has nowhere to go") return } cols, rows := spec.Cols, spec.Rows if cols <= 0 { cols = 80 } if rows <= 0 { rows = 24 } // Spawn FIRST, transcript second: a failed spawn must leave nothing behind, or the // session id (and the root approval bound to it) could never be retried. proc, err := sessionSpawn(spec.Op, spec.Shell, cols, rows) if err != nil { sessionAudit(env.DcnDir, spec.Op, spec.SessionID, "", "failed", 1, err.Error()) sendSession(ws, 'E', []byte(err.Error()+"\n")) sendSessionExit(ws, 1) return } tf, err := sessionOpenTranscript(tpath) if err != nil { proc.Kill() proc.Wait() sessionAudit(env.DcnDir, spec.Op, spec.SessionID, proc.User(), "failed", 1, "transcript: "+err.Error()) sendSession(ws, 'E', []byte("transcript: "+err.Error()+"\n")) sendSessionExit(ws, 1) return } defer tf.Close() start := time.Now() sessionAudit(env.DcnDir, spec.Op, spec.SessionID, proc.User(), "start", 0, "shell="+proc.Shell()) fmt.Fprintf(tf, "== session %s op=%s user=%s shell=%s start=%s\n", spec.SessionID, spec.Op, proc.User(), proc.Shell(), start.UTC().Format(time.RFC3339)) fmt.Printf("session: %s %s user=%s shell=%s\n", spec.SessionID, spec.Op, proc.User(), proc.Shell()) // One reader, one writer (gorilla). The read pump owns ws.ReadMessage: it feeds the // pty and resizes it, and notices the host going away. This loop is the ONLY // ws.WriteMessage caller. The pty pump only reads the pty. var lastInput atomic.Int64 lastInput.Store(time.Now().UnixNano()) closed := make(chan struct{}) go func() { defer close(closed) // Each frame is streamed (lane_bounds.go): input goes into the pty as it // arrives, never held whole, and a frame is at most sessionInputMaxBytes. ws.SetReadLimit(sessionInputMaxBytes) for { mt, r, err := ws.NextReader() if err != nil { return } if mt != websocket.BinaryMessage { continue // NextReader drops what is left of it } var tag [1]byte if _, err := io.ReadFull(r, tag[:]); err != nil { continue // an empty frame } switch tag[0] { case 'I': lastInput.Store(time.Now().UnixNano()) _, _ = io.Copy(proc, r) case 'R': var rz sessionResize b, _ := io.ReadAll(io.LimitReader(r, sessionResizeMaxBytes)) if json.Unmarshal(b, &rz) == nil && rz.Cols > 0 && rz.Rows > 0 { _ = proc.Resize(rz.Cols, rz.Rows) } } } }() out := make(chan []byte, 64) go func() { defer close(out) buf := make([]byte, 32*1024) for { n, err := proc.Read(buf) if n > 0 { b := make([]byte, n) copy(b, buf[:n]) out <- b } if err != nil { return } } }() idle := time.NewTicker(sessionIdleCheck) defer idle.Stop() hard := time.NewTimer(sessionHardCap) defer hard.Stop() reason, code := "", 0 finish := func(r string, c int) { reason, code = r, c } loop: for { select { case b, ok := <-out: if !ok { // pty closed: the shell exited on its own finish("exit", proc.Wait()) break loop } _, _ = tf.Write(b) sendSession(ws, 'O', b) case <-closed: proc.Kill() finish("host closed", proc.Wait()) break loop case <-idle.C: if time.Since(time.Unix(0, lastInput.Load())) >= sessionIdleTimeout { proc.Kill() proc.Wait() finish("idle "+sessionIdleTimeout.String(), 124) break loop } case <-hard.C: proc.Kill() proc.Wait() finish("hard cap "+sessionHardCap.String(), 124) break loop } } // Drain whatever the pty produced before it died so the transcript is whole; the // host may already be gone, in which case the writes just fail quietly. for b := range out { _, _ = tf.Write(b) sendSession(ws, 'O', b) } stop := time.Now() fmt.Fprintf(tf, "\n== session %s stop=%s exit=%d reason=%s duration=%s\n", spec.SessionID, stop.UTC().Format(time.RFC3339), code, reason, stop.Sub(start).Round(time.Second)) sessionAudit(env.DcnDir, spec.Op, spec.SessionID, proc.User(), "stop", code, reason) fmt.Printf("session: %s done exit=%d reason=%s\n", spec.SessionID, code, reason) if reason != "exit" && reason != "host closed" { sendSession(ws, 'E', []byte("session closed by this machine: "+reason+"\n")) } sendSessionExit(ws, code) } // sessionShellFromPasswd returns the login shell /etc/passwd lists for name, or "". // Read directly (not via os/user, which does not expose the shell) so the default // shell is the user's own, the way a login would give them. func sessionShellFromPasswd(passwdPath, name string) string { raw, err := os.ReadFile(passwdPath) if err != nil { return "" } for _, line := range strings.Split(string(raw), "\n") { f := strings.Split(line, ":") if len(f) >= 7 && f[0] == name { return strings.TrimSpace(f[6]) } } return "" } // sessionShellIsServer names the /etc/shells entries that are terminal SERVERS, not // shells: tmux and screen daemonize past the session's process group and would keep // a root pty alive after the session's kill. Refused as the session shell. func sessionShellIsServer(shell string) bool { switch filepath.Base(shell) { case "tmux", "screen", "byobu", "byobu-tmux", "byobu-screen": return true } return false } // sessionShellListed reports whether shell is an absolute path named in the shells // file (/etc/shells). A caller-chosen shell is the one user-supplied string that // reaches exec, so it must come from the box's own list, never from the wire alone. func sessionShellListed(shellsPath, shell string) bool { if shell == "" || !filepath.IsAbs(shell) { return false } raw, err := os.ReadFile(shellsPath) if err != nil { return false } for _, line := range strings.Split(string(raw), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } if line == shell { return true } } return false }