server-monitor-manager/agents/_salvage-2026-08-18/panel-control/Старые задачи/Task3-B2-2026-08-03/Task3-B2.patch
Ochenstarik 23eb3f5233 chore(agents): разбор рабочих папок с диска на 2026-08-18
Задания, отчёты и патчи, лежавшие в C:\Users\Ochenstarik\projects и в
домашней папке, перенесены в agents/. Разложено по агентам там, где имя
файла позволяло определить автора; остальное — в _salvage-2026-08-18/
и разбирается вручную.

Патчи в notes/salvage-2026-08-18/ — незакоммиченная работа из брошенных
рабочих копий: она существовала только на диске.

Тяжёлое (релизные архивы, инсталляторы, наборы данных) в репозиторий не
попало: оно лежит рядом, в Agent_projects/_archive и Agent_projects/_data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:19:54 +07:00

1922 lines
89 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh
index d6957af..d043fbc 100755
--- a/deploy/ochenstarik-server-monitor-manager.sh
+++ b/deploy/ochenstarik-server-monitor-manager.sh
@@ -494,6 +494,7 @@ Control__CertificateAuthorityPath=$ETC_DIR/control-ca.pfx
Control__BackupDirectory=$STATE_DIR/backups
Control__HubHelperPath=$POLICY_HELPER
Control__PrivilegeEscalationPath=/usr/bin/sudo
+Control__LinkReconciliationSeconds=300
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-emergency b/deploy/ochenstarik-smm-emergency
index 51cbad9..40c0415 100755
--- a/deploy/ochenstarik-smm-emergency
+++ b/deploy/ochenstarik-smm-emergency
@@ -1,11 +1,14 @@
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
+umask 077
readonly PROGRAM="ochenstarik-smm-emergency"
readonly STATE_DIR="/var/lib/ochenstarik-server-monitor-manager"
readonly ETC_DIR="/etc/ochenstarik-server-monitor-manager"
readonly MARKER="$STATE_DIR/mesh/emergency-disabled"
+readonly RECONCILE_MARKER="$STATE_DIR/mesh/reconcile-requested"
+readonly RECONCILE_LOCK="$STATE_DIR/mesh/reconcile-requested.lock"
readonly CONTROL_UNIT="ochenstarik-smm-control.service"
readonly AGENT_UNIT="ochenstarik-smm-agent.service"
readonly FIREWALL_UNIT="ochenstarik-smm-firewall.service"
@@ -71,6 +74,25 @@ delete_project_firewall() {
fi
}
+request_reconciliation() {
+ local marker_directory temporary_marker generation
+ marker_directory="$(dirname "$RECONCILE_MARKER")"
+ install -d -o root -g root -m 0700 "$marker_directory"
+ exec 9>"$RECONCILE_LOCK"
+ chown root:root "$RECONCILE_LOCK"
+ chmod 0600 "$RECONCILE_LOCK"
+ /usr/bin/flock -x 9
+ generation="$(</proc/sys/kernel/random/uuid)"
+ temporary_marker="$(mktemp "$marker_directory/.reconcile-requested.XXXXXXXX")"
+ if ! printf '%s\n' "$generation" >"$temporary_marker" \
+ || ! chown root:root "$temporary_marker" \
+ || ! chmod 0600 "$temporary_marker" \
+ || ! mv -f -- "$temporary_marker" "$RECONCILE_MARKER"; then
+ rm -f -- "$temporary_marker"
+ fail "could not create the Link reconciliation request"
+ fi
+}
+
mesh_disable() {
require_root
systemctl disable --now "$WIREGUARD_UNIT" 2>/dev/null || true
@@ -97,6 +119,7 @@ restore_project_firewall() {
if systemctl list-unit-files "$FIREWALL_UNIT" --no-legend 2>/dev/null | grep -q "^$FIREWALL_UNIT"; then
systemctl enable "$FIREWALL_UNIT" >/dev/null
fi
+ request_reconciliation
log "Base deny-by-default Mesh firewall restored; Control must reconcile active Links."
}
@@ -108,6 +131,7 @@ mesh_enable() {
fi
systemctl enable "$WIREGUARD_UNIT" >/dev/null
systemctl restart "$WIREGUARD_UNIT"
+ request_reconciliation
rm -f -- "$MARKER"
log "Mesh enabled locally."
}
diff --git a/deploy/ochenstarik-smm-policy-apply b/deploy/ochenstarik-smm-policy-apply
index 557e01e..87b3392 100755
--- a/deploy/ochenstarik-smm-policy-apply
+++ b/deploy/ochenstarik-smm-policy-apply
@@ -1,23 +1,33 @@
#!/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; }
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:?SMM_POLICY_STATE_FILE is required in testing mode}"
+ 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}$'
validate_node_id() {
[[ "$1" =~ $node_pattern ]] || fail "invalid node id"
@@ -59,20 +69,38 @@ run_nft() {
fi
}
-rule_exists() {
- local comment="$1" listing
+inspect_firewall() {
+ local listing
if [[ "$testing" == "1" ]]; then
- return 1
+ [[ "${SMM_POLICY_FIREWALL_UNAVAILABLE:-0}" != "1" ]] || firewall_unavailable
+ [[ -z "${SMM_POLICY_FIREWALL_ERROR:-}" ]] \
+ || fail "could not inspect nftables Link policy: $SMM_POLICY_FIREWALL_ERROR"
+ return 0
+ fi
+ 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 ! listing="$(/usr/sbin/nft -a list chain "$TABLE_FAMILY" "$TABLE_NAME" "$CHAIN_NAME")"; then
- fail "could not inspect nftables Link policy"
+ 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}"
@@ -84,6 +112,7 @@ 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
@@ -104,6 +133,7 @@ disconnect_rule() {
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
@@ -115,7 +145,49 @@ status_rule() {
fi
}
+reconcile_status() {
+ local generation
+ 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
+ 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" ;;
diff --git a/docs/linux-bootstrap.md b/docs/linux-bootstrap.md
index 13e73c3..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; разрешающие Link-правила после этого должен повторно применить Control. Если 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 b6bf159..da161fc 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -57,6 +57,8 @@
- [x] обязательное восстановление disabled policy после reconnect;
- [x] append-only audit операций Link;
- [x] интеграционные тесты kill switch, process restart и helper failure;
+- [x] B-2: независимая фоновая и emergency-triggered реконсиляция, агрегированное состояние недоступного Mesh firewall и Desktop banner;
+- [ ] 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.Control/ControlOptions.cs b/src/ServerMonitorManager.Control/ControlOptions.cs
index 090369c..3db0896 100644
--- a/src/ServerMonitorManager.Control/ControlOptions.cs
+++ b/src/ServerMonitorManager.Control/ControlOptions.cs
@@ -24,6 +24,8 @@ public sealed class ControlOptions
public int LinkExpirationPollSeconds { get; init; } = 15;
+ public int LinkReconciliationSeconds { get; init; } = 300;
+
public string BackupDirectory { get; init; } = "/var/lib/ochenstarik-server-monitor-manager/backups";
public int BackupIntervalHours { get; init; } = 24;
diff --git a/src/ServerMonitorManager.Control/ControlStore.cs b/src/ServerMonitorManager.Control/ControlStore.cs
index 469294a..f970fe9 100644
--- a/src/ServerMonitorManager.Control/ControlStore.cs
+++ b/src/ServerMonitorManager.Control/ControlStore.cs
@@ -1353,6 +1353,17 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
return link;
}
+ public async Task<LinkPolicy?> GetLinkAsync(
+ string id,
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken);
+ await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken);
+ var link = await ReadLinkAsync(connection, transaction, id, cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+ return link;
+ }
+
public async Task<IReadOnlyList<LinkPolicy>> ListLinksAsync(CancellationToken cancellationToken = default)
{
var result = new List<LinkPolicy>();
@@ -1367,6 +1378,57 @@ public sealed partial class ControlStore(IOptions<ControlOptions> options)
return result;
}
+ public async Task<IReadOnlyList<LinkPolicy>> ListEffectiveLinksAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var result = new List<LinkPolicy>();
+ await using var connection = await OpenAsync(cancellationToken);
+ var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT current.*
+ FROM links AS current
+ 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
+ AND newer.protocol = current.protocol
+ AND newer.port = current.port
+ AND newer.version > current.version)
+ ORDER BY current.version;
+ """;
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ result.Add(ReadLink(reader));
+ }
+ return result;
+ }
+
+ public async Task<bool> IsEffectiveLinkAsync(
+ LinkPolicy link,
+ CancellationToken cancellationToken = default)
+ {
+ await using var connection = await OpenAsync(cancellationToken);
+ var command = connection.CreateCommand();
+ command.CommandText = """
+ SELECT NOT EXISTS(
+ SELECT 1 FROM links
+ WHERE source_node_id = $source
+ AND target_node_id = $target
+ AND protocol = $protocol
+ AND port = $port
+ AND version > $version);
+ """;
+ command.Parameters.AddWithValue("$source", link.SourceNodeId);
+ command.Parameters.AddWithValue("$target", link.TargetNodeId);
+ command.Parameters.AddWithValue("$protocol", link.Protocol);
+ command.Parameters.AddWithValue("$port", link.Port);
+ command.Parameters.AddWithValue("$version", link.Version);
+ return Convert.ToInt32(await command.ExecuteScalarAsync(cancellationToken)) == 1;
+ }
+
public async Task<IReadOnlyList<LinkPolicy>> ListExpiredLinksAsync(
DateTimeOffset now,
CancellationToken cancellationToken = default)
diff --git a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
index 184f636..106a948 100644
--- a/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
+++ b/src/ServerMonitorManager.Control/LinkPolicyApplier.cs
@@ -9,6 +9,17 @@ public interface ILinkPolicyApplier
Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken);
Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken);
Task<bool> IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken);
+ Task<string?> GetReconciliationRequestAsync(CancellationToken cancellationToken)
+ => Task.FromResult<string?>(null);
+ Task CompleteReconciliationAsync(string generation, CancellationToken cancellationToken)
+ => Task.CompletedTask;
+}
+
+public sealed class MeshFirewallUnavailableException : InvalidOperationException
+{
+ public MeshFirewallUnavailableException(string message) : base(message)
+ {
+ }
}
public sealed class LinkPolicyApplier(IOptions<ControlOptions> options) : ILinkPolicyApplier
@@ -55,6 +66,24 @@ public sealed class LinkPolicyApplier(IOptions<ControlOptions> options) : ILinkP
};
}
+ public async Task<string?> GetReconciliationRequestAsync(CancellationToken cancellationToken)
+ => await RunAsync(["reconcile-status"], cancellationToken) switch
+ {
+ "complete" => null,
+ var status when status.StartsWith("requested:", StringComparison.Ordinal)
+ && Guid.TryParseExact(status[10..], "D", out _) => status[10..],
+ _ => throw new InvalidOperationException("Hub policy helper returned an invalid reconciliation marker status.")
+ };
+
+ public async Task CompleteReconciliationAsync(string generation, CancellationToken cancellationToken)
+ {
+ if (!Guid.TryParseExact(generation, "D", out _))
+ {
+ throw new ArgumentException("Invalid reconciliation generation.", nameof(generation));
+ }
+ _ = await RunAsync(["reconcile-complete", generation], cancellationToken);
+ }
+
private async Task<string> RunAsync(IReadOnlyList<string> arguments, CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
@@ -80,6 +109,11 @@ public sealed class LinkPolicyApplier(IOptions<ControlOptions> options) : ILinkP
if (process.ExitCode != 0)
{
var message = (await error).Trim();
+ if (process.ExitCode == 79
+ && string.Equals(message, LinkService.FirewallUnavailableCode, StringComparison.Ordinal))
+ {
+ throw new MeshFirewallUnavailableException(LinkService.FirewallUnavailableCode);
+ }
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
new file mode 100644
index 0000000..ac7abe5
--- /dev/null
+++ b/src/ServerMonitorManager.Control/LinkReconciliationBackgroundService.cs
@@ -0,0 +1,96 @@
+using Microsoft.Extensions.Options;
+
+namespace ServerMonitorManager.Control;
+
+public sealed class LinkReconciliationBackgroundService(
+ LinkService links,
+ ILinkPolicyApplier applier,
+ IOptions<ControlOptions> options,
+ TimeProvider timeProvider,
+ ILogger<LinkReconciliationBackgroundService> logger) : BackgroundService
+{
+ private readonly SemaphoreSlim _passGate = new(1, 1);
+ private DateTimeOffset? _nextRegularAt;
+ private DateTimeOffset? _backoffUntil;
+ private int _unavailableAttempts;
+
+ internal async Task<LinkFullReconciliationResult?> RunOnceAsync(
+ CancellationToken cancellationToken = default)
+ {
+ await _passGate.WaitAsync(cancellationToken);
+ try
+ {
+ var now = timeProvider.GetUtcNow();
+ string? requestGeneration = null;
+ try
+ {
+ requestGeneration = await applier.GetReconciliationRequestAsync(cancellationToken);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ logger.LogWarning(exception, "Could not inspect the Link reconciliation marker.");
+ }
+ if (_backoffUntil is not null && now < _backoffUntil)
+ {
+ return null;
+ }
+ 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);
+ if (result.FirewallUnavailable)
+ {
+ _unavailableAttempts = Math.Min(_unavailableAttempts + 1, 4);
+ _backoffUntil = now + TimeSpan.FromTicks(interval.Ticks * _unavailableAttempts);
+ }
+ else
+ {
+ _unavailableAttempts = 0;
+ _backoffUntil = null;
+ _nextRegularAt = now + interval;
+ if (requestGeneration is not null && result.Failed == 0)
+ {
+ await applier.CompleteReconciliationAsync(requestGeneration, cancellationToken);
+ }
+ }
+ return result;
+ }
+ finally
+ {
+ _passGate.Release();
+ }
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var pollSeconds = Math.Min(options.Value.LinkReconciliationSeconds, 30);
+ using var timer = new PeriodicTimer(TimeSpan.FromSeconds(pollSeconds), timeProvider);
+ do
+ {
+ try
+ {
+ var result = await RunOnceAsync(stoppingToken);
+ if (result is not null)
+ {
+ logger.LogInformation(
+ "Link reconciliation completed: {Examined} examined, {Failed} failed, firewall unavailable: {Unavailable}.",
+ result.Examined,
+ result.Failed,
+ result.FirewallUnavailable);
+ }
+ }
+ catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception exception)
+ {
+ logger.LogError(exception, "Link reconciliation failed.");
+ }
+ }
+ while (await timer.WaitForNextTickAsync(stoppingToken));
+ }
+}
diff --git a/src/ServerMonitorManager.Control/LinkService.cs b/src/ServerMonitorManager.Control/LinkService.cs
index d9b5980..e76176a 100644
--- a/src/ServerMonitorManager.Control/LinkService.cs
+++ b/src/ServerMonitorManager.Control/LinkService.cs
@@ -9,6 +9,8 @@ public sealed class LinkService(
ILinkPolicyApplier applier,
ControlEventBroker events)
{
+ public const string FirewallUnavailableCode = "mesh.firewall-unavailable";
+ public const string FirewallAvailableCode = "mesh.firewall-available";
private readonly ConcurrentDictionary<string, SemaphoreSlim> _reconciliationLocks = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _nodeLocks = new();
@@ -25,35 +27,20 @@ public sealed class LinkService(
{
return link;
}
- var gate = _reconciliationLocks.GetOrAdd(link.Id, static _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken);
+ var gate = await AcquireLinkGateAsync(link.Id, cancellationToken);
try
{
- var current = (await store.ListLinksAsync(cancellationToken))
- .SingleOrDefault(candidate => candidate.Id == link.Id)
+ var current = await store.GetLinkAsync(link.Id, cancellationToken)
?? throw new InvalidOperationException("The persisted Link disappeared.");
- if (current.DesiredState != "Active")
+ if (current.DesiredState != "Active" || !await store.IsEffectiveLinkAsync(current, cancellationToken))
{
return current;
}
- link = current;
- Publish("link.connecting", link);
- try
+ if (!mutation.IsReplay)
{
- await applier.ApplyConnectAsync(link, cancellationToken);
- await VerifyFactualStateAsync(link, expectedConnected: true, cancellationToken);
- link = await store.SetLinkActualStateAsync(link.Id, "Active", null, actor, cancellationToken)
- ?? throw new InvalidOperationException("The persisted Link disappeared.");
- Publish("link.active", link);
+ Publish("link.connecting", current);
}
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- link = await store.SetLinkActualStateAsync(
- link.Id, "Failed", CompactError(exception), actor, cancellationToken)
- ?? link;
- Publish("link.failed", link);
- }
- return link;
+ return await ConvergeAsync(current, expectedConnected: true, actor, cancellationToken);
}
finally
{
@@ -67,8 +54,7 @@ public sealed class LinkService(
string actor,
CancellationToken cancellationToken)
{
- var gate = _reconciliationLocks.GetOrAdd(id, static _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken);
+ var gate = await AcquireLinkGateAsync(id, cancellationToken);
try
{
return await DisableCoreAsync(id, request, actor, cancellationToken);
@@ -99,7 +85,7 @@ public sealed class LinkService(
{
Publish("link.disconnecting", link);
}
- return await ConvergeDisabledCoreAsync(link, actor, cancellationToken);
+ return await ConvergeAsync(link, expectedConnected: false, actor, cancellationToken);
}
internal async Task<LinkPolicy> ConvergeDisabledAsync(
@@ -107,11 +93,10 @@ public sealed class LinkService(
string actor,
CancellationToken cancellationToken)
{
- var gate = _reconciliationLocks.GetOrAdd(link.Id, static _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken);
+ var gate = await AcquireLinkGateAsync(link.Id, cancellationToken);
try
{
- return await ConvergeDisabledCoreAsync(link, actor, cancellationToken);
+ return await ConvergeAsync(link, expectedConnected: false, actor, cancellationToken);
}
finally
{
@@ -130,61 +115,94 @@ public sealed class LinkService(
{
using var nodeLease = await AcquireNodeLocksAsync(
[candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken);
- var gate = _reconciliationLocks.GetOrAdd(candidate.Id, static _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
try
{
- var current = (await store.ListEffectiveLinksForNodeAsync(nodeId, cancellationToken))
- .SingleOrDefault(link => link.Id == candidate.Id);
- if (current is null)
+ var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken);
+ if (current is null || (current.SourceNodeId != nodeId && current.TargetNodeId != nodeId))
{
continue;
}
+ var result = await ConvergeAsync(
+ current,
+ current.DesiredState == "Active",
+ $"system:reconnect:{nodeId}",
+ cancellationToken);
+ reconciled++;
+ if (result.ActualState is "Failed" or "Partial")
+ {
+ failed++;
+ }
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+ return new LinkReconciliationResult(reconciled, failed);
+ }
- var actor = $"system:reconnect:{nodeId}";
- var expectedConnected = current.DesiredState == "Active";
- var pendingState = expectedConnected ? "Connecting" : "Disconnecting";
- var completedState = expectedConnected ? "Active" : "Disabled";
- var completedEvent = expectedConnected ? "link.active" : "link.disabled";
- var failureState = expectedConnected ? "Failed" : "Partial";
- var failureEvent = expectedConnected ? "link.failed" : "link.partial";
- var link = await store.SetLinkActualStateAsync(
- current.Id, pendingState, null, actor, cancellationToken) ?? current;
- Publish("link.reconciling", link);
- try
+ public async Task<LinkFullReconciliationResult> ReconcileAllAsync(
+ CancellationToken cancellationToken = default)
+ {
+ var candidates = await store.ListEffectiveLinksAsync(cancellationToken);
+ var recoveringFirewall = candidates.Any(candidate =>
+ string.Equals(candidate.LastError, FirewallUnavailableCode, StringComparison.Ordinal));
+ var examined = 0;
+ var failed = 0;
+ var firewallUnavailable = false;
+ foreach (var candidate in candidates)
+ {
+ using var nodeLease = await AcquireNodeLocksAsync(
+ [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
+ try
+ {
+ var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken);
+ if (current is null || !IsEligibleForFullReconciliation(current))
+ {
+ continue;
+ }
+ var result = await ConvergeAsync(
+ current, current.DesiredState == "Active", "system:reconcile", cancellationToken);
+ examined++;
+ if (result.LastError == FirewallUnavailableCode)
{
- var isConnected = await applier.IsConnectedAsync(link, cancellationToken);
- if (isConnected != expectedConnected)
- {
- if (expectedConnected)
- {
- await applier.ApplyConnectAsync(link, cancellationToken);
- }
- else
- {
- await applier.ApplyDisconnectAsync(link, cancellationToken);
- }
- await VerifyFactualStateAsync(link, expectedConnected, cancellationToken);
- }
- link = await store.SetLinkActualStateAsync(
- link.Id, completedState, null, actor, cancellationToken) ?? link;
- Publish(completedEvent, link);
+ firewallUnavailable = true;
}
- catch (Exception exception) when (exception is not OperationCanceledException)
+ else if (result.ActualState is "Failed" or "Partial")
{
- link = await store.SetLinkActualStateAsync(
- link.Id, failureState, CompactError(exception), actor, cancellationToken) ?? link;
- Publish(failureEvent, link);
failed++;
}
- reconciled++;
}
finally
{
gate.Release();
}
+ if (firewallUnavailable)
+ {
+ break;
+ }
}
- return new LinkReconciliationResult(reconciled, failed);
+ 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(
+ FirewallAvailableCode,
+ "mesh",
+ JsonSerializer.Serialize(
+ new ControlError(FirewallAvailableCode), SmmJsonContext.Default.ControlError));
+ }
+ return new LinkFullReconciliationResult(examined, failed, false);
}
public async Task<LinkExpirationResult> ExpireDueLinksAsync(
@@ -196,53 +214,36 @@ public sealed class LinkService(
var links = await store.ListExpiredLinksAsync(now, cancellationToken);
foreach (var candidate in links)
{
- var gate = _reconciliationLocks.GetOrAdd(candidate.Id, static _ => new SemaphoreSlim(1, 1));
- await gate.WaitAsync(cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
try
{
- var current = (await store.ListExpiredLinksAsync(now, cancellationToken))
- .SingleOrDefault(link => link.Id == candidate.Id);
- if (current is null)
+ var current = await store.GetLinkAsync(candidate.Id, cancellationToken);
+ if (current is null || current.ExpiresAt is null || current.ExpiresAt > now
+ || !await store.IsEffectiveLinkAsync(current, cancellationToken))
{
continue;
}
-
+ LinkPolicy? result;
if (current.DesiredState == "Active")
{
- var result = await DisableCoreAsync(
+ result = await DisableCoreAsync(
current.Id,
new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
"system:ttl",
cancellationToken);
- if (result?.ActualState == "Disabled")
- {
- disabled++;
- }
- else
- {
- failed++;
- }
- continue;
}
-
- var retrying = await store.SetLinkActualStateAsync(
- current.Id, "Disconnecting", null, "system:ttl-retry", cancellationToken) ?? current;
- Publish("link.disconnecting", retrying);
- try
+ else
+ {
+ Publish("link.disconnecting", current);
+ result = await ConvergeAsync(
+ current, expectedConnected: false, "system:ttl-retry", cancellationToken);
+ }
+ if (result?.ActualState == "Disabled")
{
- await applier.ApplyDisconnectAsync(retrying, cancellationToken);
- await VerifyFactualStateAsync(retrying, expectedConnected: false, cancellationToken);
- var completed = await store.SetLinkActualStateAsync(
- retrying.Id, "Disabled", null, "system:ttl-retry", cancellationToken) ?? retrying;
- Publish("link.disabled", completed);
disabled++;
}
- catch (Exception exception) when (exception is not OperationCanceledException)
+ else
{
- var partial = await store.SetLinkActualStateAsync(
- retrying.Id, "Partial", CompactError(exception), "system:ttl-retry", cancellationToken)
- ?? retrying;
- Publish("link.partial", partial);
failed++;
}
}
@@ -254,6 +255,119 @@ public sealed class LinkService(
return new LinkExpirationResult(disabled, failed);
}
+ private async Task<LinkPolicy> ConvergeAsync(
+ LinkPolicy link,
+ bool expectedConnected,
+ string actor,
+ CancellationToken cancellationToken)
+ {
+ var current = await store.GetLinkAsync(link.Id, cancellationToken) ?? link;
+ if ((current.DesiredState == "Active") != expectedConnected
+ || !await store.IsEffectiveLinkAsync(current, cancellationToken))
+ {
+ return current;
+ }
+
+ var completedState = expectedConnected ? "Active" : "Disabled";
+ var failureState = expectedConnected ? "Failed" : "Partial";
+ var completedEvent = expectedConnected ? "link.active" : "link.disabled";
+ var failureEvent = expectedConnected ? "link.failed" : "link.partial";
+ try
+ {
+ var isConnected = await applier.IsConnectedAsync(current, cancellationToken);
+ var changedFact = isConnected != expectedConnected;
+ if (changedFact)
+ {
+ var pendingState = expectedConnected ? "Connecting" : "Disconnecting";
+ if (current.ActualState != pendingState || current.LastError is not null)
+ {
+ current = await store.SetLinkActualStateAsync(
+ current.Id, pendingState, null, actor, cancellationToken) ?? current;
+ }
+ if (actor.StartsWith("system:", StringComparison.Ordinal))
+ {
+ Publish("link.reconciling", current);
+ }
+ if (expectedConnected)
+ {
+ await applier.ApplyConnectAsync(current, cancellationToken);
+ }
+ else
+ {
+ await applier.ApplyDisconnectAsync(current, cancellationToken);
+ }
+ await VerifyFactualStateAsync(current, expectedConnected, cancellationToken);
+ }
+ if (current.ActualState != completedState || current.LastError is not null)
+ {
+ current = await store.SetLinkActualStateAsync(
+ current.Id, completedState, null, actor, cancellationToken) ?? current;
+ Publish(completedEvent, current);
+ }
+ if (changedFact && actor == "system:reconcile")
+ {
+ Publish(expectedConnected ? "link.reapplied" : "link.orphan-removed", current);
+ }
+ }
+ catch (MeshFirewallUnavailableException)
+ {
+ current = await store.SetLinkActualStateAsync(
+ current.Id, failureState, FirewallUnavailableCode, actor, cancellationToken) ?? current;
+ if (actor != "system:reconcile")
+ {
+ Publish(failureEvent, current);
+ }
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ current = await store.SetLinkActualStateAsync(
+ current.Id, failureState, CompactError(exception), actor, cancellationToken) ?? current;
+ Publish(failureEvent, current);
+ }
+ return current;
+ }
+
+ private async Task<LinkPolicy?> GetCurrentEffectiveAsync(
+ string id,
+ CancellationToken cancellationToken)
+ {
+ var current = await store.GetLinkAsync(id, cancellationToken);
+ if (current is null || !await store.IsEffectiveLinkAsync(current, cancellationToken))
+ {
+ return null;
+ }
+ return current;
+ }
+
+ private async Task MarkFirewallUnavailableAsync(
+ IReadOnlyList<LinkPolicy> candidates,
+ CancellationToken cancellationToken)
+ {
+ foreach (var candidate in candidates)
+ {
+ using var nodeLease = await AcquireNodeLocksAsync(
+ [candidate.SourceNodeId, candidate.TargetNodeId], cancellationToken);
+ var gate = await AcquireLinkGateAsync(candidate.Id, cancellationToken);
+ try
+ {
+ var current = await GetCurrentEffectiveAsync(candidate.Id, cancellationToken);
+ if (current is not null && IsEligibleForFullReconciliation(current))
+ {
+ await store.SetLinkActualStateAsync(
+ current.Id, "Partial", FirewallUnavailableCode, "system:reconcile", cancellationToken);
+ }
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+ }
+
+ private static bool IsEligibleForFullReconciliation(LinkPolicy link)
+ => link.DesiredState == "Active"
+ || (link.DesiredState == "Disabled" && link.ActualState != "Disabled");
+
private void Publish(string type, LinkPolicy link)
=> events.Publish(
type,
@@ -289,38 +403,13 @@ public sealed class LinkService(
}
}
- private async Task<LinkPolicy> ConvergeDisabledCoreAsync(
- LinkPolicy link,
- string actor,
+ private async Task<SemaphoreSlim> AcquireLinkGateAsync(
+ string id,
CancellationToken cancellationToken)
{
- var current = (await store.ListLinksAsync(cancellationToken))
- .SingleOrDefault(candidate => candidate.Id == link.Id) ?? link;
- if (current.DesiredState != "Disabled")
- {
- return current;
- }
- try
- {
- if (await applier.IsConnectedAsync(current, cancellationToken))
- {
- await applier.ApplyDisconnectAsync(current, cancellationToken);
- await VerifyFactualStateAsync(current, expectedConnected: false, cancellationToken);
- }
- if (current.ActualState != "Disabled" || current.LastError is not null)
- {
- current = await store.SetLinkActualStateAsync(
- current.Id, "Disabled", null, actor, cancellationToken) ?? current;
- Publish("link.disabled", current);
- }
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- current = await store.SetLinkActualStateAsync(
- current.Id, "Partial", CompactError(exception), actor, cancellationToken) ?? current;
- Publish("link.partial", current);
- }
- return current;
+ var gate = _reconciliationLocks.GetOrAdd(id, static _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken);
+ return gate;
}
private async Task VerifyFactualStateAsync(
@@ -336,7 +425,7 @@ public sealed class LinkService(
}
private static string CompactError(Exception exception)
- => exception.Message.Split([(char)13, '\n'], StringSplitOptions.RemoveEmptyEntries)
+ => exception.Message.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault() ?? "Policy application failed.";
private sealed class LockLease(SemaphoreSlim[] gates) : IDisposable
@@ -352,3 +441,4 @@ public sealed class LinkService(
}
public sealed record LinkExpirationResult(int Disabled, int Failed);
+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 a898afb..9ee3a22 100644
--- a/src/ServerMonitorManager.Control/Program.cs
+++ b/src/ServerMonitorManager.Control/Program.cs
@@ -46,9 +46,10 @@ builder.Services.AddOptions<ControlOptions>()
&& options.AuditRetentionDays is >= 1 and <= 3650
&& options.MaintenanceIntervalMinutes is >= 1 and <= 1440
&& options.LinkExpirationPollSeconds is >= 1 and <= 300
+ && options.LinkReconciliationSeconds is >= 30 and <= 3600
&& options.BackupIntervalHours is >= 1 and <= 720
&& options.BackupRetentionCount is >= 1 and <= 100,
- "Invalid Control paths, heartbeat, retention, maintenance, expiration, or backup settings.")
+ "Invalid Control paths, heartbeat, retention, maintenance, expiration, reconciliation, or backup settings.")
.ValidateOnStart();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<ControlStore>();
@@ -59,6 +60,7 @@ builder.Services.AddSingleton<LinkService>();
builder.Services.AddSingleton<CertificateLifecycleService>();
builder.Services.AddSingleton<ControlBackupService>();
builder.Services.AddHostedService<LinkExpirationBackgroundService>();
+builder.Services.AddHostedService<LinkReconciliationBackgroundService>();
builder.Services.AddHostedService<ControlMaintenanceBackgroundService>();
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(options =>
diff --git a/src/ServerMonitorManager.Control/appsettings.json b/src/ServerMonitorManager.Control/appsettings.json
index 86942c0..d395405 100644
--- a/src/ServerMonitorManager.Control/appsettings.json
+++ b/src/ServerMonitorManager.Control/appsettings.json
@@ -10,6 +10,7 @@
"AuditRetentionDays": 90,
"MaintenanceIntervalMinutes": 15,
"LinkExpirationPollSeconds": 15,
+ "LinkReconciliationSeconds": 300,
"BackupDirectory": "/var/lib/ochenstarik-server-monitor-manager/backups",
"BackupIntervalHours": 24,
"BackupRetentionCount": 7,
diff --git a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
index 4716803..8b953ce 100644
--- a/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/MainPage.xaml.cs
@@ -28,6 +28,7 @@ public sealed partial class MainPage : Page
private SettingsPage? _settingsPage;
private bool _loaded;
private bool _controlListening;
+ private bool _meshFirewallUnavailable;
public MainPage()
{
@@ -160,7 +161,7 @@ public sealed partial class MainPage : Page
InfoBarSeverity.Warning);
}
- if (Servers.Count > 0)
+ if (Servers.Count > 0 || _control.IsConfigured)
{
await RefreshAllAsync();
}
@@ -449,6 +450,18 @@ public sealed partial class MainPage : Page
{
DispatcherQueue.TryEnqueue(() =>
{
+ if (controlEvent.Type == MeshLinkViewModel.FirewallUnavailableErrorCode)
+ {
+ SetMeshFirewallUnavailable(true);
+ _ = RefreshMeshAsync(showSuccess: false);
+ return;
+ }
+ if (controlEvent.Type == "mesh.firewall-available")
+ {
+ SetMeshFirewallUnavailable(false);
+ _ = RefreshMeshAsync(showSuccess: false);
+ return;
+ }
if (controlEvent.Type.StartsWith("link.", StringComparison.Ordinal))
{
ShowInfo(
@@ -730,6 +743,11 @@ public sealed partial class MainPage : Page
{
if (Servers.Count == 0)
{
+ if (_control.IsConfigured)
+ {
+ await RefreshControlMeshAsync(showSuccess: false);
+ return;
+ }
ShowInfo("Серверы не добавлены", "Сначала создайте SSH-ключ и установите его на сервере.", InfoBarSeverity.Informational);
return;
}
@@ -916,6 +934,12 @@ public sealed partial class MainPage : Page
LinkActionInfo.IsOpen = true;
}
+ private void SetMeshFirewallUnavailable(bool unavailable)
+ {
+ _meshFirewallUnavailable = unavailable;
+ _linksPage?.SetFirewallUnavailable(unavailable);
+ }
+
private ServerViewModel? FindHub()
=> Servers.FirstOrDefault(server => server.IsHub);
@@ -1031,6 +1055,11 @@ public sealed partial class MainPage : Page
link.LastError));
}
+ SetMeshFirewallUnavailable(links
+ .GroupBy(link => new { link.SourceNodeId, link.TargetNodeId, link.Protocol, link.Port })
+ .Select(group => group.MaxBy(link => link.Version)!)
+ .Any(link => link.LastError == MeshLinkViewModel.FirewallUnavailableErrorCode));
+
var activeLinks = MeshLinks.Count(link => link.ActualState == "Active");
ActiveLinksValueText.Text = activeLinks.ToString(CultureInfo.InvariantCulture);
MeshStatusText.Text = $"Control · {MeshNodes.Count} узлов · {activeLinks} активных · {MeshLinks.Count} политик";
@@ -1289,6 +1318,7 @@ public sealed partial class MainPage : Page
break;
case "links":
_linksPage ??= new LinksPage(this);
+ _linksPage.SetFirewallUnavailable(_meshFirewallUnavailable);
ShowNavigationPage(_linksPage);
break;
case "sessions":
diff --git a/src/ServerMonitorManager.Desktop/MeshModels.cs b/src/ServerMonitorManager.Desktop/MeshModels.cs
index c899d62..79446d2 100644
--- a/src/ServerMonitorManager.Desktop/MeshModels.cs
+++ b/src/ServerMonitorManager.Desktop/MeshModels.cs
@@ -19,6 +19,8 @@ public sealed class MeshNodeViewModel
public sealed class MeshLinkViewModel
{
+ public const string FirewallUnavailableErrorCode = "mesh.firewall-unavailable";
+
public MeshLinkViewModel(
string source,
string target,
@@ -63,7 +65,8 @@ public sealed class MeshLinkViewModel
public string DesiredStatusText => $"Желаемое состояние: {DesiredState}";
public string ActualStatusText => $"Фактическое состояние: {ActualState}";
public string DriftText => HasDrift ? "Расхождение: требуется сверка политики" : "Расхождение: нет";
- public string ErrorText => string.IsNullOrWhiteSpace(LastError) ? "Ошибка: нет" : $"Ошибка: {LastError}";
+ 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 32af5f3..f7a3415 100644
--- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml
+++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml
@@ -7,6 +7,7 @@
<Grid RowSpacing="16">
<Grid.RowDefinitions>
+ <RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
@@ -27,7 +28,15 @@
</CommandBar>
</Grid>
- <Grid x:Name="LinksWorkspace" Grid.Row="1" ColumnSpacing="20" RowSpacing="20">
+ <InfoBar
+ x:Name="FirewallUnavailableInfo"
+ Grid.Row="1"
+ IsClosable="False"
+ IsOpen="False"
+ Message="Mesh firewall не загружен"
+ Severity="Error" />
+
+ <Grid x:Name="LinksWorkspace" Grid.Row="2" ColumnSpacing="20" RowSpacing="20">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="EditorColumn" Width="*" />
<ColumnDefinition x:Name="ListColumn" Width="0" />
diff --git a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
index 2669c49..4eb545f 100644
--- a/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
+++ b/src/ServerMonitorManager.Desktop/Pages/LinksPage.xaml.cs
@@ -18,6 +18,9 @@ public sealed partial class LinksPage : Page
public ObservableCollection<MeshLinkViewModel> Links => _host.MeshLinks;
+ internal void SetFirewallUnavailable(bool unavailable)
+ => FirewallUnavailableInfo.IsOpen = unavailable;
+
private async void RefreshButton_Click(object sender, RoutedEventArgs e)
=> await _host.RefreshLinksFromPageAsync();
diff --git a/tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs b/tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs
index 3700663..9e217ca 100644
--- a/tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs
+++ b/tests/ServerMonitorManager.Control.Tests/LinkPolicyApplierIntegrationTests.cs
@@ -26,6 +26,7 @@ public sealed class LinkPolicyApplierIntegrationTests : IAsyncDisposable
var sudoPath = Path.Combine(_directory, "sudo");
var helperPath = Path.Combine(_directory, "policy-helper");
var failureMarkerPath = Path.Combine(_directory, "fail-disconnect");
+ var firewallUnavailableMarkerPath = Path.Combine(_directory, "firewall-unavailable");
var connectedMarkerPath = Path.Combine(_directory, "connected");
var invocationLogPath = Path.Combine(_directory, "helper.log");
await WriteExecutableAsync(
@@ -58,6 +59,10 @@ public sealed class LinkPolicyApplierIntegrationTests : IAsyncDisposable
rm -f '{{ShellQuote(connectedMarkerPath)}}'
fi
if [ "${1:-}" = "link-status" ]; then
+ if [ -f '{{ShellQuote(firewallUnavailableMarkerPath)}}' ]; then
+ echo "mesh.firewall-unavailable" >&2
+ exit 79
+ fi
if [ -f '{{ShellQuote(connectedMarkerPath)}}' ]; then
printf '%s\n' active
else
@@ -79,6 +84,28 @@ public sealed class LinkPolicyApplierIntegrationTests : IAsyncDisposable
cancellationToken);
Assert.Equal("Active", active.ActualState);
+ File.Delete(connectedMarkerPath);
+ await File.WriteAllTextAsync(invocationLogPath, string.Empty, cancellationToken);
+ var restored = await service.ReconcileAllAsync(cancellationToken);
+ Assert.False(restored.FirewallUnavailable);
+ Assert.Equal("Active", (await store.GetLinkAsync(active.Id, cancellationToken))!.ActualState);
+ Assert.Equal(
+ [
+ "link-status ai-agent home tcp 22",
+ "link-connect ai-agent home tcp 22 60",
+ "link-status ai-agent home tcp 22"
+ ],
+ await File.ReadAllLinesAsync(invocationLogPath, cancellationToken));
+
+ await File.WriteAllTextAsync(firewallUnavailableMarkerPath, "fail", cancellationToken);
+ var unavailable = await service.ReconcileAllAsync(cancellationToken);
+ Assert.True(unavailable.FirewallUnavailable);
+ Assert.Equal(LinkService.FirewallUnavailableCode,
+ (await store.GetLinkAsync(active.Id, cancellationToken))!.LastError);
+ File.Delete(firewallUnavailableMarkerPath);
+ Assert.False((await service.ReconcileAllAsync(cancellationToken)).FirewallUnavailable);
+ await File.WriteAllTextAsync(invocationLogPath, string.Empty, cancellationToken);
+
await File.WriteAllTextAsync(failureMarkerPath, "fail", cancellationToken);
var partial = await service.DisableAsync(
active.Id,
@@ -110,16 +137,14 @@ public sealed class LinkPolicyApplierIntegrationTests : IAsyncDisposable
Assert.Equal("Disabled", persisted.ActualState);
var invocations = await File.ReadAllLinesAsync(invocationLogPath, cancellationToken);
- Assert.Equal(9, invocations.Length);
- Assert.Equal("link-connect ai-agent home tcp 22 60", invocations[0]);
- Assert.Equal("link-status ai-agent home tcp 22", invocations[1]);
+ Assert.Equal(7, invocations.Length);
+ Assert.Equal("link-status ai-agent home tcp 22", invocations[0]);
+ Assert.Equal("link-disconnect ai-agent home tcp 22", invocations[1]);
Assert.Equal("link-status ai-agent home tcp 22", invocations[2]);
Assert.Equal("link-disconnect ai-agent home tcp 22", invocations[3]);
Assert.Equal("link-status ai-agent home tcp 22", invocations[4]);
Assert.Equal("link-disconnect ai-agent home tcp 22", invocations[5]);
Assert.Equal("link-status ai-agent home tcp 22", invocations[6]);
- Assert.Equal("link-disconnect ai-agent home tcp 22", invocations[7]);
- Assert.Equal("link-status ai-agent home tcp 22", invocations[8]);
}
public ValueTask DisposeAsync()
diff --git a/tests/ServerMonitorManager.Control.Tests/LinkReconciliationTests.cs b/tests/ServerMonitorManager.Control.Tests/LinkReconciliationTests.cs
new file mode 100644
index 0000000..ceee620
--- /dev/null
+++ b/tests/ServerMonitorManager.Control.Tests/LinkReconciliationTests.cs
@@ -0,0 +1,338 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using ServerMonitorManager.Control;
+using ServerMonitorManager.Core;
+using Xunit;
+
+namespace ServerMonitorManager.Control.Tests;
+
+public sealed class LinkReconciliationTests : IAsyncDisposable
+{
+ private readonly string _directory = Path.Combine(
+ Path.GetTempPath(), $"smm-link-reconciliation-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task AllPolicyPassReappliesErasedActiveRulesWithoutHeartbeat()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "AA11", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "BB22", cancellationToken);
+ await EnrollAgentAsync(store, "target-two", "CC33", cancellationToken);
+ var applier = new RecordingPolicyApplier();
+ var service = new LinkService(store, applier, new ControlEventBroker());
+ var first = await service.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ var second = await service.CreateAsync(CreateRequest("target-two"), "operator", cancellationToken);
+ applier.EraseRules();
+ var mutationsBefore = applier.ConnectCalls;
+
+ var result = await service.ReconcileAllAsync(cancellationToken);
+
+ Assert.Equal(2, result.Examined);
+ Assert.Equal(0, result.Failed);
+ Assert.Equal(mutationsBefore + 2, applier.ConnectCalls);
+ Assert.All(await store.ListEffectiveLinksAsync(cancellationToken),
+ link => Assert.Equal("Active", link.ActualState));
+ Assert.True(applier.IsConnected(first));
+ Assert.True(applier.IsConnected(second));
+ }
+
+ [Fact]
+ public async Task SecondUnchangedPassDoesNotMutateFirewall()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "A111", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "B222", cancellationToken);
+ var applier = new RecordingPolicyApplier();
+ var service = new LinkService(store, applier, new ControlEventBroker());
+ await service.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+
+ await service.ReconcileAllAsync(cancellationToken);
+ var connects = applier.ConnectCalls;
+ var disconnects = applier.DisconnectCalls;
+ await service.ReconcileAllAsync(cancellationToken);
+
+ Assert.Equal(connects, applier.ConnectCalls);
+ Assert.Equal(disconnects, applier.DisconnectCalls);
+ }
+
+ [Fact]
+ public async Task AllPolicyPassRemovesOrphanRuleForDesiredDisabledLink()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "DD44", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "EE55", cancellationToken);
+ var broker = new ControlEventBroker();
+ using var subscription = broker.Subscribe();
+ var applier = new RecordingPolicyApplier();
+ var service = new LinkService(store, applier, broker);
+ var active = await service.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ await store.BeginDisableLinkMutationAsync(
+ active.Id,
+ new LinkPolicyDisableRequest(Guid.NewGuid().ToString()),
+ "operator",
+ cancellationToken);
+
+ await service.ReconcileAllAsync(cancellationToken);
+
+ Assert.Equal(1, applier.DisconnectCalls);
+ Assert.Equal("Disabled", (await store.GetLinkAsync(active.Id, cancellationToken))!.ActualState);
+ var eventTypes = new List<string>();
+ while (subscription.Reader.TryRead(out var controlEvent))
+ {
+ eventTypes.Add(controlEvent.Type);
+ }
+ Assert.Contains("link.orphan-removed", eventTypes);
+ }
+
+ [Fact]
+ public async Task FirewallUnavailableAbortsWithoutMutationsAndPublishesOneSharedEvent()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "FF66", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "0011", cancellationToken);
+ await EnrollAgentAsync(store, "target-two", "0022", cancellationToken);
+ var broker = new ControlEventBroker();
+ using var subscription = broker.Subscribe();
+ var applier = new RecordingPolicyApplier();
+ var service = new LinkService(store, applier, broker);
+ await service.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ await service.CreateAsync(CreateRequest("target-two"), "operator", cancellationToken);
+ while (subscription.Reader.TryRead(out _)) { }
+ var connectBefore = applier.ConnectCalls;
+ var disconnectBefore = applier.DisconnectCalls;
+ applier.FirewallUnavailable = true;
+
+ var result = await service.ReconcileAllAsync(cancellationToken);
+
+ Assert.True(result.FirewallUnavailable);
+ Assert.Equal(connectBefore, applier.ConnectCalls);
+ Assert.Equal(disconnectBefore, applier.DisconnectCalls);
+ Assert.All(await store.ListEffectiveLinksAsync(cancellationToken), link =>
+ {
+ Assert.Equal("Partial", link.ActualState);
+ Assert.Equal(LinkService.FirewallUnavailableCode, link.LastError);
+ });
+ var eventTypes = new List<string>();
+ while (subscription.Reader.TryRead(out var controlEvent))
+ {
+ eventTypes.Add(controlEvent.Type);
+ }
+ Assert.Equal([LinkService.FirewallUnavailableCode], eventTypes);
+
+ applier.FirewallUnavailable = false;
+ Assert.False((await service.ReconcileAllAsync(cancellationToken)).FirewallUnavailable);
+ eventTypes.Clear();
+ while (subscription.Reader.TryRead(out var controlEvent))
+ {
+ eventTypes.Add(controlEvent.Type);
+ }
+ Assert.Single(eventTypes, eventType => eventType == LinkService.FirewallAvailableCode);
+ }
+
+ [Fact]
+ public async Task MarkerCreatedAfterNormalPassTriggersPromptAdditionalPass()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "A333", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "B444", cancellationToken);
+ var applier = new RecordingPolicyApplier();
+ var links = new LinkService(store, applier, new ControlEventBroker());
+ await links.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ var background = CreateBackgroundService(
+ links,
+ applier,
+ new TestTimeProvider(DateTimeOffset.Parse("2026-08-03T12:00:00Z")));
+
+ Assert.NotNull(await background.RunOnceAsync(cancellationToken));
+ Assert.Equal(0, applier.CompleteReconciliationCalls);
+ applier.ReconciliationRequest = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+
+ Assert.NotNull(await background.RunOnceAsync(cancellationToken));
+ Assert.Equal(2, applier.ReconciliationStatusCalls);
+ Assert.Equal(1, applier.CompleteReconciliationCalls);
+ Assert.Null(applier.ReconciliationRequest);
+ Assert.Null(await background.RunOnceAsync(cancellationToken));
+ }
+
+ [Fact]
+ public async Task CompletingObservedGenerationDoesNotConsumeNewerRequest()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "A355", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "B466", cancellationToken);
+ var generationA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ var generationB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
+ var applier = new RecordingPolicyApplier { ReconciliationRequest = generationA };
+ var links = new LinkService(store, applier, new ControlEventBroker());
+ await links.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ applier.ReplacementRequestOnNextProbe = generationB;
+ var background = CreateBackgroundService(
+ links,
+ applier,
+ new TestTimeProvider(DateTimeOffset.Parse("2026-08-03T12:00:00Z")));
+
+ Assert.NotNull(await background.RunOnceAsync(cancellationToken));
+ Assert.Equal([generationA], applier.CompletedGenerations);
+ Assert.Equal(generationB, applier.ReconciliationRequest);
+
+ Assert.NotNull(await background.RunOnceAsync(cancellationToken));
+ Assert.Equal([generationA, generationB], applier.CompletedGenerations);
+ Assert.Null(applier.ReconciliationRequest);
+ }
+
+ [Fact]
+ public async Task BackgroundPassRetainsMarkerWhenFirewallIsUnavailable()
+ {
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var store = CreateStore();
+ await store.InitializeAsync(cancellationToken);
+ await EnrollAgentAsync(store, "source", "A555", cancellationToken);
+ await EnrollAgentAsync(store, "target-one", "B666", cancellationToken);
+ var applier = new RecordingPolicyApplier
+ {
+ ReconciliationRequest = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
+ };
+ var links = new LinkService(store, applier, new ControlEventBroker());
+ await links.CreateAsync(CreateRequest("target-one"), "operator", cancellationToken);
+ applier.FirewallUnavailable = true;
+ var time = new TestTimeProvider(DateTimeOffset.Parse("2026-08-03T12:00:00Z"));
+ var background = CreateBackgroundService(links, applier, time);
+
+ var result = await background.RunOnceAsync(cancellationToken);
+
+ Assert.NotNull(result);
+ Assert.True(result.FirewallUnavailable);
+ Assert.Equal(0, applier.CompleteReconciliationCalls);
+ Assert.NotNull(applier.ReconciliationRequest);
+ applier.FirewallUnavailable = false;
+ Assert.Null(await background.RunOnceAsync(cancellationToken));
+ time.Advance(TimeSpan.FromSeconds(30));
+ Assert.NotNull(await background.RunOnceAsync(cancellationToken));
+ Assert.Null(applier.ReconciliationRequest);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ SqliteConnection.ClearAllPools();
+ if (Directory.Exists(_directory))
+ {
+ Directory.Delete(_directory, recursive: true);
+ }
+ return ValueTask.CompletedTask;
+ }
+
+ private ControlStore CreateStore()
+ {
+ Directory.CreateDirectory(_directory);
+ return new ControlStore(Options.Create(new ControlOptions
+ {
+ DatabasePath = Path.Combine(_directory, "control.db"),
+ CertificateAuthorityPath = Path.Combine(_directory, "unused.pfx")
+ }));
+ }
+
+ private static LinkPolicyCreateRequest CreateRequest(string target)
+ => new("source", target, "tcp", 22, 0, "test", Guid.NewGuid().ToString());
+
+ private static LinkReconciliationBackgroundService CreateBackgroundService(
+ LinkService links,
+ ILinkPolicyApplier applier,
+ TimeProvider timeProvider)
+ => new(
+ links,
+ applier,
+ Options.Create(new ControlOptions { LinkReconciliationSeconds = 30 }),
+ timeProvider,
+ NullLogger<LinkReconciliationBackgroundService>.Instance);
+
+ private static async Task EnrollAgentAsync(
+ ControlStore store,
+ string nodeId,
+ string thumbprint,
+ CancellationToken cancellationToken)
+ {
+ var token = await store.CreateEnrollmentTokenAsync(nodeId, TimeSpan.FromMinutes(10), cancellationToken);
+ Assert.NotNull(await store.EnrollAsync(
+ new EnrollmentRequest(nodeId, token, "csr", Guid.NewGuid().ToString()),
+ () => new IssuedCertificate("certificate", "ca", thumbprint, DateTimeOffset.UtcNow.AddYears(1)),
+ cancellationToken));
+ }
+
+ private sealed class RecordingPolicyApplier : ILinkPolicyApplier
+ {
+ private readonly HashSet<string> _connected = [];
+ public int ConnectCalls { get; private set; }
+ public int DisconnectCalls { get; private set; }
+ public bool FirewallUnavailable { get; set; }
+ public string? ReconciliationRequest { get; set; }
+ public string? ReplacementRequestOnNextProbe { get; set; }
+ public int ReconciliationStatusCalls { get; private set; }
+ public int CompleteReconciliationCalls { get; private set; }
+ public List<string> CompletedGenerations { get; } = [];
+
+ public void EraseRules() => _connected.Clear();
+ public bool IsConnected(LinkPolicy link) => _connected.Contains(link.Id);
+
+ public Task ApplyConnectAsync(LinkPolicy link, CancellationToken cancellationToken)
+ {
+ ConnectCalls++;
+ _connected.Add(link.Id);
+ return Task.CompletedTask;
+ }
+
+ public Task ApplyDisconnectAsync(LinkPolicy link, CancellationToken cancellationToken)
+ {
+ DisconnectCalls++;
+ _connected.Remove(link.Id);
+ return Task.CompletedTask;
+ }
+
+ public Task<bool> IsConnectedAsync(LinkPolicy link, CancellationToken cancellationToken)
+ {
+ if (ReplacementRequestOnNextProbe is not null)
+ {
+ ReconciliationRequest = ReplacementRequestOnNextProbe;
+ ReplacementRequestOnNextProbe = null;
+ }
+ return FirewallUnavailable
+ ? Task.FromException<bool>(new MeshFirewallUnavailableException(LinkService.FirewallUnavailableCode))
+ : Task.FromResult(IsConnected(link));
+ }
+
+ public Task<string?> GetReconciliationRequestAsync(CancellationToken cancellationToken)
+ {
+ ReconciliationStatusCalls++;
+ return Task.FromResult(ReconciliationRequest);
+ }
+
+ public Task CompleteReconciliationAsync(string generation, CancellationToken cancellationToken)
+ {
+ CompleteReconciliationCalls++;
+ CompletedGenerations.Add(generation);
+ if (ReconciliationRequest == generation)
+ {
+ ReconciliationRequest = null;
+ }
+ return Task.CompletedTask;
+ }
+ }
+
+ private sealed class TestTimeProvider(DateTimeOffset now) : TimeProvider
+ {
+ public override DateTimeOffset GetUtcNow() => now;
+ public void Advance(TimeSpan value) => now += value;
+ }
+}
diff --git a/tests/acceptance/three-server-mesh.sh b/tests/acceptance/three-server-mesh.sh
index 761ba2f..5f45d6f 100644
--- a/tests/acceptance/three-server-mesh.sh
+++ b/tests/acceptance/three-server-mesh.sh
@@ -22,6 +22,7 @@ SOURCE_NODE_ID="${SOURCE_NODE_ID:-ai-agent}"
HOME_NODE_ID="${HOME_NODE_ID:-home}"
SECOND_NODE_ID="${SECOND_NODE_ID:-second}"
TARGET_PORT="${TARGET_PORT:-22}"
+LINK_RECONCILIATION_SECONDS="${LINK_RECONCILIATION_SECONDS:-300}"
CONTROL_DEVICE_ID="acceptance-$(date +%s)"
INSTALLER_COMMAND="${INSTALLER_COMMAND:-sudo /usr/local/sbin/ochenstarik-server-monitor-manager.sh}"
WORK_DIRECTORY="$(mktemp -d)"
@@ -48,31 +49,54 @@ source_ssh() {
"$SOURCE_SSH_USER@$SOURCE_SSH_HOST" "$@"
}
+probe_reachable() {
+ local ip="$1"
+ source_ssh "nc -z -w 5 '$ip' '$TARGET_PORT'"
+}
+
expect_reachable() {
- local ip="$1"
- source_ssh "nc -z -w 5 '$ip' '$TARGET_PORT'"
+ local ip="$1"
+ probe_reachable "$ip" || {
+ echo "Expected access from $SOURCE_NODE_ID to $ip:$TARGET_PORT" >&2
+ exit 1
+ }
+}
+
+probe_blocked() {
+ local ip="$1"
+ ! probe_reachable "$ip"
}
expect_blocked() {
- local ip="$1"
- if source_ssh "nc -z -w 5 '$ip' '$TARGET_PORT'"; then
- echo "Unexpected access from $SOURCE_NODE_ID to $ip:$TARGET_PORT" >&2
- exit 1
- fi
+ local ip="$1"
+ if ! probe_blocked "$ip"; then
+ echo "Unexpected access from $SOURCE_NODE_ID to $ip:$TARGET_PORT" >&2
+ exit 1
+ fi
}
-expect_factual_status() {
- local target="$1"
- local expected="$2"
- local command output
+probe_factual_status() {
+ local target="$1"
+ local expected="$2"
+ local command output
printf -v command '%q ' sudo /usr/local/libexec/ochenstarik-smm-policy-apply \
link-status "$SOURCE_NODE_ID" "$target" tcp "$TARGET_PORT"
output="$(hub_ssh "$command")"
- [[ "$output" == "$expected" ]] || {
- echo "Unexpected factual Link status for $SOURCE_NODE_ID -> $target: $output (expected $expected)" >&2
- exit 1
+ [[ "$output" == "$expected" ]]
+ }
+
+ expect_factual_status() {
+ local target="$1"
+ local expected="$2"
+ if ! probe_factual_status "$target" "$expected"; then
+ local command output
+ printf -v command '%q ' sudo /usr/local/libexec/ochenstarik-smm-policy-apply \
+ link-status "$SOURCE_NODE_ID" "$target" tcp "$TARGET_PORT"
+ output="$(hub_ssh "$command")"
+ echo "Unexpected factual Link status for $SOURCE_NODE_ID -> $target: $output (expected $expected)" >&2
+ exit 1
+ fi
}
-}
decode_base64url() {
local value="${1//-/+}"
@@ -85,11 +109,11 @@ decode_base64url() {
printf '%s' "$value" | base64 --decode
}
-echo '[1/11] Checking installed services on all three nodes'
+echo '[1/12] Checking installed services on all three nodes'
hub_ssh "sudo systemctl is-active ochenstarik-smm-control.service >/dev/null"
source_ssh "sudo systemctl is-active ochenstarik-smm-agent.service >/dev/null"
-echo '[2/11] Creating an isolated operator identity for this acceptance run'
+echo '[2/12] Creating an isolated operator identity for this acceptance run'
device_code="$(hub_ssh "$INSTALLER_COMMAND control-device-code '$CONTROL_DEVICE_ID'" | tr -d '\r' | grep -o 'SMMDEV1-[A-Za-z0-9_-]*' | tail -n1)"
[[ -n "$device_code" ]] || { echo 'Hub did not return SMMDEV1 code' >&2; exit 1; }
decode_base64url "${device_code#SMMDEV1-}" >"$WORK_DIRECTORY/device.env"
@@ -147,7 +171,7 @@ disable_link() {
"$(jq -cn --arg key "$(cat /proc/sys/kernel/random/uuid)" '{idempotencyKey:$key}')"
}
-echo '[3/11] Confirming all expected Agent identities are online'
+echo '[3/12] Confirming all expected Agent identities are online'
agents="$(api_get '/api/v1/control/agents')"
for node in "$SOURCE_NODE_ID" "$HOME_NODE_ID" "$SECOND_NODE_ID"; do
jq -e --arg node "$node" '.[] | select(.nodeId == $node)' <<<"$agents" >/dev/null || {
@@ -156,7 +180,7 @@ for node in "$SOURCE_NODE_ID" "$HOME_NODE_ID" "$SECOND_NODE_ID"; do
}
done
-echo '[4/11] Creating independent Links to home and second server'
+echo '[4/12] Creating independent Links to home and second server'
home_link="$(create_link "$HOME_NODE_ID" 0)"
second_link="$(create_link "$SECOND_NODE_ID" 0)"
LINK_HOME_ID="$(jq -r '.id' <<<"$home_link")"
@@ -166,18 +190,18 @@ jq -e '.desiredState == "Active" and .actualState == "Active" and .lastError ==
expect_factual_status "$HOME_NODE_ID" active
expect_factual_status "$SECOND_NODE_ID" active
-echo '[5/11] Verifying routed access through both Links'
+echo '[5/12] Verifying routed access through both Links'
expect_reachable "$HOME_WG_IP"
expect_reachable "$SECOND_WG_IP"
-echo '[6/11] Disabling only the second Link'
+echo '[6/12] Disabling only the second Link'
disable_link "$LINK_SECOND_ID" | jq -e \
'.desiredState == "Disabled" and .actualState == "Disabled" and .lastError == null' >/dev/null
expect_factual_status "$SECOND_NODE_ID" disabled
expect_reachable "$HOME_WG_IP"
expect_blocked "$SECOND_WG_IP"
-echo '[7/11] Verifying automatic TTL expiration'
+echo '[7/12] Verifying automatic TTL expiration'
ttl_link="$(create_link "$SECOND_NODE_ID" 1)"
ttl_id="$(jq -r '.id' <<<"$ttl_link")"
expect_reachable "$SECOND_WG_IP"
@@ -196,13 +220,34 @@ expect_factual_status "$SECOND_NODE_ID" disabled
expect_blocked "$SECOND_WG_IP"
expect_reachable "$HOME_WG_IP"
-echo '[8/11] Creating and validating a Control backup'
+if [[ "${SMM_ACCEPT_RESTORE:-0}" == '1' ]]; then
+ echo '[8/12] Restoring the base firewall without restarting any Node'
+ hub_ssh 'sudo /usr/local/sbin/ochenstarik-smm-emergency firewall-restore'
+ deadline=$((SECONDS + LINK_RECONCILIATION_SECONDS + 30))
+ while ((SECONDS < deadline)); do
+ if probe_factual_status "$HOME_NODE_ID" active \
+ && probe_factual_status "$SECOND_NODE_ID" disabled \
+ && probe_reachable "$HOME_WG_IP" \
+ && probe_blocked "$SECOND_WG_IP"; then
+ break
+ fi
+ sleep 5
+ done
+ expect_factual_status "$HOME_NODE_ID" active
+ expect_factual_status "$SECOND_NODE_ID" disabled
+ expect_reachable "$HOME_WG_IP"
+ expect_blocked "$SECOND_WG_IP"
+else
+ echo '[8/12] Firewall restore reconciliation skipped; set SMM_ACCEPT_RESTORE=1 to enable it'
+fi
+
+echo '[9/12] Creating and validating a Control backup'
backup_path="$(hub_ssh "sudo systemd-run --wait --pipe --quiet --collect --uid=ochenstarik-smm-control --gid=ochenstarik-smm-control -p EnvironmentFile=/etc/ochenstarik-server-monitor-manager/control.env /usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control backup-create" | grep '/backup-' | tail -n1)"
[[ "$backup_path" == */backup-* ]] || { echo 'Backup command did not return a backup path' >&2; exit 1; }
hub_ssh "sudo test -s '$backup_path/manifest.json' && sudo test -s '$backup_path/control.db' && sudo test -s '$backup_path/control-ca.pfx'"
if [[ "${SMM_ACCEPT_RESTORE:-0}" == '1' ]]; then
- echo '[9/11] Restoring the verified backup and restarting Control'
+ echo '[10/12] Restoring the verified backup and restarting Control'
hub_ssh "sudo sh -c 'set -a; . /etc/ochenstarik-server-monitor-manager/control.env; set +a; systemctl stop ochenstarik-smm-control.service; /usr/local/lib/ochenstarik-server-monitor-manager/control/ochenstarik-smm-control backup-restore \"\$1\"; status=\$?; systemctl start ochenstarik-smm-control.service; exit \$status' sh '$backup_path'"
for _ in {1..30}; do
if api_get '/healthz' >/dev/null 2>&1; then
@@ -216,11 +261,11 @@ if [[ "${SMM_ACCEPT_RESTORE:-0}" == '1' ]]; then
expect_factual_status "$HOME_NODE_ID" active
expect_factual_status "$SECOND_NODE_ID" disabled
else
- echo '[9/11] Restore check skipped; set SMM_ACCEPT_RESTORE=1 to enable it'
+ echo '[10/12] Restore check skipped; set SMM_ACCEPT_RESTORE=1 to enable it'
fi
if [[ "${SMM_ACCEPT_REBOOT:-0}" == '1' ]]; then
- echo '[10/11] Rebooting the Hub and source Node and rechecking policy state'
+ echo '[11/12] Rebooting the Hub and source Node and rechecking policy state'
hub_ssh 'sudo systemctl reboot' || true
source_ssh 'sudo systemctl reboot' || true
sleep 15
@@ -235,10 +280,10 @@ if [[ "${SMM_ACCEPT_REBOOT:-0}" == '1' ]]; then
expect_factual_status "$HOME_NODE_ID" active
expect_factual_status "$SECOND_NODE_ID" disabled
else
- echo '[10/11] Reboot check skipped; set SMM_ACCEPT_REBOOT=1 to enable it'
+ echo '[11/12] Reboot check skipped; set SMM_ACCEPT_REBOOT=1 to enable it'
fi
-echo '[11/11] Revoking the temporary Operator certificate'
+echo '[12/12] Revoking the temporary Operator certificate'
api_post "/api/v1/control/devices/$CONTROL_DEVICE_ID/reenroll" \
"$(jq -cn --arg key "$(cat /proc/sys/kernel/random/uuid)" \
'{reason:"three-server acceptance completed",idempotencyKey:$key}')" \
diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh
index a28687f..3316e60 100755
--- a/tests/bootstrap/test-bootstrap-contract.sh
+++ b/tests/bootstrap/test-bootstrap-contract.sh
@@ -6,11 +6,13 @@ root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
bootstrap="$root/deploy/ochenstarik-server-monitor-manager.sh"
helper="$root/deploy/ochenstarik-smm-policy-apply"
emergency="$root/deploy/ochenstarik-smm-emergency"
+acceptance="$root/tests/acceptance/three-server-mesh.sh"
-grep -Fq 'if ! listing="$(/usr/sbin/nft -a list chain' "$helper" || {
+grep -Fq 'listing="$(/usr/sbin/nft -a list chain' "$helper" || {
printf '%s\n' "policy status probe must fail closed when nftables cannot be inspected" >&2
exit 1
}
+grep -Fq "grep -Eiq 'No such file or directory|does not exist'" "$helper"
provisioning_helper_unit="$root/deploy/ochenstarik-smm-provisioning-helper.service"
grep -Fq 'EnvironmentFile=/etc/ochenstarik-server-monitor-manager/agent.env' "$provisioning_helper_unit"
@@ -61,6 +63,13 @@ grep -Eq '^ochenstarik-server-monitor-manager [0-9]+\.[0-9]+\.[0-9]+-' <<<"$vers
emergency_help="$(bash "$emergency" --help)"
grep -Fq 'mesh-disable' <<<"$emergency_help"
grep -Fq 'firewall-restore' <<<"$emergency_help"
+grep -Fq 'readonly RECONCILE_MARKER="$STATE_DIR/mesh/reconcile-requested"' "$emergency"
+grep -Fq 'chown root:root "$temporary_marker"' "$emergency"
+grep -Fq 'chmod 0600 "$temporary_marker"' "$emergency"
+grep -Fq 'mv -f -- "$temporary_marker" "$RECONCILE_MARKER"' "$emergency"
+grep -Fq '/usr/bin/flock -x 9' "$emergency"
+grep -Fq 'generation="$(</proc/sys/kernel/random/uuid)"' "$emergency"
+[[ "$(grep -Fc ' request_reconciliation' "$emergency")" -ge 2 ]]
if bash "$bootstrap" unsupported-action >/dev/null 2>&1; then
printf '%s\n' "unsupported bootstrap action unexpectedly succeeded" >&2
@@ -97,8 +106,83 @@ if SMM_POLICY_TESTING=1 SMM_POLICY_STATE_FILE="$policy_state" \
printf '%s\n' "policy helper unexpectedly accepted extra link-status arguments" >&2
exit 1
fi
+
+generation_a='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
+generation_b='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
+reconcile_marker="$(mktemp -t smm-reconcile-marker.XXXXXXXX)"
+printf '%s\n' "$generation_a" >"$reconcile_marker"
+[[ "$(SMM_POLICY_TESTING=1 SMM_POLICY_FLOCK=true SMM_POLICY_RECONCILE_MARKER="$reconcile_marker" \
+ bash "$helper" reconcile-status)" == "requested:$generation_a" ]]
+printf '%s\n' "$generation_b" >"$reconcile_marker"
+SMM_POLICY_TESTING=1 SMM_POLICY_FLOCK=true SMM_POLICY_RECONCILE_MARKER="$reconcile_marker" \
+ bash "$helper" reconcile-complete "$generation_a" >/dev/null
+[[ "$(<"$reconcile_marker")" == "$generation_b" ]]
+SMM_POLICY_TESTING=1 SMM_POLICY_FLOCK=true SMM_POLICY_RECONCILE_MARKER="$reconcile_marker" \
+ bash "$helper" reconcile-complete "$generation_b" >/dev/null
+[[ ! -e "$reconcile_marker" ]]
+[[ "$(SMM_POLICY_TESTING=1 SMM_POLICY_FLOCK=true SMM_POLICY_RECONCILE_MARKER="$reconcile_marker" \
+ bash "$helper" reconcile-status)" == 'complete' ]]
+if SMM_POLICY_TESTING=1 SMM_POLICY_FLOCK=true SMM_POLICY_RECONCILE_MARKER="$reconcile_marker" \
+ bash "$helper" reconcile-complete unexpected extra >/dev/null 2>&1; then
+ printf '%s\n' "policy helper unexpectedly accepted extra reconcile-complete arguments" >&2
+ exit 1
+fi
+
+firewall_error="$(mktemp -t smm-firewall-error.XXXXXXXX)"
+if SMM_POLICY_TESTING=1 SMM_POLICY_STATE_FILE="$policy_state" \
+ SMM_POLICY_FIREWALL_UNAVAILABLE=1 bash "$helper" \
+ link-status source target tcp 22 >/dev/null 2>"$firewall_error"; then
+ printf '%s\n' "missing firewall unexpectedly produced a factual Link status" >&2
+ exit 1
+else
+ [[ $? -eq 79 ]]
+fi
+[[ "$(<"$firewall_error")" == 'mesh.firewall-unavailable' ]]
+if SMM_POLICY_TESTING=1 SMM_POLICY_STATE_FILE="$policy_state" \
+ SMM_POLICY_FIREWALL_ERROR='permission denied' bash "$helper" \
+ link-status source target tcp 22 >/dev/null 2>"$firewall_error"; then
+ printf '%s\n' "unknown nft inspection error unexpectedly produced a factual Link status" >&2
+ exit 1
+else
+ [[ $? -eq 78 ]]
+fi
+grep -Fq 'permission denied' "$firewall_error"
+if grep -Fq 'mesh.firewall-unavailable' "$firewall_error"; then
+ printf '%s\n' "unknown nft inspection error was misclassified as missing firewall" >&2
+ exit 1
+fi
+rm -f -- "$firewall_error" "$reconcile_marker"
rm -f -- "$policy_state"
+extract_shell_function() {
+ local name="$1"
+ awk -v signature="$name() {" '
+ $0 == signature { emitting = 1 }
+ emitting { print }
+ emitting && $0 == "}" { exit }
+ ' "$acceptance"
+}
+eval "$(extract_shell_function probe_factual_status)"
+SOURCE_NODE_ID=source
+TARGET_PORT=22
+probe_counter="$(mktemp -t smm-factual-probe.XXXXXXXX)"
+printf '%s\n' 0 >"$probe_counter"
+hub_ssh() {
+ local count
+ count="$(( $(<"$probe_counter") + 1 ))"
+ printf '%s\n' "$count" >"$probe_counter"
+ [[ "$count" -eq 1 ]] && printf '%s\n' disabled || printf '%s\n' active
+}
+if probe_factual_status target active; then
+ printf '%s\n' "initial factual mismatch unexpectedly passed" >&2
+ exit 1
+fi
+probe_factual_status target active || {
+ printf '%s\n' "factual probe did not allow convergence after an initial mismatch" >&2
+ exit 1
+}
+rm -f -- "$probe_counter" "${reconcile_marker}.lock"
+
fixture="$(mktemp -d -t smm-bootstrap-test.XXXXXXXX)"
trap 'rm -rf -- "$fixture"' EXIT
mkdir -p "$fixture/payload/agent" "$fixture/payload/control" "$fixture/payload/provisioning-helper" "$fixture/payload/deploy" "$fixture/payload/bootstrap"
diff --git a/tests/windows/Test-DesktopContracts.ps1 b/tests/windows/Test-DesktopContracts.ps1
index 73a8028..f5ac6f9 100644
--- a/tests/windows/Test-DesktopContracts.ps1
+++ b/tests/windows/Test-DesktopContracts.ps1
@@ -19,6 +19,8 @@ $linksCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\Pages\LinksPage.xaml.cs')
$mainCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\MainPage.xaml.cs')
+$meshModelsCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
+ Join-Path $root 'src\ServerMonitorManager.Desktop\MeshModels.cs')
$appCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
Join-Path $root 'src\ServerMonitorManager.Desktop\App.xaml.cs')
$sshCode = Get-Content -Raw -Encoding UTF8 -LiteralPath (
@@ -52,6 +54,22 @@ if ($linksCode.IndexOf(
'LinksList.SelectedItem as MeshLinkViewModel', [StringComparison]::Ordinal) -lt 0) {
throw 'Links page must pass its selected Link to the command handler.'
}
+if ($linksXaml.IndexOf('x:Name="FirewallUnavailableInfo"', [StringComparison]::Ordinal) -lt 0 -or
+ $linksXaml.IndexOf('Message="Mesh firewall ', [StringComparison]::Ordinal) -lt 0) {
+ throw 'Links page must expose one persistent Mesh firewall unavailable banner.'
+}
+if ($linksCode.IndexOf('SetFirewallUnavailable', [StringComparison]::Ordinal) -lt 0 -or
+ $mainCode.IndexOf('MeshLinkViewModel.FirewallUnavailableErrorCode', [StringComparison]::Ordinal) -lt 0) {
+ throw 'Desktop must project both event and persisted Mesh firewall unavailable state.'
+}
+if ($meshModelsCode.IndexOf('LastError == FirewallUnavailableErrorCode ? string.Empty',
+ [StringComparison]::Ordinal) -lt 0) {
+ throw 'Shared Mesh firewall errors must be suppressed from individual Link rows.'
+}
+if ($mainCode.IndexOf('Servers.Count > 0 || _control.IsConfigured', [StringComparison]::Ordinal) -lt 0 -or
+ $mainCode.IndexOf('await RefreshControlMeshAsync(showSuccess: false);', [StringComparison]::Ordinal) -lt 0) {
+ throw 'Configured Control state must refresh even when no SSH profiles exist.'
+}
if ($mainCode.IndexOf(
'MeshLinksList.SelectedItem = selectedLink;', [StringComparison]::Ordinal) -lt 0) {
throw 'Main page must synchronize the selected Link before disconnecting it.'