From 2bf3e1c20b1cc7685744202b99a6c9d4d4daa37b Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:13:54 +0700 Subject: [PATCH 1/8] fix mesh state permissions for Control --- deploy/ochenstarik-server-monitor-manager.sh | 30 ++++++--- deploy/ochenstarik-smm-control.service | 2 +- tests/bootstrap/test-bootstrap-contract.sh | 14 ++++- .../bootstrap/test-mesh-state-permissions.sh | 61 +++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/bootstrap/test-mesh-state-permissions.sh diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index 4e25e61..af1e6f7 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -574,6 +574,7 @@ prepare_control_state() { chown -R "$CONTROL_USER:$CONTROL_USER" "$STATE_DIR/control" find "$STATE_DIR/control" -type d -exec chmod 0700 {} + find "$STATE_DIR/control" -type f -exec chmod 0600 {} + + repair_mesh_state_permissions } reverse_control_state_migration() { @@ -682,6 +683,22 @@ read_mesh_value() { awk -F '=' -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$ETC_DIR/mesh.env" } +ensure_mesh_state() { + install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" + touch "$MESH_DIR/nodes.tsv" + chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" + chmod 0660 "$MESH_DIR/nodes.tsv" +} + +repair_mesh_state_permissions() { + [[ -d "$MESH_DIR" ]] || return 0 + install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" + if [[ -e "$MESH_DIR/nodes.tsv" ]]; then + chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" + chmod 0660 "$MESH_DIR/nodes.tsv" + fi +} + render_hub_wireguard_config() { local private_key endpoint port node_id address public_key status private_key="$(cat "$WG_DIR/hub.key")" @@ -719,8 +736,10 @@ mesh_init() { fi [[ "$public_endpoint" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] \ || fail "Invalid WireGuard public endpoint." + ensure_system_user "$CONTROL_USER" ensure_mesh_packages - install -d -m 0700 "$WG_DIR" "$MESH_DIR" /etc/wireguard + install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard + ensure_mesh_state if [[ ! -f "$WG_DIR/hub.key" ]]; then umask 077 wg genkey >"$WG_DIR/hub.key" @@ -736,8 +755,6 @@ HUB_PUBLIC_KEY=$hub_public MESH_NETWORK=$MESH_NETWORK EOF chmod 0644 "$ETC_DIR/mesh.env" - touch "$MESH_DIR/nodes.tsv" - chmod 0600 "$MESH_DIR/nodes.tsv" printf '%s\n' 'net.ipv4.ip_forward=1' >"/etc/sysctl.d/90-ochenstarik-smm-mesh.conf" sysctl --system >/dev/null write_mesh_firewall @@ -761,9 +778,7 @@ EOF reserve_node_address() { local node_id="$1" existing host address - install -d -m 0700 "$MESH_DIR" - touch "$MESH_DIR/nodes.tsv" - chmod 0600 "$MESH_DIR/nodes.tsv" + ensure_mesh_state existing="$(awk -F '\t' -v node="$node_id" '$1 == node { print $2; exit }' "$MESH_DIR/nodes.tsv")" if [[ -n "$existing" ]]; then printf '%s\n' "$existing" @@ -1359,7 +1374,8 @@ add_mesh_peer() { awk -F '\t' -v OFS='\t' -v node="$node_id" -v address="$address" -v key="$public_key" \ '$1 == node { print node, address, key, "active"; found=1; next } { print } END { if (!found) exit 1 }' \ "$MESH_DIR/nodes.tsv" >"$tmp" || { rm -f -- "$tmp"; fail "Peer reservation is missing."; } - chmod 0600 "$tmp" + chown root:"$CONTROL_USER" "$tmp" + chmod 0660 "$tmp" mv -- "$tmp" "$MESH_DIR/nodes.tsv" render_hub_wireguard_config systemctl restart wg-quick@smm0.service diff --git a/deploy/ochenstarik-smm-control.service b/deploy/ochenstarik-smm-control.service index 82937fb..9d5e1a2 100644 --- a/deploy/ochenstarik-smm-control.service +++ b/deploy/ochenstarik-smm-control.service @@ -23,7 +23,7 @@ LockPersonality=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 UMask=0077 -ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control +ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control /var/lib/ochenstarik-server-monitor-manager/mesh [Install] WantedBy=multi-user.target diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 2b7405f..2a751c7 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -527,7 +527,18 @@ rm -rf "$role_fixture/lib/agent" rm -rf -- "$role_fixture" grep -Fq 'UMask=0077' "$root/deploy/ochenstarik-smm-control.service" -grep -Fq 'ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control' "$root/deploy/ochenstarik-smm-control.service" +grep -Fq 'ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control /var/lib/ochenstarik-server-monitor-manager/mesh' "$root/deploy/ochenstarik-smm-control.service" +grep -Fq 'install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR"' "$bootstrap" +grep -Fq 'chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv"' "$bootstrap" +grep -Fq 'chmod 0660 "$MESH_DIR/nodes.tsv"' "$bootstrap" +grep -Fq 'install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard' "$bootstrap" +mesh_init_definition="$(extract_bootstrap_function mesh_init)" +grep -Fq ' ensure_system_user "$CONTROL_USER"' <<<"$mesh_init_definition" +[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 2 ]] +grep -Fq ' repair_mesh_state_permissions' <<<"$prepare_control_state_definition" +if [[ "$(uname -s)" != MINGW* ]] && command -v sudo >/dev/null 2>&1; then + bash "$root/tests/bootstrap/test-mesh-state-permissions.sh" +fi native_smoke="$root/tests/bootstrap/run-native-systemd-smoke.sh" grep -Fq 'node_code="$(sudo "$system_bootstrap" node-code smoke-node)"' "$native_smoke" grep -Fq 'export SMM_ENROLL_CODE="$node_code"' "$native_smoke" @@ -870,4 +881,3 @@ grep -Fq 'record_installed_version agent' "$bootstrap" || { } printf '%s\n' "BOOTSTRAP_CONTRACT=PASS" - diff --git a/tests/bootstrap/test-mesh-state-permissions.sh b/tests/bootstrap/test-mesh-state-permissions.sh new file mode 100644 index 0000000..b467cf7 --- /dev/null +++ b/tests/bootstrap/test-mesh-state-permissions.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +bootstrap="$root/deploy/ochenstarik-server-monitor-manager.sh" +fixture="$(mktemp -d -t smm-mesh-permissions.XXXXXXXX)" +test_user="$(id -un)" +test_group="$(id -gn)" +mesh_dir="$fixture/state/mesh" +wg_dir="$fixture/etc/wireguard" + +cleanup() { + sudo rm -rf -- "$fixture" +} +trap cleanup EXIT + +extract_function() { + local name="$1" + awk -v name="$name" ' + $0 == name "() {" { capture=1 } + capture { print } + capture && $0 == "}" { exit } + ' "$bootstrap" +} + +ensure_definition="$(extract_function ensure_mesh_state)" +repair_definition="$(extract_function repair_mesh_state_permissions)" +runner="$fixture/apply-permissions.sh" +{ + printf '%s\n%s\n' "$ensure_definition" "$repair_definition" + printf '%s\n' 'ensure_mesh_state' +} >"$runner" + +sudo env MESH_DIR="$mesh_dir" CONTROL_USER="$test_group" bash "$runner" +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir")" == "770:root:$test_group" ]] +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir/nodes.tsv")" == "660:root:$test_group" ]] + +printf '%s\n' $'fixture-node\t10.77.0.2\t-\treserved' >>"$mesh_dir/nodes.tsv" +grep -Fq 'fixture-node' "$mesh_dir/nodes.tsv" + +sudo install -d -m 0700 -o root -g root "$wg_dir" +printf '%s\n' 'private-hub-key' | sudo tee "$wg_dir/hub.key" >/dev/null +sudo chown root:root "$wg_dir/hub.key" +sudo chmod 0600 "$wg_dir/hub.key" +if sudo -u "$test_user" test -r "$wg_dir/hub.key"; then + printf '%s\n' 'Control-equivalent user can read the Hub private key' >&2 + exit 1 +fi + +sudo chmod 0700 "$mesh_dir" +sudo chmod 0600 "$mesh_dir/nodes.tsv" +{ + printf '%s\n%s\n' "$ensure_definition" "$repair_definition" + printf '%s\n' 'repair_mesh_state_permissions' +} >"$runner" +sudo env MESH_DIR="$mesh_dir" CONTROL_USER="$test_group" bash "$runner" +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir")" == "770:root:$test_group" ]] +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir/nodes.tsv")" == "660:root:$test_group" ]] + +printf '%s\n' 'MESH_STATE_PERMISSIONS=PASS' From c8149e3c169a6fb5f151c79abbf3dc5fcf615c1f Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:17:01 +0700 Subject: [PATCH 2/8] test: extract complete mesh permission helpers --- tests/bootstrap/test-mesh-state-permissions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap/test-mesh-state-permissions.sh b/tests/bootstrap/test-mesh-state-permissions.sh index b467cf7..6c20de7 100644 --- a/tests/bootstrap/test-mesh-state-permissions.sh +++ b/tests/bootstrap/test-mesh-state-permissions.sh @@ -19,8 +19,8 @@ extract_function() { local name="$1" awk -v name="$name" ' $0 == name "() {" { capture=1 } + capture && $0 != name "() {" && /^[A-Za-z_][A-Za-z0-9_]*\(\) \{$/ { exit } capture { print } - capture && $0 == "}" { exit } ' "$bootstrap" } From d16fe7e78e98cbd195405643b44cad7a8cd23be0 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:19:31 +0700 Subject: [PATCH 3/8] test: isolate control state fixture --- tests/bootstrap/test-bootstrap-contract.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 2a751c7..a306bc8 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -235,6 +235,7 @@ printf '%s' backup >"$control_state_fixture/backups/manifest.json" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n' "$validate_control_state_migration_definition" "$prepare_control_state_definition") validate_control_state_migration prepare_control_state From b769d365222cf742d7e0c2aefae1c4bb4005e215 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:21:57 +0700 Subject: [PATCH 4/8] test: stub mesh repair in migration fixtures --- tests/bootstrap/test-bootstrap-contract.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index a306bc8..930f316 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -291,6 +291,7 @@ cp "$alpha7_fixture/etc/control.env" "$alpha7_fixture/original.env" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n%s\n%s\n' \ "$validate_control_state_migration_definition" \ "$prepare_control_state_definition" \ @@ -372,6 +373,7 @@ tar -C "$archive_root" -czf "$recovery_fixture/bootstrap-backups/alpha7.tar.gz" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n%s\n' \ "$record_control_legacy_state_definition" \ "$prepare_control_state_definition" \ From 315d7c3182949928a7d7b8c0d698ab263f7d62ab Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:25:43 +0700 Subject: [PATCH 5/8] fix: create mesh state before control starts --- deploy/ochenstarik-server-monitor-manager.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index af1e6f7..989d0c6 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -691,12 +691,7 @@ ensure_mesh_state() { } repair_mesh_state_permissions() { - [[ -d "$MESH_DIR" ]] || return 0 - install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" - if [[ -e "$MESH_DIR/nodes.tsv" ]]; then - chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" - chmod 0660 "$MESH_DIR/nodes.tsv" - fi + ensure_mesh_state } render_hub_wireguard_config() { From e04bfcdc10660ce65e466d260edecfd11c6aae2d Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:36:24 +0700 Subject: [PATCH 6/8] test: expect control mesh state initialization --- tests/bootstrap/test-bootstrap-contract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 930f316..69676e1 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -537,7 +537,7 @@ grep -Fq 'chmod 0660 "$MESH_DIR/nodes.tsv"' "$bootstrap" grep -Fq 'install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard' "$bootstrap" mesh_init_definition="$(extract_bootstrap_function mesh_init)" grep -Fq ' ensure_system_user "$CONTROL_USER"' <<<"$mesh_init_definition" -[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 2 ]] +[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 3 ]] grep -Fq ' repair_mesh_state_permissions' <<<"$prepare_control_state_definition" if [[ "$(uname -s)" != MINGW* ]] && command -v sudo >/dev/null 2>&1; then bash "$root/tests/bootstrap/test-mesh-state-permissions.sh" From 1db48b966526bac7ce3f8be2f35637365001c129 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:37:18 +0700 Subject: [PATCH 7/8] feat: add guided unified installer --- deploy/smm-setup.sh | 339 ++++++++++++++------- docs/linux-bootstrap.md | 14 +- tests/bootstrap/test-bootstrap-contract.sh | 10 + tests/bootstrap/test-unified-installer.sh | 118 +++++++ 4 files changed, 369 insertions(+), 112 deletions(-) create mode 100644 tests/bootstrap/test-unified-installer.sh diff --git a/deploy/smm-setup.sh b/deploy/smm-setup.sh index a66e61c..44431d9 100644 --- a/deploy/smm-setup.sh +++ b/deploy/smm-setup.sh @@ -10,19 +10,25 @@ readonly INNER_ASSET="ochenstarik-server-monitor-manager.sh" RELEASE_TAG="${SMM_TAG:-$DEFAULT_RELEASE_TAG}" REPOSITORY="${SMM_REPOSITORY:-$DEFAULT_REPOSITORY}" CACHE_DIR="${SMM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/server-monitor-manager}" +interactive=0 +node_code="" +node_control_url="" +node_hub_endpoint="" +node_ca_fingerprint="" usage() { cat <<'USAGE' Usage: + smm-setup.sh smm-setup.sh [--tag TAG] [--repository OWNER/REPO] COMMAND [ARG...] -Convenience installation commands: +With no arguments, an interactive terminal guides a complete Hub or Node +installation. Non-interactive commands remain available: install-hub PUBLIC_HOST [HTTPS_PORT] [WG_PORT] install-node Other commands are passed to the verified ochenstarik-server-monitor-manager.sh -asset from the selected immutable GitHub release. Use -- before a command to -force pass-through. Common bootstrap commands: +asset. Use -- before a command to force pass-through. Common commands: install-agent | install-control | uninstall-agent | uninstall-control backup-create | backup-restore | version @@ -33,159 +39,270 @@ Environment overrides: USAGE } -die() { - printf '%s: %s\n' "$PROGRAM_NAME" "$*" >&2 - exit 1 +no_tty_help() { + cat >&2 <<'HELP' +smm-setup: interactive installation requires a terminal on stdin and stdout. +For automation, choose an explicit command: + sudo ./smm-setup.sh install-hub PUBLIC_HOST [HTTPS_PORT] [WG_PORT] + sudo ./smm-setup.sh install-node +Run ./smm-setup.sh --help for pass-through commands. +HELP } -require_command() { - command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1" +die() { printf '%s: %s\n' "$PROGRAM_NAME" "$*" >&2; exit 1; } +require_command() { command -v "$1" >/dev/null 2>&1 || die "required command is unavailable: $1"; } + +validate_port() { + [[ "$1" =~ ^[0-9]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) \ + || die "invalid port: $1" } +validate_public_host() { + [[ "$1" != *:* && "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] \ + || die "invalid public address: $1" +} + +prompt_default() { + local prompt="$1" default="$2" value + read -r -p "$prompt [$default]: " value + printf '%s' "${value:-$default}" +} + +confirm() { + local prompt="$1" answer + read -r -p "$prompt [y/N]: " answer + [[ "$answer" == "y" || "$answer" == "Y" || "$answer" == "yes" || "$answer" == "YES" ]] +} + +os_value() { + local key="$1" + [[ -r /etc/os-release ]] || return 0 + sed -n "s/^${key}=//p" /etc/os-release | head -n1 | sed 's/^"//;s/"$//' +} + +detect_public_address() { + command -v curl >/dev/null 2>&1 || return 0 + curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true +} + +role_status() { + local role="$1" binary + case "$role" in + Hub) binary=/usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control ;; + Node) binary=/usr/local/lib/ochenstarik-server-monitor-manager/agent/ochenstarik-smm-agent ;; + esac + [[ -x "$binary" ]] && printf 'installed' || printf 'not installed' +} + +show_machine() { + local distro version architecture public_address + distro="$(os_value NAME)" + version="$(os_value VERSION_ID)" + architecture="$(uname -m)" + public_address="$(detect_public_address)" + printf '\nServer Monitor Manager setup\n' + printf ' System: %s %s\n' "${distro:-unknown Linux}" "${version:-unknown}" + printf ' Architecture: %s\n' "$architecture" + printf ' Public address: %s\n' "${public_address:-not detected}" + printf ' Hub: %s\n' "$(role_status Hub)" + printf ' Node: %s\n\n' "$(role_status Node)" + DETECTED_PUBLIC_ADDRESS="$public_address" +} + +install_dependencies() { + local item missing=() + for item in curl sha256sum mktemp openssl base64 install; do + command -v "$item" >/dev/null 2>&1 || missing+=("$item") + done + if (( ${#missing[@]} > 0 )); then + [[ ${EUID:-$(id -u)} -eq 0 ]] || die "run with sudo to install dependencies: ${missing[*]}" + command -v apt-get >/dev/null 2>&1 || die "install required commands manually: ${missing[*]}" + printf 'Installing required system packages...\n' + DEBIAN_FRONTEND=noninteractive apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl coreutils openssl + fi + printf 'Dependencies are ready. The verified bootstrap will provision cosign if needed.\n' +} + +base64url_decode() { + local encoded="$1" padding + [[ -n "$encoded" && "$encoded" =~ ^[A-Za-z0-9_-]+$ ]] || return 1 + encoded="${encoded//-/+}" + encoded="${encoded//_/\/}" + case $(( ${#encoded} % 4 )) in + 0) padding="" ;; 2) padding="==" ;; 3) padding="=" ;; *) return 1 ;; + esac + printf '%s%s' "$encoded" "$padding" | base64 --decode 2>/dev/null +} + +inspect_node_code() { + local code="$1" parts=() ca_file hub_key node_address mesh_network part + local control_url_pattern='^https://(\[[0-9A-Fa-f:.]+\]|[A-Za-z0-9.-]+)(:[0-9]{1,5})?/?$' + IFS='.' read -r -a parts <<<"$code" + [[ ${#parts[@]} -eq 9 && "${parts[0]}" == "SMMNODE2" ]] \ + || die "invalid SMMNODE2 code: expected exactly nine segments" + for part in "${parts[@]:1}"; do [[ -n "$part" ]] || die "invalid SMMNODE2 code: empty segment"; done + node_control_url="$(base64url_decode "${parts[1]}")" || die "invalid SMMNODE2 Control URL encoding" + node_hub_endpoint="$(base64url_decode "${parts[5]}")" || die "invalid SMMNODE2 Hub endpoint encoding" + hub_key="$(base64url_decode "${parts[6]}")" || die "invalid SMMNODE2 Hub key encoding" + node_address="$(base64url_decode "${parts[7]}")" || die "invalid SMMNODE2 node address encoding" + mesh_network="$(base64url_decode "${parts[8]}")" || die "invalid SMMNODE2 network encoding" + [[ "$node_control_url" =~ $control_url_pattern ]] || die "invalid Control URL in SMMNODE2 code" + [[ "$node_hub_endpoint" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]] || die "invalid Hub endpoint in SMMNODE2 code" + validate_port "${node_hub_endpoint##*:}" + [[ "$hub_key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || die "invalid Hub public key in SMMNODE2 code" + [[ "$node_address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])$ ]] \ + || die "invalid mesh address in SMMNODE2 code" + [[ "$mesh_network" == "10.77.0.0/24" ]] || die "invalid mesh network in SMMNODE2 code" + ca_file="$(mktemp -t smm-setup-ca.XXXXXXXX.crt)" + chmod 0600 "$ca_file" + if ! base64url_decode "${parts[2]}" >"$ca_file" \ + || ! openssl x509 -in "$ca_file" -noout >/dev/null 2>&1; then + rm -f -- "$ca_file" + die "invalid or tampered CA certificate in SMMNODE2 code" + fi + node_ca_fingerprint="$(openssl x509 -in "$ca_file" -noout -fingerprint -sha256 | cut -d= -f2)" + rm -f -- "$ca_file" +} + +choose_interactive_action() { + local role host https_port wg_port existing default_host + show_machine + read -r -p 'Choose role: 1) Hub 2) Node: ' role + case "${role,,}" in + 1|hub) + action=install-hub; existing="$(role_status Hub)" + default_host="${DETECTED_PUBLIC_ADDRESS:-hub.example.com}" + [[ -n "$default_host" ]] || default_host=hub.example.com + host="$(prompt_default 'Public IPv4 address or DNS name' "$default_host")" + https_port="$(prompt_default 'Control HTTPS port' 7443)" + wg_port="$(prompt_default 'WireGuard UDP port' 51820)" + validate_public_host "$host"; validate_port "$https_port"; validate_port "$wg_port" + action_args=("$host" "$https_port" "$wg_port") + ;; + 2|node) + action=install-node; existing="$(role_status Node)" + read -r -s -p 'Paste SMMNODE2 code: ' node_code; printf '\n' + [[ -n "$node_code" ]] || die "SMMNODE2 code is empty" + action_args=() + ;; + *) die "choose Hub (1) or Node (2)" ;; + esac + if [[ "$existing" == "installed" ]] && ! confirm "This role is already installed. Reinstall or update it?"; then + printf 'No changes were made.\n'; exit 0 + fi + install_dependencies + if [[ "$action" == install-node ]]; then + inspect_node_code "$node_code" + printf '\nNode enrollment details:\n Control: %s\n WireGuard Hub: %s\n CA SHA-256: %s\n' \ + "$node_control_url" "$node_hub_endpoint" "$node_ca_fingerprint" + confirm 'Do these Hub and CA fingerprint values match the operator application?' \ + || die "installation cancelled: Hub identity was not confirmed" + fi +} + +original_count=$# pass_through=0 while [[ $# -gt 0 ]]; do case "$1" in - --tag) - [[ $# -ge 2 ]] || die '--tag requires a value' - RELEASE_TAG="$2" - shift 2 - ;; - --repository) - [[ $# -ge 2 ]] || die '--repository requires a value' - REPOSITORY="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - --) - pass_through=1 - shift - break - ;; - -*) - die "unknown option: $1" - ;; - *) - break - ;; + --tag) [[ $# -ge 2 ]] || die '--tag requires a value'; RELEASE_TAG="$2"; shift 2 ;; + --repository) [[ $# -ge 2 ]] || die '--repository requires a value'; REPOSITORY="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + --) pass_through=1; shift; break ;; + -*) die "unknown option: $1" ;; + *) break ;; esac done -[[ $# -gt 0 ]] || { - usage >&2 - exit 2 -} -[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] \ - || die "invalid release tag: $RELEASE_TAG" -[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \ - || die "invalid repository: $REPOSITORY" +action_args=() +action="" +if (( original_count == 0 )); then + [[ -t 0 && -t 1 ]] || { no_tty_help; exit 2; } + interactive=1 + choose_interactive_action +else + [[ $# -gt 0 ]] || die "a command is required after options" + action="$1"; shift; action_args=("$@") +fi + +[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]] || die "invalid release tag: $RELEASE_TAG" +[[ "$REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die "invalid repository: $REPOSITORY" -action="$1" -shift if (( pass_through == 0 )); then case "$action" in - install-hub) - [[ $# -ge 1 && $# -le 3 ]] \ - || die "install-hub requires PUBLIC_HOST [HTTPS_PORT] [WG_PORT]" - ;; - install-node) - [[ $# -eq 0 ]] || die "install-node takes no arguments" - ;; + install-hub) [[ ${#action_args[@]} -ge 1 && ${#action_args[@]} -le 3 ]] || die "install-hub requires PUBLIC_HOST [HTTPS_PORT] [WG_PORT]" ;; + install-node) [[ ${#action_args[@]} -eq 0 ]] || die "install-node takes no arguments" ;; esac fi -require_command curl -require_command sha256sum -require_command mktemp - +require_command curl; require_command sha256sum; require_command mktemp release_base="https://github.com/$REPOSITORY/releases/download/$RELEASE_TAG" cache_release="$CACHE_DIR/$REPOSITORY/$RELEASE_TAG" cached_script="$cache_release/$INNER_ASSET" cached_checksum="$cache_release/$INNER_ASSET.sha256" mkdir -p "$cache_release" - temporary_directory="$(mktemp -d -t smm-setup.XXXXXXXX)" trap 'rm -rf -- "$temporary_directory"' EXIT -curl -fsSL "$release_base/$INNER_ASSET" -o "$temporary_directory/$INNER_ASSET" -curl -fsSL "$release_base/$INNER_ASSET.sha256" -o "$temporary_directory/$INNER_ASSET.sha256" - -( - cd "$temporary_directory" - sha256sum -c "$INNER_ASSET.sha256" >/dev/null -) || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET" +download() { + local url="$1" destination="$2" + if (( interactive == 1 )); then + printf 'Downloading %s\n' "${url##*/}" + curl -fL --progress-bar "$url" -o "$destination" + else + curl -fsSL "$url" -o "$destination" + fi +} +download "$release_base/$INNER_ASSET" "$temporary_directory/$INNER_ASSET" +download "$release_base/$INNER_ASSET.sha256" "$temporary_directory/$INNER_ASSET.sha256" +( cd "$temporary_directory"; sha256sum -c "$INNER_ASSET.sha256" >/dev/null ) \ + || die "checksum verification failed for $RELEASE_TAG/$INNER_ASSET" install -m 0755 "$temporary_directory/$INNER_ASSET" "$cached_script" install -m 0644 "$temporary_directory/$INNER_ASSET.sha256" "$cached_checksum" download_required_asset() { local asset="$1" - if ! curl -fsSL "$release_base/$asset" -o "$temporary_directory/$asset"; then - case "$asset" in - server-monitor-manager-manifest.json|server-monitor-manager-manifest.sig|server-monitor-manager-manifest.pem) - die "required signed-release asset is unavailable: $asset" - ;; - *) - die "required release asset is unavailable: $asset" - ;; - esac - fi + download "$release_base/$asset" "$temporary_directory/$asset" || die "required release asset is unavailable: $asset" } download_platform_release() { local platform archive_asset asset - case "$(uname -m)" in - x86_64) platform="linux-x64" ;; - aarch64|arm64) platform="linux-arm64" ;; - *) die "unsupported architecture: $(uname -m)" ;; - esac - + case "$(uname -m)" in x86_64) platform="linux-x64" ;; aarch64|arm64) platform="linux-arm64" ;; *) die "unsupported architecture: $(uname -m)" ;; esac archive_asset="server-monitor-manager-$platform.tar.gz" - for asset in \ - "$archive_asset" \ - "$archive_asset.sha256" \ - server-monitor-manager-manifest.json \ - server-monitor-manager-manifest.sig \ - server-monitor-manager-manifest.pem; do + for asset in "$archive_asset" "$archive_asset.sha256" server-monitor-manager-manifest.json \ + server-monitor-manager-manifest.sig server-monitor-manager-manifest.pem; do download_required_asset "$asset" done - - ( - cd "$temporary_directory" - sha256sum -c "$archive_asset.sha256" >/dev/null - ) || die "checksum verification failed for $RELEASE_TAG/$archive_asset" + ( cd "$temporary_directory"; sha256sum -c "$archive_asset.sha256" >/dev/null ) \ + || die "checksum verification failed for $RELEASE_TAG/$archive_asset" downloaded_archive="$temporary_directory/$archive_asset" } -if (( pass_through == 1 )); then - exec "$cached_script" "$action" "$@" -fi +if (( pass_through == 1 )); then exec "$cached_script" "$action" "${action_args[@]}"; fi case "$action" in install-hub) - download_platform_release - archive="$downloaded_archive" - public_host="$1" - https_port="${2:-}" - wg_port="${3:-}" - if [[ -n "$https_port" ]]; then - "$cached_script" install-control "$archive" "$public_host" "$https_port" - else - "$cached_script" install-control "$archive" "$public_host" + download_platform_release; archive="$downloaded_archive" + public_host="${action_args[0]}"; https_port="${action_args[1]:-}"; wg_port="${action_args[2]:-}" + if [[ -n "$https_port" ]]; then "$cached_script" install-control "$archive" "$public_host" "$https_port"; else "$cached_script" install-control "$archive" "$public_host"; fi + if [[ -n "$wg_port" ]]; then "$cached_script" mesh-init "$public_host" "$wg_port"; else "$cached_script" mesh-init "$public_host"; fi + if (( interactive == 1 )); then + printf '\nHub installation is complete. Insert this device registration code into the operator application:\n' + "$cached_script" control-device-code operator + printf 'Hub CA SHA-256 fingerprint (verify it in the application):\n' + "$cached_script" control-ca-fingerprint fi - if [[ -n "$wg_port" ]]; then - exec "$cached_script" mesh-init "$public_host" "$wg_port" - fi - exec "$cached_script" mesh-init "$public_host" ;; install-node) - download_platform_release - archive="$downloaded_archive" - exec "$cached_script" install-node "$archive" - ;; - *) - exec "$cached_script" "$action" "$@" + download_platform_release; archive="$downloaded_archive" + if (( interactive == 1 )); then + SMM_ENROLL_CODE="$node_code" SMM_ACCEPT_CA_FINGERPRINT=1 "$cached_script" install-node "$archive" + node_code="" + printf '\nNode installation is complete. Insert the SMMPEER1 code printed above into the operator application for this Node.\n' + else + exec "$cached_script" install-node "$archive" + fi ;; + *) exec "$cached_script" "$action" "${action_args[@]}" ;; esac diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md index ae21afc..d9dbf4d 100644 --- a/docs/linux-bootstrap.md +++ b/docs/linux-bootstrap.md @@ -15,7 +15,19 @@ sha256sum -c smm-setup.sh.sha256 chmod 700 smm-setup.sh ``` -На Hub установка Control и инициализация Mesh выполняются одной командой. Порты можно не указывать: по умолчанию используются HTTPS `7443` и WireGuard `51820`. +Для обычной установки запустите один и тот же файл без аргументов на каждой машине: + +```bash +sudo ./smm-setup.sh +``` + +Интерактивный режим показывает дистрибутив, архитектуру, определённый публичный адрес и уже установленные роли, затем предлагает выбрать Hub или Node. Для Hub он предлагает публичный адрес и порты HTTPS `7443`/WireGuard `51820`. Для Node он принимает готовый `SMMNODE2`, показывает извлечённые адрес Hub и SHA-256 fingerprint CA и продолжает только после подтверждения оператора. Загрузки сопровождаются индикатором; системные зависимости и закреплённый `cosign` устанавливаются и проверяются до использования. + +После установки Hub установщик печатает код регистрации устройства `SMMDEV1-...` и fingerprint CA — оба значения предназначены для подключения приложения оператора. После установки Node он печатает `SMMPEER1...`; вставьте этот код в карточку соответствующего Node в приложении оператора. + +Повторный запуск распознаёт установленную роль и просит явно подтвердить переустановку/обновление. Если stdin или stdout не являются терминалом, режим без аргументов завершается с подсказкой и ничего не меняет. + +Для автоматизации прежние команды сохранены. На Hub установка Control и инициализация Mesh выполняются одной командой: ```bash sudo ./smm-setup.sh install-hub hub.example.com 7443 51820 diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 2b7405f..f8dbe86 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -7,6 +7,16 @@ bootstrap="$root/deploy/ochenstarik-server-monitor-manager.sh" helper="$root/deploy/ochenstarik-smm-policy-apply" emergency="$root/deploy/ochenstarik-smm-emergency" acceptance="$root/tests/acceptance/three-server-mesh.sh" +unified_setup="$root/deploy/smm-setup.sh" +unified_test="$root/tests/bootstrap/test-unified-installer.sh" + +bash -n "$unified_setup" "$unified_test" +if command -v shellcheck >/dev/null 2>&1; then + shellcheck --severity=error "$unified_setup" "$unified_test" +fi +if [[ "$(uname -s)" != MINGW* ]]; then + bash "$unified_test" +fi grep -Fq 'listing="$(/usr/sbin/nft -a list chain' "$helper" || { printf '%s\n' "policy status probe must fail closed when nftables cannot be inspected" >&2 diff --git a/tests/bootstrap/test-unified-installer.sh b/tests/bootstrap/test-unified-installer.sh new file mode 100644 index 0000000..6a5015c --- /dev/null +++ b/tests/bootstrap/test-unified-installer.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +setup="$root/deploy/smm-setup.sh" +fixture="$(mktemp -d -t smm-unified-installer.XXXXXXXX)" +trap 'rm -rf -- "$fixture"' EXIT +mkdir -p "$fixture/bin" "$fixture/release" "$fixture/cache" + +cat >"$fixture/release/ochenstarik-server-monitor-manager.sh" <<'INNER' +#!/usr/bin/env bash +set -Eeuo pipefail +case "$1" in + install-control) printf 'FAKE_INSTALL_CONTROL=%s,%s\n' "$3" "$4" ;; + mesh-init) printf 'FAKE_MESH_INIT=%s,%s\n' "$2" "$3" ;; + control-device-code) [[ "$2" == operator ]]; printf '%s\n' 'SMMDEV1-test-device-code' ;; + control-ca-fingerprint) printf '%s\n' 'AA:BB:CC:DD' ;; + install-node) + [[ -n "${SMM_ENROLL_CODE:-}" && "${SMM_ACCEPT_CA_FINGERPRINT:-}" == 1 ]] + printf '%s\n' 'SMMPEER1.test-node.test-address.test-key' + ;; + *) printf 'PASSTHROUGH=%s\n' "$*" ;; +esac +INNER +chmod 0755 "$fixture/release/ochenstarik-server-monitor-manager.sh" +( + cd "$fixture/release" + sha256sum ochenstarik-server-monitor-manager.sh >ochenstarik-server-monitor-manager.sh.sha256 + printf '%s' archive >server-monitor-manager-linux-x64.tar.gz + sha256sum server-monitor-manager-linux-x64.tar.gz >server-monitor-manager-linux-x64.tar.gz.sha256 + printf '%s' manifest >server-monitor-manager-manifest.json + printf '%s' signature >server-monitor-manager-manifest.sig + printf '%s' certificate >server-monitor-manager-manifest.pem +) + +cat >"$fixture/bin/curl" <<'CURL' +#!/usr/bin/env bash +set -Eeuo pipefail +destination="" +url="" +while (( $# > 0 )); do + case "$1" in + -o) destination="$2"; shift 2 ;; + http*) url="$1"; shift ;; + *) shift ;; + esac +done +if [[ "$url" == https://api.ipify.org ]]; then + printf '%s' '203.0.113.10' +elif [[ -n "$destination" ]]; then + cp "$FIXTURE_RELEASE/${url##*/}" "$destination" +else + exit 2 +fi +CURL +chmod 0755 "$fixture/bin/curl" + +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj /CN=fixture-ca \ + -keyout "$fixture/ca.key" -out "$fixture/ca.crt" >/dev/null 2>&1 + +b64url() { base64 -w 0 | tr '+/' '-_' | tr -d '='; } +control_part="$(printf '%s' 'https://hub.example:7443' | b64url)" +ca_part="$(b64url <"$fixture/ca.crt")" +node_part="$(printf '%s' 'fixture-node' | b64url)" +token_part="$(printf '%s' 'fixture-token' | b64url)" +endpoint_part="$(printf '%s' 'hub.example:51820' | b64url)" +hub_key_part="$(printf '%s' 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' | b64url)" +address_part="$(printf '%s' '10.77.0.2' | b64url)" +network_part="$(printf '%s' '10.77.0.0/24' | b64url)" +valid_code="SMMNODE2.$control_part.$ca_part.$node_part.$token_part.$endpoint_part.$hub_key_part.$address_part.$network_part" + +run_tty() { + local input="$1" output="$2" + if ! printf '%b' "$input" | script -qefc \ + "env PATH='$fixture/bin:/usr/bin:/bin' FIXTURE_RELEASE='$fixture/release' SMM_CACHE_DIR='$fixture/cache' bash '$setup'" \ + /dev/null >"$output" 2>&1; then + return 1 + fi +} + +if bash "$setup" "$fixture/no-tty.out" 2>&1; then + printf '%s\n' 'no-tty interactive invocation unexpectedly succeeded' >&2 + exit 1 +fi +grep -Fq 'interactive installation requires a terminal' "$fixture/no-tty.out" +grep -Fq 'install-hub PUBLIC_HOST' "$fixture/no-tty.out" + +run_tty $'1\n\n\n\n' "$fixture/hub.out" +grep -Fq 'System:' "$fixture/hub.out" +grep -Fq 'Public address: 203.0.113.10' "$fixture/hub.out" +grep -Fq 'FAKE_INSTALL_CONTROL=203.0.113.10,7443' "$fixture/hub.out" +grep -Fq 'FAKE_MESH_INIT=203.0.113.10,51820' "$fixture/hub.out" +grep -Fq 'SMMDEV1-test-device-code' "$fixture/hub.out" +grep -Fq 'AA:BB:CC:DD' "$fixture/hub.out" + +run_tty "2\n$valid_code\ny\n" "$fixture/node.out" +grep -Fq 'Control: https://hub.example:7443' "$fixture/node.out" +grep -Fq 'WireGuard Hub: hub.example:51820' "$fixture/node.out" +grep -Fq 'CA SHA-256:' "$fixture/node.out" +grep -Fq 'SMMPEER1.test-node.test-address.test-key' "$fixture/node.out" +grep -Fq 'operator application' "$fixture/node.out" + +tampered_ca_part="$(printf '%s' 'not-a-certificate' | b64url)" +tampered_code="SMMNODE2.$control_part.$tampered_ca_part.$node_part.$token_part.$endpoint_part.$hub_key_part.$address_part.$network_part" +for bad_code in "${valid_code%.*}" "$valid_code.extra" "$tampered_code"; do + if run_tty "2\n$bad_code\n" "$fixture/bad.out"; then + printf '%s\n' 'corrupt SMMNODE2 code unexpectedly succeeded' >&2 + exit 1 + fi + grep -Eq 'invalid|tampered' "$fixture/bad.out" +done + +PATH="$fixture/bin:$PATH" FIXTURE_RELEASE="$fixture/release" SMM_CACHE_DIR="$fixture/cache" \ + bash "$setup" version >"$fixture/noninteractive.out" +grep -Fq 'PASSTHROUGH=version' "$fixture/noninteractive.out" + +printf '%s\n' 'UNIFIED_INSTALLER=PASS' From 783b50004677dbf099952b5e732774b385971f46 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:38:31 +0700 Subject: [PATCH 8/8] fix: preserve signed asset diagnostics --- deploy/smm-setup.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/deploy/smm-setup.sh b/deploy/smm-setup.sh index 44431d9..ec6c481 100644 --- a/deploy/smm-setup.sh +++ b/deploy/smm-setup.sh @@ -263,7 +263,14 @@ install -m 0644 "$temporary_directory/$INNER_ASSET.sha256" "$cached_checksum" download_required_asset() { local asset="$1" - download "$release_base/$asset" "$temporary_directory/$asset" || die "required release asset is unavailable: $asset" + if ! download "$release_base/$asset" "$temporary_directory/$asset"; then + case "$asset" in + server-monitor-manager-manifest.json|server-monitor-manager-manifest.sig|server-monitor-manager-manifest.pem) + die "required signed-release asset is unavailable: $asset" + ;; + *) die "required release asset is unavailable: $asset" ;; + esac + fi } download_platform_release() {