Menu
Public repository / d7eb6622a4697643ecf906b39e64b71c72e8a69b
AkurAI-EC2 / bin/akurai-ec2
Latest change d7eb6622a4697643ecf906b39e64b71c72e8a69b - fix: harden v2 manifest install and parsing by Claude Code
#!/usr/bin/env bash
# akurai-ec2 — reusable ops for the AkurAI AWS platform box (mail.olibuijr.com).
#
# One place for the things we kept re-deriving: SSH, file shipping, deploying a
# single-binary app behind nginx+systemd, TLS, and status. Designed to oneshot
# future deploys instead of guessing.
#
# akurai-ec2 ssh '<cmd>' run a command on the box
# akurai-ec2 ship <local> <remote> scp a file up (creates parent dir)
# akurai-ec2 status services, ports, nginx, disk
# akurai-ec2 processes [pattern] list the running AkurAI fleet: unit,
# akurai-ec2 ps [pattern] state, PID, memory, uptime, port.
# Optional pattern overrides the fleet
# match (an egrep alternation).
# akurai-ec2 deploy-binary <name> <bin> <port> [appdir]
# push a single binary, scaffold/serve it
# via systemd on 127.0.0.1:<port>.
# Backs up the previous binary to
# <name>.prev, health-gates the new one
# (must answer HTTP on :<port>), and
# auto-rolls-back if it doesn't.
# akurai-ec2 nginx-proxy <domain> <port> reverse-proxy vhost + reload; re-asserts
# HTTPS if a cert already exists (so a
# redeploy never drops TLS)
# akurai-ec2 tls <domain> obtain/renew Let's Encrypt cert (certbot)
# akurai-ec2 release [patch|minor|major] unified release engine — reads the
# repo's akurai-deploy.toml and runs the
# whole release (gate → bump VERSION +
# Cargo.toml → hybrid changelog → tag +
# push → deploy-binary + nginx + tls).
# Replaces each project's bespoke deploy.sh.
# [publish|ec2] ship current binary, no version bump
# [--dry-run] preview bump + changelog, ship nothing
# [--changelog "text"] one-line changelog (skip the git draft)
# [--yes] [-C <dir>] accept draft unattended / run in <dir>
# akurai-ec2 rollback <name> restore <name>.prev (the previous binary)
# akurai-ec2 logs <service> journalctl -u <service> -n 80
# akurai-ec2 mail-autoconfig verify [domain...]
# probe email client auto-setup for a domain:
# Mozilla autoconfig (GET) + MS autodiscover
# (POST, must not 405) + TLS host-match + SRV.
# Defaults to olibuijr.com + golfsetridak.is.
# akurai-ec2 ports [list] registry vs live: every port, who owns
# it, and whether it's bound (the index)
# akurai-ec2 ports free [n] next n free port(s) in the app pool
# (8094-8199). Claim it in the registry
# BEFORE deploy-binary to avoid collisions.
# akurai-ec2 ports check <port> is <port> free? who holds it (registry+live)?
# akurai-ec2 ports drift flag collisions, crash-loops, and nginx
# vhosts pointing at the wrong upstream.
# akurai-ec2 allow-ssh [ip] re-authorize SSH (port 22) from an IP
# (default: your current public IP). The
# SG locks 22 to /32s; a dynamic-IP change
# locks you out — this re-adds you.
set -euo pipefail
SSH_HOST="${AKURAI_EC2_SSH:-akurai-mail}" # ~/.ssh/config alias
PUBLIC_IP="3.94.46.219"
# Intended port assignments (SSOT) — see the _VMPORTS skill. `ports` reads this.
PORTS_REGISTRY="${AKURAI_PORTS_REGISTRY:-$HOME/.claude/skills/_VMPORTS/ports.registry}"
APP_POOL_LO=8094
APP_POOL_HI=8199
SCRIPT_SOURCE="${BASH_SOURCE[0]}"
if command -v readlink >/dev/null 2>&1; then
resolved_script="$(readlink -f "$SCRIPT_SOURCE" 2>/dev/null || true)"
[ -n "$resolved_script" ] && SCRIPT_SOURCE="$resolved_script"
fi
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" && pwd)"
MANIFEST_HELPER="$SCRIPT_DIR/../libexec/akurai-ec2-manifest.py"
declare -Ag RELEASE_MANIFEST=()
RELEASE_MANIFEST_KIND="flat"
ssh_run() { ssh -o ConnectTimeout=15 "$SSH_HOST" "$@"; }
say() { printf '\033[1;34m▸ %s\033[0m\n' "$*"; }
die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; }
release_load_manifest_flat() {
local manifest_path="$1" ln t k v
RELEASE_MANIFEST=()
RELEASE_MANIFEST_KIND="flat"
while IFS= read -r ln; do
t="$(printf '%s' "$ln" | sed -E 's/^[[:space:]]+//')"
case "$t" in \#*|"") continue ;; esac
case "$ln" in *=*) ;; *) continue ;; esac
k="$(printf '%s' "${ln%%=*}" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
v="$(printf '%s' "${ln#*=}" | sed -E 's/[[:space:]]+#.*$//; s/^[[:space:]]+//; s/[[:space:]]+$//')"
v="${v%\"}"; v="${v#\"}"
RELEASE_MANIFEST["$k"]="$v"
done < "$manifest_path"
}
release_load_manifest_toml() {
local manifest_path="$1" helper_output key value
local -A manifest_tmp=()
[ -f "$MANIFEST_HELPER" ] || die "manifest helper missing: $MANIFEST_HELPER"
command -v python3 >/dev/null 2>&1 || die "python3 is required for nested release manifests"
if ! helper_output="$(python3 "$MANIFEST_HELPER" "$manifest_path")"; then
die "manifest helper failed: $manifest_path"
fi
while IFS=$'\t' read -r key value; do
[ -n "$key" ] || continue
manifest_tmp["$key"]="$value"
done <<< "$helper_output"
RELEASE_MANIFEST=()
RELEASE_MANIFEST_KIND="toml"
for key in "${!manifest_tmp[@]}"; do
RELEASE_MANIFEST["$key"]="${manifest_tmp["$key"]}"
done
}
release_load_manifest() {
local manifest_path="$1"
if grep -qE '^[[:space:]]*\[' "$manifest_path"; then
release_load_manifest_toml "$manifest_path"
else
release_load_manifest_flat "$manifest_path"
fi
}
mf_get() {
local key
for key in "$@"; do
if [[ -v RELEASE_MANIFEST["$key"] ]]; then
printf '%s\n' "${RELEASE_MANIFEST["$key"]}"
return 0
fi
done
return 1
}
mf_get_bool() {
local default_value="$1" value key_hint
shift
key_hint="${1:-value}"
value="$(mf_get "$@" || true)"
if [ -z "$value" ]; then
printf '%s\n' "$default_value"
return 0
fi
case "${value,,}" in
1|true|yes|on) printf 'true\n' ;;
0|false|no|off) printf 'false\n' ;;
*) die "manifest: expected boolean for '$key_hint', got '$value'" ;;
esac
}
mf_require() {
local label="$1" value
shift
value="$(mf_get "$@" || true)"
[ -n "$value" ] || die "manifest: '$label' required"
printf '%s\n' "$value"
}
mf_get_string_array_lines() {
local label="$1" value decoded
shift
value="$(mf_get "$@" || true)"
[ -n "$value" ] || return 0
if [[ "$value" != \[* ]]; then
printf '%s\n' "$value"
return 0
fi
command -v python3 >/dev/null 2>&1 || die "python3 is required for array manifest fields"
if ! decoded="$(VALUE="$value" python3 - "$label" <<'PY'
import json
import os
import sys
label = sys.argv[1]
raw = os.environ["VALUE"]
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"manifest: invalid array for '{label}': {exc}", file=sys.stderr)
raise SystemExit(1)
if not isinstance(data, list):
print(f"manifest: expected array for '{label}'", file=sys.stderr)
raise SystemExit(1)
for idx, item in enumerate(data):
if not isinstance(item, str):
print(
f"manifest: '{label}' array item {idx + 1} must be a string",
file=sys.stderr,
)
raise SystemExit(1)
if any(ch in item for ch in ("\0", "\r", "\n")):
print(
f"manifest: '{label}' array item {idx + 1} contains unsupported control characters",
file=sys.stderr,
)
raise SystemExit(1)
print(item)
PY
)"; then
die "manifest: invalid array field '$label'"
fi
printf '%s' "$decoded"
}
cmd="${1:-help}"; shift || true
case "$cmd" in
ssh)
ssh_run "$@"
;;
ship)
[ $# -eq 2 ] || die "usage: akurai-ec2 ship <local> <remote>"
local_file="$1"; remote="$2"
ssh_run "mkdir -p \"\$(dirname '$remote')\""
scp -o ConnectTimeout=15 "$local_file" "$SSH_HOST:$remote"
say "shipped $local_file → $remote"
;;
status)
ssh_run 'bash -s' <<'EOF'
echo "=== app services ==="
systemctl list-units --type=service --state=running | grep -iE 'akurai|golfset|mail|idp' || true
echo "=== listening ports ==="
sudo ss -tlnp 2>/dev/null | grep -E 'LISTEN' | awk '{print $4}' | sort -u | tail -20
echo "=== nginx vhosts ==="
ls /etc/nginx/sites-enabled/
echo "=== disk ==="
df -h / | tail -1
EOF
;;
deploy-binary)
# deploy-binary <name> <local-bin> <port> [appdir-to-upload]
[ $# -ge 3 ] || die "usage: akurai-ec2 deploy-binary <name> <local-bin> <port> [appdir]"
name="$1"; bin="$2"; port="$3"; appdir="${4:-}"
base="/opt/$name"
say "deploying '$name' on port $port"
ssh_run "sudo mkdir -p $base/bin && sudo chown -R ubuntu:ubuntu $base"
# Stop the service first — a running binary is busy and can't be overwritten.
ssh_run "sudo systemctl stop $name.service 2>/dev/null || true"
# Back up the currently-deployed binary before overwriting it — the rollback
# target if the new build turns out unhealthy (see the health-gate below).
ssh_run "[ -f $base/bin/$name ] && cp -p $base/bin/$name $base/bin/$name.prev || true"
scp -o ConnectTimeout=15 "$bin" "$SSH_HOST:$base/bin/$name"
ssh_run "chmod +x $base/bin/$name"
if [ -n "$appdir" ]; then
say "uploading app dir $appdir"
( cd "$appdir" && tar czf - . ) | ssh_run "mkdir -p $base/app && tar xzf - -C $base/app"
fi
say "installing systemd unit $name.service"
# Optional overrides (set by `akurai-ec2 release` from the app manifest) let
# apps that DON'T follow the Framework `serve --dir frontend` convention keep
# their own ExecStart / EnvironmentFile / data dirs. All unset → the default
# Framework unit below, so existing callers are unchanged.
# AKURAI_EXEC_START custom ExecStart; tokens {bin}→$base/bin/$name,
# {port}→$port, {app}→$base/app are expanded
# AKURAI_ENV_FILE systemd EnvironmentFile= (must already exist on the box)
# AKURAI_DATA_DIR space-separated dirs to create + chown ubuntu before start
# AKURAI_USER systemd User= (default ubuntu) — for apps with a dedicated
# service account; data dirs are chowned to it
di_user="${AKURAI_USER:-ubuntu}"
di_exec="${AKURAI_EXEC_START:-$base/bin/$name serve --dir $base/app/frontend --host 127.0.0.1 --port $port}"
di_exec="${di_exec//\{bin\}/$base/bin/$name}"; di_exec="${di_exec//\{port\}/$port}"; di_exec="${di_exec//\{app\}/$base/app}"
di_envline=""; if [ -n "${AKURAI_ENV_FILE:-}" ]; then di_envline="EnvironmentFile=${AKURAI_ENV_FILE}"; fi
if [ -n "${AKURAI_DATA_DIR:-}" ]; then
while IFS= read -r d || [ -n "$d" ]; do
[ -n "$d" ] || continue
ssh_run "sudo mkdir -p '$d' && sudo chown $di_user:$di_user '$d'" </dev/null
done <<< "$AKURAI_DATA_DIR"
fi
ssh_run "sudo tee /etc/systemd/system/$name.service >/dev/null" <<EOF
[Unit]
Description=$name (AkurAI single-binary app)
After=network.target
[Service]
Type=simple
User=$di_user
WorkingDirectory=$base/app
${di_envline}
ExecStart=$di_exec
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
EOF
ssh_run "sudo systemctl daemon-reload && sudo systemctl enable --now $name.service && sleep 1 && systemctl is-active $name.service"
# Health-gate: confirm the app actually answers HTTP on its port — 'active'
# alone can't tell a serving app from one that crashes on first request. Any
# HTTP status means it's serving; 000 = connection refused / crashed.
health_ok=0; code=000
for _ in 1 2 3 4 5 6; do
code=$(ssh_run "curl -s -o /dev/null -w '%{http_code}' --max-time 4 http://127.0.0.1:$port/ 2>/dev/null || echo 000")
[ "$code" != "000" ] && { health_ok=1; break; }
sleep 2
done
if [ "$health_ok" = 1 ]; then
say "health: $name answering on :$port (HTTP $code)"
else
say "$name failed its health check on :$port — auto-rolling back"
if ssh_run "[ -f $base/bin/$name.prev ]"; then
ssh_run "sudo systemctl stop $name.service; cp -p $base/bin/$name.prev $base/bin/$name; sudo systemctl start $name.service; sleep 2; systemctl is-active $name.service"
die "rolled back $name to the previous binary (the new build was unhealthy on :$port)"
else
die "$name is unhealthy on :$port and there is no .prev backup to roll back to — investigate"
fi
fi
say "deployed: $name on 127.0.0.1:$port"
;;
nginx-proxy)
[ $# -eq 2 ] || die "usage: akurai-ec2 nginx-proxy <domain> <port>"
domain="$1"; port="$2"
say "nginx reverse proxy: $domain → 127.0.0.1:$port"
ssh_run "sudo tee /etc/nginx/sites-available/$domain >/dev/null" <<EOF
# Map the client's Upgrade header to the Connection value nginx must forward for
# a WebSocket handshake: "upgrade" when upgrading, "close" otherwise.
map \$http_upgrade \$connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name $domain;
location / {
proxy_pass http://127.0.0.1:$port;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# WebSocket upgrade + long-lived realtime streams (WS / SSE).
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off; # stream SSE/WS frames immediately
}
}
EOF
ssh_run "sudo ln -sf /etc/nginx/sites-available/$domain /etc/nginx/sites-enabled/$domain && sudo nginx -t && sudo systemctl reload nginx"
say "nginx vhost live for $domain (HTTP). Public IP: $PUBLIC_IP"
# TLS idempotency: the HTTP vhost written above clobbers any certbot-managed
# 443 block, which is why every redeploy used to silently drop HTTPS. If a
# cert already exists for this domain, re-assert HTTPS so it never regresses.
if ssh_run "sudo test -f /etc/letsencrypt/live/$domain/fullchain.pem"; then
say "existing cert found — re-asserting HTTPS for $domain"
ssh_run "sudo certbot --nginx -d $domain --non-interactive --agree-tos -m olibuijr@olibuijr.com --redirect >/dev/null 2>&1 && sudo nginx -t && sudo systemctl reload nginx"
say "HTTPS re-asserted for $domain"
fi
;;
tls)
[ $# -eq 1 ] || die "usage: akurai-ec2 tls <domain>"
domain="$1"
say "requesting Let's Encrypt cert for $domain"
ssh_run "sudo certbot --nginx -d $domain --non-interactive --agree-tos -m olibuijr@olibuijr.com --redirect"
;;
release)
# release [patch|minor|major|publish] [flags] — unified release engine.
#
# Reads ./akurai-deploy.toml (or -C <dir>) and runs the whole release ONCE,
# here, instead of in each project's bespoke deploy.sh:
# gate (fmt/clippy/test) → bump VERSION + Cargo.toml (lockstep, keyed on the
# old value) → changelog (hybrid: git-draft since last tag + manual
# [Unreleased] + curate) → git commit/tag/push → deploy-binary + nginx-proxy
# + tls. `publish` (alias `ec2`) ships the current binary with NO bump.
# --dry-run previews bump+changelog and writes/ships/pushes NOTHING.
# --changelog "text" uses one-line text; --yes accepts the draft unattended.
# Manifest schema:
# v1 flat TOML stays supported as-is (name=, domain=, port=, build=, artifact=, ...)
# v2 nested TOML can use dotted groups such as app.name, build.command,
# deploy.port, release.gates.fmt, and service.exec_start.
SELF="$(command -v akurai-ec2 2>/dev/null || true)"; [ -n "$SELF" ] || SELF="$0"
level="patch"; act_explicit=0; dry=0; cl_text=""; have_cl_flag=0; assume_yes=0; workdir="."
while [ $# -gt 0 ]; do
case "$1" in
patch|minor|major) level="$1"; act_explicit=1 ;;
publish|ec2) level="publish"; act_explicit=1 ;;
--dry-run) dry=1 ;;
--yes|-y) assume_yes=1 ;;
--changelog) shift; cl_text="${1:-}"; have_cl_flag=1 ;;
-C) shift; workdir="${1:-.}" ;;
*) die "release: bad arg '$1' (usage: release [patch|minor|major|publish] [--dry-run] [--changelog TEXT] [--yes] [-C DIR])" ;;
esac
shift || true
done
cd "$workdir" || die "cannot cd to $workdir"
[ -f akurai-deploy.toml ] || die "no akurai-deploy.toml in $(pwd) — every app needs one (schema in this command's header / the _AWSEC2 'release' workflow)"
release_load_manifest "akurai-deploy.toml"
name="$(mf_require "name (systemd unit)" name app.name)"
domain="$(mf_get domain app.domain || true)"; port="$(mf_get port deploy.port || true)"
buildcmd="$(mf_get build build.command || true)"; artifact="$(mf_get artifact build.artifact || true)"; appdir="$(mf_get appdir build.appdir || true)"
artifact="${artifact/#\~/$HOME}" # allow ~ in artifact path (e.g. shared Framework binary)
vfile="$(mf_get version_file release.version_file || true)"; vfile="${vfile:-VERSION}"
cargover="$(mf_get cargo_version release.cargo_version || true)"; cargover="${cargover:-workspace}"
clfile="$(mf_get changelog release.changelog || true)"; clfile="${clfile:-CHANGELOG.md}"
clmode="$(mf_get changelog_mode release.changelog_mode || true)"; clmode="${clmode:-hybrid}"
g_fmt="$(mf_get_bool true gate_fmt release.gates.fmt)"
g_clippy="$(mf_get_bool true gate_clippy release.gates.clippy)"
g_test="$(mf_get_bool true gate_test release.gates.test)"
dbsnap="$(mf_get db_snapshot deploy.db_snapshot || true)"
dbkeep="$(mf_get db_keep deploy.db_keep || true)"; dbkeep="${dbkeep:-7}"
repo="$(mf_get repo app.repo || true)"
# custom-unit passthrough (non-Framework-convention apps) + default action
execstart="$(mf_get exec_start service.exec_start || true)"
envfile="$(mf_get env_file service.env_file || true)"
appuser="$(mf_get user service.user || true)"
udatadir_raw="$(mf_get data_dir service.data_dir || true)"
udatadir="$(mf_get_string_array_lines "service.data_dir" data_dir service.data_dir || true)"
mng_nginx="$(mf_get_bool true manage_nginx deploy.manage_nginx)" # false → leave the app's existing nginx vhost untouched on redeploy
defaction="$(mf_get default_action release.default_action || true)"
if [ "$act_explicit" = 0 ] && [ -n "$defaction" ]; then case "$defaction" in ec2) level="publish" ;; *) level="$defaction" ;; esac; fi
if [ "$dry" = 1 ]; then say "release: $name @ $(pwd) [DRY-RUN]"; else say "release: $name @ $(pwd)"; fi
musl_build() {
if [ -z "$buildcmd" ]; then
say "build: (none — using prebuilt artifact ${artifact:-<unset>})"
else
say "build: $buildcmd"
if [ "$dry" = 0 ]; then rustup target add x86_64-unknown-linux-musl >/dev/null 2>&1 || true; eval "$buildcmd" || die "build failed"; fi
fi
if [ "$dry" = 1 ]; then say " [dry-run] skipping build + artifact check"; return 0; fi
if [ -z "$artifact" ] || [ ! -f "$artifact" ]; then die "build artifact not found: ${artifact:-<unset>}"; fi
}
do_publish() {
musl_build
[ -n "$port" ] || die "manifest: 'port' required to publish"
if [ "$dry" = 1 ]; then
say " [dry-run] would: deploy-binary $name $artifact $port ${appdir:+$appdir}"
if [ -n "$execstart" ]; then say " [dry-run] custom unit: ExecStart='$execstart' User='${appuser:-ubuntu}'${envfile:+ EnvironmentFile='$envfile'}${udatadir_raw:+ data='$udatadir_raw'}"; fi
if [ -n "$domain" ] && [ "$domain" != "-" ] && [ "$mng_nginx" = true ]; then say " [dry-run] would: nginx-proxy $domain $port; tls $domain"; elif [ -n "$domain" ] && [ "$domain" != "-" ]; then say " [dry-run] nginx: leaving existing $domain vhost untouched (manage_nginx=false)"; fi
return 0
fi
if [ -n "$dbsnap" ]; then
say "db-snapshot: $dbsnap (keep $dbkeep)"
"$SELF" ssh "ts=\$(date +%Y%m%d-%H%M%S); sudo systemctl stop $name 2>/dev/null||true; sudo mkdir -p /var/backups/$name; [ -d '$dbsnap' ] && sudo cp -a '$dbsnap' /var/backups/$name/data-\$ts || true; sudo systemctl start $name 2>/dev/null||true; ls -1dt /var/backups/$name/data-* 2>/dev/null | tail -n +$((dbkeep+1)) | xargs -r sudo rm -rf" || say " snapshot warning (continuing)"
fi
AKURAI_EXEC_START="$execstart" AKURAI_ENV_FILE="$envfile" AKURAI_USER="$appuser" AKURAI_DATA_DIR="$udatadir" "$SELF" deploy-binary "$name" "$artifact" "$port" ${appdir:+"$appdir"}
if [ -n "$domain" ] && [ "$domain" != "-" ] && [ "$mng_nginx" = true ]; then
"$SELF" nginx-proxy "$domain" "$port"
"$SELF" tls "$domain" || say " TLS deferred — run 'akurai-ec2 tls $domain' once DNS resolves"
fi
if [ -n "$domain" ] && [ "$domain" != "-" ]; then say "published → https://$domain"; else say "published (internal: 127.0.0.1:$port)"; fi
}
if [ "$level" = "publish" ]; then do_publish; say "publish: $name ✓"; exit 0; fi
if [ "$g_fmt" = true ]; then say "gate: rustfmt"; if [ "$dry" = 0 ]; then cargo fmt --all -- --check || die "fmt failed (run: cargo fmt --all)"; fi; fi
if [ "$g_clippy" = true ]; then say "gate: clippy -D warnings"; if [ "$dry" = 0 ]; then cargo clippy --all-targets -- -D warnings || die "clippy failed"; fi; fi
if [ "$g_test" = true ]; then say "gate: tests"; if [ "$dry" = 0 ]; then cargo test --workspace || die "tests failed"; fi; fi
[ -f "$vfile" ] || die "no $vfile (manifest version_file)"
cur="$(tr -d '[:space:]' < "$vfile")"
IFS='.' read -r MA MI PA <<< "$cur"
case "$level" in major) MA=$((MA+1)); MI=0; PA=0 ;; minor) MI=$((MI+1)); PA=0 ;; patch) PA=$((PA+1)) ;; esac
new="$MA.$MI.$PA"
say "version: $cur → $new ($level)"
if [ "$dry" = 0 ]; then
printf '%s\n' "$new" > "$vfile"
if [ "$cargover" != none ] && [ -f Cargo.toml ]; then sed -i -E "s/^version = \"$cur\"/version = \"$new\"/" Cargo.toml; fi
fi
draft=".akurai-changelog.draft"
rm -f "$draft" 2>/dev/null || true
trap 'rm -f "$draft" 2>/dev/null || true' EXIT
unreleased_body() { [ -f "$clfile" ] || return 0; awk '/^## \[Unreleased\]$/{f=1;next} /^## \[/{f=0} f' "$clfile" | sed '/^[[:space:]]*$/d'; }
git_draft() {
local range="HEAD" lt feats fixes chgd any=0
lt="$(git describe --tags --abbrev=0 2>/dev/null || true)"; if [ -n "$lt" ]; then range="$lt..HEAD"; fi
feats="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^feat(\([^)]*\))?!?: /- /p' || true)"
fixes="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^fix(\([^)]*\))?!?: /- /p' || true)"
chgd="$(git log --no-merges --pretty=%s "$range" 2>/dev/null | sed -nE 's/^(refactor|perf|docs|chore|build|ci|style|test)(\([^)]*\))?!?: /- /p' || true)"
if [ -n "$feats" ]; then printf '### Added\n%s\n\n' "$feats"; any=1; fi
if [ -n "$fixes" ]; then printf '### Fixed\n%s\n\n' "$fixes"; any=1; fi
if [ -n "$chgd" ]; then printf '### Changed\n%s\n\n' "$chgd"; any=1; fi
if [ "$any" = 0 ]; then printf -- '- maintenance release\n'; fi
}
if [ "$have_cl_flag" = 1 ]; then
printf -- '- %s\n' "$cl_text" > "$draft"
elif [ "$clmode" = manual ]; then
printf -- '- \n' > "$draft"; if [ -t 0 ] && [ "$dry" = 0 ]; then "${EDITOR:-vi}" "$draft" || true; fi
else
{ mu="$(unreleased_body)"; if [ -n "$mu" ]; then printf '%s\n\n' "$mu"; fi; git_draft; } > "$draft"
fi
if [ "$clmode" = hybrid ] && [ "$have_cl_flag" = 0 ] && [ "$assume_yes" = 0 ] && [ "$dry" = 0 ] && [ -t 0 ]; then
say "draft changelog for [$new] (commits since $(git describe --tags --abbrev=0 2>/dev/null || echo start)):"
sed 's/^/ /' "$draft"
printf ' [e]dit / [a]ccept / [s]kip ? '; read -r ans || ans=a
case "${ans:-a}" in e|E) "${EDITOR:-vi}" "$draft" || true ;; s|S) : > "$draft" ;; esac
fi
if [ "$dry" = 1 ]; then
say "[dry-run] changelog [$new] would be:"; sed 's/^/ /' "$draft"
else
hdr="## [$new] - $(date +%Y-%m-%d)"
if [ ! -f "$clfile" ]; then
{ printf '# Changelog\n\nAll notable changes to this project are documented here. Format: Keep a Changelog; SemVer.\n\n## [Unreleased]\n\n%s\n\n' "$hdr"; cat "$draft"; printf '\n'; } > "$clfile"
elif grep -q '^## \[Unreleased\]$' "$clfile"; then
awk -v hdr="$hdr" -v bf="$draft" '/^## \[Unreleased\]$/{print "## [Unreleased]"; print ""; print hdr; print ""; while ((getline l < bf) > 0) print l; print ""; next} {print}' "$clfile" > "$clfile.x" && mv "$clfile.x" "$clfile"
else
{ printf '%s\n\n' "$hdr"; cat "$draft"; printf '\n'; cat "$clfile"; } > "$clfile.x" && mv "$clfile.x" "$clfile"
fi
say "changelog: cut [$new] into $clfile"
fi
rm -f "$draft" 2>/dev/null || true
if [ "$dry" = 0 ]; then
say "git: commit + tag v$new"
git add -A
git commit -q -m "release: v$new" || die "git commit failed (nothing to commit?)"
git tag -a "v$new" -m "v$new" || die "git tag v$new failed"
if ! git remote get-url origin >/dev/null 2>&1 && [ -n "$repo" ]; then
if gh repo view "$repo" >/dev/null 2>&1; then git remote add origin "https://github.com/$repo.git"; else say "creating GitHub repo $repo (private)"; gh repo create "$repo" --private --source=. --remote=origin || true; fi
fi
if git remote get-url origin >/dev/null 2>&1; then say "git: push"; { git push -u origin HEAD && git push origin "v$new"; } || say " push warning (continuing)"; fi
fi
do_publish
if [ "$dry" = 1 ]; then say "release (dry-run): $name → v$new — nothing written, pushed, or deployed ✓"; else say "release: $name v$new ✓"; fi
;;
rollback)
# rollback <name> — restore the previous binary saved by deploy-binary.
[ $# -eq 1 ] || die "usage: akurai-ec2 rollback <name>"
name="$1"; base="/opt/$name"
ssh_run "[ -f $base/bin/$name.prev ]" || die "no $base/bin/$name.prev backup found for $name"
say "rolling back $name to its previous binary"
ssh_run "sudo systemctl stop $name.service; cp -p $base/bin/$name.prev $base/bin/$name; sudo systemctl start $name.service; sleep 2; systemctl is-active $name.service"
say "rolled back: $name"
;;
logs)
[ $# -eq 1 ] || die "usage: akurai-ec2 logs <service>"
ssh_run "journalctl -u $1 -n 80 --no-pager"
;;
mail-autoconfig)
# Email client auto-setup lives in nginx vhost 'mail-autoconfig' + snippets
# mail-autoconfig-<domain>.conf / mail-autodiscover.conf, payloads under
# /var/www/autoconfig/<domain>/, certs autoconfig.<domain> (+autodiscover SAN).
# Advertises mail.olibuijr.com IMAP 993 SSL / SMTP 587 STARTTLS for every domain.
action="${1:-verify}"; shift || true
case "$action" in
verify)
domains="olibuijr.com golfsetridak.is"
[ $# -gt 0 ] && domains="$*"
rc=0
for d in $domains; do
say "autoconfig: $d"
code=$(curl -s -o /dev/null -w '%{http_code}' "https://autoconfig.$d/mail/config-v1.1.xml" || echo 000)
printf ' mozilla autoconfig.%s/mail/config-v1.1.xml -> HTTP %s\n' "$d" "$code"; [ "$code" = 200 ] || rc=1
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "https://autodiscover.$d/autodiscover/autodiscover.xml" || echo 000)
printf ' ms autodiscover.%s (POST) -> HTTP %s\n' "$d" "$code"; [ "$code" = 200 ] || rc=1
for h in "autoconfig.$d" "autodiscover.$d"; do
if echo | timeout 10 openssl s_client -servername "$h" -connect "$h:443" 2>/dev/null | openssl x509 -noout -checkhost "$h" >/dev/null 2>&1; then
printf ' tls %s -> cert matches\n' "$h"
else printf ' tls %s -> MISMATCH/UNREACHABLE\n' "$h"; rc=1; fi
done
for s in _imaps._tcp _submission._tcp _autodiscover._tcp; do
ans=$(dig +short SRV "$s.$d" 2>/dev/null | tr '\n' ' ')
printf ' srv %s.%s -> %s\n' "$s" "$d" "${ans:-<none yet>}"
done
done
[ $rc -eq 0 ] && say "autoconfig OK (SRV may lag on 1984.is rebuild)" || die "autoconfig has failures (see above)"
;;
*) die "usage: akurai-ec2 mail-autoconfig verify [domain...]" ;;
esac
;;
allow-ssh)
# Re-authorize SSH from an IP (default: current public IP). The box's SG locks
# port 22 to specific /32s, so a dynamic-IP change times out SSH — this re-adds you.
ip="${1:-}"
[ -z "$ip" ] && ip=$(curl -s --max-time 8 https://api.ipify.org || true)
[ -n "$ip" ] || die "could not determine your IP — pass it: akurai-ec2 allow-ssh <ip>"
region="${AWS_DEFAULT_REGION:-us-east-1}"
sg=$(AWS_DEFAULT_REGION="$region" aws ec2 describe-instances \
--filters "Name=ip-address,Values=$PUBLIC_IP" \
--query "Reservations[].Instances[].SecurityGroups[].GroupId" --output text 2>/dev/null || true)
[ -n "$sg" ] || die "could not find the security group for $PUBLIC_IP (aws creds / region?)"
say "allowing SSH from $ip/32 on $sg"
if out=$(AWS_DEFAULT_REGION="$region" aws ec2 authorize-security-group-ingress \
--group-id "$sg" \
--ip-permissions "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=$ip/32,Description=\"akurai-ec2 allow-ssh\"}]" 2>&1); then
say "SSH allowed from $ip"
elif printf '%s' "$out" | grep -q Duplicate; then
say "already allowed ($ip)"
else
die "$out"
fi
;;
ports)
[ -f "$PORTS_REGISTRY" ] || die "registry not found: $PORTS_REGISTRY (see the _VMPORTS skill)"
sub="${1:-list}"; shift || true
# ---- fetch live state once (port->unit via cgroup, nginx vhost->port) ----
live="$(ssh_run 'bash -s' <<'RFETCH'
echo "### PORTS"
sudo ss -ltnpH 2>/dev/null | while read -r line; do
laddr=$(echo "$line" | awk '{print $4}'); port=${laddr##*:}
case "$port" in (*[!0-9]*|"") continue;; esac
pid=$(echo "$line" | grep -oE 'pid=[0-9]+' | head -1 | grep -oE '[0-9]+'); unit=""
[ -n "$pid" ] && unit=$(grep -aoE '[A-Za-z0-9_.@-]+\.service' /proc/$pid/cgroup 2>/dev/null | head -1)
printf '%s|%s\n' "$port" "${unit%.service}"
done | sort -t'|' -k1,1n -u
echo "### VHOSTS"
for f in /etc/nginx/sites-enabled/*; do
n=$(basename "$f")
# Resolve the ROOT "location / " upstream (trailing space avoids matching /mcp etc).
# Handle both direct (proxy_pass http://127.0.0.1:PORT) and named upstreams
# (proxy_pass http://name; upstream name { server 127.0.0.1:PORT; }).
tgt=$(grep -A4 'location / ' "$f" 2>/dev/null | grep -oE 'proxy_pass http://[^;]+' | head -1 | sed 's#proxy_pass http://##')
case "$tgt" in
127.0.0.1:*) p=${tgt##*:} ;;
"") p=static ;;
*) p=$(grep -E "upstream[[:space:]]+${tgt}[[:space:]]*\{" -A3 "$f" 2>/dev/null | grep -oE 'server[[:space:]]+127\.0\.0\.1:[0-9]+' | grep -oE '[0-9]+$' | head -1)
[ -z "$p" ] && p=static ;;
esac
printf '%s|%s\n' "$n" "${p:-static}"
done
RFETCH
)"
# ---- parse: live_unit[port]=unit ; vhost_port[domain]=port ----------------
declare -A live_unit vhost_port
section=""
while IFS= read -r ln; do
case "$ln" in
"### PORTS") section=p; continue;;
"### VHOSTS") section=v; continue;;
"") continue;;
esac
k="${ln%%|*}"; v="${ln#*|}"
[ "$section" = p ] && live_unit["$k"]="$v"
[ "$section" = v ] && vhost_port["$k"]="$v"
done <<< "$live"
# ---- parse registry: reg_unit[port], reg_domain[port], reg_pool[port] -----
declare -A reg_unit reg_domain reg_pool reg_note
reg_ports=()
trim() { local s="$1"; s="${s#"${s%%[![:space:]]*}"}"; s="${s%"${s##*[![:space:]]}"}"; printf '%s' "$s"; }
while IFS='|' read -r c_port c_unit c_dom c_repo c_pool c_note; do
[ -z "${c_port// }" ] && continue
case "$(trim "$c_port")" in (\#*) continue;; esac
p=$(trim "$c_port"); case "$p" in (*[!0-9]*|"") continue;; esac
reg_ports+=("$p")
reg_unit["$p"]="$(trim "$c_unit")"
reg_domain["$p"]="$(trim "$c_dom")"
reg_pool["$p"]="$(trim "$c_pool")"
reg_note["$p"]="$(trim "$c_note")"
done < "$PORTS_REGISTRY"
is_taken() { # taken if claimed in registry OR bound live
local p="$1"; [ -n "${reg_unit[$p]:-}" ] && return 0; [ -n "${live_unit[$p]:-}" ] && return 0; return 1
}
case "$sub" in
list)
printf '\033[1m%-6s %-20s %-7s %-30s %s\033[0m\n' PORT UNIT POOL DOMAIN LIVE
for p in $(printf '%s\n' "${reg_ports[@]}" | sort -n); do
want="${reg_unit[$p]}"; got="${live_unit[$p]:-}"
# system services carry a descriptive label, not an exact unit name —
# any live binding counts as bound for them.
if [ "$want" = "$got" ] || { [ "${reg_pool[$p]}" = system ] && [ -n "$got" ]; }; then
mark=$'\033[0;32m●\033[0m bound'
elif [ -n "$got" ]; then mark=$'\033[0;31m✗\033[0m '"$got"
else mark=$'\033[0;33m○\033[0m not bound'; fi
printf '%-6s %-20s %-7s %-30s %b\n' "$p" "$want" "${reg_pool[$p]}" "${reg_domain[$p]}" "$mark"
done
# live app-pool ports with no registry row = unregistered
unreg=""
for p in "${!live_unit[@]}"; do
[ "$p" -ge "$APP_POOL_LO" ] && [ "$p" -le "$APP_POOL_HI" ] || continue
[ -n "${reg_unit[$p]:-}" ] || unreg+=$'\n'" $p -> ${live_unit[$p]}"
done
[ -n "$unreg" ] && { printf '\n\033[1;31munregistered live ports (app pool):\033[0m'; printf '%b\n' "$unreg"; }
;;
free)
n="${1:-1}"; found=0
for ((p=APP_POOL_LO; p<=APP_POOL_HI; p++)); do
is_taken "$p" || { echo "$p"; found=$((found+1)); [ "$found" -ge "$n" ] && break; }
done
[ "$found" -gt 0 ] || die "no free ports in $APP_POOL_LO-$APP_POOL_HI"
;;
check)
[ $# -ge 1 ] || die "usage: akurai-ec2 ports check <port>"
p="$1"
say "port $p"
if [ -n "${reg_unit[$p]:-}" ]; then echo " registry: ${reg_unit[$p]} (${reg_pool[$p]}) ${reg_domain[$p]} — ${reg_note[$p]}"
else echo " registry: (unclaimed)"; fi
if [ -n "${live_unit[$p]:-}" ]; then echo " live: bound by ${live_unit[$p]}"
else echo " live: not bound"; fi
is_taken "$p" && printf '\033[1;31m => TAKEN\033[0m\n' || printf '\033[1;32m => FREE\033[0m\n'
;;
drift)
issues=0
# (a) registry port expected unit U, but live differs / missing
for p in $(printf '%s\n' "${reg_ports[@]}" | sort -n); do
[ "${reg_pool[$p]}" = system ] && continue
want="${reg_unit[$p]}"; got="${live_unit[$p]:-}"
[ -z "$want" ] && continue
if [ -z "$got" ]; then
printf '\033[0;33m○ %-18s NOT BOUND on its port %s\033[0m (%s)\n' "$want" "$p" "${reg_note[$p]}"; issues=$((issues+1))
elif [ "$got" != "$want" ]; then
printf '\033[0;31m✗ port %s: expected %s, live is %s\033[0m\n' "$p" "$want" "$got"; issues=$((issues+1))
fi
done
# (b) nginx vhost points at a port other than what the registry intends.
# A port may carry comma-separated domain aliases; a "host/path" entry is
# path-routed (e.g. notes /mcp) and is not its own vhost — skip it.
declare -A flagged_dom
for p in "${!reg_domain[@]}"; do
raw="${reg_domain[$p]}"; [ "$raw" = "-" ] && continue
IFS=',' read -ra _doms <<< "$raw"
for d in "${_doms[@]}"; do
case "$d" in (*/*) continue;; esac
lv="${vhost_port[$d]:-}"; [ -z "$lv" ] && continue
[ "$lv" = static ] && { printf '\033[0;33m○ vhost %s is static — registry routes it to %s (%s)\033[0m\n' "$d" "$p" "${reg_unit[$p]}"; flagged_dom[$d]=1; issues=$((issues+1)); continue; }
[ "$lv" != "$p" ] && { printf '\033[0;31m✗ vhost %s -> %s, registry intends %s (%s)\033[0m\n' "$d" "$lv" "$p" "${reg_unit[$p]}"; flagged_dom[$d]=1; issues=$((issues+1)); }
done
done
# (c) live app-pool listeners with no registry row
for p in "${!live_unit[@]}"; do
[ "$p" -ge "$APP_POOL_LO" ] && [ "$p" -le "$APP_POOL_HI" ] || continue
[ -n "${reg_unit[$p]:-}" ] || { printf '\033[0;31m✗ port %s bound by %s — not in registry\033[0m\n' "$p" "${live_unit[$p]}"; issues=$((issues+1)); }
done
# (d) live vhost serving an app-pool port that belongs to a different app
# (the port's registered domain — or any of its comma aliases — isn't this one)
for d in "${!vhost_port[@]}"; do
[ -n "${flagged_dom[$d]:-}" ] && continue # already reported in (b)
lv="${vhost_port[$d]}"; case "$lv" in (*[!0-9]*|"") continue;; esac
[ "$lv" -ge "$APP_POOL_LO" ] && [ "$lv" -le "$APP_POOL_HI" ] || continue
raw="${reg_domain[$lv]:-}"; { [ -z "$raw" ] || [ "$raw" = "-" ]; } && continue
owner=0; IFS=',' read -ra _owners <<< "$raw"
for o in "${_owners[@]}"; do [ "$o" = "$d" ] && owner=1; done
if [ "$owner" = 0 ]; then
printf '\033[0;31m✗ vhost %s -> %s, but %s is %s (%s)\033[0m\n' "$d" "$lv" "$lv" "${reg_unit[$lv]}" "$raw"
issues=$((issues+1))
fi
done
if [ "$issues" -eq 0 ]; then say "no drift — registry matches the box"; else die "$issues drift issue(s) — see above"; fi
;;
*) die "usage: akurai-ec2 ports [list|free [n]|check <port>|drift]" ;;
esac
;;
processes|ps)
# List the running AkurAI fleet on the box: unit, state, PID, memory, uptime,
# and listening port(s). Optional [pattern] overrides the default fleet match
# (an egrep alternation). Goes beyond `status`: per-unit PID/mem/uptime + ports.
pat="${1:-akurai|golfset|idp|mail|router|drive|fulltrui|platform|notes|passvault|crm|vpn|monitor}"
say "AkurAI processes on $SSH_HOST (match: $pat)"
ssh_run "bash -s -- '$pat'" <<'EOF'
pat="$1"
# pid -> listening port(s), so each unit can show what it's actually serving.
declare -A pid_ports
while read -r line; do
laddr=$(echo "$line" | awk '{print $4}'); port=${laddr##*:}
case "$port" in (*[!0-9]*|"") continue;; esac
pid=$(echo "$line" | grep -oE 'pid=[0-9]+' | head -1 | grep -oE '[0-9]+')
[ -n "$pid" ] && pid_ports[$pid]+="$port "
done < <(sudo ss -ltnpH 2>/dev/null)
printf '%-24s %-9s %-7s %-8s %-19s %s\n' UNIT STATE PID MEM SINCE PORTS
n=0
for unit in $(systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null | awk '{print $1}' | grep -iE "$pat" | sort -u); do
pid=$(systemctl show "$unit" -p MainPID --value 2>/dev/null)
sub=$(systemctl show "$unit" -p SubState --value 2>/dev/null)
memraw=$(systemctl show "$unit" -p MemoryCurrent --value 2>/dev/null)
since=$(systemctl show "$unit" -p ActiveEnterTimestamp --value 2>/dev/null | awk '{print $2" "$3}')
mem="-"
case "$memraw" in (''|'[not set]'|18446744073709551615) ;; (*) mem=$(numfmt --to=iec "$memraw" 2>/dev/null || echo "$memraw");; esac
ports="${pid_ports[$pid]:-—}"
printf '%-24s %-9s %-7s %-8s %-19s %s\n' "${unit%.service}" "$sub" "$pid" "$mem" "$since" "$ports"
n=$((n+1))
done
echo
echo "total running: $n"
EOF
;;
help|*)
grep -E '^#( |$)' "$0" | sed 's/^# \{0,1\}//'
;;
esac