From 5cb8ce2f63abf07de3a575bd143ee373f9422cee Mon Sep 17 00:00:00 2001 From: amir-climy Date: Thu, 21 May 2026 15:07:13 +0800 Subject: [PATCH] feat(install): macOS + Windows support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh / uninstall.sh (Linux + macOS): - Detect OS with uname -s; root check is Linux-only (Docker/Podman Desktop on macOS runs rootless, no sudo needed). - Arch detection adds arm64 case for Apple Silicon (uname -m returns "arm64" on macOS, "aarch64" on Linux). - sed_inplace() wrapper handles BSD sed on macOS (requires empty -i suffix). - Fix --image flag to append TRITON_MANAGE_IMAGE rather than sed-replace a line that is commented out in env.template. - uninstall: re-apply --purge-data fixes (rm installer dir, drop interactive prompt, use $RUNTIME for raw cleanup instead of hardcoded podman). install.ps1 / uninstall.ps1 (Windows): - Equivalent logic for Docker Desktop / Podman Desktop on Windows. - Arch via RuntimeInformation.OSArchitecture (X64 → amd64, Arm64 → arm64). - Secrets via RandomNumberGenerator (no openssl dependency). - Parameters: -GatewayHostname, -ManageHostIP, -Image, -NoTls / -PurgeData. Co-Authored-By: Claude Sonnet 4.6 --- manage-server/install.ps1 | 145 ++++++++++++++++++++++++++++++++++++ manage-server/install.sh | 37 ++++++--- manage-server/uninstall.ps1 | 68 +++++++++++++++++ manage-server/uninstall.sh | 37 +++++---- 4 files changed, 261 insertions(+), 26 deletions(-) create mode 100644 manage-server/install.ps1 create mode 100644 manage-server/uninstall.ps1 diff --git a/manage-server/install.ps1 b/manage-server/install.ps1 new file mode 100644 index 0000000..8064a86 --- /dev/null +++ b/manage-server/install.ps1 @@ -0,0 +1,145 @@ +#Requires -Version 5.1 +# install.ps1 — Triton Manage Server installer for Windows. +# +# Idempotent. Generates secrets on first run, reuses .env afterwards. +# Container-based via Docker Desktop or Podman Desktop (auto-detected). +# Requires Docker Desktop in Linux container mode (the default). +# +# Usage: +# .\install.ps1 +# +# Parameters (all optional): +# -GatewayHostname HOST Agent mTLS hostname (defaults to current FQDN). +# -ManageHostIP IP Host LAN IP — used for "+ This machine". +# -Image TAG Pin a specific manage-server image tag. +# -NoTls Skip the TLS-required sanity check (dev). +param( + [string]$GatewayHostname = '', + [string]$ManageHostIP = '', + [string]$Image = '', + [switch]$NoTls +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +function Write-Info([string]$msg) { Write-Host "[manage-server] $msg" } +function Write-Die([string]$msg) { Write-Error "[manage-server] error: $msg"; exit 1 } + +# ── architecture detection ─────────────────────────────────────────────── +$cpuArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +$arch = switch ($cpuArch.ToString()) { + 'X64' { 'amd64' } + 'Arm64' { 'arm64' } + default { Write-Die "unsupported architecture: $cpuArch (supported: X64, Arm64)"; 'unknown' } +} +Write-Info "architecture: windows/$arch" + +# ── runtime detection ──────────────────────────────────────────────────── +$composeCmd = $null +$runtime = $null + +if (Get-Command docker -ErrorAction SilentlyContinue) { + & docker compose version 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $composeCmd = @('docker','compose'); $runtime = 'docker' } +} +if (-not $composeCmd -and (Get-Command podman -ErrorAction SilentlyContinue)) { + & podman compose version 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $composeCmd = @('podman','compose'); $runtime = 'podman' } +} +if (-not $composeCmd) { + Write-Die "no compose runtime found. Install Docker Desktop or Podman Desktop." +} +Write-Info "using runtime: $runtime" + +# ── random secret generation ───────────────────────────────────────────── +function New-RandomHex([int]$byteCount) { + $buf = [byte[]]::new($byteCount) + [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($buf) + return ($buf | ForEach-Object { $_.ToString('x2') }) -join '' +} + +# ── .env bootstrap ─────────────────────────────────────────────────────── +$envFile = Join-Path $ScriptDir '.env' + +if (-not (Test-Path $envFile)) { + Write-Info "writing .env from env.template" + $template = Join-Path $ScriptDir 'env.template' + if (-not (Test-Path $template)) { Write-Die "env.template not found in $ScriptDir" } + Copy-Item $template $envFile + + $pgPass = New-RandomHex 24 + $jwtKey = New-RandomHex 32 + $workerKey = New-RandomHex 16 + $vaultKey = New-RandomHex 32 + + # Read-modify-write in one pass so line endings are preserved. + $content = [System.IO.File]::ReadAllText($envFile) + $content = $content -replace '(?m)^POSTGRES_PASSWORD=.*', "POSTGRES_PASSWORD=$pgPass" + $content = $content -replace '(?m)^TRITON_MANAGE_JWT_SIGNING_KEY=.*', "TRITON_MANAGE_JWT_SIGNING_KEY=$jwtKey" + $content = $content -replace '(?m)^TRITON_MANAGE_WORKER_KEY=.*', "TRITON_MANAGE_WORKER_KEY=$workerKey" + $content = $content -replace '(?m)^TRITON_VAULT_KEY=.*', "TRITON_VAULT_KEY=$vaultKey" + [System.IO.File]::WriteAllText($envFile, $content) + Write-Info "vault key generated (PostgreSQL AES-256-GCM)" + + if ($GatewayHostname) { + $content = [System.IO.File]::ReadAllText($envFile) + $content = $content -replace '(?m)^TRITON_MANAGE_GATEWAY_HOSTNAME=.*', "TRITON_MANAGE_GATEWAY_HOSTNAME=$GatewayHostname" + [System.IO.File]::WriteAllText($envFile, $content) + } + if ($ManageHostIP) { + $content = [System.IO.File]::ReadAllText($envFile) + $content = $content -replace '(?m)^TRITON_MANAGE_HOST_IP=.*', "TRITON_MANAGE_HOST_IP=$ManageHostIP" + [System.IO.File]::WriteAllText($envFile, $content) + } + # IMAGE has no placeholder line in env.template — append it directly. + if ($Image) { + Add-Content $envFile "`nTRITON_MANAGE_IMAGE=$Image" + } + + Write-Info ".env created at $envFile" + Write-Info " back this up — it contains the JWT signing key, worker key, and vault key" +} else { + Write-Info "reusing existing .env at $envFile" +} + +# ── start ──────────────────────────────────────────────────────────────── +Write-Info "starting containers..." +& $composeCmd[0] $composeCmd[1] --env-file $envFile up -d +if ($LASTEXITCODE -ne 0) { Write-Die "compose up failed (exit $LASTEXITCODE)" } + +# ── wait for health ────────────────────────────────────────────────────── +$portLine = Get-Content $envFile | Where-Object { $_ -match '^TRITON_MANAGE_HOST_PORT=' } | Select-Object -First 1 +$hostPort = if ($portLine) { $portLine -replace '^TRITON_MANAGE_HOST_PORT=', '' } else { '8082' } + +Write-Info "waiting for manage server to become healthy on :$hostPort..." +$up = $false +for ($i = 1; $i -le 30; $i++) { + try { + $resp = Invoke-WebRequest "http://localhost:$hostPort/" -MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop + if ($resp.StatusCode -in @(200, 302)) { $up = $true; break } + } catch [System.Net.WebException] { + $code = [int]$_.Exception.Response.StatusCode + if ($code -in @(200, 302)) { $up = $true; break } + } catch { <# connection refused — keep polling #> } + Start-Sleep -Seconds 2 +} + +if ($up) { + Write-Info "manage server is up" +} else { + Write-Info "warning: health check timed out — check logs with: $($composeCmd -join ' ') logs manage-server" +} + +Write-Info "" +Write-Info "Installation complete. Next steps:" +Write-Info " 1. Open http://localhost:$hostPort (or your public URL)" +Write-Info " 2. Complete the setup wizard:" +Write-Info " - Set your manage server name" +Write-Info " - Enter your Triton licence server URL and licence ID" +Write-Info " - Or upload an air-gap licence file" +Write-Info " 3. Configure TLS via reverse proxy (see docs)" +Write-Info "" diff --git a/manage-server/install.sh b/manage-server/install.sh index 8a76037..c1b92a4 100755 --- a/manage-server/install.sh +++ b/manage-server/install.sh @@ -4,8 +4,12 @@ # Idempotent. Generates secrets on first run, reuses .env afterwards. # Container-based via Podman or Docker (auto-detected). # +# Supported: Linux (amd64/arm64), macOS (Intel/Apple Silicon). +# For Windows use install.ps1 instead. +# # Usage: -# sudo bash install.sh +# Linux: sudo bash install.sh +# macOS: bash install.sh # # Flags (all optional): # --gateway-hostname HOST Agent mTLS hostname (defaults to current FQDN). @@ -20,6 +24,14 @@ cd "$SCRIPT_DIR" info() { printf '[manage-server] %s\n' "$*"; } die() { printf '[manage-server] error: %s\n' "$*" >&2; exit 1; } +# Portable in-place sed: BSD sed (macOS) requires an explicit empty backup suffix. +OS=$(uname -s) +if [[ "$OS" == "Darwin" ]]; then + sed_inplace() { sed -i '' "$@"; } +else + sed_inplace() { sed -i "$@"; } +fi + # ── arg parsing ────────────────────────────────────────────────────────── GATEWAY_HOST="" HOST_IP="" @@ -36,15 +48,17 @@ while [[ $# -gt 0 ]]; do esac done -[[ $EUID -eq 0 ]] || die "must run as root" +# Docker/Podman Desktop on macOS runs rootless; only Linux needs root. +[[ "$OS" != "Linux" || $EUID -eq 0 ]] || die "must run as root (on Linux use: sudo bash install.sh)" # ── architecture detection ─────────────────────────────────────────────── case "$(uname -m)" in - x86_64) ARCH=amd64 ;; - aarch64) ARCH=arm64 ;; - *) die "unsupported architecture: $(uname -m) (supported: x86_64, aarch64)" ;; + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; # aarch64 = Linux, arm64 = macOS Apple Silicon + *) die "unsupported architecture: $(uname -m) (supported: x86_64, aarch64, arm64)" ;; esac -info "architecture: linux/$ARCH" +OS_LABEL=$(echo "$OS" | tr '[:upper:]' '[:lower:]') +info "architecture: ${OS_LABEL}/$ARCH" # ── runtime detection ──────────────────────────────────────────────────── if command -v podman-compose >/dev/null 2>&1; then @@ -57,7 +71,7 @@ elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) RUNTIME=docker else - die "no compose runtime found. Install podman-compose or docker compose." + die "no compose runtime found. Install Docker Desktop or podman-compose." fi info "using runtime: $RUNTIME" @@ -73,7 +87,7 @@ if [[ ! -f "$ENV_FILE" ]]; then WORKER_KEY=$(openssl rand -hex 16) VAULT_KEY=$(openssl rand -hex 32) - sed -i \ + sed_inplace \ -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" \ -e "s|^TRITON_MANAGE_JWT_SIGNING_KEY=.*|TRITON_MANAGE_JWT_SIGNING_KEY=$JWT_KEY|" \ -e "s|^TRITON_MANAGE_WORKER_KEY=.*|TRITON_MANAGE_WORKER_KEY=$WORKER_KEY|" \ @@ -81,9 +95,10 @@ if [[ ! -f "$ENV_FILE" ]]; then "$ENV_FILE" info "vault key generated (PostgreSQL AES-256-GCM)" - [[ -n "$GATEWAY_HOST" ]] && sed -i "s|^TRITON_MANAGE_GATEWAY_HOSTNAME=.*|TRITON_MANAGE_GATEWAY_HOSTNAME=$GATEWAY_HOST|" "$ENV_FILE" - [[ -n "$HOST_IP" ]] && sed -i "s|^TRITON_MANAGE_HOST_IP=.*|TRITON_MANAGE_HOST_IP=$HOST_IP|" "$ENV_FILE" - [[ -n "$IMAGE" ]] && sed -i "s|^TRITON_MANAGE_IMAGE=.*|TRITON_MANAGE_IMAGE=$IMAGE|" "$ENV_FILE" + [[ -n "$GATEWAY_HOST" ]] && sed_inplace "s|^TRITON_MANAGE_GATEWAY_HOSTNAME=.*|TRITON_MANAGE_GATEWAY_HOSTNAME=$GATEWAY_HOST|" "$ENV_FILE" + [[ -n "$HOST_IP" ]] && sed_inplace "s|^TRITON_MANAGE_HOST_IP=.*|TRITON_MANAGE_HOST_IP=$HOST_IP|" "$ENV_FILE" + # IMAGE has no placeholder line in env.template — append it directly. + [[ -n "$IMAGE" ]] && printf '\nTRITON_MANAGE_IMAGE=%s\n' "$IMAGE" >> "$ENV_FILE" info ".env created at $ENV_FILE" info " back this up — it contains the JWT signing key, worker key, and vault key" diff --git a/manage-server/uninstall.ps1 b/manage-server/uninstall.ps1 new file mode 100644 index 0000000..b566b6b --- /dev/null +++ b/manage-server/uninstall.ps1 @@ -0,0 +1,68 @@ +#Requires -Version 5.1 +# uninstall.ps1 — stop and remove Manage Server containers on Windows. +# +# By default, KEEPS the PostgreSQL volume (scan history, hosts, users) +# and the installer directory (preserves .env secrets for reinstall). +# Pass -PurgeData to delete volumes + installer directory — irreversible. +# +# Usage: +# .\uninstall.ps1 # stop + remove containers, keep DB + .env +# .\uninstall.ps1 -PurgeData # also delete DB, volumes, and installer dir +param( + [switch]$PurgeData +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $ScriptDir + +function Write-Info([string]$msg) { Write-Host "[manage-server] $msg" } +function Write-Die([string]$msg) { Write-Error "[manage-server] error: $msg"; exit 1 } + +# ── runtime detection ──────────────────────────────────────────────────── +$composeCmd = $null +$runtime = $null + +if (Get-Command docker -ErrorAction SilentlyContinue) { + & docker compose version 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $composeCmd = @('docker','compose'); $runtime = 'docker' } +} +if (-not $composeCmd -and (Get-Command podman -ErrorAction SilentlyContinue)) { + & podman compose version 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { $composeCmd = @('podman','compose'); $runtime = 'podman' } +} +if (-not $composeCmd) { Write-Die "no compose runtime found" } + +# ── stop containers ────────────────────────────────────────────────────── +$envFile = Join-Path $ScriptDir '.env' +if (Test-Path $envFile) { + Write-Info "stopping containers..." + & $composeCmd[0] $composeCmd[1] --env-file $envFile down +} else { + Write-Info ".env not found, attempting raw container cleanup..." + $ErrorActionPreference = 'Continue' + & $runtime rm -f triton-manageserver triton-manage-db 2>$null + $ErrorActionPreference = 'Stop' +} + +# ── purge ──────────────────────────────────────────────────────────────── +if ($PurgeData) { + Write-Info "DESTRUCTIVE: removing manage server volumes..." + Write-Info " this deletes: scan history, hosts, users, worker binaries" + $ErrorActionPreference = 'Continue' + foreach ($vol in @('triton-manage-db-data', 'triton-manage-bins')) { + & $runtime volume rm -f $vol 2>$null + } + $ErrorActionPreference = 'Stop' + Write-Info " volumes removed" + Write-Info " removing installer directory $ScriptDir..." + Remove-Item -Recurse -Force $ScriptDir + Write-Info " installer directory removed" +} else { + Write-Info "DB + bins volumes retained (run with -PurgeData to delete)" + Write-Info ".env preserved at $envFile — secrets reused on reinstall" +} + +Write-Info "uninstall complete" diff --git a/manage-server/uninstall.sh b/manage-server/uninstall.sh index 52d87af..1e0d721 100755 --- a/manage-server/uninstall.sh +++ b/manage-server/uninstall.sh @@ -1,12 +1,17 @@ #!/usr/bin/env bash # uninstall.sh — stop and remove Manage Server containers. # -# By default, KEEPS the PostgreSQL volume (scan history, hosts, users). -# Pass --purge-data to delete the volumes as well — irreversible. +# By default, KEEPS the PostgreSQL volume (scan history, hosts, users) +# and the installer directory (preserves .env secrets for reinstall). +# Pass --purge-data to delete volumes + installer directory — irreversible. +# +# Supported: Linux (amd64/arm64), macOS (Intel/Apple Silicon). +# For Windows use uninstall.ps1 instead. # # Usage: -# sudo bash uninstall.sh # stop + remove containers, keep DB -# sudo bash uninstall.sh --purge-data # also delete DB + binaries volume +# Linux: sudo bash uninstall.sh # stop + remove containers, keep DB + .env +# Linux: sudo bash uninstall.sh --purge-data # also delete DB, volumes, and installer dir +# macOS: bash uninstall.sh [--purge-data] set -euo pipefail SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" @@ -15,7 +20,10 @@ cd "$SCRIPT_DIR" info() { printf '[manage-server] %s\n' "$*"; } die() { printf '[manage-server] error: %s\n' "$*" >&2; exit 1; } -[[ $EUID -eq 0 ]] || die "must run as root" +OS=$(uname -s) + +# Docker/Podman Desktop on macOS runs rootless; only Linux needs root. +[[ "$OS" != "Linux" || $EUID -eq 0 ]] || die "must run as root (on Linux use: sudo bash uninstall.sh)" PURGE=0 while [[ $# -gt 0 ]]; do @@ -26,9 +34,9 @@ while [[ $# -gt 0 ]]; do esac done -if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose) -elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose) -elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) +if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose); RUNTIME=podman +elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose); RUNTIME=podman +elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose); RUNTIME=docker else die "no compose runtime found"; fi if [[ -f .env ]]; then @@ -36,23 +44,22 @@ if [[ -f .env ]]; then "${COMPOSE[@]}" --env-file .env down else info ".env not found, attempting raw container cleanup..." - podman rm -f triton-manageserver triton-manage-db 2>/dev/null || true + "$RUNTIME" rm -f triton-manageserver triton-manage-db 2>/dev/null || true fi if [[ $PURGE -eq 1 ]]; then info "DESTRUCTIVE: removing manage server volumes..." info " this deletes: scan history, hosts, users, worker binaries" - read -r -p " Are you sure? Type 'yes' to confirm: " CONFIRM - [[ "$CONFIRM" == "yes" ]] || die "aborted" for vol in triton-manage-db-data triton-manage-bins; do - podman volume rm -f "$vol" 2>/dev/null \ - || docker volume rm -f "$vol" 2>/dev/null \ - || true + "$RUNTIME" volume rm -f "$vol" 2>/dev/null || true done info " volumes removed" - info " .env still on disk at $SCRIPT_DIR/.env — delete manually if desired" + info " removing installer directory $SCRIPT_DIR..." + rm -rf "$SCRIPT_DIR" + info " installer directory removed" else info "DB + bins volumes retained (run with --purge-data to delete)" + info ".env preserved at $SCRIPT_DIR/.env — secrets reused on reinstall" fi info "uninstall complete"