diff --git a/.github/workflows/linux-control-agent.yml b/.github/workflows/linux-control-agent.yml index c157dde..4bd94c6 100644 --- a/.github/workflows/linux-control-agent.yml +++ b/.github/workflows/linux-control-agent.yml @@ -21,7 +21,7 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore ServerMonitorManager.slnx + run: dotnet restore ServerMonitorManager.slnx --locked-mode - name: Build run: dotnet build ServerMonitorManager.slnx --configuration Release --no-restore @@ -30,7 +30,9 @@ jobs: run: dotnet test tests/ServerMonitorManager.Control.Tests/ServerMonitorManager.Control.Tests.csproj --configuration Release --no-build - name: Verify formatting - run: dotnet format ServerMonitorManager.slnx --verify-no-changes --no-restore + run: | + dotnet format whitespace ServerMonitorManager.slnx --verify-no-changes --no-restore + dotnet format style ServerMonitorManager.slnx --verify-no-changes --no-restore - name: Verify three-server acceptance harness run: | @@ -55,28 +57,28 @@ jobs: bash tests/bootstrap/test-enrollment-token-argv.sh - name: Publish agent amd64 - run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true - name: Publish agent arm64 - run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true - name: Publish provisioning helper amd64 - run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-x64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true - name: Publish provisioning helper arm64 - run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true + run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime linux-arm64 --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true - name: Build systemd smoke release run: | dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \ --configuration Release --runtime linux-x64 --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/agent + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/agent dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \ --configuration Release --runtime linux-x64 --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/control + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/control dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \ --configuration Release --runtime linux-x64 --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o smoke/provisioning-helper + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o smoke/provisioning-helper install -d smoke/deploy smoke/bootstrap install -m 0644 deploy/ochenstarik-smm-control.service smoke/deploy/ install -m 0644 deploy/ochenstarik-smm-agent.service smoke/deploy/ diff --git a/.github/workflows/linux-platform-matrix.yml b/.github/workflows/linux-platform-matrix.yml index b018276..528699f 100644 --- a/.github/workflows/linux-platform-matrix.yml +++ b/.github/workflows/linux-platform-matrix.yml @@ -48,13 +48,13 @@ jobs: set -Eeuo pipefail dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj \ --configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/agent dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj \ --configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/control dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj \ --configuration Release --runtime '${{ matrix.runtime }}' --self-contained true \ - -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper + -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/provisioning-helper 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/ diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml index e8b263a..923791f 100644 --- a/.github/workflows/linux-release.yml +++ b/.github/workflows/linux-release.yml @@ -79,13 +79,13 @@ jobs: dotnet-version: 10.0.x - name: Publish agent - run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/agent + run: dotnet publish src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/agent - name: Publish control - run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/control + run: dotnet publish src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/control - name: Publish provisioning helper - run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -o out/provisioning-helper + run: dotnet publish src/ServerMonitorManager.Provisioning.Helper/ServerMonitorManager.Provisioning.Helper.csproj --configuration Release --runtime ${{ matrix.runtime }} --self-contained true -p:PublishSingleFile=true -p:PublishTrimmed=true -p:RestoreLockedMode=true -o out/provisioning-helper - name: Package shell: bash diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 83f117a..685541b 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -21,20 +21,22 @@ jobs: dotnet-version: 10.0.x - name: Restore - run: dotnet restore src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64 + run: dotnet restore src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj -p:Platform=x64 -p:PublishReadyToRun=true -p:RestoreLockedMode=true - name: Build x64 Release run: dotnet build src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --configuration Release -p:Platform=x64 -p:PublishReadyToRun=true -r win-x64 --no-restore - name: Verify formatting - run: dotnet format src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore + run: | + dotnet format whitespace src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore + dotnet format style src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj --verify-no-changes --no-restore - name: Verify Windows desktop contracts shell: pwsh run: ./tests/windows/Test-DesktopContracts.ps1 - name: Test Desktop security - run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release + run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true - name: Build test-signed MSIX installer shell: pwsh diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..b8da30a --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,22 @@ + + + + enable + true + latest-recommended + + + true + win-x64;linux-x64;linux-arm64 + + + true + true + false + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..636fd58 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,34 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/Build-Installer.ps1 b/build/windows/Build-Installer.ps1 index 2092665..e398ebf 100644 --- a/build/windows/Build-Installer.ps1 +++ b/build/windows/Build-Installer.ps1 @@ -35,7 +35,7 @@ try { throw "The certificate subject must match Package.appxmanifest Publisher=CN=AppPublisher; actual: $($signingCertificate.Subject)" } - dotnet restore $project -r win-x64 -p:Platform=x64 -p:PublishReadyToRun=false + dotnet restore $project -p:Platform=x64 -p:PublishReadyToRun=false -p:RestoreLockedMode=true if ($LASTEXITCODE -ne 0) { throw 'dotnet restore failed' } dotnet publish $project ` diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index 0d87103..d043fbc 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -495,7 +495,6 @@ Control__BackupDirectory=$STATE_DIR/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" diff --git a/deploy/ochenstarik-smm-policy-apply b/deploy/ochenstarik-smm-policy-apply index 62ea298..87b3392 100755 --- a/deploy/ochenstarik-smm-policy-apply +++ b/deploy/ochenstarik-smm-policy-apply @@ -10,7 +10,6 @@ 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 @@ -29,36 +28,16 @@ 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 + local node_id="$1" ip [[ -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" + 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" } @@ -86,7 +65,6 @@ run_nft() { printf ' %q' "$@" printf '\n' else - [[ -x /usr/sbin/nft ]] || fail "nft executable is missing: /usr/sbin/nft" /usr/sbin/nft "$@" fi } @@ -97,13 +75,8 @@ inspect_firewall() { [[ "${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 @@ -140,6 +113,9 @@ connect_rule() { disconnect_rule() { local source_id="$1" target_id="$2" protocol="$3" port="$4" comment handle ensure_firewall_available + # 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" @@ -155,39 +131,6 @@ disconnect_rule() { ) } -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 @@ -204,10 +147,6 @@ status_rule() { 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 @@ -238,11 +177,6 @@ reconcile_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 diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md index 1dc5426..47566f8 100644 --- a/docs/linux-bootstrap.md +++ b/docs/linux-bootstrap.md @@ -108,4 +108,4 @@ sudo ochenstarik-smm-emergency firewall-restore sudo ochenstarik-smm-emergency mesh-enable ``` -`mesh-disable` останавливает WireGuard, удаляет только таблицу `inet ochenstarik_smm` и ставит локальный emergency marker, не останавливая Control, Agent или SSH. `firewall-restore` восстанавливает базовую политику deny-by-default и атомарно создаёт root-only запрос реконсиляции с уникальным generation. Control немедленно выполняет первый фоновый проход после старта, затем одним `link-list` сверяет фактические managed-правила со всеми последними Link-политиками не реже настроенного интервала `Control__LinkReconciliationSeconds` (по умолчанию 300 секунд). Новый marker запускает до трёх внеочередных проходов на следующих poll tick; после трёх отказов обычный throttle восстанавливается, marker сохраняется, а журнал получает одно предупреждение с id проблемных политик. Marker не обходит backoff недоступного firewall. Завершённые политики `Disabled/Disabled` удаляются вместе со своей историей после `Control__LinkRetentionDays` (по умолчанию 90 дней); действующие политики и расхождения retention не затрагивает. Helper удаляет только тот generation, который был прочитан перед успешно завершившимся проходом; более новый запрос сохраняется. При недоступной таблице запрос остаётся для retry с backoff, а Desktop показывает единый баннер «Mesh firewall не загружен». Если firewall не удаётся восстановить, команда отключает Mesh для fail-closed результата. `mesh-enable` также создаёт запрос реконсиляции; запускайте его только после проверки конфигурации и доступности Hub. +`mesh-disable` останавливает WireGuard, удаляет только таблицу `inet ochenstarik_smm` и ставит локальный emergency marker, не останавливая Control, Agent или SSH. `firewall-restore` восстанавливает базовую политику deny-by-default и атомарно создаёт root-only запрос реконсиляции с уникальным generation. Control немедленно выполняет первый фоновый проход после старта, затем сверяет все действующие Link-политики не реже настроенного интервала `Control__LinkReconciliationSeconds` (по умолчанию 300 секунд). Новый marker запускает внеочередной проход на следующем poll tick, но не обходит backoff недоступного firewall. Helper удаляет только тот generation, который был прочитан перед успешно завершившимся проходом; более новый запрос сохраняется. При недоступной таблице запрос остаётся для retry с backoff, а Desktop показывает единый баннер «Mesh firewall не загружен». Если firewall не удаётся восстановить, команда отключает Mesh для fail-closed результата. `mesh-enable` также создаёт запрос реконсиляции; запускайте его только после проверки конфигурации и доступности Hub. diff --git a/docs/roadmap.md b/docs/roadmap.md index ecf6d6d..da161fc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -58,7 +58,7 @@ - [x] append-only audit операций Link; - [x] интеграционные тесты kill switch, process restart и helper failure; - [x] B-2: независимая фоновая и emergency-triggered реконсиляция, агрегированное состояние недоступного Mesh firewall и Desktop banner; -- [x] B-3: факт-первичная реконсиляция использует один `link-list`, удаляет orphan/дубликаты (включая `Disabled/Disabled`), разводит `Examined` / `Converged` / `Failed` (M2), ограничивает marker-prompt тремя попытками, добавляет фильтр истории и retention завершённых Links (M4), а также типизированное ожидание активации Mesh Node (M5). Physical acceptance остаётся внешним блокером; +- [ ] B-3: развести результат реконсиляции на `Examined` / `Converged` / `Failed` (M2), выбрать фильтр или retention завершённых Disabled-политик (M4) и типизировать ещё не активированный Mesh Node (M5). Эти изменения вынесены отдельно, потому что меняют API/UI semantics и helper classification, тогда как B-2 ограничен восстановлением фактических правил и общим firewall failure; - [ ] выполнить физический acceptance Hub + source Node + два destination Node с WireGuard/nftables/reboot. ## Этап 4 — мониторинг и терминал diff --git a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj index cee78b2..a769654 100644 --- a/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj +++ b/src/ServerMonitorManager.Agent/ServerMonitorManager.Agent.csproj @@ -10,10 +10,10 @@ - - - - + + + + diff --git a/src/ServerMonitorManager.Agent/packages.lock.json b/src/ServerMonitorManager.Agent/packages.lock.json new file mode 100644 index 0000000..c2c96b2 --- /dev/null +++ b/src/ServerMonitorManager.Agent/packages.lock.json @@ -0,0 +1,118 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.Extensions.Configuration.Binder": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "GqmN2o1CkJvk7uWp+p4CwBYW0w/zfoEbvsiFDbO2G8l1Uz+mrDAbAcZiXhU2lufKPby1cjAUdd5GTWpebYOkOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "33cBeR2HRbzHUTtmcmLdNOApneNGcymwwL4arHuotgVK9Frba8kcDTrvVTj7cSCmF1R9OiSbZH0KxNOwab3HUg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "KRfFSSCV58vEdU7mPED/YMzeovIWF5P0g8s9K8n9HEfy0/WzMq37SrPdXdFN5/dFT/rPMHpF7AvpoXHckbcBFg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "uvJ6sHwjgrkMEJOgiC76G0mcZGXerwyyWkwX34EOjCbxKG6TCtfAoqDKAMsCvEBf9HxjlGQEgqsSMOGCmGBf+A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "plJWK2zpWuuyxI8F8s2scx6Je7N1Ajjs6HvYUGKwRnDMWIVIz9FHwAkiT7ASgrvAOd10T0FPVlh9BzAJJME+jg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5Vnd2I75DmZCVEjSynIdJ/0EGafgnLQwgR3t2C2/fkjx/nRG+cLwxLLdInoHeCEpkD5K4Ov/g9ZCRYrl4TRsaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "ZOhZYwvbXGTgGVRwswIirofEMVHuWdxjdh0JeUZXwaF9cgcjXdz/t0ELtgaevw7ezTyv47yPNCgGreWtLkn3IQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.10", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileProviders.Physical": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "c5zqFCY9DiIpMovLd7/d/CTiEtrMOuQ639dhv3PABtKQIKNQikSHwQt8+N679uii9q+B55lgK28Uv64FOwEu8w==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jhJAyo38kSrH3ARvWUk0h8itogVnQu2DCZuPo+s0Z+tXes0ugTxMPaHYzap85785eHQmPFqD9TYERqBbtGxn/w==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.10", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "jSOCVxEwCd4Aq925kJVz1kSO1EpX2OHYKL04qVREXkDU7Ce3pVDdHPYm+fEy8y/th2kJf/DAstRHpJAqoNWP8w==" + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "servermonitormanager.core": { + "type": "Project" + } + }, + "net10.0/linux-arm64": {}, + "net10.0/linux-x64": {}, + "net10.0/win-x64": {} + } +} \ No newline at end of file diff --git a/src/ServerMonitorManager.Control/ControlJsonContext.cs b/src/ServerMonitorManager.Control/ControlJsonContext.cs deleted file mode 100644 index cf30905..0000000 --- a/src/ServerMonitorManager.Control/ControlJsonContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Text.Json.Serialization; - -namespace ServerMonitorManager.Control; - -internal sealed record LinkOrphanAuditDetails( - string SourceNodeId, - string TargetNodeId, - string Protocol, - int Port); - -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(LinkOrphanAuditDetails))] -internal sealed partial class ControlJsonContext : JsonSerializerContext; diff --git a/src/ServerMonitorManager.Control/ControlMaintenance.cs b/src/ServerMonitorManager.Control/ControlMaintenance.cs index d8e401b..de3db45 100644 --- a/src/ServerMonitorManager.Control/ControlMaintenance.cs +++ b/src/ServerMonitorManager.Control/ControlMaintenance.cs @@ -10,7 +10,6 @@ public sealed record ControlMaintenanceResult( int MetricsDeleted, int IdempotencyDeleted, int AuditDeleted, - int LinksDeleted, int TokensDeleted, int ProvisioningJobsCancelled, int ProvisioningJobsNeedingReconciliation); @@ -69,17 +68,16 @@ public sealed class ControlMaintenanceBackgroundService( var result = await store.MaintainAsync(timeProvider.GetUtcNow(), stoppingToken); await backups.CreateIfDueAsync(timeProvider.GetUtcNow(), stoppingToken); if (result.MetricsDeleted + result.IdempotencyDeleted + result.AuditDeleted - + result.LinksDeleted + result.TokensDeleted + result.ProvisioningJobsCancelled + + result.TokensDeleted + result.ProvisioningJobsCancelled + result.ProvisioningJobsNeedingReconciliation > 0) { logger.LogInformation( "Control maintenance removed {Metrics} metrics, {Idempotency} replay records, " - + "{Audit} audit records, {Links} completed Link policies, and {Tokens} enrollment tokens; cancelled {Cancelled} " + + "{Audit} audit records, and {Tokens} enrollment tokens; cancelled {Cancelled} " + "expired jobs and marked {Reconciliation} jobs for reconciliation.", result.MetricsDeleted, result.IdempotencyDeleted, result.AuditDeleted, - result.LinksDeleted, result.TokensDeleted, result.ProvisioningJobsCancelled, result.ProvisioningJobsNeedingReconciliation); diff --git a/src/ServerMonitorManager.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs index a618f6d..3db0896 100644 --- a/src/ServerMonitorManager.Control/ControlOptions.cs +++ b/src/ServerMonitorManager.Control/ControlOptions.cs @@ -1,47 +1,38 @@ -using System.Diagnostics.CodeAnalysis; - namespace ServerMonitorManager.Control; public sealed class ControlOptions { - [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(ControlOptions))] - public ControlOptions() - { - } - public const string SectionName = "Control"; - public string DatabasePath { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/control.db"; + public string DatabasePath { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/control.db"; - public string CertificateAuthorityPath { get; set; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx"; + public string CertificateAuthorityPath { get; init; } = "/etc/ochenstarik-server-monitor-manager/control-ca.pfx"; - public string? CertificateAuthorityPassword { get; set; } + public string? CertificateAuthorityPassword { get; init; } - public int HeartbeatSeconds { get; set; } = 30; + public int HeartbeatSeconds { get; init; } = 30; - public int MaxBufferedMetricAgeHours { get; set; } = 24; + public int MaxBufferedMetricAgeHours { get; init; } = 24; - public int MetricRetentionHours { get; set; } = 168; + public int MetricRetentionHours { get; init; } = 168; - public int IdempotencyRetentionHours { get; set; } = 24; + public int IdempotencyRetentionHours { get; init; } = 24; - public int AuditRetentionDays { get; set; } = 90; + public int AuditRetentionDays { get; init; } = 90; - public int LinkRetentionDays { get; set; } = 90; + public int MaintenanceIntervalMinutes { get; init; } = 15; - public int MaintenanceIntervalMinutes { get; set; } = 15; + public int LinkExpirationPollSeconds { get; init; } = 15; - public int LinkExpirationPollSeconds { get; set; } = 15; + public int LinkReconciliationSeconds { get; init; } = 300; - public int LinkReconciliationSeconds { get; set; } = 300; + public string BackupDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/backups"; - public string BackupDirectory { get; set; } = "/var/lib/ochenstarik-server-monitor-manager/backups"; + public int BackupIntervalHours { get; init; } = 24; - public int BackupIntervalHours { get; set; } = 24; + public int BackupRetentionCount { get; init; } = 7; - public int BackupRetentionCount { get; set; } = 7; + public string HubHelperPath { get; init; } = "/usr/local/libexec/ochenstarik-smm-policy-apply"; - public string HubHelperPath { get; set; } = "/usr/local/libexec/ochenstarik-smm-policy-apply"; - - public string PrivilegeEscalationPath { get; set; } = "/usr/bin/sudo"; + public string PrivilegeEscalationPath { get; init; } = "/usr/bin/sudo"; } diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs index b349fb5..f970fe9 100644 --- a/src/ServerMonitorManager.Control/ControlStore.cs +++ b/src/ServerMonitorManager.Control/ControlStore.cs @@ -352,24 +352,6 @@ public sealed partial class ControlStore(IOptions options) SELECT changes(); DELETE FROM audit WHERE recorded_at < $audit_cutoff; SELECT changes(); - DELETE FROM links AS historical - WHERE EXISTS ( - SELECT 1 FROM links AS latest - WHERE latest.source_node_id = historical.source_node_id - AND latest.target_node_id = historical.target_node_id - AND latest.protocol = historical.protocol - AND latest.port = historical.port - AND latest.desired_state = 'Disabled' - AND latest.actual_state = 'Disabled' - AND latest.updated_at < $link_cutoff - AND NOT EXISTS ( - SELECT 1 FROM links AS newer - WHERE newer.source_node_id = latest.source_node_id - AND newer.target_node_id = latest.target_node_id - AND newer.protocol = latest.protocol - AND newer.port = latest.port - AND newer.version > latest.version)); - SELECT changes(); DELETE FROM enrollment_tokens WHERE consumed_at IS NOT NULL OR expires_at < $now; SELECT changes(); DELETE FROM device_tokens WHERE consumed_at IS NOT NULL OR expires_at < $now; @@ -383,10 +365,8 @@ public sealed partial class ControlStore(IOptions options) "$idempotency_cutoff", now.AddHours(-_options.IdempotencyRetentionHours).ToString("O")); command.Parameters.AddWithValue( "$audit_cutoff", now.AddDays(-_options.AuditRetentionDays).ToString("O")); - command.Parameters.AddWithValue( - "$link_cutoff", now.AddDays(-_options.LinkRetentionDays).ToString("O")); command.Parameters.AddWithValue("$now", now.ToString("O")); - var changes = new int[10]; + var changes = new int[9]; await using (var reader = await command.ExecuteReaderAsync(cancellationToken)) { for (var index = 0; index < changes.Length; index++) @@ -404,7 +384,7 @@ public sealed partial class ControlStore(IOptions options) optimize.CommandText = "PRAGMA optimize; PRAGMA wal_checkpoint(PASSIVE);"; await optimize.ExecuteNonQueryAsync(cancellationToken); return new ControlMaintenanceResult( - changes[3], changes[4], changes[5], changes[6], changes[7] + changes[8] + changes[9], + changes[3], changes[4], changes[5], changes[6] + changes[7] + changes[8], changes[0], changes[1] + changes[2]); } @@ -1373,31 +1353,6 @@ public sealed partial class ControlStore(IOptions options) return link; } - public async Task RecordLinkOrphanRemovedAsync( - LinkRule rule, - string actor, - CancellationToken cancellationToken = default) - { - await using var connection = await OpenAsync(cancellationToken); - await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken); - var subject = $"{rule.SourceNodeId}:{rule.TargetNodeId}:{rule.Protocol}:{rule.Port}"; - await WriteAuditAsync( - connection, - transaction, - actor, - "link.orphan-removed", - subject, - JsonSerializer.Serialize( - new LinkOrphanAuditDetails( - rule.SourceNodeId, - rule.TargetNodeId, - rule.Protocol, - rule.Port), - ControlJsonContext.Default.LinkOrphanAuditDetails), - cancellationToken); - await transaction.CommitAsync(cancellationToken); - } - public async Task GetLinkAsync( string id, CancellationToken cancellationToken = default) @@ -1409,29 +1364,6 @@ public sealed partial class ControlStore(IOptions options) return link; } - public async Task GetEffectiveLinkAsync( - LinkRule rule, - CancellationToken cancellationToken = default) - { - await using var connection = await OpenAsync(cancellationToken); - var command = connection.CreateCommand(); - command.CommandText = """ - SELECT * FROM links - WHERE source_node_id = $source - AND target_node_id = $target - AND protocol = $protocol - AND port = $port - ORDER BY version DESC - LIMIT 1; - """; - command.Parameters.AddWithValue("$source", rule.SourceNodeId); - command.Parameters.AddWithValue("$target", rule.TargetNodeId); - command.Parameters.AddWithValue("$protocol", rule.Protocol); - command.Parameters.AddWithValue("$port", rule.Port); - await using var reader = await command.ExecuteReaderAsync(cancellationToken); - return await reader.ReadAsync(cancellationToken) ? ReadLink(reader) : null; - } - public async Task> ListLinksAsync(CancellationToken cancellationToken = default) { var result = new List(); @@ -1455,7 +1387,9 @@ public sealed partial class ControlStore(IOptions options) command.CommandText = """ SELECT current.* FROM links AS current - WHERE NOT EXISTS ( + WHERE (current.desired_state = 'Active' + OR (current.desired_state = 'Disabled' AND current.actual_state != 'Disabled')) + AND NOT EXISTS ( SELECT 1 FROM links AS newer WHERE newer.source_node_id = current.source_node_id AND newer.target_node_id = current.target_node_id @@ -1771,39 +1705,7 @@ public sealed record AgentHeartbeatMutation( AgentHeartbeatResponse Response, bool RequiresReconciliation); -public sealed record LinkReconciliationResult -{ - public LinkReconciliationResult( - int examined, - int converged, - int failed, - int deferred, - IReadOnlyList failedPolicyIds, - IReadOnlyList deferredPolicyIds) - { - if (converged + failed + deferred != examined) - { - throw new InvalidOperationException( - $"Link reconciliation classification invariant failed: examined={examined}, " - + $"converged={converged}, failed={failed}, deferred={deferred}; " - + $"failed IDs=[{string.Join(',', failedPolicyIds)}], " - + $"deferred IDs=[{string.Join(',', deferredPolicyIds)}]."); - } - Examined = examined; - Converged = converged; - Failed = failed; - Deferred = deferred; - FailedPolicyIds = failedPolicyIds; - DeferredPolicyIds = deferredPolicyIds; - } - - public int Examined { get; } - public int Converged { get; } - public int Failed { get; } - public int Deferred { get; } - public IReadOnlyList FailedPolicyIds { get; } - public IReadOnlyList DeferredPolicyIds { get; } -} +public sealed record LinkReconciliationResult(int Applied, int Failed); public sealed record AgentReenrollmentMutation( CertificateReenrollmentTicket Ticket, diff --git a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs index 688deae..106a948 100644 --- a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs +++ b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs @@ -6,10 +6,8 @@ namespace ServerMonitorManager.Control; public interface ILinkPolicyApplier { - Task> ListRulesAsync(CancellationToken cancellationToken); Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken); Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken); - Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken); Task IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken); Task GetReconciliationRequestAsync(CancellationToken cancellationToken) => Task.FromResult(null); @@ -17,8 +15,6 @@ public interface ILinkPolicyApplier => Task.CompletedTask; } -public sealed record LinkRule(string SourceNodeId, string TargetNodeId, string Protocol, int Port); - public sealed class MeshFirewallUnavailableException : InvalidOperationException { public MeshFirewallUnavailableException(string message) : base(message) @@ -26,38 +22,8 @@ public sealed class MeshFirewallUnavailableException : InvalidOperationException } } -public sealed class MeshNodeNotActivatedException : InvalidOperationException -{ - public MeshNodeNotActivatedException(string message) : base(message) - { - } -} - public sealed class LinkPolicyApplier(IOptions options) : ILinkPolicyApplier { - public async Task> ListRulesAsync(CancellationToken cancellationToken) - { - var output = await RunAsync(["link-list"], cancellationToken); - if (output.Length == 0) - { - return []; - } - var rules = new List(); - foreach (var line in output.Split('\n', StringSplitOptions.RemoveEmptyEntries)) - { - var fields = line.Trim().Split('\t'); - if (fields.Length != 4 - || !int.TryParse(fields[3], System.Globalization.NumberStyles.None, - System.Globalization.CultureInfo.InvariantCulture, out var port) - || port is < 1 or > 65535) - { - throw new InvalidOperationException("Hub policy helper returned an invalid link-list record."); - } - rules.Add(new LinkRule(fields[0], fields[1], fields[2], port)); - } - return rules; - } - public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken) => RunAsync( [ @@ -71,18 +37,13 @@ public sealed class LinkPolicyApplier(IOptions options) : ILinkP cancellationToken); public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken) - => ApplyDisconnectAsync( - new LinkRule(link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port), - cancellationToken); - - public Task ApplyDisconnectAsync(LinkRule rule, CancellationToken cancellationToken) => RunAsync( [ "link-disconnect", - rule.SourceNodeId, - rule.TargetNodeId, - rule.Protocol, - rule.Port.ToString(System.Globalization.CultureInfo.InvariantCulture) + link.SourceNodeId, + link.TargetNodeId, + link.Protocol, + link.Port.ToString(System.Globalization.CultureInfo.InvariantCulture) ], cancellationToken); @@ -153,11 +114,6 @@ public sealed class LinkPolicyApplier(IOptions options) : ILinkP { throw new MeshFirewallUnavailableException(LinkService.FirewallUnavailableCode); } - if (process.ExitCode == 80 - && string.Equals(message, LinkService.NodeNotActivatedCode, StringComparison.Ordinal)) - { - throw new MeshNodeNotActivatedException(LinkService.NodeNotActivatedCode); - } throw new InvalidOperationException(string.IsNullOrWhiteSpace(message) ? $"Hub policy helper exited with code {process.ExitCode}." : message); diff --git a/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs b/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs index 5e971bf..ac7abe5 100644 --- a/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs +++ b/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs @@ -9,13 +9,10 @@ public sealed class LinkReconciliationBackgroundService( TimeProvider timeProvider, ILogger logger) : BackgroundService { - private const int PromptAttemptLimit = 3; private readonly SemaphoreSlim _passGate = new(1, 1); private DateTimeOffset? _nextRegularAt; private DateTimeOffset? _backoffUntil; private int _unavailableAttempts; - private int _promptFailureAttempts; - private bool _promptThrottleWarningLogged; internal async Task RunOnceAsync( CancellationToken cancellationToken = default) @@ -37,59 +34,27 @@ public sealed class LinkReconciliationBackgroundService( { return null; } - var promptBypassesThrottle = requestGeneration is not null - && _promptFailureAttempts < PromptAttemptLimit; - if (!promptBypassesThrottle && _nextRegularAt is not null && now < _nextRegularAt) + if (requestGeneration is null && _nextRegularAt is not null && now < _nextRegularAt) { return null; } + var result = await links.ReconcileAllAsync(cancellationToken); var interval = TimeSpan.FromSeconds(options.Value.LinkReconciliationSeconds); - _nextRegularAt = now + interval; - LinkFullReconciliationResult result; - try - { - result = await links.ReconcileAllAsync(cancellationToken); - } - catch (Exception exception) when ( - exception is not OperationCanceledException && requestGeneration is not null) - { - RegisterPromptFailure([]); - throw; - } if (result.FirewallUnavailable) { _unavailableAttempts = Math.Min(_unavailableAttempts + 1, 4); _backoffUntil = now + TimeSpan.FromTicks(interval.Ticks * _unavailableAttempts); - return result; } - - _unavailableAttempts = 0; - _backoffUntil = null; - if (requestGeneration is null) + else { - return result; - } - - if (result.Failed > 0) - { - RegisterPromptFailure(result.FailedPolicyIds); - return result; - } - - try - { - await applier.CompleteReconciliationAsync(requestGeneration, cancellationToken); - _promptFailureAttempts = 0; - _promptThrottleWarningLogged = false; - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - logger.LogWarning( - exception, - "Link reconciliation completed, but marker generation {Generation} could not be consumed.", - requestGeneration); - RegisterPromptFailure(["marker-completion"]); + _unavailableAttempts = 0; + _backoffUntil = null; + _nextRegularAt = now + interval; + if (requestGeneration is not null && result.Failed == 0) + { + await applier.CompleteReconciliationAsync(requestGeneration, cancellationToken); + } } return result; } @@ -99,19 +64,6 @@ public sealed class LinkReconciliationBackgroundService( } } - private void RegisterPromptFailure(IReadOnlyList failedPolicyIds) - { - _promptFailureAttempts = Math.Min(_promptFailureAttempts + 1, PromptAttemptLimit); - if (_promptFailureAttempts == PromptAttemptLimit && !_promptThrottleWarningLogged) - { - _promptThrottleWarningLogged = true; - logger.LogWarning( - "Link reconciliation marker prompt exhausted after {Attempts} failures; regular throttle restored. Failed policy IDs: {FailedPolicyIds}.", - PromptAttemptLimit, - failedPolicyIds.Count == 0 ? "unknown" : string.Join(",", failedPolicyIds)); - } - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var pollSeconds = Math.Min(options.Value.LinkReconciliationSeconds, 30); @@ -124,15 +76,10 @@ public sealed class LinkReconciliationBackgroundService( if (result is not null) { logger.LogInformation( - "Link reconciliation completed: {Examined} examined, {Converged} converged, {Deferred} deferred, {Failed} failed, firewall unavailable: {Unavailable}. Deferred policy IDs: {DeferredPolicyIds}.", + "Link reconciliation completed: {Examined} examined, {Failed} failed, firewall unavailable: {Unavailable}.", result.Examined, - result.Converged, - result.Deferred, result.Failed, - result.FirewallUnavailable, - result.DeferredPolicyIds.Count == 0 - ? "none" - : string.Join(",", result.DeferredPolicyIds)); + result.FirewallUnavailable); } } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) diff --git a/src/ServerMonitorManager.Control/LinkService.cs b/src/ServerMonitorManager.Control/LinkService.cs index 21c5ed9..e76176a 100644 --- a/src/ServerMonitorManager.Control/LinkService.cs +++ b/src/ServerMonitorManager.Control/LinkService.cs @@ -11,7 +11,6 @@ public sealed class LinkService( { public const string FirewallUnavailableCode = "mesh.firewall-unavailable"; public const string FirewallAvailableCode = "mesh.firewall-available"; - public const string NodeNotActivatedCode = "mesh.node-not-activated"; private readonly ConcurrentDictionary _reconciliationLocks = new(); private readonly ConcurrentDictionary _nodeLocks = new(); @@ -109,12 +108,8 @@ public sealed class LinkService( string nodeId, CancellationToken cancellationToken) { - var examined = 0; - var converged = 0; - var deferred = 0; + var reconciled = 0; var failed = 0; - var failedPolicyIds = new List(); - var deferredPolicyIds = new List(); var links = await store.ListEffectiveLinksForNodeAsync(nodeId, cancellationToken); foreach (var candidate in links) { @@ -133,20 +128,10 @@ public sealed class LinkService( current.DesiredState == "Active", $"system:reconnect:{nodeId}", cancellationToken); - examined++; + reconciled++; if (result.ActualState is "Failed" or "Partial") { failed++; - failedPolicyIds.Add(result.Id); - } - else if (result.ActualState == "PendingActivation") - { - deferred++; - deferredPolicyIds.Add(result.Id); - } - else if (result.ActualState == (current.DesiredState == "Active" ? "Active" : "Disabled")) - { - converged++; } } finally @@ -154,223 +139,61 @@ public sealed class LinkService( gate.Release(); } } - return new LinkReconciliationResult( - examined, converged, failed, deferred, failedPolicyIds, deferredPolicyIds); + return new LinkReconciliationResult(reconciled, failed); } public async Task ReconcileAllAsync( CancellationToken cancellationToken = default) { - IReadOnlyList factualRules; - try - { - factualRules = await applier.ListRulesAsync(cancellationToken); - } - catch (MeshFirewallUnavailableException) - { - var unavailableCandidates = await store.ListEffectiveLinksAsync(cancellationToken); - return await CompleteFirewallUnavailablePassAsync( - unavailableCandidates, 0, 0, cancellationToken); - } - var candidates = await store.ListEffectiveLinksAsync(cancellationToken); var recoveringFirewall = candidates.Any(candidate => string.Equals(candidate.LastError, FirewallUnavailableCode, StringComparison.Ordinal)); - var factualCounts = factualRules - .GroupBy(static rule => rule) - .ToDictionary(static group => group.Key, static group => group.Count()); - var processedKeys = new HashSet(); - var batch = new FullReconciliationBatch(); - var pendingClassifications = new List<(string Id, string ExpectedState)>(); var examined = 0; - var converged = 0; - var deferred = 0; var failed = 0; - var failedPolicyIds = new List(); - var deferredPolicyIds = new List(); - + var firewallUnavailable = false; foreach (var candidate in candidates) { - var firewallUnavailable = false; - using (await AcquireNodeLocksAsync( - [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken)) + using var nodeLease = await AcquireNodeLocksAsync( + [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken); + var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken); + try { - var candidateRule = ToRule(candidate); - var selected = await store.GetEffectiveLinkAsync(candidateRule, cancellationToken); - if (selected is null) + var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken); + if (current is null || !IsEligibleForFullReconciliation(current)) { continue; } - var gate = await AcquireLinkGateAsync(selected.Id, cancellationToken); - try + var result = await ConvergeAsync( + current, current.DesiredState == "Active", "system:reconcile", cancellationToken); + examined++; + if (result.LastError == FirewallUnavailableCode) { - var current = await store.GetEffectiveLinkAsync(candidateRule, cancellationToken); - if (current is null) - { - continue; - } - var rule = ToRule(current); - if (!processedKeys.Add(rule)) - { - continue; - } - factualCounts.TryGetValue(rule, out var factualCount); - var result = await ConvergeAsync( - current, - current.DesiredState == "Active", - "system:reconcile", - cancellationToken, - factualCount, - batch: batch); - examined++; - if (result.LastError == FirewallUnavailableCode) - { - firewallUnavailable = true; - } - else if (result.ActualState is "Failed" or "Partial") - { - failed++; - failedPolicyIds.Add(result.Id); - } - else if (result.ActualState == "PendingActivation") - { - deferred++; - deferredPolicyIds.Add(result.Id); - } - else if (result.ActualState == (current.DesiredState == "Active" ? "Active" : "Disabled")) - { - converged++; - } - else if (batch.Contains(result.Id)) - { - pendingClassifications.Add(( - result.Id, - current.DesiredState == "Active" ? "Active" : "Disabled")); - } - else - { - failed++; - failedPolicyIds.Add(result.Id); - } + firewallUnavailable = true; } - finally - { - gate.Release(); - } - } - if (firewallUnavailable) - { - return await CompleteFirewallUnavailablePassAsync( - candidates, examined, converged, cancellationToken); - } - } - - foreach (var orphan in factualCounts.Keys.Where(rule => !processedKeys.Contains(rule))) - { - var firewallUnavailable = false; - using (await AcquireNodeLocksAsync( - [orphan.SourceNodeId, orphan.TargetNodeId], cancellationToken)) - { - var selected = await store.GetEffectiveLinkAsync(orphan, cancellationToken); - var gate = await AcquireLinkGateAsync( - selected?.Id - ?? $"orphan:{orphan.SourceNodeId}:{orphan.TargetNodeId}:{orphan.Protocol}:{orphan.Port}", - cancellationToken); - try - { - var persisted = await store.GetEffectiveLinkAsync(orphan, cancellationToken); - var target = persisted ?? new LinkPolicy( - $"orphan:{orphan.SourceNodeId}:{orphan.TargetNodeId}:{orphan.Protocol}:{orphan.Port}", - orphan.SourceNodeId, - orphan.TargetNodeId, - orphan.Protocol, - orphan.Port, - 0, - "factual orphan", - "Disabled", - "Active", - 0, - DateTimeOffset.UtcNow, - null, - DateTimeOffset.UtcNow, - null); - var result = await ConvergeAsync( - target, - expectedConnected: persisted?.DesiredState == "Active", - "system:reconcile", - cancellationToken, - factualCounts[orphan], - persisted: persisted is not null, - batch: batch); - examined++; - var expectedState = persisted?.DesiredState == "Active" ? "Active" : "Disabled"; - if (result.LastError == FirewallUnavailableCode) - { - firewallUnavailable = true; - } - else if (result.ActualState == expectedState) - { - converged++; - } - else if (result.ActualState == "PendingActivation") - { - deferred++; - deferredPolicyIds.Add(result.Id); - } - else if (batch.Contains(result.Id)) - { - pendingClassifications.Add((result.Id, expectedState)); - } - else - { - failed++; - failedPolicyIds.Add(result.Id); - } - } - finally - { - gate.Release(); - } - } - if (firewallUnavailable) - { - return await CompleteFirewallUnavailablePassAsync( - candidates, examined, converged, cancellationToken); - } - } - - if (batch.MutationAttempted) - { - IReadOnlyDictionary finalized; - try - { - finalized = await FinalizeBatchAsync(batch, cancellationToken); - } - catch (MeshFirewallUnavailableException) - { - return await CompleteFirewallUnavailablePassAsync( - candidates, examined, converged, cancellationToken); - } - foreach (var pendingClassification in pendingClassifications) - { - var result = finalized[pendingClassification.Id]; - if (result.ActualState == pendingClassification.ExpectedState) - { - converged++; - } - else if (result.ActualState == "PendingActivation") - { - deferred++; - deferredPolicyIds.Add(result.Id); - } - else + else if (result.ActualState is "Failed" or "Partial") { failed++; - failedPolicyIds.Add(result.Id); } } + finally + { + gate.Release(); + } + if (firewallUnavailable) + { + break; + } + } + if (firewallUnavailable) + { + await MarkFirewallUnavailableAsync(candidates, cancellationToken); + events.Publish( + FirewallUnavailableCode, + "mesh", + JsonSerializer.Serialize( + new ControlError(FirewallUnavailableCode), SmmJsonContext.Default.ControlError)); + return new LinkFullReconciliationResult(examined, candidates.Count, true); } - if (recoveringFirewall) { events.Publish( @@ -379,14 +202,7 @@ public sealed class LinkService( JsonSerializer.Serialize( new ControlError(FirewallAvailableCode), SmmJsonContext.Default.ControlError)); } - return new LinkFullReconciliationResult( - examined, - converged, - failed, - deferred, - false, - failedPolicyIds, - deferredPolicyIds); + return new LinkFullReconciliationResult(examined, failed, false); } public async Task ExpireDueLinksAsync( @@ -443,17 +259,11 @@ public sealed class LinkService( LinkPolicy link, bool expectedConnected, string actor, - CancellationToken cancellationToken, - int? knownFactualCount = null, - bool persisted = true, - FullReconciliationBatch? batch = null) + CancellationToken cancellationToken) { - var current = persisted - ? await store.GetLinkAsync(link.Id, cancellationToken) ?? link - : link; - if (persisted - && ((current.DesiredState == "Active") != expectedConnected - || !await store.IsEffectiveLinkAsync(current, cancellationToken))) + var current = await store.GetLinkAsync(link.Id, cancellationToken) ?? link; + if ((current.DesiredState == "Active") != expectedConnected + || !await store.IsEffectiveLinkAsync(current, cancellationToken)) { return current; } @@ -464,87 +274,45 @@ public sealed class LinkService( var failureEvent = expectedConnected ? "link.failed" : "link.partial"; try { - var factualCount = knownFactualCount - ?? await CountExactRulesAsync(current, cancellationToken); - var isConnected = factualCount > 0; - var duplicateActiveRule = expectedConnected && factualCount > 1; - var changedFact = isConnected != expectedConnected || duplicateActiveRule; + var isConnected = await applier.IsConnectedAsync(current, cancellationToken); + var changedFact = isConnected != expectedConnected; if (changedFact) { var pendingState = expectedConnected ? "Connecting" : "Disconnecting"; - if (persisted && (current.ActualState != pendingState || current.LastError is not null)) + if (current.ActualState != pendingState || current.LastError is not null) { current = await store.SetLinkActualStateAsync( current.Id, pendingState, null, actor, cancellationToken) ?? current; } - if (duplicateActiveRule) - { - batch?.MarkMutationAttempted(); - await applier.ApplyDisconnectAsync(current, cancellationToken); - if (batch is null) - { - await VerifyExactFactualCountAsync(current, 0, cancellationToken); - } - } - if (expectedConnected) - { - batch?.MarkMutationAttempted(); - await applier.ApplyConnectAsync(current, cancellationToken); - if (batch is null) - { - await VerifyExactFactualCountAsync(current, 1, cancellationToken); - } - } - else if (persisted) - { - batch?.MarkMutationAttempted(); - await applier.ApplyDisconnectAsync(current, cancellationToken); - if (batch is null) - { - await VerifyExactFactualCountAsync(current, 0, cancellationToken); - } - } - else - { - batch?.MarkMutationAttempted(); - await applier.ApplyDisconnectAsync(ToRule(current), cancellationToken); - if (batch is null) - { - await VerifyExactFactualCountAsync(current, 0, cancellationToken); - } - } if (actor.StartsWith("system:", StringComparison.Ordinal)) { Publish("link.reconciling", current); } - if (batch is not null) + if (expectedConnected) { - batch.Stage(current, expectedConnected, actor, persisted); - return current; + await applier.ApplyConnectAsync(current, cancellationToken); } + else + { + await applier.ApplyDisconnectAsync(current, cancellationToken); + } + await VerifyFactualStateAsync(current, expectedConnected, cancellationToken); } - current = await FinalizeConvergenceAsync( - current, expectedConnected, actor, persisted, changedFact, cancellationToken); - } - catch (MeshNodeNotActivatedException) when (expectedConnected) - { - if (persisted) + if (current.ActualState != completedState || current.LastError is not null) { current = await store.SetLinkActualStateAsync( - current.Id, "PendingActivation", NodeNotActivatedCode, actor, cancellationToken) ?? current; - Publish("link.pending-node-activation", current); + current.Id, completedState, null, actor, cancellationToken) ?? current; + Publish(completedEvent, current); } - else + if (changedFact && actor == "system:reconcile") { - current = current with { ActualState = "PendingActivation", LastError = NodeNotActivatedCode }; + Publish(expectedConnected ? "link.reapplied" : "link.orphan-removed", current); } } catch (MeshFirewallUnavailableException) { - current = persisted - ? await store.SetLinkActualStateAsync( - current.Id, failureState, FirewallUnavailableCode, actor, cancellationToken) ?? current - : current with { ActualState = failureState, LastError = FirewallUnavailableCode }; + current = await store.SetLinkActualStateAsync( + current.Id, failureState, FirewallUnavailableCode, actor, cancellationToken) ?? current; if (actor != "system:reconcile") { Publish(failureEvent, current); @@ -552,148 +320,13 @@ public sealed class LinkService( } catch (Exception exception) when (exception is not OperationCanceledException) { - var error = CompactError(exception); - current = persisted - ? await store.SetLinkActualStateAsync( - current.Id, failureState, error, actor, cancellationToken) ?? current - : current with { ActualState = failureState, LastError = error }; + current = await store.SetLinkActualStateAsync( + current.Id, failureState, CompactError(exception), actor, cancellationToken) ?? current; Publish(failureEvent, current); } return current; } - private async Task> FinalizeBatchAsync( - FullReconciliationBatch batch, - CancellationToken cancellationToken) - { - var pendingItems = batch.Pending.ToArray(); - using var nodeLease = await AcquireNodeLocksAsync( - pendingItems.SelectMany(static pending => - new[] { pending.Link.SourceNodeId, pending.Link.TargetNodeId }), - cancellationToken); - var selectedByPendingId = new Dictionary(StringComparer.Ordinal); - foreach (var pending in pendingItems) - { - selectedByPendingId[pending.Link.Id] = await store.GetEffectiveLinkAsync( - ToRule(pending.Link), cancellationToken); - } - using var linkLease = await AcquireLinkGatesAsync( - pendingItems.Select(pending => - selectedByPendingId[pending.Link.Id]?.Id ?? pending.Link.Id), - cancellationToken); - var finalRules = await applier.ListRulesAsync(cancellationToken); - var currentByPendingId = new Dictionary(StringComparer.Ordinal); - foreach (var pending in pendingItems) - { - currentByPendingId[pending.Link.Id] = await store.GetEffectiveLinkAsync( - ToRule(pending.Link), cancellationToken); - } - var finalCounts = finalRules - .GroupBy(static rule => rule) - .ToDictionary(static group => group.Key, static group => group.Count()); - var finalized = new Dictionary(StringComparer.Ordinal); - foreach (var pending in pendingItems) - { - var current = currentByPendingId[pending.Link.Id]; - if (IsStale(pending, current)) - { - finalized.Add(pending.Link.Id, CreateStaleBatchResult(pending, current)); - continue; - } - finalCounts.TryGetValue(ToRule(pending.Link), out var actualCount); - var expectedCount = pending.ExpectedConnected ? 1 : 0; - LinkPolicy result; - if (actualCount == expectedCount) - { - result = await FinalizeConvergenceAsync( - pending.Link, - pending.ExpectedConnected, - pending.Actor, - pending.Persisted, - changedFact: true, - cancellationToken); - } - else - { - result = await FailConvergenceAsync( - pending.Link, - pending.ExpectedConnected, - pending.Actor, - pending.Persisted, - $"Factual Link policy count must be exactly {expectedCount} after application, but was {actualCount}.", - cancellationToken); - } - finalized.Add(pending.Link.Id, result); - } - return finalized; - } - - private static bool IsStale(PendingConvergence pending, LinkPolicy? current) - => pending.Persisted - ? current is null - || current.Id != pending.Link.Id - || current.Version != pending.Link.Version - || (current.DesiredState == "Active") != pending.ExpectedConnected - : current is not null; - - private static LinkPolicy CreateStaleBatchResult( - PendingConvergence pending, - LinkPolicy? current) - => (current ?? pending.Link) with - { - ActualState = pending.ExpectedConnected ? "Failed" : "Partial", - LastError = "Link policy changed before batch finalization." - }; - - private async Task FinalizeConvergenceAsync( - LinkPolicy current, - bool expectedConnected, - string actor, - bool persisted, - bool changedFact, - CancellationToken cancellationToken) - { - var completedState = expectedConnected ? "Active" : "Disabled"; - var completedEvent = expectedConnected ? "link.active" : "link.disabled"; - if (persisted && (current.ActualState != completedState || current.LastError is not null)) - { - current = await store.SetLinkActualStateAsync( - current.Id, completedState, null, actor, cancellationToken) ?? current; - Publish(completedEvent, current); - } - else if (!persisted) - { - current = current with { ActualState = completedState, LastError = null }; - } - if (changedFact && actor == "system:reconcile") - { - if (!expectedConnected) - { - await store.RecordLinkOrphanRemovedAsync(ToRule(current), actor, cancellationToken); - } - Publish(expectedConnected ? "link.reapplied" : "link.orphan-removed", current); - } - return current; - } - - private async Task FailConvergenceAsync( - LinkPolicy current, - bool expectedConnected, - string actor, - bool persisted, - string error, - CancellationToken cancellationToken) - { - var failureState = expectedConnected ? "Failed" : "Partial"; - var failureEvent = expectedConnected ? "link.failed" : "link.partial"; - current = persisted - ? await store.SetLinkActualStateAsync( - current.Id, failureState, error, actor, cancellationToken) ?? current - : current with { ActualState = failureState, LastError = error }; - Publish(failureEvent, current); - return current; - } - private async Task GetCurrentEffectiveAsync( string id, CancellationToken cancellationToken) @@ -714,17 +347,11 @@ public sealed class LinkService( { using var nodeLease = await AcquireNodeLocksAsync( [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken); - var rule = ToRule(candidate); - var selected = await store.GetEffectiveLinkAsync(rule, cancellationToken); - if (selected is null) - { - continue; - } - var gate = await AcquireLinkGateAsync(selected.Id, cancellationToken); + var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken); try { - var current = await store.GetEffectiveLinkAsync(rule, cancellationToken); - if (current is not null) + var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken); + if (current is not null && IsEligibleForFullReconciliation(current)) { await store.SetLinkActualStateAsync( current.Id, "Partial", FirewallUnavailableCode, "system:reconcile", cancellationToken); @@ -737,29 +364,9 @@ public sealed class LinkService( } } - private async Task CompleteFirewallUnavailablePassAsync( - IReadOnlyList candidates, - int examined, - int converged, - CancellationToken cancellationToken) - { - await MarkFirewallUnavailableAsync(candidates, cancellationToken); - events.Publish( - FirewallUnavailableCode, - "mesh", - JsonSerializer.Serialize( - new ControlError(FirewallUnavailableCode), SmmJsonContext.Default.ControlError)); - var failed = examined - converged; - var failedPolicyIds = candidates - .Take(failed) - .Select(static candidate => candidate.Id) - .ToArray(); - return new LinkFullReconciliationResult( - examined, converged, failed, 0, true, failedPolicyIds, []); - } - - private static LinkRule ToRule(LinkPolicy link) - => new(link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port); + private static bool IsEligibleForFullReconciliation(LinkPolicy link) + => link.DesiredState == "Active" + || (link.DesiredState == "Disabled" && link.ActualState != "Disabled"); private void Publish(string type, LinkPolicy link) => events.Publish( @@ -805,50 +412,15 @@ public sealed class LinkService( return gate; } - private async Task AcquireLinkGatesAsync( - IEnumerable ids, + private async Task VerifyFactualStateAsync( + LinkPolicy link, + bool expectedConnected, CancellationToken cancellationToken) { - var gates = ids - .Distinct(StringComparer.Ordinal) - .Order(StringComparer.Ordinal) - .Select(id => _reconciliationLocks.GetOrAdd(id, static _ => new SemaphoreSlim(1, 1))) - .ToArray(); - var acquired = 0; - try - { - foreach (var gate in gates) - { - await gate.WaitAsync(cancellationToken); - acquired++; - } - return new LockLease(gates); - } - catch - { - for (var index = acquired - 1; index >= 0; index--) - { - gates[index].Release(); - } - throw; - } - } - - private async Task CountExactRulesAsync( - LinkPolicy link, - CancellationToken cancellationToken) - => (await applier.ListRulesAsync(cancellationToken)).Count(rule => rule == ToRule(link)); - - private async Task VerifyExactFactualCountAsync( - LinkPolicy link, - int expectedCount, - CancellationToken cancellationToken) - { - var actualCount = await CountExactRulesAsync(link, cancellationToken); - if (actualCount != expectedCount) + if (await applier.IsConnectedAsync(link, cancellationToken) != expectedConnected) { throw new InvalidOperationException( - $"Factual Link policy count must be exactly {expectedCount} after application, but was {actualCount}."); + $"Factual Link policy is {(expectedConnected ? "disabled" : "active")} after application."); } } @@ -866,64 +438,7 @@ public sealed class LinkService( } } } - - private sealed class FullReconciliationBatch - { - private readonly Dictionary _pending = - new(StringComparer.Ordinal); - - public bool MutationAttempted { get; private set; } - public IReadOnlyCollection Pending => _pending.Values; - - public void MarkMutationAttempted() => MutationAttempted = true; - - public void Stage(LinkPolicy link, bool expectedConnected, string actor, bool persisted) - => _pending[link.Id] = new PendingConvergence(link, expectedConnected, actor, persisted); - - public bool Contains(string id) => _pending.ContainsKey(id); - } - - private sealed record PendingConvergence( - LinkPolicy Link, - bool ExpectedConnected, - string Actor, - bool Persisted); } public sealed record LinkExpirationResult(int Disabled, int Failed); -public sealed record LinkFullReconciliationResult -{ - public LinkFullReconciliationResult( - int examined, - int converged, - int failed, - int deferred, - bool firewallUnavailable, - IReadOnlyList failedPolicyIds, - IReadOnlyList deferredPolicyIds) - { - if (converged + failed + deferred != examined) - { - throw new InvalidOperationException( - $"Full Link reconciliation classification invariant failed: examined={examined}, " - + $"converged={converged}, failed={failed}, deferred={deferred}; " - + $"failed IDs=[{string.Join(',', failedPolicyIds)}], " - + $"deferred IDs=[{string.Join(',', deferredPolicyIds)}]."); - } - Examined = examined; - Converged = converged; - Failed = failed; - Deferred = deferred; - FirewallUnavailable = firewallUnavailable; - FailedPolicyIds = failedPolicyIds; - DeferredPolicyIds = deferredPolicyIds; - } - - public int Examined { get; } - public int Converged { get; } - public int Failed { get; } - public int Deferred { get; } - public bool FirewallUnavailable { get; } - public IReadOnlyList FailedPolicyIds { get; } - public IReadOnlyList DeferredPolicyIds { get; } -} +public sealed record LinkFullReconciliationResult(int Examined, int Failed, bool FirewallUnavailable); diff --git a/src/ServerMonitorManager.Control/Program.cs b/src/ServerMonitorManager.Control/Program.cs index 86d62db..9ee3a22 100644 --- a/src/ServerMonitorManager.Control/Program.cs +++ b/src/ServerMonitorManager.Control/Program.cs @@ -44,7 +44,6 @@ builder.Services.AddOptions() && options.MetricRetentionHours is >= 24 and <= 8760 && options.IdempotencyRetentionHours is >= 1 and <= 720 && options.AuditRetentionDays is >= 1 and <= 3650 - && options.LinkRetentionDays is >= 1 and <= 3650 && options.MaintenanceIntervalMinutes is >= 1 and <= 1440 && options.LinkExpirationPollSeconds is >= 1 and <= 300 && options.LinkReconciliationSeconds is >= 30 and <= 3600 diff --git a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj index 40b6087..7d80e8b 100644 --- a/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj +++ b/src/ServerMonitorManager.Control/ServerMonitorManager.Control.csproj @@ -4,15 +4,14 @@ enable enable true - true 0.1.0 ochenstarik-smm-control - - - + + + diff --git a/src/ServerMonitorManager.Control/appsettings.json b/src/ServerMonitorManager.Control/appsettings.json index 83afb4d..d395405 100644 --- a/src/ServerMonitorManager.Control/appsettings.json +++ b/src/ServerMonitorManager.Control/appsettings.json @@ -8,7 +8,6 @@ "MetricRetentionHours": 168, "IdempotencyRetentionHours": 24, "AuditRetentionDays": 90, - "LinkRetentionDays": 90, "MaintenanceIntervalMinutes": 15, "LinkExpirationPollSeconds": 15, "LinkReconciliationSeconds": 300, diff --git a/src/ServerMonitorManager.Control/packages.lock.json b/src/ServerMonitorManager.Control/packages.lock.json new file mode 100644 index 0000000..f4bfc72 --- /dev/null +++ b/src/ServerMonitorManager.Control/packages.lock.json @@ -0,0 +1,90 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.AspNetCore.Authentication.Certificate": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "e2WGVp2QrCHZqhmLMt82nj3AHWyKtAsl3Gu959ruP2SXdlpjwYhhYuCnTxHAqViiiM6croqvu3KMXbBqzfBLvQ==" + }, + "Microsoft.Data.Sqlite": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "58JwZ39lCvRXHfV5O6RQfbHEDu8ZsXI7weRoVRR4jrGRxxkS+qyeuGrN2xD+9VYEICApYr0iTSL6PuU+DdsvPg==", + "dependencies": { + "Microsoft.Data.Sqlite.Core": "10.0.10", + "SQLitePCLRaw.bundle_e_sqlite3": "2.1.11", + "SQLitePCLRaw.core": "2.1.11" + } + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + }, + "SQLitePCLRaw.bundle_e_sqlite3": { + "type": "Direct", + "requested": "[2.1.12, )", + "resolved": "2.1.12", + "contentHash": "mAgscpQMLw5/nfA1Q5oJVAT29yROUo1ifZGbbTpx/lwZpSxMUGoYbKfmvdm8oXER+RzxqBmmQzeBEVKfeHv2nw==", + "dependencies": { + "SQLitePCLRaw.lib.e_sqlite3": "2.1.12", + "SQLitePCLRaw.provider.e_sqlite3": "2.1.12" + } + }, + "Microsoft.Data.Sqlite.Core": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "TPCs0ldm7AWqcKmp6f/Xr+14sat7hx4rHfRlS4RgCURBH2thEWbAKEyX7cCWr63zVJVOJIJZTg2cBiUXa8ys6g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.11" + } + }, + "SQLitePCLRaw.core": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "ETpNw9DY3ckWLgRRAeCHj+GKOuPi61aeczkXhgHexUvqoZBAYg8RYESE2J7O1M7+o6QbdSEZwrw9bfqztUVWXg==" + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + }, + "SQLitePCLRaw.provider.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "W3oH4XIfCzFrgUSDKHhN6N+dgzA5YHOR2VxX8GB6Qy7CyrJJgxPEG8NirgYWlPQC5P2jz2knSsexWu4tDUL33g==", + "dependencies": { + "SQLitePCLRaw.core": "2.1.12" + } + }, + "servermonitormanager.core": { + "type": "Project" + } + }, + "net10.0/linux-arm64": { + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } + }, + "net10.0/linux-x64": { + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } + }, + "net10.0/win-x64": { + "SQLitePCLRaw.lib.e_sqlite3": { + "type": "Transitive", + "resolved": "2.1.12", + "contentHash": "fWi8Dbknuhgg72fWinIdjXVaqO1hHL4YBBwVLnr7e1c9TAZwJ0QE38j9syW1hwx6HaqEVTwI+O07WPdZn8Rp0w==" + } + } + } +} \ No newline at end of file diff --git a/src/ServerMonitorManager.Core/packages.lock.json b/src/ServerMonitorManager.Core/packages.lock.json new file mode 100644 index 0000000..cf4d330 --- /dev/null +++ b/src/ServerMonitorManager.Core/packages.lock.json @@ -0,0 +1,16 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + } + }, + "net10.0/linux-arm64": {}, + "net10.0/linux-x64": {}, + "net10.0/win-x64": {} + } +} \ No newline at end of file diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs index 9d9bb7c..8b953ce 100644 --- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs +++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs @@ -1059,7 +1059,6 @@ public sealed partial class MainPage : Page .GroupBy(link => new { link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port }) .Select(group => group.MaxBy(link => link.Version)!) .Any(link => link.LastError == MeshLinkViewModel.FirewallUnavailableErrorCode)); - _linksPage?.RefreshFilter(); var activeLinks = MeshLinks.Count(link => link.ActualState == "Active"); ActiveLinksValueText.Text = activeLinks.ToString(CultureInfo.InvariantCulture); @@ -1266,14 +1265,10 @@ public sealed partial class MainPage : Page await RefreshMeshAsync(showSuccess: false); ShowInfo( enable ? "Control Link создан" : "Control Link отключён", - link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode - ? $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · ожидает активации Node в Mesh" - : $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}", - link.LastError == MeshLinkViewModel.NodeNotActivatedErrorCode - ? InfoBarSeverity.Informational - : link.ActualState is "Failed" or "Partial" - ? InfoBarSeverity.Warning - : InfoBarSeverity.Success); + $"{source.Name} → {target.Name} · {protocol.ToUpperInvariant()}/{port} · {link.ActualState} v{link.Version}", + link.ActualState is "Failed" or "Partial" + ? InfoBarSeverity.Warning + : InfoBarSeverity.Success); return; } diff --git a/src/ServerMonitorManager.Desktop/MeshModels.cs b/src/ServerMonitorManager.Desktop/MeshModels.cs index dcf0dd5..79446d2 100644 --- a/src/ServerMonitorManager.Desktop/MeshModels.cs +++ b/src/ServerMonitorManager.Desktop/MeshModels.cs @@ -20,7 +20,6 @@ public sealed class MeshNodeViewModel public sealed class MeshLinkViewModel { public const string FirewallUnavailableErrorCode = "mesh.firewall-unavailable"; - public const string NodeNotActivatedErrorCode = "mesh.node-not-activated"; public MeshLinkViewModel( string source, @@ -64,13 +63,9 @@ public sealed class MeshLinkViewModel public string? LastError { get; set; } public bool HasDrift => !string.Equals(DesiredState, ActualState, StringComparison.Ordinal); public string DesiredStatusText => $"Желаемое состояние: {DesiredState}"; - public string ActualStatusText => LastError == NodeNotActivatedErrorCode - ? "Фактическое состояние: ожидает активации Node в Mesh" - : $"Фактическое состояние: {ActualState}"; - public string DriftText => LastError == NodeNotActivatedErrorCode - ? "Ожидание: активируйте Node в Mesh — это не ошибка политики" - : HasDrift ? "Расхождение: требуется сверка политики" : "Расхождение: нет"; - public string ErrorText => LastError is FirewallUnavailableErrorCode or NodeNotActivatedErrorCode ? string.Empty + public string ActualStatusText => $"Фактическое состояние: {ActualState}"; + public string DriftText => HasDrift ? "Расхождение: требуется сверка политики" : "Расхождение: нет"; + public string ErrorText => LastError == FirewallUnavailableErrorCode ? string.Empty : string.IsNullOrWhiteSpace(LastError) ? "Ошибка: нет" : $"Ошибка: {LastError}"; public string VersionText => $"Версия политики: {Version}"; public string ExpirationText => ExpiresUnix == 0 diff --git a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml index 4370c18..f7a3415 100644 --- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml +++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml @@ -77,15 +77,8 @@ - - - - - - - - - + + diff --git a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs index d2e26a2..4eb545f 100644 --- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs +++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs @@ -12,34 +12,11 @@ public sealed partial class LinksPage : Page { _host = host; InitializeComponent(); - RefreshFilter(); } public ObservableCollection Nodes => _host.MeshNodes; - public ObservableCollection DisplayedLinks { get; } = []; - - internal void RefreshFilter() - { - var selectedId = (LinksList.SelectedItem as MeshLinkViewModel)?.Id; - var effective = _host.MeshLinks - .GroupBy(link => new { link.Source, link.Target, link.Protocol, link.Port }) - .Select(group => group.MaxBy(link => link.Version)!) - .ToArray(); - var source = ShowHistoryToggle.IsOn - ? _host.MeshLinks - : effective.Where(link => link.DesiredState == "Active" || link.HasDrift); - DisplayedLinks.Clear(); - foreach (var link in source) - { - DisplayedLinks.Add(link); - } - LinksCountText.Text = $"Показано политик: {DisplayedLinks.Count} · фактически Active: {DisplayedLinks.Count(link => link.ActualState == "Active")} · с расхождением: {DisplayedLinks.Count(link => link.HasDrift)}"; - if (selectedId is not null) - { - LinksList.SelectedItem = DisplayedLinks.FirstOrDefault(link => link.Id == selectedId); - } - } + public ObservableCollection Links => _host.MeshLinks; internal void SetFirewallUnavailable(bool unavailable) => FirewallUnavailableInfo.IsOpen = unavailable; @@ -47,9 +24,6 @@ public sealed partial class LinksPage : Page private async void RefreshButton_Click(object sender, RoutedEventArgs e) => await _host.RefreshLinksFromPageAsync(); - private void ShowHistoryToggle_Toggled(object sender, RoutedEventArgs e) - => RefreshFilter(); - private async void ConnectButton_Click(object sender, RoutedEventArgs e) => await ChangeLinkAsync(enable: true); diff --git a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj index a4b5213..052a4a5 100644 --- a/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj +++ b/src/ServerMonitorManager.Desktop/ServerMonitorManager.Desktop.csproj @@ -54,10 +54,10 @@ --> - - - - + + + +