Files
bootstrap-public/install.sh

298 lines
15 KiB
Bash

#!/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 kind=${2:-file}
[[ "$kind" == file || "$kind" == directory ]] || { fail 'Unknown installation path type'; return 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
if [[ -e "$path" ]]; then
case "$kind" in
file) [[ -f "$path" && $(stat -c %h "$path") == 1 ]] || { fail "Not a regular file: $path"; return 1; };;
directory) [[ -d "$path" ]] || { fail "Not a directory: $path"; return 1; };;
esac
fi
}
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 runtime=/var/lib/komodo item
protected "$runtime" directory
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" <<EOF
root_directory = "$runtime"
core_address = "$core_url"
connect_as = "$node_name"
core_public_keys = "file:$keys/core.pub"
private_key = "file:$keys/periphery.key"
server_enabled = false
EOF
cat > "$task_dir/unit" <<EOF
[Unit]
Description=Komodo native Periphery
Wants=network-online.target
After=network-online.target
[Service]
User=root
Environment=HOME=/root
WorkingDirectory=$runtime
UMask=0077
ExecStart=$binary --config-path $config
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
protected "$runtime" directory
install -d -m 0700 "$runtime"
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