Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2043fa5e9e |
@@ -22,6 +22,38 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once
|
||||
that is settled. The newest entries may not be published yet; the manifest
|
||||
says which version is. Older entries are not marked.
|
||||
|
||||
## 0.62.0
|
||||
|
||||
Windows, with the bastion installed for the whole machine (the
|
||||
`PrivataeBastion` service), from the account that owns the machine:
|
||||
|
||||
- **`privatae-bastion ui` opens the console from your own account,** with no
|
||||
Administrator terminal, as it does on Linux and macOS. It asks the service
|
||||
over a new pipe, which knows your account from Windows and serves only the
|
||||
account recorded as the machine's owner. Before this it signed in with a
|
||||
key of your account's own, which the service's console refused with a bare
|
||||
401.
|
||||
- **Your agents and you reach the machine's bastion as on Linux and macOS:**
|
||||
an agent asks to stop another (`stop`, `request`), reads the machine
|
||||
(`agents`, `activity`, `status`, `doctor`, the `--json` reads), and you
|
||||
answer from your own account (`approvals`, `approve`, `deny`, `switch`, the
|
||||
drops). These were refused beside the service.
|
||||
- **Moving to the service keeps the machine's pairings.** When an install
|
||||
moves the bastion from your account to the service, the service takes over
|
||||
your account's bastion state (pairings, keys, grants, audit) before it first
|
||||
starts, and the copy in your profile is removed. Every Host it served goes
|
||||
on being served, and nothing is paired again. The service started empty
|
||||
before, and the machine went silent to its Hosts.
|
||||
- **No dead ends.** A console page opened with no session names the command
|
||||
that opens it on this machine. A second start says which bastion is already
|
||||
running (its process, its program, how it was started) and how to open,
|
||||
check or restart it. It no longer suggests ending the running one in Task
|
||||
Manager.
|
||||
- **An update never leaves a sign-in task's bastion stopped.** After a
|
||||
self-update, the new copy waits for the old one to exit before it takes the
|
||||
machine's run lock. It could find the lock still held, refuse as a second
|
||||
copy, and leave nothing running.
|
||||
|
||||
## 0.61.2
|
||||
|
||||
- **A machine a Host holds stops dialling it.** When a Host's owner
|
||||
|
||||
+42
-28
@@ -8,7 +8,8 @@ package main
|
||||
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
|
||||
// managing the bastion for the person must be able to do (2026-10-30).
|
||||
// This is the context socket's mechanism (contextsock.go) for a second
|
||||
// question: any local account may connect; WHO connected comes from the
|
||||
// question, and on Windows the service's ask pipe (asklane_windows.go), which
|
||||
// answers through the same askAnswer: any local account may connect; WHO connected comes from the
|
||||
// kernel, never from the request; it may only ASK. The ask lands in the
|
||||
// owner's factor as a pending request (stepup_machine.go), shown on Waiting
|
||||
// for you and answered with the owner's code. Nothing here approves anything.
|
||||
@@ -81,7 +82,13 @@ var askPeerIdentityFn = contextPeerIdentity
|
||||
|
||||
func startAskSock(done <-chan struct{}) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return // a named pipe, as context.sock has, is not built yet
|
||||
// The ask pipe (asklane_windows.go). Only the service serves it: a
|
||||
// bastion run by an account is that account's, and nobody else's
|
||||
// requests are its to answer.
|
||||
if runningAsService {
|
||||
startAskPipe(done)
|
||||
}
|
||||
return
|
||||
}
|
||||
p := askSockPath()
|
||||
ln, err := contextSockListen(p)
|
||||
@@ -123,8 +130,7 @@ func askSockServe(c net.Conn) {
|
||||
_ = c.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
uid, user, err := askPeerIdentityFn(c)
|
||||
if err != nil {
|
||||
_ = json.NewEncoder(c).Encode(agentAskReply{Why: "this machine could not establish who is asking (" +
|
||||
err.Error() + "), so nothing was asked. An ask nobody can attribute is one nobody can answer."})
|
||||
_ = json.NewEncoder(c).Encode(askUnattributable(err))
|
||||
return
|
||||
}
|
||||
var req agentAskRequest
|
||||
@@ -132,31 +138,42 @@ func askSockServe(c net.Conn) {
|
||||
_ = json.NewEncoder(c).Encode(agentAskReply{User: user, Why: "the request could not be read"})
|
||||
return
|
||||
}
|
||||
if req.Op == "console" {
|
||||
r := agentAskReply{User: user}
|
||||
if link, err := consoleLaneEntry(uid, user); err != nil {
|
||||
extend := func(d time.Duration) { _ = c.SetDeadline(time.Now().Add(d)) }
|
||||
_ = json.NewEncoder(c).Encode(askAnswer(askPeer{UID: uid, Name: user}, req, extend,
|
||||
func() int { return askPeerPIDFn(c) }))
|
||||
}
|
||||
|
||||
// askUnattributable is the answer to a connection whose account the
|
||||
// operating system would not name.
|
||||
func askUnattributable(err error) agentAskReply {
|
||||
return agentAskReply{Why: "this machine could not establish who is asking (" + err.Error() +
|
||||
"), so nothing was asked. An ask nobody can attribute is one nobody can answer."}
|
||||
}
|
||||
|
||||
// askAnswer is the lane's answer to one request from peer, on either
|
||||
// platform: ask.sock here, the ask pipe on Windows. extend gives the answer
|
||||
// more time where it takes longer (a read, an act); pid is the connecting
|
||||
// process, asked only for a stop.
|
||||
func askAnswer(peer askPeer, req agentAskRequest, extend func(time.Duration), pid func() int) any {
|
||||
switch req.Op {
|
||||
case "console":
|
||||
r := agentAskReply{User: peer.Name}
|
||||
if link, err := consoleLaneEntryFor(peer); err != nil {
|
||||
r.Why = err.Error()
|
||||
} else {
|
||||
r.OK, r.URL = true, link
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(r)
|
||||
return
|
||||
return r
|
||||
case "status":
|
||||
return askStatus(existingMachineFactorDir(), req.Request, peer.Name, false)
|
||||
case "read":
|
||||
extend(readDeadline)
|
||||
return serveReadFor(peer, req)
|
||||
case "act":
|
||||
extend(30 * time.Second)
|
||||
return serveActFor(peer, req)
|
||||
}
|
||||
if req.Op == "status" {
|
||||
_ = json.NewEncoder(c).Encode(askStatus(existingMachineFactorDir(), req.Request, user, false))
|
||||
return
|
||||
}
|
||||
if req.Op == "read" {
|
||||
_ = c.SetDeadline(time.Now().Add(readDeadline))
|
||||
_ = json.NewEncoder(c).Encode(serveRead(uid, user, req))
|
||||
return
|
||||
}
|
||||
if req.Op == "act" {
|
||||
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
_ = json.NewEncoder(c).Encode(serveAct(uid, user, req))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(c).Encode(fileAsk(req, user, askPeerPIDFn(c)))
|
||||
return fileAsk(req, peer.Name, pid())
|
||||
}
|
||||
|
||||
// fileAsk turns one ask into a pending request of the machine, or says why not.
|
||||
@@ -258,10 +275,7 @@ func askPendingBy(dir, user, purpose string) (n int, same bool) {
|
||||
// askViaSock is the asking side: `stop` run where it cannot write the
|
||||
// machine's state itself.
|
||||
func askViaSock(req agentAskRequest) (agentAskReply, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return agentAskReply{}, errors.New("asking another account's bastion is not built for Windows yet")
|
||||
}
|
||||
c, err := net.DialTimeout("unix", askSockPath(), 3*time.Second)
|
||||
c, err := dialAskLane(askSockPath())
|
||||
if err != nil {
|
||||
return agentAskReply{}, err
|
||||
}
|
||||
|
||||
@@ -177,6 +177,15 @@ func winPersonSID(sid string) bool {
|
||||
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
|
||||
}
|
||||
|
||||
// pipeClientNewer: a process created after the connection was made cannot be
|
||||
// the one that made it. The service finds the connecting process by its id
|
||||
// (GetNamedPipeClientProcessId), and an id is reused once its process has
|
||||
// exited and nothing holds it, so a client that hands its pipe to another
|
||||
// process and exits could otherwise be answered as whoever got its id next.
|
||||
// The connection time is taken as the service starts serving it, a moment
|
||||
// after the connect.
|
||||
func pipeClientNewer(created, connected time.Time) bool { return created.After(connected) }
|
||||
|
||||
// pipeClientRefusal says why a connected process may not use the lane, or ""
|
||||
// when it may: it must run as the person signed in to its own session, in a
|
||||
// session someone is in front of.
|
||||
|
||||
@@ -157,10 +157,11 @@ func (c *pipeConn) readLine(max int, timeout time.Duration) ([]byte, error) {
|
||||
// approvalPipeServer tracks how many of our instances are open, so an instance
|
||||
// created while none is open asks for the name to be ours alone.
|
||||
type approvalPipeServer struct {
|
||||
mu sync.Mutex
|
||||
open int
|
||||
sa *windows.SecurityAttributes
|
||||
name *uint16
|
||||
mu sync.Mutex
|
||||
open int
|
||||
sa *windows.SecurityAttributes
|
||||
name *uint16
|
||||
label string // the pipe's name, for a message
|
||||
// stopEv is set when the service stops; it ends a connect that is waiting.
|
||||
stopEv windows.Handle
|
||||
}
|
||||
@@ -190,7 +191,7 @@ func (s *approvalPipeServer) create() (*pipeConn, error) {
|
||||
windows.PIPE_UNLIMITED_INSTANCES, 64<<10, 64<<10, 0, s.sa)
|
||||
if err != nil {
|
||||
if s.open == 0 {
|
||||
return nil, fmt.Errorf("cannot create %s; if another program holds that name the tray stays off: %w", approvalPipeName, err)
|
||||
return nil, fmt.Errorf("cannot create %s; if another program holds that name, this lane stays off: %w", s.label, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -230,11 +231,17 @@ func startApprovalPipe(stop <-chan struct{}) {
|
||||
}
|
||||
|
||||
func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
|
||||
sd, err := windows.SecurityDescriptorFromString(approvalPipeSDDL)
|
||||
return servePipe(stop, ready, approvalPipeName, approvalPipeSDDL, servePipeConn)
|
||||
}
|
||||
|
||||
// servePipe serves one of the service's pipes, name with the ACL sddl, until
|
||||
// stop is closed, handing each connection to serve.
|
||||
func servePipe(stop <-chan struct{}, ready func(), pipeName, sddl string, serve func(*pipeConn)) error {
|
||||
sd, err := windows.SecurityDescriptorFromString(sddl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot build the pipe's permissions: %w", err)
|
||||
}
|
||||
name, err := windows.UTF16PtrFromString(approvalPipeName)
|
||||
name, err := windows.UTF16PtrFromString(pipeName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -249,11 +256,12 @@ func serveApprovalPipe(stop <-chan struct{}, ready func()) error {
|
||||
s := &approvalPipeServer{
|
||||
sa: &windows.SecurityAttributes{Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})), SecurityDescriptor: sd},
|
||||
name: name,
|
||||
label: pipeName,
|
||||
stopEv: stopEv,
|
||||
}
|
||||
// The loop and its no-gap rule are in approvalpipe.go, where the suite runs them.
|
||||
return pipeAcceptLoop[*pipeConn](s, stop, pipeMaxClients, time.Second,
|
||||
func(f string, a ...any) { fmt.Printf(f, a...) }, ready, servePipeConn)
|
||||
func(f string, a ...any) { fmt.Printf(f, a...) }, ready, serve)
|
||||
}
|
||||
|
||||
// servePipeConn answers one request and waits, briefly, for the client to
|
||||
@@ -291,7 +299,12 @@ func servePipeConn(c *pipeConn) {
|
||||
// (GetNamedPipeClientProcessId), its user and session from that process's
|
||||
// token, and the person signed in to that session from the session's own token
|
||||
// (WTSQueryUserToken, which LocalSystem may call).
|
||||
//
|
||||
// The process is found by its id, after it connected. An id that was freed
|
||||
// and given to a new process in between would name the wrong one, so a
|
||||
// process created after the connection is refused (pipeClientNewer).
|
||||
func pipeClientOf(h windows.Handle) (pipeClient, error) {
|
||||
connected := time.Now()
|
||||
var c pipeClient
|
||||
if err := windows.GetNamedPipeClientProcessId(h, &c.PID); err != nil {
|
||||
return c, fmt.Errorf("cannot tell which process connected: %w", err)
|
||||
@@ -301,6 +314,13 @@ func pipeClientOf(h windows.Handle) (pipeClient, error) {
|
||||
return c, fmt.Errorf("cannot open the connecting process (pid %d): %w", c.PID, err)
|
||||
}
|
||||
defer windows.CloseHandle(p)
|
||||
var created, exited, kernel, user windows.Filetime
|
||||
if err := windows.GetProcessTimes(p, &created, &exited, &kernel, &user); err != nil {
|
||||
return c, fmt.Errorf("cannot read when the connecting process started (pid %d): %w", c.PID, err)
|
||||
}
|
||||
if pipeClientNewer(time.Unix(0, created.Nanoseconds()), connected) {
|
||||
return c, fmt.Errorf("process %d started after the connection was made, so it is not the process that connected", c.PID)
|
||||
}
|
||||
var tok windows.Token
|
||||
if err := windows.OpenProcessToken(p, windows.TOKEN_QUERY, &tok); err != nil {
|
||||
return c, fmt.Errorf("cannot read the connecting process's account (pid %d): %w", c.PID, err)
|
||||
@@ -381,7 +401,12 @@ func pipeCall(req pipeRequest) (pipeResponse, error) {
|
||||
// (read, write data, synchronize; never FILE_APPEND_DATA, which GENERIC_WRITE
|
||||
// would ask for), and lets the server identify this process but not act as it.
|
||||
func dialApprovalPipe() (*pipeConn, error) {
|
||||
name, err := windows.UTF16PtrFromString(approvalPipeName)
|
||||
return dialServicePipe(approvalPipeName)
|
||||
}
|
||||
|
||||
// dialServicePipe opens one of the service's pipes (dialApprovalPipe).
|
||||
func dialServicePipe(pipeName string) (*pipeConn, error) {
|
||||
name, err := windows.UTF16PtrFromString(pipeName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -401,7 +426,7 @@ func dialApprovalPipe() (*pipeConn, error) {
|
||||
continue
|
||||
}
|
||||
if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) {
|
||||
return nil, errors.New("the bastion service is not running, or is older than this tray")
|
||||
return nil, errors.New("the bastion service is not running, or is older than this program")
|
||||
}
|
||||
return nil, fmt.Errorf("cannot reach the bastion service: %w", err)
|
||||
}
|
||||
@@ -413,14 +438,14 @@ func dialApprovalPipe() (*pipeConn, error) {
|
||||
func pipeServerIsService(h windows.Handle) error {
|
||||
var pid uint32
|
||||
if err := windows.GetNamedPipeServerProcessId(h, &pid); err != nil {
|
||||
return fmt.Errorf("cannot tell which process serves the approvals pipe: %w", err)
|
||||
return fmt.Errorf("cannot tell which process serves the pipe: %w", err)
|
||||
}
|
||||
svcPID, err := bastionServicePID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if svcPID == 0 || pid != svcPID {
|
||||
return fmt.Errorf("the approvals pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName)
|
||||
return fmt.Errorf("the pipe is served by process %d, not by the %s service; nothing was sent", pid, winServiceName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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{}) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
@@ -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
@@ -1,7 +1,8 @@
|
||||
package main
|
||||
|
||||
// consolelane.go — the machine owner's own account opens the console of the
|
||||
// bastion installed for the whole machine, without sudo (Linux and macOS).
|
||||
// bastion installed for the whole machine, without sudo on Linux and macOS
|
||||
// and without an Administrator terminal on Windows.
|
||||
//
|
||||
// THE PROBLEM. Installed for the whole machine, the bastion runs as root and
|
||||
// its console signs in with root's ui.secret. `privatae-bastion ui` run by the
|
||||
@@ -9,7 +10,8 @@ package main
|
||||
// account's key, so the person reached their own machine's console only
|
||||
// through sudo, and a browser started by sudo is root's, not theirs.
|
||||
//
|
||||
// THE LANE. ask.sock (agentask.go) already tells the daemon, from the kernel,
|
||||
// THE LANE. ask.sock (agentask.go), and on Windows the service's ask pipe
|
||||
// (asklane_windows.go), already tells the daemon, from the operating system,
|
||||
// which account connected. Asked for "console" by the account recorded as the
|
||||
// machine's owner (owner_account.go), the daemon mints a one-use ticket of a
|
||||
// LIMITED kind and answers with the link; `ui` opens it in the person's own
|
||||
@@ -21,17 +23,18 @@ package main
|
||||
// read on this install. It is refused what widens access with no code today:
|
||||
// pairing a Host, setting up or rotating the owner's code, choosing the Host
|
||||
// in charge, proposing a folder, and the approval device's approvals. Those
|
||||
// stay with the administrator (`sudo -H privatae-bastion ui`). Codes typed for
|
||||
// stay with the administrator (`sudo -H privatae-bastion ui`, or `ui` from an
|
||||
// Administrator terminal on Windows). Codes typed for
|
||||
// a Host's requests in a limited session are throttled as the owner's are
|
||||
// (machineCodeSpend), because guessing is the only attack left there.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
@@ -45,21 +48,27 @@ var consoleLaneServer atomic.Pointer[uiServer]
|
||||
// consoleLaneRefusal says why account uid may not open the console through
|
||||
// the lane, or "" when it may.
|
||||
func consoleLaneRefusal(uid uint32, who string) string {
|
||||
owner, ownerName, present, err := ownerAccountLoad()
|
||||
return consoleLaneRefusalFor(askPeer{UID: uid, Name: who})
|
||||
}
|
||||
|
||||
// consoleLaneRefusalFor is consoleLaneRefusal for a peer of either platform.
|
||||
func consoleLaneRefusalFor(p askPeer) string {
|
||||
admin := adminCommandFor(p.windows(), "ui")
|
||||
match, ownerName, present, err := laneOwnerMatch(p)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "this machine's owner account cannot be read (" + err.Error() + "); the administrator opens " +
|
||||
"the console with: sudo -H privatae-bastion ui"
|
||||
"the console with: " + admin
|
||||
case !present:
|
||||
return "no account is recorded as this machine's owner, so the console opens only for the administrator: " +
|
||||
"sudo -H privatae-bastion ui. To open it from your own account, the administrator records it once: " +
|
||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
||||
case owner != uid:
|
||||
name := ownerName
|
||||
if name == "" {
|
||||
name = "uid " + strconv.FormatUint(uint64(owner), 10)
|
||||
admin + ". To open it from your own account, the administrator records it once: " + ownerRecordCommand(p)
|
||||
case !match:
|
||||
without := "without sudo"
|
||||
if p.windows() {
|
||||
without = "without an Administrator terminal"
|
||||
}
|
||||
return "this machine's console opens without sudo only for its owner's account (" + name + "), not " + who
|
||||
return "this machine's console opens " + without + " only for its owner's account (" + ownerName + "), not " +
|
||||
p.Name + ". The administrator opens it with: " + admin
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -74,7 +83,11 @@ func accountForHint(who string) string {
|
||||
// consoleLaneEntry is what the daemon answers an owner's "console" ask with:
|
||||
// the link that redeems a fresh limited ticket.
|
||||
func consoleLaneEntry(uid uint32, who string) (string, error) {
|
||||
if why := consoleLaneRefusal(uid, who); why != "" {
|
||||
return consoleLaneEntryFor(askPeer{UID: uid, Name: who})
|
||||
}
|
||||
|
||||
func consoleLaneEntryFor(p askPeer) (string, error) {
|
||||
if why := consoleLaneRefusalFor(p); why != "" {
|
||||
return "", errors.New(why)
|
||||
}
|
||||
u := consoleLaneServer.Load()
|
||||
@@ -104,8 +117,9 @@ func (u *uiServer) signedIn(r *http.Request) bool {
|
||||
return u.sessions.valid(tok) || (u.limitedSessions != nil && u.limitedSessions.valid(tok))
|
||||
}
|
||||
|
||||
const consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
|
||||
"with no code, or reads beyond your own account. The machine's administrator does it: sudo -H privatae-bastion ui"
|
||||
var consoleLaneAdminOnly = "From your own account the console does not do this: it widens what reaches this machine " +
|
||||
"with no code, or reads beyond your own account. The machine's administrator does it: " +
|
||||
adminCommandFor(runtime.GOOS == "windows", "ui")
|
||||
|
||||
// adminOnly wraps a route a limited session may not use.
|
||||
func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -120,32 +134,54 @@ func (u *uiServer) adminOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
|
||||
// consoleEntryForThisAccount is the link `ui` opens. An account with a
|
||||
// bastion of its own signs in with its own key (localUIEntryURL). An ordinary
|
||||
// account beside the machine's root bastion asks the lane, and is told why
|
||||
// not when it is not the owner's.
|
||||
// account beside the machine's bastion asks the lane, and is told why not
|
||||
// when it is not the owner's.
|
||||
func consoleEntryForThisAccount() (entry, addr string, err error) {
|
||||
if runtime.GOOS != "windows" && os.Geteuid() != 0 && !uiDaemonListening() {
|
||||
if runtime.GOOS == "windows" {
|
||||
if systemService.installed && !machineAuthorityHeld() {
|
||||
return consoleEntryThroughLane()
|
||||
}
|
||||
return localUIEntryURL()
|
||||
}
|
||||
if os.Geteuid() != 0 && !uiDaemonListening() {
|
||||
p := askSockPath()
|
||||
if why := contextSockDialable(p); why != "" {
|
||||
return "", "", errors.New(why)
|
||||
}
|
||||
if _, statErr := os.Stat(p); statErr == nil {
|
||||
r, err := askViaSock(agentAskRequest{Op: "console"})
|
||||
if err != nil {
|
||||
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
|
||||
}
|
||||
if !r.OK {
|
||||
return "", "", errors.New(r.Why)
|
||||
}
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) {
|
||||
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
|
||||
}
|
||||
return r.URL, u.Host, nil
|
||||
return consoleEntryThroughLane()
|
||||
}
|
||||
}
|
||||
return localUIEntryURL()
|
||||
}
|
||||
|
||||
// consoleEntryThroughLane asks the machine's bastion for the owner's link.
|
||||
func consoleEntryThroughLane() (entry, addr string, err error) {
|
||||
p := askLaneAddr(askSockPath())
|
||||
r, err := askViaSock(agentAskRequest{Op: "console"})
|
||||
if err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
// The service is there (systemService.installed) but its pipe is
|
||||
// not: a service older than this command, or one not running.
|
||||
return "", "", fmt.Errorf("this machine's bastion runs as the %s service, and it did not answer your "+
|
||||
"account's request for its console on %s (%v).\n"+
|
||||
" If the service is not running, start it: %s\n"+
|
||||
" If it is older than this command, update it by running the installer again.\n"+
|
||||
" Either way the administrator can open the console now: %s",
|
||||
winServiceName, p, err, serviceRunHintFor("windows"), adminCommandFor(true, "ui"))
|
||||
}
|
||||
return "", "", errors.New("could not reach this machine's bastion at " + p + ": " + err.Error())
|
||||
}
|
||||
if !r.OK {
|
||||
return "", "", errors.New(r.Why)
|
||||
}
|
||||
u, err := url.Parse(r.URL)
|
||||
if err != nil || u.Scheme != "http" || !uiLoopbackHost(u.Host, u.Port()) {
|
||||
return "", "", errors.New("this machine's bastion answered with a link that is not its own console")
|
||||
}
|
||||
return r.URL, u.Host, nil
|
||||
}
|
||||
|
||||
// uiLimitedWriter marks a response as drawn for a limited session, so a page
|
||||
// shows the administrator's command in place of a form the session would be
|
||||
// refused (Settings, Approvals). It changes what is drawn, never what is
|
||||
|
||||
@@ -362,26 +362,3 @@ func utf16Len(s string) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// secondRunNotice is runguard_windows.go's refusal, written for a dialog box
|
||||
// rather than for a terminal.
|
||||
//
|
||||
// It is deliberately NOT the same string as the error that goes to stderr. That
|
||||
// one is laid out for a console — indented continuation lines, "you can close
|
||||
// this window" meaning the console window — and the two media want different
|
||||
// words for the same fact. They live next to each other (runguard_windows.go) so
|
||||
// they are maintained together.
|
||||
//
|
||||
// The length caps above do NOT apply here: MessageBoxW takes an ordinary
|
||||
// null-terminated string with no fixed buffer, unlike the notification-area
|
||||
// struct.
|
||||
func secondRunNotice() uiNotice {
|
||||
return uiNotice{
|
||||
Title: "Privatae bastion is already running",
|
||||
Body: "This machine is already serving your hosts.\r\n\r\n" +
|
||||
"A second copy would fight the first one for the same local ports, so this " +
|
||||
"one is stopping. Nothing is wrong and nothing has stopped working.\r\n\r\n" +
|
||||
"If you believe the running one is dead, end privatae-bastion.exe in Task " +
|
||||
"Manager and start this again.",
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -28,7 +28,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -47,8 +46,13 @@ type agentActReply struct {
|
||||
var actNow = time.Now
|
||||
|
||||
func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
|
||||
return serveActFor(askPeer{UID: uid, Name: user}, req)
|
||||
}
|
||||
|
||||
func serveActFor(peer askPeer, req agentAskRequest) (r agentActReply) {
|
||||
user := peer.Name
|
||||
r.User = user
|
||||
if why := readLaneRefusal(uid, user); why != "" {
|
||||
if why := readLaneRefusalFor(peer); why != "" {
|
||||
r.Why = actWording(why)
|
||||
return r
|
||||
}
|
||||
@@ -65,7 +69,7 @@ func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
|
||||
r.Failed, r.Why = true, err.Error()
|
||||
return r
|
||||
}
|
||||
ownerActAudit(what, user)
|
||||
ownerActAudit(what, user, peer.windows())
|
||||
r.OK, r.Done = true, done
|
||||
return r
|
||||
}
|
||||
@@ -194,21 +198,25 @@ func switchDoneText(host, family string, off bool) string {
|
||||
// factor directory is made if it is not there yet (a deny or a switch off
|
||||
// needs no code set up): dcnAuditWith joins its directory onto audit.jsonl,
|
||||
// and "" would be the daemon's working directory.
|
||||
func ownerActAudit(what, user string) {
|
||||
func ownerActAudit(what, user string, pipe bool) {
|
||||
dir, err := machineFactorDir()
|
||||
if err != nil {
|
||||
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
|
||||
return
|
||||
}
|
||||
lane := "its ask socket"
|
||||
if pipe {
|
||||
lane = "its ask pipe"
|
||||
}
|
||||
dcnAuditWith(dir, "owner_lane_act", 0, "",
|
||||
what+", from the owner's account "+user+" through its ask socket", nil)
|
||||
what+", from the owner's account "+user+" through "+lane, nil)
|
||||
}
|
||||
|
||||
// ── the asking side ─────────────────────────────────────────────────────────
|
||||
|
||||
func actViaSock(req agentAskRequest) (string, error) {
|
||||
p := readSockPath()
|
||||
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
||||
p := askLaneAddr(readSockPath())
|
||||
c, err := dialAskLane(readSockPath())
|
||||
if err != nil {
|
||||
return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
||||
}
|
||||
|
||||
+17
-18
@@ -31,7 +31,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -99,8 +98,8 @@ var machineReads = map[string]machineRead{
|
||||
// readsGoThroughLane: the machine's bastion is a system service this account
|
||||
// does not administer, so its state is not this process's to read.
|
||||
var readsGoThroughLane = func() bool {
|
||||
if runtime.GOOS == "windows" || machineAuthorityHeld() {
|
||||
return false // no ask pipe on Windows yet; the administrator reads directly
|
||||
if machineAuthorityHeld() {
|
||||
return false // the administrator reads the service's state directly
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
return linuxSystemUnitPresent()
|
||||
@@ -156,7 +155,7 @@ func laneRefusalError(cmd string, err error) error {
|
||||
if !errors.As(err, &le) {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s\n As the administrator: sudo -H privatae-bastion %s", le.why, cmd)
|
||||
return fmt.Errorf("%s\n As the administrator: %s", le.why, adminCommandFor(runtime.GOOS == "windows", cmd))
|
||||
}
|
||||
|
||||
// ── the asking side ─────────────────────────────────────────────────────────
|
||||
@@ -166,8 +165,8 @@ func laneRefusalError(cmd string, err error) error {
|
||||
var readSockPath = askSockPath
|
||||
|
||||
func readViaSock(name string, o readOpts, v any) error {
|
||||
p := readSockPath()
|
||||
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
||||
p := askLaneAddr(readSockPath())
|
||||
c, err := dialAskLane(readSockPath())
|
||||
if err != nil {
|
||||
return &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
||||
}
|
||||
@@ -207,24 +206,20 @@ type agentReadReply struct {
|
||||
View json.RawMessage `json:"view,omitempty"`
|
||||
}
|
||||
|
||||
// readLaneRefusal says why account uid may not read through the lane, or "".
|
||||
// readLaneRefusalFor says why peer may not read through the lane, or "".
|
||||
// The owner's account only: the agents, folders and prompts of every
|
||||
// account's sessions are the owner's to see, not another account's.
|
||||
func readLaneRefusal(uid uint32, who string) string {
|
||||
owner, ownerName, present, err := ownerAccountLoad()
|
||||
func readLaneRefusalFor(p askPeer) string {
|
||||
match, ownerName, present, err := laneOwnerMatch(p)
|
||||
switch {
|
||||
case err != nil:
|
||||
return "this machine's owner account cannot be read (" + err.Error() + ")"
|
||||
case !present:
|
||||
return "no account is recorded as this machine's owner, so its bastion answers reads only for the " +
|
||||
"administrator. To read from your own account, the administrator records it once: " +
|
||||
"sudo -H privatae-bastion approvals-owner --account " + accountForHint(who)
|
||||
case owner != uid:
|
||||
name := ownerName
|
||||
if name == "" {
|
||||
name = "uid " + strconv.FormatUint(uint64(owner), 10)
|
||||
}
|
||||
return "this machine's bastion answers reads only for its owner's account (" + name + "), not " + who
|
||||
ownerRecordCommand(p)
|
||||
case !match:
|
||||
return "this machine's bastion answers reads only for its owner's account (" + ownerName + "), not " + p.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -249,8 +244,12 @@ var readNow = time.Now
|
||||
var readSlots = make(chan struct{}, 4)
|
||||
|
||||
func serveRead(uid uint32, user string, req agentAskRequest) agentReadReply {
|
||||
r := agentReadReply{User: user}
|
||||
if why := readLaneRefusal(uid, user); why != "" {
|
||||
return serveReadFor(askPeer{UID: uid, Name: user}, req)
|
||||
}
|
||||
|
||||
func serveReadFor(p askPeer, req agentAskRequest) agentReadReply {
|
||||
r := agentReadReply{User: p.Name}
|
||||
if why := readLaneRefusalFor(p); why != "" {
|
||||
r.Why = why
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
|
||||
|
||||
// `switch --list` passes the guard beside the system unit, as status does;
|
||||
// `switch` does too since the owner's account switches through the lane
|
||||
// (owneracts.go), and only on Windows is it still refused here.
|
||||
// (owneracts.go), and on Windows as well since the ask pipe.
|
||||
func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) {
|
||||
prevArgs, prevUnit, prevAuth := os.Args, linuxSystemUnitPresent, systemService.authority
|
||||
t.Cleanup(func() { os.Args, linuxSystemUnitPresent, systemService.authority = prevArgs, prevUnit, prevAuth })
|
||||
@@ -81,7 +81,7 @@ func TestTheGuardLetsSwitchListThroughAndNotSwitch(t *testing.T) {
|
||||
if err := guardServiceFork("switch"); err != nil {
|
||||
t.Errorf("switch from the owner's account beside the system unit, before the lane could decide: %v", err)
|
||||
}
|
||||
if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err == nil {
|
||||
t.Error("switch from an ordinary account beside a Windows service was let through")
|
||||
if err := serviceGuard("windows", serviceGuardKey("switch", os.Args), true, false); err != nil {
|
||||
t.Errorf("switch from the owner's account beside a Windows service, before the ask pipe could decide: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,17 +239,17 @@ func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// doctor from an ordinary account beside a Windows system service would check
|
||||
// that account's own empty bastion and report it as the machine's (not
|
||||
// running, no owner, no factor), so it is refused there as status is; on
|
||||
// Linux and macOS it reads the machine's bastion through the lane.
|
||||
// doctor from an ordinary account beside a system service would, run
|
||||
// directly, check that account's own empty bastion and report it as the
|
||||
// machine's (not running, no owner, no factor). So it is a lane read, as
|
||||
// status is: through ask.sock on Linux and macOS, the ask pipe on Windows.
|
||||
//
|
||||
// MUTATION: drop "doctor" from serviceGuardedCommands. RESULT: red on windows.
|
||||
// MUTATION: drop "doctor" from serviceLaneReads. RESULT: red on every system.
|
||||
func TestDoctorBesideAWindowsServiceIsRefusedAsStatusIs(t *testing.T) {
|
||||
if err := serviceGuard("windows", "doctor", true, false); err == nil {
|
||||
t.Error("doctor from an ordinary account beside a Windows service was let through")
|
||||
if !serviceGuardedCommands["doctor"] || !serviceLaneReads["doctor"] {
|
||||
t.Error("doctor is not both guarded and a lane read, so it would check an ordinary account's own bastion")
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
if err := serviceGuard(goos, "doctor", true, false); err != nil {
|
||||
t.Errorf("%s: doctor beside the service is a lane read: %v", goos, err)
|
||||
}
|
||||
@@ -488,9 +488,9 @@ func TestAnAgentsFolderNeverReachesTheTerminalRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// On Windows, where nothing goes over the socket, the slice-3 reads from an
|
||||
// ordinary account beside the service are refused as status is; on Linux and
|
||||
// macOS they ask the machine's bastion. The rest of backup is not guarded.
|
||||
// The slice-3 reads from an ordinary account beside the service ask the
|
||||
// machine's bastion over its lane on every system (the ask pipe on Windows),
|
||||
// so the guard lets them by. The rest of backup is not guarded.
|
||||
func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
|
||||
for _, line := range [][]string{
|
||||
{"privatae-bastion", "ops", "--json"},
|
||||
@@ -500,10 +500,10 @@ func TestTheSliceThreeReadsAreGuardedBesideAWindowsService(t *testing.T) {
|
||||
{"privatae-bastion", "backup", "key", "show"},
|
||||
} {
|
||||
key := serviceGuardKey(line[1], line)
|
||||
if err := serviceGuard("windows", key, true, false); err == nil {
|
||||
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
|
||||
if !serviceGuardedCommands[key] || !serviceLaneReads[key] {
|
||||
t.Errorf("%v is not both guarded and a lane read", line[1:])
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
if err := serviceGuard(goos, key, true, false); err != nil {
|
||||
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
@@ -53,6 +54,10 @@ func relaunchAfterUpdate() {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(self, os.Args[1:]...)
|
||||
// The new bastion waits for this one to exit before it takes the run
|
||||
// lock (runholder.go takeRunLock); without this it could find the lock
|
||||
// still held, refuse as a second copy, and leave the machine with none.
|
||||
cmd.Env = append(os.Environ(), relaunchOfEnv+"="+strconv.Itoa(os.Getpid()))
|
||||
cmd.SysProcAttr = &windows.SysProcAttr{
|
||||
CreationFlags: windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// runGuardFile holds the "one running bastion" lock for the life of this
|
||||
@@ -72,8 +75,8 @@ func refuseVisibly(shown uiNotice, err error) error {
|
||||
}
|
||||
|
||||
// cannotStart is the wording for the two I/O failures below. They have no
|
||||
// prepared words the way the already-running case does (secondRunNotice,
|
||||
// notify.go), and the error itself is the most accurate thing there is to say:
|
||||
// prepared words the way the already-running case does (secondRunWords,
|
||||
// runholder.go), and the error itself is the most accurate thing there is to say:
|
||||
// both mean the config directory is unusable, which is not a state anyone can be
|
||||
// given advice about from here.
|
||||
func cannotStart(err error) uiNotice {
|
||||
@@ -90,19 +93,36 @@ func guardSingleRun() error {
|
||||
err = fmt.Errorf("cannot open the run lock: %w", err)
|
||||
return refuseVisibly(cannotStart(err), err)
|
||||
}
|
||||
if err := flockFileNB(f); err != nil {
|
||||
// Relaunched by the self-update of the bastion this one replaces, which
|
||||
// holds the lock until it has exited (runholder.go).
|
||||
if err := takeRunLock(lockTry(f), relaunchOf(), waitProcessExit,
|
||||
30*time.Second, time.Now, time.Sleep); err != nil {
|
||||
_ = f.Close()
|
||||
// The dialog and the console get DIFFERENT words for the same fact, and
|
||||
// that is on purpose: this text is laid out for a terminal — indented
|
||||
// continuation lines, and "you can close this window" meaning the console
|
||||
// window it is being printed into. secondRunNotice says it for a dialog.
|
||||
return refuseVisibly(secondRunNotice(), errors.New(
|
||||
"a bastion is already running on this machine — that one is serving "+
|
||||
"your hosts, and a second would fight it for the same local ports.\n"+
|
||||
" Nothing is wrong: you can close this window.\n"+
|
||||
" If you believe the running one is dead, stop it (Task Manager → "+
|
||||
"privatae-bastion.exe) and run this again"))
|
||||
// that is on purpose: the console's are laid out for a terminal, with
|
||||
// indented continuation lines (secondRunWords).
|
||||
h, known := readRunHolder(d)
|
||||
text, notice := secondRunWords(h, known)
|
||||
return refuseVisibly(notice, errors.New(text))
|
||||
}
|
||||
runGuardFile.Store(f)
|
||||
writeRunHolder(d, stdinIsTerminal())
|
||||
return nil
|
||||
}
|
||||
|
||||
// lockTry is one attempt at f's lock. Outside guardSingleRun, whose every
|
||||
// return is a refusal shown to the person (TestEveryRunGuardRefusalIsVisible).
|
||||
func lockTry(f *os.File) func() error {
|
||||
return func() error { return flockFileNB(f) }
|
||||
}
|
||||
|
||||
// waitProcessExit waits up to d for process pid to exit. A process that
|
||||
// cannot be opened has exited already, or is not ours to wait on.
|
||||
func waitProcessExit(pid int, d time.Duration) {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
_, _ = windows.WaitForSingleObject(h, uint32(d/time.Millisecond))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+31
-11
@@ -28,9 +28,11 @@ package main
|
||||
// installed and the caller administers the machine, the process points
|
||||
// that one variable at the service's own before anything reads state.
|
||||
// - NO SECOND BASTION FROM AN ORDINARY ACCOUNT. With the service installed,
|
||||
// `pair`, `run` and `status` from an account that does not administer the
|
||||
// machine would act on an empty bastion of that account's own, beside the
|
||||
// machine's. They are refused with a sentence saying what to do instead. As
|
||||
// `pair` and `run` from an account that does not administer the machine
|
||||
// would act on an empty bastion of that account's own, beside the
|
||||
// machine's. They are refused with a sentence saying what to do instead.
|
||||
// The reads and the owner's acts (`status` among them) ask the machine's
|
||||
// bastion over its lane instead (serviceLaneReads). As
|
||||
// with the machine marker (singleton.go), this binds a cooperating binary
|
||||
// only; an older copy will not refuse. What stops the person from removing
|
||||
// the service is that they do not administer the machine.
|
||||
@@ -46,6 +48,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// serviceCarryFromSID is the account `service install --from-account` names:
|
||||
// the bastion state carried into the service is that account's
|
||||
// (servicecarry.go). "" carries the state of the account running the install.
|
||||
var serviceCarryFromSID string
|
||||
|
||||
const (
|
||||
launchdLabel = "ai.privatae.bastion"
|
||||
launchdPlistPath = "/Library/LaunchDaemons/" + launchdLabel + ".plist"
|
||||
@@ -110,14 +117,14 @@ var serviceGuardedCommands = map[string]bool{"pair": true, "run": true, "status"
|
||||
// account each read that account's own, empty approvals, grants or asks.
|
||||
"approvals drop": true, "grants": true, "device-deny": true, "device-approve": true,
|
||||
// The machine's approvals: read, approved and denied through the owner's
|
||||
// socket on Linux and macOS (reads.go, owneracts.go); on Windows the tray
|
||||
// answers them over its own pipe, and these would read the account's own.
|
||||
// lane (reads.go, owneracts.go): ask.sock on Linux and macOS, the ask pipe
|
||||
// on Windows. Run directly, these would read the account's own.
|
||||
"approvals": true, "approve": true, "deny": true}
|
||||
|
||||
// serviceLaneReads are the guarded commands that, on Linux and macOS, read
|
||||
// the machine's bastion itself over its socket (reads.go) rather than a
|
||||
// bastion of the caller's own: not refused here, and refused there for any
|
||||
// account but the owner's.
|
||||
// serviceLaneReads are the guarded commands that read the machine's bastion
|
||||
// itself over its lane (reads.go: ask.sock on Linux and macOS, the ask pipe on
|
||||
// Windows) rather than a bastion of the caller's own: not refused here, and
|
||||
// refused there for any account but the owner's.
|
||||
var serviceLaneReads = map[string]bool{"status": true, "switch --list": true, "activity": true, "report": true,
|
||||
"doctor": true, "ops": true, "device-approvals": true, "backup read": true,
|
||||
// Not reads: the owner's account switches, and takes reach back (`ops
|
||||
@@ -138,7 +145,7 @@ func serviceGuard(goos, cmd string, installed, authority bool) error {
|
||||
if !installed || authority || !serviceGuardedCommands[cmd] {
|
||||
return nil
|
||||
}
|
||||
if serviceLaneReads[cmd] && goos != "windows" {
|
||||
if serviceLaneReads[cmd] {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("this machine's bastion runs as a system service, and it belongs to whoever administers the machine.\n"+
|
||||
@@ -355,6 +362,10 @@ var systemService struct {
|
||||
installed bool
|
||||
authority bool
|
||||
err error
|
||||
// callerAppData is APPDATA as this process was started with, before an
|
||||
// administrator's command is pointed at the service's: where a bastion of
|
||||
// this account's own keeps its state (servicecarry.go).
|
||||
callerAppData string
|
||||
}
|
||||
|
||||
// adoptSystemServiceState runs before any command. It records whether the
|
||||
@@ -372,6 +383,7 @@ var systemService struct {
|
||||
// authority only after an installed service left root on Linux looking like
|
||||
// an ordinary account, and enrolServiceStateRefusal refused it.
|
||||
func adoptSystemServiceState() {
|
||||
systemService.callerAppData = os.Getenv("APPDATA")
|
||||
installed, err := systemServiceInstalled()
|
||||
systemService.err = err
|
||||
systemService.installed = installed || err != nil
|
||||
@@ -428,8 +440,16 @@ func serviceGuardKey(cmd string, args []string) string {
|
||||
|
||||
// cmdService is `privatae-bastion service install|uninstall|status`.
|
||||
func cmdService(args []string) error {
|
||||
const usage = "usage: privatae-bastion service install [--from-account <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 {
|
||||
return errors.New("usage: privatae-bastion service install|uninstall|status")
|
||||
return errors.New(usage)
|
||||
}
|
||||
switch args[0] {
|
||||
case "install":
|
||||
|
||||
@@ -167,9 +167,10 @@ func TestServiceGuardRefusesAUserspaceBastionBesideTheService(t *testing.T) {
|
||||
for _, goos := range []string{"darwin", "windows", "linux"} {
|
||||
for _, cmd := range []string{"pair", "run", "status", "switch", "switch --list", "activity", "report"} {
|
||||
err := serviceGuard(goos, cmd, true, false)
|
||||
if (cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report") && goos != "windows" {
|
||||
// These read the machine's bastion over its socket (reads.go),
|
||||
// which answers the owner's account and refuses any other.
|
||||
if cmd == "status" || cmd == "switch" || cmd == "switch --list" || cmd == "activity" || cmd == "report" {
|
||||
// These read the machine's bastion over its lane (reads.go:
|
||||
// ask.sock, or the ask pipe on Windows), which answers the
|
||||
// owner's account and refuses any other.
|
||||
if err != nil {
|
||||
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, cmd, err)
|
||||
}
|
||||
@@ -445,16 +446,17 @@ func TestKeepOneLogAsideEmptiesInPlaceAndKeepsTheLastOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The narrowing acts beside a system service: on Linux and macOS they go
|
||||
// through the owner's lane (owneracts.go), so the guard lets them by; on
|
||||
// Windows there is no lane, and the ordinary account's own profile is a
|
||||
// separate, empty bastion, so they are refused with the administrator's way,
|
||||
// as `ops` (and so `ops drop`) already was. device-approve is the
|
||||
// administrator's on every system: the console keeps it adminOnly, and from
|
||||
// an ordinary account it read that account's own, empty asks.
|
||||
// The narrowing acts beside a system service go through the owner's lane
|
||||
// (owneracts.go) on every system, ask.sock or the Windows ask pipe, so the
|
||||
// guard lets them by; each must be both guarded and a lane command, or from
|
||||
// an ordinary account it would act on that account's own, empty bastion.
|
||||
// device-approve is the administrator's on every system: the console keeps
|
||||
// it adminOnly, and from an ordinary account it read that account's own,
|
||||
// empty asks.
|
||||
//
|
||||
// MUTATION: take "device-deny" out of serviceGuardedCommands. RESULT: red on
|
||||
// windows. Take "device-approve" out. RESULT: red on every system.
|
||||
// MUTATION: take "device-deny" out of serviceLaneReads. RESULT: red on every
|
||||
// system. Take "device-approve" out of serviceGuardedCommands. RESULT: red on
|
||||
// every system.
|
||||
func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
|
||||
lines := map[string][]string{
|
||||
"approvals drop": {"privatae-bastion", "approvals", "--host", "box-one.example", "drop", "abcd1234"},
|
||||
@@ -466,6 +468,10 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing
|
||||
if got := serviceGuardKey(args[1], args); got != want {
|
||||
t.Errorf("%v is guarded as %q, want %q", args[1:], got, want)
|
||||
}
|
||||
if !serviceGuardedCommands[want] || !serviceLaneReads[want] {
|
||||
t.Errorf("`%s` is guarded=%v, a lane command=%v: from an ordinary account beside the service it "+
|
||||
"would act on that account's own, empty bastion", want, serviceGuardedCommands[want], serviceLaneReads[want])
|
||||
}
|
||||
}
|
||||
if got := serviceGuardKey("approvals", []string{"privatae-bastion", "approvals", "--machine"}); got != "approvals" {
|
||||
t.Errorf("the approvals read is guarded as %q", got)
|
||||
@@ -473,11 +479,7 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing
|
||||
approve := serviceGuardKey("device-approve", []string{"privatae-bastion", "device-approve", "all"})
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
for key := range lines {
|
||||
err := serviceGuard(goos, key, true, false)
|
||||
if goos == "windows" && err == nil {
|
||||
t.Errorf("windows: `%s` from an ordinary account acted on its own empty bastion", key)
|
||||
}
|
||||
if goos != "windows" && err != nil {
|
||||
if err := serviceGuard(goos, key, true, false); err != nil {
|
||||
t.Errorf("%s: `%s` was refused before it could ask the machine's bastion: %v", goos, key, err)
|
||||
}
|
||||
if err := serviceGuard(goos, key, true, true); err != nil {
|
||||
@@ -497,19 +499,17 @@ func TestTheNarrowingActsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's approvals beside a system service: through the owner's
|
||||
// socket on Linux and macOS, refused on Windows (the tray answers them there).
|
||||
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLaneOrAreRefused(t *testing.T) {
|
||||
// The machine's approvals beside a system service go through the owner's
|
||||
// lane on every platform: ask.sock on Linux and macOS, the ask pipe on
|
||||
// Windows, which answers the owner's account and refuses any other.
|
||||
func TestTheMachinesApprovalsBesideTheServiceGoThroughTheLane(t *testing.T) {
|
||||
for _, line := range [][]string{
|
||||
{"privatae-bastion", "approvals", "--machine"},
|
||||
{"privatae-bastion", "approve", "--machine", "abcd1234"},
|
||||
{"privatae-bastion", "deny", "abcd1234"},
|
||||
} {
|
||||
key := serviceGuardKey(line[1], line)
|
||||
if err := serviceGuard("windows", key, true, false); err == nil {
|
||||
t.Errorf("windows: %v from an ordinary account beside the service was let through", line[1:])
|
||||
}
|
||||
for _, goos := range []string{"linux", "darwin"} {
|
||||
for _, goos := range []string{"linux", "darwin", "windows"} {
|
||||
if err := serviceGuard(goos, key, true, false); err != nil {
|
||||
t.Errorf("%s: %v was refused before it could ask the machine's bastion: %v", goos, line[1:], err)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -236,6 +237,20 @@ func installSystemService() error {
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
// Before the service first starts on this state: the pairings of the
|
||||
// bastion this machine ran for an account until now (servicecarry.go).
|
||||
// A carry that fails leaves the service stopped rather than starting it
|
||||
// empty, which would leave the machine silent to every Host it serves.
|
||||
to := filepath.Join(windowsSystemAppData(os.Getenv("SystemRoot")), "privatae-bastion")
|
||||
from, ferr := serviceCarrySource(serviceCarryFromSID)
|
||||
if ferr != nil {
|
||||
fmt.Printf(" No bastion state of an account was looked for (%v).\n", ferr)
|
||||
} else if res, err := carryStateInto(from, to); err != nil {
|
||||
return fmt.Errorf("the %s service is registered but was not started: %w", winServiceName, err)
|
||||
} else if line := carryReport(res, from); line != "" {
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 5 * time.Second}
|
||||
if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil {
|
||||
return fmt.Errorf("cannot set the %s service to restart after a failure: %w", winServiceName, err)
|
||||
@@ -268,6 +283,41 @@ func installSystemService() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// serviceCarrySource is the state folder of the bastion an account ran: the
|
||||
// named account's (its profile from the registry's ProfileList, which an
|
||||
// elevated step run as another administrator still finds), or else the
|
||||
// account running this install, as it was before its APPDATA was pointed at
|
||||
// the service's.
|
||||
func serviceCarrySource(sid string) (string, error) {
|
||||
// The account running this install, as the installer's elevated step
|
||||
// usually is: its own APPDATA, which is where its bastion kept its state
|
||||
// even when that folder is redirected away from the profile.
|
||||
if u, err := windows.GetCurrentProcessToken().GetTokenUser(); err == nil && strings.EqualFold(u.User.Sid.String(), sid) {
|
||||
sid = ""
|
||||
}
|
||||
if sid == "" {
|
||||
d := systemService.callerAppData
|
||||
if d == "" || samePath(d, windowsSystemAppData(os.Getenv("SystemRoot"))) {
|
||||
return "", nil
|
||||
}
|
||||
return filepath.Join(d, "privatae-bastion"), nil
|
||||
}
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE,
|
||||
`SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\`+sid, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no profile is recorded for %s: %w", sid, err)
|
||||
}
|
||||
defer k.Close()
|
||||
prof, _, err := k.GetStringValue("ProfileImagePath")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the profile of %s has no path: %w", sid, err)
|
||||
}
|
||||
if prof, err = registry.ExpandString(prof); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(prof, "AppData", "Roaming", "privatae-bastion"), nil
|
||||
}
|
||||
|
||||
// stopServiceAndWait asks the service to stop and waits up to thirty seconds
|
||||
// for it to say it has. A service that is not running is already stopped.
|
||||
func stopServiceAndWait(s *mgr.Service) {
|
||||
|
||||
+51
-7
@@ -130,6 +130,10 @@ const uiEventRecentFor = 7 * 24 * time.Hour
|
||||
// line of the page where the day is the point.
|
||||
const uiEventTime = "2 Jan 2006, 15:04"
|
||||
|
||||
// Admin is `privatae-bastion <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 {
|
||||
// Limited: drawn for the owner's lane (consolelane.go), which may not
|
||||
// answer the approval device's requests.
|
||||
@@ -499,8 +503,7 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if !u.signedIn(r) {
|
||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
||||
http.StatusUnauthorized)
|
||||
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if u.limitedSession(r) {
|
||||
@@ -510,6 +513,31 @@ func (u *uiServer) guarded(h http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// notSignedIn is what a page says to a browser with no session: the command
|
||||
// that opens it on this machine. The console of a bastion installed for the
|
||||
// whole machine opens from its owner's own account through the lane
|
||||
// (consolelane.go), and from the administrator's; telling either one only
|
||||
// "privatae-bastion ui" sent the person back to the command that, from the
|
||||
// wrong account, could not open it.
|
||||
func notSignedIn(verb, goos string, machines bool) string {
|
||||
cmd := "privatae-bastion " + verb
|
||||
switch {
|
||||
case !machines:
|
||||
return "not signed in — open this page with `" + cmd + "`"
|
||||
case goos == "windows":
|
||||
return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd +
|
||||
"` from the account that owns this machine, or with `" + cmd + "` in an Administrator terminal"
|
||||
}
|
||||
return "not signed in. This bastion runs for the whole machine. Open this page with `" + cmd +
|
||||
"` from the account that owns this machine, or with `sudo -H " + cmd + "`"
|
||||
}
|
||||
|
||||
// consoleIsTheMachines: this console is the bastion installed for the whole
|
||||
// machine (the Windows service, or root's on Linux and macOS).
|
||||
func consoleIsTheMachines() bool {
|
||||
return runningAsService || (runtime.GOOS != "windows" && os.Geteuid() == 0)
|
||||
}
|
||||
|
||||
// root serves the page, and is also where a ticket is redeemed.
|
||||
func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||
if why := uiRequestAllowed(r, u.port, u.verb()); why != "" {
|
||||
@@ -548,8 +576,7 @@ func (u *uiServer) root(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !u.signedIn(r) {
|
||||
http.Error(w, "not signed in — open this page from `privatae-bastion "+u.verb()+"`",
|
||||
http.StatusUnauthorized)
|
||||
http.Error(w, notSignedIn(u.verb(), runtime.GOOS, consoleIsTheMachines()), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if u.contextUI {
|
||||
@@ -937,9 +964,7 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", addr, fmt.Errorf("the local UI refused this machine's own key (%s) — "+
|
||||
"if the bastion was reinstalled, restart it so it and this command agree",
|
||||
resp.Status)
|
||||
return "", addr, uiKeyRefused(addr, resp.Status, runtime.GOOS, systemService.installed, machineAuthorityHeld())
|
||||
}
|
||||
var out struct {
|
||||
Ticket string `json:"ticket"`
|
||||
@@ -954,6 +979,25 @@ func localUIEntryURLTo(to string) (entry, addr string, err error) {
|
||||
return entry, addr, nil
|
||||
}
|
||||
|
||||
// uiKeyRefused is the console refusing this account's own key: the console
|
||||
// on that port belongs to another bastion than this account's.
|
||||
func uiKeyRefused(addr, status, goos string, service, admin bool) error {
|
||||
switch {
|
||||
case goos == "windows" && service && !admin:
|
||||
return fmt.Errorf("the console at %s did not take this account's key (%s): it belongs to the %s service, "+
|
||||
"which this account opens through the service, not with a key of its own. Update the bastion "+
|
||||
"(run the installer again), or open the console from an Administrator terminal: privatae-bastion ui",
|
||||
addr, status, winServiceName)
|
||||
case goos == "windows":
|
||||
return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that "+
|
||||
"port. Get-Process privatae-bastion | Format-List Id, Path, StartTime shows which; `privatae-bastion "+
|
||||
"status` says whether this account's own is running", addr, status)
|
||||
}
|
||||
return fmt.Errorf("the console at %s did not take this account's key (%s): another bastion holds that port, "+
|
||||
"most likely the one installed for the whole machine. Open it from the account that owns this machine "+
|
||||
"with `privatae-bastion ui`, or with `sudo -H privatae-bastion ui`", addr, status)
|
||||
}
|
||||
|
||||
// cmdUI opens the local UI in a browser. It does NOT start a server: the UI
|
||||
// belongs to the running daemon, because that is the process that knows what is
|
||||
// live.
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</form>
|
||||
{{if $.Limited}}
|
||||
<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}}
|
||||
<form hx-post="/device/approve" hx-target="#approvals" hx-swap="innerHTML">
|
||||
<input type="hidden" name="id" value="{{.ID}}">
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
{{else if .OwnerCode}}
|
||||
<p><span class="flag ok">set up</span> Your code is set up on this machine.</p>
|
||||
{{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}}
|
||||
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
||||
<input type="hidden" name="target" value="machine">
|
||||
@@ -99,7 +99,7 @@
|
||||
{{end}}
|
||||
{{else if .Limited}}
|
||||
<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}}
|
||||
<form class="setup-form" hx-post="/setup/stepup/start" hx-target="#setup" hx-swap="innerHTML">
|
||||
<input type="hidden" name="target" value="machine">
|
||||
@@ -146,7 +146,7 @@
|
||||
<h3>Connect a Host</h3>
|
||||
<div class="host">
|
||||
<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"
|
||||
autocomplete="off">
|
||||
<label for="pair-token">Token</label>
|
||||
@@ -199,7 +199,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{{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}}
|
||||
{{/* .Servable, not .Hosts: a pending pairing gets no state on this machine
|
||||
at all, not even a directory (run.go:311), and a factor for it would
|
||||
@@ -231,7 +231,7 @@
|
||||
<h3>Add a folder to your network</h3>
|
||||
<div class="host">
|
||||
{{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}}
|
||||
<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>
|
||||
@@ -275,7 +275,7 @@
|
||||
<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>
|
||||
{{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}}
|
||||
<form class="setup-form" hx-post="/setup/anchor/choose" hx-target="#anchor-cmd" hx-swap="innerHTML">
|
||||
{{range .Hosts}}
|
||||
|
||||
@@ -263,6 +263,9 @@ type uiCommand struct {
|
||||
Class string // ok | warn | bad — bad means "this cannot be run yet"
|
||||
}
|
||||
|
||||
// Admin is uiStatus.Admin for the setup page.
|
||||
func (uiSetupView) Admin(cmd string) string { return adminCommandFor(runtime.GOOS == "windows", cmd) }
|
||||
|
||||
type uiSetupView struct {
|
||||
Version string
|
||||
Mode string
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.61.2
|
||||
0.62.0
|
||||
|
||||
+17
-9
@@ -23,10 +23,13 @@
|
||||
# that form errors without installing anything.)
|
||||
# On an install that already exists, `privatae-bastion set-mode networked` (from
|
||||
# an Administrator terminal) changes the mode but not how it runs: it stays a
|
||||
# logon task. Re-run this installer with -Networked to make it a service. The
|
||||
# service starts with its own state: pairings made by the earlier per-user
|
||||
# install stay in that user's profile and are not carried over, so the machine
|
||||
# is paired again (claim-anchor with a token from the host).
|
||||
# logon task. Re-run this installer with -Networked to make it a service. When
|
||||
# an install moves the bastion from your account to the service, the service
|
||||
# takes over your account's bastion state before it first starts: its
|
||||
# pairings, keys and grants, so every Host it served goes on being served and
|
||||
# nothing is paired again (`service install --from-account`, servicecarry.go).
|
||||
# The copy in your profile is then removed, so the keys no longer sit where
|
||||
# programs running as you can read them.
|
||||
#
|
||||
# ELEVATE AS YOURSELF, not as a different administrator account. The state
|
||||
# directory is per-user (a folder under %APPDATA%), and UAC elevation of your
|
||||
@@ -248,9 +251,16 @@ if (-not $elevatedDone) {
|
||||
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
|
||||
ForEach-Object {
|
||||
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
|
||||
# Stop-Process returns before the process is gone, and its run
|
||||
# lock goes with it; the carry below needs that lock free.
|
||||
Wait-Process -Id $_.Id -Timeout 10 -ErrorAction SilentlyContinue
|
||||
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
|
||||
}
|
||||
& $exe service install
|
||||
# The account that asked for this install, or this one when run elevated
|
||||
# by hand: its bastion's pairings go into the service, and it is recorded
|
||||
# as the machine's owner below.
|
||||
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
|
||||
& $exe service install --from-account $owner
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
|
||||
} else {
|
||||
try {
|
||||
@@ -326,10 +336,8 @@ if (-not $elevatedDone) {
|
||||
& $exe set-mode networked
|
||||
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
|
||||
}
|
||||
# The machine's owner: the account that asked for this install, or this
|
||||
# one when run elevated by hand. A second administrator's reinstall leaves
|
||||
# the first owner in place; the bastion says so.
|
||||
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
|
||||
# The machine's owner ($owner, above). A second administrator's
|
||||
# reinstall leaves the first owner in place; the bastion says so.
|
||||
& $exe approvals-owner --sid $owner
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." }
|
||||
Write-Host ""
|
||||
|
||||
+6
-4
@@ -11,6 +11,8 @@ it shares with Linux is tested there, but it has not run on that system yet.
|
||||
|---|---|---|---|
|
||||
| Install for the whole machine from your own account | run ([gate](verification/m0-gate-linux-2026-10-31.md)) | built | not run |
|
||||
| `--just-me`, and the fall back where sudo cannot be used | run | built | not run |
|
||||
| Moving from an install for your account to one for the whole machine keeps its pairings | not needed (one state folder) | not built | built |
|
||||
| An update restarts the bastion | run | built | built (the service restarts it; a sign-in task's copy waits for the old one to exit) |
|
||||
| Every AI agent, by its program or its connection to a model | run | built | built |
|
||||
| Who started each one (terminal, remote login, schedule, service, another agent) | run | built | built |
|
||||
| Claude Code: session title, permission mode, what it is running now | tested | read, not checked against the process's start time | read, not checked against the process's start time |
|
||||
@@ -19,11 +21,11 @@ it shares with Linux is tested there, but it has not run on that system yet.
|
||||
| MCP tool servers and their problems | tested | built | built |
|
||||
| Changes since your baseline | tested | built | built |
|
||||
| Stop an agent with your code, from the console or `stop` | run | built | built |
|
||||
| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | not built (the ask socket is Unix only) |
|
||||
| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | not built (no ask pipe) |
|
||||
| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | not built (no ask pipe) |
|
||||
| An agent on the machine asks to stop another, and follows the answer | run ([gate](verification/m1-gate-linux-2026-11-02.md)) | built | built (the ask pipe) |
|
||||
| An agent reads the machine: `agents`, `agents show`, `activity`, `doctor`, the `--json` reads | run | built | built (the ask pipe) |
|
||||
| The owner answers from their own account: `approve`, `deny`, `switch`, the drops | run | built | built (the ask pipe) |
|
||||
| The installer checks the release's signature | tested | built | not built (`install.ps1` checks the digest only) |
|
||||
| The console from the owner's own account, no sudo | run | built | the tray's approvals window only |
|
||||
| The console from the owner's own account, no sudo or Administrator terminal | run | built | built (the ask pipe); the tray's approvals window |
|
||||
| Activity, report | run | built | built |
|
||||
| `uninstall`, keeping the audit log | run | built | built |
|
||||
| A notice when something is waiting | none yet | none yet | the tray |
|
||||
|
||||
Reference in New Issue
Block a user