diff --git a/docs/dev-time-tx-kill-switch.md b/docs/dev-time-tx-kill-switch.md new file mode 100644 index 0000000..cae0211 --- /dev/null +++ b/docs/dev-time-tx-kill-switch.md @@ -0,0 +1,74 @@ +# Dev-time TX kill-switch + +DAPPS is pre-1.0 software. Until it is judged stable enough for unsupervised use on the air, every running node polls a single URL controlled by the project author and stops transmitting if that URL says so. This page exists so you know exactly what that means before you put a node on the air. + +## What is it + +Every DAPPS daemon, regardless of operator, polls + +``` +https://compute.oarc.uk/storage/public/folders/4803/dapps-devtime-killswitch.json +``` + +once a minute. The response is small JSON: + +```json +{ + "txAllowed": true, + "reason": "normal operations", + "appliesTo": ["*"] +} +``` + +When `txAllowed` is `false` and the local callsign matches one of the `appliesTo` patterns (or the list is `["*"]`), the daemon's bearer-level TX gate closes. While closed, no DAPPS-originated frame produces an on-air emission - forwards, floods, beacons, probes, polls, ACKs are all blocked at the AGW frame / RHP open / UDP send level. Inbound RX is unaffected; AX.25 disconnect and node-control admin frames continue to flow so the BPQ/XR session stays usable and in-flight sessions tear down cleanly. + +The dashboard shows a red banner across every page when the gate is closed, with the `reason` text from the JSON. + +## Why it is here + +DAPPS is alpha-quality software running on shared amateur radio bandwidth. A bug shipped in a release could, in principle, cause a fleet of nodes to flood the air. Most operators won't catch a regression in a release within minutes; they may not even be at the keyboard. The kill-switch lets the project author gag every running node within roughly one minute of detecting a problem, without coordinating with operators individually. + +This is a software-development safety net, not a regulatory mechanism, not a moderation tool. It exists so the worst-case "I shipped a bug that hammers 144.800" stays bounded to a few minutes of harm before every node goes silent. + +## What you cannot do + +- You cannot disable the polling. +- You cannot repoint it to a different URL. +- You cannot relax the cadence, the staleness window, or the fail-open behaviour. + +The values are constants in the source (`TxKillSwitchPoller.cs`); a fork can change them but the published binaries cannot be configured at runtime. This is deliberate. A configurable kill-switch defeats its purpose - the whole point is that the author can rely on every node polling the one URL. + +If that posture is unacceptable to you, the answer is to not run pre-1.0 DAPPS, or to fork. Both are valid choices. + +## What you can do + +- See the current state in the dashboard banner and at `GET /TxControl/status`. +- Continue to use the operator master TX-stop button independently. It is a separate signal; closing the local toggle gags TX even when the remote signal is allowing, and reopening the local toggle does *not* override a remote block. +- Monitor outbound HTTPS traffic to the kill-switch URL if you want to verify what's being polled. Nothing operator-identifying is sent: the request is a plain `GET` with no body and no auth. +- Read the `Services/TxKillSwitchPoller.cs` source. The whole mechanism is around two hundred lines. + +## Failure modes + +- **URL unreachable at startup**: the gate stays open. A new install with no internet does not silently refuse to TX. +- **URL unreachable after a successful poll**: the daemon keeps using the most recent successful state for ten minutes (the staleness window). After that it falls back to allow. +- **Malformed JSON**: same as unreachable - the failure is logged at debug level and the previous state is kept. + +The staleness window is short enough that a genuinely stuck poller won't keep trusting hours-old state, and long enough to ride out the kind of network blip that's common on a domestic connection. Fail-open is the conservative posture for an amateur radio installation: an operator with a working RF stack and a flaky internet connection is not made worse off by losing transmissions on top. + +## When it goes away + +Before 1.0. Once the software is mature enough to be trusted to operators without the safety net, this whole subsystem is removed - not made configurable per-fleet, just deleted. The hardcoded URL becomes a dead endpoint at that point. + +If the project pivots and a configurable per-fleet kill-switch becomes useful (a regional sysop wanting to gate their own nodes during a contest, for example), that's a separate feature with a separate design and a separate set of tradeoffs to argue through. It will not inherit the dev-time URL or the dev-time defaults. + +## What the network sees + +A `GET` request to the URL above, once a minute, from every running DAPPS node. No body, no headers beyond a User-Agent generated by the .NET HTTP stack, no cookies, no auth. The response is cached only in process memory. + +If you operate a node in an environment where polling that URL is itself a problem (an isolated network, a regulator concerned about outbound traffic), the answer is to not run pre-1.0 DAPPS in that environment. Fail-open will keep TX working when the URL is unreachable, but the request will still be made on the polling cadence. + +## Source + +- Poller: [`src/dapps/dapps.core/Services/TxKillSwitchPoller.cs`](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs) +- Gate composition: [`src/dapps/dapps.core/Services/SystemOptionsBackedTxGate.cs`](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.core/Services/SystemOptionsBackedTxGate.cs) +- Bearer-level enforcement: [`src/dapps/dapps.client/Tx/IDappsTxGate.cs`](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.client/Tx/IDappsTxGate.cs) diff --git a/docs/getting-started.md b/docs/getting-started.md index a836ffd..b9dce04 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -26,6 +26,10 @@ The wire protocol is small and human-readable on the line: a peer connects, gets - **Not a routing protocol replacement for the AX.25 layer.** DAPPS routes its own messages over whichever bearer is available, but it doesn't replace what your packet node does for connecting users. - **Not BPQ-specific.** BPQ is one supported packet node; XRouter is another (via RHPv2). Anything that speaks AGW or RHPv2 works the same; MeshCore is in flight. +## Before you put it on the air + +DAPPS is pre-1.0. While it is, every running node polls a single URL controlled by the project author and stops transmitting if that URL says so. It's a development-phase safety net - not configurable, removed before 1.0. See [Dev-time TX kill-switch](dev-time-tx-kill-switch.md) for the full rationale and what it means in practice. + ## The journey ### 1. Install diff --git a/mkdocs.yml b/mkdocs.yml index 4af7d9f..2035020 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -104,6 +104,7 @@ nav: - Multi-hop via non-DAPPS nodes: multi-hop.md - Operate: operate.md - Audit log: audit.md + - Dev-time TX kill-switch: dev-time-tx-kill-switch.md - Update: update.md - MCP for assistants: mcp.md - App developers: diff --git a/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs b/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs index d10c861..ebe0dc7 100644 --- a/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs +++ b/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs @@ -1,18 +1,21 @@ -using System.Net; -using System.Text; using AwesomeAssertions; using dapps.core.Models; using dapps.core.Services; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; +using System.Net; +using System.Text; namespace dapps.core.tests; /// -/// PR 3: poller behaviour. Covers the JSON contract, callsign -/// targeting, fail-open / fail-closed staleness, and the -/// ITxKillSwitchSignal surface the gate consumes. +/// Poller behaviour. The URL, poll cadence, staleness window, and +/// fail-open mode are hardcoded constants on +/// ; the canned HttpMessageHandler +/// here intercepts every outbound HTTP call regardless of URL, so +/// these tests exercise behaviour without caring what the production +/// URL is. /// public class TxKillSwitchPollerTests { @@ -42,27 +45,11 @@ private sealed class CannedHttpClientFactory(HttpMessageHandler handler) : IHttp private static HttpResponseMessage Json(string body, HttpStatusCode code = HttpStatusCode.OK) => new(code) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; - [Fact] - public async Task Disabled_WhenUrlEmpty_NeverFetchesAndAllows() - { - var calls = 0; - var handler = new CannedHandler(_ => { calls++; return Json("{}"); }); - var opts = new StubOptions(new SystemOptions { TxKillSwitchUrl = "", Callsign = "M0LTE-1" }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); - - await poller.RefreshAsync(CancellationToken.None); - - calls.Should().Be(0); - poller.RemoteAllowed.Should().BeTrue(); - poller.RemoteBlockReason.Should().BeNull(); - } - [Fact] public async Task Allowed_WhenResponseSaysSo() { var handler = new CannedHandler(_ => Json("""{"txAllowed":true,"reason":"normal ops","appliesTo":["*"]}""")); - var opts = new StubOptions(new SystemOptions { TxKillSwitchUrl = "http://example/ks", Callsign = "M0LTE-1" }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); + var poller = NewPoller(handler, new SystemOptions { Callsign = "M0LTE-1" }); await poller.RefreshAsync(CancellationToken.None); @@ -76,13 +63,13 @@ public async Task Blocked_WhenResponseSaysSo_AndAppliesToThisCallsign() { var handler = new CannedHandler(_ => Json( """{"txAllowed":false,"reason":"contest QRM","appliesTo":["M0LTE-*"]}""")); - var opts = new StubOptions(new SystemOptions { TxKillSwitchUrl = "http://example/ks", Callsign = "M0LTE-2" }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); + var poller = NewPoller(handler, new SystemOptions { Callsign = "M0LTE-2" }); await poller.RefreshAsync(CancellationToken.None); poller.RemoteAllowed.Should().BeFalse(); poller.RemoteBlockReason.Should().Contain("contest QRM"); + poller.RemoteBlockReason.Should().Contain("dev-time kill-switch"); } [Fact] @@ -91,8 +78,7 @@ public async Task Allowed_WhenBlockedButCallsignNotInAppliesTo() // Block targeted at GB7RDG only; we are M0LTE-2 - should not gag us. var handler = new CannedHandler(_ => Json( """{"txAllowed":false,"reason":"GB7RDG site QRM","appliesTo":["GB7RDG-*"]}""")); - var opts = new StubOptions(new SystemOptions { TxKillSwitchUrl = "http://example/ks", Callsign = "M0LTE-2" }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); + var poller = NewPoller(handler, new SystemOptions { Callsign = "M0LTE-2" }); await poller.RefreshAsync(CancellationToken.None); @@ -104,8 +90,7 @@ public async Task Allowed_WhenBlockedButCallsignNotInAppliesTo() public async Task Allowed_WhenAppliesToOmittedAndTxAllowedTrue() { var handler = new CannedHandler(_ => Json("""{"txAllowed":true,"reason":"all good"}""")); - var opts = new StubOptions(new SystemOptions { TxKillSwitchUrl = "http://example/ks", Callsign = "M0LTE-1" }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); + var poller = NewPoller(handler, new SystemOptions { Callsign = "M0LTE-1" }); await poller.RefreshAsync(CancellationToken.None); @@ -113,133 +98,65 @@ public async Task Allowed_WhenAppliesToOmittedAndTxAllowedTrue() } [Fact] - public async Task Stale_FailOpen_AllowsAfterStalenessElapses() + public async Task NeverFetchedSuccessfully_FailOpen_Allows() { - var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); - // Initially blocked. - var handler = new CannedHandler(_ => Json("""{"txAllowed":false,"reason":"emergency","appliesTo":["*"]}""")); - var opts = new StubOptions(new SystemOptions - { - TxKillSwitchUrl = "http://example/ks", - Callsign = "M0LTE-1", - TxKillSwitchStaleSeconds = 60, - TxKillSwitchFailOpen = true, - }); - var poller = NewPoller(handler, opts, clock); + // Production constant: FailOpen=true. A node with no internet + // at boot (or behind a proxy that's not yet up) keeps emitting + // rather than going silent. + var handler = new CannedHandler(_ => throw new HttpRequestException("dns down")); + var poller = NewPoller(handler, new SystemOptions { Callsign = "M0LTE-1" }); await poller.RefreshAsync(CancellationToken.None); - poller.RemoteAllowed.Should().BeFalse("fresh successful poll said block"); - // Now the publisher goes silent. Switch the handler to throw on - // every call, and let time march past the staleness window. - handler = new CannedHandler(_ => throw new HttpRequestException("network down")); - var poller2 = NewPoller(handler, opts, clock); - - // Hydrate poller2 by replaying the original successful state. - // Simpler: have it poll once successfully then go dark. - var successThenFail = new SequentialHandler( - Json("""{"txAllowed":false,"reason":"emergency","appliesTo":["*"]}"""), - null); // null = throw on subsequent calls - var pollerSeq = NewPoller(successThenFail, opts, clock); - await pollerSeq.RefreshAsync(CancellationToken.None); - pollerSeq.RemoteAllowed.Should().BeFalse(); - - clock.Advance(TimeSpan.FromSeconds(120)); // past staleness - await pollerSeq.RefreshAsync(CancellationToken.None); // fails - pollerSeq.RemoteAllowed.Should().BeTrue("fail-open + stale = allow"); - pollerSeq.RemoteBlockReason.Should().BeNull(); + poller.RemoteAllowed.Should().BeTrue(); + poller.LastError.Should().NotBeNull(); } [Fact] - public async Task Stale_FailClosed_BlocksAfterStalenessElapses() + public async Task TransientFailure_KeepsLastSuccessfulValue_WithinStalenessWindow() { var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); + // First call: blocked. Subsequent calls: planned-fault. var sequential = new SequentialHandler( - Json("""{"txAllowed":true,"reason":"normal","appliesTo":["*"]}"""), + Json("""{"txAllowed":false,"reason":"licence inspection","appliesTo":["*"]}"""), + null, null); - var opts = new StubOptions(new SystemOptions - { - TxKillSwitchUrl = "http://example/ks", - Callsign = "M0LTE-1", - TxKillSwitchStaleSeconds = 60, - TxKillSwitchFailOpen = false, - }); - var poller = NewPoller(sequential, opts, clock); + var poller = NewPoller(sequential, new SystemOptions { Callsign = "M0LTE-1" }, clock); await poller.RefreshAsync(CancellationToken.None); - poller.RemoteAllowed.Should().BeTrue(); - - clock.Advance(TimeSpan.FromSeconds(120)); - await poller.RefreshAsync(CancellationToken.None); // fails - - poller.RemoteAllowed.Should().BeFalse("fail-closed + stale = block"); - poller.RemoteBlockReason.Should().Contain("unreachable"); - } - - [Fact] - public async Task NeverFetchedSuccessfully_FailOpen_Allows() - { - var handler = new CannedHandler(_ => throw new HttpRequestException("dns down")); - var opts = new StubOptions(new SystemOptions - { - TxKillSwitchUrl = "http://example/ks", - Callsign = "M0LTE-1", - TxKillSwitchFailOpen = true, - }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); + poller.RemoteAllowed.Should().BeFalse(); + // Bump clock 30s, attempt a refresh that fails. Still inside + // the 600s staleness window, so the cached "blocked" sticks. + clock.Advance(TimeSpan.FromSeconds(30)); await poller.RefreshAsync(CancellationToken.None); + poller.RemoteAllowed.Should().BeFalse(); + poller.RemoteBlockReason.Should().Contain("licence inspection"); - poller.RemoteAllowed.Should().BeTrue("fail-open default allows when bootstrap fails"); - poller.LastError.Should().NotBeNull(); - } - - [Fact] - public async Task NeverFetchedSuccessfully_FailClosed_Blocks() - { - var handler = new CannedHandler(_ => throw new HttpRequestException("dns down")); - var opts = new StubOptions(new SystemOptions - { - TxKillSwitchUrl = "http://example/ks", - Callsign = "M0LTE-1", - TxKillSwitchFailOpen = false, - }); - var poller = NewPoller(handler, opts, new FakeTimeProvider()); - + clock.Advance(TimeSpan.FromSeconds(30)); await poller.RefreshAsync(CancellationToken.None); - - poller.RemoteAllowed.Should().BeFalse(); - poller.RemoteBlockReason.Should().Contain("unreachable"); + poller.RemoteAllowed.Should().BeFalse("still within window"); } [Fact] - public async Task TransientFailure_KeepsLastSuccessfulValue_WithinStalenessWindow() + public async Task Stale_FallsBackToFailOpen() { var clock = new FakeTimeProvider(DateTimeOffset.UtcNow); var sequential = new SequentialHandler( - Json("""{"txAllowed":false,"reason":"licence inspection","appliesTo":["*"]}"""), - null, // network glitch - null); // still down - var opts = new StubOptions(new SystemOptions - { - TxKillSwitchUrl = "http://example/ks", - Callsign = "M0LTE-1", - TxKillSwitchStaleSeconds = 600, // 10min staleness - }); - var poller = NewPoller(sequential, opts, clock); + Json("""{"txAllowed":false,"reason":"emergency","appliesTo":["*"]}"""), + null); + var poller = NewPoller(sequential, new SystemOptions { Callsign = "M0LTE-1" }, clock); await poller.RefreshAsync(CancellationToken.None); - poller.RemoteAllowed.Should().BeFalse(); - - clock.Advance(TimeSpan.FromSeconds(30)); - await poller.RefreshAsync(CancellationToken.None); // fails - - poller.RemoteAllowed.Should().BeFalse("within staleness window, keep last known block"); - poller.RemoteBlockReason.Should().Contain("licence inspection"); + poller.RemoteAllowed.Should().BeFalse("fresh successful poll said block"); - clock.Advance(TimeSpan.FromSeconds(30)); - await poller.RefreshAsync(CancellationToken.None); // fails again - poller.RemoteAllowed.Should().BeFalse("still within window"); + // Advance past the hardcoded 600s staleness window. Subsequent + // refresh fails. With FailOpen=true (production constant), we + // re-allow. + clock.Advance(TimeSpan.FromSeconds(TxKillSwitchPoller.StaleSeconds + 60)); + await poller.RefreshAsync(CancellationToken.None); + poller.RemoteAllowed.Should().BeTrue("stale + fail-open = allow"); + poller.RemoteBlockReason.Should().BeNull(); } [Theory] @@ -250,23 +167,36 @@ public async Task TransientFailure_KeepsLastSuccessfulValue_WithinStalenessWindo [InlineData(new string[] { "M0LTE-*" }, "GB7RDG", false)] [InlineData(new string[] { "M0LTE-2" }, "M0LTE-2", true)] [InlineData(new string[] { "M0LTE-2" }, "M0LTE-3", false)] - [InlineData(new string[] { "M0LTE-*", "GB7RDG" }, "GB7RDG", true)] // multi-pattern - [InlineData(new string[] { }, "M0LTE-1", true)] // empty list = all - [InlineData(new string[] { "" }, "M0LTE-1", false)] // explicit empty entry skipped + [InlineData(new string[] { "M0LTE-*", "GB7RDG" }, "GB7RDG", true)] + [InlineData(new string[] { }, "M0LTE-1", true)] + [InlineData(new string[] { "" }, "M0LTE-1", false)] public void AppliesToThisNode_PatternMatchesExpected(string[] patterns, string callsign, bool expected) { TxKillSwitchPoller.AppliesToThisNode(patterns, callsign).Should().Be(expected); } + [Fact] + public void HardcodedConstants_PinProductionValues() + { + // Pin the production constants so an accidental tweak shows + // up as a failing test review item, not a silent change to + // safety-critical defaults. + TxKillSwitchPoller.KillSwitchUrl.Should().Be( + "https://compute.oarc.uk/storage/public/folders/4803/dapps-devtime-killswitch.json"); + TxKillSwitchPoller.PollSeconds.Should().Be(60); + TxKillSwitchPoller.StaleSeconds.Should().Be(600); + TxKillSwitchPoller.FailOpen.Should().BeTrue(); + } + private static TxKillSwitchPoller NewPoller( HttpMessageHandler handler, - IOptionsMonitor options, - TimeProvider clock) + SystemOptions options, + TimeProvider? clock = null) { return new TxKillSwitchPoller( new CannedHttpClientFactory(handler), - options, - clock, + new StubOptions(options), + clock ?? new FakeTimeProvider(), NullLogger.Instance); } diff --git a/src/dapps/dapps.core/Models/SystemOptions.cs b/src/dapps/dapps.core/Models/SystemOptions.cs index 2f73e71..01b8540 100644 --- a/src/dapps/dapps.core/Models/SystemOptions.cs +++ b/src/dapps/dapps.core/Models/SystemOptions.cs @@ -298,48 +298,6 @@ public class SystemOptions /// public bool TxEnabled { get; set; } = true; - /// - /// Centralised TX kill-switch URL. The poller fetches this URL on - /// and parses the response - /// as JSON: {"txAllowed":bool,"reason":string?,"appliesTo":["CALLSIGN-*","*"]}. - /// When txAllowed=false and the local callsign matches one - /// of the appliesTo patterns (or it's ["*"]), the - /// gate closes for this node. Empty string disables the poller. - /// Lets a network operator (RSGB, club sysop, regulator) gag a - /// single node or the whole fleet without touching individual - /// boxes. Default empty. - /// - public string TxKillSwitchUrl { get; set; } = ""; - - /// - /// Seconds between TX kill-switch URL polls when configured. - /// Default 60. Clamped to a minimum of 5 by the poller. Set - /// shorter for fast-reacting fleets, longer to reduce HTTP load - /// on the publishing endpoint. - /// - public int TxKillSwitchPollSeconds { get; set; } = 60; - - /// - /// What the gate does when the kill-switch URL is unreachable for - /// longer than . true - /// (default) = fail-open: a network outage doesn't gag a working - /// node, since the kill-switch is for active intervention not - /// graceful degradation. false = fail-closed: if we can't - /// confirm "go", don't TX. Trade-off: fail-closed loses RF - /// alongside internet; fail-open trusts the most recent successful - /// poll until it goes stale. - /// - public bool TxKillSwitchFailOpen { get; set; } = true; - - /// - /// Seconds without a successful kill-switch fetch before the gate - /// considers the cached value stale and switches to the - /// behaviour. Default 600 - /// (10 minutes). Inside this window the last-known result is - /// trusted regardless of network state. - /// - public int TxKillSwitchStaleSeconds { get; set; } = 600; - /// /// When true, every outbound transmission (beacon, solicit, probe, /// forward, poll, ack, heartbeat) is logged to the diff --git a/src/dapps/dapps.core/Program.cs b/src/dapps/dapps.core/Program.cs index 24f807c..6150945 100644 --- a/src/dapps/dapps.core/Program.cs +++ b/src/dapps/dapps.core/Program.cs @@ -50,15 +50,15 @@ // TX kill-switch wiring. The gate composes two signals: a local // operator toggle (SystemOptions.TxEnabled) and a remote -// kill-switch URL polled by TxKillSwitchPoller. The poller IS the -// ITxKillSwitchSignal: registered both as a singleton (so the gate -// can read its state) and as a hosted service (so the polling loop -// runs). When SystemOptions.TxKillSwitchUrl is empty the poller -// idles and reports "remote allowed" - the gate effectively reflects -// the local toggle alone. Register the concrete gate as a singleton -// so the TxControlController can read both signals independently -// for the dashboard banner; the IDappsTxGate alias resolves the -// same instance for bearers. +// kill-switch URL polled by TxKillSwitchPoller. The poller URL is +// hardcoded - a development-phase safety net controlled by the +// project author; see docs/dev-time-tx-kill-switch.md for the +// rationale and removal plan. The poller IS the ITxKillSwitchSignal: +// registered both as a singleton (so the gate can read its state) +// and as a hosted service (so the polling loop runs). Register the +// concrete gate as a singleton so the TxControlController can read +// both signals independently for the dashboard banner; the +// IDappsTxGate alias resolves the same instance for bearers. builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); diff --git a/src/dapps/dapps.core/Services/SystemOptionsStore.cs b/src/dapps/dapps.core/Services/SystemOptionsStore.cs index 91009cd..aec7b7a 100644 --- a/src/dapps/dapps.core/Services/SystemOptionsStore.cs +++ b/src/dapps/dapps.core/Services/SystemOptionsStore.cs @@ -105,10 +105,6 @@ public async Task SaveAsync(SystemOptions options) await Upsert(connection, existing, nameof(options.TransmissionAuditRetentionDays), options.TransmissionAuditRetentionDays.ToString()); await Upsert(connection, existing, nameof(options.TransmissionAuditMqttPublish), options.TransmissionAuditMqttPublish.ToString()); await Upsert(connection, existing, nameof(options.TxEnabled), options.TxEnabled.ToString()); - await Upsert(connection, existing, nameof(options.TxKillSwitchUrl), options.TxKillSwitchUrl); - await Upsert(connection, existing, nameof(options.TxKillSwitchPollSeconds), options.TxKillSwitchPollSeconds.ToString()); - await Upsert(connection, existing, nameof(options.TxKillSwitchFailOpen), options.TxKillSwitchFailOpen.ToString()); - await Upsert(connection, existing, nameof(options.TxKillSwitchStaleSeconds), options.TxKillSwitchStaleSeconds.ToString()); Reload(); } @@ -177,10 +173,6 @@ private static SystemOptions Parse(Dictionary r) TransmissionAuditRetentionDays = TryGetInt(r, nameof(SystemOptions.TransmissionAuditRetentionDays), 90, min: 0), TransmissionAuditMqttPublish = TryGetBool(r, nameof(SystemOptions.TransmissionAuditMqttPublish), false), TxEnabled = TryGetBool(r, nameof(SystemOptions.TxEnabled), true), - TxKillSwitchUrl = TryGet(r, nameof(SystemOptions.TxKillSwitchUrl), ""), - TxKillSwitchPollSeconds = TryGetInt(r, nameof(SystemOptions.TxKillSwitchPollSeconds), 60, min: 5), - TxKillSwitchFailOpen = TryGetBool(r, nameof(SystemOptions.TxKillSwitchFailOpen), true), - TxKillSwitchStaleSeconds = TryGetInt(r, nameof(SystemOptions.TxKillSwitchStaleSeconds), 600, min: 30), }; } diff --git a/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs b/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs index d2bbc1e..237d63d 100644 --- a/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs +++ b/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs @@ -7,34 +7,38 @@ namespace dapps.core.Services; /// /// Centralised TX kill-switch implementation of -/// . Polls -/// on a configurable -/// cadence and exposes the current allow/deny state to the +/// . Polls a single hardcoded URL +/// (controlled by the project author during the development phase) +/// and exposes the current allow/deny state to the /// . /// +/// Deliberately not configurable. The URL, poll cadence, +/// staleness window, and fail-open behaviour are constants. Operators +/// running pre-1.0 DAPPS cannot disable it, repoint it, or relax the +/// timings. The mechanism is a development-phase safety net so the +/// author can gag misbehaving experimental nodes if a release ships +/// with a bug that floods the air. It will be removed (or made +/// genuinely configurable per-fleet) before 1.0. See +/// docs/dev-time-tx-kill-switch.md for the operator-facing +/// rationale. +/// /// Wire shape (small, easy to host on a static gist / S3 / status -/// page / cluster admin endpoint): +/// page): /// /// { "txAllowed": true, "reason": "normal ops", "appliesTo": ["*"] } /// -/// appliesTo contains glob-ish callsign patterns (one or more) -/// matched case-insensitively against this node's callsign: -/// -/// "*" matches every node. -/// "M0LTE-*" matches every SSID under M0LTE. -/// "M0LTE-2" matches exactly that callsign. -/// +/// appliesTo contains glob-ish callsign patterns matched +/// case-insensitively against this node's callsign. "*" +/// matches every node; "M0LTE-*" matches every SSID under +/// M0LTE; "M0LTE-2" matches exactly that callsign. /// If the response excludes us we treat it as "not gated for this -/// node" - so one URL can stop one site without affecting the rest. +/// node". /// -/// Fail modes: -/// -/// HTTP / parse failure - keeps the last successful state. -/// Once has -/// elapsed since the last good fetch, switches to the -/// behaviour. -/// URL empty - poller idles; remote signal is open. -/// +/// Fail behaviour: HTTP / parse failure keeps the last successful +/// state for , then falls back to allow +/// (fail-open). A network outage doesn't gag a working amateur radio +/// installation - the kill-switch is for active intervention by the +/// project author, not graceful degradation. /// /// Errors are swallowed by design: a poller crash mustn't take down /// the daemon. Same posture as . @@ -45,6 +49,31 @@ public sealed class TxKillSwitchPoller( TimeProvider timeProvider, ILogger logger) : BackgroundService, ITxKillSwitchSignal { + /// + /// Hardcoded kill-switch URL controlled by the DAPPS project + /// author (M0LTE) during the development phase. See class summary + /// and docs/dev-time-tx-kill-switch.md. + /// + public const string KillSwitchUrl = + "https://compute.oarc.uk/storage/public/folders/4803/dapps-devtime-killswitch.json"; + + /// Seconds between polls. Constant: cannot be tuned per + /// node. 60s gives near-real-time response without hammering the + /// publishing endpoint. + public const int PollSeconds = 60; + + /// Seconds without a successful fetch before the cached + /// value is considered stale. Constant: 600 (10 min) - long enough + /// to ride out transient network blips, short enough that a + /// genuinely-stuck poller stops trusting old state in a reasonable + /// time. + public const int StaleSeconds = 600; + + /// When stale or never-yet-fetched, allow TX. Constant: + /// the kill-switch is for active intervention, not for gagging + /// nodes that lose internet. + public const bool FailOpen = true; + private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(2); private readonly object stateLock = new(); @@ -59,24 +88,13 @@ public bool RemoteAllowed { lock (stateLock) { - var opts = options.CurrentValue; - if (string.IsNullOrWhiteSpace(opts.TxKillSwitchUrl)) - { - // Poller is disabled - no remote signal in play. - return true; - } - if (lastSuccessAt is { } when_ && IsStale(when_, opts)) + if (lastSuccessAt is { } when_ && IsStale(when_)) { - // Cached value is older than the staleness window. - // Apply the configured fail-open / fail-closed rule. - return opts.TxKillSwitchFailOpen; + return FailOpen; } if (lastSuccessAt is null) { - // Never fetched successfully (poller hasn't run, or - // every attempt has failed since startup). Same - // fail-open / fail-closed call as a stale cache. - return opts.TxKillSwitchFailOpen; + return FailOpen; } return lastTxAllowed; } @@ -89,20 +107,21 @@ public string? RemoteBlockReason { lock (stateLock) { - var opts = options.CurrentValue; - if (string.IsNullOrWhiteSpace(opts.TxKillSwitchUrl)) return null; if (RemoteAllowed) return null; + // RemoteAllowed=false implies FailOpen is false (it's + // not, in production) OR we have a fresh non-stale + // block. Cover both cleanly. if (lastSuccessAt is null) { - return $"remote kill-switch unreachable ({lastError ?? "no successful poll yet"}); fail-closed by config"; + return $"dev-time kill-switch unreachable ({lastError ?? "no successful poll yet"}); fail-closed by config"; } - if (lastSuccessAt is { } when_ && IsStale(when_, opts)) + if (lastSuccessAt is { } when_ && IsStale(when_)) { - return $"remote kill-switch unreachable since {when_.UtcDateTime:s}Z; fail-closed by config"; + return $"dev-time kill-switch unreachable since {when_.UtcDateTime:s}Z; fail-closed by config"; } return string.IsNullOrWhiteSpace(lastReason) - ? "remote kill-switch active" - : $"remote: {lastReason}"; + ? "dev-time kill-switch active" + : $"dev-time kill-switch: {lastReason}"; } } } @@ -129,48 +148,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await PollOnce(stoppingToken); - var opts = options.CurrentValue; - var delaySeconds = Math.Max(5, opts.TxKillSwitchPollSeconds); - try { await Task.Delay(TimeSpan.FromSeconds(delaySeconds), timeProvider, stoppingToken); } + try { await Task.Delay(TimeSpan.FromSeconds(PollSeconds), timeProvider, stoppingToken); } catch (OperationCanceledException) { return; } } } - /// Trigger a fresh poll immediately. Used by tests and a - /// future "force re-check" admin button. + /// Trigger a fresh poll immediately. Used by tests. public Task RefreshAsync(CancellationToken ct) => PollOnce(ct); private async Task PollOnce(CancellationToken ct) { - var opts = options.CurrentValue; - var url = opts.TxKillSwitchUrl; - if (string.IsNullOrWhiteSpace(url)) - { - // Disabled: clear any stale state so a flip-on later starts - // clean rather than reviving a months-old cached "blocked". - lock (stateLock) - { - lastTxAllowed = true; - lastReason = null; - lastSuccessAt = null; - lastError = null; - } - return; - } - try { var client = httpClientFactory.CreateClient("tx-kill-switch"); client.Timeout = TimeSpan.FromSeconds(10); - var response = await client.GetFromJsonAsync(url, ct); + var response = await client.GetFromJsonAsync(KillSwitchUrl, ct); if (response is null) { StashError("empty response body"); return; } - var localCallsign = opts.Callsign ?? ""; + var localCallsign = options.CurrentValue.Callsign ?? ""; var applies = AppliesToThisNode(response.AppliesTo, localCallsign); var allowed = !applies || response.TxAllowed; var reason = applies ? response.Reason : null; @@ -199,10 +199,10 @@ private void StashError(string message) lock (stateLock) lastError = message; } - private bool IsStale(DateTimeOffset successAt, SystemOptions opts) + private bool IsStale(DateTimeOffset successAt) { var now = timeProvider.GetUtcNow(); - return now - successAt > TimeSpan.FromSeconds(Math.Max(30, opts.TxKillSwitchStaleSeconds)); + return now - successAt > TimeSpan.FromSeconds(StaleSeconds); } /// True when this node's callsign matches any pattern in