From 175ffd3835fe7a48b1a9cd9b16e581629debf456 Mon Sep 17 00:00:00 2001
From: Tom M0LTE <37816024+M0LTE@users.noreply.github.com>
Date: Wed, 6 May 2026 11:10:02 +0100
Subject: [PATCH] feat(tx-killswitch): 5-min cadence, harden TLS, soften docs
Three changes:
- Polling every 5 minutes instead of every minute. With hundreds of
nodes the request rate against the publishing endpoint becomes
trivial, while still pausing a misbehaving fleet within a single
beacon cadence. Bumped staleness window from 10 to 30 minutes to
preserve the "several poll cycles before declaring stale"
semantics.
- Harden TLS verification. The named "tx-kill-switch" HttpClient now
pins a fresh SocketsHttpHandler with default SslOptions
(system trust store, full chain / hostname / expiry validation).
Means any future global tweak to the default handler - someone
adding a "trust all" callback for testing and forgetting it - does
not silently weaken the kill-switch fetch. Added a runtime guard
in PollOnce that refuses to fetch over plaintext if the URL ever
drifts off https://. .NET's default HttpClient already validates;
this is belt-and-braces.
- Softer wording in the docs. Drop the "or fork" / "the answer is to
not run" framing, replace "gag" with "pause" / "silence" / "stop",
reframe the rationale around "asking nodes to pause" rather than
"every node, regardless of operator, polls". The substance is
unchanged: still hardcoded, still not configurable, still removed
before 1.0. Just less stark.
Pin test updated for the new 300s / 1800s constants.
Tests: 637/637. Manual smoke confirms the poller hits the live OARC
URL over HTTPS using the pinned named-client handler.
---
docs/dev-time-tx-kill-switch.md | 30 ++++++------
docs/getting-started.md | 2 +-
.../TxKillSwitchPollerTests.cs | 4 +-
src/dapps/dapps.core/Program.cs | 15 ++++++
.../dapps.core/Services/TxKillSwitchPoller.cs | 47 +++++++++++++------
5 files changed, 65 insertions(+), 33 deletions(-)
diff --git a/docs/dev-time-tx-kill-switch.md b/docs/dev-time-tx-kill-switch.md
index 6293a6a..6a3d0f1 100644
--- a/docs/dev-time-tx-kill-switch.md
+++ b/docs/dev-time-tx-kill-switch.md
@@ -1,16 +1,16 @@
# 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.
+DAPPS is pre-1.0 software. While it is, every running node checks a URL controlled by the project author every five minutes; the URL can ask nodes to pause transmissions. This page exists so you know what that means before you put a node on the air.
## What is it
-Every DAPPS daemon, regardless of operator, polls
+Every running DAPPS daemon polls
```
https://compute.oarc.uk/storage/public/folders/4803/dapps-devtime-killswitch.json
```
-once a minute. The response is small JSON:
+every five minutes. The response is small JSON:
```json
{
@@ -26,31 +26,31 @@ The dashboard shows a red banner across every page when the gate is closed, with
## 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.
+DAPPS is early-stage software running on shared amateur radio bandwidth. A bug in a release could, in principle, cause a fleet of nodes to transmit more than they should. An operator may not be at the keyboard to catch a regression, especially overnight or during a working day. The kill-switch lets the author signal every running node to pause within a few minutes of spotting a problem, without coordinating with each operator 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.950" stays bounded to a few minutes of harm before every node goes silent.
+This is a software-development safety net, not a regulatory mechanism, not a moderation tool. The aim is to keep the worst case ("I shipped a bug that hammers 144.950") bounded.
-## What you cannot do
+## What's not configurable
-- 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 polling cannot be disabled.
+- The URL cannot be changed at runtime.
+- The cadence, staleness window, and fail-open behaviour are fixed.
-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.
+The values are constants in the source (`TxKillSwitchPoller.cs`) and the published binaries do not expose them as settings. This is deliberate: a configurable kill-switch wouldn't reliably reach every node, which is the whole point.
-If that posture is unacceptable to you, the answer is to not run pre-1.0 DAPPS, or to fork. Both are valid choices.
+If you'd rather not run software with this in place, deferring until 1.0 is a reasonable call.
## 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.
+- Continue to use the operator master TX-stop button independently. It is a separate signal; closing the local toggle pauses 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.
+- **URL unreachable after a successful poll**: the daemon keeps using the most recent successful state for thirty 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.
@@ -63,9 +63,9 @@ If the project pivots and a configurable per-fleet kill-switch becomes useful (a
## 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.
+A `GET` request to the URL above, every five minutes, 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.
+If polling that URL is itself a problem in your environment (an isolated network, a regulatory concern about outbound traffic), waiting for 1.0 before deploying may be the right call. Fail-open keeps TX working when the URL is unreachable, but the request itself still happens on the polling cadence.
## Source
diff --git a/docs/getting-started.md b/docs/getting-started.md
index b9dce04..d8b32c2 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -28,7 +28,7 @@ The wire protocol is small and human-readable on the line: a peer connects, gets
## 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.
+DAPPS is pre-1.0. While it is, every running node checks a URL controlled by the project author every five minutes; the URL can ask nodes to pause transmissions. 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
diff --git a/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs b/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs
index ebe0dc7..4cc9b6b 100644
--- a/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs
+++ b/src/dapps/dapps.core.tests/TxKillSwitchPollerTests.cs
@@ -183,8 +183,8 @@ public void HardcodedConstants_PinProductionValues()
// 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.PollSeconds.Should().Be(300);
+ TxKillSwitchPoller.StaleSeconds.Should().Be(1800);
TxKillSwitchPoller.FailOpen.Should().BeTrue();
}
diff --git a/src/dapps/dapps.core/Program.cs b/src/dapps/dapps.core/Program.cs
index 6150945..45d986b 100644
--- a/src/dapps/dapps.core/Program.cs
+++ b/src/dapps/dapps.core/Program.cs
@@ -67,6 +67,21 @@
builder.Services.AddHttpClient();
+// Dedicated named client for the TX kill-switch poller. Pinned to
+// a fresh SocketsHttpHandler so any future global handler tweak
+// (e.g. someone adding a "trust all certs" callback to the default
+// for testing and forgetting to remove it) cannot weaken the
+// validation on the kill-switch fetch. SslOptions is left at
+// default = system trust store + chain / hostname / expiry. The
+// poller adds a runtime guard that the URL scheme is https://;
+// together those two stop a downgrade or a bypassed validation
+// from silently rendering the kill-switch ineffective.
+builder.Services.AddHttpClient("tx-kill-switch")
+ .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
+ {
+ SslOptions = new System.Net.Security.SslClientAuthenticationOptions(),
+ });
+
// Plan A polish - single TimeProvider injected everywhere
// cadence-sensitive code reads time. Tests substitute
// FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) so
diff --git a/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs b/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs
index 237d63d..df34e1a 100644
--- a/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs
+++ b/src/dapps/dapps.core/Services/TxKillSwitchPoller.cs
@@ -16,9 +16,9 @@ namespace dapps.core.Services;
/// 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
+/// author can ask experimental nodes to pause if a release ships with
+/// a bug that transmits more than it should. 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.
///
@@ -36,9 +36,9 @@ namespace dapps.core.Services;
///
/// 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.
+/// (fail-open). A network outage doesn't silence 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 .
@@ -58,19 +58,21 @@ public sealed class TxKillSwitchPoller(
"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;
+ /// node. 300s (5 min) is responsive enough to pause a misbehaving
+ /// fleet within a single beacon cadence, slow enough that the
+ /// outbound traffic to the publishing endpoint stays trivial
+ /// even at hundreds of nodes.
+ public const int PollSeconds = 300;
/// 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;
+ /// value is considered stale. Constant: 1800 (30 min) - several
+ /// poll cycles of failure before declaring the cached value
+ /// untrustworthy, long enough to ride out a transient outage,
+ /// short enough that a stuck poller stops trusting hour-old state.
+ public const int StaleSeconds = 1800;
/// When stale or never-yet-fetched, allow TX. Constant:
- /// the kill-switch is for active intervention, not for gagging
+ /// the kill-switch is for active intervention, not for silencing
/// nodes that lose internet.
public const bool FailOpen = true;
@@ -160,6 +162,21 @@ private async Task PollOnce(CancellationToken ct)
{
try
{
+ // Belt-and-braces: refuse to fetch over plaintext even if
+ // the constant ever drifts. Cert validation itself is
+ // done by the named HttpClient's primary handler (see DI
+ // registration in Program.cs), which uses a fresh
+ // SocketsHttpHandler with default SslOptions - the system
+ // trust store + chain/hostname/expiry checks. We never
+ // override RemoteCertificateValidationCallback anywhere
+ // in the process; this guard catches the URL-scheme
+ // angle that's not in the handler's job description.
+ if (!KillSwitchUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ "TX kill-switch URL must be https:// - refusing to poll over plaintext");
+ }
+
var client = httpClientFactory.CreateClient("tx-kill-switch");
client.Timeout = TimeSpan.FromSeconds(10);