From 2043fa5e9e268fa251ff594bd980ffcd237b8d7b Mon Sep 17 00:00:00 2001 From: Privatae Date: Mon, 2 Nov 2026 21:31:58 +0000 Subject: [PATCH] bastion 0.62.0 --- CHANGELOG.md | 32 +++ client-go/agentask.go | 70 +++-- client-go/approvalpipe.go | 9 + client-go/approvalpipe_windows.go | 49 +++- client-go/asklane_other.go | 19 ++ client-go/asklane_windows.go | 153 +++++++++++ client-go/askpeer.go | 89 ++++++ client-go/askpeer_test.go | 248 +++++++++++++++++ client-go/consolelane.go | 98 ++++--- client-go/notify.go | 23 -- client-go/owneracts.go | 22 +- client-go/reads.go | 35 ++- client-go/reads_slice3_linux_test.go | 6 +- client-go/reads_slice3_test.go | 28 +- client-go/relaunch_windows.go | 5 + client-go/runguard_windows.go | 44 ++- client-go/runholder.go | 141 ++++++++++ client-go/servicecarry.go | 387 +++++++++++++++++++++++++++ client-go/servicecarry_test.go | 353 ++++++++++++++++++++++++ client-go/sysservice.go | 42 ++- client-go/sysservice_test.go | 48 ++-- client-go/sysservice_windows.go | 50 ++++ client-go/ui.go | 58 +++- client-go/uiassets/approvals.html | 2 +- client-go/uiassets/setup.html | 12 +- client-go/uisetup.go | 3 + deploy/VERSION | 2 +- deploy/install.ps1 | 26 +- docs/STATUS.md | 10 +- 29 files changed, 1853 insertions(+), 211 deletions(-) create mode 100644 client-go/asklane_other.go create mode 100644 client-go/asklane_windows.go create mode 100644 client-go/askpeer.go create mode 100644 client-go/askpeer_test.go create mode 100644 client-go/runholder.go create mode 100644 client-go/servicecarry.go create mode 100644 client-go/servicecarry_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c964ab7..5d51a9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,38 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. +## 0.62.0 + +Windows, with the bastion installed for the whole machine (the +`PrivataeBastion` service), from the account that owns the machine: + +- **`privatae-bastion ui` opens the console from your own account,** with no + Administrator terminal, as it does on Linux and macOS. It asks the service + over a new pipe, which knows your account from Windows and serves only the + account recorded as the machine's owner. Before this it signed in with a + key of your account's own, which the service's console refused with a bare + 401. +- **Your agents and you reach the machine's bastion as on Linux and macOS:** + an agent asks to stop another (`stop`, `request`), reads the machine + (`agents`, `activity`, `status`, `doctor`, the `--json` reads), and you + answer from your own account (`approvals`, `approve`, `deny`, `switch`, the + drops). These were refused beside the service. +- **Moving to the service keeps the machine's pairings.** When an install + moves the bastion from your account to the service, the service takes over + your account's bastion state (pairings, keys, grants, audit) before it first + starts, and the copy in your profile is removed. Every Host it served goes + on being served, and nothing is paired again. The service started empty + before, and the machine went silent to its Hosts. +- **No dead ends.** A console page opened with no session names the command + that opens it on this machine. A second start says which bastion is already + running (its process, its program, how it was started) and how to open, + check or restart it. It no longer suggests ending the running one in Task + Manager. +- **An update never leaves a sign-in task's bastion stopped.** After a + self-update, the new copy waits for the old one to exit before it takes the + machine's run lock. It could find the lock still held, refuse as a second + copy, and leave nothing running. + ## 0.61.2 - **A machine a Host holds stops dialling it.** When a Host's owner diff --git a/client-go/agentask.go b/client-go/agentask.go index d3172e4..15d7aa2 100644 --- a/client-go/agentask.go +++ b/client-go/agentask.go @@ -8,7 +8,8 @@ package main // `stop` run by it had nowhere to ask, and asking is the one thing an agent // managing the bastion for the person must be able to do (2026-10-30). // This is the context socket's mechanism (contextsock.go) for a second -// question: any local account may connect; WHO connected comes from the +// question, and on Windows the service's ask pipe (asklane_windows.go), which +// answers through the same askAnswer: any local account may connect; WHO connected comes from the // kernel, never from the request; it may only ASK. The ask lands in the // owner's factor as a pending request (stepup_machine.go), shown on Waiting // for you and answered with the owner's code. Nothing here approves anything. @@ -81,7 +82,13 @@ var askPeerIdentityFn = contextPeerIdentity func startAskSock(done <-chan struct{}) { if runtime.GOOS == "windows" { - return // a named pipe, as context.sock has, is not built yet + // The ask pipe (asklane_windows.go). Only the service serves it: a + // bastion run by an account is that account's, and nobody else's + // requests are its to answer. + if runningAsService { + startAskPipe(done) + } + return } p := askSockPath() ln, err := contextSockListen(p) @@ -123,8 +130,7 @@ func askSockServe(c net.Conn) { _ = c.SetDeadline(time.Now().Add(10 * time.Second)) uid, user, err := askPeerIdentityFn(c) if err != nil { - _ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" + - err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."}) + _ = json.NewEncoder(c).Encode(askUnattributable(err)) return } var req agentAskRequest @@ -132,31 +138,42 @@ func askSockServe(c net.Conn) { _ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"}) return } - if req.Op == "console" { - r := agentAskReply{User: user} - if link, err := consoleLaneEntry(uid, user); err != nil { + extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) } + _ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend, + func() int { return askPeerPIDFn(c) })) +} + +// askUnattributable is the answer to a connection whose account the +// operating system would not name. +func askUnattributable(err error) agentAskReply { + return agentAskReply{Why: "this machine could not establish who is asking (" + err.Error() + + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."} +} + +// askAnswer is the lane's answer to one request from peer, on either +// platform: ask.sock here, the ask pipe on Windows. extend gives the answer +// more time where it takes longer (a read, an act); pid is the connecting +// process, asked only for a stop. +func askAnswer(peer askPeer, req agentAskRequest, extend func(time.Duration), pid func() int) any { + switch req.Op { + case "console": + r := agentAskReply{User: peer.Name} + if link, err := consoleLaneEntryFor(peer); err != nil { r.Why = err.Error() } else { r.OK, r.URL = true, link } - _ = json.NewEncoder(c).Encode(r) - return + return r + case "status": + return askStatus(existingMachineFactorDir(), req.Request, peer.Name, false) + case "read": + extend(readDeadline) + return serveReadFor(peer, req) + case "act": + extend(30 * time.Second) + return serveActFor(peer, req) } - if req.Op == "status" { - _ = json.NewEncoder(c).Encode(askStatus(existingMachineFactorDir(), req.Request, user, false)) - return - } - if req.Op == "read" { - _ = c.SetDeadline(time.Now().Add(readDeadline)) - _ = json.NewEncoder(c).Encode(serveRead(uid, user, req)) - return - } - if req.Op == "act" { - _ = c.SetDeadline(time.Now().Add(30 * time.Second)) - _ = json.NewEncoder(c).Encode(serveAct(uid, user, req)) - return - } - _ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c))) + return fileAsk(req, peer.Name, pid()) } // fileAsk turns one ask into a pending request of the machine, or says why not. @@ -258,10 +275,7 @@ func askPendingBy(dir, user, purpose string) (n int, same bool) { // askViaSock is the asking side: `stop` run where it cannot write the // machine's state itself. func askViaSock(req agentAskRequest) (agentAskReply, error) { - if runtime.GOOS == "windows" { - return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet") - } - c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second) + c, err := dialAskLane(askSockPath()) if err != nil { return agentAskReply{}, err } diff --git a/client-go/approvalpipe.go b/client-go/approvalpipe.go index 4bf9f42..c8bc8e6 100644 --- a/client-go/approvalpipe.go +++ b/client-go/approvalpipe.go @@ -177,6 +177,15 @@ func winPersonSID(sid string) bool { return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-") } +// pipeClientNewer: a process created after the connection was made cannot be +// the one that made it. The service finds the connecting process by its id +// (GetNamedPipeClientProcessId), and an id is reused once its process has +// exited and nothing holds it, so a client that hands its pipe to another +// process and exits could otherwise be answered as whoever got its id next. +// The connection time is taken as the service starts serving it, a moment +// after the connect. +func pipeClientNewer(created, connected time.Time) bool { return created.After(connected) } + // pipeClientRefusal says why a connected process may not use the lane, or "" // when it may: it must run as the person signed in to its own session, in a // session someone is in front of. diff --git a/client-go/approvalpipe_windows.go b/client-go/approvalpipe_windows.go index c7c49b2..1a8ca9e 100644 --- a/client-go/approvalpipe_windows.go +++ b/client-go/approvalpipe_windows.go @@ -157,10 +157,11 @@ func (c *pipeConn) readLine(max int, timeout time.Duration) ([]byte, error) { // approvalPipeServer tracks how many of our instances are open, so an instance // created while none is open asks for the name to be ours alone. type approvalPipeServer struct { - mu sync.Mutex - open int - sa *windows.SecurityAttributes - name *uint16 + mu sync.Mutex + open int + sa *windows.SecurityAttributes + name *uint16 + label string // the pipe's name, for a message // stopEv is set when the service stops; it ends a connect that is waiting. stopEv windows.Handle } @@ -190,7 +191,7 @@ func (s *approvalPipeServer) create() (*pipeConn, error) { windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa) if err != nil { if s.open == 0 { - return nil, fmt.Errorf("cannot create %s; if another program holds that name the tray stays off: %w", approvalPipeName, err) + return nil, fmt.Errorf("cannot create %s; if another program holds that name, this lane stays off: %w", s.label, err) } return nil, err } @@ -230,11 +231,17 @@ func startApprovalPipe(stop <-chan struct{}) { } func serveApprovalPipe(stop <-chan struct{}, ready func()) error { - sd, err := windows.SecurityDescriptorFromString(approvalPipeSDDL) + return servePipe(stop, ready, approvalPipeName, approvalPipeSDDL, servePipeConn) +} + +// servePipe serves one of the service's pipes, name with the ACL sddl, until +// stop is closed, handing each connection to serve. +func servePipe(stop <-chan struct{}, ready func(), pipeName, sddl string, serve func(*pipeConn)) error { + sd, err := windows.SecurityDescriptorFromString(sddl) if err != nil { return fmt.Errorf("cannot build the pipe's permissions: %w", err) } - name, err := windows.UTF16PtrFromString(approvalPipeName) + name, err := windows.UTF16PtrFromString(pipeName) if err != nil { return err } @@ -249,11 +256,12 @@ func serveApprovalPipe(stop <-chan struct{}, ready func()) error { s := &approvalPipeServer{ sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd}, name: name, + label: pipeName, stopEv: stopEv, } // The loop and its no-gap rule are in approvalpipe.go, where the suite runs them. return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second, - func(f string, a ...any) { fmt.Printf(f, a...) }, ready, servePipeConn) + func(f string, a ...any) { fmt.Printf(f, a...) }, ready, serve) } // servePipeConn answers one request and waits, briefly, for the client to @@ -291,7 +299,12 @@ func servePipeConn(c *pipeConn) { // (GetNamedPipeClientProcessId), its user and session from that process's // token, and the person signed in to that session from the session's own token // (WTSQueryUserToken, which LocalSystem may call). +// +// The process is found by its id, after it connected. An id that was freed +// and given to a new process in between would name the wrong one, so a +// process created after the connection is refused (pipeClientNewer). func pipeClientOf(h windows.Handle) (pipeClient, error) { + connected := time.Now() var c pipeClient if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil { return c, fmt.Errorf("cannot tell which process connected: %w", err) @@ -301,6 +314,13 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) { return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err) } defer windows.CloseHandle(p) + var created, exited, kernel, user windows.Filetime + if err := windows.GetProcessTimes(p, &created, &exited, &kernel, &user); err != nil { + return c, fmt.Errorf("cannot read when the connecting process started (pid %d): %w", c.PID, err) + } + if pipeClientNewer(time.Unix(0, created.Nanoseconds()), connected) { + return c, fmt.Errorf("process %d started after the connection was made, so it is not the process that connected", c.PID) + } var tok windows.Token if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil { return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err) @@ -381,7 +401,12 @@ func pipeCall(req pipeRequest) (pipeResponse, error) { // (read, write data, synchronize; never FILE_APPEND_DATA, which GENERIC_WRITE // would ask for), and lets the server identify this process but not act as it. func dialApprovalPipe() (*pipeConn, error) { - name, err := windows.UTF16PtrFromString(approvalPipeName) + return dialServicePipe(approvalPipeName) +} + +// dialServicePipe opens one of the service's pipes (dialApprovalPipe). +func dialServicePipe(pipeName string) (*pipeConn, error) { + name, err := windows.UTF16PtrFromString(pipeName) if err != nil { return nil, err } @@ -401,7 +426,7 @@ func dialApprovalPipe() (*pipeConn, error) { continue } if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { - return nil, errors.New("the bastion service is not running, or is older than this tray") + return nil, errors.New("the bastion service is not running, or is older than this program") } return nil, fmt.Errorf("cannot reach the bastion service: %w", err) } @@ -413,14 +438,14 @@ func dialApprovalPipe() (*pipeConn, error) { func pipeServerIsService(h windows.Handle) error { var pid uint32 if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil { - return fmt.Errorf("cannot tell which process serves the approvals pipe: %w", err) + return fmt.Errorf("cannot tell which process serves the pipe: %w", err) } svcPID, err := bastionServicePID() if err != nil { return err } if svcPID == 0 || pid != svcPID { - return fmt.Errorf("the approvals pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName) + return fmt.Errorf("the pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName) } return nil } diff --git a/client-go/asklane_other.go b/client-go/asklane_other.go new file mode 100644 index 0000000..7fa8fcf --- /dev/null +++ b/client-go/asklane_other.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +import ( + "net" + "time" +) + +// dialAskLane connects to the machine's bastion's ask socket at p. +func dialAskLane(p string) (askConn, error) { + return net.DialTimeout("unix", p, 3*time.Second) +} + +// askLaneAddr is where the lane is, as a message names it. +func askLaneAddr(p string) string { return p } + +// startAskPipe is the Windows service's; ask.sock serves here (agentask.go). +func startAskPipe(<-chan struct{}) {} diff --git a/client-go/asklane_windows.go b/client-go/asklane_windows.go new file mode 100644 index 0000000..9589d52 --- /dev/null +++ b/client-go/asklane_windows.go @@ -0,0 +1,153 @@ +//go:build windows + +package main + +// The ask pipe: ask.sock (agentask.go) for a bastion that runs as the Windows +// service. +// +// Installed for the whole machine, the bastion is a LocalSystem service and +// its state sits in LocalSystem's profile, where the person's own account can +// neither read nor write it. Before this pipe, that account had no way to the +// machine's bastion at all: `privatae-bastion ui` signed in with a key of its +// own and the console answered 401, the reads and owner acts were refused +// beside the service, and an agent could not ask for a stop. The pipe answers +// exactly what ask.sock answers, through the same code (askAnswer): a stop +// asked by any account, and the console, the reads and the owner's acts for +// the account recorded as the machine's owner (approvals-owner --sid), with +// the caller taken from the pipe client's token, never from the request. +// +// The pipe is the approvals pipe's machinery (approvalpipe_windows.go): +// overlapped I/O with a timeout on every call, a new instance created before +// the one it replaces is handed out, the first instance created with +// FILE_FLAG_FIRST_PIPE_INSTANCE, and the asking side checking the pipe's +// server is the service's own process before it sends anything, since an act +// carries the owner's code. + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "time" + + "golang.org/x/sys/windows" +) + +// startAskPipe serves the ask pipe until done is closed, retrying with backoff +// when it cannot (pipeServeRetrying), as the approvals pipe does. +func startAskPipe(done <-chan struct{}) { + wait := func(d time.Duration) bool { + select { + case <-done: + return false + case <-time.After(d): + return true + } + } + go pipeServeRetrying(func(ready func()) error { + return servePipe(done, ready, askPipeName, askPipeSDDL, serveAskPipeConn) + }, wait, func(f string, a ...any) { fmt.Printf(f, a...) }) +} + +// serveAskPipeConn answers one ask, then waits briefly for the client to +// close: closing first would discard an answer it has not read yet. +func serveAskPipeConn(c *pipeConn) { + client, cerr := pipeClientOf(c.h) + raw, err := c.readLine(askMaxBytes, pipeIOTimeout) + if err != nil { + return + } + var reply any + switch { + case cerr != nil: + reply = askUnattributable(cerr) + default: + who := pipeWho(client) + var req agentAskRequest + if err := json.Unmarshal(raw, &req); err != nil { + reply = agentAskReply{User: who, Why: "the request could not be read"} + break + } + peer := askPeer{SID: client.UserSID, Name: who, PID: int(client.PID)} + reply = askAnswer(peer, req, func(time.Duration) {}, func() int { return int(client.PID) }) + } + out, _ := json.Marshal(reply) + if err := c.write(append(out, '\n'), pipeIOTimeout); err != nil { + return + } + _, _ = c.read(pipeIOTimeout) +} + +// dialAskLane opens the service's ask pipe; p, ask.sock's path, does not +// apply here. +func dialAskLane(string) (askConn, error) { + c, err := dialServicePipe(askPipeName) + if err != nil { + return nil, err + } + if err := pipeServerIsService(c.h); err != nil { + c.close() + return nil, err + } + return &askPipeClient{c: c}, nil +} + +// askLaneAddr is where the lane is, as a message names it. +func askLaneAddr(string) string { return askPipeName } + +// askPipeClient is the asking side's end of the pipe, read and written the +// way the lane's code reads and writes a socket: a deadline for the whole +// exchange, bytes in, bytes out. +type askPipeClient struct { + c *pipeConn + deadline time.Time + pending []byte +} + +func (a *askPipeClient) SetDeadline(t time.Time) error { + a.deadline = t + return nil +} + +// left is what remains of the deadline for one call; 0 waits as long as it +// takes, which only a caller that set no deadline gets. +func (a *askPipeClient) left() time.Duration { + if a.deadline.IsZero() { + return 0 + } + if d := time.Until(a.deadline); d > 0 { + return d + } + return time.Millisecond +} + +func (a *askPipeClient) Read(b []byte) (int, error) { + for len(a.pending) == 0 { + n, err := a.c.read(a.left()) + a.pending = append(a.pending, a.c.buf[:n]...) + if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) { + if len(a.pending) > 0 { + break + } + if errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) { + return 0, io.EOF + } + return 0, err + } + } + n := copy(b, a.pending) + a.pending = a.pending[n:] + return n, nil +} + +func (a *askPipeClient) Write(b []byte) (int, error) { + if err := a.c.write(b, a.left()); err != nil { + return 0, err + } + return len(b), nil +} + +func (a *askPipeClient) Close() error { + a.c.close() + return nil +} diff --git a/client-go/askpeer.go b/client-go/askpeer.go new file mode 100644 index 0000000..f24f723 --- /dev/null +++ b/client-go/askpeer.go @@ -0,0 +1,89 @@ +package main + +// askpeer.go — who is on the other end of the owner's lane, on every platform. +// +// The lane (agentask.go: ask.sock on Linux and macOS, the ask pipe on Windows, +// asklane_windows.go) learns the connecting account from the operating system, +// never from the request: a uid from the socket's peer credentials, or a SID +// from the pipe client's token. The machine's owner is recorded the same two +// ways (approvals_owner.go). Every refusal on the lane asks here, so both +// platforms answer with the same rules, and only the commands they name differ. + +import ( + "strconv" + "strings" + "time" +) + +type askPeer struct { + UID uint32 // Linux and macOS: the connecting process's account + SID string // Windows: the pipe client's token user; "" elsewhere + Name string // the account as messages name it + PID int // the connecting process; 0 when the platform does not say +} + +// windows reports whether the peer came over the Windows pipe. +func (p askPeer) windows() bool { return p.SID != "" } + +// laneOwnerMatch reads the recorded owner the way peer's platform names +// accounts. present is true when a record exists, readable or not; ownerName +// is how a refusal names the owner. +func laneOwnerMatch(p askPeer) (match bool, ownerName string, present bool, err error) { + if p.windows() { + sid, present, err := approvalsOwnerLoad() + if err != nil || !present { + return false, "", present, err + } + return strings.EqualFold(sid, p.SID), sid, true, nil + } + uid, name, present, err := ownerAccountLoad() + if err != nil || !present { + return false, "", present, err + } + if name == "" { + name = "uid " + strconv.FormatUint(uint64(uid), 10) + } + return uid == p.UID, name, true, nil +} + +// ownerRecordCommand is the administrator's command that records peer's +// account as the machine's owner. +func ownerRecordCommand(p askPeer) string { + if p.windows() { + return adminCommandFor(true, "approvals-owner --sid "+p.SID) + } + return "sudo -H privatae-bastion approvals-owner --account " + accountForHint(p.Name) +} + +// adminCommandFor is `privatae-bastion ` as the machine's administrator +// types it: in an Administrator terminal on Windows, with `sudo -H` elsewhere. +func adminCommandFor(windows bool, cmd string) string { + if windows { + return "privatae-bastion " + cmd + " (from an Administrator terminal)" + } + return "sudo -H privatae-bastion " + cmd +} + +// askConn is one connection of the lane as the asking side holds it: a unix +// socket (net.Conn), or the Windows pipe client (asklane_windows.go). +type askConn interface { + Read(b []byte) (int, error) + Write(b []byte) (int, error) + Close() error + SetDeadline(t time.Time) error +} + +const ( + // askPipeName is the Windows service's ask pipe, the counterpart of + // ask.sock: any account on the machine may connect and ask; who it is + // comes from the pipe client's token. + askPipeName = `\\.\pipe\privatae-bastion-ask` + + // askPipeSDDL: SYSTEM and Administrators full control; Authenticated + // Users (AU) read, write and synchronize, as any local account may + // connect to ask.sock. Not FILE_CREATE_PIPE_INSTANCE (FILE_APPEND_DATA, + // 0x4, on a pipe): 0x12019b is the approvals pipe's mask for the same + // reason (approvalpipe.go), so no client can stand up its own end of this + // name. P protects the DACL from inheritance. + askPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;AU)" +) diff --git a/client-go/askpeer_test.go b/client-go/askpeer_test.go new file mode 100644 index 0000000..77b107f --- /dev/null +++ b/client-go/askpeer_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const ( + testOwnerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001" + testOtherSID = "S-1-5-21-1111111111-2222222222-3333333333-1002" +) + +// writeSIDOwner records sid as the machine's owner, as `approvals-owner --sid` +// writes it on Windows. +func writeSIDOwner(t *testing.T, dir, sid string) { + t.Helper() + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)}) + if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil { + t.Fatal(err) + } +} + +// The Windows ask pipe answers the owner's reads, acts and console for the +// account whose SID the installer recorded, and refuses every other one, in +// Windows' own commands: no sudo anywhere. +// +// MUTATION: compare p.UID in laneOwnerMatch for a Windows peer. RESULT: red +// (the owner's SID is refused). +func TestTheAskPipeServesTheOwnersSIDOnly(t *testing.T) { + dir := isolateConfigDir(t) + owner := askPeer{SID: testOwnerSID, Name: `DESKTOP\owner (` + testOwnerSID + `)`} + other := askPeer{SID: testOtherSID, Name: `DESKTOP\other (` + testOtherSID + `)`} + + why := readLaneRefusalFor(owner) + if !strings.Contains(why, "approvals-owner --sid "+testOwnerSID) || !strings.Contains(why, "Administrator terminal") { + t.Errorf("with no owner recorded, the refusal does not give Windows' command: %q", why) + } + writeSIDOwner(t, dir, testOwnerSID) + if why := readLaneRefusalFor(owner); why != "" { + t.Errorf("the owner's SID was refused a read: %s", why) + } + if why := consoleLaneRefusalFor(owner); why != "" { + t.Errorf("the owner's SID was refused the console: %s", why) + } + // Case differs between a SID read from a token and one typed. + if why := readLaneRefusalFor(askPeer{SID: strings.ToLower(testOwnerSID), Name: "owner"}); why != "" { + t.Errorf("the owner's SID in other case was refused: %s", why) + } + for _, why := range []string{readLaneRefusalFor(other), consoleLaneRefusalFor(other)} { + if why == "" || !strings.Contains(why, testOwnerSID) || !strings.Contains(why, other.Name) { + t.Errorf("another account was not refused, naming both: %q", why) + } + if strings.Contains(why, "sudo") { + t.Errorf("a Windows refusal names sudo: %q", why) + } + } + if r := serveActFor(other, agentAskRequest{Act: "deny", ID: "abcd1234"}); r.OK || r.User != other.Name { + t.Errorf("another account's act: %+v", r) + } + // A record of the other platform's kind is no owner here. + uid := uint32(1000) + raw, _ := json.Marshal(approvalsOwnerRecord{UID: &uid, Account: "someone"}) + if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil { + t.Fatal(err) + } + if why := readLaneRefusalFor(owner); why == "" { + t.Error("a uid record let a Windows SID through") + } +} + +// One dispatcher answers both lanes, so the pipe answers what the socket does. +func TestAskAnswerIsTheSocketsAnswer(t *testing.T) { + isolateConfigDir(t) + peer := askPeer{SID: testOwnerSID, Name: "owner"} + var extended []time.Duration + extend := func(d time.Duration) { extended = append(extended, d) } + if r, ok := askAnswer(peer, agentAskRequest{Op: "read", Read: "approvals"}, extend, func() int { return 0 }).(agentReadReply); !ok || r.OK { + t.Errorf("a read with no owner recorded: %+v", r) + } + if r, ok := askAnswer(peer, agentAskRequest{Op: "console"}, extend, func() int { return 0 }).(agentAskReply); !ok || r.OK || r.Why == "" { + t.Errorf("the console with no owner recorded: %+v", r) + } + pid := 0 + if r, ok := askAnswer(peer, agentAskRequest{Op: "stop", PID: 4242}, extend, func() int { pid = 7; return 7 }).(agentAskReply); !ok || r.OK { + t.Errorf("a stop with no code set up: %+v", r) + } + if pid != 7 { + t.Error("a stop was filed without asking which process asked") + } + if len(extended) != 1 || extended[0] != readDeadline { + t.Errorf("deadlines given: %v", extended) + } +} + +func TestAdminCommandsAreEachPlatformsOwn(t *testing.T) { + if got := adminCommandFor(true, "ui"); strings.Contains(got, "sudo") || !strings.Contains(got, "Administrator terminal") { + t.Errorf("windows: %q", got) + } + if got := adminCommandFor(false, "ui"); got != "sudo -H privatae-bastion ui" { + t.Errorf("unix: %q", got) + } + if !strings.Contains(askPipeSDDL, "0x12019b;;;AU") || strings.Contains(askPipeSDDL, "GW;;;AU") || strings.Contains(askPipeSDDL, "GA;;;AU") { + t.Errorf("the ask pipe's ACL gives every account more than read and write: %s", askPipeSDDL) + } +} + +// A page with no session names the command that opens it on this machine. +// The machine's console is not opened by "privatae-bastion ui" from just any +// account, and telling a person only that sent them back to a 401. +func TestNotSignedInNamesTheCommandThatOpensThisConsole(t *testing.T) { + if got := notSignedIn("ui", "windows", true); !strings.Contains(got, "Administrator terminal") || + !strings.Contains(got, "owns this machine") || strings.Contains(got, "sudo") { + t.Errorf("the Windows service's console: %q", got) + } + if got := notSignedIn("ui", "linux", true); !strings.Contains(got, "sudo -H privatae-bastion ui") || + !strings.Contains(got, "owns this machine") { + t.Errorf("the root bastion's console: %q", got) + } + if got := notSignedIn("ui", "windows", false); !strings.Contains(got, "`privatae-bastion ui`") || strings.Contains(got, "Administrator") { + t.Errorf("an account's own bastion: %q", got) + } + err := uiKeyRefused("127.0.0.1:7880", "401 Unauthorized", "windows", true, false) + if s := err.Error(); !strings.Contains(s, winServiceName) || strings.Contains(s, "sudo") || strings.Contains(s, "restart it") { + t.Errorf("a 401 beside the Windows service: %q", s) + } +} + +// A second start says what is running and how to reach it, and never that it +// should be ended: ending it was what the old words sent a person to do. +// +// MUTATION: drop the PID from secondRunWords. RESULT: red. +func TestASecondStartNamesTheRunningBastion(t *testing.T) { + task := runHolder{PID: 4242, Program: `C:\Users\someone\AppData\Local\Programs\privatae-bastion\privatae-bastion.exe`, + Started: "2026-11-02T18:02:00Z"} + console, dialog := secondRunWords(task, true) + for _, want := range []string{"4242", task.Program, "privatae-bastion ui", "Stop-Process -Id 4242"} { + if !strings.Contains(console, want) || !strings.Contains(dialog.Body, want) { + t.Errorf("the refusal does not say %q:\n%s\n%s", want, console, dialog.Body) + } + } + svc, _ := secondRunWords(runHolder{PID: 9, Service: true}, true) + if !strings.Contains(svc, "as the "+winServiceName+" service") || !strings.Contains(svc, "Restart-Service "+winServiceName) { + t.Errorf("the service: %s", svc) + } + old, _ := secondRunWords(runHolder{}, false) + if !strings.Contains(old, "Get-Process privatae-bastion") { + t.Errorf("an older bastion holding the lock: %s", old) + } + for _, s := range []string{console, svc, old, dialog.Body} { + if strings.Contains(s, "Task Manager") || strings.Contains(s, "believe the running one is dead") { + t.Errorf("the refusal sends the person to end the running bastion: %s", s) + } + } + dir := t.TempDir() + writeRunHolder(dir, true) + if h, ok := readRunHolder(dir); !ok || h.PID != os.Getpid() || !h.Console { + t.Errorf("the holder record: %+v, %v", h, ok) + } +} + +// A bastion relaunched by its predecessor's update waits for it to exit and +// then takes the lock, where it used to refuse as a second copy and leave the +// machine with none. +func TestARelaunchedBastionWaitsForThePredecessorsLock(t *testing.T) { + clock := time.Unix(0, 0) + now := func() time.Time { return clock } + sleep := func(d time.Duration) { clock = clock.Add(d) } + held := 3 + try := func() error { + if held > 0 { + held-- + return os.ErrExist + } + return nil + } + waited := 0 + waitExit := func(pid int, d time.Duration) { waited = pid } + if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err != nil { + t.Fatalf("the lock was not taken once the predecessor let go: %v", err) + } + if waited != 4242 { + t.Errorf("waited on process %d", waited) + } + held = 1 << 30 + if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err == nil { + t.Fatal("a lock held for ever was taken") + } + held, waited = 1, 0 + if err := takeRunLock(try, 0, waitExit, 30*time.Second, now, sleep); err == nil || waited != 0 { + t.Errorf("a start that replaces nothing waited, or retried: %v, %d", err, waited) + } + t.Setenv(relaunchOfEnv, "4242") + if got := relaunchOf(); got != 4242 { + t.Errorf("relaunchOf = %d", got) + } + if _, set := os.LookupEnv(relaunchOfEnv); set { + t.Error("the predecessor's pid would pass on to what this bastion starts") + } +} + +// The Windows files wire it together: they are not built on Linux, so this +// reads them, as TestServiceModeOpensNothingOnTheDesktop does. +func TestTheWindowsFilesWireTheLaneTheCarryAndTheRelaunchWait(t *testing.T) { + for file, wants := range map[string][]string{ + "relaunch_windows.go": {`relaunchOfEnv+"="+strconv.Itoa(os.Getpid())`}, + "runguard_windows.go": {"takeRunLock(", "relaunchOf()", "writeRunHolder(d,", "secondRunWords("}, + "sysservice_windows.go": {"carryStateInto(from, to)", "serviceCarrySource(serviceCarryFromSID)"}, + "asklane_windows.go": {"askAnswer(peer, req", "pipeServerIsService(c.h)", "askPipeSDDL"}, + "agentask.go": {"startAskPipe(done)"}, + "../deploy/install.ps1": {"service install --from-account $owner"}, + } { + src, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + for _, w := range wants { + if !strings.Contains(string(src), w) { + t.Errorf("%s no longer has %s", file, w) + } + } + } + // The carry runs before the service's first start, not after it. + src, _ := os.ReadFile("sysservice_windows.go") + s := string(src) + if c, st := strings.Index(s, "carryStateInto(from, to)"), strings.Index(s, "s.Start()"); c < 0 || st < 0 || c > st { + t.Error("the carry is not before the service starts, so the service would start with no pairing") + } +} + +// A process that started after the connection was made is not the one that +// made it: its id was freed and reused, and answering it would answer a +// stranger as whoever holds that id now. +func TestAPipeClientStartedAfterItsConnectionIsNotTheCaller(t *testing.T) { + connected := time.Unix(1000, 0) + if !pipeClientNewer(connected.Add(time.Millisecond), connected) { + t.Error("a process created after the connection was taken for the caller") + } + if pipeClientNewer(connected.Add(-time.Hour), connected) { + t.Error("the process that connected was refused") + } +} diff --git a/client-go/consolelane.go b/client-go/consolelane.go index 0e944f9..940812f 100644 --- a/client-go/consolelane.go +++ b/client-go/consolelane.go @@ -1,7 +1,8 @@ package main // consolelane.go — the machine owner's own account opens the console of the -// bastion installed for the whole machine, without sudo (Linux and macOS). +// bastion installed for the whole machine, without sudo on Linux and macOS +// and without an Administrator terminal on Windows. // // THE PROBLEM. Installed for the whole machine, the bastion runs as root and // its console signs in with root's ui.secret. `privatae-bastion ui` run by the @@ -9,7 +10,8 @@ package main // account's key, so the person reached their own machine's console only // through sudo, and a browser started by sudo is root's, not theirs. // -// THE LANE. ask.sock (agentask.go) already tells the daemon, from the kernel, +// THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe +// (asklane_windows.go), already tells the daemon, from the operating system, // which account connected. Asked for "console" by the account recorded as the // machine's owner (owner_account.go), the daemon mints a one-use ticket of a // LIMITED kind and answers with the link; `ui` opens it in the person's own @@ -21,17 +23,18 @@ package main // read on this install. It is refused what widens access with no code today: // pairing a Host, setting up or rotating the owner's code, choosing the Host // in charge, proposing a folder, and the approval device's approvals. Those -// stay with the administrator (`sudo -H privatae-bastion ui`). Codes typed for +// stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an +// Administrator terminal on Windows). Codes typed for // a Host's requests in a limited session are throttled as the owner's are // (machineCodeSpend), because guessing is the only attack left there. import ( "errors" + "fmt" "net/http" "net/url" "os" "runtime" - "strconv" "sync/atomic" ) @@ -45,21 +48,27 @@ var consoleLaneServer atomic.Pointer[uiServer] // consoleLaneRefusal says why account uid may not open the console through // the lane, or "" when it may. func consoleLaneRefusal(uid uint32, who string) string { - owner, ownerName, present, err := ownerAccountLoad() + return consoleLaneRefusalFor(askPeer{UID: uid, Name: who}) +} + +// consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform. +func consoleLaneRefusalFor(p askPeer) string { + admin := adminCommandFor(p.windows(), "ui") + match, ownerName, present, err := laneOwnerMatch(p) switch { case err != nil: return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " + - "the console with: sudo -H privatae-bastion ui" + "the console with: " + admin case !present: return "no account is recorded as this machine's owner, so the console opens only for the administrator: " + - "sudo -H privatae-bastion ui. To open it from your own account, the administrator records it once: " + - "sudo -H privatae-bastion approvals-owner --account " + accountForHint(who) - case owner != uid: - name := ownerName - if name == "" { - name = "uid " + strconv.FormatUint(uint64(owner), 10) + admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p) + case !match: + without := "without sudo" + if p.windows() { + without = "without an Administrator terminal" } - return "this machine's console opens without sudo only for its owner's account (" + name + "), not " + who + return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " + + p.Name + ". The administrator opens it with: " + admin } return "" } @@ -74,7 +83,11 @@ func accountForHint(who string) string { // consoleLaneEntry is what the daemon answers an owner's "console" ask with: // the link that redeems a fresh limited ticket. func consoleLaneEntry(uid uint32, who string) (string, error) { - if why := consoleLaneRefusal(uid, who); why != "" { + return consoleLaneEntryFor(askPeer{UID: uid, Name: who}) +} + +func consoleLaneEntryFor(p askPeer) (string, error) { + if why := consoleLaneRefusalFor(p); why != "" { return "", errors.New(why) } u := consoleLaneServer.Load() @@ -104,8 +117,9 @@ func (u *uiServer) signedIn(r *http.Request) bool { return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok)) } -const consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " + - "with no code, or reads beyond your own account. The machine's administrator does it: sudo -H privatae-bastion ui" +var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " + + "with no code, or reads beyond your own account. The machine's administrator does it: " + + adminCommandFor(runtime.GOOS == "windows", "ui") // adminOnly wraps a route a limited session may not use. func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc { @@ -120,32 +134,54 @@ func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc { // consoleEntryForThisAccount is the link `ui` opens. An account with a // bastion of its own signs in with its own key (localUIEntryURL). An ordinary -// account beside the machine's root bastion asks the lane, and is told why -// not when it is not the owner's. +// account beside the machine's bastion asks the lane, and is told why not +// when it is not the owner's. func consoleEntryForThisAccount() (entry, addr string, err error) { - if runtime.GOOS != "windows" && os.Geteuid() != 0 && !uiDaemonListening() { + if runtime.GOOS == "windows" { + if systemService.installed && !machineAuthorityHeld() { + return consoleEntryThroughLane() + } + return localUIEntryURL() + } + if os.Geteuid() != 0 && !uiDaemonListening() { p := askSockPath() if why := contextSockDialable(p); why != "" { return "", "", errors.New(why) } if _, statErr := os.Stat(p); statErr == nil { - r, err := askViaSock(agentAskRequest{Op: "console"}) - if err != nil { - return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error()) - } - if !r.OK { - return "", "", errors.New(r.Why) - } - u, err := url.Parse(r.URL) - if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) { - return "", "", errors.New("this machine's bastion answered with a link that is not its own console") - } - return r.URL, u.Host, nil + return consoleEntryThroughLane() } } return localUIEntryURL() } +// consoleEntryThroughLane asks the machine's bastion for the owner's link. +func consoleEntryThroughLane() (entry, addr string, err error) { + p := askLaneAddr(askSockPath()) + r, err := askViaSock(agentAskRequest{Op: "console"}) + if err != nil { + if runtime.GOOS == "windows" { + // The service is there (systemService.installed) but its pipe is + // not: a service older than this command, or one not running. + return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+ + "account's request for its console on %s (%v).\n"+ + " If the service is not running, start it: %s\n"+ + " If it is older than this command, update it by running the installer again.\n"+ + " Either way the administrator can open the console now: %s", + winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui")) + } + return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error()) + } + if !r.OK { + return "", "", errors.New(r.Why) + } + u, err := url.Parse(r.URL) + if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) { + return "", "", errors.New("this machine's bastion answered with a link that is not its own console") + } + return r.URL, u.Host, nil +} + // uiLimitedWriter marks a response as drawn for a limited session, so a page // shows the administrator's command in place of a form the session would be // refused (Settings, Approvals). It changes what is drawn, never what is diff --git a/client-go/notify.go b/client-go/notify.go index 283e331..caef9a0 100644 --- a/client-go/notify.go +++ b/client-go/notify.go @@ -362,26 +362,3 @@ func utf16Len(s string) int { } return n } - -// secondRunNotice is runguard_windows.go's refusal, written for a dialog box -// rather than for a terminal. -// -// It is deliberately NOT the same string as the error that goes to stderr. That -// one is laid out for a console — indented continuation lines, "you can close -// this window" meaning the console window — and the two media want different -// words for the same fact. They live next to each other (runguard_windows.go) so -// they are maintained together. -// -// The length caps above do NOT apply here: MessageBoxW takes an ordinary -// null-terminated string with no fixed buffer, unlike the notification-area -// struct. -func secondRunNotice() uiNotice { - return uiNotice{ - Title: "Privatae bastion is already running", - Body: "This machine is already serving your hosts.\r\n\r\n" + - "A second copy would fight the first one for the same local ports, so this " + - "one is stopping. Nothing is wrong and nothing has stopped working.\r\n\r\n" + - "If you believe the running one is dead, end privatae-bastion.exe in Task " + - "Manager and start this again.", - } -} diff --git a/client-go/owneracts.go b/client-go/owneracts.go index f0045f8..e190f1e 100644 --- a/client-go/owneracts.go +++ b/client-go/owneracts.go @@ -28,7 +28,6 @@ import ( "errors" "fmt" "io" - "net" "os" "strings" "time" @@ -47,8 +46,13 @@ type agentActReply struct { var actNow = time.Now func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) { + return serveActFor(askPeer{UID: uid, Name: user}, req) +} + +func serveActFor(peer askPeer, req agentAskRequest) (r agentActReply) { + user := peer.Name r.User = user - if why := readLaneRefusal(uid, user); why != "" { + if why := readLaneRefusalFor(peer); why != "" { r.Why = actWording(why) return r } @@ -65,7 +69,7 @@ func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) { r.Failed, r.Why = true, err.Error() return r } - ownerActAudit(what, user) + ownerActAudit(what, user, peer.windows()) r.OK, r.Done = true, done return r } @@ -194,21 +198,25 @@ func switchDoneText(host, family string, off bool) string { // factor directory is made if it is not there yet (a deny or a switch off // needs no code set up): dcnAuditWith joins its directory onto audit.jsonl, // and "" would be the daemon's working directory. -func ownerActAudit(what, user string) { +func ownerActAudit(what, user string, pipe bool) { dir, err := machineFactorDir() if err != nil { uilogf("owner act not audited (%v): %s, from %s\n", err, what, user) return } + lane := "its ask socket" + if pipe { + lane = "its ask pipe" + } dcnAuditWith(dir, "owner_lane_act", 0, "", - what+", from the owner's account "+user+" through its ask socket", nil) + what+", from the owner's account "+user+" through "+lane, nil) } // ── the asking side ───────────────────────────────────────────────────────── func actViaSock(req agentAskRequest) (string, error) { - p := readSockPath() - c, err := net.DialTimeout("unix", p, 3*time.Second) + p := askLaneAddr(readSockPath()) + c, err := dialAskLane(readSockPath()) if err != nil { return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"} } diff --git a/client-go/reads.go b/client-go/reads.go index 3a4f534..5f6141f 100644 --- a/client-go/reads.go +++ b/client-go/reads.go @@ -31,7 +31,6 @@ import ( "errors" "fmt" "io" - "net" "os" "runtime" "runtime/debug" @@ -99,8 +98,8 @@ var machineReads = map[string]machineRead{ // readsGoThroughLane: the machine's bastion is a system service this account // does not administer, so its state is not this process's to read. var readsGoThroughLane = func() bool { - if runtime.GOOS == "windows" || machineAuthorityHeld() { - return false // no ask pipe on Windows yet; the administrator reads directly + if machineAuthorityHeld() { + return false // the administrator reads the service's state directly } if runtime.GOOS == "linux" { return linuxSystemUnitPresent() @@ -156,7 +155,7 @@ func laneRefusalError(cmd string, err error) error { if !errors.As(err, &le) { return err } - return fmt.Errorf("%s\n As the administrator: sudo -H privatae-bastion %s", le.why, cmd) + return fmt.Errorf("%s\n As the administrator: %s", le.why, adminCommandFor(runtime.GOOS == "windows", cmd)) } // ── the asking side ───────────────────────────────────────────────────────── @@ -166,8 +165,8 @@ func laneRefusalError(cmd string, err error) error { var readSockPath = askSockPath func readViaSock(name string, o readOpts, v any) error { - p := readSockPath() - c, err := net.DialTimeout("unix", p, 3*time.Second) + p := askLaneAddr(readSockPath()) + c, err := dialAskLane(readSockPath()) if err != nil { return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"} } @@ -207,24 +206,20 @@ type agentReadReply struct { View json.RawMessage `json:"view,omitempty"` } -// readLaneRefusal says why account uid may not read through the lane, or "". +// readLaneRefusalFor says why peer may not read through the lane, or "". // The owner's account only: the agents, folders and prompts of every // account's sessions are the owner's to see, not another account's. -func readLaneRefusal(uid uint32, who string) string { - owner, ownerName, present, err := ownerAccountLoad() +func readLaneRefusalFor(p askPeer) string { + match, ownerName, present, err := laneOwnerMatch(p) switch { case err != nil: return "this machine's owner account cannot be read (" + err.Error() + ")" case !present: return "no account is recorded as this machine's owner, so its bastion answers reads only for the " + "administrator. To read from your own account, the administrator records it once: " + - "sudo -H privatae-bastion approvals-owner --account " + accountForHint(who) - case owner != uid: - name := ownerName - if name == "" { - name = "uid " + strconv.FormatUint(uint64(owner), 10) - } - return "this machine's bastion answers reads only for its owner's account (" + name + "), not " + who + ownerRecordCommand(p) + case !match: + return "this machine's bastion answers reads only for its owner's account (" + ownerName + "), not " + p.Name } return "" } @@ -249,8 +244,12 @@ var readNow = time.Now var readSlots = make(chan struct{}, 4) func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply { - r := agentReadReply{User: user} - if why := readLaneRefusal(uid, user); why != "" { + return serveReadFor(askPeer{UID: uid, Name: user}, req) +} + +func serveReadFor(p askPeer, req agentAskRequest) agentReadReply { + r := agentReadReply{User: p.Name} + if why := readLaneRefusalFor(p); why != "" { r.Why = why return r } diff --git a/client-go/reads_slice3_linux_test.go b/client-go/reads_slice3_linux_test.go index a116ae3..779645f 100644 --- a/client-go/reads_slice3_linux_test.go +++ b/client-go/reads_slice3_linux_test.go @@ -67,7 +67,7 @@ func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) { // `switch --list` passes the guard beside the system unit, as status does; // `switch` does too since the owner's account switches through the lane -// (owneracts.go), and only on Windows is it still refused here. +// (owneracts.go), and on Windows as well since the ask pipe. func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) { prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority t.Cleanup(func() { os.Args, linuxSystemUnitPresent, systemService.authority = prevArgs, prevUnit, prevAuth }) @@ -81,7 +81,7 @@ func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) { if err := guardServiceFork("switch"); err != nil { t.Errorf("switch from the owner's account beside the system unit, before the lane could decide: %v", err) } - if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err == nil { - t.Error("switch from an ordinary account beside a Windows service was let through") + if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err != nil { + t.Errorf("switch from the owner's account beside a Windows service, before the ask pipe could decide: %v", err) } } diff --git a/client-go/reads_slice3_test.go b/client-go/reads_slice3_test.go index 5da8742..24368f7 100644 --- a/client-go/reads_slice3_test.go +++ b/client-go/reads_slice3_test.go @@ -239,17 +239,17 @@ func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) { } } -// doctor from an ordinary account beside a Windows system service would check -// that account's own empty bastion and report it as the machine's (not -// running, no owner, no factor), so it is refused there as status is; on -// Linux and macOS it reads the machine's bastion through the lane. +// doctor from an ordinary account beside a system service would, run +// directly, check that account's own empty bastion and report it as the +// machine's (not running, no owner, no factor). So it is a lane read, as +// status is: through ask.sock on Linux and macOS, the ask pipe on Windows. // -// MUTATION: drop "doctor" from serviceGuardedCommands. RESULT: red on windows. +// MUTATION: drop "doctor" from serviceLaneReads. RESULT: red on every system. func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) { - if err := serviceGuard("windows", "doctor", true, false); err == nil { - t.Error("doctor from an ordinary account beside a Windows service was let through") + if !serviceGuardedCommands["doctor"] || !serviceLaneReads["doctor"] { + t.Error("doctor is not both guarded and a lane read, so it would check an ordinary account's own bastion") } - for _, goos := range []string{"linux", "darwin"} { + for _, goos := range []string{"linux", "darwin", "windows"} { if err := serviceGuard(goos, "doctor", true, false); err != nil { t.Errorf("%s: doctor beside the service is a lane read: %v", goos, err) } @@ -488,9 +488,9 @@ func TestAnAgentsFolderNeverReachesTheTerminalRaw(t *testing.T) { } } -// On Windows, where nothing goes over the socket, the slice-3 reads from an -// ordinary account beside the service are refused as status is; on Linux and -// macOS they ask the machine's bastion. The rest of backup is not guarded. +// The slice-3 reads from an ordinary account beside the service ask the +// machine's bastion over its lane on every system (the ask pipe on Windows), +// so the guard lets them by. The rest of backup is not guarded. func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) { for _, line := range [][]string{ {"privatae-bastion", "ops", "--json"}, @@ -500,10 +500,10 @@ func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) { {"privatae-bastion", "backup", "key", "show"}, } { key := serviceGuardKey(line[1], line) - if err := serviceGuard("windows", key, true, false); err == nil { - t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:]) + if !serviceGuardedCommands[key] || !serviceLaneReads[key] { + t.Errorf("%v is not both guarded and a lane read", line[1:]) } - for _, goos := range []string{"linux", "darwin"} { + for _, goos := range []string{"linux", "darwin", "windows"} { if err := serviceGuard(goos, key, true, false); err != nil { t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err) } diff --git a/client-go/relaunch_windows.go b/client-go/relaunch_windows.go index 8b3f65b..38f407d 100644 --- a/client-go/relaunch_windows.go +++ b/client-go/relaunch_windows.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "os/exec" + "strconv" "golang.org/x/sys/windows" ) @@ -53,6 +54,10 @@ func relaunchAfterUpdate() { return } cmd := exec.Command(self, os.Args[1:]...) + // The new bastion waits for this one to exit before it takes the run + // lock (runholder.go takeRunLock); without this it could find the lock + // still held, refuse as a second copy, and leave the machine with none. + cmd.Env = append(os.Environ(), relaunchOfEnv+"="+strconv.Itoa(os.Getpid())) cmd.SysProcAttr = &windows.SysProcAttr{ CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, } diff --git a/client-go/runguard_windows.go b/client-go/runguard_windows.go index da48387..af65904 100644 --- a/client-go/runguard_windows.go +++ b/client-go/runguard_windows.go @@ -8,6 +8,9 @@ import ( "os" "path/filepath" "sync/atomic" + "time" + + "golang.org/x/sys/windows" ) // runGuardFile holds the "one running bastion" lock for the life of this @@ -72,8 +75,8 @@ func refuseVisibly(shown uiNotice, err error) error { } // cannotStart is the wording for the two I/O failures below. They have no -// prepared words the way the already-running case does (secondRunNotice, -// notify.go), and the error itself is the most accurate thing there is to say: +// prepared words the way the already-running case does (secondRunWords, +// runholder.go), and the error itself is the most accurate thing there is to say: // both mean the config directory is unusable, which is not a state anyone can be // given advice about from here. func cannotStart(err error) uiNotice { @@ -90,19 +93,36 @@ func guardSingleRun() error { err = fmt.Errorf("cannot open the run lock: %w", err) return refuseVisibly(cannotStart(err), err) } - if err := flockFileNB(f); err != nil { + // Relaunched by the self-update of the bastion this one replaces, which + // holds the lock until it has exited (runholder.go). + if err := takeRunLock(lockTry(f), relaunchOf(), waitProcessExit, + 30*time.Second, time.Now, time.Sleep); err != nil { _ = f.Close() // The dialog and the console get DIFFERENT words for the same fact, and - // that is on purpose: this text is laid out for a terminal — indented - // continuation lines, and "you can close this window" meaning the console - // window it is being printed into. secondRunNotice says it for a dialog. - return refuseVisibly(secondRunNotice(), errors.New( - "a bastion is already running on this machine — that one is serving "+ - "your hosts, and a second would fight it for the same local ports.\n"+ - " Nothing is wrong: you can close this window.\n"+ - " If you believe the running one is dead, stop it (Task Manager → "+ - "privatae-bastion.exe) and run this again")) + // that is on purpose: the console's are laid out for a terminal, with + // indented continuation lines (secondRunWords). + h, known := readRunHolder(d) + text, notice := secondRunWords(h, known) + return refuseVisibly(notice, errors.New(text)) } runGuardFile.Store(f) + writeRunHolder(d, stdinIsTerminal()) return nil } + +// lockTry is one attempt at f's lock. Outside guardSingleRun, whose every +// return is a refusal shown to the person (TestEveryRunGuardRefusalIsVisible). +func lockTry(f *os.File) func() error { + return func() error { return flockFileNB(f) } +} + +// waitProcessExit waits up to d for process pid to exit. A process that +// cannot be opened has exited already, or is not ours to wait on. +func waitProcessExit(pid int, d time.Duration) { + h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) + if err != nil { + return + } + defer windows.CloseHandle(h) + _, _ = windows.WaitForSingleObject(h, uint32(d/time.Millisecond)) +} diff --git a/client-go/runholder.go b/client-go/runholder.go new file mode 100644 index 0000000..946a478 --- /dev/null +++ b/client-go/runholder.go @@ -0,0 +1,141 @@ +package main + +// runholder.go — what a second start on Windows is told about the first. +// +// A bastion that finds its run lock held (runguard_windows.go) used to say +// only that one was running, and that if it seemed dead it could be ended in +// Task Manager. On a machine where `privatae-bastion ui` then failed, that +// was the one thing to try, so a running bastion was killed for a symptom +// that was not its. The bastion that holds the lock now writes who it is +// beside it, and the refusal names it: its process, its program, how it was +// started, and the commands that open, check and restart it. +// +// And the relaunch after a self-update (relaunch_windows.go) starts the new +// binary while the old one still holds the lock, a few milliseconds from +// exiting. The new one waits for it (takeRunLock) rather than refusing and +// leaving the machine with no bastion at all. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "time" +) + +type runHolder struct { + PID int `json:"pid"` + Program string `json:"program,omitempty"` + Started string `json:"started,omitempty"` // RFC3339 + Service bool `json:"service,omitempty"` // the Windows service + Console bool `json:"console,omitempty"` // started from a terminal +} + +func runHolderPath(dir string) string { return filepath.Join(dir, "run.holder.json") } + +// writeRunHolder records this process as the lock's holder. Best effort: a +// refusal without it is only less specific. +func writeRunHolder(dir string, console bool) { + h := runHolder{PID: os.Getpid(), Started: time.Now().UTC().Format(time.RFC3339), + Service: runningAsService, Console: console} + if exe, err := os.Executable(); err == nil { + h.Program = exe + } + if raw, err := json.Marshal(h); err == nil { + _ = atomicWrite(runHolderPath(dir), raw) + } +} + +func readRunHolder(dir string) (runHolder, bool) { + var h runHolder + raw, err := os.ReadFile(runHolderPath(dir)) + if err != nil || json.Unmarshal(raw, &h) != nil || h.PID <= 0 { + return runHolder{}, false + } + return h, true +} + +// secondRunWords is the refusal of a second start, for the terminal and for +// the dialog a start with no console shows. known is false +// when the running bastion left no record (an older version holds the lock). +func secondRunWords(h runHolder, known bool) (console string, dialog uiNotice) { + what := "A bastion is already running on this machine" + restart := "Get-Process privatae-bastion | Format-List Id, Path, StartTime shows it; " + + "Stop-Process -Id , then Start-ScheduledTask -TaskName " + winServiceName + if known { + how := "in the background (its sign-in task, or itself after an update)" + switch { + case h.Service: + how = "as the " + winServiceName + " service" + case h.Console: + how = "from a terminal" + } + what += " (process " + strconv.Itoa(h.PID) + if h.Program != "" { + what += ", " + h.Program + } + if t, err := time.Parse(time.RFC3339, h.Started); err == nil { + what += ", started " + t.Local().Format("2 Jan 15:04") + } + what += ", " + how + ")" + switch { + case h.Service: + restart = "Restart-Service " + winServiceName + " (from an Administrator terminal)" + default: + restart = "Stop-Process -Id " + strconv.Itoa(h.PID) + ", then start it again: Start-ScheduledTask -TaskName " + + winServiceName + " (or privatae-bastion run)" + } + } + console = fmt.Sprintf("%s.\n"+ + " It is the one serving your Hosts, and a second copy would fight it for the same local ports,\n"+ + " so this one is stopping. Nothing is wrong, and nothing needs ending.\n"+ + " To open it: privatae-bastion ui\n"+ + " To check it: privatae-bastion status\n"+ + " To restart it: %s", what, restart) + dialog = uiNotice{ + Title: "Privatae bastion is already running", + Body: what + ".\r\n\r\n" + + "It is the one serving your Hosts. A second copy would fight it for the same local ports, so this one " + + "is stopping. Nothing is wrong, and nothing needs ending.\r\n\r\n" + + "To open it, run: privatae-bastion ui\r\n" + + "To restart it: " + restart, + } + return console, dialog +} + +// relaunchOfEnv names, in the environment of a bastion started by its +// predecessor's self-update, the predecessor's process id. +const relaunchOfEnv = "PRIVATAE_BASTION_RELAUNCH_OF" + +// takeRunLock takes the run lock with try. A bastion relaunched by its +// predecessor (replaces > 0) first waits up to patience for that process to +// exit (waitExit), then tries again until patience has passed: the lock is +// released as the predecessor's handles close, a moment after it exits. +func takeRunLock(try func() error, replaces int, waitExit func(pid int, d time.Duration), patience time.Duration, + now func() time.Time, sleep func(time.Duration)) error { + if replaces <= 0 { + return try() + } + deadline := now().Add(patience) + waitExit(replaces, patience) + for { + err := try() + if err == nil || !now().Before(deadline) { + return err + } + sleep(200 * time.Millisecond) + } +} + +// relaunchOf reads relaunchOfEnv once and clears it, so nothing this process +// starts inherits it. +func relaunchOf() int { + v := os.Getenv(relaunchOfEnv) + _ = os.Unsetenv(relaunchOfEnv) + pid, err := strconv.Atoi(v) + if err != nil || pid <= 0 { + return 0 + } + return pid +} diff --git a/client-go/servicecarry.go b/client-go/servicecarry.go new file mode 100644 index 0000000..589dde4 --- /dev/null +++ b/client-go/servicecarry.go @@ -0,0 +1,387 @@ +package main + +// servicecarry.go — an install that moves a machine's bastion from an account +// to the system service brings that bastion's state with it. +// +// THE PROBLEM. A bastion installed for one account keeps its pairings, keys, +// grants and audit under that account's profile. The Windows service keeps +// its own under LocalSystem's (sysservice.go). Until this, the switch started +// the service with empty state: the per-user bastion was stopped and removed +// by the installer, the service had no pairing, and the machine went silent to +// every Host it served until someone paired it again by hand. The installer's +// header said so; nothing else did. +// +// THE CARRY. `service install` (elevated) moves the account's state folder +// into the service's before the service starts, when the service has no +// pairing of its own yet: +// +// - only then: a service that already serves a Host is never merged into; +// - not while a bastion still runs from that folder (its run lock is held, +// waited for a moment), and the lock is held through the copy, the check +// and the emptying, so none starts in the middle; +// - a file the service already has is kept, the service's own; where the +// account's version differs, it is kept beside it (carryAsideSuffix); +// - every path in the carried JSON that names the old folder is rewritten +// to the new one (a pairing's context, DCN and backup folders); +// - every file written is read back and compared, and every pairing must +// be in the service's folder, before anything is removed; a copy that +// fails takes back what it wrote, so a later install carries it again; +// - then the account's folder is removed, so the pairing's keys and bearer +// no longer sit where any program running as that account can read them. +// One that will not go is said, and the service still starts. +// +// The decisions are here, untagged, so the suite runs them on every platform; +// finding the account's folder is in sysservice_windows.go. + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "runtime" + "sort" + "strconv" + "strings" + "time" +) + +// The carry's file operations, so the suite can fail one part way. +var ( + carryWriteFile = os.WriteFile + carryRemoveAll = os.RemoveAll +) + +// carryAsideSuffix names the account's version of a file the service already +// had, kept beside the service's own: the account's folder is removed after +// the carry, and its version would go with it. +const carryAsideSuffix = ".from-account" + +// carryLockPatience is how long the carry waits for the account's run lock. +// The installer stops the account's bastion just before (Stop-Process), and +// Windows releases a killed process's file locks a moment after it is gone. +var carryLockPatience = 10 * time.Second + +// carrySkip are the files that belong to a running bastion, not to its state. +var carrySkip = map[string]bool{"run.lock": true, "run.holder.json": true, "pairings.lock": true, + "ui.addr": true, "bastion.log": true, "bastion.log.1": true} + +// carryResult is what a carry did. +type carryResult struct { + Hosts []string // the pairings carried, by Host + Kept int // files the service already had, left as they were + // Aside are the files the service already had whose account version + // differed; that version is beside each, named with carryAsideSuffix. + Aside []string + Note string // why nothing was carried, when nothing was + // Left: the pairings are in the service, but the account's folder could + // not be removed, and still holds copies of their keys. + Left string +} + +// pairingHosts lists the Hosts dir/pairings holds a pairing for. A Host's +// signed grant beside it (pairingGrantSuffix) is not a pairing (loadPairings). +func pairingHosts(dir string) ([]string, error) { + ents, err := os.ReadDir(filepath.Join(dir, "pairings")) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var hosts []string + for _, e := range ents { + if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") && !strings.HasSuffix(e.Name(), pairingGrantSuffix) { + hosts = append(hosts, strings.TrimSuffix(e.Name(), ".json")) + } + } + sort.Strings(hosts) + return hosts, nil +} + +// carryLock takes the account's run lock, waiting up to carryLockPatience for +// a bastion that is exiting to let it go. +func carryLock(f *os.File) error { + deadline := time.Now().Add(carryLockPatience) + for { + err := flockFileNB(f) + if err == nil || !time.Now().Before(deadline) { + return err + } + time.Sleep(200 * time.Millisecond) + } +} + +// carryStateInto moves the bastion state at from into to (see the header). +func carryStateInto(from, to string) (carryResult, error) { + var res carryResult + if from == "" || samePath(from, to) { + return res, nil + } + // Only ever a bastion's own folder: from is removed at the end. + if filepath.Base(filepath.Clean(from)) != "privatae-bastion" { + return res, fmt.Errorf("%s is not a bastion's state folder; nothing was moved", from) + } + hosts, err := pairingHosts(from) + if err != nil { + return res, fmt.Errorf("cannot read the pairings under %s: %w", from, err) + } + if len(hosts) == 0 { + return res, nil + } + have, err := pairingHosts(to) + if err != nil { + return res, fmt.Errorf("cannot read the service's pairings under %s: %w", to, err) + } + if len(have) > 0 { + res.Note = fmt.Sprintf("the service already serves %s, so the pairings under %s (%s) were left where they are", + strings.Join(have, ", "), from, strings.Join(hosts, ", ")) + return res, nil + } + lock, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return res, fmt.Errorf("cannot open the run lock under %s: %w", from, err) + } + if err := carryLock(lock); err != nil { + _ = lock.Close() + return res, fmt.Errorf("a bastion is still running from %s (it holds that folder's run lock), so its pairings "+ + "were not moved into the service. Stop it, then run `privatae-bastion service install` again", from) + } + // Held until only the lock itself is left to remove, so no bastion starts + // from the folder while it is copied, checked and emptied. + locked := true + unlock := func() { + if locked { + _ = lock.Close() + locked = false + } + } + defer unlock() + // A copy that fails takes back every file it wrote: a service left with + // some of the pairings serves a Host, so no later install would carry the + // rest, and the service would serve only those few. + written, kept, aside, err := copyStateTree(from, to) + undo := func() { + for p := range written { + _ = os.Remove(p) + } + } + if err != nil { + undo() + return res, fmt.Errorf("could not copy %s into the service's %s: %w. Nothing was removed from %s", from, to, err, from) + } + for p, want := range written { + got, err := os.ReadFile(p) + if err != nil || !bytes.Equal(got, want) { + undo() + return res, fmt.Errorf("%s did not read back as written; nothing was removed from %s", p, from) + } + } + // Every pairing counted above is in the service's folder, or nothing is + // removed: a source the walk could not enter (a link) copies nothing. + got, err := pairingHosts(to) + if err != nil || strings.Join(got, "\x00") != strings.Join(hosts, "\x00") { + undo() + return res, fmt.Errorf("the service's folder %s holds the pairings %v after the copy, not %v; nothing was "+ + "removed from %s", to, got, hosts, from) + } + res.Hosts, res.Kept, res.Aside = hosts, kept, aside + // From here the service has everything, so a folder that will not go is + // said, not a failure: failing would leave the service stopped. + var leftErr error + ents, err := os.ReadDir(from) + if err != nil { + leftErr = err + } + for _, e := range ents { + if e.Name() == "run.lock" { + continue // open here; Windows will not delete an open file + } + if err := carryRemoveAll(filepath.Join(from, e.Name())); err != nil && leftErr == nil { + leftErr = err + } + } + unlock() + if err := carryRemoveAll(from); err != nil && leftErr == nil { + leftErr = err + } + if leftErr != nil { + res.Left = fmt.Sprintf("%s could not be removed (%v). It still holds copies of the pairings' keys: delete that folder", + from, leftErr) + } + return res, nil +} + +// copyStateTree copies from into to, file by file, and returns what it wrote +// (path to content, so far when it fails), how many files to already had, +// and which of those differed from the account's (see carryAsideSuffix). +// JSON files have every string that names a path under from rewritten to the +// same path under to. The pairing files go last: a service that starts +// meanwhile serves a pairing only once its file is there, and by then +// everything it reads is too. +func copyStateTree(from, to string) (map[string][]byte, int, []string, error) { + type item struct { + src, dst string + name string + } + var first, last []item + written := map[string][]byte{} + err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(from, p) + if err != nil { + return err + } + dst := filepath.Join(to, rel) + switch { + case d.IsDir(): + return os.MkdirAll(dst, 0o700) + case !d.Type().IsRegular(): + return nil // a link or a device is never part of the state + case rel == d.Name() && carrySkip[d.Name()]: + return nil + } + it := item{p, dst, d.Name()} + if filepath.Dir(rel) == "pairings" { + last = append(last, it) + } else { + first = append(first, it) + } + return nil + }) + if err != nil { + return written, 0, nil, err + } + kept := 0 + var aside []string + write := func(p string, data []byte) error { + if err := carryWriteFile(p, data, 0o600); err != nil { + _ = os.Remove(p) + return err + } + written[p] = data + return nil + } + for _, it := range append(first, last...) { + data, err := os.ReadFile(it.src) + if err != nil { + return written, 0, nil, err + } + if strings.HasSuffix(it.name, ".json") { + data = rebaseJSONPaths(data, from, to) + } + if _, err := os.Lstat(it.dst); err == nil { + kept++ + if have, err := os.ReadFile(it.dst); err == nil && bytes.Equal(have, data) { + continue + } + p := it.dst + carryAsideSuffix + if _, err := os.Lstat(p); err == nil { + return written, 0, nil, fmt.Errorf("%s is already there; the account's %s was not put beside the "+ + "service's", p, it.name) + } + if err := write(p, data); err != nil { + return written, 0, nil, err + } + aside = append(aside, p) + continue + } else if !errors.Is(err, fs.ErrNotExist) { + return written, 0, nil, err + } + if err := write(it.dst, data); err != nil { + return written, 0, nil, err + } + } + return written, kept, aside, nil +} + +// rebaseJSONPaths rewrites every string in a JSON document that is from, or a +// path under it, to the same path under to. A document that does not parse is +// returned as it was. +func rebaseJSONPaths(data []byte, from, to string) []byte { + var v any + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := dec.Decode(&v); err != nil { + return data + } + changed := false + var walk func(any) any + walk = func(x any) any { + switch t := x.(type) { + case string: + if r, ok := rebasePath(t, from, to); ok { + changed = true + return r + } + case []any: + for i := range t { + t[i] = walk(t[i]) + } + case map[string]any: + for k := range t { + t[k] = walk(t[k]) + } + } + return x + } + v = walk(v) + if !changed { + return data + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return data + } + return out +} + +// rebasePath is p moved from under from to under to, and whether p was there. +func rebasePath(p, from, to string) (string, bool) { + sep := string(filepath.Separator) + f := strings.TrimRight(from, `\/`) + switch { + case samePath(p, f): + return to, true + case len(p) > len(f) && samePath(p[:len(f)], f) && (p[len(f)] == '\\' || p[len(f)] == '/'): + return strings.TrimRight(to, `\/`) + sep + strings.TrimLeft(p[len(f):], `\/`), true + } + return p, false +} + +// samePath compares two paths as this platform's filesystem does: without +// case on Windows. +func samePath(a, b string) bool { + a, b = filepath.Clean(a), filepath.Clean(b) + if runtime.GOOS == "windows" { + return strings.EqualFold(a, b) + } + return a == b +} + +// carryReport is what `service install` says of a carry. +func carryReport(r carryResult, from string) string { + switch { + case r.Note != "": + return " " + sentenceCase(r.Note) + "." + case len(r.Hosts) == 0: + return "" + } + s := " Moved this machine's pairings into the service from " + from + ": " + strings.Join(r.Hosts, ", ") + + ". They go on serving as they were; nothing needs pairing again." + if r.Kept > 0 { + s += fmt.Sprintf(" %d file(s) the service already had were kept as the service's.", r.Kept) + } + if len(r.Aside) > 0 { + s += " Your account's own version of " + strconv.Itoa(len(r.Aside)) + " of them differed, and is kept " + + "beside it in the service's folder as: " + strings.Join(r.Aside, ", ") + "." + } + if r.Left != "" { + s += "\n " + sentenceCase(r.Left) + "." + } + return s +} diff --git a/client-go/servicecarry_test.go b/client-go/servicecarry_test.go new file mode 100644 index 0000000..54cad04 --- /dev/null +++ b/client-go/servicecarry_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// carryTree is a per-user bastion's state folder as an install finds it. +func carryTree(t *testing.T) (from, to string) { + t.Helper() + root := t.TempDir() + from = filepath.Join(root, "account", "privatae-bastion") + to = filepath.Join(root, "service", "privatae-bastion") + files := map[string]string{ + "pairings/box-one.example.test.json": `{"host_name":"box-one.example.test","bearer":"mcp_test",` + + `"context_root":"` + filepath.Join(from, "context", "box-one.example.test") + `",` + + `"dcn_dir":"` + filepath.Join(from, "dcn", "box-one.example.test") + `",` + + `"backup_root":"/mnt/nas/backups"}`, + "dcn/box-one.example.test/grants.json": `{"folders":["/home/someone/notes"]}`, + "context/box-one.example.test/blob": "context bytes", + "ui.secret": "account secret", + "run.lock": "", + "run.holder.json": `{"pid":1}`, + "ui.addr": "127.0.0.1:7880", + "bastion.log": "a log line", + } + for rel, body := range files { + p := filepath.Join(from, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } + return from, to +} + +// An install that moves the bastion from an account to the service brings +// its pairings with it: the service serves the same Hosts with the same keys, +// the paths that named the account's folder name the service's, nothing that +// belongs to a running bastion comes along, and the account's copy is gone. +// +// MUTATION: return before copyStateTree in carryStateInto. RESULT: red (the +// service has no pairing). Drop the rebaseJSONPaths call. RESULT: red (the +// pairing's context folder is still the account's). +func TestAServiceInstallCarriesTheAccountsPairingsIntoTheService(t *testing.T) { + from, to := carryTree(t) + res, err := carryStateInto(from, to) + if err != nil { + t.Fatal(err) + } + if strings.Join(res.Hosts, ",") != "box-one.example.test" { + t.Fatalf("carried %v", res.Hosts) + } + raw, err := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json")) + if err != nil { + t.Fatalf("the service has no pairing: %v", err) + } + var c Config + if err := json.Unmarshal(raw, &c); err != nil { + t.Fatal(err) + } + if c.Bearer != "mcp_test" || c.HostName != "box-one.example.test" { + t.Errorf("the pairing changed on the way: %+v", c) + } + if c.ContextRoot != filepath.Join(to, "context", "box-one.example.test") || c.DCNDir != filepath.Join(to, "dcn", "box-one.example.test") { + t.Errorf("the pairing's folders still name the account's state: context %q, dcn %q", c.ContextRoot, c.DCNDir) + } + if c.BackupRoot != "/mnt/nas/backups" { + t.Errorf("a path outside the state folder was rewritten: %q", c.BackupRoot) + } + if b, _ := os.ReadFile(filepath.Join(to, "dcn", "box-one.example.test", "grants.json")); !strings.Contains(string(b), "/home/someone/notes") { + t.Errorf("the grants did not come along unchanged: %q", b) + } + if b, _ := os.ReadFile(filepath.Join(to, "context", "box-one.example.test", "blob")); string(b) != "context bytes" { + t.Errorf("the context store did not come along: %q", b) + } + for _, n := range []string{"run.lock", "run.holder.json", "ui.addr", "bastion.log"} { + if _, err := os.Stat(filepath.Join(to, n)); err == nil { + t.Errorf("%s belongs to the running bastion it was carried from, not to its state", n) + } + } + if _, err := os.Stat(from); !os.IsNotExist(err) { + t.Errorf("the account's copy, keys included, is still there: %v", err) + } + if line := carryReport(res, from); !strings.Contains(line, "box-one.example.test") || !strings.Contains(line, "nothing needs pairing again") { + t.Errorf("the install does not say what it carried: %q", line) + } +} + +// A service that already serves a Host is never merged into, and the +// account's state stays where it was. +func TestACarryNeverMergesIntoAServiceThatServesAHost(t *testing.T) { + from, to := carryTree(t) + p := filepath.Join(to, "pairings", "box-two.example.test.json") + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil { + t.Fatal(err) + } + res, err := carryStateInto(from, to) + if err != nil { + t.Fatal(err) + } + if len(res.Hosts) != 0 || !strings.Contains(res.Note, "box-two.example.test") { + t.Fatalf("carried into a serving service: %+v", res) + } + if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil { + t.Errorf("the account's pairing was touched: %v", err) + } + if _, err := os.Stat(filepath.Join(to, "pairings", "box-one.example.test.json")); err == nil { + t.Error("the account's pairing was merged in") + } +} + +// Not while a bastion still runs from the folder: its next write would be +// lost, and two bastions would hold the same keys. +func TestACarryWaitsForTheAccountsBastionToStop(t *testing.T) { + prev := carryLockPatience + t.Cleanup(func() { carryLockPatience = prev }) + carryLockPatience = 300 * time.Millisecond + from, to := carryTree(t) + f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := flockFileNB(f); err != nil { + t.Fatal(err) + } + if _, err := carryStateInto(from, to); err == nil || !strings.Contains(err.Error(), "still running") { + t.Fatalf("carried while the account's bastion ran: %v", err) + } + if _, err := os.Stat(filepath.Join(to, "pairings")); err == nil { + t.Error("the service got a pairing from a running bastion") + } + if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil { + t.Errorf("the account's state was touched: %v", err) + } +} + +// A file the service already has is the service's: kept, and counted. +func TestACarryKeepsWhatTheServiceAlreadyHas(t *testing.T) { + from, to := carryTree(t) + if err := os.MkdirAll(to, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil { + t.Fatal(err) + } + res, err := carryStateInto(from, to) + if err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" { + t.Errorf("the service's own file was replaced: %q", b) + } + if res.Kept != 1 { + t.Errorf("kept %d files, want 1", res.Kept) + } +} + +// The carry removes the folder it carried from, so it refuses anything but a +// bastion's own state folder, and does nothing for a folder with no pairing. +func TestACarryTouchesOnlyABastionsStateFolder(t *testing.T) { + root := t.TempDir() + other := filepath.Join(root, "Documents") + if err := os.MkdirAll(filepath.Join(other, "pairings"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(other, "pairings", "x.json"), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := carryStateInto(other, filepath.Join(root, "service", "privatae-bastion")); err == nil { + t.Fatal("a folder that is not a bastion's was carried, and would have been removed") + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("the folder was touched: %v", err) + } + empty := filepath.Join(root, "account", "privatae-bastion") + if err := os.MkdirAll(empty, 0o700); err != nil { + t.Fatal(err) + } + if res, err := carryStateInto(empty, filepath.Join(root, "service", "privatae-bastion")); err != nil || len(res.Hosts) != 0 { + t.Fatalf("a folder with no pairing: %+v, %v", res, err) + } + if _, err := os.Stat(empty); err != nil { + t.Errorf("a folder with nothing to carry was removed: %v", err) + } +} + +func TestRebasePathMovesOnlyPathsUnderTheFolder(t *testing.T) { + from, to := "/a/privatae-bastion", "/b/privatae-bastion" + for in, want := range map[string]string{ + "/a/privatae-bastion": "/b/privatae-bastion", + "/a/privatae-bastion/dcn/x": "/b/privatae-bastion/dcn/x", + "/a/privatae-bastion-old/dcn": "/a/privatae-bastion-old/dcn", + "/a/other": "/a/other", + "mcp_/a/privatae-bastion/fake": "mcp_/a/privatae-bastion/fake", + } { + if got, _ := rebasePath(in, from, to); got != want { + t.Errorf("rebasePath(%q) = %q, want %q", in, got, want) + } + } +} + +// A grant file beside a pairing (pairings/.grant.json, anchor.go) is +// not a pairing: it names no Host the service serves, and a service holding +// only one is not one that serves a Host. +func TestACarryCountsOnlyPairingsAsHosts(t *testing.T) { + from, _ := carryTree(t) + if err := os.WriteFile(filepath.Join(from, "pairings", "box-one.example.test"+pairingGrantSuffix), []byte(`{}`), 0o600); err != nil { + t.Fatal(err) + } + hosts, err := pairingHosts(from) + if err != nil { + t.Fatal(err) + } + if strings.Join(hosts, ",") != "box-one.example.test" { + t.Fatalf("hosts %v, want only box-one.example.test", hosts) + } +} + +// A bastion killed a moment ago (the installer's Stop-Process) can hold its +// run lock for a moment after it is gone; the carry waits for it rather than +// failing the install. +func TestACarryWaitsAMomentForAJustStoppedBastionsLock(t *testing.T) { + from, to := carryTree(t) + f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + if err := flockFileNB(f); err != nil { + t.Fatal(err) + } + go func() { + time.Sleep(300 * time.Millisecond) + _ = f.Close() + }() + res, err := carryStateInto(from, to) + if err != nil { + t.Fatalf("a lock released 300 ms in failed the carry: %v", err) + } + if strings.Join(res.Hosts, ",") != "box-one.example.test" { + t.Fatalf("carried %v", res.Hosts) + } +} + +// The account's version of a file the service already has is not lost when +// the account's folder is removed: it is kept beside the service's. +func TestACarryKeepsTheAccountsVersionOfAFileTheServiceHas(t *testing.T) { + from, to := carryTree(t) + if err := os.MkdirAll(to, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil { + t.Fatal(err) + } + res, err := carryStateInto(from, to) + if err != nil { + t.Fatal(err) + } + if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" { + t.Errorf("the service's own file was replaced: %q", b) + } + if b, err := os.ReadFile(filepath.Join(to, "ui.secret"+carryAsideSuffix)); err != nil || string(b) != "account secret" { + t.Errorf("the account's version is gone with its folder: %q, %v", b, err) + } + if line := carryReport(res, from); !strings.Contains(line, carryAsideSuffix) { + t.Errorf("the install does not say where the account's version went: %q", line) + } +} + +// A state folder that is a link to elsewhere is walked as nothing at all; the +// carry must not report the pairings moved, nor remove anything, when the +// service did not get them. +func TestACarryRemovesNothingUnlessTheServiceHasEveryPairing(t *testing.T) { + from, to := carryTree(t) + link := filepath.Join(filepath.Dir(filepath.Dir(from)), "linked", "privatae-bastion") + if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink(from, link); err != nil { + t.Skipf("no symlinks here: %v", err) + } + if _, err := carryStateInto(link, to); err == nil { + t.Fatal("the carry reported success with no pairing in the service") + } + if _, err := os.Lstat(link); err != nil { + t.Errorf("the account's state folder was removed: %v", err) + } + if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil { + t.Errorf("the account's pairing was touched: %v", err) + } +} + +// A copy that fails part way takes back what it wrote: a service left with +// some of the pairings would never be carried into again (it serves a Host), +// and would serve those few. +func TestACarryThatFailsPartWayLeavesTheServiceAsItWas(t *testing.T) { + from, to := carryTree(t) + second := filepath.Join(from, "pairings", "box-two.example.test.json") + if err := os.WriteFile(second, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil { + t.Fatal(err) + } + prev := carryWriteFile + t.Cleanup(func() { carryWriteFile = prev }) + carryWriteFile = func(p string, b []byte, m os.FileMode) error { + if filepath.Base(p) == "box-two.example.test.json" { + return errors.New("disk full") + } + return prev(p, b, m) + } + if _, err := carryStateInto(from, to); err == nil { + t.Fatal("a failed copy reported success") + } + if hosts, _ := pairingHosts(to); len(hosts) != 0 { + t.Errorf("the service was left serving %v of the account's pairings", hosts) + } + if b, err := os.ReadFile(filepath.Join(to, "ui.secret")); err == nil { + t.Errorf("a file the failed copy wrote was left in the service: %q", b) + } + if _, err := os.Stat(second); err != nil { + t.Errorf("the account's state was touched: %v", err) + } +} + +// Once the service holds every pairing, a source folder that cannot be +// removed is said, not a failure: failing would leave the service stopped, +// and the machine as silent as the empty start this carry replaces. +func TestACarryWhoseSourceCannotBeRemovedStillSucceeds(t *testing.T) { + from, to := carryTree(t) + prev := carryRemoveAll + t.Cleanup(func() { carryRemoveAll = prev }) + carryRemoveAll = func(string) error { return errors.New("in use by another process") } + res, err := carryStateInto(from, to) + if err != nil { + t.Fatalf("a carry that moved every pairing failed the install: %v", err) + } + if hosts, _ := pairingHosts(to); strings.Join(hosts, ",") != "box-one.example.test" { + t.Errorf("the service holds %v", hosts) + } + if line := carryReport(res, from); !strings.Contains(line, "delete that folder") { + t.Errorf("the install does not say the account's copy is still there: %q", line) + } +} diff --git a/client-go/sysservice.go b/client-go/sysservice.go index 7a7861d..7608e88 100644 --- a/client-go/sysservice.go +++ b/client-go/sysservice.go @@ -28,9 +28,11 @@ package main // installed and the caller administers the machine, the process points // that one variable at the service's own before anything reads state. // - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed, -// `pair`, `run` and `status` from an account that does not administer the -// machine would act on an empty bastion of that account's own, beside the -// machine's. They are refused with a sentence saying what to do instead. As +// `pair` and `run` from an account that does not administer the machine +// would act on an empty bastion of that account's own, beside the +// machine's. They are refused with a sentence saying what to do instead. +// The reads and the owner's acts (`status` among them) ask the machine's +// bastion over its lane instead (serviceLaneReads). As // with the machine marker (singleton.go), this binds a cooperating binary // only; an older copy will not refuse. What stops the person from removing // the service is that they do not administer the machine. @@ -46,6 +48,11 @@ import ( "time" ) +// serviceCarryFromSID is the account `service install --from-account` names: +// the bastion state carried into the service is that account's +// (servicecarry.go). "" carries the state of the account running the install. +var serviceCarryFromSID string + const ( launchdLabel = "ai.privatae.bastion" launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist" @@ -110,14 +117,14 @@ var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status" // account each read that account's own, empty approvals, grants or asks. "approvals drop": true, "grants": true, "device-deny": true, "device-approve": true, // The machine's approvals: read, approved and denied through the owner's - // socket on Linux and macOS (reads.go, owneracts.go); on Windows the tray - // answers them over its own pipe, and these would read the account's own. + // lane (reads.go, owneracts.go): ask.sock on Linux and macOS, the ask pipe + // on Windows. Run directly, these would read the account's own. "approvals": true, "approve": true, "deny": true} -// serviceLaneReads are the guarded commands that, on Linux and macOS, read -// the machine's bastion itself over its socket (reads.go) rather than a -// bastion of the caller's own: not refused here, and refused there for any -// account but the owner's. +// serviceLaneReads are the guarded commands that read the machine's bastion +// itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on +// Windows) rather than a bastion of the caller's own: not refused here, and +// refused there for any account but the owner's. var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true, "doctor": true, "ops": true, "device-approvals": true, "backup read": true, // Not reads: the owner's account switches, and takes reach back (`ops @@ -138,7 +145,7 @@ func serviceGuard(goos, cmd string, installed, authority bool) error { if !installed || authority || !serviceGuardedCommands[cmd] { return nil } - if serviceLaneReads[cmd] && goos != "windows" { + if serviceLaneReads[cmd] { return nil } return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+ @@ -355,6 +362,10 @@ var systemService struct { installed bool authority bool err error + // callerAppData is APPDATA as this process was started with, before an + // administrator's command is pointed at the service's: where a bastion of + // this account's own keeps its state (servicecarry.go). + callerAppData string } // adoptSystemServiceState runs before any command. It records whether the @@ -372,6 +383,7 @@ var systemService struct { // authority only after an installed service left root on Linux looking like // an ordinary account, and enrolServiceStateRefusal refused it. func adoptSystemServiceState() { + systemService.callerAppData = os.Getenv("APPDATA") installed, err := systemServiceInstalled() systemService.err = err systemService.installed = installed || err != nil @@ -428,8 +440,16 @@ func serviceGuardKey(cmd string, args []string) string { // cmdService is `privatae-bastion service install|uninstall|status`. func cmdService(args []string) error { + const usage = "usage: privatae-bastion service install [--from-account ] | uninstall | status" + if len(args) == 3 && args[0] == "install" && args[1] == "--from-account" { + if !approvalsOwnerSIDOK(args[2]) { + return fmt.Errorf("%q is not the SID of a person's account (S-1-5-21-… or S-1-12-1-…) — nothing was installed", args[2]) + } + serviceCarryFromSID = args[2] + args = args[:1] + } if len(args) != 1 { - return errors.New("usage: privatae-bastion service install|uninstall|status") + return errors.New(usage) } switch args[0] { case "install": diff --git a/client-go/sysservice_test.go b/client-go/sysservice_test.go index d1ac9b8..ef86fe2 100644 --- a/client-go/sysservice_test.go +++ b/client-go/sysservice_test.go @@ -167,9 +167,10 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) { for _, goos := range []string{"darwin", "windows", "linux"} { for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} { err := serviceGuard(goos, cmd, true, false) - if (cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report") && goos != "windows" { - // These read the machine's bastion over its socket (reads.go), - // which answers the owner's account and refuses any other. + if cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report" { + // These read the machine's bastion over its lane (reads.go: + // ask.sock, or the ask pipe on Windows), which answers the + // owner's account and refuses any other. if err != nil { t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err) } @@ -445,16 +446,17 @@ func TestKeepOneLogAsideEmptiesInPlaceAndKeepsTheLastOne(t *testing.T) { } } -// The narrowing acts beside a system service: on Linux and macOS they go -// through the owner's lane (owneracts.go), so the guard lets them by; on -// Windows there is no lane, and the ordinary account's own profile is a -// separate, empty bastion, so they are refused with the administrator's way, -// as `ops` (and so `ops drop`) already was. device-approve is the -// administrator's on every system: the console keeps it adminOnly, and from -// an ordinary account it read that account's own, empty asks. +// The narrowing acts beside a system service go through the owner's lane +// (owneracts.go) on every system, ask.sock or the Windows ask pipe, so the +// guard lets them by; each must be both guarded and a lane command, or from +// an ordinary account it would act on that account's own, empty bastion. +// device-approve is the administrator's on every system: the console keeps +// it adminOnly, and from an ordinary account it read that account's own, +// empty asks. // -// MUTATION: take "device-deny" out of serviceGuardedCommands. RESULT: red on -// windows. Take "device-approve" out. RESULT: red on every system. +// MUTATION: take "device-deny" out of serviceLaneReads. RESULT: red on every +// system. Take "device-approve" out of serviceGuardedCommands. RESULT: red on +// every system. func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) { lines := map[string][]string{ "approvals drop": {"privatae-bastion", "approvals", "--host", "box-one.example", "drop", "abcd1234"}, @@ -466,6 +468,10 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing if got := serviceGuardKey(args[1], args); got != want { t.Errorf("%v is guarded as %q, want %q", args[1:], got, want) } + if !serviceGuardedCommands[want] || !serviceLaneReads[want] { + t.Errorf("`%s` is guarded=%v, a lane command=%v: from an ordinary account beside the service it "+ + "would act on that account's own, empty bastion", want, serviceGuardedCommands[want], serviceLaneReads[want]) + } } if got := serviceGuardKey("approvals", []string{"privatae-bastion", "approvals", "--machine"}); got != "approvals" { t.Errorf("the approvals read is guarded as %q", got) @@ -473,11 +479,7 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing approve := serviceGuardKey("device-approve", []string{"privatae-bastion", "device-approve", "all"}) for _, goos := range []string{"linux", "darwin", "windows"} { for key := range lines { - err := serviceGuard(goos, key, true, false) - if goos == "windows" && err == nil { - t.Errorf("windows: `%s` from an ordinary account acted on its own empty bastion", key) - } - if goos != "windows" && err != nil { + if err := serviceGuard(goos, key, true, false); err != nil { t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, key, err) } if err := serviceGuard(goos, key, true, true); err != nil { @@ -497,19 +499,17 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing } } -// The machine's approvals beside a system service: through the owner's -// socket on Linux and macOS, refused on Windows (the tray answers them there). -func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) { +// The machine's approvals beside a system service go through the owner's +// lane on every platform: ask.sock on Linux and macOS, the ask pipe on +// Windows, which answers the owner's account and refuses any other. +func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLane(t *testing.T) { for _, line := range [][]string{ {"privatae-bastion", "approvals", "--machine"}, {"privatae-bastion", "approve", "--machine", "abcd1234"}, {"privatae-bastion", "deny", "abcd1234"}, } { key := serviceGuardKey(line[1], line) - if err := serviceGuard("windows", key, true, false); err == nil { - t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:]) - } - for _, goos := range []string{"linux", "darwin"} { + for _, goos := range []string{"linux", "darwin", "windows"} { if err := serviceGuard(goos, key, true, false); err != nil { t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err) } diff --git a/client-go/sysservice_windows.go b/client-go/sysservice_windows.go index c2ac8b3..f65f023 100644 --- a/client-go/sysservice_windows.go +++ b/client-go/sysservice_windows.go @@ -24,6 +24,7 @@ import ( "log" "os" "path/filepath" + "strings" "syscall" "time" "unsafe" @@ -236,6 +237,20 @@ func installSystemService() error { } defer s.Close() + // Before the service first starts on this state: the pairings of the + // bastion this machine ran for an account until now (servicecarry.go). + // A carry that fails leaves the service stopped rather than starting it + // empty, which would leave the machine silent to every Host it serves. + to := filepath.Join(windowsSystemAppData(os.Getenv("SystemRoot")), "privatae-bastion") + from, ferr := serviceCarrySource(serviceCarryFromSID) + if ferr != nil { + fmt.Printf(" No bastion state of an account was looked for (%v).\n", ferr) + } else if res, err := carryStateInto(from, to); err != nil { + return fmt.Errorf("the %s service is registered but was not started: %w", winServiceName, err) + } else if line := carryReport(res, from); line != "" { + fmt.Println(line) + } + restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second} if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil { return fmt.Errorf("cannot set the %s service to restart after a failure: %w", winServiceName, err) @@ -268,6 +283,41 @@ func installSystemService() error { return nil } +// serviceCarrySource is the state folder of the bastion an account ran: the +// named account's (its profile from the registry's ProfileList, which an +// elevated step run as another administrator still finds), or else the +// account running this install, as it was before its APPDATA was pointed at +// the service's. +func serviceCarrySource(sid string) (string, error) { + // The account running this install, as the installer's elevated step + // usually is: its own APPDATA, which is where its bastion kept its state + // even when that folder is redirected away from the profile. + if u, err := windows.GetCurrentProcessToken().GetTokenUser(); err == nil && strings.EqualFold(u.User.Sid.String(), sid) { + sid = "" + } + if sid == "" { + d := systemService.callerAppData + if d == "" || samePath(d, windowsSystemAppData(os.Getenv("SystemRoot"))) { + return "", nil + } + return filepath.Join(d, "privatae-bastion"), nil + } + k, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE) + if err != nil { + return "", fmt.Errorf("no profile is recorded for %s: %w", sid, err) + } + defer k.Close() + prof, _, err := k.GetStringValue("ProfileImagePath") + if err != nil { + return "", fmt.Errorf("the profile of %s has no path: %w", sid, err) + } + if prof, err = registry.ExpandString(prof); err != nil { + return "", err + } + return filepath.Join(prof, "AppData", "Roaming", "privatae-bastion"), nil +} + // stopServiceAndWait asks the service to stop and waits up to thirty seconds // for it to say it has. A service that is not running is already stopped. func stopServiceAndWait(s *mgr.Service) { diff --git a/client-go/ui.go b/client-go/ui.go index 56dc74c..094a140 100644 --- a/client-go/ui.go +++ b/client-go/ui.go @@ -130,6 +130,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour // line of the page where the day is the point. const uiEventTime = "2 Jan 2006, 15:04" +// Admin is `privatae-bastion ` as this machine's administrator types it, +// for what a page tells a limited session to ask of them. +func (uiStatus) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) } + type uiStatus struct { // Limited: drawn for the owner's lane (consolelane.go), which may not // answer the approval device's requests. @@ -499,8 +503,7 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc { return } if !u.signedIn(r) { - http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`", - http.StatusUnauthorized) + http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized) return } if u.limitedSession(r) { @@ -510,6 +513,31 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc { } } +// notSignedIn is what a page says to a browser with no session: the command +// that opens it on this machine. The console of a bastion installed for the +// whole machine opens from its owner's own account through the lane +// (consolelane.go), and from the administrator's; telling either one only +// "privatae-bastion ui" sent the person back to the command that, from the +// wrong account, could not open it. +func notSignedIn(verb, goos string, machines bool) string { + cmd := "privatae-bastion " + verb + switch { + case !machines: + return "not signed in — open this page with `" + cmd + "`" + case goos == "windows": + return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd + + "` from the account that owns this machine, or with `" + cmd + "` in an Administrator terminal" + } + return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd + + "` from the account that owns this machine, or with `sudo -H " + cmd + "`" +} + +// consoleIsTheMachines: this console is the bastion installed for the whole +// machine (the Windows service, or root's on Linux and macOS). +func consoleIsTheMachines() bool { + return runningAsService || (runtime.GOOS != "windows" && os.Geteuid() == 0) +} + // root serves the page, and is also where a ticket is redeemed. func (u *uiServer) root(w http.ResponseWriter, r *http.Request) { if why := uiRequestAllowed(r, u.port, u.verb()); why != "" { @@ -548,8 +576,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) { return } if !u.signedIn(r) { - http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`", - http.StatusUnauthorized) + http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized) return } if u.contextUI { @@ -937,9 +964,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) { } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", addr, fmt.Errorf("the local UI refused this machine's own key (%s) — "+ - "if the bastion was reinstalled, restart it so it and this command agree", - resp.Status) + return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld()) } var out struct { Ticket string `json:"ticket"` @@ -954,6 +979,25 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) { return entry, addr, nil } +// uiKeyRefused is the console refusing this account's own key: the console +// on that port belongs to another bastion than this account's. +func uiKeyRefused(addr, status, goos string, service, admin bool) error { + switch { + case goos == "windows" && service && !admin: + return fmt.Errorf("the console at %s did not take this account's key (%s): it belongs to the %s service, "+ + "which this account opens through the service, not with a key of its own. Update the bastion "+ + "(run the installer again), or open the console from an Administrator terminal: privatae-bastion ui", + addr, status, winServiceName) + case goos == "windows": + return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that "+ + "port. Get-Process privatae-bastion | Format-List Id, Path, StartTime shows which; `privatae-bastion "+ + "status` says whether this account's own is running", addr, status) + } + return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that port, "+ + "most likely the one installed for the whole machine. Open it from the account that owns this machine "+ + "with `privatae-bastion ui`, or with `sudo -H privatae-bastion ui`", addr, status) +} + // cmdUI opens the local UI in a browser. It does NOT start a server: the UI // belongs to the running daemon, because that is the process that knows what is // live. diff --git a/client-go/uiassets/approvals.html b/client-go/uiassets/approvals.html index d6fa1bd..409b3f3 100644 --- a/client-go/uiassets/approvals.html +++ b/client-go/uiassets/approvals.html @@ -58,7 +58,7 @@ {{if $.Limited}}

