bastion 0.62.0
ci / fuzz (push) Successful in 3m27s
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m39s

This commit is contained in:
Privatae
2026-11-02 21:31:58 +00:00
parent 79646bda13
commit 2043fa5e9e
29 changed files with 1853 additions and 211 deletions
+42 -28
View File
@@ -8,7 +8,8 @@ package main
// `stop` run by it had nowhere to ask, and asking is the one thing an agent
// 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
}
+9
View File
@@ -177,6 +177,15 @@ func winPersonSID(sid string) bool {
return strings.HasPrefix(sid, "S-1-5-21-") || strings.HasPrefix(sid, "S-1-12-1-")
}
// 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.
+37 -12
View File
@@ -157,10 +157,11 @@ func (c *pipeConn) readLine(max int, timeout time.Duration) ([]byte, error) {
// approvalPipeServer tracks how many of our instances are open, so an instance
// 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
}
+19
View File
@@ -0,0 +1,19 @@
//go:build !windows
package main
import (
"net"
"time"
)
// dialAskLane connects to the machine's bastion's ask socket at p.
func dialAskLane(p string) (askConn, error) {
return net.DialTimeout("unix", p, 3*time.Second)
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(p string) string { return p }
// startAskPipe is the Windows service's; ask.sock serves here (agentask.go).
func startAskPipe(<-chan struct{}) {}
+153
View File
@@ -0,0 +1,153 @@
//go:build windows
package main
// The ask pipe: ask.sock (agentask.go) for a bastion that runs as the Windows
// service.
//
// Installed for the whole machine, the bastion is a LocalSystem service and
// its state sits in LocalSystem's profile, where the person's own account can
// neither read nor write it. Before this pipe, that account had no way to the
// machine's bastion at all: `privatae-bastion ui` signed in with a key of its
// own and the console answered 401, the reads and owner acts were refused
// beside the service, and an agent could not ask for a stop. The pipe answers
// exactly what ask.sock answers, through the same code (askAnswer): a stop
// asked by any account, and the console, the reads and the owner's acts for
// the account recorded as the machine's owner (approvals-owner --sid), with
// the caller taken from the pipe client's token, never from the request.
//
// The pipe is the approvals pipe's machinery (approvalpipe_windows.go):
// overlapped I/O with a timeout on every call, a new instance created before
// the one it replaces is handed out, the first instance created with
// FILE_FLAG_FIRST_PIPE_INSTANCE, and the asking side checking the pipe's
// server is the service's own process before it sends anything, since an act
// carries the owner's code.
import (
"encoding/json"
"errors"
"fmt"
"io"
"time"
"golang.org/x/sys/windows"
)
// startAskPipe serves the ask pipe until done is closed, retrying with backoff
// when it cannot (pipeServeRetrying), as the approvals pipe does.
func startAskPipe(done <-chan struct{}) {
wait := func(d time.Duration) bool {
select {
case <-done:
return false
case <-time.After(d):
return true
}
}
go pipeServeRetrying(func(ready func()) error {
return servePipe(done, ready, askPipeName, askPipeSDDL, serveAskPipeConn)
}, wait, func(f string, a ...any) { fmt.Printf(f, a...) })
}
// serveAskPipeConn answers one ask, then waits briefly for the client to
// close: closing first would discard an answer it has not read yet.
func serveAskPipeConn(c *pipeConn) {
client, cerr := pipeClientOf(c.h)
raw, err := c.readLine(askMaxBytes, pipeIOTimeout)
if err != nil {
return
}
var reply any
switch {
case cerr != nil:
reply = askUnattributable(cerr)
default:
who := pipeWho(client)
var req agentAskRequest
if err := json.Unmarshal(raw, &req); err != nil {
reply = agentAskReply{User: who, Why: "the request could not be read"}
break
}
peer := askPeer{SID: client.UserSID, Name: who, PID: int(client.PID)}
reply = askAnswer(peer, req, func(time.Duration) {}, func() int { return int(client.PID) })
}
out, _ := json.Marshal(reply)
if err := c.write(append(out, '\n'), pipeIOTimeout); err != nil {
return
}
_, _ = c.read(pipeIOTimeout)
}
// dialAskLane opens the service's ask pipe; p, ask.sock's path, does not
// apply here.
func dialAskLane(string) (askConn, error) {
c, err := dialServicePipe(askPipeName)
if err != nil {
return nil, err
}
if err := pipeServerIsService(c.h); err != nil {
c.close()
return nil, err
}
return &askPipeClient{c: c}, nil
}
// askLaneAddr is where the lane is, as a message names it.
func askLaneAddr(string) string { return askPipeName }
// askPipeClient is the asking side's end of the pipe, read and written the
// way the lane's code reads and writes a socket: a deadline for the whole
// exchange, bytes in, bytes out.
type askPipeClient struct {
c *pipeConn
deadline time.Time
pending []byte
}
func (a *askPipeClient) SetDeadline(t time.Time) error {
a.deadline = t
return nil
}
// left is what remains of the deadline for one call; 0 waits as long as it
// takes, which only a caller that set no deadline gets.
func (a *askPipeClient) left() time.Duration {
if a.deadline.IsZero() {
return 0
}
if d := time.Until(a.deadline); d > 0 {
return d
}
return time.Millisecond
}
func (a *askPipeClient) Read(b []byte) (int, error) {
for len(a.pending) == 0 {
n, err := a.c.read(a.left())
a.pending = append(a.pending, a.c.buf[:n]...)
if err != nil && !errors.Is(err, windows.ERROR_MORE_DATA) {
if len(a.pending) > 0 {
break
}
if errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_PIPE_NOT_CONNECTED) {
return 0, io.EOF
}
return 0, err
}
}
n := copy(b, a.pending)
a.pending = a.pending[n:]
return n, nil
}
func (a *askPipeClient) Write(b []byte) (int, error) {
if err := a.c.write(b, a.left()); err != nil {
return 0, err
}
return len(b), nil
}
func (a *askPipeClient) Close() error {
a.c.close()
return nil
}
+89
View File
@@ -0,0 +1,89 @@
package main
// askpeer.go — who is on the other end of the owner's lane, on every platform.
//
// The lane (agentask.go: ask.sock on Linux and macOS, the ask pipe on Windows,
// asklane_windows.go) learns the connecting account from the operating system,
// never from the request: a uid from the socket's peer credentials, or a SID
// from the pipe client's token. The machine's owner is recorded the same two
// ways (approvals_owner.go). Every refusal on the lane asks here, so both
// platforms answer with the same rules, and only the commands they name differ.
import (
"strconv"
"strings"
"time"
)
type askPeer struct {
UID uint32 // Linux and macOS: the connecting process's account
SID string // Windows: the pipe client's token user; "" elsewhere
Name string // the account as messages name it
PID int // the connecting process; 0 when the platform does not say
}
// windows reports whether the peer came over the Windows pipe.
func (p askPeer) windows() bool { return p.SID != "" }
// laneOwnerMatch reads the recorded owner the way peer's platform names
// accounts. present is true when a record exists, readable or not; ownerName
// is how a refusal names the owner.
func laneOwnerMatch(p askPeer) (match bool, ownerName string, present bool, err error) {
if p.windows() {
sid, present, err := approvalsOwnerLoad()
if err != nil || !present {
return false, "", present, err
}
return strings.EqualFold(sid, p.SID), sid, true, nil
}
uid, name, present, err := ownerAccountLoad()
if err != nil || !present {
return false, "", present, err
}
if name == "" {
name = "uid " + strconv.FormatUint(uint64(uid), 10)
}
return uid == p.UID, name, true, nil
}
// ownerRecordCommand is the administrator's command that records peer's
// account as the machine's owner.
func ownerRecordCommand(p askPeer) string {
if p.windows() {
return adminCommandFor(true, "approvals-owner --sid "+p.SID)
}
return "sudo -H privatae-bastion approvals-owner --account " + accountForHint(p.Name)
}
// adminCommandFor is `privatae-bastion <cmd>` as the machine's administrator
// types it: in an Administrator terminal on Windows, with `sudo -H` elsewhere.
func adminCommandFor(windows bool, cmd string) string {
if windows {
return "privatae-bastion " + cmd + " (from an Administrator terminal)"
}
return "sudo -H privatae-bastion " + cmd
}
// askConn is one connection of the lane as the asking side holds it: a unix
// socket (net.Conn), or the Windows pipe client (asklane_windows.go).
type askConn interface {
Read(b []byte) (int, error)
Write(b []byte) (int, error)
Close() error
SetDeadline(t time.Time) error
}
const (
// askPipeName is the Windows service's ask pipe, the counterpart of
// ask.sock: any account on the machine may connect and ask; who it is
// comes from the pipe client's token.
askPipeName = `\\.\pipe\privatae-bastion-ask`
// askPipeSDDL: SYSTEM and Administrators full control; Authenticated
// Users (AU) read, write and synchronize, as any local account may
// connect to ask.sock. Not FILE_CREATE_PIPE_INSTANCE (FILE_APPEND_DATA,
// 0x4, on a pipe): 0x12019b is the approvals pipe's mask for the same
// reason (approvalpipe.go), so no client can stand up its own end of this
// name. P protects the DACL from inheritance.
askPipeSDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x12019b;;;AU)"
)
+248
View File
@@ -0,0 +1,248 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const (
testOwnerSID = "S-1-5-21-1111111111-2222222222-3333333333-1001"
testOtherSID = "S-1-5-21-1111111111-2222222222-3333333333-1002"
)
// writeSIDOwner records sid as the machine's owner, as `approvals-owner --sid`
// writes it on Windows.
func writeSIDOwner(t *testing.T, dir, sid string) {
t.Helper()
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(approvalsOwnerRecord{SID: sid, SetAt: time.Now().UTC().Format(time.RFC3339)})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
}
// The Windows ask pipe answers the owner's reads, acts and console for the
// account whose SID the installer recorded, and refuses every other one, in
// Windows' own commands: no sudo anywhere.
//
// MUTATION: compare p.UID in laneOwnerMatch for a Windows peer. RESULT: red
// (the owner's SID is refused).
func TestTheAskPipeServesTheOwnersSIDOnly(t *testing.T) {
dir := isolateConfigDir(t)
owner := askPeer{SID: testOwnerSID, Name: `DESKTOP\owner (` + testOwnerSID + `)`}
other := askPeer{SID: testOtherSID, Name: `DESKTOP\other (` + testOtherSID + `)`}
why := readLaneRefusalFor(owner)
if !strings.Contains(why, "approvals-owner --sid "+testOwnerSID) || !strings.Contains(why, "Administrator terminal") {
t.Errorf("with no owner recorded, the refusal does not give Windows' command: %q", why)
}
writeSIDOwner(t, dir, testOwnerSID)
if why := readLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused a read: %s", why)
}
if why := consoleLaneRefusalFor(owner); why != "" {
t.Errorf("the owner's SID was refused the console: %s", why)
}
// Case differs between a SID read from a token and one typed.
if why := readLaneRefusalFor(askPeer{SID: strings.ToLower(testOwnerSID), Name: "owner"}); why != "" {
t.Errorf("the owner's SID in other case was refused: %s", why)
}
for _, why := range []string{readLaneRefusalFor(other), consoleLaneRefusalFor(other)} {
if why == "" || !strings.Contains(why, testOwnerSID) || !strings.Contains(why, other.Name) {
t.Errorf("another account was not refused, naming both: %q", why)
}
if strings.Contains(why, "sudo") {
t.Errorf("a Windows refusal names sudo: %q", why)
}
}
if r := serveActFor(other, agentAskRequest{Act: "deny", ID: "abcd1234"}); r.OK || r.User != other.Name {
t.Errorf("another account's act: %+v", r)
}
// A record of the other platform's kind is no owner here.
uid := uint32(1000)
raw, _ := json.Marshal(approvalsOwnerRecord{UID: &uid, Account: "someone"})
if err := os.WriteFile(filepath.Join(dir, approvalsOwnerFile), raw, 0o600); err != nil {
t.Fatal(err)
}
if why := readLaneRefusalFor(owner); why == "" {
t.Error("a uid record let a Windows SID through")
}
}
// One dispatcher answers both lanes, so the pipe answers what the socket does.
func TestAskAnswerIsTheSocketsAnswer(t *testing.T) {
isolateConfigDir(t)
peer := askPeer{SID: testOwnerSID, Name: "owner"}
var extended []time.Duration
extend := func(d time.Duration) { extended = append(extended, d) }
if r, ok := askAnswer(peer, agentAskRequest{Op: "read", Read: "approvals"}, extend, func() int { return 0 }).(agentReadReply); !ok || r.OK {
t.Errorf("a read with no owner recorded: %+v", r)
}
if r, ok := askAnswer(peer, agentAskRequest{Op: "console"}, extend, func() int { return 0 }).(agentAskReply); !ok || r.OK || r.Why == "" {
t.Errorf("the console with no owner recorded: %+v", r)
}
pid := 0
if r, ok := askAnswer(peer, agentAskRequest{Op: "stop", PID: 4242}, extend, func() int { pid = 7; return 7 }).(agentAskReply); !ok || r.OK {
t.Errorf("a stop with no code set up: %+v", r)
}
if pid != 7 {
t.Error("a stop was filed without asking which process asked")
}
if len(extended) != 1 || extended[0] != readDeadline {
t.Errorf("deadlines given: %v", extended)
}
}
func TestAdminCommandsAreEachPlatformsOwn(t *testing.T) {
if got := adminCommandFor(true, "ui"); strings.Contains(got, "sudo") || !strings.Contains(got, "Administrator terminal") {
t.Errorf("windows: %q", got)
}
if got := adminCommandFor(false, "ui"); got != "sudo -H privatae-bastion ui" {
t.Errorf("unix: %q", got)
}
if !strings.Contains(askPipeSDDL, "0x12019b;;;AU") || strings.Contains(askPipeSDDL, "GW;;;AU") || strings.Contains(askPipeSDDL, "GA;;;AU") {
t.Errorf("the ask pipe's ACL gives every account more than read and write: %s", askPipeSDDL)
}
}
// A page with no session names the command that opens it on this machine.
// The machine's console is not opened by "privatae-bastion ui" from just any
// account, and telling a person only that sent them back to a 401.
func TestNotSignedInNamesTheCommandThatOpensThisConsole(t *testing.T) {
if got := notSignedIn("ui", "windows", true); !strings.Contains(got, "Administrator terminal") ||
!strings.Contains(got, "owns this machine") || strings.Contains(got, "sudo") {
t.Errorf("the Windows service's console: %q", got)
}
if got := notSignedIn("ui", "linux", true); !strings.Contains(got, "sudo -H privatae-bastion ui") ||
!strings.Contains(got, "owns this machine") {
t.Errorf("the root bastion's console: %q", got)
}
if got := notSignedIn("ui", "windows", false); !strings.Contains(got, "`privatae-bastion ui`") || strings.Contains(got, "Administrator") {
t.Errorf("an account's own bastion: %q", got)
}
err := uiKeyRefused("127.0.0.1:7880", "401 Unauthorized", "windows", true, false)
if s := err.Error(); !strings.Contains(s, winServiceName) || strings.Contains(s, "sudo") || strings.Contains(s, "restart it") {
t.Errorf("a 401 beside the Windows service: %q", s)
}
}
// A second start says what is running and how to reach it, and never that it
// should be ended: ending it was what the old words sent a person to do.
//
// MUTATION: drop the PID from secondRunWords. RESULT: red.
func TestASecondStartNamesTheRunningBastion(t *testing.T) {
task := runHolder{PID: 4242, Program: `C:\Users\someone\AppData\Local\Programs\privatae-bastion\privatae-bastion.exe`,
Started: "2026-11-02T18:02:00Z"}
console, dialog := secondRunWords(task, true)
for _, want := range []string{"4242", task.Program, "privatae-bastion ui", "Stop-Process -Id 4242"} {
if !strings.Contains(console, want) || !strings.Contains(dialog.Body, want) {
t.Errorf("the refusal does not say %q:\n%s\n%s", want, console, dialog.Body)
}
}
svc, _ := secondRunWords(runHolder{PID: 9, Service: true}, true)
if !strings.Contains(svc, "as the "+winServiceName+" service") || !strings.Contains(svc, "Restart-Service "+winServiceName) {
t.Errorf("the service: %s", svc)
}
old, _ := secondRunWords(runHolder{}, false)
if !strings.Contains(old, "Get-Process privatae-bastion") {
t.Errorf("an older bastion holding the lock: %s", old)
}
for _, s := range []string{console, svc, old, dialog.Body} {
if strings.Contains(s, "Task Manager") || strings.Contains(s, "believe the running one is dead") {
t.Errorf("the refusal sends the person to end the running bastion: %s", s)
}
}
dir := t.TempDir()
writeRunHolder(dir, true)
if h, ok := readRunHolder(dir); !ok || h.PID != os.Getpid() || !h.Console {
t.Errorf("the holder record: %+v, %v", h, ok)
}
}
// A bastion relaunched by its predecessor's update waits for it to exit and
// then takes the lock, where it used to refuse as a second copy and leave the
// machine with none.
func TestARelaunchedBastionWaitsForThePredecessorsLock(t *testing.T) {
clock := time.Unix(0, 0)
now := func() time.Time { return clock }
sleep := func(d time.Duration) { clock = clock.Add(d) }
held := 3
try := func() error {
if held > 0 {
held--
return os.ErrExist
}
return nil
}
waited := 0
waitExit := func(pid int, d time.Duration) { waited = pid }
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err != nil {
t.Fatalf("the lock was not taken once the predecessor let go: %v", err)
}
if waited != 4242 {
t.Errorf("waited on process %d", waited)
}
held = 1 << 30
if err := takeRunLock(try, 4242, waitExit, 30*time.Second, now, sleep); err == nil {
t.Fatal("a lock held for ever was taken")
}
held, waited = 1, 0
if err := takeRunLock(try, 0, waitExit, 30*time.Second, now, sleep); err == nil || waited != 0 {
t.Errorf("a start that replaces nothing waited, or retried: %v, %d", err, waited)
}
t.Setenv(relaunchOfEnv, "4242")
if got := relaunchOf(); got != 4242 {
t.Errorf("relaunchOf = %d", got)
}
if _, set := os.LookupEnv(relaunchOfEnv); set {
t.Error("the predecessor's pid would pass on to what this bastion starts")
}
}
// The Windows files wire it together: they are not built on Linux, so this
// reads them, as TestServiceModeOpensNothingOnTheDesktop does.
func TestTheWindowsFilesWireTheLaneTheCarryAndTheRelaunchWait(t *testing.T) {
for file, wants := range map[string][]string{
"relaunch_windows.go": {`relaunchOfEnv+"="+strconv.Itoa(os.Getpid())`},
"runguard_windows.go": {"takeRunLock(", "relaunchOf()", "writeRunHolder(d,", "secondRunWords("},
"sysservice_windows.go": {"carryStateInto(from, to)", "serviceCarrySource(serviceCarryFromSID)"},
"asklane_windows.go": {"askAnswer(peer, req", "pipeServerIsService(c.h)", "askPipeSDDL"},
"agentask.go": {"startAskPipe(done)"},
"../deploy/install.ps1": {"service install --from-account $owner"},
} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, w := range wants {
if !strings.Contains(string(src), w) {
t.Errorf("%s no longer has %s", file, w)
}
}
}
// The carry runs before the service's first start, not after it.
src, _ := os.ReadFile("sysservice_windows.go")
s := string(src)
if c, st := strings.Index(s, "carryStateInto(from, to)"), strings.Index(s, "s.Start()"); c < 0 || st < 0 || c > st {
t.Error("the carry is not before the service starts, so the service would start with no pairing")
}
}
// A process that started after the connection was made is not the one that
// made it: its id was freed and reused, and answering it would answer a
// stranger as whoever holds that id now.
func TestAPipeClientStartedAfterItsConnectionIsNotTheCaller(t *testing.T) {
connected := time.Unix(1000, 0)
if !pipeClientNewer(connected.Add(time.Millisecond), connected) {
t.Error("a process created after the connection was taken for the caller")
}
if pipeClientNewer(connected.Add(-time.Hour), connected) {
t.Error("the process that connected was refused")
}
}
+67 -31
View File
@@ -1,7 +1,8 @@
package main
// 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
-23
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+3 -3
View File
@@ -67,7 +67,7 @@ func TestTheSliceThreeReadsGoThroughTheLane(t *testing.T) {
// `switch --list` passes the guard beside the system unit, as status does;
// `switch` 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)
}
}
+14 -14
View File
@@ -239,17 +239,17 @@ func TestAReadThatPanicsFailsAndLeavesTheDaemonUp(t *testing.T) {
}
}
// doctor from an ordinary account beside a Windows system service would check
// 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)
}
+5
View File
@@ -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,
}
+32 -12
View File
@@ -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))
}
+141
View File
@@ -0,0 +1,141 @@
package main
// runholder.go — what a second start on Windows is told about the first.
//
// A bastion that finds its run lock held (runguard_windows.go) used to say
// only that one was running, and that if it seemed dead it could be ended in
// Task Manager. On a machine where `privatae-bastion ui` then failed, that
// was the one thing to try, so a running bastion was killed for a symptom
// that was not its. The bastion that holds the lock now writes who it is
// beside it, and the refusal names it: its process, its program, how it was
// started, and the commands that open, check and restart it.
//
// And the relaunch after a self-update (relaunch_windows.go) starts the new
// binary while the old one still holds the lock, a few milliseconds from
// exiting. The new one waits for it (takeRunLock) rather than refusing and
// leaving the machine with no bastion at all.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
)
type runHolder struct {
PID int `json:"pid"`
Program string `json:"program,omitempty"`
Started string `json:"started,omitempty"` // RFC3339
Service bool `json:"service,omitempty"` // the Windows service
Console bool `json:"console,omitempty"` // started from a terminal
}
func runHolderPath(dir string) string { return filepath.Join(dir, "run.holder.json") }
// writeRunHolder records this process as the lock's holder. Best effort: a
// refusal without it is only less specific.
func writeRunHolder(dir string, console bool) {
h := runHolder{PID: os.Getpid(), Started: time.Now().UTC().Format(time.RFC3339),
Service: runningAsService, Console: console}
if exe, err := os.Executable(); err == nil {
h.Program = exe
}
if raw, err := json.Marshal(h); err == nil {
_ = atomicWrite(runHolderPath(dir), raw)
}
}
func readRunHolder(dir string) (runHolder, bool) {
var h runHolder
raw, err := os.ReadFile(runHolderPath(dir))
if err != nil || json.Unmarshal(raw, &h) != nil || h.PID <= 0 {
return runHolder{}, false
}
return h, true
}
// secondRunWords is the refusal of a second start, for the terminal and for
// the dialog a start with no console shows. known is false
// when the running bastion left no record (an older version holds the lock).
func secondRunWords(h runHolder, known bool) (console string, dialog uiNotice) {
what := "A bastion is already running on this machine"
restart := "Get-Process privatae-bastion | Format-List Id, Path, StartTime shows it; " +
"Stop-Process -Id <its Id>, then Start-ScheduledTask -TaskName " + winServiceName
if known {
how := "in the background (its sign-in task, or itself after an update)"
switch {
case h.Service:
how = "as the " + winServiceName + " service"
case h.Console:
how = "from a terminal"
}
what += " (process " + strconv.Itoa(h.PID)
if h.Program != "" {
what += ", " + h.Program
}
if t, err := time.Parse(time.RFC3339, h.Started); err == nil {
what += ", started " + t.Local().Format("2 Jan 15:04")
}
what += ", " + how + ")"
switch {
case h.Service:
restart = "Restart-Service " + winServiceName + " (from an Administrator terminal)"
default:
restart = "Stop-Process -Id " + strconv.Itoa(h.PID) + ", then start it again: Start-ScheduledTask -TaskName " +
winServiceName + " (or privatae-bastion run)"
}
}
console = fmt.Sprintf("%s.\n"+
" It is the one serving your Hosts, and a second copy would fight it for the same local ports,\n"+
" so this one is stopping. Nothing is wrong, and nothing needs ending.\n"+
" To open it: privatae-bastion ui\n"+
" To check it: privatae-bastion status\n"+
" To restart it: %s", what, restart)
dialog = uiNotice{
Title: "Privatae bastion is already running",
Body: what + ".\r\n\r\n" +
"It is the one serving your Hosts. A second copy would fight it for the same local ports, so this one " +
"is stopping. Nothing is wrong, and nothing needs ending.\r\n\r\n" +
"To open it, run: privatae-bastion ui\r\n" +
"To restart it: " + restart,
}
return console, dialog
}
// relaunchOfEnv names, in the environment of a bastion started by its
// predecessor's self-update, the predecessor's process id.
const relaunchOfEnv = "PRIVATAE_BASTION_RELAUNCH_OF"
// takeRunLock takes the run lock with try. A bastion relaunched by its
// predecessor (replaces > 0) first waits up to patience for that process to
// exit (waitExit), then tries again until patience has passed: the lock is
// released as the predecessor's handles close, a moment after it exits.
func takeRunLock(try func() error, replaces int, waitExit func(pid int, d time.Duration), patience time.Duration,
now func() time.Time, sleep func(time.Duration)) error {
if replaces <= 0 {
return try()
}
deadline := now().Add(patience)
waitExit(replaces, patience)
for {
err := try()
if err == nil || !now().Before(deadline) {
return err
}
sleep(200 * time.Millisecond)
}
}
// relaunchOf reads relaunchOfEnv once and clears it, so nothing this process
// starts inherits it.
func relaunchOf() int {
v := os.Getenv(relaunchOfEnv)
_ = os.Unsetenv(relaunchOfEnv)
pid, err := strconv.Atoi(v)
if err != nil || pid <= 0 {
return 0
}
return pid
}
+387
View File
@@ -0,0 +1,387 @@
package main
// servicecarry.go — an install that moves a machine's bastion from an account
// to the system service brings that bastion's state with it.
//
// THE PROBLEM. A bastion installed for one account keeps its pairings, keys,
// grants and audit under that account's profile. The Windows service keeps
// its own under LocalSystem's (sysservice.go). Until this, the switch started
// the service with empty state: the per-user bastion was stopped and removed
// by the installer, the service had no pairing, and the machine went silent to
// every Host it served until someone paired it again by hand. The installer's
// header said so; nothing else did.
//
// THE CARRY. `service install` (elevated) moves the account's state folder
// into the service's before the service starts, when the service has no
// pairing of its own yet:
//
// - only then: a service that already serves a Host is never merged into;
// - not while a bastion still runs from that folder (its run lock is held,
// waited for a moment), and the lock is held through the copy, the check
// and the emptying, so none starts in the middle;
// - a file the service already has is kept, the service's own; where the
// account's version differs, it is kept beside it (carryAsideSuffix);
// - every path in the carried JSON that names the old folder is rewritten
// to the new one (a pairing's context, DCN and backup folders);
// - every file written is read back and compared, and every pairing must
// be in the service's folder, before anything is removed; a copy that
// fails takes back what it wrote, so a later install carries it again;
// - then the account's folder is removed, so the pairing's keys and bearer
// no longer sit where any program running as that account can read them.
// One that will not go is said, and the service still starts.
//
// The decisions are here, untagged, so the suite runs them on every platform;
// finding the account's folder is in sysservice_windows.go.
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// The carry's file operations, so the suite can fail one part way.
var (
carryWriteFile = os.WriteFile
carryRemoveAll = os.RemoveAll
)
// carryAsideSuffix names the account's version of a file the service already
// had, kept beside the service's own: the account's folder is removed after
// the carry, and its version would go with it.
const carryAsideSuffix = ".from-account"
// carryLockPatience is how long the carry waits for the account's run lock.
// The installer stops the account's bastion just before (Stop-Process), and
// Windows releases a killed process's file locks a moment after it is gone.
var carryLockPatience = 10 * time.Second
// carrySkip are the files that belong to a running bastion, not to its state.
var carrySkip = map[string]bool{"run.lock": true, "run.holder.json": true, "pairings.lock": true,
"ui.addr": true, "bastion.log": true, "bastion.log.1": true}
// carryResult is what a carry did.
type carryResult struct {
Hosts []string // the pairings carried, by Host
Kept int // files the service already had, left as they were
// Aside are the files the service already had whose account version
// differed; that version is beside each, named with carryAsideSuffix.
Aside []string
Note string // why nothing was carried, when nothing was
// Left: the pairings are in the service, but the account's folder could
// not be removed, and still holds copies of their keys.
Left string
}
// pairingHosts lists the Hosts dir/pairings holds a pairing for. A Host's
// signed grant beside it (pairingGrantSuffix) is not a pairing (loadPairings).
func pairingHosts(dir string) ([]string, error) {
ents, err := os.ReadDir(filepath.Join(dir, "pairings"))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var hosts []string
for _, e := range ents {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".json") && !strings.HasSuffix(e.Name(), pairingGrantSuffix) {
hosts = append(hosts, strings.TrimSuffix(e.Name(), ".json"))
}
}
sort.Strings(hosts)
return hosts, nil
}
// carryLock takes the account's run lock, waiting up to carryLockPatience for
// a bastion that is exiting to let it go.
func carryLock(f *os.File) error {
deadline := time.Now().Add(carryLockPatience)
for {
err := flockFileNB(f)
if err == nil || !time.Now().Before(deadline) {
return err
}
time.Sleep(200 * time.Millisecond)
}
}
// carryStateInto moves the bastion state at from into to (see the header).
func carryStateInto(from, to string) (carryResult, error) {
var res carryResult
if from == "" || samePath(from, to) {
return res, nil
}
// Only ever a bastion's own folder: from is removed at the end.
if filepath.Base(filepath.Clean(from)) != "privatae-bastion" {
return res, fmt.Errorf("%s is not a bastion's state folder; nothing was moved", from)
}
hosts, err := pairingHosts(from)
if err != nil {
return res, fmt.Errorf("cannot read the pairings under %s: %w", from, err)
}
if len(hosts) == 0 {
return res, nil
}
have, err := pairingHosts(to)
if err != nil {
return res, fmt.Errorf("cannot read the service's pairings under %s: %w", to, err)
}
if len(have) > 0 {
res.Note = fmt.Sprintf("the service already serves %s, so the pairings under %s (%s) were left where they are",
strings.Join(have, ", "), from, strings.Join(hosts, ", "))
return res, nil
}
lock, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_CREATE|os.O_RDWR, 0o600)
if err != nil {
return res, fmt.Errorf("cannot open the run lock under %s: %w", from, err)
}
if err := carryLock(lock); err != nil {
_ = lock.Close()
return res, fmt.Errorf("a bastion is still running from %s (it holds that folder's run lock), so its pairings "+
"were not moved into the service. Stop it, then run `privatae-bastion service install` again", from)
}
// Held until only the lock itself is left to remove, so no bastion starts
// from the folder while it is copied, checked and emptied.
locked := true
unlock := func() {
if locked {
_ = lock.Close()
locked = false
}
}
defer unlock()
// A copy that fails takes back every file it wrote: a service left with
// some of the pairings serves a Host, so no later install would carry the
// rest, and the service would serve only those few.
written, kept, aside, err := copyStateTree(from, to)
undo := func() {
for p := range written {
_ = os.Remove(p)
}
}
if err != nil {
undo()
return res, fmt.Errorf("could not copy %s into the service's %s: %w. Nothing was removed from %s", from, to, err, from)
}
for p, want := range written {
got, err := os.ReadFile(p)
if err != nil || !bytes.Equal(got, want) {
undo()
return res, fmt.Errorf("%s did not read back as written; nothing was removed from %s", p, from)
}
}
// Every pairing counted above is in the service's folder, or nothing is
// removed: a source the walk could not enter (a link) copies nothing.
got, err := pairingHosts(to)
if err != nil || strings.Join(got, "\x00") != strings.Join(hosts, "\x00") {
undo()
return res, fmt.Errorf("the service's folder %s holds the pairings %v after the copy, not %v; nothing was "+
"removed from %s", to, got, hosts, from)
}
res.Hosts, res.Kept, res.Aside = hosts, kept, aside
// From here the service has everything, so a folder that will not go is
// said, not a failure: failing would leave the service stopped.
var leftErr error
ents, err := os.ReadDir(from)
if err != nil {
leftErr = err
}
for _, e := range ents {
if e.Name() == "run.lock" {
continue // open here; Windows will not delete an open file
}
if err := carryRemoveAll(filepath.Join(from, e.Name())); err != nil && leftErr == nil {
leftErr = err
}
}
unlock()
if err := carryRemoveAll(from); err != nil && leftErr == nil {
leftErr = err
}
if leftErr != nil {
res.Left = fmt.Sprintf("%s could not be removed (%v). It still holds copies of the pairings' keys: delete that folder",
from, leftErr)
}
return res, nil
}
// copyStateTree copies from into to, file by file, and returns what it wrote
// (path to content, so far when it fails), how many files to already had,
// and which of those differed from the account's (see carryAsideSuffix).
// JSON files have every string that names a path under from rewritten to the
// same path under to. The pairing files go last: a service that starts
// meanwhile serves a pairing only once its file is there, and by then
// everything it reads is too.
func copyStateTree(from, to string) (map[string][]byte, int, []string, error) {
type item struct {
src, dst string
name string
}
var first, last []item
written := map[string][]byte{}
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(from, p)
if err != nil {
return err
}
dst := filepath.Join(to, rel)
switch {
case d.IsDir():
return os.MkdirAll(dst, 0o700)
case !d.Type().IsRegular():
return nil // a link or a device is never part of the state
case rel == d.Name() && carrySkip[d.Name()]:
return nil
}
it := item{p, dst, d.Name()}
if filepath.Dir(rel) == "pairings" {
last = append(last, it)
} else {
first = append(first, it)
}
return nil
})
if err != nil {
return written, 0, nil, err
}
kept := 0
var aside []string
write := func(p string, data []byte) error {
if err := carryWriteFile(p, data, 0o600); err != nil {
_ = os.Remove(p)
return err
}
written[p] = data
return nil
}
for _, it := range append(first, last...) {
data, err := os.ReadFile(it.src)
if err != nil {
return written, 0, nil, err
}
if strings.HasSuffix(it.name, ".json") {
data = rebaseJSONPaths(data, from, to)
}
if _, err := os.Lstat(it.dst); err == nil {
kept++
if have, err := os.ReadFile(it.dst); err == nil && bytes.Equal(have, data) {
continue
}
p := it.dst + carryAsideSuffix
if _, err := os.Lstat(p); err == nil {
return written, 0, nil, fmt.Errorf("%s is already there; the account's %s was not put beside the "+
"service's", p, it.name)
}
if err := write(p, data); err != nil {
return written, 0, nil, err
}
aside = append(aside, p)
continue
} else if !errors.Is(err, fs.ErrNotExist) {
return written, 0, nil, err
}
if err := write(it.dst, data); err != nil {
return written, 0, nil, err
}
}
return written, kept, aside, nil
}
// rebaseJSONPaths rewrites every string in a JSON document that is from, or a
// path under it, to the same path under to. A document that does not parse is
// returned as it was.
func rebaseJSONPaths(data []byte, from, to string) []byte {
var v any
dec := json.NewDecoder(bytes.NewReader(data))
dec.UseNumber()
if err := dec.Decode(&v); err != nil {
return data
}
changed := false
var walk func(any) any
walk = func(x any) any {
switch t := x.(type) {
case string:
if r, ok := rebasePath(t, from, to); ok {
changed = true
return r
}
case []any:
for i := range t {
t[i] = walk(t[i])
}
case map[string]any:
for k := range t {
t[k] = walk(t[k])
}
}
return x
}
v = walk(v)
if !changed {
return data
}
out, err := json.MarshalIndent(v, "", " ")
if err != nil {
return data
}
return out
}
// rebasePath is p moved from under from to under to, and whether p was there.
func rebasePath(p, from, to string) (string, bool) {
sep := string(filepath.Separator)
f := strings.TrimRight(from, `\/`)
switch {
case samePath(p, f):
return to, true
case len(p) > len(f) && samePath(p[:len(f)], f) && (p[len(f)] == '\\' || p[len(f)] == '/'):
return strings.TrimRight(to, `\/`) + sep + strings.TrimLeft(p[len(f):], `\/`), true
}
return p, false
}
// samePath compares two paths as this platform's filesystem does: without
// case on Windows.
func samePath(a, b string) bool {
a, b = filepath.Clean(a), filepath.Clean(b)
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
// carryReport is what `service install` says of a carry.
func carryReport(r carryResult, from string) string {
switch {
case r.Note != "":
return " " + sentenceCase(r.Note) + "."
case len(r.Hosts) == 0:
return ""
}
s := " Moved this machine's pairings into the service from " + from + ": " + strings.Join(r.Hosts, ", ") +
". They go on serving as they were; nothing needs pairing again."
if r.Kept > 0 {
s += fmt.Sprintf(" %d file(s) the service already had were kept as the service's.", r.Kept)
}
if len(r.Aside) > 0 {
s += " Your account's own version of " + strconv.Itoa(len(r.Aside)) + " of them differed, and is kept " +
"beside it in the service's folder as: " + strings.Join(r.Aside, ", ") + "."
}
if r.Left != "" {
s += "\n " + sentenceCase(r.Left) + "."
}
return s
}
+353
View File
@@ -0,0 +1,353 @@
package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// carryTree is a per-user bastion's state folder as an install finds it.
func carryTree(t *testing.T) (from, to string) {
t.Helper()
root := t.TempDir()
from = filepath.Join(root, "account", "privatae-bastion")
to = filepath.Join(root, "service", "privatae-bastion")
files := map[string]string{
"pairings/box-one.example.test.json": `{"host_name":"box-one.example.test","bearer":"mcp_test",` +
`"context_root":"` + filepath.Join(from, "context", "box-one.example.test") + `",` +
`"dcn_dir":"` + filepath.Join(from, "dcn", "box-one.example.test") + `",` +
`"backup_root":"/mnt/nas/backups"}`,
"dcn/box-one.example.test/grants.json": `{"folders":["/home/someone/notes"]}`,
"context/box-one.example.test/blob": "context bytes",
"ui.secret": "account secret",
"run.lock": "",
"run.holder.json": `{"pid":1}`,
"ui.addr": "127.0.0.1:7880",
"bastion.log": "a log line",
}
for rel, body := range files {
p := filepath.Join(from, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return from, to
}
// An install that moves the bastion from an account to the service brings
// its pairings with it: the service serves the same Hosts with the same keys,
// the paths that named the account's folder name the service's, nothing that
// belongs to a running bastion comes along, and the account's copy is gone.
//
// MUTATION: return before copyStateTree in carryStateInto. RESULT: red (the
// service has no pairing). Drop the rebaseJSONPaths call. RESULT: red (the
// pairing's context folder is still the account's).
func TestAServiceInstallCarriesTheAccountsPairingsIntoTheService(t *testing.T) {
from, to := carryTree(t)
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if strings.Join(res.Hosts, ",") != "box-one.example.test" {
t.Fatalf("carried %v", res.Hosts)
}
raw, err := os.ReadFile(filepath.Join(to, "pairings", "box-one.example.test.json"))
if err != nil {
t.Fatalf("the service has no pairing: %v", err)
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
t.Fatal(err)
}
if c.Bearer != "mcp_test" || c.HostName != "box-one.example.test" {
t.Errorf("the pairing changed on the way: %+v", c)
}
if c.ContextRoot != filepath.Join(to, "context", "box-one.example.test") || c.DCNDir != filepath.Join(to, "dcn", "box-one.example.test") {
t.Errorf("the pairing's folders still name the account's state: context %q, dcn %q", c.ContextRoot, c.DCNDir)
}
if c.BackupRoot != "/mnt/nas/backups" {
t.Errorf("a path outside the state folder was rewritten: %q", c.BackupRoot)
}
if b, _ := os.ReadFile(filepath.Join(to, "dcn", "box-one.example.test", "grants.json")); !strings.Contains(string(b), "/home/someone/notes") {
t.Errorf("the grants did not come along unchanged: %q", b)
}
if b, _ := os.ReadFile(filepath.Join(to, "context", "box-one.example.test", "blob")); string(b) != "context bytes" {
t.Errorf("the context store did not come along: %q", b)
}
for _, n := range []string{"run.lock", "run.holder.json", "ui.addr", "bastion.log"} {
if _, err := os.Stat(filepath.Join(to, n)); err == nil {
t.Errorf("%s belongs to the running bastion it was carried from, not to its state", n)
}
}
if _, err := os.Stat(from); !os.IsNotExist(err) {
t.Errorf("the account's copy, keys included, is still there: %v", err)
}
if line := carryReport(res, from); !strings.Contains(line, "box-one.example.test") || !strings.Contains(line, "nothing needs pairing again") {
t.Errorf("the install does not say what it carried: %q", line)
}
}
// A service that already serves a Host is never merged into, and the
// account's state stays where it was.
func TestACarryNeverMergesIntoAServiceThatServesAHost(t *testing.T) {
from, to := carryTree(t)
p := filepath.Join(to, "pairings", "box-two.example.test.json")
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if len(res.Hosts) != 0 || !strings.Contains(res.Note, "box-two.example.test") {
t.Fatalf("carried into a serving service: %+v", res)
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's pairing was touched: %v", err)
}
if _, err := os.Stat(filepath.Join(to, "pairings", "box-one.example.test.json")); err == nil {
t.Error("the account's pairing was merged in")
}
}
// Not while a bastion still runs from the folder: its next write would be
// lost, and two bastions would hold the same keys.
func TestACarryWaitsForTheAccountsBastionToStop(t *testing.T) {
prev := carryLockPatience
t.Cleanup(func() { carryLockPatience = prev })
carryLockPatience = 300 * time.Millisecond
from, to := carryTree(t)
f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := flockFileNB(f); err != nil {
t.Fatal(err)
}
if _, err := carryStateInto(from, to); err == nil || !strings.Contains(err.Error(), "still running") {
t.Fatalf("carried while the account's bastion ran: %v", err)
}
if _, err := os.Stat(filepath.Join(to, "pairings")); err == nil {
t.Error("the service got a pairing from a running bastion")
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's state was touched: %v", err)
}
}
// A file the service already has is the service's: kept, and counted.
func TestACarryKeepsWhatTheServiceAlreadyHas(t *testing.T) {
from, to := carryTree(t)
if err := os.MkdirAll(to, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" {
t.Errorf("the service's own file was replaced: %q", b)
}
if res.Kept != 1 {
t.Errorf("kept %d files, want 1", res.Kept)
}
}
// The carry removes the folder it carried from, so it refuses anything but a
// bastion's own state folder, and does nothing for a folder with no pairing.
func TestACarryTouchesOnlyABastionsStateFolder(t *testing.T) {
root := t.TempDir()
other := filepath.Join(root, "Documents")
if err := os.MkdirAll(filepath.Join(other, "pairings"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(other, "pairings", "x.json"), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := carryStateInto(other, filepath.Join(root, "service", "privatae-bastion")); err == nil {
t.Fatal("a folder that is not a bastion's was carried, and would have been removed")
}
if _, err := os.Stat(other); err != nil {
t.Fatalf("the folder was touched: %v", err)
}
empty := filepath.Join(root, "account", "privatae-bastion")
if err := os.MkdirAll(empty, 0o700); err != nil {
t.Fatal(err)
}
if res, err := carryStateInto(empty, filepath.Join(root, "service", "privatae-bastion")); err != nil || len(res.Hosts) != 0 {
t.Fatalf("a folder with no pairing: %+v, %v", res, err)
}
if _, err := os.Stat(empty); err != nil {
t.Errorf("a folder with nothing to carry was removed: %v", err)
}
}
func TestRebasePathMovesOnlyPathsUnderTheFolder(t *testing.T) {
from, to := "/a/privatae-bastion", "/b/privatae-bastion"
for in, want := range map[string]string{
"/a/privatae-bastion": "/b/privatae-bastion",
"/a/privatae-bastion/dcn/x": "/b/privatae-bastion/dcn/x",
"/a/privatae-bastion-old/dcn": "/a/privatae-bastion-old/dcn",
"/a/other": "/a/other",
"mcp_/a/privatae-bastion/fake": "mcp_/a/privatae-bastion/fake",
} {
if got, _ := rebasePath(in, from, to); got != want {
t.Errorf("rebasePath(%q) = %q, want %q", in, got, want)
}
}
}
// A grant file beside a pairing (pairings/<host>.grant.json, anchor.go) is
// not a pairing: it names no Host the service serves, and a service holding
// only one is not one that serves a Host.
func TestACarryCountsOnlyPairingsAsHosts(t *testing.T) {
from, _ := carryTree(t)
if err := os.WriteFile(filepath.Join(from, "pairings", "box-one.example.test"+pairingGrantSuffix), []byte(`{}`), 0o600); err != nil {
t.Fatal(err)
}
hosts, err := pairingHosts(from)
if err != nil {
t.Fatal(err)
}
if strings.Join(hosts, ",") != "box-one.example.test" {
t.Fatalf("hosts %v, want only box-one.example.test", hosts)
}
}
// A bastion killed a moment ago (the installer's Stop-Process) can hold its
// run lock for a moment after it is gone; the carry waits for it rather than
// failing the install.
func TestACarryWaitsAMomentForAJustStoppedBastionsLock(t *testing.T) {
from, to := carryTree(t)
f, err := os.OpenFile(filepath.Join(from, "run.lock"), os.O_RDWR, 0o600)
if err != nil {
t.Fatal(err)
}
if err := flockFileNB(f); err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(300 * time.Millisecond)
_ = f.Close()
}()
res, err := carryStateInto(from, to)
if err != nil {
t.Fatalf("a lock released 300 ms in failed the carry: %v", err)
}
if strings.Join(res.Hosts, ",") != "box-one.example.test" {
t.Fatalf("carried %v", res.Hosts)
}
}
// The account's version of a file the service already has is not lost when
// the account's folder is removed: it is kept beside the service's.
func TestACarryKeepsTheAccountsVersionOfAFileTheServiceHas(t *testing.T) {
from, to := carryTree(t)
if err := os.MkdirAll(to, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(to, "ui.secret"), []byte("service secret"), 0o600); err != nil {
t.Fatal(err)
}
res, err := carryStateInto(from, to)
if err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(filepath.Join(to, "ui.secret")); string(b) != "service secret" {
t.Errorf("the service's own file was replaced: %q", b)
}
if b, err := os.ReadFile(filepath.Join(to, "ui.secret"+carryAsideSuffix)); err != nil || string(b) != "account secret" {
t.Errorf("the account's version is gone with its folder: %q, %v", b, err)
}
if line := carryReport(res, from); !strings.Contains(line, carryAsideSuffix) {
t.Errorf("the install does not say where the account's version went: %q", line)
}
}
// A state folder that is a link to elsewhere is walked as nothing at all; the
// carry must not report the pairings moved, nor remove anything, when the
// service did not get them.
func TestACarryRemovesNothingUnlessTheServiceHasEveryPairing(t *testing.T) {
from, to := carryTree(t)
link := filepath.Join(filepath.Dir(filepath.Dir(from)), "linked", "privatae-bastion")
if err := os.MkdirAll(filepath.Dir(link), 0o700); err != nil {
t.Fatal(err)
}
if err := os.Symlink(from, link); err != nil {
t.Skipf("no symlinks here: %v", err)
}
if _, err := carryStateInto(link, to); err == nil {
t.Fatal("the carry reported success with no pairing in the service")
}
if _, err := os.Lstat(link); err != nil {
t.Errorf("the account's state folder was removed: %v", err)
}
if _, err := os.Stat(filepath.Join(from, "pairings", "box-one.example.test.json")); err != nil {
t.Errorf("the account's pairing was touched: %v", err)
}
}
// A copy that fails part way takes back what it wrote: a service left with
// some of the pairings would never be carried into again (it serves a Host),
// and would serve those few.
func TestACarryThatFailsPartWayLeavesTheServiceAsItWas(t *testing.T) {
from, to := carryTree(t)
second := filepath.Join(from, "pairings", "box-two.example.test.json")
if err := os.WriteFile(second, []byte(`{"host_name":"box-two.example.test"}`), 0o600); err != nil {
t.Fatal(err)
}
prev := carryWriteFile
t.Cleanup(func() { carryWriteFile = prev })
carryWriteFile = func(p string, b []byte, m os.FileMode) error {
if filepath.Base(p) == "box-two.example.test.json" {
return errors.New("disk full")
}
return prev(p, b, m)
}
if _, err := carryStateInto(from, to); err == nil {
t.Fatal("a failed copy reported success")
}
if hosts, _ := pairingHosts(to); len(hosts) != 0 {
t.Errorf("the service was left serving %v of the account's pairings", hosts)
}
if b, err := os.ReadFile(filepath.Join(to, "ui.secret")); err == nil {
t.Errorf("a file the failed copy wrote was left in the service: %q", b)
}
if _, err := os.Stat(second); err != nil {
t.Errorf("the account's state was touched: %v", err)
}
}
// Once the service holds every pairing, a source folder that cannot be
// removed is said, not a failure: failing would leave the service stopped,
// and the machine as silent as the empty start this carry replaces.
func TestACarryWhoseSourceCannotBeRemovedStillSucceeds(t *testing.T) {
from, to := carryTree(t)
prev := carryRemoveAll
t.Cleanup(func() { carryRemoveAll = prev })
carryRemoveAll = func(string) error { return errors.New("in use by another process") }
res, err := carryStateInto(from, to)
if err != nil {
t.Fatalf("a carry that moved every pairing failed the install: %v", err)
}
if hosts, _ := pairingHosts(to); strings.Join(hosts, ",") != "box-one.example.test" {
t.Errorf("the service holds %v", hosts)
}
if line := carryReport(res, from); !strings.Contains(line, "delete that folder") {
t.Errorf("the install does not say the account's copy is still there: %q", line)
}
}
+31 -11
View File
@@ -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":
+24 -24
View File
@@ -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)
}
+50
View File
@@ -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
View File
@@ -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.
+1 -1
View File
@@ -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}}">
+6 -6
View File
@@ -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}}
+3
View File
@@ -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