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}}