From d9ca16b27e2ba6816fb9d86e5f44213668cca454 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Sun, 19 Jul 2026 13:15:45 +0700 Subject: [PATCH] Add standalone Linux bootstrap foundation --- .github/workflows/linux-control-agent.yml | 8 + .github/workflows/linux-release.yml | 55 ++- README.md | 2 +- deploy/ochenstarik-server-monitor-manager.sh | 486 +++++++++++++++++++ deploy/ochenstarik-smm-policy-apply | 27 ++ docs/linux-bootstrap.md | 83 ++++ tests/bootstrap/test-bootstrap-contract.sh | 28 ++ 7 files changed, 687 insertions(+), 2 deletions(-) create mode 100755 deploy/ochenstarik-server-monitor-manager.sh create mode 100755 deploy/ochenstarik-smm-policy-apply create mode 100644 docs/linux-bootstrap.md create mode 100755 tests/bootstrap/test-bootstrap-contract.sh diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml index f9b52ab..c860720 100644 --- a/.github/workflows/linux-control-agent.yml +++ b/.github/workflows/linux-control-agent.yml @@ -37,6 +37,14 @@ jobs: bash -n tests/acceptance/three-server-mesh.sh shellcheck --severity=error tests/acceptance/three-server-mesh.sh + - name: Verify standalone bootstrap + run: | + bash -n deploy/ochenstarik-server-monitor-manager.sh + bash -n deploy/ochenstarik-smm-policy-apply + shellcheck --severity=error deploy/ochenstarik-server-monitor-manager.sh + shellcheck --severity=error deploy/ochenstarik-smm-policy-apply + bash tests/bootstrap/test-bootstrap-contract.sh + - name: Publish agent amd64 run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 87475c1..3b81bd2 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -10,6 +10,54 @@ permissions: contents: write jobs: + bootstrap: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Validate bootstrap + run: | + bash -n deploy/ochenstarik-server-monitor-manager.sh + bash -n deploy/ochenstarik-smm-policy-apply + shellcheck --severity=error deploy/ochenstarik-server-monitor-manager.sh + shellcheck --severity=error deploy/ochenstarik-smm-policy-apply + bash tests/bootstrap/test-bootstrap-contract.sh + + - name: Package bootstrap + shell: bash + run: | + set -Eeuo pipefail + install -m 0755 deploy/ochenstarik-server-monitor-manager.sh ochenstarik-server-monitor-manager.sh + sha256sum ochenstarik-server-monitor-manager.sh > ochenstarik-server-monitor-manager.sh.sha256 + bootstrap_sha="$(sha256sum ochenstarik-server-monitor-manager.sh | awk '{print $1}')" + jq -n \ + --arg schema "smm-bootstrap-manifest/v1" \ + --arg version "${GITHUB_REF_NAME}" \ + --arg bootstrap "ochenstarik-server-monitor-manager.sh" \ + --arg bootstrap_sha256 "$bootstrap_sha" \ + '{schema: $schema, version: $version, bootstrap: $bootstrap, bootstrap_sha256: $bootstrap_sha256, supported_runtimes: ["linux-x64", "linux-arm64"]}' \ + > server-monitor-manager-bootstrap-manifest.json + + - name: Upload bootstrap artifact + uses: actions/upload-artifact@v6 + with: + name: server-monitor-manager-bootstrap + path: | + ochenstarik-server-monitor-manager.sh + ochenstarik-server-monitor-manager.sh.sha256 + server-monitor-manager-bootstrap-manifest.json + + - name: Attach bootstrap to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + prerelease: ${{ contains(github.ref_name, '-') }} + files: | + ochenstarik-server-monitor-manager.sh + ochenstarik-server-monitor-manager.sh.sha256 + server-monitor-manager-bootstrap-manifest.json + publish: runs-on: ubuntu-latest strategy: @@ -34,8 +82,13 @@ jobs: shell: bash run: | set -Eeuo pipefail + install -d out/deploy out/bootstrap + install -m 0644 deploy/ochenstarik-smm-control.service out/deploy/ + install -m 0644 deploy/ochenstarik-smm-agent.service out/deploy/ + install -m 0755 deploy/ochenstarik-smm-policy-apply out/deploy/ + install -m 0755 deploy/ochenstarik-server-monitor-manager.sh out/bootstrap/ archive="server-monitor-manager-${{ matrix.runtime }}.tar.gz" - tar -C out -czf "$archive" agent control + tar -C out -czf "$archive" agent control deploy bootstrap sha256sum "$archive" > "$archive.sha256" - name: Upload artifact diff --git a/README.md b/README.md index 57b626f..0099910 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The control plane and data plane are separated: See [architecture](docs/architecture.md), [security model](docs/security-model.md), [roadmap](docs/roadmap.md), [Linux bootstrap contract](docs/installer-contract.md), and the [Provisioning and Xray specification](docs/provisioning-vpn-requirements.md). -Operational procedures are documented in [Control backup and recovery](docs/control-backup.md) and the [three-server acceptance test](docs/three-server-acceptance.md). +Operational procedures are documented in [Linux bootstrap](docs/linux-bootstrap.md), [Control backup and recovery](docs/control-backup.md), and the [three-server acceptance test](docs/three-server-acceptance.md). ## Repository layout diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh new file mode 100755 index 0000000..d556f3c --- /dev/null +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -0,0 +1,486 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +readonly PROGRAM="ochenstarik-server-monitor-manager" +readonly VERSION="0.2.0-dev" +readonly ETC_DIR="/etc/ochenstarik-server-monitor-manager" +readonly LIB_DIR="/usr/local/lib/ochenstarik-server-monitor-manager" +readonly STATE_DIR="/var/lib/ochenstarik-server-monitor-manager" +readonly BACKUP_DIR="${STATE_DIR}/bootstrap-backups" +readonly CONTROL_USER="ochenstarik-smm-control" +readonly AGENT_USER="ochenstarik-smm-agent" +readonly CONTROL_UNIT="ochenstarik-smm-control.service" +readonly AGENT_UNIT="ochenstarik-smm-agent.service" +readonly POLICY_HELPER="/usr/local/libexec/ochenstarik-smm-policy-apply" +readonly SUDOERS_FILE="/etc/sudoers.d/ochenstarik-smm-control" + +TEMP_DIR="" + +log() { printf '%s\n' "[$PROGRAM] $*"; } +fail() { printf '%s\n' "[$PROGRAM] ERROR: $*" >&2; exit 1; } + +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf -- "$TEMP_DIR" + fi +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Server Monitor Manager Linux bootstrap + +Usage: + ochenstarik-server-monitor-manager.sh preflight + ochenstarik-server-monitor-manager.sh install-control ARCHIVE PUBLIC_HOST [HTTPS_PORT] + ochenstarik-server-monitor-manager.sh install-agent ARCHIVE NODE_ID CONTROL_URL CA_CERT + ochenstarik-server-monitor-manager.sh update-control ARCHIVE + ochenstarik-server-monitor-manager.sh update-agent ARCHIVE + ochenstarik-server-monitor-manager.sh rollback control|agent [BACKUP_ID] + ochenstarik-server-monitor-manager.sh node-token NODE_ID + ochenstarik-server-monitor-manager.sh control-ca-fingerprint + ochenstarik-server-monitor-manager.sh status + ochenstarik-server-monitor-manager.sh uninstall-agent [--purge] + ochenstarik-server-monitor-manager.sh uninstall-control --confirm-destroy-control + ochenstarik-server-monitor-manager.sh version + +ARCHIVE must have a matching ARCHIVE.sha256 file. Agent enrollment reads the +one-time token from SMM_ENROLL_TOKEN or from a hidden local prompt; it is never +written to agent.env. +EOF +} + +require_root() { + [[ ${EUID:-$(id -u)} -eq 0 ]] || fail "This action must run as root (use sudo)." +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command is missing: $1" +} + +validate_platform() { + [[ -r /etc/os-release ]] || fail "/etc/os-release is missing." + # shellcheck disable=SC1091 + . /etc/os-release + case "${ID:-}" in + ubuntu) + case "${VERSION_ID:-}" in 22.04|24.04) ;; *) fail "Unsupported Ubuntu version: ${VERSION_ID:-unknown}" ;; esac + ;; + debian) + case "${VERSION_ID:-}" in 12|13) ;; *) fail "Unsupported Debian version: ${VERSION_ID:-unknown}" ;; esac + ;; + *) fail "Unsupported distribution: ${ID:-unknown}" ;; + esac + case "$(uname -m)" in + x86_64|aarch64|arm64) ;; + *) fail "Unsupported architecture: $(uname -m)" ;; + esac + [[ "$(ps -p 1 -o comm=)" == "systemd" ]] || fail "systemd must be PID 1." +} + +validate_node_id() { + [[ "$1" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ + || fail "Node id must contain 1-63 lowercase letters, digits, or hyphens." +} + +validate_port() { + [[ "$1" =~ ^[0-9]+$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) \ + || fail "Port must be in range 1-65535." +} + +validate_control_url() { + [[ "$1" =~ ^https://[A-Za-z0-9._:\[\]-]+(:[0-9]{1,5})?/?$ ]] \ + || fail "Control URL must be an https URL without a path or credentials." +} + +verify_archive() { + local archive="$1" checksum_file expected actual entry + [[ -f "$archive" ]] || fail "Archive not found: $archive" + checksum_file="${archive}.sha256" + [[ -f "$checksum_file" ]] || fail "Checksum file not found: $checksum_file" + expected="$(awk 'NR == 1 { print $1 }' "$checksum_file")" + [[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || fail "Invalid checksum file: $checksum_file" + actual="$(sha256sum "$archive" | awk '{ print $1 }')" + [[ "${actual,,}" == "${expected,,}" ]] || fail "Archive checksum mismatch." + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + [[ "$entry" != /* && "$entry" != *".."* ]] || fail "Unsafe archive entry: $entry" + case "$entry" in + agent|agent/*|control|control/*|deploy|deploy/*|bootstrap|bootstrap/*) ;; + *) fail "Unexpected archive entry: $entry" ;; + esac + done < <(tar -tzf "$archive") +} + +extract_archive() { + local archive="$1" + verify_archive "$archive" + TEMP_DIR="$(mktemp -d -t smm-bootstrap.XXXXXXXX)" + chmod 700 "$TEMP_DIR" + tar -xzf "$archive" -C "$TEMP_DIR" --no-same-owner --no-same-permissions + [[ -f "$TEMP_DIR/deploy/$CONTROL_UNIT" ]] || fail "Control systemd unit is missing from archive." + [[ -f "$TEMP_DIR/deploy/$AGENT_UNIT" ]] || fail "Agent systemd unit is missing from archive." +} + +ensure_system_user() { + local user="$1" + if ! getent group "$user" >/dev/null; then + groupadd --system "$user" + fi + if ! id "$user" >/dev/null 2>&1; then + useradd --system --gid "$user" --home-dir /nonexistent --no-create-home --shell /usr/sbin/nologin "$user" + fi +} + +install_tree_atomic() { + local source="$1" destination="$2" owner="$3" staging + [[ -d "$source" ]] || fail "Release payload is missing: $source" + staging="${destination}.new.$$" + rm -rf -- "$staging" + install -d -m 0755 "$staging" + cp -a -- "$source/." "$staging/" + chown -R "$owner" "$staging" + find "$staging" -type d -exec chmod 0755 {} + + find "$staging" -type f -exec chmod 0644 {} + + find "$staging" -type f -name 'ochenstarik-smm-*' -exec chmod 0755 {} + + rm -rf -- "$destination" + mv -- "$staging" "$destination" +} + +create_backup() { + local role="$1" backup_id archive list=() + backup_id="$(date -u +%Y%m%dT%H%M%SZ)-${role}-$RANDOM" + install -d -m 0700 "$BACKUP_DIR" + archive="$BACKUP_DIR/${backup_id}.tar.gz" + case "$role" in + control) + list=( + "usr/local/lib/ochenstarik-server-monitor-manager/control" + "etc/ochenstarik-server-monitor-manager/control.env" + "etc/ochenstarik-server-monitor-manager/control-ca.pfx" + "etc/ochenstarik-server-monitor-manager/control-server.pfx" + "etc/systemd/system/$CONTROL_UNIT" + "usr/local/libexec/ochenstarik-smm-policy-apply" + "etc/sudoers.d/ochenstarik-smm-control" + ) + ;; + agent) + list=( + "usr/local/lib/ochenstarik-server-monitor-manager/agent" + "etc/ochenstarik-server-monitor-manager/agent.env" + "etc/ochenstarik-server-monitor-manager/control-ca.crt" + "etc/systemd/system/$AGENT_UNIT" + ) + ;; + *) fail "Unknown backup role: $role" ;; + esac + local existing=() item + for item in "${list[@]}"; do + [[ -e "/$item" ]] && existing+=("$item") + done + if (( ${#existing[@]} == 0 )); then + printf '%s\n' "empty" >"$BACKUP_DIR/${backup_id}.empty" + else + tar -C / -czf "$archive" -- "${existing[@]}" + chmod 0600 "$archive" + fi + printf '%s\n' "$backup_id" +} + +install_unit() { + local source="$1" unit="$2" + install -m 0644 "$source" "/etc/systemd/system/$unit" + systemctl daemon-reload +} + +create_control_certificates() { + local public_host="$1" ca_key ca_cert serial_file server_key server_csr ext_file san + ca_key="$TEMP_DIR/control-ca.key" + ca_cert="$TEMP_DIR/control-ca.crt" + serial_file="$TEMP_DIR/control-ca.srl" + server_key="$TEMP_DIR/control-server.key" + server_csr="$TEMP_DIR/control-server.csr" + ext_file="$TEMP_DIR/control-server.ext" + if [[ "$public_host" == *:* || "$public_host" =~ ^[0-9]+(\.[0-9]+){3}$ ]]; then + san="IP:$public_host" + else + [[ "$public_host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] \ + || fail "Invalid public host name." + san="DNS:$public_host" + fi + + openssl ecparam -name prime256v1 -genkey -noout -out "$ca_key" + openssl req -x509 -new -sha256 -days 3650 -key "$ca_key" -out "$ca_cert" \ + -subj "/CN=Server Monitor Manager Control CA" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + openssl ecparam -name prime256v1 -genkey -noout -out "$server_key" + openssl req -new -sha256 -key "$server_key" -out "$server_csr" \ + -subj "/CN=$public_host" + printf '%s\n' \ + "basicConstraints=critical,CA:FALSE" \ + "keyUsage=critical,digitalSignature,keyEncipherment" \ + "extendedKeyUsage=serverAuth" \ + "subjectAltName=$san" >"$ext_file" + openssl x509 -req -sha256 -days 825 -in "$server_csr" -CA "$ca_cert" -CAkey "$ca_key" \ + -CAserial "$serial_file" -CAcreateserial -out "$TEMP_DIR/control-server.crt" -extfile "$ext_file" + openssl pkcs12 -export -out "$ETC_DIR/control-ca.pfx" -inkey "$ca_key" -in "$ca_cert" -passout pass: + openssl pkcs12 -export -out "$ETC_DIR/control-server.pfx" -inkey "$server_key" \ + -in "$TEMP_DIR/control-server.crt" -certfile "$ca_cert" -passout pass: + install -m 0644 "$ca_cert" "$ETC_DIR/control-ca.crt" + chown root:"$CONTROL_USER" "$ETC_DIR/control-ca.pfx" "$ETC_DIR/control-server.pfx" + chmod 0640 "$ETC_DIR/control-ca.pfx" "$ETC_DIR/control-server.pfx" +} + +install_control() { + local archive="$1" public_host="$2" port="${3:-7443}" backup_id + require_root + validate_platform + validate_port "$port" + require_command openssl + require_command sha256sum + require_command tar + require_command systemctl + require_command sudo + require_command visudo + extract_archive "$archive" + [[ -x "$TEMP_DIR/control/ochenstarik-smm-control" ]] || fail "Control binary is missing." + backup_id="$(create_backup control)" + ensure_system_user "$CONTROL_USER" + install -d -m 0750 -o root -g "$CONTROL_USER" "$ETC_DIR" + install -d -m 0750 -o "$CONTROL_USER" -g "$CONTROL_USER" "$STATE_DIR" "$STATE_DIR/backups" + install_tree_atomic "$TEMP_DIR/control" "$LIB_DIR/control" "root:root" + if [[ ! -f "$ETC_DIR/control-ca.pfx" || ! -f "$ETC_DIR/control-server.pfx" ]]; then + create_control_certificates "$public_host" + fi + cat >"$ETC_DIR/control.env" <"$SUDOERS_FILE" + chmod 0440 "$SUDOERS_FILE" + visudo -cf "$SUDOERS_FILE" >/dev/null + install_unit "$TEMP_DIR/deploy/$CONTROL_UNIT" "$CONTROL_UNIT" + systemctl enable --now "$CONTROL_UNIT" + systemctl is-active --quiet "$CONTROL_UNIT" || { + systemctl status --no-pager "$CONTROL_UNIT" >&2 || true + fail "Control service failed; backup is $backup_id" + } + log "Control installed. Backup: $backup_id" + log "CA fingerprint: $(openssl x509 -in "$ETC_DIR/control-ca.crt" -noout -fingerprint -sha256 | cut -d= -f2)" +} + +read_enrollment_token() { + if [[ -n "${SMM_ENROLL_TOKEN:-}" ]]; then + ENROLL_TOKEN="$SMM_ENROLL_TOKEN" + unset SMM_ENROLL_TOKEN + return + fi + [[ -t 0 ]] || fail "Set SMM_ENROLL_TOKEN or run from an interactive local terminal." + read -r -s -p "One-time enrollment token: " ENROLL_TOKEN + printf '\n' + [[ -n "$ENROLL_TOKEN" ]] || fail "Enrollment token is empty." +} + +install_agent() { + local archive="$1" node_id="$2" control_url="$3" ca_cert="$4" backup_id + require_root + validate_platform + validate_node_id "$node_id" + validate_control_url "$control_url" + [[ -f "$ca_cert" ]] || fail "Control CA certificate not found: $ca_cert" + require_command sha256sum + require_command tar + require_command systemctl + require_command runuser + require_command openssl + openssl x509 -in "$ca_cert" -noout >/dev/null 2>&1 || fail "Invalid Control CA certificate." + extract_archive "$archive" + [[ -x "$TEMP_DIR/agent/ochenstarik-smm-agent" ]] || fail "Agent binary is missing." + backup_id="$(create_backup agent)" + ensure_system_user "$AGENT_USER" + install -d -m 0750 -o root -g "$AGENT_USER" "$ETC_DIR" + install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR/agent" + install_tree_atomic "$TEMP_DIR/agent" "$LIB_DIR/agent" "root:root" + if [[ "$(realpath "$ca_cert")" != "$(realpath -m "$ETC_DIR/control-ca.crt")" ]]; then + install -m 0600 -o "$AGENT_USER" -g "$AGENT_USER" "$ca_cert" "$ETC_DIR/control-ca.crt" + else + chown "$AGENT_USER:$AGENT_USER" "$ETC_DIR/control-ca.crt" + chmod 0600 "$ETC_DIR/control-ca.crt" + fi + cat >"$ETC_DIR/agent.env" <&2 || true + fail "Agent service failed; backup is $backup_id" + } + log "Agent $node_id installed and enrolled. Backup: $backup_id" +} + +update_role() { + local role="$1" archive="$2" binary unit user backup_id + require_root + validate_platform + extract_archive "$archive" + case "$role" in + control) binary="ochenstarik-smm-control"; unit="$CONTROL_UNIT"; user="root:root" ;; + agent) binary="ochenstarik-smm-agent"; unit="$AGENT_UNIT"; user="root:root" ;; + *) fail "Unknown role: $role" ;; + esac + [[ -x "$TEMP_DIR/$role/$binary" ]] || fail "$role binary is missing." + systemctl stop "$unit" + backup_id="$(create_backup "$role")" + install_tree_atomic "$TEMP_DIR/$role" "$LIB_DIR/$role" "$user" + systemctl start "$unit" + if ! systemctl is-active --quiet "$unit"; then + log "Update failed; restoring backup $backup_id" + restore_backup "$role" "$backup_id" + fail "$role update was rolled back." + fi + log "$role updated. Backup: $backup_id" +} + +latest_backup_id() { + local role="$1" path + path="$(find "$BACKUP_DIR" -maxdepth 1 -type f \( -name "*-${role}-*.tar.gz" -o -name "*-${role}-*.empty" \) -printf '%f\n' 2>/dev/null | sort | tail -n1)" + [[ -n "$path" ]] || fail "No backup found for $role." + printf '%s\n' "${path%.tar.gz}" | sed 's/\.empty$//' +} + +restore_backup() { + local role="$1" backup_id="$2" archive="$BACKUP_DIR/${backup_id}.tar.gz" unit + case "$role" in control) unit="$CONTROL_UNIT" ;; agent) unit="$AGENT_UNIT" ;; *) fail "Unknown role: $role" ;; esac + [[ -f "$archive" ]] || fail "Backup archive not found: $backup_id" + systemctl stop "$unit" || true + tar -C / -xzf "$archive" + systemctl daemon-reload + systemctl start "$unit" + systemctl is-active --quiet "$unit" || fail "Rollback restored files but service is not active." + log "$role restored from $backup_id" +} + +rollback_role() { + local role="$1" backup_id="${2:-}" + require_root + [[ -n "$backup_id" ]] || backup_id="$(latest_backup_id "$role")" + restore_backup "$role" "$backup_id" +} + +show_status() { + local unit + for unit in "$CONTROL_UNIT" "$AGENT_UNIT"; do + if systemctl list-unit-files "$unit" --no-legend 2>/dev/null | grep -q "^$unit"; then + printf '%s: %s\n' "$unit" "$(systemctl is-active "$unit" 2>/dev/null || true)" + else + printf '%s: not-installed\n' "$unit" + fi + done + if [[ -f "$ETC_DIR/control-ca.crt" ]] && command -v openssl >/dev/null; then + printf 'control-ca: %s\n' "$(openssl x509 -in "$ETC_DIR/control-ca.crt" -noout -fingerprint -sha256 | cut -d= -f2)" + fi +} + +run_control_cli() { + local command_name="$1" identifier="$2" + require_root + validate_node_id "$identifier" + [[ -x "$LIB_DIR/control/ochenstarik-smm-control" ]] || fail "Control is not installed." + [[ -f "$ETC_DIR/control.env" ]] || fail "Control environment is missing." + require_command systemd-run + systemd-run --wait --pipe --quiet --collect \ + --uid="$CONTROL_USER" \ + --gid="$CONTROL_USER" \ + -p "EnvironmentFile=$ETC_DIR/control.env" \ + "$LIB_DIR/control/ochenstarik-smm-control" "$command_name" "$identifier" +} + +show_ca_fingerprint() { + [[ -f "$ETC_DIR/control-ca.crt" ]] || fail "Control CA certificate is not installed." + require_command openssl + openssl x509 -in "$ETC_DIR/control-ca.crt" -noout -fingerprint -sha256 +} + +uninstall_agent() { + local purge="${1:-}" + require_root + systemctl disable --now "$AGENT_UNIT" 2>/dev/null || true + rm -f -- "/etc/systemd/system/$AGENT_UNIT" "$ETC_DIR/agent.env" + rm -rf -- "$LIB_DIR/agent" + [[ "$purge" == "--purge" ]] && rm -rf -- "$STATE_DIR/agent" "$ETC_DIR/control-ca.crt" + systemctl daemon-reload + log "Agent removed${purge:+ ($purge)}." +} + +uninstall_control() { + [[ "${1:-}" == "--confirm-destroy-control" ]] || fail "Control removal requires --confirm-destroy-control" + require_root + systemctl disable --now "$CONTROL_UNIT" 2>/dev/null || true + rm -f -- "/etc/systemd/system/$CONTROL_UNIT" "$ETC_DIR/control.env" \ + "$ETC_DIR/control-ca.pfx" "$ETC_DIR/control-server.pfx" "$ETC_DIR/control-ca.crt" \ + "$POLICY_HELPER" "$SUDOERS_FILE" + rm -rf -- "$LIB_DIR/control" "$STATE_DIR/control.db" "$STATE_DIR/control.db-wal" \ + "$STATE_DIR/control.db-shm" "$STATE_DIR/backups" + systemctl daemon-reload + log "Control role and its state were removed." +} + +preflight() { + validate_platform + local command_name + for command_name in openssl sha256sum tar systemctl getent useradd groupadd; do + require_command "$command_name" + done + log "Supported platform: $(. /etc/os-release; printf '%s %s' "$ID" "$VERSION_ID"), $(uname -m)" +} + +main() { + local action="${1:-help}" + shift || true + case "$action" in + help|-h|--help) usage ;; + version|--version) printf '%s %s\n' "$PROGRAM" "$VERSION" ;; + preflight) preflight ;; + install-control) [[ $# -ge 2 && $# -le 3 ]] || fail "install-control requires ARCHIVE PUBLIC_HOST [HTTPS_PORT]"; install_control "$@" ;; + install-agent) [[ $# -eq 4 ]] || fail "install-agent requires ARCHIVE NODE_ID CONTROL_URL CA_CERT"; install_agent "$@" ;; + update-control) [[ $# -eq 1 ]] || fail "update-control requires ARCHIVE"; update_role control "$1" ;; + update-agent) [[ $# -eq 1 ]] || fail "update-agent requires ARCHIVE"; update_role agent "$1" ;; + rollback) [[ $# -ge 1 && $# -le 2 ]] || fail "rollback requires control|agent [BACKUP_ID]"; rollback_role "$@" ;; + node-token) [[ $# -eq 1 ]] || fail "node-token requires NODE_ID"; run_control_cli token-create "$1" ;; + control-ca-fingerprint) [[ $# -eq 0 ]] || fail "control-ca-fingerprint takes no arguments"; show_ca_fingerprint ;; + status) show_status ;; + uninstall-agent) [[ $# -le 1 ]] || fail "uninstall-agent accepts only [--purge]"; uninstall_agent "${1:-}" ;; + uninstall-control) [[ $# -eq 1 ]] || fail "uninstall-control requires confirmation"; uninstall_control "$1" ;; + *) fail "Unknown action: $action (run with --help)" ;; + esac +} + +main "$@" diff --git a/deploy/ochenstarik-smm-policy-apply b/deploy/ochenstarik-smm-policy-apply new file mode 100755 index 0000000..00a492b --- /dev/null +++ b/deploy/ochenstarik-smm-policy-apply @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +fail() { printf '%s\n' "policy helper: $*" >&2; exit 78; } + +[[ ${EUID:-$(id -u)} -eq 0 ]] || fail "root is required" +action="${1:-}" +case "$action" in + link-connect) + [[ $# -eq 6 ]] || fail "invalid link-connect argument count" + ;; + link-disconnect) + [[ $# -eq 5 ]] || fail "invalid link-disconnect argument count" + ;; + *) fail "unsupported action" ;; +esac + +node_pattern='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' +[[ "$2" =~ $node_pattern && "$3" =~ $node_pattern ]] || fail "invalid node id" +[[ "$4" == "tcp" || "$4" == "udp" ]] || fail "invalid protocol" +[[ "$5" =~ ^[0-9]+$ ]] && (( 10#$5 >= 1 && 10#$5 <= 65535 )) || fail "invalid port" +if [[ "$action" == "link-connect" ]]; then + [[ "$6" =~ ^[0-9]+$ ]] && (( 10#$6 >= 1 && 10#$6 <= 10080 )) || fail "invalid TTL" +fi + +fail "WireGuard/nftables data-plane configuration is not installed yet" diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md new file mode 100644 index 0000000..f84795b --- /dev/null +++ b/docs/linux-bootstrap.md @@ -0,0 +1,83 @@ +# Linux bootstrap: первая тестовая версия + +Собственный bootstrap Server Monitor Manager устанавливает Control или Agent из локального release-архива. Архив принимается только вместе с файлом `.sha256`, проверяется до распаковки и может содержать лишь каталоги `agent`, `control`, `deploy` и `bootstrap`. + +Текущая версия предназначена для подготовки следующего alpha release. Она устанавливает Control/Agent и mTLS enrollment, но пока не настраивает WireGuard data plane. Ограниченный policy helper намеренно отклоняет Link mutations до реализации следующего этапа. + +## Поддерживаемые системы + +- Ubuntu Server 22.04/24.04; +- Debian 12/13; +- `amd64` и `arm64`; +- systemd. + +## Установка Control + +Скачайте из одного release: + +- `ochenstarik-server-monitor-manager.sh` и его `.sha256`; +- `server-monitor-manager-linux-x64.tar.gz` или `server-monitor-manager-linux-arm64.tar.gz`; +- соответствующий `.tar.gz.sha256`. + +Сначала проверьте bootstrap, затем установите Control. `PUBLIC_HOST` должен совпадать с DNS-именем или IP, по которому Agents обращаются к Hub: + +```bash +sha256sum -c ochenstarik-server-monitor-manager.sh.sha256 +chmod 700 ochenstarik-server-monitor-manager.sh +sudo ./ochenstarik-server-monitor-manager.sh preflight +sudo ./ochenstarik-server-monitor-manager.sh install-control \ + ./server-monitor-manager-linux-x64.tar.gz \ + hub.example.com \ + 7443 +``` + +Bootstrap создаёт собственный Control CA и HTTPS certificate, системного пользователя, root-only configuration, SQLite directory и hardened systemd unit. В конце он показывает SHA-256 fingerprint CA. + +Создайте десятиминутный token для Node: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh node-token home +sudo ./ochenstarik-server-monitor-manager.sh control-ca-fingerprint +``` + +Скопируйте на Node только публичный файл `/etc/ochenstarik-server-monitor-manager/control-ca.crt`. Приватный CA PFX остаётся на Hub. + +## Установка Agent + +`CONTROL_URL` обязан использовать то же DNS-имя/IP, которое было передано при установке Control. Token вводится скрыто в локальном терминале и не сохраняется в `agent.env`: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh install-agent \ + ./server-monitor-manager-linux-x64.tar.gz \ + home \ + https://hub.example.com:7443 \ + ./control-ca.crt +``` + +Для автоматизированного теста token можно передать только в окружении одного процесса: + +```bash +sudo SMM_ENROLL_TOKEN='one-time-token' \ + ./ochenstarik-server-monitor-manager.sh install-agent \ + ./server-monitor-manager-linux-x64.tar.gz home \ + https://hub.example.com:7443 ./control-ca.crt +``` + +## Жизненный цикл + +```bash +sudo ./ochenstarik-server-monitor-manager.sh status +sudo ./ochenstarik-server-monitor-manager.sh update-control ARCHIVE +sudo ./ochenstarik-server-monitor-manager.sh update-agent ARCHIVE +sudo ./ochenstarik-server-monitor-manager.sh rollback control +sudo ./ochenstarik-server-monitor-manager.sh rollback agent +sudo ./ochenstarik-server-monitor-manager.sh uninstall-agent +sudo ./ochenstarik-server-monitor-manager.sh uninstall-agent --purge +sudo ./ochenstarik-server-monitor-manager.sh uninstall-control --confirm-destroy-control +``` + +Update создаёт root-only backup перед заменой binaries. При неуспешном health state предыдущая версия восстанавливается. Agent uninstall без `--purge` сохраняет state; удаление Control требует явного подтверждения и удаляет его SQLite/CA state. + +## Следующий этап + +До тестирования Mesh необходимо добавить WireGuard Hub/Node setup, выдачу адресов, persistent keepalive и применение nftables Links в policy helper. До этого момента bootstrap подходит для тестирования Control, Agent, enrollment, heartbeat, update и rollback, но не для сетевого объединения серверов. diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh new file mode 100755 index 0000000..28afcae --- /dev/null +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -0,0 +1,28 @@ +#!/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" +helper="$root/deploy/ochenstarik-smm-policy-apply" + +help_output="$(bash "$bootstrap" --help)" +version_output="$(bash "$bootstrap" --version)" + +grep -Fq "install-control ARCHIVE PUBLIC_HOST" <<<"$help_output" +grep -Fq "install-agent ARCHIVE NODE_ID CONTROL_URL CA_CERT" <<<"$help_output" +grep -Fq "SMM_ENROLL_TOKEN" <<<"$help_output" +grep -Fq "node-token NODE_ID" <<<"$help_output" +grep -Eq '^ochenstarik-server-monitor-manager [0-9]+\.[0-9]+\.[0-9]+-' <<<"$version_output" + +if bash "$bootstrap" unsupported-action >/dev/null 2>&1; then + printf '%s\n' "unsupported bootstrap action unexpectedly succeeded" >&2 + exit 1 +fi + +if env -u SUDO_UID -u SUDO_USER bash "$helper" link-connect source target tcp 22 10 >/dev/null 2>&1; then + printf '%s\n' "policy helper unexpectedly applied an unconfigured rule" >&2 + exit 1 +fi + +printf '%s\n' "BOOTSTRAP_CONTRACT=PASS"