3 Commits

Author SHA1 Message Date
Privatae 2043fa5e9e bastion 0.62.0
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s
2026-11-02 21:31:58 +00:00
Privatae 79646bda13 bastion 0.61.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m56s
ci / fuzz (push) Successful in 3m48s
2026-11-02 15:46:29 +00:00
Privatae d4594d18b7 bastion 0.61.1
ci / test (push) Successful in 5m45s
ci / personal-data (push) Successful in 9s
ci / fuzz (push) Successful in 3m30s
2026-11-02 08:00:14 +00:00
41 changed files with 2347 additions and 213 deletions
+54
View File
@@ -22,6 +22,60 @@ 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 that is settled. The newest entries may not be published yet; the manifest
says which version is. Older entries are not marked. 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
quarantines this machine there, the Host refuses each of the bastion's lane
connections, and three of the five lanes dialled again at once, the other
two a second later: tens of connections a second, each a TLS handshake.
Now a refusal holds all of that pairing's lanes for 5 seconds, twice as long
after each further refusal up to 5 minutes; and while the Host's heartbeat
reply says the machine is quarantined, the lanes do not dial at all. The
first reply that no longer says so brings them back. Other Hosts the
machine is paired with are not slowed.
## 0.61.1
- **An op that creates a folder runs on every Linux kernel.** Where the
kernel's overlay keeps an index (or has `redirect_dir` or `metacopy` on, as
Azure's kernel does), it marks each new folder opaque, and the bastion took
that for a replaced folder: every op that created one was refused as "a path
outside the effects in disguise". A new folder is now read as created, with
what it holds; a folder that replaces one the machine holds is still
refused.
## 0.61.0 ## 0.61.0
- **An agent on the machine reads the rest of it.** `agents show <pid|session>` - **An agent on the machine reads the rest of it.** `agents show <pid|session>`
+1 -1
View File
@@ -3,7 +3,7 @@
[![License: GPL v3+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE) [![License: GPL v3+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](LICENSE)
[![Go 1.26+](https://img.shields.io/badge/go-1.26%2B-00ADD8.svg)](client-go/go.mod) [![Go 1.26+](https://img.shields.io/badge/go-1.26%2B-00ADD8.svg)](client-go/go.mod)
[![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms) [![Platforms](https://img.shields.io/badge/platforms-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](#supported-platforms)
[![ci](https://git.privatae.ai/privatae/privatae-bastion/actions/workflows/ci.yml/badge.svg?branch=main)](https://git.privatae.ai/privatae/privatae-bastion/actions) [![ci](https://github.com/Privatae/bastion/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Privatae/bastion/actions/workflows/ci.yml)
**See every AI agent running on your computer, what each one is doing and what it can reach, and stop any of them. One program, no account, no AI of its own.** **See every AI agent running on your computer, what each one is doing and what it can reach, and stop any of them. One program, no account, no AI of its own.**
+42 -28
View File
@@ -8,7 +8,8 @@ package main
// `stop` run by it had nowhere to ask, and asking is the one thing an agent // `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). // 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 // 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 // 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 // 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. // 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{}) { func startAskSock(done <-chan struct{}) {
if runtime.GOOS == "windows" { 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() p := askSockPath()
ln, err := contextSockListen(p) ln, err := contextSockListen(p)
@@ -123,8 +130,7 @@ func askSockServe(c net.Conn) {
_ = c.SetDeadline(time.Now().Add(10 * time.Second)) _ = c.SetDeadline(time.Now().Add(10 * time.Second))
uid, user, err := askPeerIdentityFn(c) uid, user, err := askPeerIdentityFn(c)
if err != nil { if err != nil {
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" + _ = json.NewEncoder(c).Encode(askUnattributable(err))
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
return return
} }
var req agentAskRequest 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"}) _ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
return return
} }
if req.Op == "console" { extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) }
r := agentAskReply{User: user} _ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend,
if link, err := consoleLaneEntry(uid, user); err != nil { 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() r.Why = err.Error()
} else { } else {
r.OK, r.URL = true, link r.OK, r.URL = true, link
} }
_ = json.NewEncoder(c).Encode(r) return r
return 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" { return fileAsk(req, peer.Name, pid())
_ = 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)))
} }
// fileAsk turns one ask into a pending request of the machine, or says why not. // 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 // askViaSock is the asking side: `stop` run where it cannot write the
// machine's state itself. // machine's state itself.
func askViaSock(req agentAskRequest) (agentAskReply, error) { func askViaSock(req agentAskRequest) (agentAskReply, error) {
if runtime.GOOS == "windows" { c, err := dialAskLane(askSockPath())
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
}
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
if err != nil { if err != nil {
return agentAskReply{}, err return agentAskReply{}, err
} }
+9
View File
@@ -177,6 +177,15 @@ func winPersonSID(sid string) bool {
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-") 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 "" // 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 // when it may: it must run as the person signed in to its own session, in a
// session someone is in front of. // session someone is in front of.
+37 -12
View File
@@ -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 // 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. // created while none is open asks for the name to be ours alone.
type approvalPipeServer struct { type approvalPipeServer struct {
mu sync.Mutex mu sync.Mutex
open int open int
sa *windows.SecurityAttributes sa *windows.SecurityAttributes
name *uint16 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 is set when the service stops; it ends a connect that is waiting.
stopEv windows.Handle stopEv windows.Handle
} }
@@ -190,7 +191,7 @@ func (s *approvalPipeServer) create() (*pipeConn, error) {
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa) windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
if err != nil { if err != nil {
if s.open == 0 { 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 return nil, err
} }
@@ -230,11 +231,17 @@ func startApprovalPipe(stop <-chan struct{}) {
} }
func serveApprovalPipe(stop <-chan struct{}, ready func()) error { 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 { if err != nil {
return fmt.Errorf("cannot build the pipe's permissions: %w", err) return fmt.Errorf("cannot build the pipe's permissions: %w", err)
} }
name, err := windows.UTF16PtrFromString(approvalPipeName) name, err := windows.UTF16PtrFromString(pipeName)
if err != nil { if err != nil {
return err return err
} }
@@ -249,11 +256,12 @@ func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
s := &approvalPipeServer{ s := &approvalPipeServer{
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd}, sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
name: name, name: name,
label: pipeName,
stopEv: stopEv, stopEv: stopEv,
} }
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them. // The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second, 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 // 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 // (GetNamedPipeClientProcessId), its user and session from that process's
// token, and the person signed in to that session from the session's own token // token, and the person signed in to that session from the session's own token
// (WTSQueryUserToken, which LocalSystem may call). // (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) { func pipeClientOf(h windows.Handle) (pipeClient, error) {
connected := time.Now()
var c pipeClient var c pipeClient
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil { if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
return c, fmt.Errorf("cannot tell which process connected: %w", err) 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) return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err)
} }
defer windows.CloseHandle(p) 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 var tok windows.Token
if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil { 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) 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 // (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. // would ask for), and lets the server identify this process but not act as it.
func dialApprovalPipe() (*pipeConn, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -401,7 +426,7 @@ func dialApprovalPipe() (*pipeConn, error) {
continue continue
} }
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { 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) 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 { func pipeServerIsService(h windows.Handle) error {
var pid uint32 var pid uint32
if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil { 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() svcPID, err := bastionServicePID()
if err != nil { if err != nil {
return err return err
} }
if svcPID == 0 || pid != svcPID { 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 return nil
} }
+19
View File
@@ -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{}) {}
+153
View File
@@ -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
}
+89
View File
@@ -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 <cmd>` 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)"
)
+248
View File
@@ -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")
}
}
+67 -31
View File
@@ -1,7 +1,8 @@
package main package main
// consolelane.go — the machine owner's own account opens the console of the // 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 // 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 // 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 // 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. // 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 // 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 // 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 // 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: // 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 // 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 // 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 // a Host's requests in a limited session are throttled as the owner's are
// (machineCodeSpend), because guessing is the only attack left there. // (machineCodeSpend), because guessing is the only attack left there.
import ( import (
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"runtime" "runtime"
"strconv"
"sync/atomic" "sync/atomic"
) )
@@ -45,21 +48,27 @@ var consoleLaneServer atomic.Pointer[uiServer]
// consoleLaneRefusal says why account uid may not open the console through // consoleLaneRefusal says why account uid may not open the console through
// the lane, or "" when it may. // the lane, or "" when it may.
func consoleLaneRefusal(uid uint32, who string) string { 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 { switch {
case err != nil: case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " + 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: case !present:
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " + 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: " + admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who) case !match:
case owner != uid: without := "without sudo"
name := ownerName if p.windows() {
if name == "" { without = "without an Administrator terminal"
name = "uid " + strconv.FormatUint(uint64(owner), 10)
} }
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 "" return ""
} }
@@ -74,7 +83,11 @@ func accountForHint(who string) string {
// consoleLaneEntry is what the daemon answers an owner's "console" ask with: // consoleLaneEntry is what the daemon answers an owner's "console" ask with:
// the link that redeems a fresh limited ticket. // the link that redeems a fresh limited ticket.
func consoleLaneEntry(uid uint32, who string) (string, error) { 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) return "", errors.New(why)
} }
u := consoleLaneServer.Load() 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)) 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 " + 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: sudo -H privatae-bastion ui" "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. // adminOnly wraps a route a limited session may not use.
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc { 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 // consoleEntryForThisAccount is the link `ui` opens. An account with a
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary // 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 // account beside the machine's bastion asks the lane, and is told why not
// not when it is not the owner's. // when it is not the owner's.
func consoleEntryForThisAccount() (entry, addr string, err error) { 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() p := askSockPath()
if why := contextSockDialable(p); why != "" { if why := contextSockDialable(p); why != "" {
return "", "", errors.New(why) return "", "", errors.New(why)
} }
if _, statErr := os.Stat(p); statErr == nil { if _, statErr := os.Stat(p); statErr == nil {
r, err := askViaSock(agentAskRequest{Op: "console"}) return consoleEntryThroughLane()
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 localUIEntryURL() 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 // 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 // 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 // refused (Settings, Approvals). It changes what is drawn, never what is
+9
View File
@@ -124,7 +124,11 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
hold := laneHoldFor(self)
for pctx.Err() == nil { for pctx.Err() == nil {
if !hold.waitClear(pctx) {
return
}
ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
cancel() cancel()
@@ -137,12 +141,17 @@ func runEgressWorkers(pctx context.Context, self string, wsURL string, tlsCfg *t
sleepCtx(pctx, time.Second) sleepCtx(pctx, time.Second)
continue continue
} }
held := watchHeldClose(ws)
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() }) unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
took := handleEgressConn(&wsConn{ws: ws}, dialOut, self) // blocks until a job, serves it, then returns took := handleEgressConn(&wsConn{ws: ws}, dialOut, self) // blocks until a job, serves it, then returns
unpark() unpark()
stopKA() stopKA()
_ = ws.Close() _ = ws.Close()
if held() {
hold.refused(self) // the Host holds this machine (lanehold.go)
continue
}
if !took { if !took {
sleepCtx(pctx, egressRefusedWait) sleepCtx(pctx, egressRefusedWait)
} }
+8
View File
@@ -114,7 +114,11 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
} }
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
go func() { go func() {
hold := laneHoldFor(self)
for pctx.Err() == nil { for pctx.Err() == nil {
if !hold.waitClear(pctx) {
return
}
ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
cancel() cancel()
@@ -127,12 +131,16 @@ func runHarnessWorkers(pctx context.Context, self, dcnDir string, wsURL string,
sleepCtx(pctx, time.Second) sleepCtx(pctx, time.Second)
continue continue
} }
held := watchHeldClose(ws)
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() }) unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
handleHarnessConn(ws, self, dcnDir) // blocks until a job, serves it, then returns handleHarnessConn(ws, self, dcnDir) // blocks until a job, serves it, then returns
unpark() unpark()
stopKA() stopKA()
_ = ws.Close() _ = ws.Close()
if held() {
hold.refused(self) // the Host holds this machine (lanehold.go)
}
} }
}() }()
} }
+157
View File
@@ -0,0 +1,157 @@
package main
// When a Host holds this machine (its owner quarantined it there), the Host
// refuses every worker socket of every lane with close code 4403, and says
// "quarantined": true on each beat it answers. The lane workers park a pool of
// sockets per lane and used to dial again at once (mgmt, harness, session) or
// a second after a close (egress, serve), so one held machine whose tunnel
// stayed up dialled the Host 30 to 80 times a second, each one a TLS
// handshake. A pairing's workers now share one hold:
//
// - a socket the Host closes with 4403 holds every worker of that pairing for
// laneHoldFirst, doubling with each hold up to laneHoldMax;
// - a beat that says quarantined stops them dialling until a beat no longer
// does, which is how the owner's release reaches this machine.
//
// The hold is the pairing's, so another Host this machine is paired with is
// never slowed by it.
import (
"context"
"encoding/json"
"fmt"
"math/rand/v2"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// laneHeldCloseCode is the close code a Host gives a worker socket of a
// machine it holds (the Host's quarantine gate).
const laneHeldCloseCode = 4403
// The first hold, and the longest. Variables so a test can shorten them.
var (
laneHoldFirst = 5 * time.Second
laneHoldMax = 5 * time.Minute
)
type laneHold struct {
mu sync.Mutex
wait time.Duration // the last hold's length; the next one doubles it
until time.Time // no worker of the pairing dials before this
last time.Time // when the last hold began
quarantined bool // what the last beat said
wake chan struct{} // closed when the beat lifts a quarantine
}
var laneHolds sync.Map // host name -> *laneHold
func laneHoldFor(host string) *laneHold {
if h, ok := laneHolds.Load(host); ok {
return h.(*laneHold)
}
h, _ := laneHolds.LoadOrStore(host, &laneHold{wake: make(chan struct{})})
return h.(*laneHold)
}
// refused is a worker socket the Host closed with 4403. The workers of a pool
// are refused together, so a refusal while a hold is on is that hold's and
// does not lengthen it. A refusal long after the last hold starts again from
// laneHoldFirst.
func (h *laneHold) refused(host string) {
h.mu.Lock()
defer h.mu.Unlock()
now := time.Now()
if now.Before(h.until) {
return
}
switch {
case h.wait == 0 || now.Sub(h.last) > 2*laneHoldMax:
h.wait = laneHoldFirst
default:
h.wait = min(2*h.wait, laneHoldMax)
}
h.last = now
h.until = now.Add(h.wait)
fmt.Printf("[%s] the Host refused a lane: it holds this machine; its lanes dial again in %s\n", host, h.wait)
}
// fromBeat is what a beat's reply said. A release ends the hold at once and
// starts the next one from laneHoldFirst.
func (h *laneHold) fromBeat(host string, quarantined bool) {
h.mu.Lock()
defer h.mu.Unlock()
switch {
case quarantined && !h.quarantined:
h.quarantined = true
fmt.Printf("[%s] the Host holds this machine (quarantined there): its lanes wait until its owner releases it\n", host)
case !quarantined && h.quarantined:
h.quarantined = false
h.wait, h.until = 0, time.Time{}
close(h.wake)
h.wake = make(chan struct{})
fmt.Printf("[%s] the Host released this machine: its lanes are back\n", host)
}
}
// waitClear blocks a worker until it may dial: the hold is over and the last
// beat did not say quarantined. A worker waits a little past the hold's end,
// a different little for each, so a pool does not dial in one burst. False
// when ctx ends first.
func (h *laneHold) waitClear(ctx context.Context) bool {
for {
h.mu.Lock()
quarantined, until, wake := h.quarantined, h.until, h.wake
h.mu.Unlock()
if ctx.Err() != nil {
return false
}
var t *time.Timer
var timer <-chan time.Time
switch {
case quarantined:
// Only a beat lifts it.
case time.Now().Before(until):
d := time.Until(until)
t = time.NewTimer(d + rand.N(d/10+time.Millisecond))
timer = t.C
default:
return true
}
select {
case <-ctx.Done():
case <-wake:
case <-timer:
}
if t != nil {
t.Stop()
}
}
}
// watchHeldClose reports, once the socket has been read to its end, whether
// the Host closed it with laneHeldCloseCode.
func watchHeldClose(ws *websocket.Conn) (held func() bool) {
var got atomic.Bool
def := ws.CloseHandler()
ws.SetCloseHandler(func(code int, text string) error {
if code == laneHeldCloseCode {
got.Store(true)
}
return def(code, text)
})
return got.Load
}
// beatSaysQuarantined reads the "quarantined" a Host puts on the beat's reply
// for a machine it holds. A reply without it, or one that cannot be read, is
// not a hold.
func beatSaysQuarantined(body []byte) bool {
var r struct {
Quarantined bool `json:"quarantined"`
}
return json.Unmarshal(body, &r) == nil && r.Quarantined
}
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
// heldHost stands in for a Host's lane port: it counts each worker's hello,
// then either closes the socket with the code a Host gives a machine it holds,
// or parks it.
type heldHost struct {
hellos atomic.Int32
hold atomic.Bool
tls *tls.Config
dial func(ctx context.Context) (net.Conn, error)
url string
}
func startHeldHost(t *testing.T) *heldHost {
t.Helper()
h := &heldHost{}
h.hold.Store(true)
up := websocket.Upgrader{}
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
defer c.Close()
if _, _, err := c.ReadMessage(); err != nil {
return
}
h.hellos.Add(1)
if h.hold.Load() {
_ = c.WriteControl(websocket.CloseMessage,
websocket.FormatCloseMessage(laneHeldCloseCode, "this bastion is quarantined"), time.Now().Add(time.Second))
return
}
for {
if _, _, err := c.ReadMessage(); err != nil {
return
}
}
}))
srv.StartTLS()
t.Cleanup(srv.Close)
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
h.tls = &tls.Config{RootCAs: pool, ServerName: "example.com"}
addr := srv.Listener.Addr().String()
h.dial = func(ctx context.Context) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}
h.url = "wss://example.com/lane"
return h
}
// shortHolds makes the holds short enough for a test. Set before any worker
// runs, and never from a parallel subtest.
func shortHolds(t *testing.T, first, max time.Duration) {
t.Helper()
f, m := laneHoldFirst, laneHoldMax
laneHoldFirst, laneHoldMax = first, max
t.Cleanup(func() { laneHoldFirst, laneHoldMax = f, m })
}
// ownHold gives a test its own pairing's hold, and forgets it after.
func ownHold(t *testing.T, host string) {
t.Helper()
laneHolds.Delete(host)
t.Cleanup(func() { laneHolds.Delete(host) })
}
// Every lane's pool, so a lane added later without the hold is caught.
func lanePools(dir string) map[string]func(ctx context.Context, host string, h *heldHost, n int) {
return map[string]func(ctx context.Context, host string, h *heldHost, n int){
"egress": func(ctx context.Context, host string, h *heldHost, n int) {
var d net.Dialer
runEgressWorkers(ctx, host, h.url, h.tls, h.dial, "b", d.DialContext, n)
},
"serve": func(ctx context.Context, host string, h *heldHost, n int) {
runServeWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
},
"mgmt": func(ctx context.Context, host string, h *heldHost, n int) {
runMgmtWorkers(ctx, host, h.url, h.tls, h.dial, "b", dir, dir, dir, n)
},
"harness": func(ctx context.Context, host string, h *heldHost, n int) {
runHarnessWorkers(ctx, host, dir, h.url, h.tls, h.dial, "b", n)
},
"session": func(ctx context.Context, host string, h *heldHost, n int) {
runSessionWorkers(ctx, host, h.url, h.tls, h.dial, "b", n,
sessionEnv{CtxRoot: dir, DcnDir: dir, HostName: host, Server: "test"})
},
}
}
// A Host that holds this machine closes every worker socket with 4403. No
// worker of any lane dials it again before the hold is over: one hello per
// worker, where a pool used to dial again at once or a second later, 30 to 80
// times a second from one held machine.
func TestEveryLanePoolWaitsOutAHostThatHoldsThisMachine(t *testing.T) {
shortHolds(t, 3*time.Second, time.Minute)
for lane, run := range lanePools(t.TempDir()) {
t.Run(lane, func(t *testing.T) {
t.Parallel()
host := "held-" + lane + ".example.test"
ownHold(t, host)
h := startHeldHost(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
const n = 2
go run(ctx, host, h, n)
time.Sleep(2 * time.Second)
if got := h.hellos.Load(); got != n {
t.Fatalf("%d dials within the hold from %d workers; want one each", got, n)
}
})
}
}
// A beat whose reply says quarantined stops the lanes before any of them
// dials, and the first beat that no longer says it brings them back at once.
func TestABeatThatSaysQuarantinedStopsTheLanesUntilOneDoesNot(t *testing.T) {
host := "quarantined.example.test"
shortHolds(t, time.Hour, time.Hour)
ownHold(t, host)
h := startHeldHost(t)
h.hold.Store(false)
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true,"quarantined":true}`)))
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
const n = 2
go lanePools(t.TempDir())["mgmt"](ctx, host, h, n)
time.Sleep(700 * time.Millisecond)
if got := h.hellos.Load(); got != 0 {
t.Fatalf("%d dials while the Host says it holds this machine", got)
}
laneHoldFor(host).fromBeat(host, beatSaysQuarantined([]byte(`{"ok":true}`)))
deadline := time.Now().Add(3 * time.Second)
for h.hellos.Load() < n && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if got := h.hellos.Load(); got != n {
t.Fatalf("after the release, %d of %d workers parked again", got, n)
}
}
// A pool's workers are refused together, so a refusal inside a hold is that
// hold's; each hold after one runs out is twice as long, up to the most; a
// refusal long after the last starts again from the first; a release ends a
// hold at once.
func TestAHoldDoublesOncePerHoldUpToItsMost(t *testing.T) {
host := "doubling.example.test"
shortHolds(t, time.Second, 4*time.Second)
ownHold(t, host)
h := laneHoldFor(host)
expire := func() {
h.mu.Lock()
h.until = time.Now().Add(-time.Millisecond)
h.mu.Unlock()
}
wait := func() time.Duration {
h.mu.Lock()
defer h.mu.Unlock()
return h.wait
}
h.refused(host)
h.refused(host)
h.refused(host)
if got := wait(); got != time.Second {
t.Fatalf("three refusals in one hold: %s, want the first hold", got)
}
var got []string
for range 3 {
expire()
h.refused(host)
got = append(got, wait().String())
}
if strings.Join(got, " ") != "2s 4s 4s" {
t.Fatalf("holds after the first: %v, want 2s 4s 4s", got)
}
expire()
h.mu.Lock()
h.last = time.Now().Add(-9 * time.Second)
h.mu.Unlock()
h.refused(host)
if got := wait(); got != time.Second {
t.Fatalf("a refusal after a long quiet: %s, want the first hold again", got)
}
h.fromBeat(host, true)
h.fromBeat(host, false)
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
if !h.waitClear(ctx) {
t.Fatal("a release must end the hold at once")
}
}
func TestBeatSaysQuarantinedReadsOnlyTheHostsTrue(t *testing.T) {
for body, want := range map[string]bool{
`{"ok":true,"quarantined":true}`: true,
`{"ok":true,"quarantined":false}`: false,
`{"ok":true}`: false,
`{"quarantined":"yes"}`: false,
`not json`: false,
``: false,
} {
if got := beatSaysQuarantined([]byte(body)); got != want {
t.Errorf("%q: %v, want %v", body, got, want)
}
}
}
+8
View File
@@ -347,7 +347,11 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
} }
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
go func() { go func() {
hold := laneHoldFor(self)
for pctx.Err() == nil { for pctx.Err() == nil {
if !hold.waitClear(pctx) {
return
}
ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
cancel() cancel()
@@ -360,6 +364,7 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
sleepCtx(pctx, time.Second) sleepCtx(pctx, time.Second)
continue continue
} }
held := watchHeldClose(ws)
stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park stopKA := armWSKeepalive(ws) // ping/read-deadline: a dead tunnel unblocks the park
// A revoke while parked: closing the socket is what unblocks the read. // A revoke while parked: closing the socket is what unblocks the read.
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() }) unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
@@ -367,6 +372,9 @@ func runMgmtWorkers(pctx context.Context, self string, wsURL string, tlsCfg *tls
unpark() unpark()
stopKA() stopKA()
_ = ws.Close() _ = ws.Close()
if held() {
hold.refused(self) // the Host holds this machine (lanehold.go)
}
} }
}() }()
} }
-23
View File
@@ -362,26 +362,3 @@ func utf16Len(s string) int {
} }
return n 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.",
}
}
+7 -1
View File
@@ -53,6 +53,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"os" "os"
"os/exec" "os/exec"
"os/user" "os/user"
@@ -648,7 +649,12 @@ func opUpperChanges(upper, hostRoot string) ([]opChange, error) {
out = append(out, opChange{Path: real, Kind: "special"}) out = append(out, opChange{Path: real, Kind: "special"})
return nil return nil
case mode.IsDir(): case mode.IsDir():
if opaque, _ := opXattrOpaque(p); opaque { // Opaque hides what the lower holds at this path: a replaced
// tree, refused. With nothing there it hides nothing. A kernel
// whose overlay has index, redirect_dir or metacopy on marks every
// new folder under an existing one opaque, so such a folder is
// created, and its contents are read like any other.
if opaque, _ := opXattrOpaque(p); opaque && !errors.Is(lowerErr, fs.ErrNotExist) {
out = append(out, opChange{Path: real, Kind: "opaque"}) out = append(out, opChange{Path: real, Kind: "opaque"})
return filepath.SkipDir return filepath.SkipDir
} }
+38
View File
@@ -13,6 +13,8 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"golang.org/x/sys/unix"
) )
// ── the upper-dir reader (no sandbox needed) ──────────────────────────────── // ── the upper-dir reader (no sandbox needed) ────────────────────────────────
@@ -102,6 +104,42 @@ func TestUpperChangesReadCreatedModifiedAndLinks(t *testing.T) {
} }
} }
// A kernel whose overlay has index, redirect_dir or metacopy on marks each
// new folder under an existing one opaque. Over nothing the machine holds,
// such a folder is created and what it holds is read; over a folder the
// machine holds, opaque is a replaced tree, and refused.
func TestAnOpaqueNewFolderIsCreatedAndAReplacedOneIsRefused(t *testing.T) {
lower := t.TempDir()
upper := t.TempDir()
_ = os.MkdirAll(filepath.Join(lower, "srv/kept"), 0o755)
_ = os.WriteFile(filepath.Join(lower, "srv/kept/old.txt"), []byte("old\n"), 0o644)
for _, d := range []string{"srv/new", "srv/kept"} {
_ = os.MkdirAll(filepath.Join(upper, d), 0o755)
if err := unix.Setxattr(filepath.Join(upper, d), "trusted.overlay.opaque", []byte("y"), 0); err != nil {
t.Skipf("cannot set an overlay attribute here (%v): needs root on a filesystem with trusted attributes", err)
}
}
_ = os.WriteFile(filepath.Join(upper, "srv/new/hello.txt"), []byte("hi\n"), 0o644)
changes, err := opUpperChanges(upper, lower)
if err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, c := range changes {
got[strings.TrimPrefix(c.Path, lower)] = c.Kind
}
if got["/srv/new"] != "created_dir" || got["/srv/new/hello.txt"] != "created" {
t.Fatalf("an opaque folder over nothing is created, with what it holds: %v", got)
}
if got["/srv/kept"] != "opaque" {
t.Fatalf("an opaque folder over one the machine holds is a replaced tree: %v", got)
}
m := &opManifest{Effects: opEffects{Files: []string{lower + "/srv/"}}}
if why := opEffectsViolation(m, &opReport{Changes: changes}); !strings.Contains(why, "opaque") {
t.Fatalf("the replaced tree is refused even inside the effects: %q", why)
}
}
func TestHostMountsAndEffectPrefix(t *testing.T) { func TestHostMountsAndEffectPrefix(t *testing.T) {
mounts := []opHostMount{{"/", "ext4"}, {"/srv", "xfs"}, {"/srv/share", "nfs4"}, {"/proc", "proc"}, {"/home", "ext4"}} mounts := []opHostMount{{"/", "ext4"}, {"/srv", "xfs"}, {"/srv/share", "nfs4"}, {"/proc", "proc"}, {"/home", "ext4"}}
for p, want := range map[string]string{ for p, want := range map[string]string{
+15 -7
View File
@@ -28,7 +28,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"strings" "strings"
"time" "time"
@@ -47,8 +46,13 @@ type agentActReply struct {
var actNow = time.Now var actNow = time.Now
func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) { 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 r.User = user
if why := readLaneRefusal(uid, user); why != "" { if why := readLaneRefusalFor(peer); why != "" {
r.Why = actWording(why) r.Why = actWording(why)
return r return r
} }
@@ -65,7 +69,7 @@ func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
r.Failed, r.Why = true, err.Error() r.Failed, r.Why = true, err.Error()
return r return r
} }
ownerActAudit(what, user) ownerActAudit(what, user, peer.windows())
r.OK, r.Done = true, done r.OK, r.Done = true, done
return r 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 // 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, // needs no code set up): dcnAuditWith joins its directory onto audit.jsonl,
// and "" would be the daemon's working directory. // and "" would be the daemon's working directory.
func ownerActAudit(what, user string) { func ownerActAudit(what, user string, pipe bool) {
dir, err := machineFactorDir() dir, err := machineFactorDir()
if err != nil { if err != nil {
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user) uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
return return
} }
lane := "its ask socket"
if pipe {
lane = "its ask pipe"
}
dcnAuditWith(dir, "owner_lane_act", 0, "", 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 ───────────────────────────────────────────────────────── // ── the asking side ─────────────────────────────────────────────────────────
func actViaSock(req agentAskRequest) (string, error) { func actViaSock(req agentAskRequest) (string, error) {
p := readSockPath() p := askLaneAddr(readSockPath())
c, err := net.DialTimeout("unix", p, 3*time.Second) c, err := dialAskLane(readSockPath())
if err != nil { if err != nil {
return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"} return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
} }
+17 -18
View File
@@ -31,7 +31,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"os" "os"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
@@ -99,8 +98,8 @@ var machineReads = map[string]machineRead{
// readsGoThroughLane: the machine's bastion is a system service this account // readsGoThroughLane: the machine's bastion is a system service this account
// does not administer, so its state is not this process's to read. // does not administer, so its state is not this process's to read.
var readsGoThroughLane = func() bool { var readsGoThroughLane = func() bool {
if runtime.GOOS == "windows" || machineAuthorityHeld() { if machineAuthorityHeld() {
return false // no ask pipe on Windows yet; the administrator reads directly return false // the administrator reads the service's state directly
} }
if runtime.GOOS == "linux" { if runtime.GOOS == "linux" {
return linuxSystemUnitPresent() return linuxSystemUnitPresent()
@@ -156,7 +155,7 @@ func laneRefusalError(cmd string, err error) error {
if !errors.As(err, &le) { if !errors.As(err, &le) {
return err 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 ───────────────────────────────────────────────────────── // ── the asking side ─────────────────────────────────────────────────────────
@@ -166,8 +165,8 @@ func laneRefusalError(cmd string, err error) error {
var readSockPath = askSockPath var readSockPath = askSockPath
func readViaSock(name string, o readOpts, v any) error { func readViaSock(name string, o readOpts, v any) error {
p := readSockPath() p := askLaneAddr(readSockPath())
c, err := net.DialTimeout("unix", p, 3*time.Second) c, err := dialAskLane(readSockPath())
if err != nil { if err != nil {
return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"} return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
} }
@@ -207,24 +206,20 @@ type agentReadReply struct {
View json.RawMessage `json:"view,omitempty"` 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 // 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. // account's sessions are the owner's to see, not another account's.
func readLaneRefusal(uid uint32, who string) string { func readLaneRefusalFor(p askPeer) string {
owner, ownerName, present, err := ownerAccountLoad() match, ownerName, present, err := laneOwnerMatch(p)
switch { switch {
case err != nil: case err != nil:
return "this machine's owner account cannot be read (" + err.Error() + ")" return "this machine's owner account cannot be read (" + err.Error() + ")"
case !present: case !present:
return "no account is recorded as this machine's owner, so its bastion answers reads only for the " + 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: " + "administrator. To read from your own account, the administrator records it once: " +
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who) ownerRecordCommand(p)
case owner != uid: case !match:
name := ownerName return "this machine's bastion answers reads only for its owner's account (" + ownerName + "), not " + p.Name
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
} }
return "" return ""
} }
@@ -249,8 +244,12 @@ var readNow = time.Now
var readSlots = make(chan struct{}, 4) var readSlots = make(chan struct{}, 4)
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply { func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
r := agentReadReply{User: user} return serveReadFor(askPeer{UID: uid, Name: user}, req)
if why := readLaneRefusal(uid, user); why != "" { }
func serveReadFor(p askPeer, req agentAskRequest) agentReadReply {
r := agentReadReply{User: p.Name}
if why := readLaneRefusalFor(p); why != "" {
r.Why = why r.Why = why
return r return r
} }
+3 -3
View File
@@ -67,7 +67,7 @@ func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
// `switch --list` passes the guard beside the system unit, as status does; // `switch --list` passes the guard beside the system unit, as status does;
// `switch` does too since the owner's account switches through the lane // `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) { func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) {
prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority
t.Cleanup(func() { os.Args, linuxSystemUnitPresent, systemService.authority = prevArgs, prevUnit, prevAuth }) 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 { 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) 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 { 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") t.Errorf("switch from the owner's account beside a Windows service, before the ask pipe could decide: %v", err)
} }
} }
+14 -14
View File
@@ -239,17 +239,17 @@ func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
} }
} }
// doctor from an ordinary account beside a Windows system service would check // doctor from an ordinary account beside a system service would, run
// that account's own empty bastion and report it as the machine's (not // directly, check that account's own empty bastion and report it as the
// running, no owner, no factor), so it is refused there as status is; on // machine's (not running, no owner, no factor). So it is a lane read, as
// Linux and macOS it reads the machine's bastion through the lane. // 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) { func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) {
if err := serviceGuard("windows", "doctor", true, false); err == nil { if !serviceGuardedCommands["doctor"] || !serviceLaneReads["doctor"] {
t.Error("doctor from an ordinary account beside a Windows service was let through") 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 { if err := serviceGuard(goos, "doctor", true, false); err != nil {
t.Errorf("%s: doctor beside the service is a lane read: %v", goos, err) 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 // The slice-3 reads from an ordinary account beside the service ask the
// ordinary account beside the service are refused as status is; on Linux and // machine's bastion over its lane on every system (the ask pipe on Windows),
// macOS they ask the machine's bastion. The rest of backup is not guarded. // so the guard lets them by. The rest of backup is not guarded.
func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) { func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
for _, line := range [][]string{ for _, line := range [][]string{
{"privatae-bastion", "ops", "--json"}, {"privatae-bastion", "ops", "--json"},
@@ -500,10 +500,10 @@ func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
{"privatae-bastion", "backup", "key", "show"}, {"privatae-bastion", "backup", "key", "show"},
} { } {
key := serviceGuardKey(line[1], line) key := serviceGuardKey(line[1], line)
if err := serviceGuard("windows", key, true, false); err == nil { if !serviceGuardedCommands[key] || !serviceLaneReads[key] {
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:]) 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 { 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) t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
} }
+5
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strconv"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
@@ -53,6 +54,10 @@ func relaunchAfterUpdate() {
return return
} }
cmd := exec.Command(self, os.Args[1:]...) 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{ cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP, CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
} }
+3
View File
@@ -646,6 +646,8 @@ func runPairing(ctx context.Context, cfg *Config) error {
// silence path reads this — 14 days with no 2xx from the // silence path reads this — 14 days with no 2xx from the
// anchor host lets root re-anchor without its consent. // anchor host lets root re-anchor without its consent.
recordPairingOK(cfg.HostName) recordPairingOK(cfg.HostName)
// A Host that holds this machine says so; its lanes wait (lanehold.go).
laneHoldFor(cfg.HostName).fromBeat(cfg.HostName, beatSaysQuarantined(body))
} }
// Box-wide directives (update, contain, mirror) are anchor-only // Box-wide directives (update, contain, mirror) are anchor-only
// on an anchored box — each takes this pairing's identity and // on an anchored box — each takes this pairing's identity and
@@ -891,6 +893,7 @@ func runUnixPairing(ctx context.Context, cfg *Config) error {
} }
if resp.StatusCode < 300 { if resp.StatusCode < 300 {
recordPairingOK(cfg.HostName) recordPairingOK(cfg.HostName)
laneHoldFor(cfg.HostName).fromBeat(cfg.HostName, beatSaysQuarantined(body))
} }
maybeSelfUpdate(cfg.HostName, cfg.dcnDir(), body) maybeSelfUpdate(cfg.HostName, cfg.dcnDir(), body)
maybeContain(cfg.HostName, cfg.dcnDir(), body) maybeContain(cfg.HostName, cfg.dcnDir(), body)
+32 -12
View File
@@ -8,6 +8,9 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"time"
"golang.org/x/sys/windows"
) )
// runGuardFile holds the "one running bastion" lock for the life of this // 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 // cannotStart is the wording for the two I/O failures below. They have no
// prepared words the way the already-running case does (secondRunNotice, // prepared words the way the already-running case does (secondRunWords,
// notify.go), and the error itself is the most accurate thing there is to say: // 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 // both mean the config directory is unusable, which is not a state anyone can be
// given advice about from here. // given advice about from here.
func cannotStart(err error) uiNotice { func cannotStart(err error) uiNotice {
@@ -90,19 +93,36 @@ func guardSingleRun() error {
err = fmt.Errorf("cannot open the run lock: %w", err) err = fmt.Errorf("cannot open the run lock: %w", err)
return refuseVisibly(cannotStart(err), 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() _ = f.Close()
// The dialog and the console get DIFFERENT words for the same fact, and // 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 // that is on purpose: the console's are laid out for a terminal, with
// continuation lines, and "you can close this window" meaning the console // indented continuation lines (secondRunWords).
// window it is being printed into. secondRunNotice says it for a dialog. h, known := readRunHolder(d)
return refuseVisibly(secondRunNotice(), errors.New( text, notice := secondRunWords(h, known)
"a bastion is already running on this machine — that one is serving "+ return refuseVisibly(notice, errors.New(text))
"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"))
} }
runGuardFile.Store(f) runGuardFile.Store(f)
writeRunHolder(d, stdinIsTerminal())
return nil 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))
}
+141
View File
@@ -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 <its 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
}
+9
View File
@@ -616,7 +616,11 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
spawn = func() { spawn = func() {
go func() { go func() {
wait := 3 * time.Second wait := 3 * time.Second
hold := laneHoldFor(self)
for pctx.Err() == nil { for pctx.Err() == nil {
if !hold.waitClear(pctx) {
return
}
ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
ws, resp, err := dialer.DialContext(ctx, wsURL, http.Header{}) ws, resp, err := dialer.DialContext(ctx, wsURL, http.Header{})
cancel() cancel()
@@ -635,6 +639,7 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
sleepCtx(pctx, time.Second) sleepCtx(pctx, time.Second)
continue continue
} }
held := watchHeldClose(ws)
stopKA := armWSKeepalive(ws) stopKA := armWSKeepalive(ws)
unpark := context.AfterFunc(pctx, func() { _ = ws.Close() }) unpark := context.AfterFunc(pctx, func() { _ = ws.Close() })
took := serveJob(ws, self, dcnDir, spawn) took := serveJob(ws, self, dcnDir, spawn)
@@ -644,6 +649,10 @@ func runServeWorkers(pctx context.Context, self, dcnDir, wsURL string, tlsCfg *t
if took { if took {
return return
} }
if held() {
hold.refused(self) // the Host holds this machine (lanehold.go)
continue
}
// Closed while parked (a Host restart), or the job refused: not // Closed while parked (a Host restart), or the job refused: not
// straight back, so a Host that closes or misuses every worker is // straight back, so a Host that closes or misuses every worker is
// not dialled in a loop. // not dialled in a loop.
+387
View File
@@ -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
}
+353
View File
@@ -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/<host>.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)
}
}
+8
View File
@@ -83,7 +83,11 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
} }
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
go func() { go func() {
hold := laneHoldFor(self)
for pctx.Err() == nil { for pctx.Err() == nil {
if !hold.waitClear(pctx) {
return
}
ctx, cancel := context.WithTimeout(pctx, 25*time.Second) ctx, cancel := context.WithTimeout(pctx, 25*time.Second)
ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{}) ws, _, err := dialer.DialContext(ctx, wsURL, http.Header{})
cancel() cancel()
@@ -96,6 +100,7 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
sleepCtx(pctx, time.Second) sleepCtx(pctx, time.Second)
continue continue
} }
held := watchHeldClose(ws)
stopKA := armWSKeepalive(ws) stopKA := armWSKeepalive(ws)
// A revoked pairing closes the socket under the session, which ends the // A revoked pairing closes the socket under the session, which ends the
// shell through the same path a host disconnect takes. // shell through the same path a host disconnect takes.
@@ -104,6 +109,9 @@ func runSessionWorkers(pctx context.Context, self string, wsURL string, tlsCfg *
unpark() unpark()
stopKA() stopKA()
_ = ws.Close() _ = ws.Close()
if held() {
hold.refused(self) // the Host holds this machine (lanehold.go)
}
} }
}() }()
} }
+31 -11
View File
@@ -28,9 +28,11 @@ package main
// installed and the caller administers the machine, the process points // installed and the caller administers the machine, the process points
// that one variable at the service's own before anything reads state. // that one variable at the service's own before anything reads state.
// - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed, // - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed,
// `pair`, `run` and `status` from an account that does not administer the // `pair` and `run` from an account that does not administer the machine
// machine would act on an empty bastion of that account's own, beside the // 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 // 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 // with the machine marker (singleton.go), this binds a cooperating binary
// only; an older copy will not refuse. What stops the person from removing // only; an older copy will not refuse. What stops the person from removing
// the service is that they do not administer the machine. // the service is that they do not administer the machine.
@@ -46,6 +48,11 @@ import (
"time" "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 ( const (
launchdLabel = "ai.privatae.bastion" launchdLabel = "ai.privatae.bastion"
launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist" 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. // account each read that account's own, empty approvals, grants or asks.
"approvals drop": true, "grants": true, "device-deny": true, "device-approve": true, "approvals drop": true, "grants": true, "device-deny": true, "device-approve": true,
// The machine's approvals: read, approved and denied through the owner's // 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 // lane (reads.go, owneracts.go): ask.sock on Linux and macOS, the ask pipe
// answers them over its own pipe, and these would read the account's own. // on Windows. Run directly, these would read the account's own.
"approvals": true, "approve": true, "deny": true} "approvals": true, "approve": true, "deny": true}
// serviceLaneReads are the guarded commands that, on Linux and macOS, read // serviceLaneReads are the guarded commands that read the machine's bastion
// the machine's bastion itself over its socket (reads.go) rather than a // itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on
// bastion of the caller's own: not refused here, and refused there for any // Windows) rather than a bastion of the caller's own: not refused here, and
// account but the owner's. // refused there for any account but the owner's.
var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true, var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true,
"doctor": true, "ops": true, "device-approvals": true, "backup read": true, "doctor": true, "ops": true, "device-approvals": true, "backup read": true,
// Not reads: the owner's account switches, and takes reach back (`ops // 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] { if !installed || authority || !serviceGuardedCommands[cmd] {
return nil return nil
} }
if serviceLaneReads[cmd] && goos != "windows" { if serviceLaneReads[cmd] {
return nil return nil
} }
return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+ 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 installed bool
authority bool authority bool
err error 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 // 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 // authority only after an installed service left root on Linux looking like
// an ordinary account, and enrolServiceStateRefusal refused it. // an ordinary account, and enrolServiceStateRefusal refused it.
func adoptSystemServiceState() { func adoptSystemServiceState() {
systemService.callerAppData = os.Getenv("APPDATA")
installed, err := systemServiceInstalled() installed, err := systemServiceInstalled()
systemService.err = err systemService.err = err
systemService.installed = installed || err != nil systemService.installed = installed || err != nil
@@ -428,8 +440,16 @@ func serviceGuardKey(cmd string, args []string) string {
// cmdService is `privatae-bastion service install|uninstall|status`. // cmdService is `privatae-bastion service install|uninstall|status`.
func cmdService(args []string) error { func cmdService(args []string) error {
const usage = "usage: privatae-bastion service install [--from-account <SID>] | 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 { if len(args) != 1 {
return errors.New("usage: privatae-bastion service install|uninstall|status") return errors.New(usage)
} }
switch args[0] { switch args[0] {
case "install": case "install":
+24 -24
View File
@@ -167,9 +167,10 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
for _, goos := range []string{"darwin", "windows", "linux"} { for _, goos := range []string{"darwin", "windows", "linux"} {
for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} { for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} {
err := serviceGuard(goos, cmd, true, false) err := serviceGuard(goos, cmd, true, false)
if (cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report") && goos != "windows" { if cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report" {
// These read the machine's bastion over its socket (reads.go), // These read the machine's bastion over its lane (reads.go:
// which answers the owner's account and refuses any other. // ask.sock, or the ask pipe on Windows), which answers the
// owner's account and refuses any other.
if err != nil { if err != nil {
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err) 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 // The narrowing acts beside a system service go through the owner's lane
// through the owner's lane (owneracts.go), so the guard lets them by; on // (owneracts.go) on every system, ask.sock or the Windows ask pipe, so the
// Windows there is no lane, and the ordinary account's own profile is a // guard lets them by; each must be both guarded and a lane command, or from
// separate, empty bastion, so they are refused with the administrator's way, // an ordinary account it would act on that account's own, empty bastion.
// as `ops` (and so `ops drop`) already was. device-approve is the // device-approve is the administrator's on every system: the console keeps
// administrator's on every system: the console keeps it adminOnly, and from // it adminOnly, and from an ordinary account it read that account's own,
// an ordinary account it read that account's own, empty asks. // empty asks.
// //
// MUTATION: take "device-deny" out of serviceGuardedCommands. RESULT: red on // MUTATION: take "device-deny" out of serviceLaneReads. RESULT: red on every
// windows. Take "device-approve" out. RESULT: red on every system. // system. Take "device-approve" out of serviceGuardedCommands. RESULT: red on
// every system.
func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) { func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
lines := map[string][]string{ lines := map[string][]string{
"approvals drop": {"privatae-bastion", "approvals", "--host", "box-one.example", "drop", "abcd1234"}, "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 { if got := serviceGuardKey(args[1], args); got != want {
t.Errorf("%v is guarded as %q, want %q", args[1:], 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" { if got := serviceGuardKey("approvals", []string{"privatae-bastion", "approvals", "--machine"}); got != "approvals" {
t.Errorf("the approvals read is guarded as %q", got) 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"}) approve := serviceGuardKey("device-approve", []string{"privatae-bastion", "device-approve", "all"})
for _, goos := range []string{"linux", "darwin", "windows"} { for _, goos := range []string{"linux", "darwin", "windows"} {
for key := range lines { for key := range lines {
err := serviceGuard(goos, key, true, false) if err := serviceGuard(goos, key, true, false); err != nil {
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 {
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, key, err) 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 { 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 // The machine's approvals beside a system service go through the owner's
// socket on Linux and macOS, refused on Windows (the tray answers them there). // lane on every platform: ask.sock on Linux and macOS, the ask pipe on
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) { // Windows, which answers the owner's account and refuses any other.
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLane(t *testing.T) {
for _, line := range [][]string{ for _, line := range [][]string{
{"privatae-bastion", "approvals", "--machine"}, {"privatae-bastion", "approvals", "--machine"},
{"privatae-bastion", "approve", "--machine", "abcd1234"}, {"privatae-bastion", "approve", "--machine", "abcd1234"},
{"privatae-bastion", "deny", "abcd1234"}, {"privatae-bastion", "deny", "abcd1234"},
} { } {
key := serviceGuardKey(line[1], line) key := serviceGuardKey(line[1], line)
if err := serviceGuard("windows", key, true, false); err == nil { for _, goos := range []string{"linux", "darwin", "windows"} {
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
}
for _, goos := range []string{"linux", "darwin"} {
if err := serviceGuard(goos, key, true, false); err != nil { 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) t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
} }
+50
View File
@@ -24,6 +24,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"syscall" "syscall"
"time" "time"
"unsafe" "unsafe"
@@ -236,6 +237,20 @@ func installSystemService() error {
} }
defer s.Close() 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} restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second}
if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil { 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) 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 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 // 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. // for it to say it has. A service that is not running is already stopped.
func stopServiceAndWait(s *mgr.Service) { func stopServiceAndWait(s *mgr.Service) {
+51 -7
View File
@@ -130,6 +130,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour
// line of the page where the day is the point. // line of the page where the day is the point.
const uiEventTime = "2 Jan 2006, 15:04" const uiEventTime = "2 Jan 2006, 15:04"
// Admin is `privatae-bastion <cmd>` 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 { type uiStatus struct {
// Limited: drawn for the owner's lane (consolelane.go), which may not // Limited: drawn for the owner's lane (consolelane.go), which may not
// answer the approval device's requests. // answer the approval device's requests.
@@ -499,8 +503,7 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
return return
} }
if !u.signedIn(r) { if !u.signedIn(r) {
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`", http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
http.StatusUnauthorized)
return return
} }
if u.limitedSession(r) { 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. // root serves the page, and is also where a ticket is redeemed.
func (u *uiServer) root(w http.ResponseWriter, r *http.Request) { func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
if why := uiRequestAllowed(r, u.port, u.verb()); why != "" { if why := uiRequestAllowed(r, u.port, u.verb()); why != "" {
@@ -548,8 +576,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
return return
} }
if !u.signedIn(r) { if !u.signedIn(r) {
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`", http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
http.StatusUnauthorized)
return return
} }
if u.contextUI { if u.contextUI {
@@ -937,9 +964,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", addr, fmt.Errorf("the local UI refused this machine's own key (%s) — "+ return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld())
"if the bastion was reinstalled, restart it so it and this command agree",
resp.Status)
} }
var out struct { var out struct {
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
@@ -954,6 +979,25 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
return entry, addr, nil 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 // 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 // belongs to the running daemon, because that is the process that knows what is
// live. // live.
+1 -1
View File
@@ -58,7 +58,7 @@
</form> </form>
{{if $.Limited}} {{if $.Limited}}
<p class="approval-line">Approving signs with this device's key, which is the administrator's: <p class="approval-line">Approving signs with this device's key, which is the administrator's:
<code>sudo -H privatae-bastion ui</code></p> <code>{{$.Admin "ui"}}</code></p>
{{else}} {{else}}
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML"> <form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
<input type="hidden" name="id" value="{{.ID}}"> <input type="hidden" name="id" value="{{.ID}}">
+6 -6
View File
@@ -89,7 +89,7 @@
{{else if .OwnerCode}} {{else if .OwnerCode}}
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p> <p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
{{if .Limited}} {{if .Limited}}
<p class="sub">To replace it, at a terminal: <code>sudo -H privatae-bastion stepup-enroll --machine --rotate</code></p> <p class="sub">To replace it, at a terminal: <code>{{$.Admin "stepup-enroll --machine --rotate"}}</code></p>
{{else}} {{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML"> <form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine"> <input type="hidden" name="target" value="machine">
@@ -99,7 +99,7 @@
{{end}} {{end}}
{{else if .Limited}} {{else if .Limited}}
<p class="sub">Set it up once, at a terminal, as the machine's administrator, so no program running as you <p class="sub">Set it up once, at a terminal, as the machine's administrator, so no program running as you
can set it up first: <code>sudo -H privatae-bastion stepup-enroll --machine</code></p> can set it up first: <code>{{$.Admin "stepup-enroll --machine"}}</code></p>
{{else}} {{else}}
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML"> <form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
<input type="hidden" name="target" value="machine"> <input type="hidden" name="target" value="machine">
@@ -146,7 +146,7 @@
<h3>Connect a Host</h3> <h3>Connect a Host</h3>
<div class="host"> <div class="host">
<p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p> <p class="sub">The token comes from your host's “Set up a bastion”. It is used once.</p>
{{if .Limited}}<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p>{{else}} {{if .Limited}}<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>{{else}}
<form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML" <form class="setup-form" hx-post="/setup/pair" hx-target="#setup" hx-swap="innerHTML"
autocomplete="off"> autocomplete="off">
<label for="pair-token">Token</label> <label for="pair-token">Token</label>
@@ -199,7 +199,7 @@
</div> </div>
</div> </div>
{{else if and .Servable .Limited}} {{else if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p> <p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Servable}} {{else if .Servable}}
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine {{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
at all, not even a directory (run.go:311), and a factor for it would at all, not even a directory (run.go:311), and a factor for it would
@@ -231,7 +231,7 @@
<h3>Add a folder to your network</h3> <h3>Add a folder to your network</h3>
<div class="host"> <div class="host">
{{if and .Servable .Limited}} {{if and .Servable .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p> <p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Servable}} {{else if .Servable}}
<p class="sub">Your files stay on this machine. Your host keeps only the index, and <p class="sub">Your files stay on this machine. Your host keeps only the index, and
the draft does nothing until you approve it there.</p> the draft does nothing until you approve it there.</p>
@@ -275,7 +275,7 @@
<p class="sub">The anchor host is the one that approves every other host paired here. <p class="sub">The anchor host is the one that approves every other host paired here.
It is a choice among the hosts this machine already serves.</p> It is a choice among the hosts this machine already serves.</p>
{{if and .Hosts .Limited}} {{if and .Hosts .Limited}}
<p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>sudo -H privatae-bastion ui</code></p> <p class="sub">From your own account the console does not do this: it widens what reaches this machine and takes no code. The machine's administrator does it: <code>{{$.Admin "ui"}}</code></p>
{{else if .Hosts}} {{else if .Hosts}}
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML"> <form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
{{range .Hosts}} {{range .Hosts}}
+3
View File
@@ -263,6 +263,9 @@ type uiCommand struct {
Class string // ok | warn | bad — bad means "this cannot be run yet" Class string // ok | warn | bad — bad means "this cannot be run yet"
} }
// Admin is uiStatus.Admin for the setup page.
func (uiSetupView) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) }
type uiSetupView struct { type uiSetupView struct {
Version string Version string
Mode string Mode string
+1 -1
View File
@@ -1 +1 @@
0.61.0 0.62.0
+17 -9
View File
@@ -23,10 +23,13 @@
# that form errors without installing anything.) # that form errors without installing anything.)
# On an install that already exists, `privatae-bastion set-mode networked` (from # On an install that already exists, `privatae-bastion set-mode networked` (from
# an Administrator terminal) changes the mode but not how it runs: it stays a # an Administrator terminal) changes the mode but not how it runs: it stays a
# logon task. Re-run this installer with -Networked to make it a service. The # logon task. Re-run this installer with -Networked to make it a service. When
# service starts with its own state: pairings made by the earlier per-user # an install moves the bastion from your account to the service, the service
# install stay in that user's profile and are not carried over, so the machine # takes over your account's bastion state before it first starts: its
# is paired again (claim-anchor with a token from the host). # pairings, keys and grants, so every Host it served goes on being served and
# nothing is paired again (`service install --from-account`, servicecarry.go).
# The copy in your profile is then removed, so the keys no longer sit where
# programs running as you can read them.
# #
# ELEVATE AS YOURSELF, not as a different administrator account. The state # ELEVATE AS YOURSELF, not as a different administrator account. The state
# directory is per-user (a folder under %APPDATA%), and UAC elevation of your # directory is per-user (a folder under %APPDATA%), and UAC elevation of your
@@ -248,9 +251,16 @@ if (-not $elevatedDone) {
Where-Object { $_.Path -and ($_.Path -ne $exe) } | Where-Object { $_.Path -and ($_.Path -ne $exe) } |
ForEach-Object { ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
# Stop-Process returns before the process is gone, and its run
# lock goes with it; the carry below needs that lock free.
Wait-Process -Id $_.Id -Timeout 10 -ErrorAction SilentlyContinue
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)." Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
} }
& $exe service install # The account that asked for this install, or this one when run elevated
# by hand: its bastion's pairings go into the service, and it is recorded
# as the machine's owner below.
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
& $exe service install --from-account $owner
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
} else { } else {
try { try {
@@ -326,10 +336,8 @@ if (-not $elevatedDone) {
& $exe set-mode networked & $exe set-mode networked
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" } if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
} }
# The machine's owner: the account that asked for this install, or this # The machine's owner ($owner, above). A second administrator's
# one when run elevated by hand. A second administrator's reinstall leaves # reinstall leaves the first owner in place; the bastion says so.
# the first owner in place; the bastion says so.
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
& $exe approvals-owner --sid $owner & $exe approvals-owner --sid $owner
if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." } if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." }
Write-Host "" Write-Host ""
+6 -4
View File
@@ -11,6 +11,8 @@ it shares with Linux is tested there, but it has not run on that system yet.
|---|---|---|---| |---|---|---|---|
| Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run | | Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run |
| `--just-me`, and the fall back where sudo cannot be used | run | built | not run | | `--just-me`, and the fall back where sudo cannot be used | run | built | not run |
| Moving from an install for your account to one for the whole machine keeps its pairings | not needed (one state folder) | not built | built |
| An update restarts the bastion | run | built | built (the service restarts it; a sign-in task's copy waits for the old one to exit) |
| Every AI agent, by its program or its connection to a model | run | built | built | | Every AI agent, by its program or its connection to a model | run | built | built |
| Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built | | Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built |
| Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time | | Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time |
@@ -19,11 +21,11 @@ it shares with Linux is tested there, but it has not run on that system yet.
| MCP tool servers and their problems | tested | built | built | | MCP tool servers and their problems | tested | built | built |
| Changes since your baseline | tested | built | built | | Changes since your baseline | tested | built | built |
| Stop an agent with your code, from the console or `stop` | run | built | built | | Stop an agent with your code, from the console or `stop` | run | built | built |
| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | not built (the ask socket is Unix only) | | An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | built (the ask pipe) |
| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | not built (no ask pipe) | | An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | built (the ask pipe) |
| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | not built (no ask pipe) | | The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | built (the ask pipe) |
| The installer checks the release's signature | tested | built | not built (`install.ps1` checks the digest only) | | The installer checks the release's signature | tested | built | not built (`install.ps1` checks the digest only) |
| The console from the owner's own account, no sudo | run | built | the tray's approvals window only | | The console from the owner's own account, no sudo or Administrator terminal | run | built | built (the ask pipe); the tray's approvals window |
| Activity, report | run | built | built | | Activity, report | run | built | built |
| `uninstall`, keeping the audit log | run | built | built | | `uninstall`, keeping the audit log | run | built | built |
| A notice when something is waiting | none yet | none yet | the tray | | A notice when something is waiting | none yet | none yet | the tray |
+1
View File
@@ -92,6 +92,7 @@ says so. Those parts are enforced only by the code as written.
| | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. | | | `ops_linux_test.go`: `TestOpDryRunShowsTheChangeAndApplyCommitsExactlyThat` | A dry run commits nothing; an apply must name the dry run's change list and carry an approval bound to that op, its parameters, the machine and that change list. Skipped where the sandbox is unavailable. |
| | `ops_test.go`: `TestOpTermsMutationsAreEachRefused` | Each altered field of signed op terms is refused. | | | `ops_test.go`: `TestOpTermsMutationsAreEachRefused` | Each altered field of signed op terms is refused. |
| | `ops_linux_test.go`: `TestOpSandboxShowsOnlyTheBaseAndWhatTheOpDeclared` | In a real sandbox, an op reads the file it declared and the system base; a file beside its own folder, a folder beside it and `/etc/shadow` read as empty; a declared path that has since become a link onto `/etc/ssh` refuses the run. Skipped where the sandbox cannot run. | | | `ops_linux_test.go`: `TestOpSandboxShowsOnlyTheBaseAndWhatTheOpDeclared` | In a real sandbox, an op reads the file it declared and the system base; a file beside its own folder, a folder beside it and `/etc/shadow` read as empty; a declared path that has since become a link onto `/etc/ssh` refuses the run. Skipped where the sandbox cannot run. |
| | `ops_linux_test.go`: `TestAnOpaqueNewFolderIsCreatedAndAReplacedOneIsRefused` | A folder an op left opaque over one the machine holds (a replaced tree) is refused, inside its effects or not; a new folder the kernel marks opaque is a created folder, and what it holds is read. |
| | `ops_taskcap_linux_test.go`: `TestARootOpIsStoppedAtItsTaskCap` | A root op starting 100 background tasks is stopped at its scope's cap, and its report says the cap held. The scope is made by a stand-in for `systemd-run` that creates the cgroup directly. Skipped where the sandbox, systemd as init or a pids controller is unavailable. | | | `ops_taskcap_linux_test.go`: `TestARootOpIsStoppedAtItsTaskCap` | A root op starting 100 background tasks is stopped at its scope's cap, and its report says the cap held. The scope is made by a stand-in for `systemd-run` that creates the cgroup directly. Skipped where the sandbox, systemd as init or a pids controller is unavailable. |
| | `ops_view_test.go`: `TestOpSensitivePathsAreRefusedAtUnderOrAboveAndThroughLinks` | A declared path at, under or above a credential store, as written or through a link, is refused; a malformed `sensitive_paths` refuses every op. | | | `ops_view_test.go`: `TestOpSensitivePathsAreRefusedAtUnderOrAboveAndThroughLinks` | A declared path at, under or above a credential store, as written or through a link, is refused; a malformed `sensitive_paths` refuses every op. |
| | `fuzz_paths_unix_test.go`: `FuzzAnOpEffectNeverCoversTheMachinesOwnRecord` | No accepted effect covers the bastion's own records or the system directories the sandbox masks. | | | `fuzz_paths_unix_test.go`: `FuzzAnOpEffectNeverCoversTheMachinesOwnRecord` | No accepted effect covers the bastion's own records or the system directories the sandbox masks. |