Approving signs with this device's key, which is the administrator's: - sudo -H privatae-bastion ui

+ {{$.Admin "ui"}}

{{else}}
diff --git a/client-go/uiassets/setup.html b/client-go/uiassets/setup.html index 2af8e29..e4a2e26 100644 --- a/client-go/uiassets/setup.html +++ b/client-go/uiassets/setup.html @@ -89,7 +89,7 @@ {{else if .OwnerCode}}

set up Your code is set up on this machine.

{{if .Limited}} -

To replace it, at a terminal: sudo -H privatae-bastion stepup-enroll --machine --rotate

+

To replace it, at a terminal: {{$.Admin "stepup-enroll --machine --rotate"}}

{{else}} @@ -99,7 +99,7 @@ {{end}} {{else if .Limited}}

Set it up once, at a terminal, as the machine's administrator, so no program running as you - can set it up first: sudo -H privatae-bastion stepup-enroll --machine

+ can set it up first: {{$.Admin "stepup-enroll --machine"}}

{{else}} @@ -146,7 +146,7 @@

Connect a Host

The token comes from your host's “Set up a bastion”. It is used once.

- {{if .Limited}}

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: sudo -H privatae-bastion ui

{{else}} + {{if .Limited}}

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: {{$.Admin "ui"}}

