298 lines
15 KiB
Bash
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
|