#!/usr/bin/env bash # ConusAI Cloud installer — https://get.conusai.com # # POSIX guard: this is a bash script, but `curl … | sh` runs dash on # Debian/Ubuntu. Everything up to the `set` line below must be POSIX so we # can hand off to bash transparently instead of dying on `pipefail`. if [ -z "${BASH_VERSION:-}" ]; then if command -v bash >/dev/null 2>&1; then # Forward BOTH the arguments and the base URL. The previous form was # `curl … | bash` with neither: `sh install.sh --channel beta` re-fetched # the script and silently installed STABLE, because the re-exec dropped # --channel on the floor. Arguments now survive the hand-off, and the # script re-fetches from the same host it was served by. _conusai_base="${CONUSAI_BASE_URL:-https://get.conusai.com}" exec bash -c 'curl -fsSL "$1/install.sh" | bash -s -- "${@:2}"' _ "$_conusai_base" "$@" fi echo "error: this installer requires bash. Install it (e.g. apt-get install bash) and re-run." >&2 exit 1 fi set -euo pipefail platform=$(uname -ms) if [[ ${OS:-} = Windows_NT ]]; then echo "Windows is not yet supported. Please use WSL2 or download the binary manually." exit 1 fi # Reset Color_Off='' # Regular Colors Red='' Green='' Dim='' Yellow='' # Bold Bold_White='' Bold_Green='' if [[ -t 1 ]]; then # Reset Color_Off='\033[0m' # Regular Colors Red='\033[0;31m' Green='\033[0;32m' Dim='\033[0;2m' Yellow='\033[0;33m' # Bold Bold_Green='\033[1;32m' Bold_White='\033[1m' fi error() { echo -e "${Red}error${Color_Off}:" "$@" >&2 exit 1 } info() { echo -e "${Dim}$@ ${Color_Off}" } info_bold() { echo -e "${Bold_White}$@ ${Color_Off}" } success() { echo -e "${Green}$@ ${Color_Off}" } # ── Step progress + ETA ────────────────────────────────────────────────────── # # The installer has no compile step to count, so progress is per PHASE, and the # estimate is LEARNED the same way the build one is: each run records how long # every phase took, and the next run sums the phases still ahead to state a # remaining time. The first install on a machine shows elapsed time only, # rather than an invented estimate. INSTALL_STARTED=$SECONDS STEP_N=0 STEP_TOTAL=0 STEP_STARTED=$SECONDS STEP_CURRENT="" STEP_REMAINING=() STEP_STATS_FILE="${HOME:-/tmp}/.conusai/install-step-stats" hms() { local t=${1:-0} if [ "$t" -ge 3600 ]; then printf '%dh%02dm' $((t/3600)) $(((t%3600)/60)) elif [ "$t" -ge 60 ]; then printf '%dm%02ds' $((t/60)) $((t%60)) else printf '%ds' "$t"; fi } # Last recorded duration for a phase, or empty when never seen. step_prev_secs() { [ -f "$STEP_STATS_FILE" ] || return 0 awk -F'\t' -v k="$1" '$1 == k { v = $2 } END { if (v != "") print v }' \ "$STEP_STATS_FILE" 2>/dev/null | tr -cd '0-9' } # Declare the phases this run will execute, in order, so an ETA can be summed # from the phases still ahead. # # The names here MUST match the strings passed to `step` verbatim: they are the # keys durations are recorded under, so a mismatch silently disables the ETA # (every lookup misses) with no other visible symptom. steps_plan() { STEP_REMAINING=("$@") STEP_TOTAL=$# STEP_N=0 } step() { local name="$1" eta=0 known=1 d STEP_CURRENT="$name" STEP_N=$((STEP_N + 1)) if [ "${#STEP_REMAINING[@]}" -gt 0 ]; then STEP_REMAINING=("${STEP_REMAINING[@]:1}") fi for s in ${STEP_REMAINING[@]+"${STEP_REMAINING[@]}"}; do d=$(step_prev_secs "$s") if [ -z "$d" ]; then known=0; break; fi eta=$((eta + d)) done if [ "$known" = 1 ] && [ "$eta" -gt 0 ]; then echo -e "\n${Bold_White}[$STEP_N/$STEP_TOTAL]${Color_Off} $name ${Dim}($(hms $((SECONDS - INSTALL_STARTED))) elapsed, ~$(hms $eta) left)${Color_Off}" else echo -e "\n${Bold_White}[$STEP_N/$STEP_TOTAL]${Color_Off} $name ${Dim}($(hms $((SECONDS - INSTALL_STARTED))) elapsed)${Color_Off}" fi STEP_STARTED=$SECONDS } # Record the phase duration so the NEXT install can estimate. Best-effort: a # read-only or missing HOME must never fail an install over a stats file. step_done() { local d=$((SECONDS - STEP_STARTED)) echo -e " ${Dim}done in $(hms $d)${Color_Off}" [ -n "$STEP_CURRENT" ] || return 0 mkdir -p "$(dirname "$STEP_STATS_FILE")" 2>/dev/null || return 0 { grep -v -F "$STEP_CURRENT"$'\t' "$STEP_STATS_FILE" 2>/dev/null || true printf '%s\t%s\n' "$STEP_CURRENT" "$d" } > "$STEP_STATS_FILE.tmp" 2>/dev/null && mv "$STEP_STATS_FILE.tmp" "$STEP_STATS_FILE" 2>/dev/null || true } warning() { echo -e "${Yellow}warning${Color_Off}:" "$@" } # Verify a downloaded file against its published `.sha256` sibling asset # (ADR-020 WS-7 / supplychain-1, supplychain-8). We fail CLOSED: if the # checksum cannot be fetched or does not match, we refuse to install rather # than silently running an unverified binary. An explicit, loud opt-out # (CONUSAI_INSTALL_SKIP_CHECKSUM=1) exists for the rare case a release lacks # the asset, so users are never hard-blocked — but the default is verified. verify_checksum() { local file="$1" url_sha="$2" local sha_file="$file.sha256" expected actual if ! curl --fail --silent --location --output "$sha_file" "$url_sha" 2>/dev/null; then rm -f "$sha_file" if [[ "${CONUSAI_INSTALL_SKIP_CHECKSUM:-}" = "1" ]]; then warning "Checksum not found at \"$url_sha\"; skipping verification (CONUSAI_INSTALL_SKIP_CHECKSUM=1)." return 0 fi rm -f "$file" error "Could not download a checksum from \"$url_sha\" to verify the binary. Refusing to install an unverified binary. To override (NOT recommended), re-run with CONUSAI_INSTALL_SKIP_CHECKSUM=1." fi # Accept both '' and ' filename' formats: take the first 64-hex token. expected=$(grep -oE '[0-9a-fA-F]{64}' "$sha_file" | head -n 1) rm -f "$sha_file" if [[ -z "$expected" ]]; then rm -f "$file" error "Checksum file from \"$url_sha\" did not contain a valid SHA-256 digest." fi if command -v sha256sum >/dev/null 2>&1; then actual=$(sha256sum "$file" | awk '{print $1}') elif command -v shasum >/dev/null 2>&1; then actual=$(shasum -a 256 "$file" | awk '{print $1}') else rm -f "$file" error "Neither 'sha256sum' nor 'shasum' is available to verify the download." fi if [[ "$(printf '%s' "$expected" | tr 'A-F' 'a-f')" != "$(printf '%s' "$actual" | tr 'A-F' 'a-f')" ]]; then rm -f "$file" error "Checksum verification FAILED — the download may be corrupted or tampered with. expected: $expected actual: $actual Aborting installation." fi success "Checksum verified (sha256)." } # Embedded minisign public key — the trust root for release signature # verification (the dual-mode release-signing design). A same-origin attacker # can replace both the tarball and its .sha256, so the checksum proves # integrity but not authenticity; this signature closes that gap. The matching # secret key never exists in this repository or in CI -- it lives only on the # release engineer's machine (scripts/release signs there). The public half is # also committed at scripts/conusai-release.pub. Rotating the key means # rotating this constant and that file together, which is exactly what # scripts/rotate-release-key.sh does; `scripts/rotate-release-key.sh --check` # fails if they ever drift. readonly CONUSAI_MINISIGN_PUBKEY="RWSG6hqLUXS25TkJH1aHMXZ5iOM0YpQ5pQCxLThLDVuDepF/+B8QR5XO" # Verify the downloaded file against its published `.minisig` sibling asset. # Called AFTER verify_checksum(); the checksum path above is untouched. # Semantics (fixed): # - signature MISMATCH → always fatal, whatever the flags say # - signature ABSENT (or minisign not installed) # → fatal only under CONUSAI_INSTALL_REQUIRE_SIG=1, # otherwise warn and continue, so releases published # before signing keep installing verify_signature() { local file="$1" url_sig="$2" if ! command -v minisign >/dev/null 2>&1; then if [[ "${CONUSAI_INSTALL_REQUIRE_SIG:-}" = "1" ]]; then rm -f "$file" error "minisign required (CONUSAI_INSTALL_REQUIRE_SIG=1) but not installed." fi warning "minisign not found — signature not verified (checksum WAS verified)." return 0 fi if ! curl --fail --silent --location --output "$file.minisig" "$url_sig" 2>/dev/null; then rm -f "$file.minisig" if [[ "${CONUSAI_INSTALL_REQUIRE_SIG:-}" = "1" ]]; then rm -f "$file" error "No signature at \"$url_sig\" (CONUSAI_INSTALL_REQUIRE_SIG=1)." fi warning "No signature published for this release; checksum verification stands." return 0 fi if ! minisign -Vm "$file" -P "$CONUSAI_MINISIGN_PUBKEY" >/dev/null 2>&1; then rm -f "$file" "$file.minisig" error "SIGNATURE VERIFICATION FAILED — the download may be tampered with. Refusing to install." fi rm -f "$file.minisig" success "Signature verified (minisign)." } command -v curl >/dev/null || error 'curl is required to install conusai' # Channel selection. Mirrors `conusai upgrade --channel`: # stable (default) — track non-prerelease tags only # beta — track the newest tag, prerelease or not, EXCLUDING # nightly builds (a `-nightly.` tag never satisfies beta) # nightly — track only automated nightly builds (`-nightly.` tags), # cut once a day from `main` when it has new commits # # CLI-only by design: there is no env-var fallback. A user must pass # `--channel beta` or `--channel nightly` explicitly to opt into prereleases. # `bash install.sh` always lands on stable — same contract as `conusai upgrade`. channel="stable" public_ip="" release_host="" skip_docker_install=0 positional=() while [[ $# -gt 0 ]]; do case "$1" in --channel=*) channel="${1#--channel=}" shift ;; --channel) shift [[ $# -gt 0 ]] || error '--channel requires a value, e.g. --channel beta' channel="$1" shift ;; --public-ip=*) public_ip="${1#--public-ip=}" shift ;; --public-ip) shift [[ $# -gt 0 ]] || error '--public-ip requires a value, e.g. --public-ip 203.0.113.7' public_ip="$1" shift ;; --skip-docker-install) skip_docker_install=1 shift ;; --release-host=*) release_host="${1#--release-host=}" shift ;; --release-host) shift [[ $# -gt 0 ]] || error '--release-host requires a URL, e.g. --release-host https://build.conusai.com' release_host="$1" shift ;; *) positional+=("$1") shift ;; esac done case "$channel" in stable|beta|nightly) ;; *) error "Unknown channel '$channel'. Supported: stable, beta, nightly" ;; esac if [[ ${#positional[@]} -gt 1 ]]; then error 'Too many arguments. Usage: install.sh [--channel beta|stable] [--release-host URL] [version]' fi # Platform detection is EXPLICIT on both axes. The previous # `'Linux x86_64' | *)` catch-all silently handed an amd64 binary to every # unrecognised platform -- a 32-bit Raspberry Pi ('Linux armv7l'), riscv64, or # a BSD would download it and fail later with an opaque exec-format error, # far from the real cause. An unsupported platform must say so, here. case $platform in 'Darwin '*) # macOS releases are paused (self-hosted platform plan, D13). They resume # when a Mac runner with the label macos-arm64 is registered. error "no macOS build for this release. ConusAI macOS builds are paused; see the release notes." ;; 'Linux aarch64' | 'Linux arm64') target=linux-arm64 ;; 'Linux x86_64' | 'Linux amd64') target=linux-amd64 ;; *) error "Unsupported platform: $platform ConusAI ships binaries for: Linux x86_64 / aarch64 (arm64) 32-bit ARM (armv6l/armv7l), riscv64 and the BSDs are not built. If you believe this platform should work, build from source in the repository." ;; esac # Best-effort: make signature verification possible. Non-interactive only — # never prompt for a sudo password before the wizard section owns the TTY. # Best-effort minisign install, so signature verification can actually happen. # # `apt-get update` FIRST is not optional. A fresh Ubuntu cloud image ships with # empty package lists, so `apt-get install minisign` fails with "Unable to # locate package minisign" -- silently, behind the `|| true`. That was harmless # while a missing signature only warned; it is fatal on a host where signatures # are mandatory (CONUSAI_INSTALL_REQUIRE_SIG), which is every self-hosted # release host. Verified on a pristine ubuntu-24.04 cloud image: install fails # before the update, succeeds after. if ! command -v minisign >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then if [[ $(id -u) -eq 0 ]]; then apt-get update -qq >/dev/null 2>&1 || true apt-get install -y -qq minisign >/dev/null 2>&1 || true elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then sudo -n apt-get update -qq >/dev/null 2>&1 || true sudo -n apt-get install -y -qq minisign >/dev/null 2>&1 || true fi fi # If it is still missing and signatures are mandatory, say what to do rather # than failing with a bare "not installed" three lines later. if ! command -v minisign >/dev/null 2>&1 && [[ "${CONUSAI_INSTALL_REQUIRE_SIG:-}" = "1" ]]; then warning "minisign is required on this host but could not be installed automatically. Install it and re-run: apt-get update && apt-get install -y minisign (or: brew install minisign / dnf install minisign)" fi # Releases and the channel index come from one host: get.conusai.com, which # fronts build.conusai.com's release files (self-hosted platform plan, D19). # --release-host (or CONUSAI_BASE_URL) retargets that host: the bootstrap of a platform host installs from its # own build host before get.conusai.com is pointed there. The signature is required either way. conusai_base_url="${release_host:-${CONUSAI_BASE_URL:-https://get.conusai.com}}" # An unsigned artifact did not come from scripts/release. The host accepts # authenticated uploads, so "the bytes arrived" proves nothing about who put # them there; the signature is what does. Fail closed unless the caller has # explicitly said otherwise. if [[ -z "${CONUSAI_INSTALL_REQUIRE_SIG:-}" ]]; then CONUSAI_INSTALL_REQUIRE_SIG=1 fi exe_name=conusai if [[ ${#positional[@]} -eq 0 ]]; then info "Fetching latest release on channel: $channel" # Channel resolution: channels.json, a static index the release job # publishes last and atomically (scripts/release). It maps each channel to # its current tag, for example {"stable": "v0.1.0", "beta": "v0.1.0-beta.63", # "nightly": "v0.1.0-nightly.20261006"}. The channel's tag is read from the # key of that name; a missing key means the channel has no release. set +e conusai_tag=$(curl --fail --silent --location "$conusai_base_url/channels.json" 2>/dev/null | sed -n 's/.*"'"$channel"'" *: *"\([^"]*\)".*/\1/p' | tr -d '[:space:]') set -e if [[ -z "$conusai_tag" ]]; then echo "" error "No releases found on channel '$channel'. Try a specific version: curl -fsSL https://get.conusai.com/install.sh | bash -s -- v0.1.0 Or pick a different channel: curl -fsSL https://get.conusai.com/install.sh | bash -s -- --channel beta Available versions: $conusai_base_url/channels.json" fi info "Latest version on $channel: $conusai_tag" conusai_uri="$conusai_base_url/releases/$conusai_tag/conusai-$target.tar.gz" else # Explicit version pin — channel is irrelevant. conusai_uri="$conusai_base_url/releases/${positional[0]}/conusai-$target.tar.gz" fi install_env=CONUSAI_INSTALL bin_env=\$$install_env/bin install_dir=${!install_env:-$HOME/.conusai} bin_dir=$install_dir/bin exe=$bin_dir/conusai if [[ ! -d $bin_dir ]]; then mkdir -p "$bin_dir" || error "Failed to create install directory \"$bin_dir\"" fi info "Downloading conusai from $conusai_uri..." tarball="$install_dir/conusai-$target.tar.gz" curl --fail --location --progress-bar --output "$tarball" "$conusai_uri" || error "Failed to download conusai from \"$conusai_uri\"" info "Verifying download integrity..." verify_checksum "$tarball" "$conusai_uri.sha256" verify_signature "$tarball" "$conusai_uri.minisig" info "Extracting conusai..." tar -xzf "$tarball" -C "$bin_dir" || error "Failed to extract conusai" rm "$tarball" || warning "Failed to remove temporary tarball" chmod +x "$exe" || error 'Failed to set permissions on conusai executable' # Vendor bundle (showcase plan D3): the release ships conusai-vendor.tar.gz next # to the binary -- manifest.json, npm/*.tgz and templates/*.tar.gz. The default # Git & Packages service seeds the packages and templates on init and start, so # `npm install @conusai-sdk/*` works on an air-gapped box. Older releases have no # such asset: absent means skip, silently. PRESENT means verified exactly like # the binary -- checksum and signature -- before a single byte is extracted. VENDOR_DIR="" install_vendor_bundle() { local bundle_uri="${conusai_uri%/*}/conusai-vendor.tar.gz" local bundle_tarball="$install_dir/conusai-vendor.tar.gz" local vendor_dir="$install_dir/vendor" if ! curl --fail --silent --location --output "$bundle_tarball" "$bundle_uri" 2>/dev/null; then rm -f "$bundle_tarball" return 0 fi info "Verifying vendor bundle..." verify_checksum "$bundle_tarball" "$bundle_uri.sha256" verify_signature "$bundle_tarball" "$bundle_uri.minisig" # Replace the contents wholesale: the directory is ours, and a stale bundle # from a previous release would be re-seeded next to the new one. rm -rf "$vendor_dir" mkdir -p "$vendor_dir" || error "Failed to create vendor directory \"$vendor_dir\"" tar -xzf "$bundle_tarball" -C "$vendor_dir" || error "Failed to extract vendor bundle" rm -f "$bundle_tarball" local count count=$(find "$vendor_dir/npm" -maxdepth 1 -name '*.tgz' 2>/dev/null | wc -l | tr -d ' ') VENDOR_DIR="$vendor_dir" success "Vendor bundle installed ($count packages in $vendor_dir)." } install_vendor_bundle tildify() { if [[ $1 = $HOME/* ]]; then local replacement=\~/ echo "${1/$HOME\//$replacement}" else echo "$1" fi } success "conusai was installed successfully to $Bold_Green$(tildify "$exe")" # Root installs (fresh VPS, remote anchor bootstrap) also get the binary on a # standard PATH — non-interactive SSH never sources ~/.bashrc, so ~/.conusai/bin # alone leaves later `ssh host conusai …` phases with exit 127. if [[ $(id -u) -eq 0 ]]; then install -m 755 "$exe" /usr/local/bin/conusai info "Also installed to /usr/local/bin/conusai (system PATH)." fi already_on_path=false if command -v conusai >/dev/null; then # A previous install already put conusai on PATH — skip the shell-config # section, but keep going: provisioning and setup below must still run. already_on_path=true fi refresh_command='' tilde_bin_dir=$(tildify "$bin_dir") quoted_install_dir=\"${install_dir//\"/\\\"}\" if [[ $quoted_install_dir = \"$HOME/* ]]; then quoted_install_dir=${quoted_install_dir/$HOME\//\$HOME/} fi echo if [[ $already_on_path = true ]]; then :; else case $(basename "$SHELL") in fish) commands=( "set --export $install_env $quoted_install_dir" "set --export PATH $bin_env \$PATH" ) fish_config=$HOME/.config/fish/config.fish tilde_fish_config=$(tildify "$fish_config") if [[ -w $fish_config ]]; then { echo -e '\n# conusai' for command in "${commands[@]}"; do echo "$command" done } >>"$fish_config" info "Added \"$tilde_bin_dir\" to \$PATH in \"$tilde_fish_config\"" refresh_command="source $tilde_fish_config" else echo "Manually add the directory to $tilde_fish_config (or similar):" for command in "${commands[@]}"; do info_bold " $command" done fi ;; zsh) commands=( "export $install_env=$quoted_install_dir" "export PATH=\"$bin_env:\$PATH\"" ) zsh_config=$HOME/.zshrc tilde_zsh_config=$(tildify "$zsh_config") if [[ -w $zsh_config ]]; then { echo -e '\n# conusai' for command in "${commands[@]}"; do echo "$command" done } >>"$zsh_config" info "Added \"$tilde_bin_dir\" to \$PATH in \"$tilde_zsh_config\"" refresh_command="exec $SHELL" else echo "Manually add the directory to $tilde_zsh_config (or similar):" for command in "${commands[@]}"; do info_bold " $command" done fi ;; bash) commands=( "export $install_env=$quoted_install_dir" "export PATH=\"$bin_env:\$PATH\"" ) bash_configs=( "$HOME/.bash_profile" "$HOME/.bashrc" ) if [[ ${XDG_CONFIG_HOME:-} ]]; then bash_configs+=( "$XDG_CONFIG_HOME/.bash_profile" "$XDG_CONFIG_HOME/.bashrc" "$XDG_CONFIG_HOME/bash_profile" "$XDG_CONFIG_HOME/bashrc" ) fi set_manually=true for bash_config in "${bash_configs[@]}"; do tilde_bash_config=$(tildify "$bash_config") if [[ -w $bash_config ]]; then { echo -e '\n# conusai' for command in "${commands[@]}"; do echo "$command" done } >>"$bash_config" info "Added \"$tilde_bin_dir\" to \$PATH in \"$tilde_bash_config\"" refresh_command="source $bash_config" set_manually=false break fi done if [[ $set_manually = true ]]; then echo "Manually add the directory to $tilde_bash_config (or similar):" for command in "${commands[@]}"; do info_bold " $command" done fi ;; *) echo 'Manually add the directory to ~/.bashrc (or similar):' info_bold " export $install_env=$quoted_install_dir" info_bold " export PATH=\"$bin_env:\$PATH\"" ;; esac fi echo info "To get started, run:" echo if [[ $refresh_command ]]; then info_bold " $refresh_command" fi info_bold " conusai --help" # ── Hand off to the interactive wizard (rustup pattern) ────────────── # stdin is the curl pipe → prompts must read the controlling TTY. # No TTY (CI/containers): NEVER hang — print the recipe and exit 0. SUDO="" [[ $(id -u) -ne 0 ]] && command -v sudo >/dev/null 2>&1 && SUDO="sudo" # Mode B pairing creates a WireGuard interface locally, and the server binds # :80/:443 — both privileged operations. File capabilities grant exactly those # two to the binary, so setup and the service run as a regular user (no # setuid, no running everything as root). Must be re-applied after every copy: # `install` writes a new inode, which drops file capabilities. grant_net_caps() { local target=$1 [[ $(id -u) -eq 0 ]] && return 0 if ! command -v setcap >/dev/null 2>&1 && [[ -n $SUDO ]] && command -v apt-get >/dev/null 2>&1; then $SUDO apt-get install -y -qq libcap2-bin >/dev/null 2>&1 || true fi if command -v setcap >/dev/null 2>&1 && [[ -n $SUDO ]]; then $SUDO setcap 'cap_net_admin,cap_net_bind_service+ep' "$target" 2>/dev/null || warning "Could not grant network capabilities to $target — Mode B setup will need root: sudo -E conusai setup --interactive" else warning "setcap is unavailable — Mode B setup will need root: sudo -E conusai setup --interactive" fi } # ── Preflight (warn, never block — the operator knows their machine) ── preflight() { local mem_kb disk_kb mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null || echo 0) if [[ $mem_kb -gt 0 && $mem_kb -lt 1900000 ]]; then warning "Less than 2 GB RAM detected — ConusAI runs, but builds may struggle. 4 GB recommended." fi disk_kb=$(df -k / 2>/dev/null | awk 'NR==2{print $4}') if [[ -n ${disk_kb:-} && $disk_kb -lt 10485760 ]]; then warning "Less than 10 GB free on / — container images and builds need room." fi for p in 80 443; do if command -v ss >/dev/null 2>&1 && ss -tln 2>/dev/null | grep -q ":$p "; then warning "Port $p is already in use — another web server? ConusAI's proxy wants 80/443." fi done # Mode B builds its WireGuard link in-process. Without kernel WireGuard the # backend falls back to userspace (boringtun), which needs a TUN device -- # commonly absent in unprivileged containers, where pairing would otherwise # fail late with an opaque interface error. if [[ "$(uname -s)" = Linux && ! -c /dev/net/tun ]]; then warning "/dev/net/tun is missing — Mode B (anchored origin) cannot create its WireGuard interface here. Load the module (modprobe tun) or run the container with --device /dev/net/tun --cap-add NET_ADMIN. Mode A is unaffected." fi } docker_cmd="" resolve_docker() { if command -v docker >/dev/null 2>&1; then if docker info >/dev/null 2>&1; then docker_cmd="docker"; return 0; fi if [[ -n $SUDO ]] && $SUDO docker info >/dev/null 2>&1 /dev/null 2>&1; then docker_cmd="docker"; return 0; fi fi return 1 } ensure_docker() { resolve_docker && { success "Docker is ready."; return 0; } if ! command -v docker >/dev/null 2>&1; then if [[ $skip_docker_install -eq 1 ]]; then error "Docker is not installed and --skip-docker-install was given. Install Docker, then re-run." fi info "Docker not found — installing it (get.docker.com)…" # Download first, then run: piping `curl | sudo sh /dev/null || true fi resolve_docker || error "Docker is installed but not reachable. Check: ${SUDO:+$SUDO }docker info" success "Docker is ready." } # ── Docker host hardening ──────────────────────────────────────────────────── # # Two controls land together here because they are two halves of the same # answer to "a tenant container escapes": # # 1. The AUTHORIZATION PLUGIN is prevention. dockerd calls # /AuthZPlugin.AuthZReq before it processes a request, so a # `POST /containers/create` asking for --privileged, the Docker socket, # a host namespace, seccomp=unconfined, CAP_SYS_ADMIN, a device, or a # /proc/self/fd working directory (CVE-2024-21626) is refused before the # container exists. See crates/conusai-authz-plugin. # 2. USERNS-REMAP is damage limitation. It maps container UID 0 to an # unprivileged host UID, so an escape that does happen -- through a runc # or kernel bug, beneath every container-level control -- lands as # nobody, not root. # # What userns-remap does NOT do, and the docs say so too: it is DAEMON-WIDE. # Docker's API has no per-container user namespace (`UsernsMode` accepts only # "" or "host"), so every container on the host shares one remap range. It # protects the HOST from tenants. It does not isolate tenant A from tenant B. # # Both need a dockerd restart, which is why they run before any container is # created below. USERNS_UID_OFFSET="" # Set by configure_docker_daemon from the merge's changed/unchanged report, so # a no-op re-run does not restart dockerd. DAEMON_JSON_CHANGED=0 # The graph-store decision, recorded here because it is the one that silently # breaks a host: Docker Engine 29 defaults to the containerd-integrated image # store, which is INCOMPATIBLE with userns-remap. We pin the classic overlay2 # store rather than skipping the remap -- the remap is the control the user # asked for, and `features.containerd-snapshotter: false` is a supported, # documented setting, not a workaround. The cost is that images already in the # containerd store are invisible to the pinned store and get re-pulled; that # is unavoidable on this transition and is stated in # docs/how-to/enable-userns-remap.md. If the Engine version cannot be read at # all we refuse rather than guess, because guessing wrong here means a daemon # that starts and then cannot run anything. docker_engine_major() { local v v=$($docker_cmd version --format '{{.Server.Version}}' 2>/dev/null) || return 1 [[ -n "$v" ]] || return 1 printf '%s' "${v%%.*}" } # Deterministic subordinate ID ranges for the `dockremap` user. # # dockerd will allocate these itself on first start with userns-remap set, but # then the base is only knowable after the fact -- and CONUSAI_USERNS_UID_OFFSET # has to match it exactly or every secrets chown targets the wrong host uid. # Creating them here makes the base an input rather than a discovery. # Sets SUBID_BASE rather than echoing it: this function also prints progress, # and a `$(...)` capture would swallow those lines into the value. SUBID_BASE="" setup_subid_ranges() { local base if ! id -u dockremap >/dev/null 2>&1; then info "Creating the dockremap user for Docker's user-namespace remap…" $SUDO useradd -r -s /usr/sbin/nologin dockremap >/dev/null 2>&1 || true fi for f in /etc/subuid /etc/subgid; do [[ -f $f ]] || $SUDO touch "$f" if ! $SUDO grep -q '^dockremap:' "$f"; then # 231072 = 100000 + 2*65536, the block Docker's own documentation # uses and the first one above useradd's default SUB_UID_MIN range. printf 'dockremap:231072:65536\n' | $SUDO tee -a "$f" >/dev/null fi done # `|| true` INSIDE the substitution: without it a missing dockremap entry # makes grep's non-zero exit abort the script under pipefail before the # guard below can print its own message. base=$($SUDO grep -m1 '^dockremap:' /etc/subuid | cut -d: -f2 || true) [[ -n "$base" ]] || error "Could not determine the dockremap subuid base from /etc/subuid." SUBID_BASE="$base" } # Install the plugin binary, its policy file and its unit. # # The unit is ordered Before=docker.service and RequiredBy=docker.service on # purpose: a plugin named in daemon.json that is not listening makes moby deny # EVERY Docker API call, so "docker starts without it" is the failure we are # designing out, not a graceful degradation. install_authz_plugin() { local plugin_src="$bin_dir/conusai-authz-plugin" [[ -f "$plugin_src" ]] || error "This release does not contain conusai-authz-plugin, which the Docker authorization plugin requires. Install a release built with it, or set CONUSAI_SKIP_DOCKER_HARDENING=1 to install without Docker admission control." $SUDO install -m 0755 "$plugin_src" /usr/local/bin/conusai-authz-plugin $SUDO mkdir -p /etc/conusai $SUDO chmod 0755 /etc/conusai # The exemption list. Over a Unix socket dockerd gives a plugin no caller # identity (Request.User is empty unless the daemon uses TLS client certs), # so the platform's own privileged operations are named here, by container # name prefix, in a root-owned file. Each entry grants ONE narrowly scoped # thing; nothing here can grant --privileged, a host bind, seccomp= # unconfined, CAP_SYS_ADMIN or device passthrough -- those have no # exemption path in the plugin at all. # # The strong boundary for tenant workloads remains compose_policy.rs, which # rejects these directives before the deployer ever calls Docker. This # plugin is the backstop for every path that does not go through it. if [[ ! -f /etc/conusai/authz-plugin.toml ]]; then $SUDO tee /etc/conusai/authz-plugin.toml >/dev/null <<'AUTHZ' # ConusAI Docker authorization plugin policy. # Docs: docs/how-to/enable-userns-remap.md # # enforce = false logs violations and ALLOWS them. Use it for one maintenance # window when first enabling admission control on a host that already has # workloads, read the warnings in `journalctl -u conusai-authz-plugin`, add the # exemptions they name, then set it back to true. enforce = true socket_path = "/run/conusai-authz/conusai-authz.sock" # The vulnerability scanner talks to the Engine API to read locally built # images (crates/conusai-vulnerability-scanner/src/trivy.rs). [[exempt]] name_prefix = "trivy-" allow = ["runtime-socket-mount"] # Backup, restore and mirror sidecars reach services on loopback-bound ports. [[exempt]] name_prefix = "conusai-cp-backup-" allow = ["host-network"] [[exempt]] name_prefix = "conusai-pg-backup-" allow = ["host-network"] [[exempt]] name_prefix = "conusai-pg-restore-" allow = ["host-network"] [[exempt]] name_prefix = "conusai-s3mirror-" allow = ["host-network"] [[exempt]] name_prefix = "rustfs-" allow = ["host-network"] AUTHZ $SUDO chmod 0644 /etc/conusai/authz-plugin.toml else info "Keeping the existing /etc/conusai/authz-plugin.toml policy." fi $SUDO tee /etc/systemd/system/conusai-authz-plugin.service >/dev/null <<'UNIT' [Unit] Description=ConusAI Docker authorization plugin Documentation=https://get.conusai.com # dockerd denies every API call when a configured authorization plugin is not # listening, so this must be up first and must be a hard requirement of it. Before=docker.service StartLimitIntervalSec=60 StartLimitBurst=5 [Service] Type=exec ExecStart=/usr/local/bin/conusai-authz-plugin Environment=RUST_LOG=info # The socket lives in a directory this service owns rather than in # /run/docker/plugins, so systemd never creates or re-modes dockerd's own # runtime directory. /etc/docker/plugins/conusai-authz.spec points the daemon # at it. 0700 because dockerd (root) is the only legitimate client. RuntimeDirectory=conusai-authz RuntimeDirectoryMode=0700 RuntimeDirectoryPreserve=yes Restart=always RestartSec=1 # The process only needs to read one config file, write one socket and answer # HTTP on it. Nothing here needs the network, a device, or a home directory. NoNewPrivileges=yes PrivateTmp=yes PrivateDevices=yes ProtectSystem=strict ProtectHome=yes ProtectKernelTunables=yes ProtectKernelModules=yes ProtectKernelLogs=yes ProtectControlGroups=yes ProtectClock=yes ProtectHostname=yes ProtectProc=invisible LockPersonality=yes MemoryDenyWriteExecute=yes RestrictRealtime=yes RestrictSUIDSGID=yes RestrictNamespaces=yes RestrictAddressFamilies=AF_UNIX SystemCallArchitectures=native SystemCallFilter=@system-service SystemCallErrorNumber=EPERM CapabilityBoundingSet= AmbientCapabilities= UMask=0077 StandardOutput=journal StandardError=journal SyslogIdentifier=conusai-authz [Install] WantedBy=multi-user.target RequiredBy=docker.service UNIT # The spec-file half of Docker's plugin discovery: the daemon reads this # file, finds the socket address inside it, and connects there. The # basename must equal the name used in authorization-plugins. $SUDO mkdir -p /etc/docker/plugins printf 'unix:///run/conusai-authz/conusai-authz.sock\n' | $SUDO tee /etc/docker/plugins/conusai-authz.spec >/dev/null $SUDO chmod 0644 /etc/docker/plugins/conusai-authz.spec $SUDO systemctl daemon-reload # `enable --now` is a no-op when the unit is already active, so an upgrade # that replaced the binary above would keep running the old one. # try-restart touches only an already-active unit; fresh installs fall # through to `enable --now`. $SUDO systemctl try-restart conusai-authz-plugin || true $SUDO systemctl enable --now conusai-authz-plugin local i for i in $(seq 1 20); do [[ -S /run/conusai-authz/conusai-authz.sock ]] && break sleep 1 done [[ -S /run/conusai-authz/conusai-authz.sock ]] || error "The authorization plugin did not create its socket. Docker would deny every API call if we enabled it now. Inspect: journalctl -u conusai-authz-plugin -e" success "Docker authorization plugin is listening." } # Merge our keys into /etc/docker/daemon.json without discarding the # operator's. Done in python3 rather than sed/jq: daemon.json is JSON, an # operator may already have registry mirrors or log options in it, and a # regex-edited daemon.json that fails to parse stops dockerd from starting. configure_docker_daemon() { local engine_major="$1" command -v python3 >/dev/null 2>&1 || error "python3 is required to safely edit /etc/docker/daemon.json. Install it (apt-get install -y python3-minimal) and re-run, or set CONUSAI_SKIP_DOCKER_HARDENING=1." $SUDO mkdir -p /etc/docker local merge_status merge_status=$($SUDO python3 - "$engine_major" <<'PY' import copy, json, os, sys, shutil path = "/etc/docker/daemon.json" engine_major = int(sys.argv[1]) try: with open(path) as fh: text = fh.read().strip() config = json.loads(text) if text else {} # Keep the FIRST backup: a re-run must not overwrite the pristine # pre-ConusAI file with the already-edited one, or the recovery # instructions below would restore the broken config. bak = path + ".conusai.bak" if not os.path.exists(bak): shutil.copy2(path, bak) except FileNotFoundError: config = {} if not isinstance(config, dict): sys.exit("existing /etc/docker/daemon.json is not a JSON object; refusing to edit it") original = copy.deepcopy(config) config["userns-remap"] = "default" # Address pool for the per-organization bridges. # # Stock Docker offers roughly 31 networks from its built-in pools, and that # budget is shared with the default bridge, conusai-app-network, the # Firecracker bridge, and every Compose project's own bridge. Since every live # organization gets its own bridge, a busy install runs out and the symptom is # a deploy failing with "no available IPv4 address pool" at customer N — # nothing warns first, and the deploy that fails is not the one that caused it. # # A /16 split into /24s is 256 networks with 254 usable addresses each, an # order of magnitude above stock. Left alone if the operator already set the # key: their range may be chosen to avoid a corporate network this one would # collide with, and silently overwriting that would black-hole their routes. if "default-address-pools" not in config: config["default-address-pools"] = [{"base": "10.201.0.0/16", "size": 24}] # The default bridge's own address, pinned outside that pool (classic Docker's own # 172.17.0.1/16, never a /24 the pool would hand to an org bridge). Left unset, Docker # re-picks the default bridge's subnet from whichever pool slot is free at the next # daemon restart or bridge recreation -- an unrelated `systemctl restart docker` (a kernel # update, for instance) then reassigns it, and every container that published a port on the # OLD address (baked into that container's own port bindings at creation) loses it silently; # the container keeps running, but nothing can reach its published port until it is recreated. # Left alone if the operator already set the key, same as the pool above. if "bip" not in config: config["bip"] = "172.17.0.1/16" plugins = config.get("authorization-plugins") or [] if not isinstance(plugins, list): sys.exit("existing authorization-plugins is not a list; refusing to edit it") if "conusai-authz" not in plugins: plugins.append("conusai-authz") config["authorization-plugins"] = plugins # Engine 29+ defaults to the containerd image store, which cannot do # userns-remap. Pin the classic store so the remap is actually in force # rather than silently ignored. if engine_major >= 29: features = config.get("features") or {} if not isinstance(features, dict): sys.exit("existing features is not an object; refusing to edit it") features["containerd-snapshotter"] = False config["features"] = features # Report whether the merge changed anything so the caller can skip the # dockerd restart on a no-op re-run. changed = config != original if changed: tmp = path + ".conusai.tmp" with open(tmp, "w") as fh: json.dump(config, fh, indent=2, sort_keys=True) fh.write("\n") os.replace(tmp, path) os.chmod(path, 0o644) print("changed" if changed else "unchanged") PY ) [[ "$merge_status" == changed ]] && DAEMON_JSON_CHANGED=1 || DAEMON_JSON_CHANGED=0 if [[ "$engine_major" -ge 29 ]]; then warning "Docker Engine $engine_major defaults to the containerd image store, which cannot do userns-remap. Pinned the classic overlay2 store (features.containerd-snapshotter=false). Images already in the containerd store will be re-pulled." fi } harden_docker() { if [[ "${CONUSAI_SKIP_DOCKER_HARDENING:-}" = "1" ]]; then warning "Skipping Docker hardening (CONUSAI_SKIP_DOCKER_HARDENING=1). This host has NO Docker admission control and NO user-namespace remapping: a container escape lands as host root." return 0 fi if [[ "$(uname -s)" != Linux ]]; then info "Docker hardening (authz plugin + userns-remap) is Linux-only — skipping." return 0 fi if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then warning "systemd not detected — skipping the Docker authorization plugin and userns-remap. Both need a managed service and a daemon restart. This host has no admission control." return 0 fi if [[ $(id -u) -ne 0 && -z $SUDO ]]; then warning "No root/sudo — skipping the Docker authorization plugin and userns-remap. This host has no admission control." return 0 fi local engine_major engine_major=$(docker_engine_major) || error "Could not read the Docker Engine version. Refusing to enable userns-remap blind: Engine 29+ needs the classic image store pinned and the wrong choice leaves a daemon that starts but cannot run anything. Check: ${SUDO:+$SUDO }docker version" info "Hardening Docker (authorization plugin + userns-remap)…" # Every existing container is stopped and every image is re-pulled under # the remapped storage namespace. Say so before doing it. if [[ -n "$($docker_cmd ps -aq 2>/dev/null)" ]]; then warning "Enabling userns-remap starts a NEW storage namespace: existing containers stop and their images are re-pulled. Named volumes keep their pre-remap ownership and may need chown -R . See docs/how-to/enable-userns-remap.md." fi install_authz_plugin setup_subid_ranges local uid_base="$SUBID_BASE" info "dockremap subordinate ID base: $uid_base" configure_docker_daemon "$engine_major" if [[ "$DAEMON_JSON_CHANGED" == 0 ]]; then info "/etc/docker/daemon.json already matches — no Docker restart needed." else info "Restarting Docker to apply the new daemon configuration…" if ! $SUDO systemctl restart docker; then error "Docker failed to restart with the new /etc/docker/daemon.json. The previous file was saved as /etc/docker/daemon.json.conusai.bak. Recover with: ${SUDO:+$SUDO }mv /etc/docker/daemon.json.conusai.bak /etc/docker/daemon.json ${SUDO:+$SUDO }systemctl restart docker Then inspect: journalctl -u docker -e" fi resolve_docker || error "Docker did not come back after the restart. Recover with: ${SUDO:+$SUDO }mv /etc/docker/daemon.json.conusai.bak /etc/docker/daemon.json ${SUDO:+$SUDO }systemctl restart docker" fi # Verify rather than assume. A daemon.json key Docker silently ignored # would leave a compliance claim with nothing behind it. if ! $docker_cmd info --format '{{.SecurityOptions}}' 2>/dev/null | grep -q 'name=userns'; then error "Docker restarted but does not report user-namespace remapping (\`docker info\` has no 'name=userns' security option). Do not proceed as if it were on. Inspect: ${SUDO:+$SUDO }docker info and journalctl -u docker -e" fi USERNS_UID_OFFSET="$uid_base" success "Docker hardened: authorization plugin active, userns-remap on (base $uid_base)." } # >>> secure_data_dir_secrets # Tighten the installation's secret material on every run, install AND # upgrade. The master key `encryption_key` shipped world-readable (0644) on # installs whose key was written by `conusai setup` before the 0600 fix # (found on the beta.72 production host 2026-09-15); a fresh binary tightens # it at `serve` startup too, but an upgrade should not leave it loose until # then. Idempotent; only touches files/dirs that exist; never reads them. secure_data_dir_secrets() { local data_dir=${1:-${CONUSAI_DATA_DIR:-$HOME/.conusai}} f [[ -d "$data_dir" ]] || return 0 for f in encryption_key auth_secret pg.pass conusai.env; do [[ -f "$data_dir/$f" ]] && chmod 600 "$data_dir/$f" done [[ -d "$data_dir/secrets" ]] && chmod 700 "$data_dir/secrets" return 0 } # <<< secure_data_dir_secrets provision_postgres() { [[ -n "${CONUSAI_DATABASE_URL:-}" ]] && return 0 local PG_CONTAINER=conusai-postgres PG_IMAGE=timescale/timescaledb-ha:pg18 PG_PORT=5433 local conf_dir="$HOME/.conusai" PG_PASSWORD mkdir -p "$conf_dir"; chmod 700 "$conf_dir" if [[ -f "$conf_dir/pg.pass" ]]; then PG_PASSWORD=$(cat "$conf_dir/pg.pass") else PG_PASSWORD=$(head -c16 /dev/urandom | od -An -tx1 | tr -d ' \n') (umask 077; printf '%s' "$PG_PASSWORD" > "$conf_dir/pg.pass") fi if $docker_cmd ps --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then info "PostgreSQL container '$PG_CONTAINER' already running." elif $docker_cmd ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER"; then info "Starting existing PostgreSQL container…" $docker_cmd start "$PG_CONTAINER" >/dev/null else info "Provisioning PostgreSQL (TimescaleDB) container…" # Also published on the default bridge's gateway address: managed # containers (Gitea) run on per-org bridges where this container's NAME # does not resolve, and the gateway is the one address every bridge # reaches. Not the LAN: only the host and its containers see it. local pg_gw; pg_gw=$($docker_cmd network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || true) local -a pg_gw_publish=() [[ -n "$pg_gw" ]] && pg_gw_publish=(-p "${pg_gw}:${PG_PORT}:5432") $docker_cmd run -d --name "$PG_CONTAINER" --restart unless-stopped \ -e POSTGRES_USER=conusai -e POSTGRES_PASSWORD="$PG_PASSWORD" -e POSTGRES_DB=conusai \ -p "127.0.0.1:${PG_PORT}:5432" ${pg_gw_publish[@]+"${pg_gw_publish[@]}"} \ -v conusai-pg-data:/home/postgres/pgdata/data \ "$PG_IMAGE" >/dev/null fi # Readiness must mean what the app needs: TCP + password auth + the target # database answering a real query — and STABLE. timescaledb-ha restarts # postgres during first-boot init, so a single socket-level pg_isready can # succeed inside the restart window ("0 bytes at EOF" for the next client). # Require 3 consecutive TCP-authenticated queries, 2s apart. info "Waiting for PostgreSQL to become ready…" local ok_streak=0 tries=0 while [[ $ok_streak -lt 3 ]]; do if $docker_cmd exec -e PGPASSWORD="$PG_PASSWORD" "$PG_CONTAINER" \ psql -h 127.0.0.1 -p 5432 -U conusai -d conusai -tAc 'SELECT 1' >/dev/null 2>&1; then ok_streak=$((ok_streak+1)) else ok_streak=0 fi tries=$((tries+1)) [[ $tries -gt 120 ]] && error "PostgreSQL did not become ready in time. Check: $docker_cmd logs $PG_CONTAINER" sleep 2 done success "PostgreSQL is ready (verified over TCP, 3× stable)." # The container's POSTGRES_USER is a superuser, and PostgreSQL exempts # superusers from row-level security unconditionally — connecting the app # as that role would make tenant isolation inert (the server refuses to # stay quiet about it). So the app connects as 'conusai_app': LOGIN, # NOSUPERUSER, NOBYPASSRLS, owner of everything in the database. The # extensions the migrations expect are created here once, with superuser. # The extension list is deliberately NOT written out here. The baseline # migration verifies the same list before it creates anything and refuses # to run if one is missing, so a second copy in this script could drift # from what the binary actually requires — and the drift would surface as # a failed install on somebody else's machine. Asking the binary that runs # the migrations to state its own preconditions makes drift impossible: # one artifact, one answer, shipped together. local bootstrap_sql if ! bootstrap_sql=$("$exe" migrate --print-bootstrap-sql 2>&1); then error "Could not read the database bootstrap SQL from conusai: $bootstrap_sql" fi info "Configuring least-privilege application role…" if ! printf '%s\n' \ "SET client_min_messages = error;" \ "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'conusai_app') THEN CREATE ROLE conusai_app LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; END IF; END \$\$;" \ "ALTER ROLE conusai_app PASSWORD '${PG_PASSWORD}';" \ "$bootstrap_sql" \ "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'conusai_sealer') THEN CREATE ROLE conusai_sealer LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; END IF; END \$\$;" \ "ALTER ROLE conusai_sealer PASSWORD '${PG_PASSWORD}';" \ "GRANT conusai_audit_sealer TO conusai_sealer;" \ "GRANT USAGE ON SCHEMA public TO conusai_sealer;" \ "ALTER DATABASE conusai OWNER TO conusai_app;" \ | $docker_cmd exec -i -e PGPASSWORD="$PG_PASSWORD" "$PG_CONTAINER" \ psql -h 127.0.0.1 -p 5432 -U conusai -d conusai -q -v ON_ERROR_STOP=1 >/dev/null; then error "Failed to configure the application database role. Check: $docker_cmd logs $PG_CONTAINER" fi export CONUSAI_DATABASE_URL="postgresql://conusai_app:${PG_PASSWORD}@localhost:${PG_PORT}/conusai?sslmode=disable" # The in-process audit sealer (P2.4, plan D9). The NOLOGIN group role # comes from $bootstrap_sql above; conusai_sealer is a LOGIN member of it. # Same pg.pass credential as the app role — deliberately not a new secret: # the role's power is bounded by its group membership (SELECT + the chain # column UPDATE grants + checkpoint INSERT), not by who knows the password. export CONUSAI_AUDIT_SEALER_DATABASE_URL="postgresql://conusai_sealer:${PG_PASSWORD}@localhost:${PG_PORT}/conusai?sslmode=disable" (umask 077; printf 'CONUSAI_DATABASE_URL=%s\nCONUSAI_AUDIT_SEALER_DATABASE_URL=%s\n' \ "$CONUSAI_DATABASE_URL" "$CONUSAI_AUDIT_SEALER_DATABASE_URL" > "$conf_dir/conusai.env") info "Database URL saved to ~/.conusai/conusai.env" } refresh_db_bootstrap() { # Re-apply the binary's PRIVILEGED bootstrap SQL on every run, not only on # first provisioning. `conusai migrate --print-bootstrap-sql` is the # binary's own statement of what must exist before its migrations can run # as the NOSUPERUSER application role: extensions (`vector` is untrusted) # and the AU-9 audit group roles (the app role is NOCREATEROLE). Each new # release may add to that list, and provision_postgres() returns early on # an upgrade (CONUSAI_DATABASE_URL is already set), so without this step # an upgraded binary fails its first start with "run this once as a # superuser" — exactly what took cloud.conusai.com down on 2026-09-11 # when v0.1.0-beta.64 landed on a beta.60 install. Every statement in the # bootstrap is idempotent (IF NOT EXISTS), so repeating it is free. # # Only the installer-managed container is handled here: with an external # Postgres the superuser is the operator's, so print the SQL and move on. local PG_CONTAINER=conusai-postgres conf_dir="$HOME/.conusai" if ! $docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -qx "$PG_CONTAINER"; then if [[ -n "${CONUSAI_DATABASE_URL:-}" ]]; then info "External database in use — apply the privileged bootstrap yourself if this release added to it:" info_bold " conusai migrate --print-bootstrap-sql | psql " fi return 0 fi [[ -f "$conf_dir/pg.pass" ]] || { warning "No $conf_dir/pg.pass — skipping privileged bootstrap refresh."; return 0; } local PG_PASSWORD bootstrap_sql PG_PASSWORD=$(cat "$conf_dir/pg.pass") if ! bootstrap_sql=$("$exe" migrate --print-bootstrap-sql 2>&1); then error "Could not read the database bootstrap SQL from conusai: $bootstrap_sql" fi info "Refreshing privileged database bootstrap (extensions, audit roles)…" if ! printf '%s\n' "SET client_min_messages = error;" "$bootstrap_sql" \ "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'conusai_sealer') THEN CREATE ROLE conusai_sealer LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE; END IF; END \$\$;" \ "ALTER ROLE conusai_sealer PASSWORD '${PG_PASSWORD}';" \ "GRANT conusai_audit_sealer TO conusai_sealer;" \ "GRANT USAGE ON SCHEMA public TO conusai_sealer;" \ | $docker_cmd exec -i -e PGPASSWORD="$PG_PASSWORD" "$PG_CONTAINER" \ psql -h 127.0.0.1 -p 5432 -U conusai -d conusai -q -v ON_ERROR_STOP=1 >/dev/null; then error "Failed to refresh the privileged database bootstrap. Check: $docker_cmd logs $PG_CONTAINER" fi # Upgrades predate the in-process sealer (P2.4): the role is created # above, and its URL joins the env file here — same pg.pass credential, # swapped into the app URL's host/port/database so external-Postgres # installs stay correct without parsing. local env_file="$conf_dir/conusai.env" db_url sealer_url if [[ -f "$env_file" ]] && ! grep -q '^CONUSAI_AUDIT_SEALER_DATABASE_URL=' "$env_file"; then db_url=$(grep '^CONUSAI_DATABASE_URL=' "$env_file" | head -1 | cut -d= -f2-) if [[ -n "$db_url" ]]; then sealer_url="${db_url/postgresql:\/\/conusai_app:/postgresql://conusai_sealer:}" if [[ "$sealer_url" != "$db_url" ]]; then (umask 077; printf 'CONUSAI_AUDIT_SEALER_DATABASE_URL=%s\n' "$sealer_url" >> "$env_file") info "Sealer database URL added to ~/.conusai/conusai.env" fi fi fi } # ── Git & Packages (docs/plans/2026-09-12-git-packages-by-default.md D1/D2) ── # The block between the markers is a VERBATIM copy of scripts/lib/git-packages.sh # (this script is curl-piped and cannot source the repo at run time). start.sh # sources the library directly; scripts/tests/install-git-step.sh diffs this # copy against it and fails on drift — edit the library, then paste it here. # >>> scripts/lib/git-packages.sh # git-packages.sh — provision the backing role for the default Git & Packages # service on the platform Postgres, and record its URL for the binary's # first-run bootstrap. # # WHY THIS FILE EXISTS # docs/plans/2026-09-12-git-packages-by-default.md D1/D2: the installer does # only what shell must — a `conusai_git` role with CREATEDB on the platform # Postgres container, its URL written root-only to conusai.env — and the # binary's first-run bootstrap creates the service through the same code # path the console uses. Both `scripts/install.sh` (curl-piped, standalone; # it EMBEDS a copy of this file between `# >>> scripts/lib/git-packages.sh` # and `# <<< scripts/lib/git-packages.sh` markers) and `scripts/start.sh` # (sources it) call the same function, so the two cannot drift: the # self-test `scripts/tests/install-git-step.sh` diffs the embedded copy # against this file and fails on any difference. # # USAGE # source scripts/lib/git-packages.sh # git_packages_enabled "${CONUSAI_GIT_PACKAGES:-}" # exit 0 = provision # GIT_PACKAGES_DOCKER="docker" \ # GIT_PACKAGES_PG_PASSWORD="" \ # git_packages_provision [url_host] # # CONTRACT # - Idempotent. The password is generated ONCE: when already # carries CONUSAI_GIT_BACKING_URL its password is reused and re-asserted # on the role (heals a role lost to a database wipe, never rotates a # credential the running service still holds). # - The URL is never printed. Only role/host names reach stdout. # - is created 0600 (umask 077) when absent; existing keys are # never rewritten (same `grep -q || echo >>` pattern as install.sh). # - The SQL travels on psql's stdin, never in argv. GIT_PACKAGES_ROLE="${GIT_PACKAGES_ROLE:-conusai_git}" # git_packages_enabled # Exit 0 when the Git & Packages default should be provisioned. Unset/empty # means yes (D1: default `yes`); only an explicit no/false/0/off opts out. git_packages_enabled() { local v="${1:-yes}" case "$(printf '%s' "$v" | tr '[:upper:]' '[:lower:]')" in n|no|false|0|off) return 1 ;; *) return 0 ;; esac } # git_packages_generate_password # 32 URL-safe characters (alphanumeric only, so the URL needs no quoting and # the SQL literal needs no escaping). git_packages_generate_password() { openssl rand -base64 30 | tr -d '/+=\n' | cut -c1-32 } # git_packages_url_password # The password half of `scheme://user:PASSWORD@host…`. git_packages_url_password() { local rest="${1#*://}" rest="${rest%%@*}" printf '%s' "${rest#*:}" } # _git_packages_psql # Run psql inside the platform Postgres container, SQL on stdin. With # GIT_PACKAGES_PG_PASSWORD set it authenticates over TCP like install.sh's # provision_postgres(); without it, the container's local socket (trust for # the container superuser) is used, like start.sh. _git_packages_psql() { local container="$1" superuser="$2" db="$3" local -a docker_cmd psql_args read -ra docker_cmd <<<"${GIT_PACKAGES_DOCKER:-docker}" docker_cmd+=(exec -i) psql_args=(psql -U "$superuser" -d "$db" -q -v ON_ERROR_STOP=1) if [[ -n "${GIT_PACKAGES_PG_PASSWORD:-}" ]]; then docker_cmd+=(-e "PGPASSWORD=$GIT_PACKAGES_PG_PASSWORD") psql_args+=(-h 127.0.0.1 -p 5432) fi "${docker_cmd[@]}" "$container" "${psql_args[@]}" } # git_packages_provision [url_host] git_packages_provision() { local container="$1" superuser="$2" db="$3" env_file="$4" local url_host="${5:-$1}" role="$GIT_PACKAGES_ROLE" local password existing_line existing_line=$(grep '^CONUSAI_GIT_BACKING_URL=' "$env_file" 2>/dev/null | head -n1 || true) if [[ -n "$existing_line" ]]; then password=$(git_packages_url_password "${existing_line#CONUSAI_GIT_BACKING_URL=}") password="${password//[\'\"]/}" fi if [[ -z "${password:-}" ]]; then password=$(git_packages_generate_password) existing_line="" fi [[ -n "$password" ]] || { echo "git-packages: could not obtain a role password" >&2; return 1; } # NOSUPERUSER/NOCREATEROLE/NOBYPASSRLS keep the role a plain database # owner: CREATEDB is all Gitea's backing store needs (the engine runs # CREATE DATABASE from the platform process, then Gitea owns its schema). if ! printf '%s\n' \ "SET client_min_messages = error;" \ "DO \$\$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${role}') THEN CREATE ROLE ${role} LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS; END IF; END \$\$;" \ "ALTER ROLE ${role} LOGIN CREATEDB NOSUPERUSER NOCREATEROLE NOBYPASSRLS PASSWORD '${password//\'/\'\'}';" \ | _git_packages_psql "$container" "$superuser" "$db" >/dev/null; then echo "git-packages: failed to provision role '${role}' on container '${container}'" >&2 return 1 fi if [[ ! -f "$env_file" ]]; then mkdir -p "$(dirname "$env_file")" (umask 077; : > "$env_file") fi # Always record the CURRENT host: the password is reused across runs, but # the host that reaches Postgres may change (a URL kept from an older run # would keep pointing at a name Gitea cannot resolve). local new_line="CONUSAI_GIT_BACKING_URL=postgresql://${role}:${password}@${url_host}" if [[ "$existing_line" != "$new_line" ]]; then local tmp tmp=$(umask 077; mktemp "${env_file}.XXXXXX") grep -v '^CONUSAI_GIT_BACKING_URL=' "$env_file" > "$tmp" || true (umask 077; printf '%s\n' "$new_line" >> "$tmp") chmod 600 "$tmp" mv "$tmp" "$env_file" fi grep -q '^CONUSAI_BOOTSTRAP_GIT_PACKAGES=' "$env_file" 2>/dev/null \ || echo "CONUSAI_BOOTSTRAP_GIT_PACKAGES=true" >> "$env_file" echo "git-packages: role '${role}' ready on '${container}' (CREATEDB); backing URL recorded in ${env_file}" } # <<< scripts/lib/git-packages.sh provision_git_packages() { # One question / one variable: CONUSAI_GIT_PACKAGES (default yes; `no` # skips). Prompted only when a TTY is present AND the variable is unset — # the same gating the "Setup wizard" step uses — so cloud-init/CI runs # never block, and an operator who exported the variable is never asked. # Everything past the role + URL happens in the binary (first-run # bootstrap creates the `git` service through the console's own create # path), so this step stays shell-minimal. local PG_CONTAINER=conusai-postgres conf_dir="$HOME/.conusai" local env_file="$conf_dir/conusai.env" answer="${CONUSAI_GIT_PACKAGES:-}" if [[ -z "$answer" && "$setup_stdin" == /dev/tty ]]; then echo -e " ${Dim}Git & Packages hosts a private git server and npm/cargo/container registry on this box" echo -e " (Gitea, backed by the platform PostgreSQL). Set CONUSAI_GIT_PACKAGES=no to skip.${Color_Off}" read -r -p " Enable Git & Packages? [Y/n] " answer <"$setup_stdin" || answer="" fi if ! git_packages_enabled "$answer"; then info "Git & Packages skipped (CONUSAI_GIT_PACKAGES=no). Enable later: create a 'git' service in the console." return 0 fi if ! $docker_cmd ps --format '{{.Names}}' 2>/dev/null | grep -qx "$PG_CONTAINER"; then info "External database in use — Git & Packages needs a CREATEDB role on it. Create one yourself, then set" info_bold " CONUSAI_GIT_BACKING_URL=postgresql://:@[:port] (no database name)" info_bold " CONUSAI_BOOTSTRAP_GIT_PACKAGES=true" info "in ~/.conusai/conusai.env; the next 'conusai serve' creates the service." return 0 fi [[ -f "$conf_dir/pg.pass" ]] || { warning "No $conf_dir/pg.pass — skipping Git & Packages role provisioning."; return 0; } info "Provisioning the Git & Packages backing role on '$PG_CONTAINER'…" # Gitea cannot resolve the container name from its org bridge, so record an # address it can reach: the bridge gateway, when this container publishes # there (installs made by this script do). An older container that only # published on loopback keeps the name; the platform then refuses to start # Gitea with an error naming the fix, instead of crash-looping. local url_host="$PG_CONTAINER" pg_gw pg_bound pg_gw=$($docker_cmd network inspect bridge -f '{{(index .IPAM.Config 0).Gateway}}' 2>/dev/null || true) pg_bound=$($docker_cmd port "$PG_CONTAINER" 5432/tcp 2>/dev/null | grep -F "${pg_gw}:" | head -n1 | sed 's/.*://' || true) if [[ -n "$pg_gw" && -n "$pg_bound" ]]; then url_host="${pg_gw}:${pg_bound}" else warning "'$PG_CONTAINER' is not published on the Docker bridge gateway; Gitea cannot resolve its name from the org network." warning "Recreate the container with -p :5433:5432, or set CONUSAI_GIT_BACKING_URL to an address reachable from the org network." fi if ! GIT_PACKAGES_DOCKER="$docker_cmd" GIT_PACKAGES_PG_PASSWORD="$(cat "$conf_dir/pg.pass")" \ git_packages_provision "$PG_CONTAINER" conusai conusai "$env_file" "$url_host" >/dev/null; then error "Failed to provision the Git & Packages role. Check: $docker_cmd logs $PG_CONTAINER" fi success "Git & Packages enabled: role 'conusai_git' ready; the first 'conusai serve' creates the service." } install_service() { # Make ConusAI a real server: start on boot, restart on failure, survive # logout. Skips gracefully when systemd is absent (containers, WSL1). if ! command -v systemctl >/dev/null 2>&1 || [[ ! -d /run/systemd/system ]]; then info "systemd not detected — start the server manually:" info_bold " set -a; source ~/.conusai/conusai.env; set +a; conusai serve" return 0 fi if [[ $(id -u) -ne 0 && -z $SUDO ]]; then info "No root/sudo — start the server manually:" info_bold " set -a; source ~/.conusai/conusai.env; set +a; conusai serve" return 0 fi info "Installing systemd service…" $SUDO install -m 755 "$exe" /usr/local/bin/conusai grant_net_caps /usr/local/bin/conusai local svc_user; svc_user=$(id -un) local env_file="$HOME/.conusai/conusai.env" # Pin the listeners once, in the env file the unit reads (0600 already). grep -q '^CONUSAI_ADDRESS=' "$env_file" 2>/dev/null || echo "CONUSAI_ADDRESS=0.0.0.0:80" >> "$env_file" grep -q '^CONUSAI_TLS_ADDRESS=' "$env_file" 2>/dev/null || echo "CONUSAI_TLS_ADDRESS=0.0.0.0:443" >> "$env_file" grep -q '^CONUSAI_CONSOLE_ADDRESS=' "$env_file" 2>/dev/null || echo "CONUSAI_CONSOLE_ADDRESS=127.0.0.1:8081" >> "$env_file" grep -q '^CONUSAI_DATA_DIR=' "$env_file" 2>/dev/null || echo "CONUSAI_DATA_DIR=$HOME/.conusai" >> "$env_file" # Under userns-remap the container's uid N is host uid subuid_base + N, so # the host-side secrets directory must be chowned to the offset uid or the # remapped container process cannot read its own secrets. Written only when # harden_docker actually turned the remap on and verified it -- an offset # set on a host without the remap would chown to a uid nothing maps to. if [[ -n "$USERNS_UID_OFFSET" ]]; then grep -q '^CONUSAI_USERNS_UID_OFFSET=' "$env_file" 2>/dev/null || echo "CONUSAI_USERNS_UID_OFFSET=$USERNS_UID_OFFSET" >> "$env_file" fi # First-run org identity (ADR-008), same deal: pinned with the documented # defaults so the headless bootstrap's org is reproducible; the operator's # exported values win when set. Quoted — the file is also bash-sourced. grep -q '^CONUSAI_ORG_NAME=' "$env_file" 2>/dev/null || echo "CONUSAI_ORG_NAME=\"${CONUSAI_ORG_NAME:-Default Organization}\"" >> "$env_file" grep -q '^CONUSAI_ORG_SLUG=' "$env_file" 2>/dev/null || echo "CONUSAI_ORG_SLUG=\"${CONUSAI_ORG_SLUG:-default}\"" >> "$env_file" # Vendor SDK packages (D5): only when this run actually installed the # bundle -- pinning a directory that does not exist would send the git # engine looking for tarballs on every start. if [[ -n "$VENDOR_DIR" ]]; then grep -q '^CONUSAI_VENDOR_DIR=' "$env_file" 2>/dev/null || echo "CONUSAI_VENDOR_DIR=$VENDOR_DIR" >> "$env_file" fi # WireGuard address plan (ADR-008), same deal. grep -q '^CONUSAI_WG_ANCHOR_SUBNET=' "$env_file" 2>/dev/null || echo "CONUSAI_WG_ANCHOR_SUBNET=${CONUSAI_WG_ANCHOR_SUBNET:-10.101.0.0/24}" >> "$env_file" grep -q '^CONUSAI_WG_MESH_SUBNET=' "$env_file" 2>/dev/null || echo "CONUSAI_WG_MESH_SUBNET=${CONUSAI_WG_MESH_SUBNET:-10.100.0.0/24}" >> "$env_file" # CONUSAI_EDGE_NETWORKS (tenant-isolation edge classifier, spec P0.1/P0.4) # is deliberately NOT pinned: unset, the proxy derives the trusted edge # set at startup from the WG subnets above PLUS this host's default-route # addresses (the public NIC in Mode A, the LAN address of a Mode B # origin), so both modes route public traffic without configuration. # Set it in the env file only to override -- a comma-separated # CIDR/bare-IP list that REPLACES the derived default (include the WG # subnets yourself then), e.g. for a host with several public addresses. # If nothing can be derived the edge set is empty and every non-loopback # source fails closed to Tenant, with a loud startup warn -- see # scripts/systemd/conusai.env.example. $SUDO tee /etc/systemd/system/conusai.service >/dev/null </dev/null || hostname -I 2>/dev/null | awk '{print $1}') fi echo info_bold " Dashboard: http://${ip:-}/" info_bold " Logs: journalctl -u conusai -f" info_bold " Manage: systemctl status|restart|stop conusai" return 0 fi sleep 2 done warning "Service installed but not active yet — inspect: journalctl -u conusai -e" } # ── Anchor ownership (Mode B) ──────────────────────────────────────────────── # # ONE ANCHOR SERVES EXACTLY ONE ORGANIZATION. The tenant boundary is physical -- # separate anchors, separate key material -- not firewall rules that can have # bugs. Re-pointing an anchor at a second origin severs the first one's tunnel, # and across organizations it hands one customer's traffic to another. # # `anchor init` already refuses to overwrite an existing keypair, but anchor.toml # records no owner, so it can only report "already exists". These functions add # a sidecar marker so a conflict can be NAMED. # # No-ops outside Mode B, and never fatal on a transport failure: an anchor we # cannot reach is a pairing problem that `anchor pair` will report far better # than a preflight guess. _anchor_ssh_parts() { local t="${CONUSAI_ANCHOR_SSH#ssh://}" if [[ "$t" == *:* ]]; then printf '%s %s' "${t%:*}" "${t##*:}"; else printf '%s 22' "$t"; fi } check_anchor_ownership() { [[ "${CONUSAI_SETUP_MODE:-}" == "b" ]] || return 0 [[ -n "${CONUSAI_ANCHOR_SSH:-}" ]] || return 0 command -v ssh >/dev/null 2>&1 || return 0 local dest port marker owner_domain read -r dest port < <(_anchor_ssh_parts) marker=$(ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 \ -p "$port" "$dest" ' S=""; [ "$(id -u)" -eq 0 ] || S=sudo if [ -f /etc/conusai/anchor-owner ]; then $S cat /etc/conusai/anchor-owner elif [ -f /etc/conusai/anchor.toml ]; then echo "domain=unknown" fi ' 2>/dev/null) || return 0 [[ -z "$marker" ]] && return 0 owner_domain=$(sed -n 's/^domain=//p' <<<"$marker") if [[ -n "$owner_domain" && "$owner_domain" == "${MAIN_DOMAIN:-}" ]]; then info "Anchor already claimed by this deployment ($owner_domain) — continuing" return 0 fi # CONUSAI_ANCHOR_JOIN is the NON-destructive override: this deployment # attaches beside the existing origin (SNI-routed) instead of replacing # it. The wizard reads the same variable and skips `anchor init`, so the # existing tunnel survives. CONUSAI_ANCHOR_FORCE remains the destructive # one; they are not interchangeable. if [[ "${CONUSAI_ANCHOR_JOIN:-}" == "1" ]]; then warning "Anchor is claimed by '${owner_domain:-unknown}' — joining it as an ADDITIONAL origin. That deployment keeps its tunnel and stays the default for unmatched hosts. Both deployments' key material now lives on that one host." return 0 fi if [[ "${CONUSAI_ANCHOR_FORCE:-}" == "1" ]]; then warning "Anchor is claimed by '${owner_domain:-unknown}' — proceeding anyway (CONUSAI_ANCHOR_FORCE=1). This SEVERS that deployment's tunnel." return 0 fi error "This anchor already serves a different deployment. anchor: ${dest} claimed: ${marker//$'\n'/ } requested: domain=${MAIN_DOMAIN:-} One anchor serves exactly one organization. Pairing here would sever the existing tunnel, and across organizations it would breach the tenant boundary. Use a separate anchor for this deployment. If this anchor is genuinely retired, release it first: ssh ${dest} 'systemctl stop conusai-anchor; rm -rf /etc/conusai; ip link del wg-anchor' To attach to this anchor as an ADDITIONAL origin instead, leaving the existing deployment running and routing by hostname: CONUSAI_ANCHOR_JOIN=1 or re-run with CONUSAI_ANCHOR_FORCE=1 if you accept severing it." } claim_anchor() { [[ "${CONUSAI_SETUP_MODE:-}" == "b" ]] || return 0 [[ -n "${CONUSAI_ANCHOR_SSH:-}" ]] || return 0 command -v ssh >/dev/null 2>&1 || return 0 local dest port read -r dest port < <(_anchor_ssh_parts) # A joined origin does not own the anchor: `anchor-owner` keeps naming the # deployment that provisioned it (the ownership check above reads it), and # this deployment is recorded in an append-only sidecar instead. if [[ "${CONUSAI_ANCHOR_JOIN:-}" == "1" ]]; then ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 \ -p "$port" "$dest" " S=\"\"; [ \"\$(id -u)\" -eq 0 ] || S=sudo \$S mkdir -p /etc/conusai \$S touch /etc/conusai/anchor-origins \$S chmod 600 /etc/conusai/anchor-origins \$S sed -i '/^domain=${MAIN_DOMAIN:-unknown} /d' /etc/conusai/anchor-origins 2>/dev/null || true printf 'domain=%s joined_by=%s joined_at=%s mode=install\\n' \\ '${MAIN_DOMAIN:-unknown}' '$(id -un)@$(hostname 2>/dev/null || echo origin)' \\ \"\$(date -u +%Y-%m-%dT%H:%M:%SZ)\" | \$S tee -a /etc/conusai/anchor-origins >/dev/null " 2>/dev/null || true return 0 fi ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=20 \ -p "$port" "$dest" " S=\"\"; [ \"\$(id -u)\" -eq 0 ] || S=sudo \$S mkdir -p /etc/conusai printf 'domain=%s\\nclaimed_by=%s\\nclaimed_at=%s\\nmode=install\\n' \\ '${MAIN_DOMAIN:-unknown}' '$(id -un)@$(hostname 2>/dev/null || echo origin)' \\ \"\$(date -u +%Y-%m-%dT%H:%M:%SZ)\" | \$S tee /etc/conusai/anchor-owner >/dev/null \$S chmod 600 /etc/conusai/anchor-owner " 2>/dev/null || true } # A TTY is needed for prompts; an env-driven run (CONUSAI_SETUP_MODE=… or # CONUSAI_SETUP_EXISTING=…) never prompts, so it may run without one # (cloud-init, CI, ssh without -t). setup_stdin=/dev/null if [[ -e /dev/tty ]] && ( : /dev/null; then setup_stdin=/dev/tty; fi if [[ "${CONUSAI_INSTALL_NO_WIZARD:-}" != "1" ]] \ && { [[ $setup_stdin == /dev/tty ]] || [[ -n ${CONUSAI_SETUP_MODE:-}${CONUSAI_SETUP_EXISTING:-} ]]; }; then echo steps_plan "Preflight checks" "Anchor ownership" "Docker engine" "Docker hardening" \ "PostgreSQL" "Database bootstrap" "Git & Packages" "Network capabilities" \ "Setup wizard" "System service" secure_data_dir_secrets step "Preflight checks"; preflight; step_done step "Anchor ownership"; check_anchor_ownership; step_done step "Docker engine"; ensure_docker; step_done # Before provision_postgres: enabling userns-remap starts a new storage # namespace, so any container created first would have to be recreated. step "Docker hardening"; harden_docker; step_done step "PostgreSQL"; provision_postgres; step_done step "Database bootstrap"; refresh_db_bootstrap; step_done step "Git & Packages"; provision_git_packages; step_done step "Network capabilities"; grant_net_caps "$exe"; step_done step "Setup wizard" if "$exe" setup --interactive <"$setup_stdin"; then step_done claim_anchor step "System service"; install_service; step_done echo -e "\n${Bold_Green}Install complete${Color_Off} ${Dim}in $(hms $((SECONDS - INSTALL_STARTED)))${Color_Off}" else error "Setup did not complete. Re-run it any time: set -a; source ~/.conusai/conusai.env; set +a; conusai setup --interactive" fi else echo # Headless installs harden too: cloud-init and CI hosts are exactly the # ones that need admission control, and hardening before any container # exists makes the userns-remap storage switch free. This branch used to # do neither the work nor any warning that it was skipped. steps_plan "Docker engine" "Docker hardening" secure_data_dir_secrets step "Docker engine"; ensure_docker; step_done step "Docker hardening"; harden_docker; step_done info "No TTY available — skipping interactive setup." info_bold " Run later: conusai setup --interactive" info_bold " Or headless: re-run with CONUSAI_SETUP_MODE=a|b and the wizard's env vars set" info_bold " Mode A (public server): CONUSAI_SETUP_MODE=a" info_bold " Mode B (own a gateway): CONUSAI_SETUP_MODE=b CONUSAI_ANCHOR_SSH=root@gw" info_bold " Mode B (join a gateway): ... plus CONUSAI_ANCHOR_JOIN=1 (adds this" info_bold " origin beside the deployment already there, routed by hostname)" info_bold " (ADMIN_EMAIL, ADMIN_PASSWORD, MAIN_DOMAIN, LETSENCRYPT_EMAIL, CONUSAI_SETUP_YES=1)" info_bold " Optional outbound email, same run: CONUSAI_SMTP_PRESET=gmail|ses|scaleway|custom" info_bold " CONUSAI_SMTP_HOST CONUSAI_SMTP_PORT CONUSAI_SMTP_USERNAME CONUSAI_SMTP_PASSWORD" info_bold " CONUSAI_SMTP_ENCRYPTION=starttls|tls|none CONUSAI_SMTP_FROM_ADDRESS CONUSAI_SMTP_SES_REGION" fi