#!/usr/bin/env bash # Native Bash entry. Source of truth: bootstrap-public Git repository. set -euo pipefail export LC_ALL=C usage() { cat <<'EOF' Usage: bash install.sh --name NAME --core-url wss://CORE --core-public-key KEY --assets-url HTTPS_BASE [--check] Debian 13 x86_64; native Periphery 2.3.4. No Python payload. --check performs local checks only. Core registration is verified by the controller. Docker, Headscale and SSH hardening run afterwards through Komodo. EOF } fail() { printf 'ERROR: %s\n' "$*" >&2; return 1; } validate_host() { local host=$1 label octet [[ -n "$host" && ${#host} -le 253 && "$host" != .* && "$host" != *. && "$host" != *..* ]] || { fail 'Invalid URL host'; return 1; } if [[ "$host" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then local octets=() IFS=. read -r -a octets <<< "$host" for octet in "${octets[@]}"; do [[ "$octet" =~ ^(0|[1-9][0-9]{0,2})$ ]] && ((10#$octet <= 255)) || { fail 'Invalid IPv4 URL host'; return 1; } done return 0 fi [[ "$host" =~ ^[A-Za-z0-9.-]+$ ]] || { fail 'Invalid URL host'; return 1; } local labels=() IFS=. read -r -a labels <<< "$host" for label in "${labels[@]}"; do [[ "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || { fail 'Invalid URL host label'; return 1; } done } validate_port() { local port=$1 [[ -z "$port" ]] && return 0 [[ "$port" =~ ^[0-9]{1,5}$ ]] && ((10#$port >= 1 && 10#$port <= 65535)) || { fail 'Invalid URL port'; return 1; } } validate_inputs() { local word='[a-z][a-z0-9]{0,15}' local host='' port='' [[ ${#node_name} -le 63 && "$node_name" =~ ^(dev|test|stage|prod)-$word-$word(-$word)?-(0[1-9]|[1-9][0-9])$ ]] || { fail 'Invalid short hostname'; return 1; } if [[ "$core_url" =~ ^wss://([^/:?#]+)(:([0-9]{1,5}))?/?$ ]]; then host=${BASH_REMATCH[1]}; port=${BASH_REMATCH[3]} validate_host "$host" || return 1 validate_port "$port" || return 1 else fail 'Expected a wss Core base URL'; return 1 fi if [[ "$assets_url" =~ ^https://([^/:?#]+)(:([0-9]{1,5}))?/releases/[0-9a-f]{40}$ ]]; then host=${BASH_REMATCH[1]}; port=${BASH_REMATCH[3]} validate_host "$host" || return 1 validate_port "$port" || return 1 else fail 'Expected the fixed Caddy release URL https://HOST/releases/COMMIT'; return 1 fi [[ "$core_key" =~ ^MCowBQYDK2VuAyEA[A-Za-z0-9+/]{43}=$ ]] || { fail 'Expected a single-line X25519 SPKI Core public key'; return 1; } } protected() { local path=$1 parent=$1 [[ "$path" == /* ]] || { fail 'Absolute installation path required'; return 1; } while [[ "$parent" != / ]]; do [[ ! -L "$parent" ]] || { fail "Symlink in installation path: $parent"; return 1; } if [[ -e "$parent" ]]; then [[ $(stat -c %u "$parent") == 0 ]] || { fail "Untrusted owner: $parent"; return 1; } (( (8#$(stat -c %a "$parent") & 0022) == 0 )) || { fail "Writable installation path: $parent"; return 1; } fi parent=$(dirname -- "$parent") done [[ ! -e "$path" || ( -f "$path" && $(stat -c %h "$path") == 1 ) ]] || { fail "Not a regular file: $path"; return 1; } } write_new() { local target=$1 source=$2 mode=$3 protected "$target" if [[ -e "$target" ]]; then cmp -s "$source" "$target" || { fail "Existing configuration differs: $target"; return 1; } else atomic_install "$source" "$target" "$mode" fi } atomic_install() { local source=$1 target=$2 mode=$3 temporary protected "$target" temporary=$(mktemp "$(dirname -- "$target")/.platform-file-XXXXXXXX") if ! install -m "$mode" "$source" "$temporary" || ! mv -fT -- "$temporary" "$target"; then rm -f -- "$temporary"; return 1 fi } check_identity() { local binary=$1 keys=$2 sha=$3 item for item in "$binary" "$keys/periphery.key" "$keys/periphery.pub" "$keys/core.pub"; do protected "$item" || return 1; done if [[ -e "$binary" ]]; then printf '%s %s\n' "$sha" "$binary" | sha256sum -c - >/dev/null || return 1; fi if [[ -e "$keys/periphery.key" || -e "$keys/periphery.pub" ]]; then [[ -s "$keys/periphery.key" && -s "$keys/periphery.pub" ]] || { fail 'Incomplete identity; restore the original key pair'; return 1; } local private_mode=$(stat -c %a "$keys/periphery.key") [[ "$private_mode" == 400 || "$private_mode" == 600 ]] || { fail 'Private key must be owner-readable only (0400 or 0600)'; return 1; } verify_identity_pair "$keys" || return 1 elif [[ -e "$keys/core.pub" ]]; then fail 'Trust exists but node identity is missing'; return 1; fi } key_pem() { local file=$1 kind=$2 if head -n 1 "$file" | grep -q '^-----BEGIN '; then cat "$file"; else printf -- '-----BEGIN %s KEY-----\n' "$kind" tr -d '\r\n' < "$file" printf '\n-----END %s KEY-----\n' "$kind" fi } public_key_base64() { local source=$1 temp result command -v openssl >/dev/null || return 1 temp=$(mktemp) || return 1 chmod 600 "$temp" if ! key_pem "$source" PUBLIC > "$temp"; then rm -f -- "$temp" return 1 fi if ! result=$(openssl pkey -pubin -in "$temp" -pubout -outform DER 2>/dev/null | openssl base64 -A); then rm -f -- "$temp" return 1 fi rm -f -- "$temp" printf '%s' "$result" } validate_core_key() { local temp canonical command -v openssl >/dev/null || { fail 'OpenSSL is required to validate the Core public key'; return 1; } temp=$(mktemp) || { fail 'Cannot create a temporary file for Core key validation'; return 1; } chmod 600 "$temp" printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$core_key" > "$temp" if ! canonical=$(openssl pkey -pubin -in "$temp" -pubout -outform DER 2>/dev/null | openssl base64 -A); then rm -f -- "$temp" fail 'Core public key is not a valid X25519 SPKI key'; return 1 fi rm -f -- "$temp" [[ "$canonical" == "$core_key" ]] || { fail 'Core public key is not canonical X25519 SPKI Base64'; return 1; } } check_core_trust() { [[ ! -e "$1" ]] && return 0 command -v openssl >/dev/null || { fail 'OpenSSL is required to verify the existing Core public key'; return 1; } [[ $(public_key_base64 "$1") == "$2" ]] || { fail 'Existing Core public key differs'; return 1; } } verify_identity_pair() { local keys=$1 expected computed temp_dir [[ -s "$keys/periphery.key" && -s "$keys/periphery.pub" ]] || { fail 'Incomplete Periphery identity'; return 1; } [[ $(head -n 1 "$keys/periphery.key") == '-----BEGIN PRIVATE KEY-----' && $(tail -n 1 "$keys/periphery.key") == '-----END PRIVATE KEY-----' && $(head -n 1 "$keys/periphery.pub") == '-----BEGIN PUBLIC KEY-----' && $(tail -n 1 "$keys/periphery.pub") == '-----END PUBLIC KEY-----' ]] || { fail 'Periphery identity must use the new PEM file format'; return 1; } command -v openssl >/dev/null || { fail 'OpenSSL is required to verify the existing key pair'; return 1; } expected=$(public_key_base64 "$keys/periphery.pub") || { fail 'Invalid Periphery public key'; return 1; } temp_dir=$(mktemp -d) || { fail 'Cannot create a temporary directory for key verification'; return 1; } chmod 700 "$temp_dir" if ! key_pem "$keys/periphery.key" PRIVATE > "$temp_dir/private.pem" || ! key_pem "$keys/periphery.pub" PUBLIC > "$temp_dir/public.pem"; then rm -rf -- "$temp_dir" fail 'Invalid Periphery key encoding'; return 1 fi chmod 600 "$temp_dir/private.pem" "$temp_dir/public.pem" computed=$(openssl pkey -in "$temp_dir/private.pem" -pubout -outform DER 2>/dev/null | openssl base64 -A) || { rm -rf -- "$temp_dir" fail 'Invalid Periphery private key'; return 1; } rm -rf -- "$temp_dir" [[ "$computed" =~ ^MCowBQYDK2VuAyEA[A-Za-z0-9+/]{43}=$ && "$computed" == "$expected" ]] || { fail 'Periphery X25519 private/public key pair does not match'; return 1; } } check_service() { local unit=$1 unit_name=${1##*/} report status=0 key value loaded='' fragment='' dropins='' count=0 report=$(systemctl show "$unit_name" -p LoadState -p FragmentPath -p DropInPaths) || status=$? while IFS='=' read -r key value; do case "$key" in LoadState) loaded=$value; count=$((count + 1));; FragmentPath) fragment=$value; count=$((count + 1));; DropInPaths) dropins=$value; count=$((count + 1));; esac done <<< "$report" [[ "$count" == 3 && ( "$loaded" == loaded || "$loaded" == not-found ) ]] || { fail 'Cannot inspect systemd service'; return 1; } [[ "$status" == 0 || ( "$loaded" == not-found && -z "$fragment" && -z "$dropins" ) ]] || { fail 'systemctl inspection failed'; return 1; } [[ -z "$dropins" && ( -z "$fragment" || "$fragment" == "$unit" ) ]] || { fail 'Existing service override requires review'; return 1; } } main() { local node_name='' core_url='' core_key='' assets_url='' check=false while (($#)); do case "$1" in --help|-h) usage; return 0 ;; --name|--core-url|--core-public-key|--assets-url) (($# >= 2)) || { fail "Missing value: $1"; return 1; } case "$1" in --name) node_name=$2;; --core-url) core_url=$2;; --core-public-key) core_key=$2;; --assets-url) assets_url=$2;; esac shift 2 ;; --check) check=true; shift ;; *) fail "Unknown argument: $1"; return 1 ;; esac done validate_inputs [[ $EUID == 0 && $(uname -s) == Linux && $(uname -m) == x86_64 ]] || { fail 'Run as root on Linux x86_64'; return 1; } grep -Eq '^ID="?debian"?$' /etc/os-release && grep -Eq '^VERSION_ID="?13"?$' /etc/os-release || { fail 'Debian 13 required; use the separate upgrade entry for Debian 12'; return 1; } [[ -d /run/systemd/system && ! -e /.dockerenv ]] || { fail 'Native systemd host required'; return 1; } local version=2.3.4 local sha=46d50313970aea59d293bbe237648f4becea74e8abc2f30f1bb62b73456fc72e local binary=/usr/local/bin/periphery keys=/etc/komodo/keys state=/var/lib/platform-bootstrap local config=/etc/komodo/periphery.config.toml unit=/etc/systemd/system/periphery.service item for item in "$binary" "$config" "$unit" "$keys/periphery.key" "$keys/periphery.pub" "$keys/core.pub" "$state/binding" "$state/receipt.json" "$state/lock"; do protected "$item" || return 1; done for item in "$keys" "$state"; do [[ ! -e "$item" ]] || (( (8#$(stat -c %a "$item") & 0077) == 0 )) || { fail "Private directory must be 0700: $item"; return 1; } done check_identity "$binary" "$keys" "$sha" check_service "$unit" umask 077 local task_dir cleanup task_dir=$(mktemp -d) printf -v cleanup 'rm -rf -- %q' "$task_dir"; trap "$cleanup" EXIT core_url=${core_url%/} printf '%s\n%s\n%s\n%s\n' "$node_name" "$core_url" "$core_key" "$sha" > "$task_dir/binding" printf -- '-----BEGIN PUBLIC KEY-----\n%s\n-----END PUBLIC KEY-----\n' "$core_key" > "$task_dir/core.pub" cat > "$task_dir/config" < "$task_dir/unit" <<'EOF' [Unit] Description=Komodo native Periphery Wants=network-online.target After=network-online.target [Service] User=root Environment=HOME=/root UMask=0077 ExecStart=/usr/local/bin/periphery --config-path /etc/komodo/periphery.config.toml Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target EOF check_core_trust "$keys/core.pub" "$core_key" for item in "binding:$state/binding" "config:$config" "unit:$unit"; do [[ ! -e "${item#*:}" ]] || cmp -s "$task_dir/${item%%:*}" "${item#*:}" || { fail "Existing identity/configuration differs: ${item#*:}"; return 1; } done if "$check"; then printf 'Local checks passed. Expected Core version: %s. No installation; Core connection not checked.\n' "$version" rm -rf -- "$task_dir"; trap - EXIT; return 0 fi install -d -m 0700 "$state" "$keys" exec 9>>"$state/lock" flock -n 9 || { fail 'Another installation is running'; return 1; } check_identity "$binary" "$keys" "$sha" check_core_trust "$keys/core.pub" "$core_key" check_service "$unit" if ! command -v curl >/dev/null || ! command -v jq >/dev/null || ! command -v git >/dev/null || ! command -v openssl >/dev/null || [[ ! -s /etc/ssl/certs/ca-certificates.crt ]]; then apt-get -o DPkg::Lock::Timeout=60 update apt-get -o DPkg::Lock::Timeout=60 install -y --no-install-recommends ca-certificates curl jq git openssl fi validate_core_key write_new "$state/binding" "$task_dir/binding" 0600 if [[ ! -e "$binary" ]]; then curl --proto '=https' --proto-redir '=https' -fSL --retry 2 --connect-timeout 15 --max-time 300 "$assets_url/vendor/komodo/$version/periphery-x86_64" -o "$task_dir/periphery" printf '%s %s\n' "$sha" "$task_dir/periphery" | sha256sum -c - atomic_install "$task_dir/periphery" "$binary" 0755 fi if [[ ! -e "$keys/periphery.key" ]]; then if ! "$binary" key generate --format json > "$task_dir/pair.json" 2> "$task_dir/key-error"; then fail 'Native identity generation failed; output suppressed'; return 1 fi jq -er '.private_key | select(type == "string" and length > 0)' "$task_dir/pair.json" > "$task_dir/private.raw" jq -er '.public_key | select(type == "string" and length > 0)' "$task_dir/pair.json" > "$task_dir/public.raw" key_pem "$task_dir/private.raw" PRIVATE > "$task_dir/private" key_pem "$task_dir/public.raw" PUBLIC > "$task_dir/public" atomic_install "$task_dir/private" "$keys/periphery.key" 0600 atomic_install "$task_dir/public" "$keys/periphery.pub" 0600 fi verify_identity_pair "$keys" local changed=false [[ -e "$config" && -e "$unit" && -e "$keys/core.pub" ]] || changed=true atomic_install "$task_dir/core.pub" "$keys/core.pub" 0600 write_new "$config" "$task_dir/config" 0600 write_new "$unit" "$task_dir/unit" 0644 systemctl daemon-reload systemctl enable periphery.service if "$changed" || ! systemctl is-active --quiet periphery.service; then systemctl restart periphery.service; fi sleep 2 systemctl is-active --quiet periphery.service jq -n --arg hostname "$node_name" --arg core_url "$core_url" --arg version "$version" --arg key "$(public_key_base64 "$keys/periphery.pub")" \ '{hostname:$hostname,core_url:$core_url,periphery_version:$version,periphery_public_key:($key|rtrimstr("\n")),native_service:"active",core_registration:"not_verified",initialization:"not_started"}' > "$task_dir/receipt.json" atomic_install "$task_dir/receipt.json" "$state/receipt.json" 0600 cat "$state/receipt.json" printf 'Return this public receipt to the controller to register the exact Server key.\n' rm -rf -- "$task_dir"; trap - EXIT } # Calling main only after the full function is received also supports curl | bash. if [[ ${BASH_SOURCE[0]:-} == "$0" || -z ${BASH_SOURCE[0]:-} ]]; then main "$@"; fi