diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index 3b81bd2..5ab5706 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -85,6 +85,7 @@ jobs: 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 0644 deploy/ochenstarik-smm-firewall.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" diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index 3e63eb0..5f0f307 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -14,8 +14,14 @@ 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" +readonly MESH_DIR="${STATE_DIR}/mesh" +readonly WG_DIR="${ETC_DIR}/wireguard" +readonly FIREWALL_UNIT="ochenstarik-smm-firewall.service" +readonly MESH_NETWORK="10.77.0.0/24" +readonly HUB_MESH_ADDRESS="10.77.0.1/24" TEMP_DIR="" +MESH_PEER_CODE="" log() { printf '%s\n' "[$PROGRAM] $*"; } fail() { printf '%s\n' "[$PROGRAM] ERROR: $*" >&2; exit 1; } @@ -37,6 +43,9 @@ Usage: 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 install-node ARCHIVE + ochenstarik-server-monitor-manager.sh mesh-init PUBLIC_ENDPOINT [WG_PORT] + ochenstarik-server-monitor-manager.sh peer-add SMMPEER1_CODE + ochenstarik-server-monitor-manager.sh mesh-status 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] @@ -142,6 +151,7 @@ extract_archive() { 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." + [[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall systemd unit is missing from archive." } verify_release_payload() { @@ -152,6 +162,7 @@ verify_release_payload() { [[ -x "$TEMP_DIR/control/ochenstarik-smm-control" ]] || fail "Control binary is missing." [[ -x "$TEMP_DIR/agent/ochenstarik-smm-agent" ]] || fail "Agent binary is missing." [[ -x "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" ]] || fail "Policy helper is missing." + [[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall unit is missing." [[ -x "$TEMP_DIR/bootstrap/ochenstarik-server-monitor-manager.sh" ]] || fail "Packaged bootstrap is missing." log "Release archive and checksum are valid." } @@ -166,6 +177,19 @@ ensure_system_user() { fi } +ensure_mesh_packages() { + local missing=0 command_name + for command_name in wg wg-quick nft ip; do + command -v "$command_name" >/dev/null 2>&1 || missing=1 + done + (( missing == 0 )) && return + require_command apt-get + log "Installing WireGuard/nftables dependencies." + DEBIAN_FRONTEND=noninteractive apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + wireguard-tools nftables iproute2 +} + install_tree_atomic() { local source="$1" destination="$2" owner="$3" staging [[ -d "$source" ]] || fail "Release payload is missing: $source" @@ -227,6 +251,163 @@ install_unit() { systemctl daemon-reload } +write_mesh_firewall() { + cat >"$ETC_DIR/mesh.nft" <<'EOF' +table inet ochenstarik_smm { + chain links { + ct state established,related accept + counter drop + } + + chain mesh_forward { + type filter hook forward priority filter; policy accept; + iifname "smm0" oifname "smm0" jump links + } + } +EOF + chmod 0644 "$ETC_DIR/mesh.nft" + if ! nft list table inet ochenstarik_smm >/dev/null 2>&1; then + nft --check -f "$ETC_DIR/mesh.nft" + fi +} + +read_mesh_value() { + local key="$1" + [[ -r "$ETC_DIR/mesh.env" ]] || fail "Mesh Hub is not initialized." + awk -F '=' -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$ETC_DIR/mesh.env" +} + +render_hub_wireguard_config() { + local private_key endpoint port node_id address public_key status + private_key="$(cat "$WG_DIR/hub.key")" + endpoint="$(read_mesh_value HUB_ENDPOINT)" + port="${endpoint##*:}" + cat >"/etc/wireguard/smm0.conf" <>"/etc/wireguard/smm0.conf" <"$WG_DIR/hub.key" + fi + hub_private="$(cat "$WG_DIR/hub.key")" + hub_public="$(printf '%s' "$hub_private" | wg pubkey)" + printf '%s\n' "$hub_public" >"$WG_DIR/hub.pub" + chmod 0600 "$WG_DIR/hub.key" + chmod 0644 "$WG_DIR/hub.pub" + cat >"$ETC_DIR/mesh.env" <"/etc/sysctl.d/90-ochenstarik-smm-mesh.conf" + sysctl --system >/dev/null + write_mesh_firewall + [[ -f "$LIB_DIR/control/ochenstarik-smm-control" ]] \ + || log "Warning: Control is not installed yet; mesh peer codes require Control enrollment." + if [[ -f "${TEMP_DIR:-}/deploy/$FIREWALL_UNIT" ]]; then + install_unit "$TEMP_DIR/deploy/$FIREWALL_UNIT" "$FIREWALL_UNIT" + elif [[ -f "$LIB_DIR/bootstrap/$FIREWALL_UNIT" ]]; then + install_unit "$LIB_DIR/bootstrap/$FIREWALL_UNIT" "$FIREWALL_UNIT" + else + fail "Mesh firewall systemd unit is unavailable; reinstall Control from the current release." + fi + systemctl enable "$FIREWALL_UNIT" + systemctl restart "$FIREWALL_UNIT" + render_hub_wireguard_config + systemctl enable wg-quick@smm0.service + systemctl restart wg-quick@smm0.service + log "Mesh Hub initialized at $public_endpoint:$port with $MESH_NETWORK." + log "WireGuard public key: $hub_public" +} + +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" + 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" + return + fi + for host in $(seq 2 254); do + address="10.77.0.$host" + if ! awk -F '\t' -v address="$address" '$2 == address { found=1 } END { exit found ? 0 : 1 }' "$MESH_DIR/nodes.tsv"; then + printf '%s\t%s\t-\treserved\n' "$node_id" "$address" >>"$MESH_DIR/nodes.tsv" + printf '%s\n' "$address" + return + fi + done + fail "Mesh address pool is exhausted." +} + +configure_node_wireguard() { + local node_id="$1" node_address="$2" hub_endpoint="$3" hub_public_key="$4" node_private node_public + ensure_mesh_packages + install -d -m 0700 "$WG_DIR" /etc/wireguard + if [[ ! -f "$WG_DIR/node.key" ]]; then + umask 077 + wg genkey >"$WG_DIR/node.key" + fi + node_private="$(cat "$WG_DIR/node.key")" + node_public="$(printf '%s' "$node_private" | wg pubkey)" + printf '%s\n' "$node_public" >"$WG_DIR/node.pub" + cat >"/etc/wireguard/smm0.conf" <"$SUDOERS_FILE" chmod 0440 "$SUDOERS_FILE" visudo -cf "$SUDOERS_FILE" >/dev/null @@ -387,7 +570,7 @@ read_enrollment_code() { return fi [[ -t 0 ]] || fail "Set SMM_ENROLL_CODE or run from an interactive local terminal." - read -r -s -p "SMMNODE1 enrollment code: " ENROLL_CODE + read -r -s -p "SMMNODE enrollment code: " ENROLL_CODE printf '\n' [[ -n "$ENROLL_CODE" ]] || fail "Enrollment code is empty." } @@ -404,20 +587,43 @@ confirm_ca_fingerprint() { } install_node_from_code() { - local archive="$1" prefix control_part ca_part node_part token_part extra - local control_url node_id token ca_file + local archive="$1" prefix control_part ca_part node_part token_part + local endpoint_part hub_key_part address_part network_part extra + local control_url node_id token ca_file hub_endpoint hub_public_key node_address mesh_network require_root require_command base64 require_command openssl read_enrollment_code - IFS='.' read -r prefix control_part ca_part node_part token_part extra <<<"$ENROLL_CODE" + IFS='.' read -r prefix control_part ca_part node_part token_part endpoint_part \ + hub_key_part address_part network_part extra <<<"$ENROLL_CODE" ENROLL_CODE="" - [[ "$prefix" == "SMMNODE1" && -n "$control_part" && -n "$ca_part" \ - && -n "$node_part" && -n "$token_part" && -z "${extra:-}" ]] \ - || fail "Invalid SMMNODE1 enrollment code." + [[ "$prefix" == "SMMNODE1" || "$prefix" == "SMMNODE2" ]] \ + || fail "Unsupported SMMNODE enrollment code version." + [[ -n "$control_part" && -n "$ca_part" && -n "$node_part" && -n "$token_part" \ + && -z "${extra:-}" ]] || fail "Invalid SMMNODE enrollment code." + if [[ "$prefix" == "SMMNODE1" ]]; then + [[ -z "${endpoint_part:-}${hub_key_part:-}${address_part:-}${network_part:-}" ]] \ + || fail "Invalid SMMNODE1 enrollment code." + else + [[ -n "${endpoint_part:-}" && -n "${hub_key_part:-}" \ + && -n "${address_part:-}" && -n "${network_part:-}" ]] \ + || fail "Invalid SMMNODE2 mesh enrollment code." + fi control_url="$(base64url_decode "$control_part")" node_id="$(base64url_decode "$node_part")" token="$(base64url_decode "$token_part")" + if [[ "$prefix" == "SMMNODE2" ]]; then + hub_endpoint="$(base64url_decode "$endpoint_part")" + hub_public_key="$(base64url_decode "$hub_key_part")" + node_address="$(base64url_decode "$address_part")" + mesh_network="$(base64url_decode "$network_part")" + [[ "$hub_endpoint" =~ ^[A-Za-z0-9.-]+:[0-9]{1,5}$ ]] || fail "Invalid Hub WireGuard endpoint." + validate_port "${hub_endpoint##*:}" + [[ "$hub_public_key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || fail "Invalid Hub WireGuard public key." + [[ "$node_address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])$ ]] \ + || fail "Invalid reserved mesh address." + [[ "$mesh_network" == "$MESH_NETWORK" ]] || fail "Unsupported mesh network." + fi ca_file="$(mktemp -t smm-control-ca.XXXXXXXX.crt)" chmod 0600 "$ca_file" base64url_decode "$ca_part" >"$ca_file" @@ -426,6 +632,12 @@ install_node_from_code() { token="" install_agent "$archive" "$node_id" "$control_url" "$ca_file" rm -f -- "$ca_file" + if [[ "$prefix" == "SMMNODE2" ]]; then + configure_node_wireguard "$node_id" "$node_address" "$hub_endpoint" "$hub_public_key" + log "Node mesh configured. Copy this public peer code back to the Hub:" + printf '%s\n' "$MESH_PEER_CODE" + MESH_PEER_CODE="" + fi } update_role() { @@ -506,7 +718,7 @@ run_control_cli() { } create_node_code() { - local node_id="$1" token control_url ca_pem + local node_id="$1" token control_url ca_pem node_address hub_endpoint hub_public_key mesh_network require_root validate_node_id "$node_id" [[ -r "$ETC_DIR/control-public-url" ]] || fail "Control public URL is missing; reinstall Control with PUBLIC_HOST." @@ -517,14 +729,83 @@ create_node_code() { token="$(run_control_cli token-create "$node_id")" [[ -n "$token" && "$token" != *$'\n'* ]] || fail "Control returned an invalid enrollment token." ca_pem="$(cat "$ETC_DIR/control-ca.crt")" - printf 'SMMNODE1.%s.%s.%s.%s\n' \ - "$(printf '%s' "$control_url" | base64url_encode)" \ - "$(printf '%s' "$ca_pem" | base64url_encode)" \ - "$(printf '%s' "$node_id" | base64url_encode)" \ - "$(printf '%s' "$token" | base64url_encode)" + if [[ -r "$ETC_DIR/mesh.env" && -r "$WG_DIR/hub.pub" ]]; then + node_address="$(reserve_node_address "$node_id")" + hub_endpoint="$(read_mesh_value HUB_ENDPOINT)" + hub_public_key="$(read_mesh_value HUB_PUBLIC_KEY)" + mesh_network="$(read_mesh_value MESH_NETWORK)" + printf 'SMMNODE2.%s.%s.%s.%s.%s.%s.%s.%s\n' \ + "$(printf '%s' "$control_url" | base64url_encode)" \ + "$(printf '%s' "$ca_pem" | base64url_encode)" \ + "$(printf '%s' "$node_id" | base64url_encode)" \ + "$(printf '%s' "$token" | base64url_encode)" \ + "$(printf '%s' "$hub_endpoint" | base64url_encode)" \ + "$(printf '%s' "$hub_public_key" | base64url_encode)" \ + "$(printf '%s' "$node_address" | base64url_encode)" \ + "$(printf '%s' "$mesh_network" | base64url_encode)" + else + printf 'SMMNODE1.%s.%s.%s.%s\n' \ + "$(printf '%s' "$control_url" | base64url_encode)" \ + "$(printf '%s' "$ca_pem" | base64url_encode)" \ + "$(printf '%s' "$node_id" | base64url_encode)" \ + "$(printf '%s' "$token" | base64url_encode)" + fi token="" } +add_mesh_peer() { + local code="$1" prefix node_part address_part key_part extra + local node_id address public_key current tmp + require_root + require_command base64 + require_command wg + [[ -r "$ETC_DIR/mesh.env" && -r "$MESH_DIR/nodes.tsv" ]] || fail "Mesh Hub is not initialized." + IFS='.' read -r prefix node_part address_part key_part extra <<<"$code" + [[ "$prefix" == "SMMPEER1" && -n "$node_part" && -n "$address_part" \ + && -n "$key_part" && -z "${extra:-}" ]] || fail "Invalid SMMPEER1 code." + node_id="$(base64url_decode "$node_part")" + address="$(base64url_decode "$address_part")" + public_key="$(base64url_decode "$key_part")" + validate_node_id "$node_id" + [[ "$address" =~ ^10\.77\.0\.([2-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-4])$ ]] \ + || fail "Invalid peer mesh address." + [[ "$public_key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || fail "Invalid peer WireGuard public key." + current="$(awk -F '\t' -v node="$node_id" '$1 == node { print $2; exit }' "$MESH_DIR/nodes.tsv")" + [[ "$current" == "$address" ]] || fail "Peer address does not match the Hub reservation." + if awk -F '\t' -v address="$address" -v node="$node_id" '$2 == address && $1 != node { found=1 } END { exit found ? 0 : 1 }' "$MESH_DIR/nodes.tsv"; then + fail "Peer mesh address is already assigned." + fi + tmp="$(mktemp -p "$MESH_DIR" nodes.tsv.XXXXXXXX)" + 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" + mv -- "$tmp" "$MESH_DIR/nodes.tsv" + render_hub_wireguard_config + systemctl restart wg-quick@smm0.service + systemctl is-active --quiet wg-quick@smm0.service || fail "WireGuard failed after peer registration." + log "Mesh peer $node_id activated at $address." +} + +show_mesh_status() { + require_root + [[ -r "$ETC_DIR/mesh.env" ]] || fail "Mesh Hub is not initialized." + printf 'endpoint: %s\n' "$(read_mesh_value HUB_ENDPOINT)" + printf 'network: %s\n' "$(read_mesh_value MESH_NETWORK)" + if [[ -r "$MESH_DIR/nodes.tsv" ]]; then + printf '%-24s %-15s %-10s %s\n' NODE ADDRESS STATUS HANDSHAKE + while IFS=$'\t' read -r node_id address public_key status; do + [[ -n "$node_id" ]] || continue + local handshake="-" + if [[ "$status" == "active" ]]; then + handshake="$(wg show smm0 latest-handshakes 2>/dev/null | awk -v key="$public_key" '$1 == key { print $2; exit }')" + [[ -n "$handshake" && "$handshake" != "0" ]] || handshake="never" + fi + printf '%-24s %-15s %-10s %s\n' "$node_id" "$address" "$status" "$handshake" + done <"$MESH_DIR/nodes.tsv" + fi +} + show_ca_fingerprint() { [[ -f "$ETC_DIR/control-ca.crt" ]] || fail "Control CA certificate is not installed." require_command openssl @@ -575,6 +856,9 @@ main() { 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 "$@" ;; install-node) [[ $# -eq 1 ]] || fail "install-node requires ARCHIVE"; install_node_from_code "$1" ;; + mesh-init) [[ $# -ge 1 && $# -le 2 ]] || fail "mesh-init requires PUBLIC_ENDPOINT [WG_PORT]"; mesh_init "$@" ;; + peer-add) [[ $# -eq 1 ]] || fail "peer-add requires SMMPEER1_CODE"; add_mesh_peer "$1" ;; + mesh-status) [[ $# -eq 0 ]] || fail "mesh-status takes no arguments"; show_mesh_status ;; 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 "$@" ;; diff --git a/deploy/ochenstarik-smm-firewall.service b/deploy/ochenstarik-smm-firewall.service new file mode 100644 index 0000000..f2d19e4 --- /dev/null +++ b/deploy/ochenstarik-smm-firewall.service @@ -0,0 +1,15 @@ +[Unit] +Description=Server Monitor Manager Mesh firewall +After=network-pre.target +Before=wg-quick@smm0.service network-online.target +Wants=network-pre.target + +[Service] +Type=oneshot +ExecStartPre=-/usr/sbin/nft delete table inet ochenstarik_smm +ExecStart=/usr/sbin/nft -f /etc/ochenstarik-server-monitor-manager/mesh.nft +ExecReload=/usr/sbin/nft -f /etc/ochenstarik-server-monitor-manager/mesh.nft +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/deploy/ochenstarik-smm-policy-apply b/deploy/ochenstarik-smm-policy-apply index 00a492b..040993e 100755 --- a/deploy/ochenstarik-smm-policy-apply +++ b/deploy/ochenstarik-smm-policy-apply @@ -2,26 +2,106 @@ set -Eeuo pipefail IFS=$'\n\t' +readonly TABLE_FAMILY="inet" +readonly TABLE_NAME="ochenstarik_smm" +readonly CHAIN_NAME="links" + 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" +testing="${SMM_POLICY_TESTING:-0}" +if [[ "$testing" != "1" ]]; then + [[ ${EUID:-$(id -u)} -eq 0 ]] || fail "root is required" + readonly STATE_FILE="/var/lib/ochenstarik-server-monitor-manager/mesh/nodes.tsv" +else + readonly STATE_FILE="${SMM_POLICY_STATE_FILE:?SMM_POLICY_STATE_FILE is required in testing mode}" fi -fail "WireGuard/nftables data-plane configuration is not installed yet" +node_pattern='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' +ipv4_pattern='^([0-9]{1,3}\.){3}[0-9]{1,3}$' + +validate_node_id() { + [[ "$1" =~ $node_pattern ]] || fail "invalid node id" +} + +lookup_node_ip() { + local node_id="$1" ip + [[ -r "$STATE_FILE" ]] || fail "mesh node state is unavailable" + ip="$(awk -F '\t' -v node="$node_id" '$1 == node { print $2; exit }' "$STATE_FILE")" + [[ "$ip" =~ $ipv4_pattern ]] || fail "node has no valid mesh address: $node_id" + printf '%s\n' "$ip" +} + +validate_rule() { + local 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 + validate_node_id "$2" + validate_node_id "$3" + [[ "$2" != "$3" ]] || fail "source and destination must differ" + [[ "$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 >= 0 && 10#$6 <= 525600 )) || fail "invalid TTL" + fi +} + +run_nft() { + if [[ "$testing" == "1" ]]; then + printf 'nft' + printf ' %q' "$@" + printf '\n' + else + /usr/sbin/nft "$@" + fi +} + +rule_exists() { + local comment="$1" + if [[ "$testing" == "1" ]]; then + return 1 + fi + /usr/sbin/nft -a list chain "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" \ + | grep -Fq "comment \"$comment\"" +} + +connect_rule() { + local source_id="$1" target_id="$2" protocol="$3" port="$4" + local source_ip target_ip comment + source_ip="$(lookup_node_ip "$source_id")" + target_ip="$(lookup_node_ip "$target_id")" + comment="smm:${source_id}:${target_id}:${protocol}:${port}" + rule_exists "$comment" && return 0 + run_nft add rule "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" \ + ip saddr "$source_ip" ip daddr "$target_ip" "$protocol" dport "$port" \ + counter accept comment "$comment" +} + +disconnect_rule() { + local source_id="$1" target_id="$2" protocol="$3" port="$4" comment handle + # Resolve both identities before touching firewall state. + lookup_node_ip "$source_id" >/dev/null + lookup_node_ip "$target_id" >/dev/null + comment="smm:${source_id}:${target_id}:${protocol}:${port}" + if [[ "$testing" == "1" ]]; then + printf 'nft-delete-comment %q\n' "$comment" + return + fi + while IFS= read -r handle; do + [[ "$handle" =~ ^[0-9]+$ ]] || continue + run_nft delete rule "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" handle "$handle" + done < <( + /usr/sbin/nft -a list chain "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" \ + | grep -F "comment \"$comment\"" \ + | sed -n 's/.* # handle \([0-9][0-9]*\)$/\1/p' + ) +} + +action="${1:-}" +validate_rule "$@" +case "$action" in + link-connect) connect_rule "$2" "$3" "$4" "$5" ;; + link-disconnect) disconnect_rule "$2" "$3" "$4" "$5" ;; +esac diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md index 8b2c94c..9e32cbe 100644 --- a/docs/linux-bootstrap.md +++ b/docs/linux-bootstrap.md @@ -1,25 +1,20 @@ -# Linux bootstrap: первая тестовая версия +# Linux bootstrap и собственная Mesh-сеть -Собственный bootstrap Server Monitor Manager устанавливает Control или Agent из локального release-архива. Архив принимается только вместе с файлом `.sha256`, проверяется до распаковки и может содержать лишь каталоги `agent`, `control`, `deploy` и `bootstrap`. +Server Monitor Manager устанавливает Control (Hub) и Agent (Node) из проверяемого release-архива. Для связи серверов используется собственная WireGuard-сеть `10.77.0.0/24`; Tailscale и другие внешние VPN-сервисы не требуются. -Текущая версия предназначена для подготовки следующего alpha release. Она устанавливает Control/Agent и mTLS enrollment, но пока не настраивает WireGuard data plane. Ограниченный policy helper намеренно отклоняет Link mutations до реализации следующего этапа. +Текущая версия предназначена для alpha-тестирования на Ubuntu Server 22.04/24.04 и Debian 12/13 (`amd64`, `arm64`, systemd). Hub должен иметь публичный IPv4-адрес или DNS-имя и доступный UDP-порт. Node может находиться за NAT без белого IP. -## Поддерживаемые системы +## Файлы релиза -- Ubuntu Server 22.04/24.04; -- Debian 12/13; -- `amd64` и `arm64`; -- systemd. +Скачайте из одного GitHub Release: -## Установка Control - -Скачайте из одного release: - -- `ochenstarik-server-monitor-manager.sh` и его `.sha256`; +- `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: +Bootstrap проверяет SHA-256 до распаковки и принимает в архиве только каталоги `agent`, `control`, `deploy` и `bootstrap`. + +## 1. Установка главного сервера (Hub) ```bash sha256sum -c ochenstarik-server-monitor-manager.sh.sha256 @@ -31,40 +26,66 @@ sudo ./ochenstarik-server-monitor-manager.sh install-control \ ./server-monitor-manager-linux-x64.tar.gz \ hub.example.com \ 7443 +sudo ./ochenstarik-server-monitor-manager.sh mesh-init hub.example.com 51820 ``` -Bootstrap создаёт собственный Control CA и HTTPS certificate, системного пользователя, root-only configuration, SQLite directory и hardened systemd unit. В конце он показывает SHA-256 fingerprint CA. +Откройте на Hub и во внешнем firewall/security group: -Создайте десятиминутный самодостаточный код `SMMNODE1` для Node: +- TCP `7443` для Control HTTPS; +- UDP `51820` для WireGuard. + +`install-control` создаёт локальный Control CA и HTTPS-сертификат. Приватный ключ CA не включается в коды подключения и остаётся на Hub. `mesh-init` устанавливает `wireguard-tools`, `nftables` и `iproute2`, создаёт ключ Hub, интерфейс `smm0`, включает IPv4 forwarding и межсерверный firewall с запретом по умолчанию. + +## 2. Выпуск кода для Node ```bash sudo ./ochenstarik-server-monitor-manager.sh node-code home sudo ./ochenstarik-server-monitor-manager.sh control-ca-fingerprint ``` -Код содержит URL Control, Node ID, одноразовый token и только публичный CA certificate. Приватный CA PFX остаётся на Hub. +После `mesh-init` команда выдаёт одноразовый код `SMMNODE2`. Он содержит Control URL, публичный сертификат CA, Node ID, десятиминутный enrollment token, endpoint и публичный ключ Hub, а также зарезервированный Mesh-адрес. Обращайтесь с кодом как с временным секретом. -## Установка Agent +## 3. Установка вторичного сервера (Node) -Вставьте код `SMMNODE1` в скрытый prompt. Bootstrap покажет fingerprint CA и потребует сравнить его с Hub до создания локального Agent key и CSR. Token не сохраняется в `agent.env`: +Node может быть за NAT. Ему нужен исходящий доступ к TCP-порту Control и UDP-порту WireGuard на Hub. ```bash sudo ./ochenstarik-server-monitor-manager.sh install-node \ ./server-monitor-manager-linux-x64.tar.gz ``` -Для автоматизированного теста код и явное принятие уже сверенного fingerprint можно передать только в окружении одного процесса: +Вставьте `SMMNODE2` в скрытый prompt. Сверьте показанный SHA-256 fingerprint CA с Hub и введите `yes`. После mTLS enrollment установщик создаст локальный приватный ключ WireGuard, запустит `smm0` с `PersistentKeepalive = 25` и выведет публичный код `SMMPEER1`. + +Для автоматизированного стенда допускается передача кода только в окружении процесса после отдельной сверки fingerprint: ```bash -sudo SMM_ENROLL_CODE='SMMNODE1....' SMM_ACCEPT_CA_FINGERPRINT=1 \ +sudo SMM_ENROLL_CODE='SMMNODE2....' SMM_ACCEPT_CA_FINGERPRINT=1 \ ./ochenstarik-server-monitor-manager.sh install-node \ ./server-monitor-manager-linux-x64.tar.gz ``` -## Жизненный цикл +## 4. Активация Node на Hub + +Скопируйте выведенный Node код `SMMPEER1` на Hub: + +```bash +sudo ./ochenstarik-server-monitor-manager.sh peer-add 'SMMPEER1....' +sudo ./ochenstarik-server-monitor-manager.sh mesh-status +``` + +Hub проверяет Node ID и ранее зарезервированный IP, сохраняет публичный ключ peer и перезапускает интерфейс. Приватный ключ Node никогда не покидает Node. + +## Изоляция и управляемые соединения + +Трафик `smm0 -> smm0` по умолчанию блокируется. Control вызывает root-helper только для точных правил `source IP -> target IP`, протокола и порта. Поддерживаются команды helper `link-connect SOURCE TARGET tcp|udp PORT TTL_MINUTES` и `link-disconnect SOURCE TARGET tcp|udp PORT`. Это позволяет вручную подключать AI-агент к выбранному серверу и затем отзывать доступ, не открывая связь между всеми Node. + +В текущем alpha TTL валидируется и хранится Control, а удаление просроченных правил зависит от reconciliation Control. После перезапуска firewall разрешающие правила должны быть повторно применены Control. + +## Обслуживание ```bash sudo ./ochenstarik-server-monitor-manager.sh status +sudo ./ochenstarik-server-monitor-manager.sh mesh-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 @@ -74,8 +95,4 @@ 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, но не для сетевого объединения серверов. +Update создаёт root-only backup перед заменой binaries и автоматически восстанавливает предыдущую версию, если сервис не запускается. Перед alpha-тестом на реальных серверах обязательно сохраните отдельную консольную/SSH-сессию и не закрывайте основной административный доступ firewall-правилами проекта. diff --git a/docs/roadmap.md b/docs/roadmap.md index c0e5227..0665d4a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -97,8 +97,8 @@ - [x] добавить non-interactive install/update/rollback/uninstall; - [x] устанавливать Control/Agent, restricted helper и systemd units; - [x] локально создавать Agent key/CSR, выполнять mTLS enrollment и не сохранять token; -- [ ] добавить собственную установку WireGuard Hub/Node и выдачу внутренних адресов; -- [ ] реализовать nftables policy helper вместо временного deny-by-default helper; +- [x] добавить собственную установку WireGuard Hub/Node и выдачу внутренних адресов; +- [x] реализовать nftables policy helper вместо временного deny-by-default helper; - [ ] добавить VM CI matrix, повторную установку и reboot; - [ ] добавить локальную emergency recovery command. diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 004f7ff..7b5f5fe 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -12,6 +12,9 @@ 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 "install-node ARCHIVE" <<<"$help_output" +grep -Fq "mesh-init PUBLIC_ENDPOINT" <<<"$help_output" +grep -Fq "peer-add SMMPEER1_CODE" <<<"$help_output" +grep -Fq "mesh-status" <<<"$help_output" grep -Fq "SMM_ENROLL_TOKEN" <<<"$help_output" grep -Fq "node-code NODE_ID" <<<"$help_output" grep -Fq "verify-release ARCHIVE" <<<"$help_output" @@ -28,6 +31,18 @@ if env -u SUDO_UID -u SUDO_USER bash "$helper" link-connect source target tcp 22 exit 1 fi +policy_state="$(mktemp -t smm-policy-state.XXXXXXXX)" +printf 'source\t10.77.0.2\tkey-source\tactive\n' >"$policy_state" +printf 'target\t10.77.0.3\tkey-target\tactive\n' >>"$policy_state" +connect_output="$(SMM_POLICY_TESTING=1 SMM_POLICY_STATE_FILE="$policy_state" \ + bash "$helper" link-connect source target tcp 22 10)" +grep -Fq 'ip saddr 10.77.0.2 ip daddr 10.77.0.3 tcp dport 22' <<<"$connect_output" +grep -Fq 'smm:source:target:tcp:22' <<<"$connect_output" +disconnect_output="$(SMM_POLICY_TESTING=1 SMM_POLICY_STATE_FILE="$policy_state" \ + bash "$helper" link-disconnect source target tcp 22)" +grep -Fq 'smm:source:target:tcp:22' <<<"$disconnect_output" +rm -f -- "$policy_state" + fixture="$(mktemp -d -t smm-bootstrap-test.XXXXXXXX)" trap 'rm -rf -- "$fixture"' EXIT mkdir -p "$fixture/payload/agent" "$fixture/payload/control" "$fixture/payload/deploy" "$fixture/payload/bootstrap" @@ -36,6 +51,7 @@ install -m 0755 /bin/true "$fixture/payload/control/ochenstarik-smm-control" install -m 0755 "$helper" "$fixture/payload/deploy/ochenstarik-smm-policy-apply" install -m 0644 "$root/deploy/ochenstarik-smm-control.service" "$fixture/payload/deploy/" install -m 0644 "$root/deploy/ochenstarik-smm-agent.service" "$fixture/payload/deploy/" +install -m 0644 "$root/deploy/ochenstarik-smm-firewall.service" "$fixture/payload/deploy/" install -m 0755 "$bootstrap" "$fixture/payload/bootstrap/ochenstarik-server-monitor-manager.sh" tar -C "$fixture/payload" -czf "$fixture/release.tar.gz" agent control deploy bootstrap sha256sum "$fixture/release.tar.gz" >"$fixture/release.tar.gz.sha256"