The update guard compared the archive version with PROGRAM_VERSION using shell
string ordering. Both halves were wrong.
PROGRAM_VERSION is a constant describing the bootstrap source tree ("0.2.0-dev"),
never the deployed component, so it could not represent what is installed. The
cross-role compatibility check compared it with a manifest field such as
"v0.1.0-alpha.9"; those can never be equal, so update-control on a host that also
runs the agent always failed. The downgrade guard compared the same mismatched
pair and passed only by accident, because "v" sorts above "0" in ASCII.
String ordering is also wrong for the version scheme in use: "0.1.0-alpha.10"
sorts below "0.1.0-alpha.9", so the next release after the ninth would have been
rejected as a downgrade.
- record the installed version per role at install and update time, and compare
against that instead of PROGRAM_VERSION;
- order versions with sort -V after stripping the leading "v", so prerelease
numbering and tag prefixes compare correctly;
- treat an unknown peer version as a warning rather than a failure, because
installations predating version recording have nothing to compare against;
- guard all of the above in the bootstrap contract test, including the six
ordering cases and a check that the lexicographic comparison is not restored.
Verified by deliberately reintroducing each defect: lexicographic comparison,
sort without -V, and a missing version record are all caught by the contract test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1516 lines
64 KiB
Bash
Executable file
1516 lines
64 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
IFS=$'\n\t'
|
|
|
|
readonly PROGRAM="ochenstarik-server-monitor-manager"
|
|
readonly PROGRAM_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 ENROLLMENT_DIR="${STATE_DIR}-enrollment"
|
|
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 PROVISIONING_HELPER_UNIT="ochenstarik-smm-provisioning-helper.service"
|
|
readonly POLICY_HELPER="/usr/local/libexec/ochenstarik-smm-policy-apply"
|
|
readonly EMERGENCY_COMMAND="/usr/local/sbin/ochenstarik-smm-emergency"
|
|
readonly BOOTSTRAP_COMMAND="/usr/local/sbin/ochenstarik-server-monitor-manager.sh"
|
|
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"
|
|
# Trust anchors — see docs/release-policy.md for the full signing and identity contract.
|
|
readonly COSIGN_ISSUER="https://token.actions.githubusercontent.com"
|
|
readonly COSIGN_IDENTITY_REGEXP="^https://github.com/ochenstarik-ui/server-monitor-manager/\.github/workflows/linux-release\.yml@refs/tags/v.*$"
|
|
|
|
TEMP_DIR=""
|
|
MESH_PEER_CODE=""
|
|
ENROLLMENT_TOKEN_FILE=""
|
|
ENROLLMENT_TOKEN_TEMP=""
|
|
CONTROL_UPDATE_BACKUP_ID=""
|
|
CONTROL_UPDATE_RECOVERY_REQUIRED=0
|
|
CONTROL_UPDATE_LEGACY_ITEMS=()
|
|
|
|
log() { printf '%s\n' "[$PROGRAM] $*"; }
|
|
fail() { printf '%s\n' "[$PROGRAM] ERROR: $*" >&2; exit 1; }
|
|
|
|
cleanup() {
|
|
local status=$?
|
|
trap - EXIT
|
|
if [[ "$CONTROL_UPDATE_RECOVERY_REQUIRED" == "1" ]]; then
|
|
log "Control update failed; restoring the pre-update state."
|
|
if recover_control_update "$CONTROL_UPDATE_BACKUP_ID"; then
|
|
log "Control recovery completed."
|
|
else
|
|
printf '%s\n' "[$PROGRAM] ERROR: Automatic Control recovery failed; manual recovery is required." >&2
|
|
status=1
|
|
fi
|
|
fi
|
|
if [[ -n "$ENROLLMENT_TOKEN_FILE" ]]; then
|
|
rm -f -- "$ENROLLMENT_TOKEN_FILE"
|
|
fi
|
|
if [[ -n "$ENROLLMENT_TOKEN_TEMP" ]]; then
|
|
rm -f -- "$ENROLLMENT_TOKEN_TEMP"
|
|
fi
|
|
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
|
|
rm -rf -- "$TEMP_DIR"
|
|
fi
|
|
exit "$status"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Server Monitor Manager Linux bootstrap
|
|
|
|
Usage:
|
|
ochenstarik-server-monitor-manager.sh preflight
|
|
ochenstarik-server-monitor-manager.sh verify-release ARCHIVE
|
|
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 verify-manifest MANIFEST SIGNATURE
|
|
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]
|
|
ochenstarik-server-monitor-manager.sh node-code NODE_ID
|
|
ochenstarik-server-monitor-manager.sh control-device-code DEVICE_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
|
|
}
|
|
|
|
base64url_encode() {
|
|
base64 -w 0 | tr '+/' '-_' | tr -d '='
|
|
}
|
|
|
|
base64url_decode() {
|
|
local value="$1" remainder
|
|
[[ "$value" =~ ^[A-Za-z0-9_-]+$ ]] || fail "Enrollment code contains invalid base64url data."
|
|
remainder=$(( ${#value} % 4 ))
|
|
case "$remainder" in
|
|
0) ;;
|
|
2) value+="==" ;;
|
|
3) value+="=" ;;
|
|
*) fail "Enrollment code contains invalid base64url length." ;;
|
|
esac
|
|
printf '%s' "$value" | tr '_-' '/+' | base64 -d
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
# Release tags carry a leading "v" while some manifest fields and recorded values may not.
|
|
normalize_version() {
|
|
printf '%s' "${1#v}"
|
|
}
|
|
|
|
# True when the first version orders strictly before the second. Lexicographic comparison is
|
|
# wrong here: "0.1.0-alpha.10" sorts before "0.1.0-alpha.9" as a string, which would reject
|
|
# every release after the ninth as a downgrade.
|
|
version_lt() {
|
|
local left right first
|
|
left="$(normalize_version "$1")"
|
|
right="$(normalize_version "$2")"
|
|
[[ "$left" != "$right" ]] || return 1
|
|
first="$(printf '%s\n%s\n' "$left" "$right" | sort -V | head -n1)"
|
|
[[ "$first" == "$left" ]]
|
|
}
|
|
|
|
installed_version_file() {
|
|
printf '%s' "$ETC_DIR/installed-version-$1"
|
|
}
|
|
|
|
read_installed_version() {
|
|
local file
|
|
file="$(installed_version_file "$1")"
|
|
[[ -r "$file" ]] || return 0
|
|
tr -d '\r\n' <"$file"
|
|
}
|
|
|
|
record_installed_version() {
|
|
local role="$1" version="$2" file
|
|
[[ -n "$version" ]] || return 0
|
|
file="$(installed_version_file "$role")"
|
|
install -d -m 0755 "$ETC_DIR"
|
|
printf '%s\n' "$version" >"$file"
|
|
chmod 0644 "$file"
|
|
}
|
|
|
|
manifest_version_field() {
|
|
local manifest="$1" field="$2"
|
|
[[ -r "$manifest" ]] || return 0
|
|
awk -F'"' -v key="$field" '$2 == key { print $4; exit }' "$manifest" || true
|
|
}
|
|
|
|
# Version recorded for an archive being installed, empty when the release predates manifests.
|
|
archive_version() {
|
|
manifest_version_field "$(dirname "$1")/server-monitor-manager-manifest.json" version
|
|
}
|
|
|
|
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]{1,5}$ ]] && (( 10#$1 >= 1 && 10#$1 <= 65535 )) \
|
|
|| fail "Port must be in range 1-65535."
|
|
}
|
|
|
|
validate_ipv4_literal() {
|
|
local address="$1" octet
|
|
local -a octets
|
|
IFS=. read -r -a octets <<<"$address"
|
|
(( ${#octets[@]} == 4 )) || fail "Control URL contains an invalid IPv4 host."
|
|
for octet in "${octets[@]}"; do
|
|
[[ "$octet" =~ ^[0-9]{1,3}$ ]] && (( 10#$octet <= 255 )) \
|
|
|| fail "Control URL contains an invalid IPv4 host."
|
|
done
|
|
}
|
|
|
|
validate_control_url() {
|
|
local authority host port="" label left right compressed=0 ipv4_groups=0 group_count last_group_index
|
|
local -a groups labels right_groups
|
|
[[ "$1" == https://* ]] || fail "Control URL must be an https URL without a path or credentials."
|
|
authority="${1#https://}"
|
|
authority="${authority%/}"
|
|
[[ -n "$authority" && "$authority" != *['/?#@']* ]] \
|
|
|| fail "Control URL must be an https URL without a path or credentials."
|
|
|
|
if [[ "$authority" == \[* ]]; then
|
|
[[ "$authority" =~ ^\[([0-9A-Fa-f:.]+)\](:([0-9]+))?$ ]] \
|
|
|| fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
host="${BASH_REMATCH[1]}"
|
|
port="${BASH_REMATCH[3]:-}"
|
|
[[ "$host" == *:* ]] || fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
[[ "$host" != *:::* ]] || fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
[[ "$host" != :* || "$host" == ::* ]] \
|
|
|| fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
[[ "$host" != *: || "$host" == *:: ]] \
|
|
|| fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
groups=()
|
|
if [[ "$host" == *::* ]]; then
|
|
compressed=1
|
|
[[ "${host/::/}" != *::* ]] \
|
|
|| fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
left="${host%%::*}"
|
|
right="${host#*::}"
|
|
if [[ -n "$left" ]]; then
|
|
IFS=: read -r -a groups <<<"$left"
|
|
fi
|
|
if [[ -n "$right" ]]; then
|
|
IFS=: read -r -a right_groups <<<"$right"
|
|
groups+=("${right_groups[@]}")
|
|
fi
|
|
else
|
|
IFS=: read -r -a groups <<<"$host"
|
|
fi
|
|
if (( ${#groups[@]} > 0 )) && [[ "${groups[${#groups[@]}-1]}" == *.* ]]; then
|
|
last_group_index=$(( ${#groups[@]} - 1 ))
|
|
validate_ipv4_literal "${groups[$last_group_index]}"
|
|
unset "groups[$last_group_index]"
|
|
ipv4_groups=2
|
|
fi
|
|
for label in "${groups[@]}"; do
|
|
[[ "$label" =~ ^[0-9A-Fa-f]{1,4}$ ]] \
|
|
|| fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
done
|
|
group_count=$(( ${#groups[@]} + ipv4_groups ))
|
|
if (( compressed == 1 )); then
|
|
(( group_count < 8 )) || fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
else
|
|
(( group_count == 8 )) || fail "Control URL contains an invalid bracketed IPv6 authority."
|
|
fi
|
|
else
|
|
[[ "$authority" != *:*:* ]] \
|
|
|| fail "Control URL IPv6 authorities must use balanced brackets."
|
|
if [[ "$authority" == *:* ]]; then
|
|
host="${authority%%:*}"
|
|
port="${authority#*:}"
|
|
[[ -n "$port" ]] || fail "Control URL contains an invalid port."
|
|
else
|
|
host="$authority"
|
|
fi
|
|
[[ "$host" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] \
|
|
|| fail "Control URL contains an invalid DNS or IPv4 host."
|
|
[[ ${#host} -le 253 && "$host" != *..* ]] \
|
|
|| fail "Control URL contains an invalid DNS or IPv4 host."
|
|
IFS=. read -r -a labels <<<"$host"
|
|
for label in "${labels[@]}"; do
|
|
[[ ${#label} -le 63 && "$label" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]] \
|
|
|| fail "Control URL contains an invalid DNS or IPv4 host."
|
|
done
|
|
if [[ "$host" =~ ^[0-9.]+$ ]]; then
|
|
validate_ipv4_literal "$host"
|
|
fi
|
|
fi
|
|
[[ -n "$host" ]] || fail "Control URL host is empty."
|
|
[[ -z "$port" ]] || validate_port "$port"
|
|
}
|
|
|
|
verify_manifest() {
|
|
local manifest="$1" signature="$2"
|
|
if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then
|
|
log "WARNING: Signature verification skipped due to SMM_ALLOW_UNSIGNED=1."
|
|
return 0
|
|
fi
|
|
require_command cosign
|
|
[[ -f "$manifest" ]] || fail "Manifest not found: $manifest"
|
|
[[ -f "$signature" ]] || fail "Signature not found: $signature"
|
|
log "Verifying manifest signature..."
|
|
local verify_args=(--certificate-oidc-issuer "$COSIGN_ISSUER" --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP")
|
|
if [[ -n "${SMM_TEST_PUBKEY:-}" ]]; then
|
|
verify_args=(--key "$SMM_TEST_PUBKEY")
|
|
log "WARNING: Using test public key for verification. This must NOT happen in production."
|
|
fi
|
|
if ! cosign verify-blob "${verify_args[@]}" \
|
|
--signature "$signature" "$manifest" >/dev/null 2>&1; then
|
|
fail "Manifest signature verification failed."
|
|
fi
|
|
log "Manifest signature is valid."
|
|
}
|
|
|
|
verify_archive() {
|
|
local archive="$1" expected actual entry manifest signature
|
|
[[ -f "$archive" ]] || fail "Archive not found: $archive"
|
|
manifest="$(dirname "$archive")/server-monitor-manager-manifest.json"
|
|
signature="$(dirname "$archive")/server-monitor-manager-manifest.sig"
|
|
|
|
if [[ -f "$manifest" && -f "$signature" ]]; then
|
|
verify_manifest "$manifest" "$signature"
|
|
local archive_basename
|
|
archive_basename="$(basename "$archive")"
|
|
expected="$(awk -F'"' -v name="$archive_basename" '$2 == name {print $4}' "$manifest" || true)"
|
|
if [[ -z "$expected" && "$archive_basename" == ochenstarik-* ]]; then
|
|
expected="$(awk -F'"' -v name="${archive_basename#ochenstarik-}" '$2 == name {print $4}' "$manifest" || true)"
|
|
fi
|
|
[[ -n "$expected" ]] || fail "Could not extract archive hash from manifest."
|
|
else
|
|
if [[ "${SMM_ALLOW_UNSIGNED:-0}" == "1" ]]; then
|
|
log "WARNING: Manifest and signature not found, falling back to .sha256 file due to SMM_ALLOW_UNSIGNED=1."
|
|
local 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"
|
|
else
|
|
fail "Manifest and signature are required for archive verification. Set SMM_ALLOW_UNSIGNED=1 to bypass."
|
|
fi
|
|
fi
|
|
|
|
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/*|provisioning-helper|provisioning-helper/*|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."
|
|
[[ -f "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" ]] || fail "Provisioning helper systemd unit is missing from archive."
|
|
[[ -f "$TEMP_DIR/deploy/$FIREWALL_UNIT" ]] || fail "Mesh firewall systemd unit is missing from archive."
|
|
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" ]] || fail "Policy helper is missing from archive."
|
|
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency command is missing from archive."
|
|
}
|
|
|
|
verify_release_payload() {
|
|
local archive="$1"
|
|
require_command sha256sum
|
|
require_command tar
|
|
extract_archive "$archive"
|
|
[[ -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/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] || fail "Provisioning helper binary is missing."
|
|
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" ]] || fail "Policy helper is missing."
|
|
[[ -x "$TEMP_DIR/deploy/ochenstarik-smm-emergency" ]] || fail "Emergency recovery command 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."
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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"
|
|
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"
|
|
"usr/local/lib/ochenstarik-server-monitor-manager/provisioning-helper"
|
|
"etc/ochenstarik-server-monitor-manager/agent.env"
|
|
"etc/ochenstarik-server-monitor-manager/control-ca.crt"
|
|
"etc/systemd/system/$AGENT_UNIT"
|
|
"etc/systemd/system/$PROVISIONING_HELPER_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
|
|
}
|
|
|
|
install_bootstrap_command() {
|
|
local source="$TEMP_DIR/bootstrap/ochenstarik-server-monitor-manager.sh" staging
|
|
[[ -x "$source" ]] || fail "Packaged bootstrap is missing."
|
|
install -d -m 0755 "$(dirname "$BOOTSTRAP_COMMAND")"
|
|
staging="$(mktemp "$(dirname "$BOOTSTRAP_COMMAND")/.ochenstarik-server-monitor-manager.XXXXXXXX")"
|
|
if ! install -m 0755 -o root -g root "$source" "$staging"; then
|
|
rm -f -- "$staging"
|
|
fail "Could not stage the system bootstrap command."
|
|
fi
|
|
if ! mv -fT -- "$staging" "$BOOTSTRAP_COMMAND"; then
|
|
rm -f -- "$staging"
|
|
fail "Could not publish the system bootstrap command."
|
|
fi
|
|
}
|
|
|
|
validate_control_state_migration() {
|
|
local name
|
|
for name in control.db control.db-wal control.db-shm; do
|
|
[[ ! -e "$STATE_DIR/$name" || ! -e "$STATE_DIR/control/$name" ]] \
|
|
|| fail "Both legacy and role-isolated Control state exist: $name"
|
|
done
|
|
[[ ! -e "$STATE_DIR/backups" || ! -e "$STATE_DIR/control/backups" ]] \
|
|
|| fail "Both legacy and role-isolated Control backup directories exist."
|
|
}
|
|
|
|
record_control_legacy_state() {
|
|
local name
|
|
CONTROL_UPDATE_LEGACY_ITEMS=()
|
|
for name in control.db control.db-wal control.db-shm; do
|
|
[[ ! -e "$STATE_DIR/$name" ]] || CONTROL_UPDATE_LEGACY_ITEMS+=("$name")
|
|
done
|
|
[[ ! -e "$STATE_DIR/backups" ]] || CONTROL_UPDATE_LEGACY_ITEMS+=(backups)
|
|
}
|
|
|
|
prepare_control_state() {
|
|
local name
|
|
install -d -m 0700 -o "$CONTROL_USER" -g "$CONTROL_USER" "$STATE_DIR/control"
|
|
for name in control.db control.db-wal control.db-shm; do
|
|
if [[ -e "$STATE_DIR/$name" ]]; then
|
|
mv -- "$STATE_DIR/$name" "$STATE_DIR/control/$name"
|
|
fi
|
|
done
|
|
if [[ -e "$STATE_DIR/backups" ]]; then
|
|
mv -- "$STATE_DIR/backups" "$STATE_DIR/control/backups"
|
|
fi
|
|
install -d -m 0700 -o "$CONTROL_USER" -g "$CONTROL_USER" "$STATE_DIR/control/backups"
|
|
chown -R "$CONTROL_USER:$CONTROL_USER" "$STATE_DIR/control"
|
|
find "$STATE_DIR/control" -type d -exec chmod 0700 {} +
|
|
find "$STATE_DIR/control" -type f -exec chmod 0600 {} +
|
|
}
|
|
|
|
reverse_control_state_migration() {
|
|
local name source destination
|
|
for name in "${CONTROL_UPDATE_LEGACY_ITEMS[@]}"; do
|
|
source="$STATE_DIR/control/$name"
|
|
destination="$STATE_DIR/$name"
|
|
if [[ -e "$destination" ]]; then
|
|
[[ ! -e "$source" ]] || return 1
|
|
continue
|
|
fi
|
|
[[ ! -e "$source" ]] || mv -- "$source" "$destination" || return 1
|
|
done
|
|
rmdir "$STATE_DIR/control/backups" 2>/dev/null || true
|
|
rmdir "$STATE_DIR/control" 2>/dev/null || true
|
|
}
|
|
|
|
restore_control_update_backup() {
|
|
local archive="$1" restore_root="${2:-/}"
|
|
[[ -f "$archive" ]] || return 1
|
|
tar -C "$restore_root" -xzf "$archive"
|
|
}
|
|
|
|
restore_control_binary_from_archive() {
|
|
local archive="$1" restore_root="${2:-/}"
|
|
[[ -f "$archive" ]] || return 1
|
|
tar -C "$restore_root" -xzf "$archive" \
|
|
usr/local/lib/ochenstarik-server-monitor-manager/control
|
|
}
|
|
|
|
recover_control_update() {
|
|
local backup_id="$1" restore_root="${2:-/}"
|
|
local archive="$BACKUP_DIR/${backup_id}.tar.gz"
|
|
systemctl stop "$CONTROL_UNIT" 2>/dev/null || true
|
|
reverse_control_state_migration || return 1
|
|
restore_control_update_backup "$archive" "$restore_root" || return 1
|
|
systemctl daemon-reload || return 1
|
|
systemctl start "$CONTROL_UNIT" || return 1
|
|
systemctl is-active --quiet "$CONTROL_UNIT"
|
|
}
|
|
|
|
validate_control_environment_migration() {
|
|
local env_file="$ETC_DIR/control.env" database_count backup_count database_value backup_value
|
|
[[ -f "$env_file" && ! -L "$env_file" ]] \
|
|
|| fail "Control environment is missing or unsafe."
|
|
database_count="$(grep -c '^Control__DatabasePath=' "$env_file" || true)"
|
|
backup_count="$(grep -c '^Control__BackupDirectory=' "$env_file" || true)"
|
|
[[ "$database_count" == 1 && "$backup_count" == 1 ]] \
|
|
|| fail "Control environment contains missing or conflicting state paths."
|
|
database_value="$(grep '^Control__DatabasePath=' "$env_file")"
|
|
backup_value="$(grep '^Control__BackupDirectory=' "$env_file")"
|
|
case "$database_value" in
|
|
"Control__DatabasePath=$STATE_DIR/control.db"|"Control__DatabasePath=$STATE_DIR/control/control.db") ;;
|
|
*) fail "Control environment contains an unsupported database path." ;;
|
|
esac
|
|
case "$backup_value" in
|
|
"Control__BackupDirectory=$STATE_DIR/backups"|"Control__BackupDirectory=$STATE_DIR/control/backups") ;;
|
|
*) fail "Control environment contains an unsupported backup path." ;;
|
|
esac
|
|
}
|
|
|
|
rewrite_control_environment() {
|
|
local env_file="$ETC_DIR/control.env" staging line
|
|
staging="$(mktemp "$ETC_DIR/.control.env.XXXXXXXX")"
|
|
if ! while IFS= read -r line || [[ -n "$line" ]]; do
|
|
case "$line" in
|
|
Control__DatabasePath=*) printf 'Control__DatabasePath=%s/control/control.db\n' "$STATE_DIR" ;;
|
|
Control__BackupDirectory=*) printf 'Control__BackupDirectory=%s/control/backups\n' "$STATE_DIR" ;;
|
|
*) printf '%s\n' "$line" ;;
|
|
esac
|
|
done <"$env_file" >"$staging"; then
|
|
rm -f -- "$staging"
|
|
fail "Could not rewrite the Control environment."
|
|
fi
|
|
chown root:"$CONTROL_USER" "$staging"
|
|
chmod 0640 "$staging"
|
|
if ! mv -fT -- "$staging" "$env_file"; then
|
|
rm -f -- "$staging"
|
|
fail "Could not publish the Control environment."
|
|
fi
|
|
}
|
|
|
|
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"
|
|
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"
|
|
validate_control_state_migration
|
|
systemctl stop "$CONTROL_UNIT" 2>/dev/null || true
|
|
install -d -m 0711 -o root -g root "$ETC_DIR" "$STATE_DIR"
|
|
prepare_control_state
|
|
install_tree_atomic "$TEMP_DIR/control" "$LIB_DIR/control" "root:root"
|
|
install_bootstrap_command
|
|
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" <<EOF
|
|
ASPNETCORE_URLS=https://0.0.0.0:$port
|
|
ASPNETCORE_Kestrel__Certificates__Default__Path=$ETC_DIR/control-server.pfx
|
|
Control__DatabasePath=$STATE_DIR/control/control.db
|
|
Control__CertificateAuthorityPath=$ETC_DIR/control-ca.pfx
|
|
Control__BackupDirectory=$STATE_DIR/control/backups
|
|
Control__HubHelperPath=$POLICY_HELPER
|
|
Control__PrivilegeEscalationPath=/usr/bin/sudo
|
|
Control__LinkReconciliationSeconds=300
|
|
Control__LinkRetentionDays=90
|
|
EOF
|
|
printf '%s\n' "https://$public_host:$port" >"$ETC_DIR/control-public-url"
|
|
chown root:"$CONTROL_USER" "$ETC_DIR/control.env"
|
|
chmod 0640 "$ETC_DIR/control.env"
|
|
chmod 0644 "$ETC_DIR/control-public-url"
|
|
install -d -m 0755 "$(dirname "$POLICY_HELPER")"
|
|
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-policy-apply" "$POLICY_HELPER"
|
|
install -d -m 0755 "$(dirname "$EMERGENCY_COMMAND")"
|
|
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-emergency" "$EMERGENCY_COMMAND"
|
|
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
|
|
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"
|
|
}
|
|
record_installed_version control "$(archive_version "$archive")"
|
|
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 token_file token_temp
|
|
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."
|
|
[[ -x "$TEMP_DIR/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] || fail "Provisioning helper binary is missing."
|
|
backup_id="$(create_backup agent)"
|
|
ensure_system_user "$AGENT_USER"
|
|
install -d -m 0711 -o root -g root "$ETC_DIR" "$STATE_DIR"
|
|
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$STATE_DIR/agent"
|
|
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$ENROLLMENT_DIR"
|
|
install -d -m 0700 -o root -g root "$STATE_DIR/provisioning/rollback"
|
|
install_tree_atomic "$TEMP_DIR/agent" "$LIB_DIR/agent" "root:root"
|
|
install_tree_atomic "$TEMP_DIR/provisioning-helper" "$LIB_DIR/provisioning-helper" "root:root"
|
|
install_bootstrap_command
|
|
install -d -m 0755 "$(dirname "$EMERGENCY_COMMAND")"
|
|
install -m 0755 "$TEMP_DIR/deploy/ochenstarik-smm-emergency" "$EMERGENCY_COMMAND"
|
|
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" <<EOF
|
|
SMM_NodeId=$node_id
|
|
SMM_AgentUid=$(id -u "$AGENT_USER")
|
|
SMM_ControlUrl=${control_url%/}
|
|
SMM_StateDirectory=$STATE_DIR/agent
|
|
SMM_EnrollmentTokenDirectory=$ENROLLMENT_DIR
|
|
SMM_CertificateAuthorityPath=$ETC_DIR/control-ca.crt
|
|
EOF
|
|
chown root:"$AGENT_USER" "$ETC_DIR/agent.env"
|
|
chmod 0640 "$ETC_DIR/agent.env"
|
|
read_enrollment_token
|
|
token_file="$ENROLLMENT_DIR/enroll-token"
|
|
token_temp="$(mktemp "$ENROLLMENT_DIR/.enroll-token.XXXXXXXX")"
|
|
ENROLLMENT_TOKEN_TEMP="$token_temp"
|
|
if ! printf '%s' "$ENROLL_TOKEN" >"$token_temp"; then
|
|
ENROLL_TOKEN=""
|
|
fail "Could not write the enrollment token file."
|
|
fi
|
|
chown "$AGENT_USER:$AGENT_USER" "$token_temp"
|
|
chmod 0400 "$token_temp"
|
|
ENROLLMENT_TOKEN_FILE="$token_file"
|
|
mv -fT -- "$token_temp" "$token_file"
|
|
ENROLLMENT_TOKEN_TEMP=""
|
|
ENROLL_TOKEN=""
|
|
if ! runuser -u "$AGENT_USER" -- env \
|
|
"SMM_NodeId=$node_id" \
|
|
"SMM_ControlUrl=${control_url%/}" \
|
|
"SMM_StateDirectory=$STATE_DIR/agent" \
|
|
"SMM_EnrollmentTokenDirectory=$ENROLLMENT_DIR" \
|
|
"SMM_CertificateAuthorityPath=$ETC_DIR/control-ca.crt" \
|
|
"SMM_EnrollTokenFile=$token_file" \
|
|
"$LIB_DIR/agent/ochenstarik-smm-agent"; then
|
|
rm -f -- "$token_file"
|
|
fail "Agent enrollment failed."
|
|
fi
|
|
rm -f -- "$token_file"
|
|
ENROLLMENT_TOKEN_FILE=""
|
|
chown root:"$AGENT_USER" "$ETC_DIR/control-ca.crt"
|
|
chmod 0640 "$ETC_DIR/control-ca.crt"
|
|
install_unit "$TEMP_DIR/deploy/$AGENT_UNIT" "$AGENT_UNIT"
|
|
install_unit "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" "$PROVISIONING_HELPER_UNIT"
|
|
systemctl enable --now "$PROVISIONING_HELPER_UNIT"
|
|
systemctl enable --now "$AGENT_UNIT"
|
|
systemctl is-active --quiet "$AGENT_UNIT" || {
|
|
systemctl status --no-pager "$AGENT_UNIT" >&2 || true
|
|
fail "Agent service failed; backup is $backup_id"
|
|
}
|
|
record_installed_version agent "$(archive_version "$archive")"
|
|
log "Agent $node_id installed and enrolled. Backup: $backup_id"
|
|
}
|
|
|
|
read_enrollment_code() {
|
|
if [[ -n "${SMM_ENROLL_CODE:-}" ]]; then
|
|
ENROLL_CODE="$SMM_ENROLL_CODE"
|
|
unset SMM_ENROLL_CODE
|
|
return
|
|
fi
|
|
[[ -t 0 ]] || fail "Set SMM_ENROLL_CODE or run from an interactive local terminal."
|
|
read -r -s -p "SMMNODE enrollment code: " ENROLL_CODE
|
|
printf '\n'
|
|
[[ -n "$ENROLL_CODE" ]] || fail "Enrollment code is empty."
|
|
}
|
|
|
|
confirm_ca_fingerprint() {
|
|
local ca_file="$1" answer
|
|
log "Control CA fingerprint: $(openssl x509 -in "$ca_file" -noout -fingerprint -sha256 | cut -d= -f2)"
|
|
if [[ "${SMM_ACCEPT_CA_FINGERPRINT:-}" == "1" ]]; then
|
|
return
|
|
fi
|
|
[[ -t 0 ]] || fail "Set SMM_ACCEPT_CA_FINGERPRINT=1 only after verifying the fingerprint out of band."
|
|
read -r -p "Type 'yes' after comparing this fingerprint with the Hub: " answer
|
|
[[ "$answer" == "yes" ]] || fail "Control CA fingerprint was not confirmed."
|
|
}
|
|
|
|
install_node_from_code() {
|
|
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 endpoint_part \
|
|
hub_key_part address_part network_part extra <<<"$ENROLL_CODE"
|
|
ENROLL_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"
|
|
confirm_ca_fingerprint "$ca_file"
|
|
SMM_ENROLL_TOKEN="$token"
|
|
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
|
|
}
|
|
|
|
refresh_agent_uid() {
|
|
local env_file="$ETC_DIR/agent.env" agent_uid temp line found=0
|
|
[[ -f "$env_file" && ! -L "$env_file" ]] \
|
|
|| fail "Agent environment is missing or unsafe: $env_file"
|
|
agent_uid="$(id -u "$AGENT_USER")"
|
|
temp="$(mktemp "$ETC_DIR/.agent.env.XXXXXXXX")"
|
|
if ! while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == SMM_AgentUid=* ]]; then
|
|
printf 'SMM_AgentUid=%s\n' "$agent_uid"
|
|
found=1
|
|
else
|
|
printf '%s\n' "$line"
|
|
fi
|
|
done <"$env_file" >"$temp"; then
|
|
rm -f -- "$temp"
|
|
fail "Could not refresh SMM_AgentUid in agent.env."
|
|
fi
|
|
if [[ "$found" == "0" ]]; then
|
|
printf 'SMM_AgentUid=%s\n' "$agent_uid" >>"$temp"
|
|
fi
|
|
chown root:"$AGENT_USER" "$temp"
|
|
chmod 0640 "$temp"
|
|
mv -fT -- "$temp" "$env_file"
|
|
}
|
|
|
|
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."
|
|
|
|
local manifest new_version m_control m_agent installed peer_role peer_expected
|
|
manifest="$(dirname "$archive")/server-monitor-manager-manifest.json"
|
|
if [[ -f "$manifest" ]]; then
|
|
new_version="$(manifest_version_field "$manifest" version)"
|
|
m_control="$(manifest_version_field "$manifest" control)"
|
|
m_agent="$(manifest_version_field "$manifest" agent)"
|
|
if [[ -n "$new_version" ]]; then
|
|
# Compared against the version recorded when this role was installed, not against
|
|
# PROGRAM_VERSION: that constant describes the bootstrap source tree and never the
|
|
# deployed component, so it can neither detect a downgrade nor match a release tag.
|
|
installed="$(read_installed_version "$role")"
|
|
if [[ -n "$installed" ]] \
|
|
&& version_lt "$new_version" "$installed" \
|
|
&& [[ "${SMM_ALLOW_DOWNGRADE:-0}" != "1" ]]; then
|
|
fail "Downgrade of $role from $installed to $new_version is not allowed. Set SMM_ALLOW_DOWNGRADE=1 to bypass."
|
|
fi
|
|
|
|
# Cross-role compatibility: the peer component already on this host must run the
|
|
# version this archive expects of it. An unknown peer version is reported but not
|
|
# treated as failure, because installations predating version recording have
|
|
# nothing to compare against.
|
|
case "$role" in
|
|
control) peer_role="agent"; peer_expected="$m_agent" ;;
|
|
agent) peer_role="control"; peer_expected="$m_control" ;;
|
|
*) peer_role="" ;;
|
|
esac
|
|
if [[ -n "$peer_role" ]] \
|
|
&& systemctl list-unit-files 2>/dev/null | grep -q "^ochenstarik-smm-${peer_role}.service"; then
|
|
local peer_actual
|
|
peer_actual="$(read_installed_version "$peer_role")"
|
|
if [[ -z "$peer_actual" ]]; then
|
|
log "Warning: installed $peer_role version is unknown; compatibility check skipped."
|
|
elif [[ -n "$peer_expected" ]] \
|
|
&& [[ "$(normalize_version "$peer_actual")" != "$(normalize_version "$peer_expected")" ]]; then
|
|
fail "Incompatible versions: installed $peer_role is $peer_actual, but this archive expects $peer_role $peer_expected. Update rejected."
|
|
fi
|
|
fi
|
|
|
|
log "Updating $role to version $new_version"
|
|
fi
|
|
fi
|
|
if [[ "$role" == "control" ]]; then
|
|
validate_control_state_migration
|
|
validate_control_environment_migration
|
|
record_control_legacy_state
|
|
else
|
|
refresh_agent_uid
|
|
fi
|
|
backup_id="$(create_backup "$role")"
|
|
if [[ "$role" == "control" ]]; then
|
|
CONTROL_UPDATE_BACKUP_ID="$backup_id"
|
|
CONTROL_UPDATE_RECOVERY_REQUIRED=1
|
|
fi
|
|
systemctl stop "$unit"
|
|
if [[ "$role" == "agent" ]]; then
|
|
systemctl stop "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
|
fi
|
|
if [[ "$role" == "control" ]]; then
|
|
install -d -m 0711 -o root -g root "$ETC_DIR" "$STATE_DIR"
|
|
prepare_control_state
|
|
rewrite_control_environment
|
|
install_bootstrap_command
|
|
install_unit "$TEMP_DIR/deploy/$CONTROL_UNIT" "$CONTROL_UNIT"
|
|
systemctl enable "$CONTROL_UNIT"
|
|
install_tree_atomic "$TEMP_DIR/control" "$LIB_DIR/control" "$user"
|
|
else
|
|
install_tree_atomic "$TEMP_DIR/$role" "$LIB_DIR/$role" "$user"
|
|
[[ -x "$TEMP_DIR/provisioning-helper/ochenstarik-smm-provisioning-helper" ]] \
|
|
|| fail "Provisioning helper binary is missing."
|
|
install_tree_atomic "$TEMP_DIR/provisioning-helper" "$LIB_DIR/provisioning-helper" "root:root"
|
|
install_unit "$TEMP_DIR/deploy/$AGENT_UNIT" "$AGENT_UNIT"
|
|
install_unit "$TEMP_DIR/deploy/$PROVISIONING_HELPER_UNIT" "$PROVISIONING_HELPER_UNIT"
|
|
systemctl enable --now "$PROVISIONING_HELPER_UNIT"
|
|
fi
|
|
systemctl restart "$unit"
|
|
if ! systemctl is-active --quiet "$unit"; then
|
|
if [[ "$role" == "agent" ]]; then
|
|
log "Update failed; restoring backup $backup_id"
|
|
restore_backup "$role" "$backup_id"
|
|
fi
|
|
fail "$role update was rolled back."
|
|
fi
|
|
if [[ "$role" == "control" ]]; then
|
|
CONTROL_UPDATE_RECOVERY_REQUIRED=0
|
|
CONTROL_UPDATE_BACKUP_ID=""
|
|
CONTROL_UPDATE_LEGACY_ITEMS=()
|
|
fi
|
|
record_installed_version "$role" "$(archive_version "$archive")"
|
|
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
|
|
if [[ "$role" == "agent" ]]; then
|
|
systemctl stop "$PROVISIONING_HELPER_UNIT" || true
|
|
fi
|
|
if [[ "$role" == "control" ]]; then
|
|
restore_control_binary_from_archive "$archive"
|
|
else
|
|
tar -C / -xzf "$archive"
|
|
fi
|
|
systemctl daemon-reload
|
|
if [[ "$role" == "agent" ]]; then
|
|
systemctl start "$PROVISIONING_HELPER_UNIT"
|
|
fi
|
|
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"
|
|
}
|
|
|
|
create_node_code() {
|
|
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."
|
|
[[ -r "$ETC_DIR/control-ca.crt" ]] || fail "Control CA certificate is missing."
|
|
require_command base64
|
|
control_url="$(tr -d '\r\n' <"$ETC_DIR/control-public-url")"
|
|
validate_control_url "$control_url"
|
|
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=""
|
|
}
|
|
|
|
create_device_code() {
|
|
local device_id="$1" token control_url ca_der
|
|
require_root
|
|
validate_node_id "$device_id"
|
|
[[ -r "$ETC_DIR/control-public-url" ]] || fail "Control public URL is missing; reinstall Control with PUBLIC_HOST."
|
|
[[ -r "$ETC_DIR/control-ca.crt" ]] || fail "Control CA certificate is missing."
|
|
require_command base64
|
|
require_command openssl
|
|
control_url="$(tr -d '\r\n' <"$ETC_DIR/control-public-url")"
|
|
validate_control_url "$control_url"
|
|
token="$(run_control_cli device-token-create "$device_id")"
|
|
[[ "$token" =~ ^[A-Za-z0-9_-]{43}$ ]] \
|
|
|| fail "Control returned an invalid device enrollment token."
|
|
ca_der="$(openssl x509 -in "$ETC_DIR/control-ca.crt" -outform DER | base64 -w 0)"
|
|
printf 'SMMDEV1-'
|
|
printf 'VERSION=1\nDEVICE=%s\nTOKEN=%s\nURL=%s\nCA=%s\n' \
|
|
"$device_id" "$token" "$control_url" "$ca_der" | base64url_encode
|
|
printf '\n'
|
|
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
|
|
openssl x509 -in "$ETC_DIR/control-ca.crt" -noout -fingerprint -sha256
|
|
}
|
|
|
|
role_is_installed() {
|
|
case "$1" in
|
|
control) [[ -d "$LIB_DIR/control" || -f "$ETC_DIR/control.env" \
|
|
|| -f "/etc/systemd/system/${CONTROL_UNIT:-ochenstarik-smm-control.service}" ]] ;;
|
|
agent) [[ -d "$LIB_DIR/agent" || -f "$ETC_DIR/agent.env" \
|
|
|| -f "/etc/systemd/system/${AGENT_UNIT:-ochenstarik-smm-agent.service}" ]] ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
remove_shared_ca_if_unused() {
|
|
if ! role_is_installed control && ! role_is_installed agent; then
|
|
rm -f -- "$ETC_DIR/control-ca.crt"
|
|
fi
|
|
}
|
|
|
|
uninstall_agent() {
|
|
local purge="${1:-}"
|
|
require_root
|
|
systemctl disable --now "$AGENT_UNIT" 2>/dev/null || true
|
|
systemctl disable --now "$PROVISIONING_HELPER_UNIT" 2>/dev/null || true
|
|
rm -f -- "/etc/systemd/system/$AGENT_UNIT" "/etc/systemd/system/$PROVISIONING_HELPER_UNIT" "$ETC_DIR/agent.env"
|
|
rm -rf -- "$LIB_DIR/agent" "$LIB_DIR/provisioning-helper"
|
|
if [[ "$purge" == "--purge" ]]; then
|
|
rm -rf -- "$STATE_DIR/agent" "$ENROLLMENT_DIR"
|
|
remove_shared_ca_if_unused
|
|
fi
|
|
systemctl daemon-reload
|
|
log "Agent removed${purge:+ ($purge)}."
|
|
}
|
|
|
|
install_monitor() {
|
|
local public_key="$1"
|
|
require_root
|
|
validate_platform
|
|
|
|
local metrics_script="/usr/local/libexec/ochenstarik-smm-metrics"
|
|
local monitor_user="ochenstarik-monitor"
|
|
local monitor_home="/var/lib/ochenstarik-monitor"
|
|
|
|
if ! id -u "$monitor_user" >/dev/null 2>&1; then
|
|
useradd -r -s /usr/sbin/nologin -d "$monitor_home" -M "$monitor_user"
|
|
fi
|
|
install -d -m 0755 -o "$monitor_user" -g "$monitor_user" "$monitor_home"
|
|
|
|
install -d -m 0755 "/usr/local/libexec"
|
|
cat >"$metrics_script" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
# ochenstarik-smm-metrics
|
|
|
|
echo "PROTOCOL=1"
|
|
echo "HOSTNAME=$(hostname)"
|
|
UPTIME=$(awk '{print int($1)}' /proc/uptime 2>/dev/null || echo "0")
|
|
echo "UPTIME_SECONDS=${UPTIME}"
|
|
LOAD1=$(awk '{print $1}' /proc/loadavg 2>/dev/null || echo "0.00")
|
|
echo "LOAD1=${LOAD1}"
|
|
CPU_COUNT=$(nproc 2>/dev/null || echo "1")
|
|
echo "CPU_COUNT=${CPU_COUNT}"
|
|
MEM_TOTAL=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
echo "MEM_TOTAL_KB=${MEM_TOTAL}"
|
|
MEM_AVAIL=$(awk '/^MemAvailable:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
if [ "$MEM_AVAIL" = "0" ] || [ -z "$MEM_AVAIL" ]; then
|
|
MEM_FREE=$(awk '/^MemFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
MEM_CACHED=$(awk '/^Cached:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
MEM_AVAIL=$((MEM_FREE + MEM_CACHED))
|
|
fi
|
|
echo "MEM_AVAILABLE_KB=${MEM_AVAIL}"
|
|
SWAP_TOTAL=$(awk '/^SwapTotal:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
echo "SWAP_TOTAL_KB=${SWAP_TOTAL}"
|
|
SWAP_FREE=$(awk '/^SwapFree:/ {print $2}' /proc/meminfo 2>/dev/null || echo "0")
|
|
echo "SWAP_FREE_KB=${SWAP_FREE}"
|
|
DF_OUT=$(df -k / 2>/dev/null | awk 'NR==2 {print $2, $4}' || echo "0 0")
|
|
DISK_TOTAL=$(echo "$DF_OUT" | awk '{print $1}')
|
|
DISK_AVAIL=$(echo "$DF_OUT" | awk '{print $2}')
|
|
echo "DISK_TOTAL_KB=${DISK_TOTAL}"
|
|
echo "DISK_AVAILABLE_KB=${DISK_AVAIL}"
|
|
DF_INODES=$(df -i / 2>/dev/null | awk 'NR==2 {print $2, $4}' || echo "0 0")
|
|
INODES_TOTAL=$(echo "$DF_INODES" | awk '{print $1}')
|
|
INODES_FREE=$(echo "$DF_INODES" | awk '{print $2}')
|
|
echo "DISK_INODES_TOTAL=${INODES_TOTAL}"
|
|
echo "DISK_INODES_FREE=${INODES_FREE}"
|
|
NET_RX=$(awk 'NR>2 {rx+=$1} END {print rx}' /proc/net/dev 2>/dev/null || echo "0")
|
|
NET_TX=$(awk 'NR>2 {tx+=$9} END {print tx}' /proc/net/dev 2>/dev/null || echo "0")
|
|
echo "NETWORK_RX_BYTES=${NET_RX}"
|
|
echo "NETWORK_TX_BYTES=${NET_TX}"
|
|
KERNEL=$(uname -r 2>/dev/null || echo "unknown")
|
|
echo "KERNEL=${KERNEL}"
|
|
SYSTEMD_SSH=$(systemctl is-active ssh.service 2>/dev/null || true)
|
|
echo "SYSTEMD_SSH=${SYSTEMD_SSH:-unknown}"
|
|
SYSTEMD_WIREGUARD=$(systemctl is-active wg-quick@smm0.service 2>/dev/null || true)
|
|
echo "SYSTEMD_WIREGUARD=${SYSTEMD_WIREGUARD:-unknown}"
|
|
EOF
|
|
chown root:root "$metrics_script"
|
|
chmod 0755 "$metrics_script"
|
|
|
|
install -d -m 0700 -o "$monitor_user" -g "$monitor_user" "$monitor_home/.ssh"
|
|
local auth_keys="$monitor_home/.ssh/authorized_keys"
|
|
|
|
# We idempotently add the key
|
|
local key_entry="command=\"$metrics_script\",restrict,no-pty,no-agent-forwarding,no-port-forwarding,no-X11-forwarding $public_key"
|
|
if [[ -f "$auth_keys" ]] && grep -qF "$public_key" "$auth_keys"; then
|
|
log "Monitor key already installed."
|
|
else
|
|
echo "$key_entry" >> "$auth_keys"
|
|
chown "$monitor_user:$monitor_user" "$auth_keys"
|
|
chmod 0600 "$auth_keys"
|
|
log "Monitor key installed."
|
|
fi
|
|
}
|
|
|
|
uninstall_monitor() {
|
|
require_root
|
|
local monitor_user="ochenstarik-monitor"
|
|
local monitor_home="/var/lib/ochenstarik-monitor"
|
|
local metrics_script="/usr/local/libexec/ochenstarik-smm-metrics"
|
|
|
|
if id -u "$monitor_user" >/dev/null 2>&1; then
|
|
userdel -f "$monitor_user" || true
|
|
fi
|
|
rm -rf -- "$monitor_home"
|
|
rm -f -- "$metrics_script"
|
|
log "Monitor removed."
|
|
}
|
|
|
|
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" \
|
|
"$POLICY_HELPER" "$SUDOERS_FILE"
|
|
rm -rf -- "$LIB_DIR/control" "$STATE_DIR/control" \
|
|
"$STATE_DIR/control.db" "$STATE_DIR/control.db-wal" \
|
|
"$STATE_DIR/control.db-shm" "$STATE_DIR/backups"
|
|
remove_shared_ca_if_unused
|
|
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) [[ $# -eq 0 ]] || fail "$action takes no arguments"; usage ;;
|
|
version|--version) [[ $# -eq 0 ]] || fail "$action takes no arguments"; printf '%s %s\n' "$PROGRAM" "$PROGRAM_VERSION" ;;
|
|
preflight) [[ $# -eq 0 ]] || fail "preflight takes no arguments"; preflight ;;
|
|
verify-release) [[ $# -eq 1 ]] || fail "verify-release requires ARCHIVE"; verify_release_payload "$1" ;;
|
|
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" ;;
|
|
verify-manifest) [[ $# -eq 2 ]] || fail "verify-manifest requires MANIFEST SIGNATURE"; verify_manifest "$1" "$2" ;;
|
|
install-monitor) [[ $# -eq 1 ]] || fail "install-monitor requires PUBLIC_KEY"; install_monitor "$1" ;;
|
|
uninstall-monitor) [[ $# -eq 0 ]] || fail "uninstall-monitor takes no arguments"; uninstall_monitor ;;
|
|
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 "$@" ;;
|
|
node-code) [[ $# -eq 1 ]] || fail "node-code requires NODE_ID"; create_node_code "$1" ;;
|
|
control-device-code) [[ $# -eq 1 ]] || fail "control-device-code requires DEVICE_ID"; create_device_code "$1" ;;
|
|
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) [[ $# -eq 0 ]] || fail "status takes no arguments"; show_status ;;
|
|
uninstall-agent) [[ $# -eq 0 || ( $# -eq 1 && "$1" == "--purge" ) ]] || 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 "$@"
|