From 55b67c64efbb4cc30999f08ea97157eaea68c9cd Mon Sep 17 00:00:00 2001
From: Tom M0LTE <37816024+M0LTE@users.noreply.github.com>
Date: Wed, 6 May 2026 10:57:16 +0100
Subject: [PATCH] feat: hardcode dev-time TX kill-switch URL + operator-facing
docs
The centralised kill-switch URL is now a constant pointing at the
project author's published JSON file. Cadence, staleness window,
and fail-open behaviour are constants too. Operators cannot disable
the polling, repoint it, or relax the timings - that's the whole
point of a development-phase safety net.
- TxKillSwitchUrl, TxKillSwitchPollSeconds, TxKillSwitchFailOpen,
TxKillSwitchStaleSeconds removed from SystemOptions and from the
/Config surface entirely.
- TxKillSwitchPoller carries them as public consts so a code reviewer
can see them at a glance, and a pin test fails on accidental edit.
- Reason text from the JSON is now prefixed "dev-time kill-switch:"
in the dashboard banner so operators always know why TX is gated.
New docs/dev-time-tx-kill-switch.md spells out, transparently:
- the URL being polled and the wire shape;
- why the mechanism exists (alpha software on shared bandwidth);
- what the operator cannot do (disable / repoint / configure);
- failure modes (fail-open at boot, fail-open after staleness);
- when it goes away (before 1.0, deleted not configurable);
- what the network sees (one GET/min, no operator-identifying
headers, no body).
Linked from getting-started.md "Before you put it on the air" and
nav under Operate / Audit log.
Tests: 637/637 (dropped 4 tests for the removed disabled-URL and
fail-closed cases that no longer apply; added a pin test for the
hardcoded constants). Manual smoke confirmed the daemon polls the
real OARC URL and parses the live "normal operations" response.
---
docs/dev-time-tx-kill-switch.md | 74 +++++++
docs/getting-started.md | 4 +
mkdocs.yml | 1 +
.../TxKillSwitchPollerTests.cs | 202 ++++++------------
src/dapps/dapps.core/Models/SystemOptions.cs | 42 ----
src/dapps/dapps.core/Program.cs | 18 +-
.../dapps.core/Services/SystemOptionsStore.cs | 8 -
.../dapps.core/Services/TxKillSwitchPoller.cs | 132 ++++++------
8 files changed, 220 insertions(+), 261 deletions(-)
create mode 100644 docs/dev-time-tx-kill-switch.md
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