#!/usr/bin/env sh # AMerc Outpost installer -- one line, and the amerc page can watch it. # # curl -fsSL https://amerc.ai/install.sh | sh # # Same contract as the Windows .ps1: the install opens a small HTTP control # service on 127.0.0.1 BEFORE it has any credentials, so the amerc page the user # is already signed in to can hand it a one-time install key, watch every step, # and answer a failed one. With no page open the same install runs as a console # TUI. The service needs python3; without it the install still works, keyboard # only, and says so. set -eu AMERC_BASE=${AMERC_BASE_URL:-https://nuget.lessokaji.com} AMERC_TOKEN=${AMERC_INSTALL_KEY:-} AMERC_LABEL='' AMERC_INSTALLER_VERSION='1.1.52-reuse' AMERC_ORIGINS='https://nuget.lessokaji.com https://amerc.lessokaji.com https://us.amerc.ai https://amerc.ai https://uz.amerc.ai' AMERC_PORTS='51781 51782 51783' # The raw asset is runnable from a checkout: unsubstituted markers fall back. case "$AMERC_BASE" in '@@'*) AMERC_BASE='https://amerc.ai' ;; esac case "$AMERC_TOKEN" in '@@'*) AMERC_TOKEN='' ;; esac case "$AMERC_LABEL" in '@@'*) AMERC_LABEL='' ;; esac case "$AMERC_INSTALLER_VERSION" in '@@'*) AMERC_INSTALLER_VERSION='dev' ;; esac case "$AMERC_ORIGINS" in '@@'*) AMERC_ORIGINS='https://amerc.ai' ;; esac case "$AMERC_PORTS" in '@@'*) AMERC_PORTS='51781 51782 51783' ;; esac AMERC_BASE=$(printf '%s' "$AMERC_BASE" | sed 's#/*$##') [ -n "$AMERC_LABEL" ] || AMERC_LABEL="$(hostname 2>/dev/null || echo linux)-outpost" if [ -z "${HOME:-}" ]; then HOME=$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6) [ -n "$HOME" ] || { printf 'HOME is not set and could not be resolved.\n' >&2; exit 1; } export HOME fi INSTALL_ROOT=${AMERC_OUTPOST_HOME:-"$HOME/.local/share/amerc/outpost"} # Piped through `curl | sh`, stdin holds THIS SCRIPT -- a bare `read` would eat # the next script line and exit at the first prompt. Interactive answers must # come from the terminal itself. if [ -t 0 ]; then TTY_IN='' elif ( : /dev/null; then TTY_IN='/dev/tty' else TTY_IN='none'; fi read_tty() { if [ "$TTY_IN" = '/dev/tty' ]; then IFS= read -r "$1" /dev/null || true else stty "$@" 2>/dev/null || true; fi } WORK=$(mktemp -d "${TMPDIR:-/tmp}/amerc-installer.XXXXXX") STATE="$WORK/state.json" INBOX="$WORK/inbox" mkdir -p "$INBOX" SERVICE_PID='' SERVICE_PORT=0 INSTALLER_ID=$(od -An -N12 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || date +%s%N) cleanup() { [ -n "$SERVICE_PID" ] && kill "$SERVICE_PID" 2>/dev/null || true rm -rf "$WORK" } trap cleanup EXIT HUP INT TERM PHASE='starting' NEEDS='' DETAIL='' ERROR='' PROMPT_OPTIONS='' OUTPOST_ID='' DONE='false' STEPS_FILE="$WORK/steps.txt" : >"$STEPS_FILE" json_escape() { printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' -e 's/\t/ /g' | tr -d '\r\n'; } write_state() { steps=$(awk 'BEGIN{ORS=""} {gsub(/\\/,"\\\\");gsub(/"/,"\\\"");if(NR>1)printf ",";printf "{\"text\":\"%s\"}",$0}' "$STEPS_FILE") cat >"$WORK/state.tmp" </dev/null || echo linux)")","label":"$(json_escape "$AMERC_LABEL")", "base":"$(json_escape "$AMERC_BASE")","phase":"$PHASE","needs":"$NEEDS","detail":"$(json_escape "$DETAIL")", "error":"$(json_escape "$ERROR")","done":$DONE,"outpostId":"$OUTPOST_ID","port":$SERVICE_PORT, "hasCredential":$( [ -n "$AMERC_TOKEN" ] && echo true || echo false ), "prompt":$( [ -n "$PROMPT_OPTIONS" ] && printf '{"kind":"step_failed","text":"%s","options":[%s]}' "$(json_escape "$ERROR")" "$PROMPT_OPTIONS" || echo null), "steps":[$steps]} EOF mv -f "$WORK/state.tmp" "$STATE" } step() { printf '[amerc] %s\n' "$1" printf '%s\n' "$1" >>"$STEPS_FILE" DETAIL="$1" write_state if [ -n "$AMERC_TOKEN" ]; then curl -fsS -m 3 -X POST -H 'content-type: application/json' \ -d "{\"step\":\"$(json_escape "$1")\"}" \ "$AMERC_BASE/api/outpost/install-progress/$AMERC_TOKEN" >/dev/null 2>&1 || true fi } # --------------------------------------------------------------------------- # The control service. python3 only: writing an HTTP server that gets CORS and # Private Network Access right in POSIX sh is not a thing anyone should do, and # every machine that can run the Outpost has python3. # --------------------------------------------------------------------------- start_control_service() { command -v python3 >/dev/null 2>&1 || return 1 cat >"$WORK/control.py" <<'PYEOF' import json, os, sys from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer STATE = os.environ['AMERC_STATE'] INBOX = os.environ['AMERC_INBOX'] ORIGINS = set(o for o in os.environ.get('AMERC_ORIGINS', '').split() if o) PORTS = [int(p) for p in os.environ.get('AMERC_PORTS', '').split() if p.isdigit()] ROUTES = ['GET /api/installer/health', 'GET /api/installer/status', 'POST /api/installer/credential', 'POST /api/installer/answer', 'POST /api/installer/action'] ACTIONS = ('retry', 'skip', 'continue', 'cancel', 'shutdown') class Handler(BaseHTTPRequestHandler): protocol_version = 'HTTP/1.1' def log_message(self, *args): pass def _cors(self, origin): headers = [('Vary', 'Origin, Access-Control-Request-Private-Network'), ('Cache-Control', 'no-store'), ('X-Content-Type-Options', 'nosniff')] if origin and origin in ORIGINS: headers.append(('Access-Control-Allow-Origin', origin)) return headers def _send(self, status, body, origin, extra=()): payload = body.encode('utf-8') self.send_response(status) for key, value in list(self._cors(origin)) + list(extra): self.send_header(key, value) self.send_header('Content-Type', 'application/json; charset=utf-8') self.send_header('Content-Length', str(len(payload))) self.end_headers() self.wfile.write(payload) def _loopback(self): name = (self.headers.get('Host') or '').split(':')[0] return name in ('', '127.0.0.1', 'localhost', '[::1]') def do_OPTIONS(self): origin = self.headers.get('Origin') or '' if origin not in ORIGINS: self._send(403, '{"error":"origin_not_allowed"}', '') return extra = [('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'), ('Access-Control-Allow-Headers', 'content-type'), ('Access-Control-Max-Age', '600')] if self.headers.get('Access-Control-Request-Private-Network') == 'true': extra.append(('Access-Control-Allow-Private-Network', 'true')) self._send(204, '', origin, extra) def do_GET(self): origin = self.headers.get('Origin') or '' if not self._loopback(): self._send(421, '{"error":"bad_host"}', origin) return path = self.path.split('?')[0] if path in ('/api/installer/health', '/api/installer/status'): try: with open(STATE, 'r', encoding='utf-8') as handle: body = handle.read() except OSError: body = '{"ok":false,"error":"state_unavailable"}' self._send(200, body, origin) return self._send(404, json.dumps({'error': 'unknown_route', 'routes': ROUTES}), origin) def do_POST(self): origin = self.headers.get('Origin') or '' if not self._loopback(): self._send(421, '{"error":"bad_host"}', origin) return if 'application/json' not in (self.headers.get('Content-Type') or ''): self._send(415, '{"error":"json_required"}', origin) return if origin and origin not in ORIGINS: self._send(403, '{"error":"origin_not_allowed"}', origin) return try: length = int(self.headers.get('Content-Length') or 0) payload = json.loads(self.rfile.read(length).decode('utf-8') or '{}') except Exception: self._send(400, '{"error":"bad_json"}', origin) return path = self.path.split('?')[0] if path == '/api/installer/credential': key = str(payload.get('token_reg') or payload.get('token') or '') if len(key) != 48 or any(c not in '0123456789abcdef' for c in key): self._send(400, '{"error":"install_key_invalid","hint":"token_reg must be the 48-hex install key from build_outpost"}', origin) return base = str(payload.get('base') or '') with open(os.path.join(INBOX, 'credential.tmp'), 'w', encoding='utf-8') as handle: handle.write(json.dumps({'token': key, 'base': base})) os.replace(os.path.join(INBOX, 'credential.tmp'), os.path.join(INBOX, 'credential')) elif path in ('/api/installer/answer', '/api/installer/action'): value = str(payload.get('action') or payload.get('value') or '').strip().lower() if value not in ACTIONS: self._send(400, json.dumps({'error': 'unknown_action', 'actions': list(ACTIONS)}), origin) return with open(os.path.join(INBOX, 'answer.tmp'), 'w', encoding='utf-8') as handle: handle.write(value) os.replace(os.path.join(INBOX, 'answer.tmp'), os.path.join(INBOX, 'answer')) else: self._send(404, json.dumps({'error': 'unknown_route', 'routes': ROUTES}), origin) return try: with open(STATE, 'r', encoding='utf-8') as handle: body = handle.read() except OSError: body = '{"ok":true}' self._send(200, body, origin) for port in PORTS: try: server = ThreadingHTTPServer(('127.0.0.1', port), Handler) except OSError: continue sys.stdout.write(str(port) + '\n') sys.stdout.flush() server.serve_forever() break sys.exit(3) PYEOF AMERC_STATE="$STATE" AMERC_INBOX="$INBOX" AMERC_ORIGINS="$AMERC_ORIGINS" AMERC_PORTS="$AMERC_PORTS" \ python3 "$WORK/control.py" >"$WORK/port" 2>"$WORK/control.log" & SERVICE_PID=$! waited=0 while [ "$waited" -lt 40 ]; do SERVICE_PORT=$(head -n1 "$WORK/port" 2>/dev/null || echo 0) case "$SERVICE_PORT" in [0-9]*) [ "$SERVICE_PORT" -gt 0 ] && return 0 ;; esac SERVICE_PORT=0 sleep 0.1 waited=$((waited + 1)) done return 1 } take_answer() { if [ -f "$INBOX/answer" ]; then value=$(cat "$INBOX/answer" 2>/dev/null || echo '') rm -f "$INBOX/answer" printf '%s' "$value" fi } take_credential() { [ -f "$INBOX/credential" ] || return 1 body=$(cat "$INBOX/credential" 2>/dev/null || echo '') rm -f "$INBOX/credential" key=$(printf '%s' "$body" | sed -n 's/.*"token":[ ]*"\([a-f0-9]\{48\}\)".*/\1/p') pushed_base=$(printf '%s' "$body" | sed -n 's/.*"base":[ ]*"\(https\{0,1\}:\/\/[^"]*\)".*/\1/p') [ -n "$key" ] || return 1 AMERC_TOKEN="$key" [ -n "$pushed_base" ] && AMERC_BASE=$(printf '%s' "$pushed_base" | sed 's#/*$##') return 0 } banner() { printf '\n%s\n' '------------------------------------------------------------------' printf ' AMerc Outpost installer %s\n' "$AMERC_INSTALLER_VERSION" printf '%s\n' '------------------------------------------------------------------' printf ' site : %s\n' "$AMERC_BASE" printf ' install to : %s\n' "$INSTALL_ROOT" if [ "$SERVICE_PORT" -gt 0 ]; then printf ' control service: http://127.0.0.1:%s (the amerc page drives this install)\n' "$SERVICE_PORT" else printf ' control service: not started -- this install is keyboard-only\n' fi printf '%s\n\n' '------------------------------------------------------------------' } sign_in_here() { [ "$TTY_IN" != 'none' ] || { printf 'No terminal for prompts. Set AMERC_INSTALL_KEY and rerun.\n' >&2; return 1; } printf ' AMerc account username: ' >&2 read_tty amerc_user printf ' AMerc account password: ' >&2 stty_tty -echo read_tty amerc_password stty_tty echo printf '\n' >&2 cookie=$(mktemp "${TMPDIR:-/tmp}/amerc-cookie.XXXXXX") if ! curl -fsS -L -c "$cookie" -b "$cookie" -o /dev/null \ --data-urlencode "username=$amerc_user" --data-urlencode "password=$amerc_password" \ "$AMERC_BASE/login"; then rm -f "$cookie"; printf ' Sign-in failed.\n' >&2; return 1 fi build=$(curl -fsS -b "$cookie" --data-urlencode 'platform=linux' \ --data-urlencode "label=$AMERC_LABEL" --data-urlencode 'servesAgents=true' \ "$AMERC_BASE/api/outposts/build" || echo '') rm -f "$cookie" amerc_password='' key=$(printf '%s' "$build" | sed -n 's/.*\/api\/outpost\/download\/\([a-f0-9]\{48\}\).*/\1/p') [ -n "$key" ] || { printf ' AMerc did not return an install key.\n' >&2; return 1; } AMERC_TOKEN="$key" step 'Signed in and minted a one-time install key.' return 0 } wait_for_credential() { [ -z "$AMERC_TOKEN" ] || return 0 PHASE='awaiting_credential'; NEEDS='credential' step 'Waiting for the amerc page to configure this install.' printf ' Keep the amerc tab open -- it configures this install automatically.\n' if [ "$TTY_IN" != 'none' ]; then printf ' No browser? Press Ctrl+C, or wait: after 60s this asks for your account here.\n' fi waited=0 while [ "$waited" -lt 1200 ]; do if take_credential; then PHASE='configured'; NEEDS='' step 'The amerc page supplied a one-time install key.' return 0 fi if [ "$(take_answer)" = 'cancel' ]; then return 1; fi if [ "$waited" -eq 60 ] && [ "$TTY_IN" != 'none' ]; then printf '\n No page has configured this install yet -- signing in here instead.\n' if sign_in_here; then PHASE='configured'; NEEDS=''; return 0; fi printf ' Still waiting for the amerc page...\n' fi sleep 1 waited=$((waited + 1)) done return 1 } # A step that can fail, ask, and be answered -- by the page or by the keyboard. run_step() { # $1 = label, rest = command label=$1; shift while :; do step "$label" if "$@"; then ERROR=''; PROMPT_OPTIONS=''; write_state; return 0; fi ERROR="$label failed" PROMPT_OPTIONS='"retry","skip","cancel"' PHASE='blocked'; NEEDS='answer' write_state printf ' FAILED: %s\n' "$label" >&2 printf ' [the amerc page can answer this: retry / skip / cancel]\n' >&2 waited=0 answer='' while [ "$waited" -lt 600 ]; do answer=$(take_answer) [ -n "$answer" ] && break sleep 1 waited=$((waited + 1)) done PROMPT_OPTIONS=''; PHASE='running'; NEEDS=''; write_state case "$answer" in retry) printf ' retrying...\n' ;; skip) step "$label -- skipped on request."; return 0 ;; *) return 1 ;; esac done } fetch_package() { curl -fsS -L -o "$WORK/package.tar.gz" "$AMERC_BASE/api/outpost/payload/$AMERC_TOKEN" \ || curl -fsS -L -o "$WORK/package.tar.gz" "$AMERC_BASE/api/outpost/download/$AMERC_TOKEN" \ || return 1 # A proxy or an error page answering 200 is the failure mode that used to # surface three steps later as "tar: not in gzip format". Name it here. magic=$(od -An -N2 -tx1 "$WORK/package.tar.gz" 2>/dev/null | tr -d ' \n') [ "$magic" = '1f8b' ] || { printf ' the download is not an Outpost package (install key expired?)\n' >&2; return 1; } return 0 } unpack_package() { mkdir -p "$INSTALL_ROOT" "$INSTALL_ROOT/logs" tar -xzf "$WORK/package.tar.gz" -C "$INSTALL_ROOT" || return 1 chmod 700 "$INSTALL_ROOT/run-outpost.sh" "$INSTALL_ROOT/outpost-cli.sh" "$INSTALL_ROOT/outpost.mjs" 2>/dev/null || true mkdir -p "$HOME/.local/bin" ln -sf "$INSTALL_ROOT/outpost-cli.sh" "$HOME/.local/bin/amerc-outpost" ln -sf "$INSTALL_ROOT/outpost-cli.sh" "$HOME/.local/bin/amerc-outpost-cli" OUTPOST_ID=$(sed -n 's/.*"outpostId"[ ]*:[ ]*"\([A-Za-z0-9_-]*\)".*/\1/p' "$INSTALL_ROOT/outpost.config.json" 2>/dev/null | head -n1) return 0 } launch_outpost() { nohup "$INSTALL_ROOT/run-outpost.sh" >"$INSTALL_ROOT/logs/outpost.stdout.log" 2>"$INSTALL_ROOT/logs/outpost.stderr.log" "$INSTALL_ROOT/outpost.pid" return 0 } wait_for_outpost() { PHASE='verifying'; NEEDS=''; write_state step 'Waiting for the Outpost to answer on this machine.' waited=0 while [ "$waited" -lt 60 ]; do for port in 51771 51772 51773 51774 51775; do if curl -fsS -m 2 "http://127.0.0.1:$port/api/health" >/dev/null 2>&1; then step "Outpost is answering on 127.0.0.1:$port" return 0 fi done sleep 2 waited=$((waited + 2)) done step 'The Outpost did not answer on loopback yet; it may still be starting.' return 0 } if start_control_service; then :; else SERVICE_PORT=0; fi write_state banner [ "$SERVICE_PORT" -gt 0 ] && step "Control service listening on http://127.0.0.1:$SERVICE_PORT" if ! wait_for_credential; then PHASE='cancelled'; write_state printf '\n Install cancelled. Nothing was changed.\n' exit 1 fi PHASE='downloading_core'; write_state run_step 'Downloading the Outpost package (your credentials are baked in)' fetch_package PHASE='extracting'; write_state run_step 'Unpacking the Outpost' unpack_package PHASE='starting_outpost'; write_state run_step 'Starting the Outpost' launch_outpost wait_for_outpost PHASE='done'; NEEDS=''; DONE='true'; write_state step 'Done.' printf '\n%s\n' '------------------------------------------------------------------' printf ' AMerc Outpost is installed.\n' printf ' install dir : %s\n' "$INSTALL_ROOT" [ -n "$OUTPOST_ID" ] && printf ' outpost id : %s\n' "$OUTPOST_ID" printf ' cli : amerc-outpost\n' printf '%s\n' '------------------------------------------------------------------' printf ' Keep the amerc page open -- it takes over from here.\n\n' # Stay reachable briefly so the page can read the final state: an installer that # vanishes the instant it finishes looks exactly like one that crashed. if [ "$SERVICE_PORT" -gt 0 ]; then sleep 60; fi