bastion 0.60.0
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
0.58.1
|
||||
0.60.0
|
||||
|
||||
+350
-246
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user