{{else}} @@ -199,7 +199,7 @@
{{else if and .Servable .Limited}} -

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: sudo -H privatae-bastion ui

+

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: {{$.Admin "ui"}}

{{else if .Servable}} {{/* .Servable, not .Hosts: a pending pairing gets no state on this machine at all, not even a directory (run.go:311), and a factor for it would @@ -231,7 +231,7 @@

Add a folder to your network

{{if and .Servable .Limited}} -

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: sudo -H privatae-bastion ui

+

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: {{$.Admin "ui"}}

{{else if .Servable}}

Your files stay on this machine. Your host keeps only the index, and the draft does nothing until you approve it there.

@@ -275,7 +275,7 @@

The anchor host is the one that approves every other host paired here. It is a choice among the hosts this machine already serves.

{{if and .Hosts .Limited}} -

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: sudo -H privatae-bastion ui

+

From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: {{$.Admin "ui"}}

{{else if .Hosts}} {{range .Hosts}} diff --git a/client-go/uisetup.go b/client-go/uisetup.go index 8d492af..d2d6360 100644 --- a/client-go/uisetup.go +++ b/client-go/uisetup.go @@ -263,6 +263,9 @@ type uiCommand struct { Class string // ok | warn | bad — bad means "this cannot be run yet" } +// Admin is uiStatus.Admin for the setup page. +func (uiSetupView) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) } + type uiSetupView struct { Version string Mode string diff --git a/deploy/VERSION b/deploy/VERSION index 905fae0..4d74f32 100644 --- a/deploy/VERSION +++ b/deploy/VERSION @@ -1 +1 @@ -0.61.2 +0.62.0 diff --git a/deploy/install.ps1 b/deploy/install.ps1 index 4b03575..d6b2aa9 100644 --- a/deploy/install.ps1 +++ b/deploy/install.ps1 @@ -23,10 +23,13 @@ # that form errors without installing anything.) # On an install that already exists, `privatae-bastion set-mode networked` (from # an Administrator terminal) changes the mode but not how it runs: it stays a -# logon task. Re-run this installer with -Networked to make it a service. The -# service starts with its own state: pairings made by the earlier per-user -# install stay in that user's profile and are not carried over, so the machine -# is paired again (claim-anchor with a token from the host). +# logon task. Re-run this installer with -Networked to make it a service. When +# an install moves the bastion from your account to the service, the service +# takes over your account's bastion state before it first starts: its +# pairings, keys and grants, so every Host it served goes on being served and +# nothing is paired again (`service install --from-account`, servicecarry.go). +# The copy in your profile is then removed, so the keys no longer sit where +# programs running as you can read them. # # ELEVATE AS YOURSELF, not as a different administrator account. The state # directory is per-user (a folder under %APPDATA%), and UAC elevation of your @@ -248,9 +251,16 @@ if (-not $elevatedDone) { Where-Object { $_.Path -and ($_.Path -ne $exe) } | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + # Stop-Process returns before the process is gone, and its run + # lock goes with it; the carry below needs that lock free. + Wait-Process -Id $_.Id -Timeout 10 -ErrorAction SilentlyContinue Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)." } - & $exe service install + # The account that asked for this install, or this one when run elevated + # by hand: its bastion's pairings go into the service, and it is recorded + # as the machine's owner below. + $owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value } + & $exe service install --from-account $owner if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" } } else { try { @@ -326,10 +336,8 @@ if (-not $elevatedDone) { & $exe set-mode networked if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" } } - # The machine's owner: the account that asked for this install, or this - # one when run elevated by hand. A second administrator's reinstall leaves - # the first owner in place; the bastion says so. - $owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value } + # The machine's owner ($owner, above). A second administrator's + # reinstall leaves the first owner in place; the bastion says so. & $exe approvals-owner --sid $owner if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." } Write-Host "" diff --git a/docs/STATUS.md b/docs/STATUS.md index c924e22..88470e4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -11,6 +11,8 @@ it shares with Linux is tested there, but it has not run on that system yet. |---|---|---|---| | Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run | | `--just-me`, and the fall back where sudo cannot be used | run | built | not run | +| Moving from an install for your account to one for the whole machine keeps its pairings | not needed (one state folder) | not built | built | +| An update restarts the bastion | run | built | built (the service restarts it; a sign-in task's copy waits for the old one to exit) | | Every AI agent, by its program or its connection to a model | run | built | built | | Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built | | Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time | @@ -19,11 +21,11 @@ it shares with Linux is tested there, but it has not run on that system yet. | MCP tool servers and their problems | tested | built | built | | Changes since your baseline | tested | built | built | | Stop an agent with your code, from the console or `stop` | run | built | built | -| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | not built (the ask socket is Unix only) | -| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | not built (no ask pipe) | -| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | not built (no ask pipe) | +| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | built (the ask pipe) | +| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | built (the ask pipe) | +| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | built (the ask pipe) | | The installer checks the release's signature | tested | built | not built (`install.ps1` checks the digest only) | -| The console from the owner's own account, no sudo | run | built | the tray's approvals window only | +| The console from the owner's own account, no sudo or Administrator terminal | run | built | built (the ask pipe); the tray's approvals window | | Activity, report | run | built | built | | `uninstall`, keeping the audit log | run | built | built | | A notice when something is waiting | none yet | none yet | the tray |