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);