From 5dcd420d34e41ea1d035ddb1d1d3adabea2c4fe7 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 00:17:45 +0700 Subject: [PATCH 1/9] test(desktop): add live release update verification tests (intentional failure proof) --- .../LiveReleaseUpdateVerificationTests.cs | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs new file mode 100644 index 0000000..ddad5b3 --- /dev/null +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs @@ -0,0 +1,231 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using ServerMonitorManager_Desktop; +using Xunit; + +namespace ServerMonitorManager.Desktop.Security.Tests; + +[Trait("Category", "LiveRelease")] +public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable +{ + private const string DefaultReleaseTag = "v0.1.0-alpha.14"; + private const string Repository = "ochenstarik-ui/server-monitor-manager"; + private readonly string _tempDir; + private readonly HttpClient _http; + private readonly string _tag; + + public LiveReleaseUpdateVerificationTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"smm-live-release-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _http = new HttpClient(); + _http.DefaultRequestHeaders.Add("User-Agent", "ServerMonitorManager.Desktop.Tests"); + _tag = Environment.GetEnvironmentVariable("SMM_TEST_RELEASE_TAG") ?? DefaultReleaseTag; + } + + private async Task<(string ManifestPath, string SigPath, string PemPath)> DownloadReleaseArtifactsAsync() + { + var manifestUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.json"; + var sigUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.sig"; + var pemUrl = $"https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.pem"; + + var manifestPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.json"); + var sigPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.sig"); + var pemPath = Path.Combine(_tempDir, "server-monitor-manager-manifest.pem"); + + var manifestBytes = await _http.GetByteArrayAsync(manifestUrl, TestContext.Current.CancellationToken); + var sigBytes = await _http.GetByteArrayAsync(sigUrl, TestContext.Current.CancellationToken); + var pemBytes = await _http.GetByteArrayAsync(pemUrl, TestContext.Current.CancellationToken); + + await File.WriteAllBytesAsync(manifestPath, manifestBytes, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync(sigPath, sigBytes, TestContext.Current.CancellationToken); + await File.WriteAllBytesAsync(pemPath, pemBytes, TestContext.Current.CancellationToken); + + return (manifestPath, sigPath, pemPath); + } + + [Fact] + public async Task Acceptance_RealReleaseManifestAndSignatureAreAccepted() + { + var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + // 1. ProcessSignatureVerifier directly verifies real release material + await verifier.VerifySignatureAsync(sigPath, manifestPath, pemPath, TestContext.Current.CancellationToken); + + // 2. Parse and verify manifest content + var manifestJson = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + var node = JsonNode.Parse(manifestJson); + Assert.NotNull(node); + Assert.Equal(_tag, node["version"]?.GetValue()); + + var msixHash = node["hashes"]?["ServerMonitorManager-win-x64.msix"]?.GetValue(); + Assert.False(string.IsNullOrWhiteSpace(msixHash)); + Assert.Equal(64, msixHash.Length); + + // 3. UpdateService end-to-end against real release material + var mockHttp = new MockHttpTransport + { + GetStringAsyncFunc = url => + { + if (url.EndsWith("releases/latest", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult($@"{{ + ""tag_name"": ""{_tag}"", + ""assets"": [ + {{ ""name"": ""server-monitor-manager-manifest.json"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.json"" }}, + {{ ""name"": ""server-monitor-manager-manifest.sig"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.sig"" }}, + {{ ""name"": ""server-monitor-manager-manifest.pem"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/server-monitor-manager-manifest.pem"" }}, + {{ ""name"": ""ServerMonitorManager-win-x64.msix"", ""browser_download_url"": ""https://github.com/{Repository}/releases/download/{_tag}/ServerMonitorManager-win-x64.msix"" }} + ] + }}"); + } + if (url.EndsWith("server-monitor-manager-manifest.json", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + } + if (url.EndsWith("server-monitor-manager-manifest.sig", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(sigPath, TestContext.Current.CancellationToken); + } + if (url.EndsWith("server-monitor-manager-manifest.pem", StringComparison.OrdinalIgnoreCase)) + { + return File.ReadAllTextAsync(pemPath, TestContext.Current.CancellationToken); + } + throw new InvalidOperationException($"Unexpected URL: {url}"); + } + }; + + var service = new UpdateService(mockHttp, verifier, fileStorage); + var updateInfo = await service.CheckForUpdatesAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(updateInfo); + Assert.Equal("v999.999.999-intentional-failure", updateInfo.Version); + Assert.Equal(msixHash, updateInfo.ExpectedHash); + Assert.Contains(_tag, updateInfo.DownloadUrl, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative1_TamperedHashInRealManifestIsRejected() + { + var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); + + // Alter the manifest content by tampering with the hash + var originalManifest = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); + var tamperedManifest = originalManifest.Replace( + "710813668ac5efacc245472afd4da6dca6739c042b5189bd12c2bbbd3c6b7e19", + "0000000000000000000000000000000000000000000000000000000000000000", + StringComparison.Ordinal); + + var tamperedManifestPath = Path.Combine(_tempDir, "tampered-manifest.json"); + await File.WriteAllTextAsync(tamperedManifestPath, tamperedManifest, TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + // Verification of tampered manifest with real signature must fail + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, tamperedManifestPath, pemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative2_RealManifestWithSignatureFromDifferentIdentityIsRejected() + { + var (manifestPath, _, pemPath) = await DownloadReleaseArtifactsAsync(); + + // Create a fake signature signed by a different key + using var rsa = RSA.Create(); + var fakeSigBytes = rsa.SignData( + await File.ReadAllBytesAsync(manifestPath, TestContext.Current.CancellationToken), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + var fakeSigPath = Path.Combine(_tempDir, "fake-identity.sig"); + await File.WriteAllTextAsync(fakeSigPath, Convert.ToBase64String(fakeSigBytes), TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(fakeSigPath, manifestPath, pemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative3_MissingCertificateIsRejected() + { + var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); + var nonExistentPemPath = Path.Combine(_tempDir, "non-existent-certificate.pem"); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, manifestPath, nonExistentPemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Negative4_CertificateFromDifferentWorkflowIsRejected() + { + var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); + + // Create a custom self-signed certificate with a different subject/workflow identity + using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + "CN=Untrusted Workflow Fake Cert", + ecdsa, + HashAlgorithmName.SHA256); + using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddDays(1)); + var fakePemPath = Path.Combine(_tempDir, "fake-workflow-cert.pem"); + await File.WriteAllTextAsync(fakePemPath, cert.ExportCertificatePem(), TestContext.Current.CancellationToken); + + var fileStorage = new TestDirectoryFileStorage(_tempDir); + var httpTransport = new DefaultHttpTransport(); + var verifier = new ProcessSignatureVerifier(fileStorage, httpTransport); + + var ex = await Assert.ThrowsAsync(() => + verifier.VerifySignatureAsync(sigPath, manifestPath, fakePemPath, TestContext.Current.CancellationToken)); + Assert.Contains("Signature verification failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + public async ValueTask DisposeAsync() + { + _http.Dispose(); + try + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + } + catch + { + // Best effort cleanup + } + await Task.CompletedTask; + } + + private sealed class TestDirectoryFileStorage(string directory) : IFileStorage + { + public string GetTempFolder() => directory; + public bool FileExists(string path) => File.Exists(path); + public Task WriteAllBytesAsync(string path, byte[] bytes, System.Threading.CancellationToken cancellationToken = default) => + File.WriteAllBytesAsync(path, bytes, cancellationToken); + public Task WriteAllTextAsync(string path, string text, System.Threading.CancellationToken cancellationToken = default) => + File.WriteAllTextAsync(path, text, cancellationToken); + public Stream OpenRead(string path) => File.OpenRead(path); + public void LaunchFile(string path) { } + } +} From 279349f44583941d89ff9a6672e7ec9076dcfa2a Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 00:24:26 +0700 Subject: [PATCH 2/9] test(desktop): restore valid release assertion in live update verification tests --- .../LiveReleaseUpdateVerificationTests.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs index ddad5b3..5c62655 100644 --- a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Net.Http; using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json.Nodes; using System.Threading.Tasks; @@ -50,7 +51,7 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable } [Fact] - public async Task Acceptance_RealReleaseManifestAndSignatureAreAccepted() + public async Task AcceptanceRealReleaseManifestAndSignatureAreAccepted() { var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); @@ -108,17 +109,17 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable var updateInfo = await service.CheckForUpdatesAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.NotNull(updateInfo); - Assert.Equal("v999.999.999-intentional-failure", updateInfo.Version); + Assert.Equal(_tag, updateInfo.Version); Assert.Equal(msixHash, updateInfo.ExpectedHash); Assert.Contains(_tag, updateInfo.DownloadUrl, StringComparison.OrdinalIgnoreCase); } [Fact] - public async Task Negative1_TamperedHashInRealManifestIsRejected() + public async Task Negative1TamperedHashInRealManifestIsRejected() { var (manifestPath, sigPath, pemPath) = await DownloadReleaseArtifactsAsync(); - // Alter the manifest content by tampering with the hash + // Alter manifest content by tampering with the hash var originalManifest = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); var tamperedManifest = originalManifest.Replace( "710813668ac5efacc245472afd4da6dca6739c042b5189bd12c2bbbd3c6b7e19", @@ -139,7 +140,7 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable } [Fact] - public async Task Negative2_RealManifestWithSignatureFromDifferentIdentityIsRejected() + public async Task Negative2RealManifestWithSignatureFromDifferentIdentityIsRejected() { var (manifestPath, _, pemPath) = await DownloadReleaseArtifactsAsync(); @@ -162,7 +163,7 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable } [Fact] - public async Task Negative3_MissingCertificateIsRejected() + public async Task Negative3MissingCertificateIsRejected() { var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); var nonExistentPemPath = Path.Combine(_tempDir, "non-existent-certificate.pem"); @@ -177,13 +178,13 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable } [Fact] - public async Task Negative4_CertificateFromDifferentWorkflowIsRejected() + public async Task Negative4CertificateFromDifferentWorkflowIsRejected() { var (manifestPath, sigPath, _) = await DownloadReleaseArtifactsAsync(); // Create a custom self-signed certificate with a different subject/workflow identity using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); - var req = new System.Security.Cryptography.X509Certificates.CertificateRequest( + var req = new CertificateRequest( "CN=Untrusted Workflow Fake Cert", ecdsa, HashAlgorithmName.SHA256); From c9cedd055251f80c1233e39cbad546e6cd3e46c8 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 01:11:28 +0700 Subject: [PATCH 3/9] ci(windows): separate offline and live release desktop security test steps --- .github/workflows/windows-build.yml | 7 ++++++- .../LiveReleaseUpdateVerificationTests.cs | 18 ++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml index 74a8691..c5e3102 100644 --- a/.github/workflows/windows-build.yml +++ b/.github/workflows/windows-build.yml @@ -36,7 +36,12 @@ jobs: 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 -p:RestoreLockedMode=true + run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true --filter Category!=LiveRelease + + - name: Test Desktop security (live release) + env: + SMM_TEST_RELEASE_TAG: v0.1.0-alpha.18 + run: dotnet test tests/ServerMonitorManager.Desktop.Security.Tests/ServerMonitorManager.Desktop.Security.Tests.csproj --configuration Release -p:RestoreLockedMode=true --filter Category=LiveRelease - name: Build test-signed MSIX installer shell: pwsh diff --git a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs index 5c62655..5e8ab03 100644 --- a/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs +++ b/tests/ServerMonitorManager.Desktop.Security.Tests/LiveReleaseUpdateVerificationTests.cs @@ -121,10 +121,20 @@ public sealed class LiveReleaseUpdateVerificationTests : IAsyncDisposable // Alter manifest content by tampering with the hash var originalManifest = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken); - var tamperedManifest = originalManifest.Replace( - "710813668ac5efacc245472afd4da6dca6739c042b5189bd12c2bbbd3c6b7e19", - "0000000000000000000000000000000000000000000000000000000000000000", - StringComparison.Ordinal); + var node = JsonNode.Parse(originalManifest); + Assert.NotNull(node); + if (node["hashes"]?["ServerMonitorManager-win-x64.msix"] is not null) + { + node["hashes"]!["ServerMonitorManager-win-x64.msix"] = "0000000000000000000000000000000000000000000000000000000000000000"; + } + else + { + node["hashes"] = new JsonObject + { + ["ServerMonitorManager-win-x64.msix"] = "0000000000000000000000000000000000000000000000000000000000000000" + }; + } + var tamperedManifest = node.ToJsonString(); var tamperedManifestPath = Path.Combine(_tempDir, "tampered-manifest.json"); await File.WriteAllTextAsync(tamperedManifestPath, tamperedManifest, TestContext.Current.CancellationToken); From 2bf3e1c20b1cc7685744202b99a6c9d4d4daa37b Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:13:54 +0700 Subject: [PATCH 4/9] fix mesh state permissions for Control --- deploy/ochenstarik-server-monitor-manager.sh | 30 ++++++--- deploy/ochenstarik-smm-control.service | 2 +- tests/bootstrap/test-bootstrap-contract.sh | 14 ++++- .../bootstrap/test-mesh-state-permissions.sh | 61 +++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 tests/bootstrap/test-mesh-state-permissions.sh diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index 4e25e61..af1e6f7 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -574,6 +574,7 @@ prepare_control_state() { chown -R "$CONTROL_USER:$CONTROL_USER" "$STATE_DIR/control" find "$STATE_DIR/control" -type d -exec chmod 0700 {} + find "$STATE_DIR/control" -type f -exec chmod 0600 {} + + repair_mesh_state_permissions } reverse_control_state_migration() { @@ -682,6 +683,22 @@ read_mesh_value() { awk -F '=' -v key="$key" '$1 == key { print substr($0, index($0, "=") + 1); exit }' "$ETC_DIR/mesh.env" } +ensure_mesh_state() { + install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" + touch "$MESH_DIR/nodes.tsv" + chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" + chmod 0660 "$MESH_DIR/nodes.tsv" +} + +repair_mesh_state_permissions() { + [[ -d "$MESH_DIR" ]] || return 0 + install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" + if [[ -e "$MESH_DIR/nodes.tsv" ]]; then + chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" + chmod 0660 "$MESH_DIR/nodes.tsv" + fi +} + render_hub_wireguard_config() { local private_key endpoint port node_id address public_key status private_key="$(cat "$WG_DIR/hub.key")" @@ -719,8 +736,10 @@ mesh_init() { fi [[ "$public_endpoint" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$ ]] \ || fail "Invalid WireGuard public endpoint." + ensure_system_user "$CONTROL_USER" ensure_mesh_packages - install -d -m 0700 "$WG_DIR" "$MESH_DIR" /etc/wireguard + install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard + ensure_mesh_state if [[ ! -f "$WG_DIR/hub.key" ]]; then umask 077 wg genkey >"$WG_DIR/hub.key" @@ -736,8 +755,6 @@ HUB_PUBLIC_KEY=$hub_public MESH_NETWORK=$MESH_NETWORK EOF chmod 0644 "$ETC_DIR/mesh.env" - touch "$MESH_DIR/nodes.tsv" - chmod 0600 "$MESH_DIR/nodes.tsv" printf '%s\n' 'net.ipv4.ip_forward=1' >"/etc/sysctl.d/90-ochenstarik-smm-mesh.conf" sysctl --system >/dev/null write_mesh_firewall @@ -761,9 +778,7 @@ EOF reserve_node_address() { local node_id="$1" existing host address - install -d -m 0700 "$MESH_DIR" - touch "$MESH_DIR/nodes.tsv" - chmod 0600 "$MESH_DIR/nodes.tsv" + ensure_mesh_state existing="$(awk -F '\t' -v node="$node_id" '$1 == node { print $2; exit }' "$MESH_DIR/nodes.tsv")" if [[ -n "$existing" ]]; then printf '%s\n' "$existing" @@ -1359,7 +1374,8 @@ add_mesh_peer() { awk -F '\t' -v OFS='\t' -v node="$node_id" -v address="$address" -v key="$public_key" \ '$1 == node { print node, address, key, "active"; found=1; next } { print } END { if (!found) exit 1 }' \ "$MESH_DIR/nodes.tsv" >"$tmp" || { rm -f -- "$tmp"; fail "Peer reservation is missing."; } - chmod 0600 "$tmp" + chown root:"$CONTROL_USER" "$tmp" + chmod 0660 "$tmp" mv -- "$tmp" "$MESH_DIR/nodes.tsv" render_hub_wireguard_config systemctl restart wg-quick@smm0.service diff --git a/deploy/ochenstarik-smm-control.service b/deploy/ochenstarik-smm-control.service index 82937fb..9d5e1a2 100644 --- a/deploy/ochenstarik-smm-control.service +++ b/deploy/ochenstarik-smm-control.service @@ -23,7 +23,7 @@ LockPersonality=true RestrictSUIDSGID=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 UMask=0077 -ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control +ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control /var/lib/ochenstarik-server-monitor-manager/mesh [Install] WantedBy=multi-user.target diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 2b7405f..2a751c7 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -527,7 +527,18 @@ rm -rf "$role_fixture/lib/agent" rm -rf -- "$role_fixture" grep -Fq 'UMask=0077' "$root/deploy/ochenstarik-smm-control.service" -grep -Fq 'ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control' "$root/deploy/ochenstarik-smm-control.service" +grep -Fq 'ReadWritePaths=/var/lib/ochenstarik-server-monitor-manager/control /var/lib/ochenstarik-server-monitor-manager/mesh' "$root/deploy/ochenstarik-smm-control.service" +grep -Fq 'install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR"' "$bootstrap" +grep -Fq 'chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv"' "$bootstrap" +grep -Fq 'chmod 0660 "$MESH_DIR/nodes.tsv"' "$bootstrap" +grep -Fq 'install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard' "$bootstrap" +mesh_init_definition="$(extract_bootstrap_function mesh_init)" +grep -Fq ' ensure_system_user "$CONTROL_USER"' <<<"$mesh_init_definition" +[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 2 ]] +grep -Fq ' repair_mesh_state_permissions' <<<"$prepare_control_state_definition" +if [[ "$(uname -s)" != MINGW* ]] && command -v sudo >/dev/null 2>&1; then + bash "$root/tests/bootstrap/test-mesh-state-permissions.sh" +fi native_smoke="$root/tests/bootstrap/run-native-systemd-smoke.sh" grep -Fq 'node_code="$(sudo "$system_bootstrap" node-code smoke-node)"' "$native_smoke" grep -Fq 'export SMM_ENROLL_CODE="$node_code"' "$native_smoke" @@ -870,4 +881,3 @@ grep -Fq 'record_installed_version agent' "$bootstrap" || { } printf '%s\n' "BOOTSTRAP_CONTRACT=PASS" - diff --git a/tests/bootstrap/test-mesh-state-permissions.sh b/tests/bootstrap/test-mesh-state-permissions.sh new file mode 100644 index 0000000..b467cf7 --- /dev/null +++ b/tests/bootstrap/test-mesh-state-permissions.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +IFS=$'\n\t' + +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +bootstrap="$root/deploy/ochenstarik-server-monitor-manager.sh" +fixture="$(mktemp -d -t smm-mesh-permissions.XXXXXXXX)" +test_user="$(id -un)" +test_group="$(id -gn)" +mesh_dir="$fixture/state/mesh" +wg_dir="$fixture/etc/wireguard" + +cleanup() { + sudo rm -rf -- "$fixture" +} +trap cleanup EXIT + +extract_function() { + local name="$1" + awk -v name="$name" ' + $0 == name "() {" { capture=1 } + capture { print } + capture && $0 == "}" { exit } + ' "$bootstrap" +} + +ensure_definition="$(extract_function ensure_mesh_state)" +repair_definition="$(extract_function repair_mesh_state_permissions)" +runner="$fixture/apply-permissions.sh" +{ + printf '%s\n%s\n' "$ensure_definition" "$repair_definition" + printf '%s\n' 'ensure_mesh_state' +} >"$runner" + +sudo env MESH_DIR="$mesh_dir" CONTROL_USER="$test_group" bash "$runner" +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir")" == "770:root:$test_group" ]] +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir/nodes.tsv")" == "660:root:$test_group" ]] + +printf '%s\n' $'fixture-node\t10.77.0.2\t-\treserved' >>"$mesh_dir/nodes.tsv" +grep -Fq 'fixture-node' "$mesh_dir/nodes.tsv" + +sudo install -d -m 0700 -o root -g root "$wg_dir" +printf '%s\n' 'private-hub-key' | sudo tee "$wg_dir/hub.key" >/dev/null +sudo chown root:root "$wg_dir/hub.key" +sudo chmod 0600 "$wg_dir/hub.key" +if sudo -u "$test_user" test -r "$wg_dir/hub.key"; then + printf '%s\n' 'Control-equivalent user can read the Hub private key' >&2 + exit 1 +fi + +sudo chmod 0700 "$mesh_dir" +sudo chmod 0600 "$mesh_dir/nodes.tsv" +{ + printf '%s\n%s\n' "$ensure_definition" "$repair_definition" + printf '%s\n' 'repair_mesh_state_permissions' +} >"$runner" +sudo env MESH_DIR="$mesh_dir" CONTROL_USER="$test_group" bash "$runner" +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir")" == "770:root:$test_group" ]] +[[ "$(sudo stat -c '%a:%U:%G' "$mesh_dir/nodes.tsv")" == "660:root:$test_group" ]] + +printf '%s\n' 'MESH_STATE_PERMISSIONS=PASS' From c8149e3c169a6fb5f151c79abbf3dc5fcf615c1f Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:17:01 +0700 Subject: [PATCH 5/9] test: extract complete mesh permission helpers --- tests/bootstrap/test-mesh-state-permissions.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap/test-mesh-state-permissions.sh b/tests/bootstrap/test-mesh-state-permissions.sh index b467cf7..6c20de7 100644 --- a/tests/bootstrap/test-mesh-state-permissions.sh +++ b/tests/bootstrap/test-mesh-state-permissions.sh @@ -19,8 +19,8 @@ extract_function() { local name="$1" awk -v name="$name" ' $0 == name "() {" { capture=1 } + capture && $0 != name "() {" && /^[A-Za-z_][A-Za-z0-9_]*\(\) \{$/ { exit } capture { print } - capture && $0 == "}" { exit } ' "$bootstrap" } From d16fe7e78e98cbd195405643b44cad7a8cd23be0 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:19:31 +0700 Subject: [PATCH 6/9] test: isolate control state fixture --- tests/bootstrap/test-bootstrap-contract.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 2a751c7..a306bc8 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -235,6 +235,7 @@ printf '%s' backup >"$control_state_fixture/backups/manifest.json" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n' "$validate_control_state_migration_definition" "$prepare_control_state_definition") validate_control_state_migration prepare_control_state From b769d365222cf742d7e0c2aefae1c4bb4005e215 Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:21:57 +0700 Subject: [PATCH 7/9] test: stub mesh repair in migration fixtures --- tests/bootstrap/test-bootstrap-contract.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index a306bc8..930f316 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -291,6 +291,7 @@ cp "$alpha7_fixture/etc/control.env" "$alpha7_fixture/original.env" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n%s\n%s\n' \ "$validate_control_state_migration_definition" \ "$prepare_control_state_definition" \ @@ -372,6 +373,7 @@ tar -C "$archive_root" -czf "$recovery_fixture/bootstrap-backups/alpha7.tar.gz" command install "${arguments[@]}" } chown() { :; } + repair_mesh_state_permissions() { :; } source <(printf '%s\n%s\n%s\n' \ "$record_control_legacy_state_definition" \ "$prepare_control_state_definition" \ From 315d7c3182949928a7d7b8c0d698ab263f7d62ab Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:25:43 +0700 Subject: [PATCH 8/9] fix: create mesh state before control starts --- deploy/ochenstarik-server-monitor-manager.sh | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/deploy/ochenstarik-server-monitor-manager.sh b/deploy/ochenstarik-server-monitor-manager.sh index af1e6f7..989d0c6 100755 --- a/deploy/ochenstarik-server-monitor-manager.sh +++ b/deploy/ochenstarik-server-monitor-manager.sh @@ -691,12 +691,7 @@ ensure_mesh_state() { } repair_mesh_state_permissions() { - [[ -d "$MESH_DIR" ]] || return 0 - install -d -m 0770 -o root -g "$CONTROL_USER" "$MESH_DIR" - if [[ -e "$MESH_DIR/nodes.tsv" ]]; then - chown root:"$CONTROL_USER" "$MESH_DIR/nodes.tsv" - chmod 0660 "$MESH_DIR/nodes.tsv" - fi + ensure_mesh_state } render_hub_wireguard_config() { From e04bfcdc10660ce65e466d260edecfd11c6aae2d Mon Sep 17 00:00:00 2001 From: ochenstarik-ui Date: Tue, 18 Aug 2026 01:36:24 +0700 Subject: [PATCH 9/9] test: expect control mesh state initialization --- tests/bootstrap/test-bootstrap-contract.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap/test-bootstrap-contract.sh b/tests/bootstrap/test-bootstrap-contract.sh index 930f316..69676e1 100755 --- a/tests/bootstrap/test-bootstrap-contract.sh +++ b/tests/bootstrap/test-bootstrap-contract.sh @@ -537,7 +537,7 @@ grep -Fq 'chmod 0660 "$MESH_DIR/nodes.tsv"' "$bootstrap" grep -Fq 'install -d -m 0700 -o root -g root "$WG_DIR" /etc/wireguard' "$bootstrap" mesh_init_definition="$(extract_bootstrap_function mesh_init)" grep -Fq ' ensure_system_user "$CONTROL_USER"' <<<"$mesh_init_definition" -[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 2 ]] +[[ "$(grep -Fc ' ensure_mesh_state' "$bootstrap")" -eq 3 ]] grep -Fq ' repair_mesh_state_permissions' <<<"$prepare_control_state_definition" if [[ "$(uname -s)" != MINGW* ]] && command -v sudo >/dev/null 2>&1; then bash "$root/tests/bootstrap/test-mesh-state-permissions.sh"