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