Add managed WireGuard mesh bootstrap

This commit is contained in:
Ochenstarik 2026-07-19 13:45:53 +07:00
parent db995d26a0
commit 661919be39
7 changed files with 474 additions and 61 deletions

View file

@ -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"

View file

@ -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" <<EOF
[Interface]
Address = $HUB_MESH_ADDRESS
ListenPort = $port
PrivateKey = $private_key
SaveConfig = false
EOF
if [[ -r "$MESH_DIR/nodes.tsv" ]]; then
while IFS=$'\t' read -r node_id address public_key status; do
[[ "$status" == "active" ]] || continue
cat >>"/etc/wireguard/smm0.conf" <<EOF
# Node: $node_id
[Peer]
PublicKey = $public_key
AllowedIPs = $address/32
EOF
done <"$MESH_DIR/nodes.tsv"
fi
chmod 0600 "/etc/wireguard/smm0.conf"
}
mesh_init() {
local public_endpoint="$1" port="${2:-51820}" hub_private hub_public
require_root
validate_platform
validate_port "$port"
if [[ "$public_endpoint" == *:* ]]; then
fail "Use an IPv4 address or DNS name without a port for PUBLIC_ENDPOINT."
fi
[[ "$public_endpoint" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] \
|| fail "Invalid WireGuard public endpoint."
ensure_mesh_packages
install -d -m 0700 "$WG_DIR" "$MESH_DIR" /etc/wireguard
if [[ ! -f "$WG_DIR/hub.key" ]]; then
umask 077
wg genkey >"$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" <<EOF
HUB_ENDPOINT=$public_endpoint:$port
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
[[ -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" <<EOF
[Interface]
Address = $node_address/32
PrivateKey = $node_private
SaveConfig = false
[Peer]
PublicKey = $hub_public_key
Endpoint = $hub_endpoint
AllowedIPs = $MESH_NETWORK
PersistentKeepalive = 25
EOF
chmod 0600 "$WG_DIR/node.key" /etc/wireguard/smm0.conf
chmod 0644 "$WG_DIR/node.pub"
systemctl enable wg-quick@smm0.service
systemctl restart wg-quick@smm0.service
MESH_PEER_CODE="$(printf 'SMMPEER1.%s.%s.%s' \
"$(printf '%s' "$node_id" | base64url_encode)" \
"$(printf '%s' "$node_address" | base64url_encode)" \
"$(printf '%s' "$node_public" | base64url_encode)")"
}
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"
@ -301,6 +482,8 @@ EOF
chmod 0640 "$ETC_DIR/control.env"
chmod 0644 "$ETC_DIR/control-public-url"
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" "$POLICY_HELPER"
install -d -m 0755 "$LIB_DIR/bootstrap"
install -m 0644 "$TEMP_DIR/deploy/$FIREWALL_UNIT" "$LIB_DIR/bootstrap/$FIREWALL_UNIT"
printf '%s\n' "$CONTROL_USER ALL=(root) NOPASSWD: $POLICY_HELPER *" >"$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:-}" ]] \
[[ "$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")"
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 "$@" ;;

View file

@ -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

View file

@ -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

View file

@ -1,25 +1,20 @@
# Linux bootstrap: первая тестовая версия
# Linux bootstrap и собственная Mesh-сеть
Собственный bootstrap Server Monitor Manager устанавливает Control или Agent из локального release-архива. Архив принимается только вместе с файлом `<archive>.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-правилами проекта.

View file

@ -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.

View file

@ -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"