bastion 0.60.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s

This commit is contained in:
Privatae
2026-11-02 00:10:58 +00:00
parent 4a5c310482
commit 4f8285ef18
100 changed files with 11537 additions and 840 deletions
+1 -1
View File
@@ -1 +1 @@
0.58.1
0.60.0
+350 -246
View File
@@ -1,13 +1,25 @@
# privatae-bastion installer — Windows. Usage: irm https://privatae.ai/bastion/install.ps1 | iex
#
# -Networked: install the NETWORKED bastion — a machine several hosts share,
# where every host after the first waits until the anchor host approves it. It
# runs as a Windows service under LocalSystem, so it runs whoever is signed in
# and only an administrator can stop it. Needs an ELEVATED terminal
# (right-click → Run as administrator), because whoever administers the box is
# what stands behind the anchor.
# & ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked
# (NOT `irm ... | iex -Networked` — Invoke-Expression takes no such parameter and
# It installs the bastion FOR THE WHOLE MACHINE: the Windows service
# PrivataeBastion, running as LocalSystem, so that no program running as you,
# an AI agent included, can stop it or change what it allows. Run from an
# ordinary terminal, it asks Windows for an administrator's yes once (UAC) and
# runs the install in that elevated window, which fetches this installer again
# itself; what it printed is shown here afterwards. Your account is recorded as
# the machine's owner (approvals-owner --sid), whose tray answers approvals.
# Your own computer is the default (-Personal: one Host at most, chosen at the
# machine).
#
# -JustMe installs it for your account alone, as a logon task, and says what
# that means: any program running as you can stop it. Where Windows gives no
# administrator's yes, that is what it falls back to, and it says so; but
# never beside a bastion already installed for the whole machine.
#
# -Shared (also spelled -Networked, its earlier name): a machine several Hosts
# share, where every Host after the first waits until the one in charge
# approves it.
# & ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Shared
# (NOT `irm ... | iex -Shared` — Invoke-Expression takes no such parameter and
# that form errors without installing anything.)
# On an install that already exists, `privatae-bastion set-mode networked` (from
# an Administrator terminal) changes the mode but not how it runs: it stays a
@@ -31,7 +43,11 @@
# Windows service (installed with -Networked, or by `npx cseries`), -Pair pairs
# that service from an Administrator PowerShell instead of installing a second
# bastion beside it.
param([switch]$Networked, [string]$Pair)
param([switch]$Networked, [switch]$Shared, [switch]$Personal, [switch]$JustMe, [string]$Pair,
[string]$PairFile, [string]$OwnerSid)
# -PairFile and -OwnerSid are the elevated step's own (see the elevation
# below): the pairing code in a file, never an argument, and the SID of the
# account that asked, recorded as the machine's owner.
#
# Brought into the repo (this file previously existed ONLY as bytes in the
# edge webroot, so what customers piped to `iex` had no review history) and
@@ -42,6 +58,16 @@ $ErrorActionPreference = 'Stop'
# is verified against the manifest from this same base, so a redirectable base
# would defeat the verification it is paired with.
$base = 'https://dl.privatae.ai'
# What the elevated step runs: this installer, fetched again by that step. Like
# $base, never taken from anywhere else.
$installScript = 'https://privatae.ai/bastion/install.ps1'
if ($Networked) { $Shared = $true }
if ($Shared -and $Personal) { throw "-Shared and -Personal contradict each other; pass one. Nothing was installed." }
if ($JustMe -and $Shared) { throw "A shared machine's bastion runs for the whole machine; -JustMe and -Shared contradict each other. Nothing was installed." }
if ($PairFile) {
$Pair = ([IO.File]::ReadAllText($PairFile)).Trim()
Remove-Item -Force -LiteralPath $PairFile -ErrorAction SilentlyContinue
}
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {'arm64'} else {'amd64'}
$key = "windows-$arch.exe"
@@ -49,250 +75,328 @@ $key = "windows-$arch.exe"
if ($Pair -and -not $Pair.StartsWith('skp_')) {
throw "-Pair takes the pairing code from your Host's card, which starts skp_. Nothing was installed."
}
# A machine whose bastion already runs as the Windows service is paired
# through that service. A per-user install beside it would be a second
# bastion, which the service's own guard refuses to pair anyway.
if ($Pair -and -not $Networked -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) {
$Networked = $true
Write-Host "This computer's bastion runs as the Windows service PrivataeBastion; pairing that service."
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
$serviceInstalled = [bool](Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)
# A bastion installed for the whole machine is never joined by a second one for
# an account: the service's own guard would refuse to run it.
if ($JustMe -and $serviceInstalled) {
throw ("This machine's bastion is installed for the whole machine, as the Windows service PrivataeBastion, " +
"and a second one for your account would be refused beside it. Nothing was installed.")
}
if ($Networked) {
# Checked before anything is downloaded, so a refusal leaves nothing behind
# and is one clear sentence at the moment it can be acted on.
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
throw ("-Networked needs an elevated terminal. A networked bastion runs as a Windows service, " +
"and whoever administers the machine is what stands behind it.`n" +
"Close this window, right-click your terminal, choose 'Run as administrator', and run the same line again.")
$Service = -not $JustMe
$elevatedDone = $false
$fellBack = $false
if ($Service -and -not $isAdmin) {
# The elevated step. Its window cannot show its output here, so it writes
# it to a file this account reads afterwards. The pairing code goes in a
# file of this account's, never in the elevated command line.
Write-Host "Installing the bastion for the whole machine, so that no program running as you, an AI agent included, can stop it or change what it allows."
Write-Host "Windows asks for an administrator's yes once."
$kind = if ($Shared) { '-Shared' } else { '-Personal' }
$sid = ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value
$tmpRoot = [IO.Path]::GetTempPath()
$log = Join-Path $tmpRoot ("privatae-bastion-install-" + [guid]::NewGuid().ToString() + ".log")
$pf = ''
if ($Pair) {
$pf = Join-Path $tmpRoot ("privatae-bastion-pair-" + [guid]::NewGuid().ToString())
[IO.File]::WriteAllText($pf, $Pair)
}
}
# A networked bastion runs as a Windows service under LocalSystem, so its
# program goes under Program Files, which only administrators can change. In
# the per-user folder anyone signed in could replace the file the service runs
# as the system, and the bastion refuses to register a service from there.
$dest = if ($Networked) { Join-Path $env:ProgramFiles 'Privatae' } else { Join-Path $env:LOCALAPPDATA 'Programs\privatae-bastion' }
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$exe = Join-Path $dest 'privatae-bastion.exe'
Write-Host "Fetching manifest..."
try { $manifest = Invoke-RestMethod -Uri "$base/version.json" }
catch { throw "cannot fetch $base/version.json - refusing to install unverified" }
$expected = $manifest.binaries.$key.sha256
if (-not $expected -or $expected -notmatch '^[0-9a-f]{64}$') {
throw "manifest has no usable sha256 for $key - refusing to install unverified"
}
# Download to a TEMP path and only move it into place after the digest matches,
# so a failed verification can never leave a runnable executable behind.
# For -Networked the download is made inside the destination folder. A file
# moved within one volume keeps the permissions it was created with, so one
# made under %TEMP% would carry this account's full control into Program Files
# and let this account, unelevated, replace the program the service runs as
# the system. Made here, it has the folder's permissions from the start.
$tmpDir = if ($Networked) { $dest } else { [System.IO.Path]::GetTempPath() }
$tmp = Join-Path $tmpDir ("privatae-bastion-" + [System.Guid]::NewGuid().ToString() + ".exe")
try {
# Take the URL FROM THE MANIFEST, never build one. The manifest's url now
# carries ?v=<version>, which is what stops Cloudflare handing back the
# PREVIOUS release's binary against the CURRENT release's digest — a stale
# cache that surfaces as "checksum MISMATCH" and reads like a corrupt or
# tampered download. Building the path here would silently opt out of that.
# Falls back to the constructed path for a manifest that predates the field.
$url = $manifest.binaries.$key.url
if (-not $url) { $url = "$base/dl/privatae-bastion-$key" }
Write-Host "Downloading privatae-bastion ($key)..."
Invoke-WebRequest -Uri $url -OutFile $tmp
$actual = (Get-FileHash -Algorithm SHA256 -Path $tmp).Hash.ToLower()
if ($actual -ne $expected.ToLower()) {
throw ("checksum MISMATCH for privatae-bastion-$key`n expected $expected`n actual $actual`n" +
"The downloaded binary is not the one the platform published. Nothing was installed.")
}
Write-Host "Checksum verified ($expected)."
# A running service holds its program open, and Windows will not replace an
# open program. Stop it first; `service install` below starts it again.
if ($Networked -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) {
Stop-Service -Name 'PrivataeBastion' -Force -ErrorAction SilentlyContinue
}
Move-Item -Force -Path $tmp -Destination $exe
} finally {
if (Test-Path $tmp) { Remove-Item -Force $tmp -ErrorAction SilentlyContinue }
}
# ── AUTOSTART: a Scheduled Task at logon ──────────────────────────────────────
# Until now this installer registered NOTHING: no service, no task, no Run key.
# So the bastion lived in whatever terminal you happened to start it in, died
# with that window, and never came back after a reboot. A person with no
# terminal open had no way to tell where it was running — a fair question
# whose real answer was that 0.24.0 restarts itself DETACHED after a self-update,
# so it was serving with no console and nothing on screen saying so.
#
# A SCHEDULED TASK, NOT A WINDOWS SERVICE, and that is not laziness. The
# bastion's state directory is per-USER (under %APPDATA% — see the note at the
# top of this file). A service registered with the SCM runs as SYSTEM or
# a service account, which has a DIFFERENT %APPDATA%, so it would start cleanly
# and then read an empty state directory and see no pairings at all. That is the
# same "Run as different user" trap this installer already warns about, wearing
# a service's clothes. A logon task runs as the user, so the state is the state.
#
# -Force replaces an existing registration, so re-running the installer updates
# the task instead of failing on a duplicate.
#
# -NETWORKED IS THE EXCEPTION, AND THE SERVICE IS THE POINT OF IT. A machine an
# organisation enrols needs the bastion to run whoever is signed in, and not be
# stoppable by them. So there the bastion registers itself as a Windows service
# running as LocalSystem (`privatae-bastion service install`). The state-folder
# trap above does not apply: the bastion keeps a service's state in
# LocalSystem's own profile and points an administrator's commands at the same
# folder (client-go/sysservice.go). A logon task left over from an earlier
# install would start a second bastion beside the service, so it is removed.
#
# Removing the task does not stop a copy it already started, and after a
# self-update that copy runs detached, outside the task altogether. It holds
# 127.0.0.1:7878, which is the port the service's first pairing is given, so the
# service would fail to bind and restart every few seconds until that copy
# exited at sign-out. So any bastion running from another path is stopped here.
if ($Networked) {
$task = Get-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName 'PrivataeBastion' -Confirm:$false
Write-Host "Removed the logon task from an earlier install; the service replaces it."
}
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
}
& $exe service install
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
} else {
try {
$taskName = 'PrivataeBastion'
# DOMAIN\user rather than a bare name: a bare one is ambiguous on a machine
# that is domain-joined, and the task would be registered for the wrong
# identity — which is the same empty-%APPDATA% trap as a service.
$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name
$action = New-ScheduledTaskAction -Execute $exe -Argument 'run'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $me
# LogonType Interactive is LOAD-BEARING, not a default worth inheriting. A
# task that runs with a background (S4U) or service logon has no window
# station, so the tray icon (tray_windows.go) would never appear and the
# bastion would be exactly as invisible as it was before any of this — while
# still reporting itself as started. Interactive puts it in the session the
# person is actually looking at. RunLevel Limited because the bastion needs
# no elevation to serve; only `claim-anchor` and friends do, and those are
# typed by hand.
$principal = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
# RunOnlyIfNetworkAvailable is deliberately OFF: the tunnel dials out and
# retries, and a machine that boots before its NIC is up would otherwise
# never start the bastion at all.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal `
-Settings $settings -Description 'Privatae bastion — serves this machine to your Hosts.' `
-Force | Out-Null
Write-Host "Autostart registered: Task Scheduler -> $taskName (runs at your logon)."
Write-Host " Stop it with: Stop-ScheduledTask -TaskName $taskName"
Write-Host " Remove it with: Unregister-ScheduledTask -TaskName $taskName -Confirm:`$false"
} catch {
# Never fail the install over autostart — the binary is on disk and usable.
Write-Host "NOTE: could not register autostart ($($_.Exception.Message))."
Write-Host " The bastion still works; start it with 'privatae-bastion run'."
}
}
# A networked install is the machine's, so its command goes on the MACHINE
# path, where an administrator's terminal finds it whoever installed it.
#
# The machine Path is read and written in the registry as it is stored, NOT
# through [Environment]::GetEnvironmentVariable: that returns it with every
# %VARIABLE% already expanded, and writing it back stores the expanded text as
# a plain string, which fixes those entries for the whole machine. The new
# value takes effect in new sessions; this terminal is updated below.
if ($Networked) {
$envKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey(
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true)
# Every value goes into the elevated command as a single-quoted literal,
# its own quotes doubled: a profile path like C:\Users\O'Brien would
# otherwise end the literal and the elevated command would not parse.
# PowerShell's own escaper: it also doubles the typographic quotes
# (U+2018-U+201B), which PowerShell reads as single quotes too.
$q = { param($v) "'" + [System.Management.Automation.Language.CodeGeneration]::EscapeSingleQuotedStringContent([string]$v) + "'" }
$inner = "& ([scriptblock]::Create((irm $(& $q $installScript)))) $kind -OwnerSid $(& $q $sid)"
if ($pf) { $inner += " -PairFile $(& $q $pf)" }
$cmd = "try { $inner *>&1 | Out-File -Encoding utf8 -Append -LiteralPath $(& $q $log); exit 0 } " +
"catch { `$_.Exception.Message | Out-File -Encoding utf8 -Append -LiteralPath $(& $q $log); exit 1 }"
$declined = $false
try {
$raw = [string]$envKey.GetValue('Path', '',
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if (($raw -split ';') -notcontains $dest) {
$envKey.SetValue('Path', ($raw.TrimEnd(';') + ';' + $dest),
[Microsoft.Win32.RegistryValueKind]::ExpandString)
# -EncodedCommand, not -Command: Start-Process joins its arguments
# with spaces and quotes none of them, so a command carried as text
# loses runs of spaces and double quotes on the way. Base64 of its
# UTF-16 bytes arrives exactly as written.
$enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
$p = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -WindowStyle Hidden `
-ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', $enc)
} catch {
$declined = $true
}
if ($pf -and (Test-Path -LiteralPath $pf)) { Remove-Item -Force -LiteralPath $pf -ErrorAction SilentlyContinue }
if (-not $declined) {
if (Test-Path -LiteralPath $log) {
Get-Content -LiteralPath $log | ForEach-Object { Write-Host $_ }
Remove-Item -Force -LiteralPath $log -ErrorAction SilentlyContinue
}
} finally {
$envKey.Close()
}
} else {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notlike "*$dest*") { [Environment]::SetEnvironmentVariable('Path', ($userPath.TrimEnd(';') + ';' + $dest), 'User') }
}
$env:Path = "$env:Path;$dest"
Write-Host ""
Write-Host "Installed: $exe"
if ($Networked) {
# After `service install`, so this elevated command already writes into the
# service's state folder rather than this administrator's own profile.
& $exe set-mode networked
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
Write-Host ""
Write-Host "The bastion runs as the Windows service 'PrivataeBastion' (LocalSystem). It starts"
Write-Host "serving as soon as this machine is paired."
if (-not $Pair) {
Write-Host "Next, anchor this machine to the Host that should govern it:"
Write-Host " privatae-bastion claim-anchor"
Write-Host " (add --host <name> if this machine already serves several Hosts)"
Write-Host "Every host paired after the anchor waits, pending, until that Host approves it."
}
} elseif (-not $Pair) {
Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>"
}
if ($Pair) {
Write-Host ""
# The full path, never the name on PATH, which this window may not have
# picked up yet. The code goes on standard input: `pair -` reads its first
# line. `pair` exits 3 when this computer is already paired with that Host,
# which is success here too. On a computer that already serves another
# Host, `pair` asks in this window whether to add this one.
$Pair | & $exe pair -
$rc = $LASTEXITCODE
if ($rc -eq 0) { $done = 'This computer is paired.' }
elseif ($rc -eq 3) { $done = 'This computer was already paired with this Host; nothing changed there.' }
else { throw "Pairing did not finish (the reason is above). The bastion is installed; nothing else changed." }
# A running bastion reads a new pairing at its next start, so it is
# restarted, not just started.
if ($Networked) {
Restart-Service -Name 'PrivataeBastion'
$how = 'The bastion runs as the Windows service PrivataeBastion, and starts with Windows.'
if ($p.ExitCode -ne 0) { throw "The install for the whole machine did not finish (the reason is above)." }
# What an earlier install for this account alone left that would run
# a second bastion beside the service.
$own = Join-Path $env:LOCALAPPDATA 'Programs\privatae-bastion\privatae-bastion.exe'
if (Test-Path -LiteralPath $own) {
Remove-Item -Force -LiteralPath $own -ErrorAction SilentlyContinue
Write-Host "Removed $own, an earlier copy for your account alone."
}
$elevatedDone = $true
} elseif ($serviceInstalled) {
throw ("This machine's bastion is installed for the whole machine, and updating it needs an administrator's yes. " +
"Nothing was installed.")
} else {
# Every copy is stopped first, from wherever it was started (a terminal,
# an older install, a detached copy after a self-update): each would
# hold this machine's first control port, 127.0.0.1:7878, and the new
# one would fail to start beside it.
Stop-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue | ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
Write-Host "Windows gave no administrator's yes, so the bastion is installed for your account only."
$Service = $false
$fellBack = $true
}
}
# Everything below is the install itself, run here when no elevated step did it.
if (-not $elevatedDone) {
# Installed for the whole machine, the bastion runs as a Windows service under
# LocalSystem, so its program goes under Program Files, which only administrators can change. In
# the per-user folder anyone signed in could replace the file the service runs
# as the system, and the bastion refuses to register a service from there.
$dest = if ($Service) { Join-Path $env:ProgramFiles 'Privatae' } else { Join-Path $env:LOCALAPPDATA 'Programs\privatae-bastion' }
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$exe = Join-Path $dest 'privatae-bastion.exe'
Write-Host "Fetching manifest..."
try { $manifest = Invoke-RestMethod -Uri "$base/version.json" }
catch { throw "cannot fetch $base/version.json - refusing to install unverified" }
$expected = $manifest.binaries.$key.sha256
if (-not $expected -or $expected -notmatch '^[0-9a-f]{64}$') {
throw "manifest has no usable sha256 for $key - refusing to install unverified"
}
# Download to a TEMP path and only move it into place after the digest matches,
# so a failed verification can never leave a runnable executable behind.
# For the service the download is made inside the destination folder. A file
# moved within one volume keeps the permissions it was created with, so one
# made under %TEMP% would carry this account's full control into Program Files
# and let this account, unelevated, replace the program the service runs as
# the system. Made here, it has the folder's permissions from the start.
$tmpDir = if ($Service) { $dest } else { [System.IO.Path]::GetTempPath() }
$tmp = Join-Path $tmpDir ("privatae-bastion-" + [System.Guid]::NewGuid().ToString() + ".exe")
try {
# Take the URL FROM THE MANIFEST, never build one. The manifest's url now
# carries ?v=<version>, which is what stops Cloudflare handing back the
# PREVIOUS release's binary against the CURRENT release's digest — a stale
# cache that surfaces as "checksum MISMATCH" and reads like a corrupt or
# tampered download. Building the path here would silently opt out of that.
# Falls back to the constructed path for a manifest that predates the field.
$url = $manifest.binaries.$key.url
if (-not $url) { $url = "$base/dl/privatae-bastion-$key" }
Write-Host "Downloading privatae-bastion ($key)..."
Invoke-WebRequest -Uri $url -OutFile $tmp
$actual = (Get-FileHash -Algorithm SHA256 -Path $tmp).Hash.ToLower()
if ($actual -ne $expected.ToLower()) {
throw ("checksum MISMATCH for privatae-bastion-$key`n expected $expected`n actual $actual`n" +
"The downloaded binary is not the one the platform published. Nothing was installed.")
}
Write-Host "Checksum verified ($expected)."
# A running service holds its program open, and Windows will not replace an
# open program. Stop it first; `service install` below starts it again.
if ($Service -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) {
Stop-Service -Name 'PrivataeBastion' -Force -ErrorAction SilentlyContinue
}
Move-Item -Force -Path $tmp -Destination $exe
} finally {
if (Test-Path $tmp) { Remove-Item -Force $tmp -ErrorAction SilentlyContinue }
}
# ── AUTOSTART: a Scheduled Task at logon ──────────────────────────────────────
# Until now this installer registered NOTHING: no service, no task, no Run key.
# So the bastion lived in whatever terminal you happened to start it in, died
# with that window, and never came back after a reboot. A person with no
# terminal open had no way to tell where it was running — a fair question
# whose real answer was that 0.24.0 restarts itself DETACHED after a self-update,
# so it was serving with no console and nothing on screen saying so.
#
# A SCHEDULED TASK, NOT A WINDOWS SERVICE, and that is not laziness. The
# bastion's state directory is per-USER (under %APPDATA% — see the note at the
# top of this file). A service registered with the SCM runs as SYSTEM or
# a service account, which has a DIFFERENT %APPDATA%, so it would start cleanly
# and then read an empty state directory and see no pairings at all. That is the
# same "Run as different user" trap this installer already warns about, wearing
# a service's clothes. A logon task runs as the user, so the state is the state.
#
# -Force replaces an existing registration, so re-running the installer updates
# the task instead of failing on a duplicate.
#
# THE WHOLE MACHINE IS THE DEFAULT, AND THE SERVICE IS THE POINT OF IT. A machine an
# organisation enrols needs the bastion to run whoever is signed in, and not be
# stoppable by them. So there the bastion registers itself as a Windows service
# running as LocalSystem (`privatae-bastion service install`). The state-folder
# trap above does not apply: the bastion keeps a service's state in
# LocalSystem's own profile and points an administrator's commands at the same
# folder (client-go/sysservice.go). A logon task left over from an earlier
# install would start a second bastion beside the service, so it is removed.
#
# Removing the task does not stop a copy it already started, and after a
# self-update that copy runs detached, outside the task altogether. It holds
# 127.0.0.1:7878, which is the port the service's first pairing is given, so the
# service would fail to bind and restart every few seconds until that copy
# exited at sign-out. So any bastion running from another path is stopped here.
if ($Service) {
$task = Get-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName 'PrivataeBastion' -Confirm:$false
Write-Host "Removed the logon task from an earlier install; the service replaces it."
}
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
}
& $exe service install
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
} else {
try {
$taskName = 'PrivataeBastion'
# DOMAIN\user rather than a bare name: a bare one is ambiguous on a machine
# that is domain-joined, and the task would be registered for the wrong
# identity — which is the same empty-%APPDATA% trap as a service.
$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name
$action = New-ScheduledTaskAction -Execute $exe -Argument 'run'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $me
# LogonType Interactive is LOAD-BEARING, not a default worth inheriting. A
# task that runs with a background (S4U) or service logon has no window
# station, so the tray icon (tray_windows.go) would never appear and the
# bastion would be exactly as invisible as it was before any of this — while
# still reporting itself as started. Interactive puts it in the session the
# person is actually looking at. RunLevel Limited because the bastion needs
# no elevation to serve; only `claim-anchor` and friends do, and those are
# typed by hand.
$principal = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
# RunOnlyIfNetworkAvailable is deliberately OFF: the tunnel dials out and
# retries, and a machine that boots before its NIC is up would otherwise
# never start the bastion at all.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal `
-Settings $settings -Description 'Privatae bastion — serves this machine to your Hosts.' `
-Force | Out-Null
Write-Host "Autostart registered: Task Scheduler -> $taskName (runs at your logon)."
Write-Host " Stop it with: Stop-ScheduledTask -TaskName $taskName"
Write-Host " Remove it with: Unregister-ScheduledTask -TaskName $taskName -Confirm:`$false"
} catch {
# Never fail the install over autostart — the binary is on disk and usable.
Write-Host "NOTE: could not register autostart ($($_.Exception.Message))."
Write-Host " The bastion still works; start it with 'privatae-bastion run'."
}
}
# A whole-machine install is the machine's, so its command goes on the MACHINE
# path, where an administrator's terminal finds it whoever installed it.
#
# The machine Path is read and written in the registry as it is stored, NOT
# through [Environment]::GetEnvironmentVariable: that returns it with every
# %VARIABLE% already expanded, and writing it back stores the expanded text as
# a plain string, which fixes those entries for the whole machine. The new
# value takes effect in new sessions; this terminal is updated below.
if ($Service) {
$envKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey(
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true)
try {
Start-ScheduledTask -TaskName 'PrivataeBastion'
$how = 'The bastion is running, and starts again each time you sign in to Windows.'
} catch {
Start-Process -FilePath $exe -ArgumentList 'run' -WindowStyle Hidden
$how = "The bastion is running until you sign out (its sign-in task could not be started: $($_.Exception.Message))."
$raw = [string]$envKey.GetValue('Path', '',
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if (($raw -split ';') -notcontains $dest) {
$envKey.SetValue('Path', ($raw.TrimEnd(';') + ';' + $dest),
[Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$envKey.Close()
}
} else {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notlike "*$dest*") { [Environment]::SetEnvironmentVariable('Path', ($userPath.TrimEnd(';') + ';' + $dest), 'User') }
}
$env:Path = "$env:Path;$dest"
Write-Host ""
Write-Host "Installed: $exe"
if ($Service) {
# After `service install`, so these elevated commands already write into
# the service's state folder rather than this administrator's own profile.
if ($Shared) {
& $exe set-mode networked
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
}
# The machine's owner: the account that asked for this install, or this
# one when run elevated by hand. A second administrator's reinstall leaves
# the first owner in place; the bastion says so.
$owner = if ($OwnerSid) { $OwnerSid } else { ([Security.Principal.WindowsIdentity]::GetCurrent()).User.Value }
& $exe approvals-owner --sid $owner
if ($LASTEXITCODE -ne 0) { Write-Host "NOTE: your account was not recorded as this machine's owner (the reason is above)." }
Write-Host ""
Write-Host "The bastion runs for the whole machine as the Windows service 'PrivataeBastion' (LocalSystem), and starts with Windows."
if (-not $Pair -and $Shared) {
Write-Host "Next, anchor this machine to the Host that should govern it:"
Write-Host " privatae-bastion claim-anchor"
Write-Host " (add --host <name> if this machine already serves several Hosts)"
Write-Host "Every host paired after the anchor waits, pending, until that Host approves it."
} elseif (-not $Pair) {
Write-Host "It is watching the AI agents on this computer now; no Host is needed."
Write-Host " Its approvals open from the tray. Set up your code, from an Administrator terminal:"
Write-Host " privatae-bastion stepup-enroll --machine"
Write-Host " Connect a Host, if you run one (optional), from an Administrator terminal: privatae-bastion pair <token>"
}
} else {
Write-Host "WARNING: installed for your account only. Any program running as you, an AI agent included, can stop"
Write-Host " the bastion or change what it allows."
if ($JustMe) { Write-Host " To install it for the whole machine instead, run this installer again without -JustMe." }
elseif ($fellBack) { Write-Host " To install it for the whole machine, run this installer again and answer Windows' prompt with an administrator's yes." }
if (-not $Pair) {
# Started now, not only at the next sign-in: it watches with no Host.
try { Start-ScheduledTask -TaskName 'PrivataeBastion'; $now = 'It is running now, and' } catch { $now = 'It' }
Write-Host "$now starts each time you sign in. Open a NEW terminal, then: privatae-bastion ui"
Write-Host "Connect a Host, if you run one (optional): privatae-bastion pair <token>"
}
}
Start-Sleep -Seconds 3
& $exe status
Write-Host ""
Write-Host "$done $how"
if ($Pair) {
Write-Host ""
# The full path, never the name on PATH, which this window may not have
# picked up yet. The code goes on standard input: `pair -` reads its first
# line. `pair` exits 3 when this computer is already paired with that Host,
# which is success here too. On a computer that already serves another
# Host, `pair` asks in this window whether to add this one.
$Pair | & $exe pair -
$rc = $LASTEXITCODE
if ($rc -eq 0) { $done = 'This computer is paired.' }
elseif ($rc -eq 3) { $done = 'This computer was already paired with this Host; nothing changed there.' }
else { throw "Pairing did not finish (the reason is above). The bastion is installed; nothing else changed." }
# A running bastion reads a new pairing at its next start, so it is
# restarted, not just started.
if ($Service) {
Restart-Service -Name 'PrivataeBastion'
$how = 'The bastion runs as the Windows service PrivataeBastion, and starts with Windows.'
} else {
# Every copy is stopped first, from wherever it was started (a terminal,
# an older install, a detached copy after a self-update): each would
# hold this machine's first control port, 127.0.0.1:7878, and the new
# one would fail to start beside it.
Stop-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue | ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
}
try {
Start-ScheduledTask -TaskName 'PrivataeBastion'
$how = 'The bastion is running, and starts again each time you sign in to Windows.'
} catch {
Start-Process -FilePath $exe -ArgumentList 'run' -WindowStyle Hidden
$how = "The bastion is running until you sign out (its sign-in task could not be started: $($_.Exception.Message))."
}
}
Start-Sleep -Seconds 3
& $exe status
Write-Host ""
Write-Host "$done $how"
}
Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
}
Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
+232 -23
View File
@@ -1,10 +1,24 @@
#!/bin/sh
# privatae-bastion installer (macOS / Linux). Run as root (a server), it installs the
# NETWORKED bastion as a system service (systemd on Linux, launchd on macOS), and the
# first host paired anchors it, so onboarding a server is two commands:
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
# sudo -H privatae-bastion pair <token>
# Run as your own user (a desktop), it installs the LOCAL bastion for that user.
# privatae-bastion installer (macOS / Linux).
#
# On your own computer: curl -fsSL https://privatae.ai/bastion/install.sh | sh
# It installs the bastion for the WHOLE MACHINE, as a system service (systemd on
# Linux, launchd on macOS), so that no program running as you, an AI agent
# included, can stop it or change what it allows. sudo asks for your password
# once. Your own computer is the default ("personal": one Host at most, chosen at
# the machine), and your account opens the console without sudo:
# privatae-bastion ui. It watches the AI agents on the machine at once; no Host is
# needed.
#
# On a server: curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
# Run as root, it installs the SHARED bastion (several Hosts, the first one paired
# is in charge), as before: sudo -H privatae-bastion pair <token>
#
# --just-me installs it for your own account only, with no sudo. Any program
# running as you can then stop it; the installer says so. Without sudo, or when
# sudo refuses, that is what it falls back to, and it says that too.
# --personal / --shared choose the kind of machine (also spelled --local /
# --networked, their earlier names).
#
# PRIVATAE_PAIR_CODE='<code>' pairs as well, with the code from your Host's "Set
# up a bastion" card, and starts the bastion so it serves now and after a
@@ -55,17 +69,43 @@ bin="$bindir/privatae-bastion"
# it is always local. --networked is accepted and is the root default.
networked=0
local=0
just_me=0
code_on_stdin=0
for a in "$@"; do
case "$a" in
--networked) networked=1 ;;
--local) local=1 ;;
--networked|--shared) networked=1 ;;
--local|--personal) local=1 ;;
--just-me) just_me=1 ;;
# Internal: the whole-machine step this script runs through sudo
# (elevate_whole_machine) hands the pairing code on standard input, never
# in its arguments.
--pair-code-on-stdin) code_on_stdin=1 ;;
# An unknown argument is never echoed: it may be a pairing code given in
# the wrong place, and a log of the failed install must not hold it.
*) echo "ERROR: unknown option. This installer takes --networked or --local; a pairing code goes in PRIVATAE_PAIR_CODE, as your Host's card shows. Nothing was installed." >&2; exit 2 ;;
*) echo "ERROR: unknown option. This installer takes --personal, --shared or --just-me; a pairing code goes in PRIVATAE_PAIR_CODE, as your Host's card shows. Nothing was installed." >&2; exit 2 ;;
esac
done
if [ "$networked" = 1 ] && [ "$local" = 1 ]; then
echo "ERROR: --networked and --local contradict each other; pass one" >&2; exit 2
echo "ERROR: --shared and --personal contradict each other; pass one" >&2; exit 2
fi
if [ "$just_me" = 1 ] && [ "$(id -u)" = 0 ]; then
echo "ERROR: --just-me installs the bastion for your own account; run it without sudo. Nothing was installed." >&2; exit 2
fi
if [ "$just_me" = 1 ] && [ "$networked" = 1 ]; then
echo "ERROR: a shared machine's bastion runs for the whole machine; --just-me and --shared contradict each other" >&2; exit 2
fi
if [ "$code_on_stdin" = 1 ]; then
if [ "$(id -u)" != 0 ] || [ -n "$pair_given" ]; then
echo "ERROR: unknown option. Nothing was installed." >&2; exit 2
fi
IFS= read -r pair_code || true
pair_given=1
case "$pair_code" in
skp_*[!A-Za-z0-9_-]* | skp_) pair_code="" ;;
skp_?*) ;;
*) pair_code="" ;;
esac
[ -n "$pair_code" ] || { echo "ERROR: the pairing code did not arrive. Nothing was installed." >&2; exit 2; }
fi
asked_networked=$networked
@@ -173,7 +213,7 @@ start_bastion() {
mkdir -p "$sb_dir"
cat > "$sb_dir/privatae-bastion.service" <<UNIT
[Unit]
Description=Privatae bastion - serves this machine to your Hosts (tunnel + lanes)
Description=Privatae bastion - watches the AI agents in this account
[Service]
Type=simple
ExecStart=$sb_bin run
@@ -231,6 +271,36 @@ PLIST
echo "The bastion is running, until this machine restarts. Start it again with: $sb_bin run"
}
# record_owner BIN ROOT OS: run through sudo from a person's account on their
# own computer, record that account as the machine's owner, whose own account
# then opens the console without sudo (privatae-bastion ui). SUDO_UID is set by
# sudo itself. Not on a shared machine: there the console stays the
# administrator's (sudo -H privatae-bastion ui), so no agent running as the
# account that ran sudo can reach it. A second administrator's reinstall leaves
# the first owner in place; the bastion says so.
record_owner() {
ro_bin=$1; ro_root=$2; ro_os=$3
[ "$ro_root" = 1 ] || return 0
[ "$networked" != 1 ] || return 0
case "${SUDO_UID-}" in ""|0|*[!0-9]*) return 0 ;; esac
pair_as "$ro_root" "$ro_os" "$ro_bin" approvals-owner --account "$SUDO_UID" </dev/null || \
echo "NOTE: your account was not recorded as this machine's owner (the reason is above); open the console with: sudo -H privatae-bastion ui" >&2
}
# whole_machine_next BIN: what to do next on a person's own computer, the
# bastion installed for the whole machine and no Host connected.
whole_machine_next() {
echo "The bastion runs for the whole machine and starts at boot. It is watching the AI agents on this computer now; no Host is needed."
echo " Open its console: privatae-bastion ui"
case "$(uname -s)" in
Darwin) wmn_state="/var/root/Library/Application Support/privatae-bastion" ;;
*) wmn_state=/root/.config/privatae-bastion ;;
esac
[ -f "$wmn_state/machine-factor/stepup.json" ] && return 0
echo " Set up your code, to stop an agent or answer what agents ask (an authenticator app on your phone):"
echo " sudo -H privatae-bastion stepup-enroll --machine"
}
# pair_and_start BIN CODE ROOT OS: pair with CODE, handed to `pair -` on
# standard input and never as an argument, where the process list would show
# it. Then start the bastion and show its status. `pair` exits 3 when this
@@ -255,6 +325,124 @@ pair_and_start() {
echo "$ps_done $ps_how"
}
# whole_machine_script is what the whole-machine step runs as root: this same
# installer, fetched by root from the one place it is published. A script
# piped to sh cannot hand itself on, and a copy this account wrote would be a
# file this account could change before root ran it. Hardcoded, like $base.
whole_machine_script="https://privatae.ai/bastion/install.sh"
# account_install_paired: 0 when this account already has a bastion of its own
# that is paired. An upgrade keeps it where it is: moved to the whole machine,
# its pairings would stay behind in this account.
account_install_paired() {
case "$(uname -s)" in
Darwin) aip_dir="${HOME}/Library/Application Support/privatae-bastion" ;;
*) aip_dir="${XDG_CONFIG_HOME:-$HOME/.config}/privatae-bastion" ;;
esac
[ -f "$aip_dir/config.json" ] && return 0
for aip_f in "$aip_dir"/pairings/*.json; do [ -f "$aip_f" ] && return 0; done
return 1
}
# elevate_whole_machine: install for the whole machine through sudo. Returns 3
# when sudo is missing or refuses (no admin rights, no terminal to ask on, a
# wrong password): nothing was installed. Otherwise the root step's own exit
# status. The pairing code reaches the root step on standard input, never in
# an argument, where the process list would show it.
elevate_whole_machine() {
command -v sudo >/dev/null 2>&1 || return 3
echo "Installing the bastion for the whole machine, so that no program running as you, an AI agent included, can stop it or change what it allows."
echo "sudo asks for your password once."
# `sudo true`, not `sudo -v`: -v asks for a password unless every sudo rule
# of the account is NOPASSWD (sudoers verifypw), so an account that may run
# anything without one was asked anyway, and fell back to itself where no
# terminal could answer.
sudo -p '[bastion] sudo password for %u: ' true </dev/null || return 3
ew_kind=--personal
[ "$networked" = 1 ] && ew_kind=--shared
# -H and curl -q: root's step reads nothing from this account's home. A sudo
# configured to keep HOME (env_keep) would otherwise have root's curl
# read this account's ~/.curlrc, which can point the fetch at another server
# (resolve, proxy) and switch off its certificate check: a file this account
# can change, deciding what root runs.
ew_cmd='f=$(mktemp) || exit 1; if curl -q -fsSL "$1" -o "$f"; then shift; /bin/sh "$f" "$@"; rc=$?; else echo "ERROR: could not fetch $1. Nothing was installed." >&2; rc=1; fi; rm -f "$f"; exit $rc'
ew_rc=0
if [ -n "$pair_code" ]; then
printf '%s\n' "$pair_code" | sudo -H /bin/sh -c "$ew_cmd" sh "$whole_machine_script" "$ew_kind" --pair-code-on-stdin || ew_rc=$?
else
sudo -H /bin/sh -c "$ew_cmd" sh "$whole_machine_script" "$ew_kind" </dev/null || ew_rc=$?
fi
# 3 means sudo refused (above); the root step's own failure is never read
# as that.
[ "$ew_rc" = 3 ] && ew_rc=1
return "$ew_rc"
}
# account_copy_cleanup: what an earlier install for this account alone left
# that would run a second bastion beside the machine's: its unit or LaunchAgent
# and its program. Its state, pairings included, stays in the account. Reached
# when that install held no pairing, or when the machine already has a
# bastion for the whole machine, beside which it could not run anyway.
account_copy_cleanup() {
acc_unit="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user/privatae-bastion.service"
if [ -f "$acc_unit" ]; then
systemctl --user disable --now privatae-bastion >/dev/null 2>&1 || true
rm -f "$acc_unit"
systemctl --user daemon-reload >/dev/null 2>&1 || true
echo "Removed this account's own bastion service; the machine's replaces it."
fi
acc_plist="$HOME/Library/LaunchAgents/ai.privatae.bastion.user.plist"
if [ -f "$acc_plist" ]; then
/bin/launchctl bootout "gui/$(id -u)/ai.privatae.bastion.user" >/dev/null 2>&1 || true
rm -f "$acc_plist"
echo "Removed this account's own bastion LaunchAgent; the machine's replaces it."
fi
if [ -f "$HOME/.local/bin/privatae-bastion" ] && [ ! -L "$HOME/.local/bin/privatae-bastion" ]; then
rm -f "$HOME/.local/bin/privatae-bastion"
echo "Removed $HOME/.local/bin/privatae-bastion, an earlier copy that would come first on PATH."
fi
if [ "$(uname -s)" = Linux ]; then stop_user_copies; fi
}
# machine_install_present: 0 when this machine's bastion is already installed
# for the whole machine. A second one for an account beside it would only be
# refused (the bastion's own guard), so none is installed.
machine_install_present() {
case "$(uname -s)" in
Darwin) [ -f /Library/LaunchDaemons/ai.privatae.bastion.plist ] ;;
*) [ -f /etc/systemd/system/privatae-bastion.service ] ;;
esac
}
# Not root: the whole machine, through sudo, unless this account's own bastion
# is asked for (--just-me) or is already paired. Where sudo cannot be used,
# this account, and the installer says what that means; but never beside a
# bastion installed for the whole machine.
kept_account=0
fell_back=0
if [ "$root" != 1 ] && [ "$just_me" = 1 ] && machine_install_present; then
echo "ERROR: this machine's bastion is installed for the whole machine, and a second one for your account would be refused beside it. Its console opens from its owner's account with: privatae-bastion ui. Nothing was installed." >&2
exit 1
fi
if [ "$root" != 1 ] && [ "$just_me" != 1 ]; then
if account_install_paired && ! machine_install_present; then
kept_account=1
else
ew_rc=0
elevate_whole_machine || ew_rc=$?
case "$ew_rc" in
0) account_copy_cleanup; exit 0 ;;
3) if machine_install_present; then
echo "ERROR: this machine's bastion is installed for the whole machine, and updating it needs an account that may use sudo, in a terminal. Nothing was installed." >&2
exit 1
fi
fell_back=1
echo "sudo could not be used here, so the bastion is installed for your account only." >&2 ;;
*) exit "$ew_rc" ;;
esac
fi
fi
if [ "$root" = 1 ] && [ "$local" != 1 ]; then
case "$(uname -s)" in
Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;;
@@ -340,7 +528,7 @@ fi
echo "Checksum verified ($expected)."
darwin_service=0
if [ "$os" = darwin ] && [ "$networked" = 1 ]; then
if [ "$os" = darwin ] && [ "$root" = 1 ]; then
# The launchd daemon runs this program as root, so only root may be able to
# replace it: a root-owned folder, not /usr/local/bin, which a common package
# manager hands to the signed-in user on many Macs. `service install` checks
@@ -376,7 +564,7 @@ if [ "$os" = linux ] && [ "$root" = 1 ] && command -v systemctl >/dev/null 2>&1;
# the installer WITHOUT sudo and gets the --user path below instead.
cat > /etc/systemd/system/privatae-bastion.service <<UNIT
[Unit]
Description=Privatae bastion - your secure door into your private mesh (tunnel + lanes)
Description=Privatae bastion - watches the AI agents on this machine
After=network-online.target
Wants=network-online.target
[Service]
@@ -397,8 +585,10 @@ UNIT
# binary itself so the path is resolved exactly one way.
HOME=/root "$bin" set-mode networked || die "could not set networked mode"
fi
systemctl enable --now privatae-bastion >/dev/null 2>&1 || true
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
systemctl enable privatae-bastion >/dev/null 2>&1 || true
# restart, not start: a reinstall must run the new program.
systemctl restart privatae-bastion >/dev/null 2>&1 || true
record_owner "$bin" "$root" "$os"
echo ""
if [ -n "$pair_code" ]; then
:
@@ -417,17 +607,23 @@ UNIT
echo " sudo -H privatae-bastion stepup-enroll"
echo " sudo systemctl restart privatae-bastion"
else
echo "Now pair: sudo -H privatae-bastion pair <token>"
whole_machine_next "$bin"
echo " Connect a Host, if you run one (optional): sudo -H privatae-bastion pair <token>"
fi
elif [ "$darwin_service" = 1 ]; then
# The mode is box state under root's home, where the daemon keeps its state
# (the daemon's HOME is /var/root), written by the binary itself so the path
# is resolved exactly one way. Before the service starts, so its first run
# already sees it.
HOME=/var/root "$bin" set-mode networked || die "could not set networked mode"
if [ "$networked" = 1 ]; then
HOME=/var/root "$bin" set-mode networked || die "could not set networked mode"
fi
"$bin" service install || die "could not register the system service"
record_owner "$bin" "$root" "$os"
echo ""
if [ -z "$pair_code" ]; then
if [ -z "$pair_code" ] && [ "$networked" != 1 ]; then
whole_machine_next "$bin"
elif [ -z "$pair_code" ]; then
echo "Networked bastion, running as a system service (launchd, as root)."
echo "Pair it with YOUR host; the first host paired becomes its anchor:"
# The full path, not the name on PATH: /usr/local/bin may belong to the
@@ -444,13 +640,26 @@ elif [ "$networked" = 1 ]; then
else
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
echo ""
if [ "$kept_local" = 1 ]; then
echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local."
echo "To make it a networked server instead, run the installer again with --networked."
if [ "$kept_account" = 1 ]; then
echo "This account's own bastion is already paired, so it stays installed for this account."
echo "To move it to the whole machine: disconnect its Host (privatae-bastion unpair <host>), run this"
echo "installer again, and connect the Host again with: sudo -H privatae-bastion pair <token>"
fi
echo "WARNING: installed for your account only. Any program running as you, an AI agent included, can stop"
echo " the bastion or change what it allows."
if [ "$just_me" = 1 ]; then
echo " To install it for the whole machine instead, run this installer again without --just-me."
elif [ "$fell_back" = 1 ]; then
echo " To install it for the whole machine, run this installer again in a terminal, from an account that may use sudo."
fi
if [ -z "$pair_code" ]; then
echo "Now pair: privatae-bastion pair <token>"
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
if start_how=$(start_bastion "$bin" "$root" "$os"); then
echo "$start_how"
echo "It is watching the AI agents in this account now; no Host is needed. Open its console: $bin ui"
else
echo "Start it with: $bin run" >&2
fi
echo "Connect a Host, if you run one (optional): $bin pair <token>"
fi
fi
if [ -n "$pair_code" ]; then