#!/usr/bin/env bash set -Eeuo pipefail IFS=$'\n\t' export LC_ALL=C umask 077 readonly TABLE_FAMILY="inet" readonly TABLE_NAME="ochenstarik_smm" readonly CHAIN_NAME="links" fail() { printf '%s\n' "policy helper: $*" >&2; exit 78; } firewall_unavailable() { printf '%s\n' "mesh.firewall-unavailable" >&2; exit 79; } node_not_activated() { printf '%s\n' "mesh.node-not-activated" >&2; exit 80; } 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" readonly RECONCILE_MARKER="/var/lib/ochenstarik-server-monitor-manager/mesh/reconcile-requested" readonly RECONCILE_LOCK="/var/lib/ochenstarik-server-monitor-manager/mesh/reconcile-requested.lock" readonly FLOCK_COMMAND="/usr/bin/flock" else readonly STATE_FILE="${SMM_POLICY_STATE_FILE:-/dev/null}" readonly RECONCILE_MARKER="${SMM_POLICY_RECONCILE_MARKER:-${TMPDIR:-/tmp}/smm-policy-reconcile-requested}" readonly RECONCILE_LOCK="${SMM_POLICY_RECONCILE_LOCK:-${RECONCILE_MARKER}.lock}" readonly FLOCK_COMMAND="${SMM_POLICY_FLOCK:-/usr/bin/flock}" fi node_pattern='^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' ipv4_pattern='^([0-9]{1,3}\.){3}[0-9]{1,3}$' generation_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' # Mesh node lifecycle statuses are lowercase tokens (for example active or reserved). node_status_pattern='^[a-z][a-z0-9-]{0,31}$' validate_node_id() { [[ "$1" =~ $node_pattern ]] || fail "invalid node id" } is_valid_ipv4() { local address="$1" octet local -a octets [[ "$address" =~ $ipv4_pattern ]] || return 1 IFS='.' read -r -a octets <<<"$address" [[ ${#octets[@]} -eq 4 ]] || return 1 for octet in "${octets[@]}"; do (( 10#$octet <= 255 )) || return 1 done } lookup_node_ip() { local node_id="$1" record field_count ip status [[ -r "$STATE_FILE" ]] || fail "mesh node state is unavailable" record="$(awk -F '\t' -v node="$node_id" '$1 == node { print; exit }' "$STATE_FILE")" [[ -n "$record" ]] || node_not_activated field_count="$(awk -F '\t' '{ print NF }' <<<"$record")" [[ "$field_count" == "4" ]] || fail "invalid mesh node record: $node_id" ip="$(awk -F '\t' '{ print $2 }' <<<"$record")" status="$(awk -F '\t' '{ print $4 }' <<<"$record")" [[ "$status" =~ $node_status_pattern ]] || fail "invalid mesh node status: $node_id" [[ "$status" == "active" ]] || node_not_activated is_valid_ipv4 "$ip" || 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" ;; link-status) [[ $# -eq 5 ]] || fail "invalid link-status 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 [[ -x /usr/sbin/nft ]] || fail "nft executable is missing: /usr/sbin/nft" /usr/sbin/nft "$@" fi } inspect_firewall() { local listing if [[ "$testing" == "1" ]]; then [[ "${SMM_POLICY_FIREWALL_UNAVAILABLE:-0}" != "1" ]] || firewall_unavailable [[ -z "${SMM_POLICY_FIREWALL_ERROR:-}" ]] \ || fail "could not inspect nftables Link policy: $SMM_POLICY_FIREWALL_ERROR" if [[ -n "${SMM_POLICY_LISTING_FILE:-}" ]]; then [[ -r "$SMM_POLICY_LISTING_FILE" ]] || fail "test firewall listing is unavailable" cat -- "$SMM_POLICY_LISTING_FILE" fi return 0 fi [[ -x /usr/sbin/nft ]] || fail "nft executable is missing: /usr/sbin/nft" if listing="$(/usr/sbin/nft -a list chain "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME" 2>&1)"; then printf '%s\n' "$listing" return 0 fi if grep -Eiq 'No such file or directory|does not exist' <<<"$listing"; then firewall_unavailable fi fail "could not inspect nftables Link policy: ${listing%%$'\n'*}" } ensure_firewall_available() { inspect_firewall >/dev/null } rule_exists() { local comment="$1" listing listing="$(inspect_firewall)" grep -Fq "comment \"$comment\"" <<<"$listing" } connect_rule() { local source_id="$1" target_id="$2" protocol="$3" port="$4" local source_ip target_ip comment ensure_firewall_available 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 ensure_firewall_available 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' ) } list_rules() { local listing line comment source_id target_id protocol port invalid local -a fields listing="$(inspect_firewall)" while IFS= read -r line; do [[ "$line" =~ comment[[:space:]]+\"([^\"]*)\" ]] || continue comment="${BASH_REMATCH[1]}" [[ "$comment" == smm:* ]] || continue IFS=':' read -r -a fields <<<"$comment" invalid=0 if [[ ${#fields[@]} -ne 5 ]]; then invalid=1 else source_id="${fields[1]}" target_id="${fields[2]}" protocol="${fields[3]}" port="${fields[4]}" [[ "$source_id" =~ $node_pattern && "$target_id" =~ $node_pattern \ && "$source_id" != "$target_id" ]] || invalid=1 [[ "$protocol" == "tcp" || "$protocol" == "udp" ]] || invalid=1 if [[ ! "$port" =~ ^[0-9]+$ ]] \ || (( 10#$port < 1 || 10#$port > 65535 )); then invalid=1 fi fi if (( invalid != 0 )); then printf '%s\n' "policy helper: forged managed comment ignored: $comment" >&2 continue fi printf '%s\t%s\t%s\t%s\n' "$source_id" "$target_id" "$protocol" "$port" done <<<"$listing" } status_rule() { local source_id="$1" target_id="$2" protocol="$3" port="$4" comment ensure_firewall_available # A status is meaningful only for identities currently known to the mesh. lookup_node_ip "$source_id" >/dev/null lookup_node_ip "$target_id" >/dev/null comment="smm:${source_id}:${target_id}:${protocol}:${port}" if rule_exists "$comment"; then printf '%s\n' active else printf '%s\n' disabled fi } reconcile_status() { local generation if [[ ! -d "$(dirname -- "$RECONCILE_MARKER")" ]]; then printf '%s\n' complete return fi exec 9>"$RECONCILE_LOCK" chmod 0600 "$RECONCILE_LOCK" "$FLOCK_COMMAND" -x 9 if [[ ! -f "$RECONCILE_MARKER" ]]; then printf '%s\n' complete return fi IFS= read -r generation <"$RECONCILE_MARKER" || fail "invalid reconciliation marker" [[ "$generation" =~ $generation_pattern ]] || fail "invalid reconciliation marker" printf 'requested:%s\n' "$generation" } reconcile_complete() { local expected_generation="$1" current_generation='' [[ "$expected_generation" =~ $generation_pattern ]] || fail "invalid reconciliation generation" exec 9>"$RECONCILE_LOCK" chmod 0600 "$RECONCILE_LOCK" "$FLOCK_COMMAND" -x 9 if [[ -f "$RECONCILE_MARKER" ]]; then IFS= read -r current_generation <"$RECONCILE_MARKER" || fail "invalid reconciliation marker" [[ "$current_generation" =~ $generation_pattern ]] || fail "invalid reconciliation marker" if [[ "$current_generation" == "$expected_generation" ]]; then rm -f -- "$RECONCILE_MARKER" fi fi printf '%s\n' complete } action="${1:-}" case "$action" in link-list) [[ $# -eq 1 ]] || fail "invalid link-list argument count" list_rules exit 0 ;; reconcile-status) [[ $# -eq 1 ]] || fail "invalid reconcile-status argument count" reconcile_status exit 0 ;; reconcile-complete) [[ $# -eq 2 ]] || fail "invalid reconcile-complete argument count" reconcile_complete "$2" exit 0 ;; esac validate_rule "$@" case "$action" in link-connect) connect_rule "$2" "$3" "$4" "$5" ;; link-disconnect) disconnect_rule "$2" "$3" "$4" "$5" ;; link-status) status_rule "$2" "$3" "$4" "$5" ;; esac