From 5ce0ab61b6a71e8a9b32dcebf05e4ef828d90074 Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Thu, 11 Jun 2026 10:20:56 +0000 Subject: [PATCH 1/3] Config: env values are deployment-managed; derive callsign from the host node Two startup-config behaviours for the pdn supervised-app deployment, both inert for standalone installs: 1. Set DAPPS_* env vars are now re-applied at every start, not just first start. A stored row that differs from its set env var is updated and logged as deployment-managed. Unset env vars never touch stored config, so the standalone /Setup // /Config flow is unchanged, and seeding once via env then unsetting keeps dashboard control as before. /Settings and /Setup badge env-managed fields ("managed by environment") and render them read-only. 2. When the stored callsign is absent or the N0CALL placeholder and the pdn host injects PDN_NODE_CALLSIGN, the callsign is derived as -, where Ssid is a new seeded option (DAPPS_SSID) defaulting to "7" - proposing the convention that DAPPS resides at SSID -7 of the node callsign. Any SSID on PDN_NODE_CALLSIGN is stripped first. An explicit DAPPS_CALLSIGN or a real stored callsign always wins. /Setup prefills the derived value when applicable. Also: the release job now stamps pdn-app.yaml's version to the release tag and attaches it as a release asset so pdn's build can fetch manifest + binary from one release; pdn-app.yaml's seeding notes updated to match (no DAPPS_CALLSIGN pre-set needed under pdn any more); the seed call in Program.cs now gets a bootstrap console logger so these decisions are visible in the journal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 14 +- pdn-app.yaml | 28 +- src/dapps/dapps.core.tests/DbStartupTests.cs | 165 +++++++++++- src/dapps/dapps.core/Pages/Settings.cshtml | 40 +++ src/dapps/dapps.core/Pages/Settings.cshtml.cs | 14 + src/dapps/dapps.core/Pages/Setup.cshtml | 36 ++- src/dapps/dapps.core/Pages/Setup.cshtml.cs | 15 +- src/dapps/dapps.core/Program.cs | 11 +- src/dapps/dapps.core/Services/DbStartup.cs | 252 ++++++++++++++---- 9 files changed, 495 insertions(+), 80 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a89a886..edf36c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -247,7 +247,18 @@ jobs: path: dist merge-multiple: true + - name: Stamp manifest version + # pdn-app.yaml's `version:` field is informational (pdn shows it + # in its UI) and should track the release tag. Stamp it into the + # copy we attach so the asset always matches the release it + # ships with; the repo copy stays as-is. + run: | + sed -i 's/^version: .*/version: "${{ needs.test.outputs.version }}"/' pdn-app.yaml + grep '^version:' pdn-app.yaml + - name: Create GitHub Release + # pdn-app.yaml rides along as a release asset so the pdn build + # can fetch manifest + binary from the same release. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | @@ -256,4 +267,5 @@ jobs: --title "$tag" \ --target "${{ github.sha }}" \ --generate-notes \ - dist/* + dist/* \ + pdn-app.yaml diff --git a/pdn-app.yaml b/pdn-app.yaml index 8a35fb8..4157577 100644 --- a/pdn-app.yaml +++ b/pdn-app.yaml @@ -6,9 +6,9 @@ # deb from this repo's published release binary) or /var/lib/packetnet/apps/dapps # (owner-installed), and the app stays OFF until the node owner enables it. # -# A future dapps release workflow should attach this file as a release asset -# (next to the dapps-linux-* binaries) so pdn's build can fetch manifest + -# binary together from one release instead of carrying an interim copy. +# The release workflow attaches this file as a release asset (next to the +# dapps-linux-* binaries, with `version:` stamped to the release tag) so pdn's +# build can fetch manifest + binary together from one release. manifest: 1 id: dapps # must equal the package directory name name: DAPPS @@ -21,20 +21,24 @@ capabilities: [network, web] # binds its own callsigns over RHPv2; serves its o # DAPPS is not a console verb; it speaks RHPv2 to the node and binds its own # callsigns. # -# FIRST-START SEEDING — read before enabling: DAPPS seeds its config from -# DAPPS_* env vars ONLY on first start (a fresh data/dapps.db in the working -# directory). Once the db exists, env vars stop mattering — later changes go -# through the DAPPS dashboard's /Setup // /Config pages, or by deleting the db -# to re-seed. In particular the owner MUST supply their callsign via pdn's -# `apps:` override environment BEFORE the first start (there is no sensible -# default — DAPPS boots in setup-required mode with the N0CALL placeholder and -# refuses to bind bearers until a real callsign is configured): +# CONFIG SEEDING — how DAPPS_* env vars behave: every set DAPPS_* env var is +# deployment-managed config — it is applied at EVERY start (re-applied over +# whatever the dashboard stored), not just the first. Unset variables never +# touch stored config: anything not pinned below stays editable live through +# the DAPPS dashboard's /Setup // /Config pages, exactly as standalone. +# +# CALLSIGN — no pre-set needed: pdn injects PDN_NODE_CALLSIGN, and while no +# real callsign is configured DAPPS derives its own identity as +# - (DAPPS_SSID defaults to 7, so node M0XYZ +# gets DAPPS at M0XYZ-7). The owner can still pin a different identity via +# pdn's `apps:` override environment — an explicit DAPPS_CALLSIGN (or a real +# callsign saved via the dashboard) always wins over derivation: # # apps: # - id: dapps # enabled: true # environment: -# DAPPS_CALLSIGN: M0XYZ-7 +# DAPPS_CALLSIGN: M0XYZ-9 # optional — omit to accept -7 # # The working directory defaults to the app's state dir, so the database lands # in /var/lib/packetnet/apps/dapps (DAPPS resolves it cwd-relative). diff --git a/src/dapps/dapps.core.tests/DbStartupTests.cs b/src/dapps/dapps.core.tests/DbStartupTests.cs index beaafc9..c3be90a 100644 --- a/src/dapps/dapps.core.tests/DbStartupTests.cs +++ b/src/dapps/dapps.core.tests/DbStartupTests.cs @@ -7,10 +7,12 @@ namespace dapps.core.tests; /// -/// DbStartup.EnsureSchemaAndSeed is the system's first-run config seam: -/// it creates schema, seeds defaults from env vars (Plan C2), and -/// refuses to start on a placeholder callsign. These tests drive each -/// path against a fresh SQLite file. +/// DbStartup.EnsureSchemaAndSeed is the system's startup config seam: +/// it creates schema, seeds defaults from env vars (Plan C2), +/// re-applies set env vars at every start (deployment-managed config), +/// derives a callsign from a pdn host's PDN_NODE_CALLSIGN, and warns +/// on a placeholder callsign. These tests drive each path against a +/// fresh SQLite file. /// [Collection(SqliteOverridePathCollection.Name)] public sealed class DbStartupTests : IAsyncLifetime @@ -25,6 +27,8 @@ public sealed class DbStartupTests : IAsyncLifetime "DAPPS_DEFAULT_BEARER_PORT", "DAPPS_CALLSIGN", "DAPPS_MQTT_PORT", + "DAPPS_SSID", + "PDN_NODE_CALLSIGN", ]; public ValueTask InitializeAsync() @@ -103,21 +107,166 @@ public void EnsureSchemaAndSeed_AllEnvVars_SeedsEachOption() } [Fact] - public void EnsureSchemaAndSeed_ExistingRow_NotOverwrittenByEnv() + public void EnsureSchemaAndSeed_ExistingRow_EnvSet_AppliedAtEveryStart() { - // Pre-seed a manually-configured callsign as if /Config POST had set it. + // Deployment-managed config: a SET env var wins over the stored + // row at every start, not just the first - the pdn supervised- + // app case, where the host's app config is authoritative. + using (var c = DbInfo.GetConnection()) + { + c.CreateTable(); + c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); + } + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST-7"); + + DbStartup.EnsureSchemaAndSeed(); + + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("G0TST-7", + "a set env var is deployment-managed and re-applied over the stored row at every start"); + } + + [Fact] + public void EnsureSchemaAndSeed_ExistingRow_NoEnv_LeftAlone() + { + // The standalone flow: no DAPPS_* env set, so the stored + // (dashboard-configured) value must survive every restart + // byte-for-byte. using (var c = DbInfo.GetConnection()) { c.CreateTable(); c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); } - Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "DIFFERENT-CALL"); DbStartup.EnsureSchemaAndSeed(); using var conn = DbInfo.GetConnection(); conn.Find("Callsign")!.Value.Should().Be("M0LTE-3", - "existing rows MUST NOT be overwritten by env vars on subsequent starts"); + "unset env vars must never touch stored config"); + } + + [Fact] + public void EnsureSchemaAndSeed_SeedOnceViaEnvThenUnset_DashboardEditSticks() + { + // A standalone operator who seeds once via env then unsets it + // keeps dashboard control exactly as before this change. + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST"); + DbStartup.EnsureSchemaAndSeed(); + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", null); + + // Restart without the env var: seeded value sticks. + DbStartup.EnsureSchemaAndSeed(); + using (var c = DbInfo.GetConnection()) + { + c.Find("Callsign")!.Value.Should().Be("G0TST"); + // Dashboard edit (as /Config POST would persist it). + c.Execute("update systemoptions set value=? where option=?", "M0LTE-3", "Callsign"); + } + + // Next restart: the edit survives. + DbStartup.EnsureSchemaAndSeed(); + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("M0LTE-3"); + } + + [Fact] + public void EnsureSchemaAndSeed_PdnNodeCallsign_DerivesCallsignWithDefaultSsid() + { + // pdn-hosted fresh install: no DAPPS_CALLSIGN, host injects + // PDN_NODE_CALLSIGN -> DAPPS takes up residence at SSID -7 of + // the node callsign. + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("M9YYY-7"); + c.Find("Ssid")!.Value.Should().Be("7", "the SSID knob is seeded alongside"); + } + + [Fact] + public void EnsureSchemaAndSeed_PdnNodeCallsignWithSsid_StripsItBeforeComposing() + { + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "m9yyy-2"); + + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("M9YYY-7", + "the node's own SSID is stripped (and the base upper-cased) before composing"); + } + + [Fact] + public void EnsureSchemaAndSeed_DappsSsidEnv_OverridesDerivationSsid() + { + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + Environment.SetEnvironmentVariable("DAPPS_SSID", "4"); + + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("M9YYY-4"); + } + + [Fact] + public void EnsureSchemaAndSeed_ExplicitDappsCallsign_WinsOverDerivation() + { + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST-1"); + + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("G0TST-1", + "an explicit DAPPS_CALLSIGN always wins over derivation"); + } + + [Fact] + public void EnsureSchemaAndSeed_RealStoredCallsign_WinsOverDerivation() + { + using (var c = DbInfo.GetConnection()) + { + c.CreateTable(); + c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); + } + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + DbStartup.EnsureSchemaAndSeed(); + + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("M0LTE-3", + "a real stored callsign always wins over derivation"); + } + + [Fact] + public void EnsureSchemaAndSeed_StoredPlaceholder_PdnNodeCallsign_Derives() + { + // A pdn host whose DAPPS db predates a callsign config (or was + // reset to the placeholder) picks up the derived identity on + // the next start. + using (var c = DbInfo.GetConnection()) + { + c.CreateTable(); + c.Insert(new DbSystemOption { Option = "Callsign", Value = "N0CALL" }); + } + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + DbStartup.EnsureSchemaAndSeed(); + + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("M9YYY-7"); + } + + [Fact] + public void EnsureSchemaAndSeed_NoPdnEnv_PlaceholderUnchanged() + { + // Standalone install: no PDN_NODE_CALLSIGN, no DAPPS_CALLSIGN - + // the placeholder stays and the daemon boots into the existing + // setup-required flow (configure via /Setup // /Config). + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("N0CALL"); } [Fact] diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml b/src/dapps/dapps.core/Pages/Settings.cshtml index 5ac510a..11527e0 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml +++ b/src/dapps/dapps.core/Pages/Settings.cshtml @@ -19,6 +19,18 @@ font-family: var(--font-mono); letter-spacing: 0.18em; } + + /* Deployment-managed (env-set) fields: badge next to the label, + control rendered read-only. See the env-managed script below. */ + .env-badge { + display: inline-block; margin-left: 0.4rem; + padding: 0.05rem 0.4rem; border-radius: 999px; + font-size: 0.68rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.05em; + background: color-mix(in srgb, var(--accent, #2563eb) 15%, transparent); + color: var(--accent, #2563eb); + cursor: help; vertical-align: middle; + }
@@ -227,6 +239,34 @@ async function dappsRotatePassword(ev) { } } +// --- Deployment-managed (env-set) fields --- +// Any option whose DAPPS_* env var is set on the daemon's environment +// is re-applied from the environment at EVERY start (the pdn +// supervised-app case), so a dashboard edit would be silently +// overridden on the next restart. Badge those fields and make them +// read-only so the operator isn't invited to fight the deployment. +// The form still serialises their values on save (the JS reads .value +// // .checked directly, which works on readonly/disabled controls), +// so saving unrelated fields keeps env-managed ones intact. +(function () { + const managed = @Json.Serialize(Model.EnvManaged.Select(f => new { key = f.Key, env = f.Env })); + const form = document.getElementById('settings-form'); + if (!form) return; + for (const m of managed) { + const el = form.querySelector(`[name='${m.key}']`); + if (!el) continue; // option without a form field (e.g. Ssid, DefaultBearerPort) + if (el.tagName === 'SELECT' || el.type === 'checkbox') el.disabled = true; + else el.readOnly = true; + const badge = document.createElement('span'); + badge.className = 'env-badge'; + badge.textContent = 'managed by environment'; + badge.title = `Set from ${m.env} at every start — dashboard edits will be overridden while the variable remains set.`; + const holder = el.closest('.field')?.querySelector('label') + ?? el.closest('.field-checkbox')?.querySelector('span'); + holder?.appendChild(badge); + } +})(); + // --- Bearer-conditional fields --- // AGW vs RHPv2 are mutually exclusive at runtime; showing all four // fields at once is noisy and invites the operator to "fix" the diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml.cs b/src/dapps/dapps.core/Pages/Settings.cshtml.cs index d407417..e66404b 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml.cs +++ b/src/dapps/dapps.core/Pages/Settings.cshtml.cs @@ -1,4 +1,5 @@ using dapps.core.Models; +using dapps.core.Services; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Options; @@ -10,13 +11,26 @@ namespace dapps.core.Pages; /// "Routing & probing", "Polling", "Heartbeat", "Updates", and /// "Admin password" panels. Posts JSON to /Config; the daemon /// hot-reloads everything except the MQTT and UDP listener ports. +/// +/// Fields whose DAPPS_* env var is set on the daemon's +/// environment are deployment-managed: +/// re-applies the env value at every start, so dashboard edits would +/// be silently overridden. feeds the page +/// script that badges those fields and makes them read-only. /// public sealed class SettingsModel(IOptionsMonitor options) : PageModel { + public sealed record EnvManagedField(string Key, string Env); + public SystemOptions Options { get; private set; } = new(); + public IReadOnlyList EnvManaged { get; private set; } = []; + public void OnGet() { Options = options.CurrentValue; + EnvManaged = DbStartup.EnvManagedKeys() + .Select(k => new EnvManagedField(k, DbStartup.EnvVarFor(k))) + .ToArray(); } } diff --git a/src/dapps/dapps.core/Pages/Setup.cshtml b/src/dapps/dapps.core/Pages/Setup.cshtml index aaeba13..5e19e71 100644 --- a/src/dapps/dapps.core/Pages/Setup.cshtml +++ b/src/dapps/dapps.core/Pages/Setup.cshtml @@ -137,6 +137,18 @@ .detect-result.err { color: var(--err); } .bearer-fields { display: none; } .bearer-fields.show { display: block; } + /* Deployment-managed (env-set) fields: DbStartup re-applies the + DAPPS_* env value at every start, so edits here would be + overridden on the next restart. */ + .env-badge { + display: inline-block; margin-left: 0.4rem; + padding: 0.05rem 0.4rem; border-radius: 999px; + font-size: 0.68rem; font-weight: 600; + letter-spacing: 0.05em; + background: color-mix(in srgb, var(--accent) 15%, transparent); + color: var(--accent); + cursor: help; vertical-align: middle; + } @@ -185,33 +197,37 @@ linbpq or xrouter on this host.

- + + pattern="[A-Za-z0-9\-]{3,15}" value="@Model.Callsign" + readonly="@SetupModel.IsEnvManaged("Callsign")" /> - - + +
not detected yet
- + - +
- - + + - - + +
diff --git a/src/dapps/dapps.core/Pages/Setup.cshtml.cs b/src/dapps/dapps.core/Pages/Setup.cshtml.cs index 4227ab4..efb3647 100644 --- a/src/dapps/dapps.core/Pages/Setup.cshtml.cs +++ b/src/dapps/dapps.core/Pages/Setup.cshtml.cs @@ -64,6 +64,11 @@ public enum SetupStep { Password, Bearer } public string? Error { get; private set; } + /// True when the option's DAPPS_* env var is set: + /// the value is deployment-managed (re-applied at every start), so + /// the form badges it. See . + public static bool IsEnvManaged(string key) => DbStartup.IsEnvManaged(key); + public async Task OnGetAsync() { if (!await store.IsConfiguredAsync()) @@ -77,9 +82,15 @@ public async Task OnGetAsync() { // Operator set the password but hasn't done bearer yet. // Pre-fill the form from the persisted defaults so they - // only type what they want to change. + // only type what they want to change. Under a pdn host + // (PDN_NODE_CALLSIGN injected) suggest the conventional + // - identity; normally DbStartup has + // already persisted that derivation at boot and this step + // is skipped entirely, but if the callsign was reset to the + // placeholder mid-run the wizard still offers the right + // default. Step = SetupStep.Bearer; - Callsign = ""; + Callsign = DbStartup.DeriveCallsignFromHostNode() ?? ""; NodeHost = string.IsNullOrWhiteSpace(opts.NodeHost) ? "localhost" : opts.NodeHost; NodeBearer = string.Equals(opts.NodeBearer, "rhpv2", StringComparison.OrdinalIgnoreCase) ? "rhpv2" : "agw"; AgwPortDefault = opts.AgwPort > 0 ? opts.AgwPort : 8000; diff --git a/src/dapps/dapps.core/Program.cs b/src/dapps/dapps.core/Program.cs index aceae22..55f2b29 100644 --- a/src/dapps/dapps.core/Program.cs +++ b/src/dapps/dapps.core/Program.cs @@ -30,7 +30,16 @@ // which would race a hosted-service seeder and lose, since hosted // services are CONSTRUCTED in one pass before any of their StartAsync // runs. -DbStartup.EnsureSchemaAndSeed(); +// +// A throwaway console logger (the host's logging isn't built yet) +// makes the seeding decisions visible: which options were seeded from +// env vars, which stored values an env var re-applied (deployment- +// managed config), and a callsign derived from a pdn host's +// PDN_NODE_CALLSIGN. +using (var seedLoggerFactory = LoggerFactory.Create(b => b.AddSimpleConsole())) +{ + DbStartup.EnsureSchemaAndSeed(seedLoggerFactory.CreateLogger(nameof(DbStartup))); +} var builder = WebApplication.CreateBuilder(args); diff --git a/src/dapps/dapps.core/Services/DbStartup.cs b/src/dapps/dapps.core/Services/DbStartup.cs index 1b717d0..0a19e9a 100644 --- a/src/dapps/dapps.core/Services/DbStartup.cs +++ b/src/dapps/dapps.core/Services/DbStartup.cs @@ -32,10 +32,73 @@ public static class DbStartup /// placeholder never go on the air. public const string PlaceholderCallsign = "N0CALL"; + /// Option key holding the SSID used when deriving this + /// instance's callsign from the host node's callsign (see + /// ). Seeded to ; + /// env-overridable as DAPPS_SSID like every other seeded + /// option. Only consulted at derivation time - a stored or + /// env-supplied Callsign always wins. + public const string SsidOptionKey = "Ssid"; + + /// Proposed convention: DAPPS lives at SSID -7 of the host + /// node's callsign (matches the M0LTE-7 acceptance-test identity). + /// There is no packet-wide DAPPS SSID convention yet; this default + /// is the proposal. + public const string DefaultSsid = "7"; + + /// Injected by a pdn host into supervised app processes: + /// the node's own callsign text, e.g. M9YYY (may carry an + /// SSID, which we strip before composing). Absent when DAPPS runs + /// standalone alongside BPQ/XRouter. + public const string NodeCallsignEnvVar = "PDN_NODE_CALLSIGN"; + /// - /// Create every table the daemon needs and seed the first-run - /// systemoptions defaults (env-var overrides → hardcoded fallback). - /// Safe to call multiple times - every step is idempotent. + /// Every option key EnsureSchemaAndSeed seeds, with its hardcoded + /// fallback default. Single source of truth for seeding, for the + /// per-start env application, and for the dashboard's + /// "managed by environment" markers. + /// + private static readonly (string Key, string Default)[] SeededOptions = + [ + ("NodeHost", "localhost"), + ("AgwPort", "8000"), + ("DefaultBearerPort", "0"), + ("Callsign", PlaceholderCallsign), + ("MqttPort", "1883"), + ("UdpListenPort", "0"), + ("AuthRequired", "false"), + ("UpdateCheckEnabled", "true"), + ("RoutingAlgorithm", "passive-flood"), + ("ProbingEnabled", "false"), + ("ProbeIntervalHours", "24"), + ("FragmentThresholdBytes", "4096"), + ("FragmentReassemblyTimeoutSeconds", "604800"), + ("RouteGossipStalenessHours", "6"), + ("OpportunisticPollEnabled", "true"), + ("ScheduledPollEnabled", "false"), + ("PollIntervalHours", "6"), + ("DiscoveryAirtimeBudgetSecondsPerHour", "0"), + ("ProbeStrategy", nameof(Models.ProbeStrategy.FixedInterval)), + ("ProbeOvernightStartHour", "2"), + ("ProbeOvernightEndHour", "6"), + ("ProbeQuietWindowSeconds", "300"), + ("HeartbeatEnabled", "true"), + ("HeartbeatIntervalSeconds", "60"), + ("AutoDiscoverViaNodeCall", "false"), + ("NodePromptApplicationCommand", "DAPPS"), + ("NodeBearer", "agw"), + ("RhpPort", "9000"), + ("RhpUser", ""), + ("RhpPass", ""), + (SsidOptionKey, DefaultSsid), + ]; + + /// + /// Create every table the daemon needs, seed the first-run + /// systemoptions defaults (env-var overrides → hardcoded fallback), + /// and re-apply any env-set values to existing rows (deployment- + /// managed config). Safe to call multiple times - every step is + /// idempotent. /// /// Called once from Program.cs *before* builder.Build() so /// the eager DI materialisation of hosted services (which transit @@ -71,65 +134,162 @@ public static void EnsureSchemaAndSeed(ILogger? logger = null) var optionsTable = db.Table().Table.TableName; var options = db.Query($"select * from {optionsTable};"); - // Seeded defaults. When an env var DAPPS_ is set, it wins; - // otherwise the hardcoded fallback applies. Either way the value - // is only written when no row exists - once configured (here or - // via /Config), the row sticks and env vars stop mattering. - InsertIfNotPresent(db, options, "NodeHost", "localhost", logger); - InsertIfNotPresent(db, options, "AgwPort", "8000", logger); - InsertIfNotPresent(db, options, "DefaultBearerPort", "0", logger); - InsertIfNotPresent(db, options, "Callsign", PlaceholderCallsign, logger); - InsertIfNotPresent(db, options, "MqttPort", "1883", logger); - InsertIfNotPresent(db, options, "UdpListenPort", "0", logger); - InsertIfNotPresent(db, options, "AuthRequired", "false", logger); - InsertIfNotPresent(db, options, "UpdateCheckEnabled", "true", logger); - InsertIfNotPresent(db, options, "RoutingAlgorithm", "passive-flood", logger); - InsertIfNotPresent(db, options, "ProbingEnabled", "false", logger); - InsertIfNotPresent(db, options, "ProbeIntervalHours", "24", logger); - InsertIfNotPresent(db, options, "FragmentThresholdBytes", "4096", logger); - InsertIfNotPresent(db, options, "FragmentReassemblyTimeoutSeconds", "604800", logger); - InsertIfNotPresent(db, options, "RouteGossipStalenessHours", "6", logger); - InsertIfNotPresent(db, options, "OpportunisticPollEnabled", "true", logger); - InsertIfNotPresent(db, options, "ScheduledPollEnabled", "false", logger); - InsertIfNotPresent(db, options, "PollIntervalHours", "6", logger); - InsertIfNotPresent(db, options, "DiscoveryAirtimeBudgetSecondsPerHour", "0", logger); - InsertIfNotPresent(db, options, "ProbeStrategy", nameof(ProbeStrategy.FixedInterval), logger); - InsertIfNotPresent(db, options, "ProbeOvernightStartHour", "2", logger); - InsertIfNotPresent(db, options, "ProbeOvernightEndHour", "6", logger); - InsertIfNotPresent(db, options, "ProbeQuietWindowSeconds", "300", logger); - InsertIfNotPresent(db, options, "HeartbeatEnabled", "true", logger); - InsertIfNotPresent(db, options, "HeartbeatIntervalSeconds", "60", logger); - InsertIfNotPresent(db, options, "AutoDiscoverViaNodeCall", "false", logger); - InsertIfNotPresent(db, options, "NodePromptApplicationCommand", "DAPPS", logger); - InsertIfNotPresent(db, options, "NodeBearer", "agw", logger); - InsertIfNotPresent(db, options, "RhpPort", "9000", logger); - InsertIfNotPresent(db, options, "RhpUser", "", logger); - InsertIfNotPresent(db, options, "RhpPass", "", logger); + // Seeded defaults. When an env var DAPPS_ is set, it wins - + // on EVERY start, not just the first: a set env var is + // deployment-managed config (the pdn supervised-app case) and is + // re-applied over whatever the row holds. An UNSET env var never + // touches an existing row, so the standalone flow (no DAPPS_* + // env; configure via /Setup // /Config) is unchanged, and a + // standalone operator who seeds once via env then unsets it + // keeps dashboard control exactly as before. + foreach (var (key, defaultValue) in SeededOptions) + { + SeedOrApplyEnv(db, options, key, defaultValue, logger); + } + + DeriveCallsignFromHostNodeIfUnset(db, logger); ValidateRequiredConfig(db, logger); logger?.LogInformation("DB schema refreshed"); } - private static void InsertIfNotPresent(SQLiteConnection db, List options, string key, string defaultValue, ILogger? logger) + private static void SeedOrApplyEnv(SQLiteConnection db, List options, string key, string defaultValue, ILogger? logger) { - if (options.Any(o => string.Equals(o.Option, key, StringComparison.OrdinalIgnoreCase))) + var envKey = EnvVarFor(key); + var envValue = Environment.GetEnvironmentVariable(envKey); + + var existing = options.FirstOrDefault(o => string.Equals(o.Option, key, StringComparison.OrdinalIgnoreCase)); + if (existing is null) { + var value = string.IsNullOrEmpty(envValue) ? defaultValue : envValue; + db.Insert(new DbSystemOption { Option = key, Value = value }); + + if (!string.IsNullOrEmpty(envValue)) + { + logger?.LogInformation("Seeded {0} from {1}", key, envKey); + } return; } - var envKey = "DAPPS_" + ToScreamingSnake(key); - var envValue = Environment.GetEnvironmentVariable(envKey); - var value = string.IsNullOrEmpty(envValue) ? defaultValue : envValue; + if (!string.IsNullOrEmpty(envValue) && !string.Equals(existing.Value, envValue, StringComparison.Ordinal)) + { + existing.Value = envValue; + db.Update(existing); + logger?.LogInformation( + "SystemOption {Key} applied from environment ({EnvVar}) — this value is deployment-managed; " + + "dashboard edits will be overridden while the variable remains set", + key, envKey); + } + } - db.Insert(new DbSystemOption { Option = key, Value = value }); + /// + /// "DAPPS resides at an SSID of the node callsign": when DAPPS runs + /// supervised under a pdn node, the host injects + /// and we derive + /// <base-of-node-call>-<Ssid> as the callsign - + /// but only while the stored callsign is absent or still the + /// placeholder. An explicit + /// DAPPS_CALLSIGN env var or a real stored callsign always + /// wins over derivation. Standalone installs (no PDN_NODE_CALLSIGN) + /// are untouched. + /// + private static void DeriveCallsignFromHostNodeIfUnset(SQLiteConnection db, ILogger? logger) + { + // An explicit DAPPS_CALLSIGN wins over derivation. (It was + // already applied above; this guard also keeps a pathological + // DAPPS_CALLSIGN=N0CALL from being re-derived underneath.) + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvVarFor("Callsign")))) + { + return; + } - if (!string.IsNullOrEmpty(envValue)) + var options = db.Query("select * from systemoptions;"); + var callsignRow = options.FirstOrDefault( + o => string.Equals(o.Option, "Callsign", StringComparison.OrdinalIgnoreCase)); + var stored = callsignRow?.Value ?? ""; + var unset = string.IsNullOrWhiteSpace(stored) + || string.Equals(stored, PlaceholderCallsign, StringComparison.OrdinalIgnoreCase); + if (!unset) { - logger?.LogInformation("Seeded {0} from {1}", key, envKey); + return; // a real stored callsign always wins } + + var ssidRow = options.FirstOrDefault( + o => string.Equals(o.Option, SsidOptionKey, StringComparison.OrdinalIgnoreCase)); + var ssid = string.IsNullOrWhiteSpace(ssidRow?.Value) ? DefaultSsid : ssidRow!.Value.Trim(); + var derived = DeriveCallsignFromHostNode(ssid); + if (derived is null) + { + return; // not pdn-hosted - standalone setup-required flow as before + } + + if (callsignRow is null) + { + db.Insert(new DbSystemOption { Option = "Callsign", Value = derived }); + } + else + { + callsignRow.Value = derived; + db.Update(callsignRow); + } + + logger?.LogInformation( + "Callsign {Derived} derived from the host node ({EnvVar}={NodeCall}, SSID {Ssid}). " + + "Set DAPPS_CALLSIGN or configure a callsign via the dashboard to pin a different identity.", + derived, NodeCallsignEnvVar, Environment.GetEnvironmentVariable(NodeCallsignEnvVar), ssid); + } + + /// Compose the conventional pdn-hosted DAPPS callsign: + /// base of the host node's callsign (any SSID stripped, upper-cased) + /// + . Null when + /// is not set or empty (standalone install). + public static string? DeriveCallsignFromHostNode(string ssid) + { + var nodeCall = Environment.GetEnvironmentVariable(NodeCallsignEnvVar); + if (string.IsNullOrWhiteSpace(nodeCall)) + { + return null; + } + + var baseCall = nodeCall.Trim().ToUpperInvariant().Split('-')[0].Trim(); + if (baseCall.Length == 0) + { + return null; + } + + return $"{baseCall}-{ssid}"; + } + + /// Overload reading the stored Ssid option (fallback + /// ). Used by /Setup to prefill the + /// callsign field when the daemon is pdn-hosted and still in + /// setup-required mode. + public static string? DeriveCallsignFromHostNode() + { + using var db = DbInfo.GetConnection(); + db.CreateTable(); + var ssidRow = db.Query("select * from systemoptions;") + .FirstOrDefault(o => string.Equals(o.Option, SsidOptionKey, StringComparison.OrdinalIgnoreCase)); + var ssid = string.IsNullOrWhiteSpace(ssidRow?.Value) ? DefaultSsid : ssidRow!.Value.Trim(); + return DeriveCallsignFromHostNode(ssid); } + /// The env var that overrides the given seeded option key, + /// e.g. NodeHostDAPPS_NODE_HOST. + public static string EnvVarFor(string key) => "DAPPS_" + ToScreamingSnake(key); + + /// True when the given option key's DAPPS_* env var + /// is currently set (non-empty) - i.e. the value is deployment- + /// managed and re-applied at every start. + public static bool IsEnvManaged(string key) => + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvVarFor(key))); + + /// Seeded option keys whose env var is currently set, for + /// the dashboard's "managed by environment" field markers. + public static IReadOnlyList EnvManagedKeys() => + SeededOptions.Select(s => s.Key).Where(IsEnvManaged).ToArray(); + /// /// Warn if the callsign is the seeded placeholder. The daemon starts /// either way - inbound bearer services and the outbound forwarder @@ -161,7 +321,7 @@ private static void ValidateRequiredConfig(SQLiteConnection db, ILogger? logger) /// /// Convert a PascalCase or camelCase identifier to SCREAMING_SNAKE_CASE /// for use as an environment-variable suffix. NodeHost → - /// NODE_HOST; DefaultBearerPortDEFAULT_BPQ_PORT. + /// NODE_HOST; DefaultBearerPortDEFAULT_BEARER_PORT. /// private static string ToScreamingSnake(string identifier) { From aaa6b26b4e087100de48a91d5e35dbb0a227bec2 Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Thu, 11 Jun 2026 11:12:31 +0000 Subject: [PATCH 2/3] Env precedence opt-in via DAPPS_ENV_MANAGED; derived callsigns probe for a free SSID Amendment 1 (review fix - the re-apply semantics were a breaking change for standalone installs): scripts/dapps.service keeps EnvironmentFile=/etc/dapps.env applied forever, so re-applying set DAPPS_* values at every start would silently revert dashboard edits for every documented standalone install. The deployment-managed behaviour is now gated behind DAPPS_ENV_MANAGED=true, a mode switch read each start and never stored. Unset/false keeps today's byte-for-byte semantics: env vars seed missing rows on first start only, no UI badges. True gives the deployment-managed semantics exactly as built: re-apply at every start + journal line + read-only "managed by environment" badges. pdn-app.yaml opts supervised deployments in and documents both modes; docs/configure.md gets the same note. The original ExistingRow_NotOverwrittenByEnv test is restored verbatim and both contracts are pinned under their respective modes. The callsign derivation stays ungated - it only fires when the callsign is placeholder/absent AND PDN_NODE_CALLSIGN is present, which standalone installs never have. Amendment 2: instead of blindly trusting -, a derived callsign now checks for free/taken SSIDs over RHPv2 itself. pdn refuses a listen on an already-claimed callsign (including the node's own) with errCode 9 "Duplicate socket", deterministically (packet.net docs/rhp2-server.md deviation D5). DbStartup records each derivation in a DerivedCallsignPending row; while that marker still matches the callsign in use (and no explicit DAPPS_CALLSIGN is set), Rhpv2InboundService treats a 9 as "taken" and walks the candidate SSIDs - start+1..15, then 1..start-1, skipping 0 and the SSID the node itself uses (parsed off PDN_NODE_CALLSIGN). The first successful listen wins and is persisted as the stored callsign (one log line: derived callsign M9YYY-8 - -7 was taken on the node), so the identity is stable on every later start; a persisted, confirmed callsign never walks again. An explicitly configured callsign NEVER walks - a 9 logs the refusal and keeps the existing retry/reconnect behaviour. Non-9 errors never walk. All candidates taken logs loudly and reverts to setup-required mode instead of spinning; the next restart re-derives and probes again. Against a server that answers duplicate listens Ok (live XRouter does), the walk simply never triggers - fine, since derivation only runs under pdn supervision. dapps.core.tests: 674 passed, 0 failed (branch baseline 660; +14: both env modes incl. the restored standalone contract, badge gating, the pending-marker lifecycle, and the probe-walk against MockRhpServer playing pdn's D5 role - walk+persist+stable-across-restart, explicit-never-walks, non-9-never-walks, all-taken-reverts, and the candidate-order unit test). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configure.md | 2 + pdn-app.yaml | 27 +- src/dapps/dapps.core.tests/DbStartupTests.cs | 193 +++++++++- .../Rhpv2InboundServiceTests.cs | 348 +++++++++++++++++- src/dapps/dapps.core/Pages/Settings.cshtml | 12 +- src/dapps/dapps.core/Pages/Settings.cshtml.cs | 13 +- src/dapps/dapps.core/Pages/Setup.cshtml.cs | 8 +- src/dapps/dapps.core/Services/DbStartup.cs | 185 ++++++++-- .../Services/Rhpv2InboundService.cs | 158 +++++++- 9 files changed, 887 insertions(+), 59 deletions(-) diff --git a/docs/configure.md b/docs/configure.md index 24360e4..1f8822a 100644 --- a/docs/configure.md +++ b/docs/configure.md @@ -8,6 +8,8 @@ Every operator-tunable setting on a DAPPS node has three configuration surfaces: For runtime-changeable knobs (probing on/off, fragment threshold, airtime budget, etc.), the dashboard is the right surface - changes pick up without a restart. For startup-only knobs (callsign, packet-node host/port, MQTT port, dashboard URL), the env var is the right surface - they're read once at boot. +> **Deployment-managed mode (`DAPPS_ENV_MANAGED=true`)**: when a supervising deployment (a packet.net node host, say) sets `DAPPS_ENV_MANAGED=true`, every *set* `DAPPS_*` env var becomes deployment-managed config instead - re-applied over the persisted row at **every** start, with the matching dashboard fields badged "managed by environment" and rendered read-only. `DAPPS_ENV_MANAGED` itself is a mode switch read each start, never stored. Leave it unset for the standalone installs documented here: with it unset (or `false`) env vars keep the first-start-only semantics above and the dashboard stays in charge. + ## Show the current persisted config ```bash diff --git a/pdn-app.yaml b/pdn-app.yaml index 4157577..6e1bb0a 100644 --- a/pdn-app.yaml +++ b/pdn-app.yaml @@ -21,18 +21,28 @@ capabilities: [network, web] # binds its own callsigns over RHPv2; serves its o # DAPPS is not a console verb; it speaks RHPv2 to the node and binds its own # callsigns. # -# CONFIG SEEDING — how DAPPS_* env vars behave: every set DAPPS_* env var is -# deployment-managed config — it is applied at EVERY start (re-applied over -# whatever the dashboard stored), not just the first. Unset variables never -# touch stored config: anything not pinned below stays editable live through -# the DAPPS dashboard's /Setup // /Config pages, exactly as standalone. +# CONFIG SEEDING — how DAPPS_* env vars behave: DAPPS has two modes, switched +# by DAPPS_ENV_MANAGED (read each start, never stored): +# +# - unset/false (the standalone default): env vars seed missing options on +# first start only; thereafter the dashboard owns every value. +# - "true" (set below — pdn-supervised deployments opt in): every set +# DAPPS_* env var is deployment-managed config — re-applied over whatever +# the dashboard stored at EVERY start, and badged read-only in the +# dashboard. Unset variables never touch stored config: anything not +# pinned below stays editable live through the DAPPS dashboard's +# /Setup // /Config pages, exactly as standalone. # # CALLSIGN — no pre-set needed: pdn injects PDN_NODE_CALLSIGN, and while no # real callsign is configured DAPPS derives its own identity as # - (DAPPS_SSID defaults to 7, so node M0XYZ -# gets DAPPS at M0XYZ-7). The owner can still pin a different identity via -# pdn's `apps:` override environment — an explicit DAPPS_CALLSIGN (or a real -# callsign saved via the dashboard) always wins over derivation: +# gets DAPPS at M0XYZ-7). If that SSID is already claimed on the node (pdn +# refuses the listen with errCode 9 "Duplicate socket"), DAPPS probes the +# next free SSID over RHPv2 (skipping 0 and the node's own SSID) and keeps +# the first one that binds — the confirmed identity is then stable across +# restarts. The owner can still pin a different identity via pdn's `apps:` +# override environment — an explicit DAPPS_CALLSIGN (or a real callsign saved +# via the dashboard) always wins over derivation and never probes: # # apps: # - id: dapps @@ -45,6 +55,7 @@ capabilities: [network, web] # binds its own callsigns over RHPv2; serves its o service: command: ./dapps # relative — resolves to the release binary in this package dir environment: + DAPPS_ENV_MANAGED: "true" # opt in: env vars below are re-applied at every start DAPPS_NODE_BEARER: rhpv2 # speak RHPv2 to pdn (not AGW) DAPPS_NODE_HOST: "127.0.0.1" DAPPS_RHP_PORT: "9000" # pdn's RHP server loopback port diff --git a/src/dapps/dapps.core.tests/DbStartupTests.cs b/src/dapps/dapps.core.tests/DbStartupTests.cs index c3be90a..89232d9 100644 --- a/src/dapps/dapps.core.tests/DbStartupTests.cs +++ b/src/dapps/dapps.core.tests/DbStartupTests.cs @@ -9,10 +9,12 @@ namespace dapps.core.tests; /// /// DbStartup.EnsureSchemaAndSeed is the system's startup config seam: /// it creates schema, seeds defaults from env vars (Plan C2), -/// re-applies set env vars at every start (deployment-managed config), -/// derives a callsign from a pdn host's PDN_NODE_CALLSIGN, and warns -/// on a placeholder callsign. These tests drive each path against a -/// fresh SQLite file. +/// re-applies set env vars at every start ONLY under the opt-in +/// DAPPS_ENV_MANAGED=true mode (deployment-managed config - the pdn +/// supervised-app case), derives a callsign from a pdn host's +/// PDN_NODE_CALLSIGN, and warns on a placeholder callsign. These tests +/// drive each path against a fresh SQLite file - both env-var modes +/// have their contract pinned here. /// [Collection(SqliteOverridePathCollection.Name)] public sealed class DbStartupTests : IAsyncLifetime @@ -28,6 +30,7 @@ public sealed class DbStartupTests : IAsyncLifetime "DAPPS_CALLSIGN", "DAPPS_MQTT_PORT", "DAPPS_SSID", + "DAPPS_ENV_MANAGED", "PDN_NODE_CALLSIGN", ]; @@ -107,23 +110,86 @@ public void EnsureSchemaAndSeed_AllEnvVars_SeedsEachOption() } [Fact] - public void EnsureSchemaAndSeed_ExistingRow_EnvSet_AppliedAtEveryStart() + public void EnsureSchemaAndSeed_ExistingRow_NotOverwrittenByEnv() { - // Deployment-managed config: a SET env var wins over the stored - // row at every start, not just the first - the pdn supervised- - // app case, where the host's app config is authoritative. + // THE standalone contract: without the DAPPS_ENV_MANAGED=true + // opt-in, a set env var seeds first-start values only. The + // shipped standalone flow (scripts/dapps.service + + // EnvironmentFile=/etc/dapps.env) keeps DAPPS_* set permanently, + // so re-applying here would revert every dashboard edit on + // every restart for every documented standalone install. + // Pre-seed a manually-configured callsign as if /Config POST had set it. using (var c = DbInfo.GetConnection()) { c.CreateTable(); c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); } + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "DIFFERENT-CALL"); + + DbStartup.EnsureSchemaAndSeed(); + + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("M0LTE-3", + "existing rows MUST NOT be overwritten by env vars on subsequent starts"); + } + + [Fact] + public void EnsureSchemaAndSeed_EnvManagedFalse_SameAsUnset() + { + // An explicit =false is the standalone default, not a third mode. + using (var c = DbInfo.GetConnection()) + { + c.CreateTable(); + c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); + } + Environment.SetEnvironmentVariable("DAPPS_ENV_MANAGED", "false"); + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "DIFFERENT-CALL"); + + DbStartup.EnsureSchemaAndSeed(); + + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("M0LTE-3"); + } + + [Fact] + public void EnsureSchemaAndSeed_EnvManagedMode_ExistingRow_AppliedAtEveryStart() + { + // Deployment-managed config (opt-in via DAPPS_ENV_MANAGED=true - + // pdn-app.yaml sets it for supervised installs): a SET env var + // wins over the stored row at every start, not just the first - + // the host's app config is authoritative. + using (var c = DbInfo.GetConnection()) + { + c.CreateTable(); + c.Insert(new DbSystemOption { Option = "Callsign", Value = "M0LTE-3" }); + } + Environment.SetEnvironmentVariable("DAPPS_ENV_MANAGED", "true"); Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST-7"); DbStartup.EnsureSchemaAndSeed(); using var conn = DbInfo.GetConnection(); conn.Find("Callsign")!.Value.Should().Be("G0TST-7", - "a set env var is deployment-managed and re-applied over the stored row at every start"); + "under DAPPS_ENV_MANAGED=true a set env var is deployment-managed and re-applied over the stored row at every start"); + } + + [Fact] + public void IsEnvManaged_RequiresTheOptInMode() + { + // The dashboard's "managed by environment" badges key off + // IsEnvManaged / EnvManagedKeys; without the opt-in they must + // stay dark even when DAPPS_* vars are set (standalone installs + // routinely keep /etc/dapps.env applied). + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST-7"); + + DbStartup.IsEnvManaged("Callsign").Should().BeFalse("no DAPPS_ENV_MANAGED opt-in"); + DbStartup.EnvManagedKeys().Should().BeEmpty(); + + Environment.SetEnvironmentVariable("DAPPS_ENV_MANAGED", "true"); + + DbStartup.IsEnvManaged("Callsign").Should().BeTrue(); + DbStartup.EnvManagedKeys().Should().Contain("Callsign"); + DbStartup.IsEnvManaged("NodeHost").Should().BeFalse("DAPPS_NODE_HOST is not set"); } [Fact] @@ -267,6 +333,115 @@ public void EnsureSchemaAndSeed_NoPdnEnv_PlaceholderUnchanged() using var c = DbInfo.GetConnection(); c.Find("Callsign")!.Value.Should().Be("N0CALL"); + c.Find(DbStartup.DerivedCallsignPendingKey).Should().BeNull( + "nothing was derived, so nothing is pending confirmation"); + } + + [Fact] + public void Derivation_MarksCallsignAsPendingConfirmation() + { + // The derived identity is provisional until the RHPv2 listener + // confirms the SSID is free on the node (errCode 9 probe-walk). + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + DbStartup.EnsureSchemaAndSeed(); + + using var c = DbInfo.GetConnection(); + c.Find(DbStartup.DerivedCallsignPendingKey)!.Value.Should().Be("M9YYY-7"); + DbStartup.ReadPendingDerivedCallsign().Should().Be("M9YYY-7"); + } + + [Fact] + public void PendingMarker_SurvivesRestart_WhileUnconfirmed() + { + // Daemon restarted before the listener ever confirmed (node was + // down, say): the stored callsign is real now, so derivation + // skips, but the pending marker must survive so the probe-walk + // can still run on this start. + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + DbStartup.EnsureSchemaAndSeed(); + + DbStartup.EnsureSchemaAndSeed(); // restart, still unconfirmed + + DbStartup.ReadPendingDerivedCallsign().Should().Be("M9YYY-7"); + } + + [Fact] + public void PendingMarker_ClearedByDashboardConfiguredCallsign() + { + // Derive, then the operator pins an identity via the dashboard + // before the listener confirmed: the explicit identity must + // never probe-walk, so the next start drops the stale marker. + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + DbStartup.EnsureSchemaAndSeed(); + using (var c = DbInfo.GetConnection()) + { + c.Execute("update systemoptions set value=? where option=?", "G0TST-1", "Callsign"); + } + + DbStartup.EnsureSchemaAndSeed(); + + DbStartup.ReadPendingDerivedCallsign().Should().BeNull( + "a dashboard-configured callsign is explicit - it never walks"); + using var conn = DbInfo.GetConnection(); + conn.Find("Callsign")!.Value.Should().Be("G0TST-1"); + } + + [Fact] + public void PendingMarker_ClearedByExplicitDappsCallsignEnv() + { + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + DbStartup.EnsureSchemaAndSeed(); + + Environment.SetEnvironmentVariable("DAPPS_CALLSIGN", "G0TST-1"); + DbStartup.EnsureSchemaAndSeed(); + + DbStartup.ReadPendingDerivedCallsign().Should().BeNull( + "an explicit DAPPS_CALLSIGN pins the identity - it never walks"); + } + + [Fact] + public void ConfirmDerivedCallsign_PersistsWinnerAndClearsMarker() + { + // The probe-walk found -7 taken and -8 free: the winner becomes + // the stored identity and nothing is pending any more, so every + // later start binds M9YYY-8 directly (never walks again). + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + DbStartup.EnsureSchemaAndSeed(); + + DbStartup.ConfirmDerivedCallsign("M9YYY-8"); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("M9YYY-8"); + DbStartup.ReadPendingDerivedCallsign().Should().BeNull(); + + // And a restart leaves the confirmed identity alone. + DbStartup.EnsureSchemaAndSeed(); + using var c2 = DbInfo.GetConnection(); + c2.Find("Callsign")!.Value.Should().Be("M9YYY-8"); + DbStartup.ReadPendingDerivedCallsign().Should().BeNull(); + } + + [Fact] + public void AbandonDerivedCallsign_RevertsToSetupRequired() + { + // Every candidate SSID taken on the node: back to the + // placeholder (setup-required mode) so the bearer idles instead + // of hammering the node; a restart re-derives and tries again. + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + DbStartup.EnsureSchemaAndSeed(); + + DbStartup.AbandonDerivedCallsign(); + + using var c = DbInfo.GetConnection(); + c.Find("Callsign")!.Value.Should().Be("N0CALL"); + DbStartup.ReadPendingDerivedCallsign().Should().BeNull(); + + // Restart: derivation fires again (placeholder + PDN env). + DbStartup.EnsureSchemaAndSeed(); + using var c2 = DbInfo.GetConnection(); + c2.Find("Callsign")!.Value.Should().Be("M9YYY-7"); + DbStartup.ReadPendingDerivedCallsign().Should().Be("M9YYY-7"); } [Fact] diff --git a/src/dapps/dapps.core.tests/Rhpv2InboundServiceTests.cs b/src/dapps/dapps.core.tests/Rhpv2InboundServiceTests.cs index b381fd3..ed9179f 100644 --- a/src/dapps/dapps.core.tests/Rhpv2InboundServiceTests.cs +++ b/src/dapps/dapps.core.tests/Rhpv2InboundServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using AwesomeAssertions; using dapps.client.Backhaul; using dapps.core.Models; @@ -16,9 +17,30 @@ namespace dapps.core.tests; /// callsign passed through verbatim. End-to-end delivery (ACCEPT → /// per-child stream → DAPPSv1 parsing) is covered by the /// scripts/sim-mixed-bearer.sh end-to-end run, not here. +/// +/// In the collection because the service now consults the +/// DerivedCallsignPending marker through the static +/// DbInfo.OverridePath seam; isolate from classes that mutate it. /// -public sealed class Rhpv2InboundServiceTests +[Collection(SqliteOverridePathCollection.Name)] +public sealed class Rhpv2InboundServiceTests : IAsyncLifetime { + private string dbPath = null!; + + public ValueTask InitializeAsync() + { + dbPath = Path.Combine(Path.GetTempPath(), $"dapps-rhp-inbound-test-{Guid.NewGuid():N}.db"); + DbInfo.OverridePath = dbPath; + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + DbInfo.OverridePath = null; + try { File.Delete(dbPath); } catch { /* ignore */ } + return ValueTask.CompletedTask; + } + [Fact] public async Task Startup_BindsLocalCallsign_AndListensPassive() { @@ -143,3 +165,327 @@ private static List WaitForFrames(MockRhpServer server, int count, T $"Did not observe {count} frames within {timeout}; saw [{string.Join(", ", server.ReceivedFrames.Select(f => f.GetType().Name))}]"); } } + +/// +/// The derived-callsign SSID probe-walk (Rhpv2InboundService. +/// BindListenerAsync). pdn answers a listen on an already-claimed +/// callsign with errCode 9 "Duplicate socket" deterministically +/// (packet.net docs/rhp2-server.md deviation D5); while the +/// DerivedCallsignPending marker matches the callsign in use, a 9 +/// walks the SSIDs for a free one and persists the winner. The mock +/// server plays the pdn role via a Handler that refuses listens on a +/// "taken" set, keyed off the handle→callsign map built from BINDs. +/// +/// Uses the real (not a fixed +/// options monitor) so the persist→Reload→OnChange path the walk +/// drives in production is the one under test. In the collection +/// because both the store and the marker go through the static +/// DbInfo.OverridePath seam. +/// +[Collection(SqliteOverridePathCollection.Name)] +public sealed class Rhpv2SsidProbeWalkTests : IAsyncLifetime +{ + private string dbPath = null!; + private readonly Dictionary savedEnv = []; + + private static readonly string[] EnvKeys = + [ + "DAPPS_CALLSIGN", + "DAPPS_ENV_MANAGED", + "PDN_NODE_CALLSIGN", + ]; + + public ValueTask InitializeAsync() + { + dbPath = Path.Combine(Path.GetTempPath(), $"dapps-ssid-walk-test-{Guid.NewGuid():N}.db"); + DbInfo.OverridePath = dbPath; + foreach (var k in EnvKeys) + { + savedEnv[k] = Environment.GetEnvironmentVariable(k); + Environment.SetEnvironmentVariable(k, null); + } + return ValueTask.CompletedTask; + } + + public ValueTask DisposeAsync() + { + foreach (var (k, v) in savedEnv) + { + Environment.SetEnvironmentVariable(k, v); + } + DbInfo.OverridePath = null; + try { File.Delete(dbPath); } catch { /* ignore */ } + return ValueTask.CompletedTask; + } + + [Fact] + public async Task WalkOn9_PicksNextFreeSsid_PersistsWinner_AndIsStableAcrossRestart() + { + // The node already has an M9YYY-7 (errCode 9 on listen); the + // derivation is still pending confirmation, so the service + // walks to -8, wins, and persists the winner as the identity. + await using var server = new MockRhpServer { Handler = RefuseListenOn("M9YYY-7") }; + server.Start(); + SeedDb(server.Endpoint.Port, callsign: "M9YYY-7", pendingMarker: "M9YYY-7"); + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + var store = new SystemOptionsStore(NullLogger.Instance); + var service = NewService(store); + var ct = TestContext.Current.CancellationToken; + await service.StartAsync(ct); + try + { + WaitUntil(() => ReadOption("Callsign") == "M9YYY-8", TimeSpan.FromSeconds(5), + "the first free SSID must be persisted as the stored callsign"); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + ReadOption(DbStartup.DerivedCallsignPendingKey).Should().BeNull( + "a successful listen confirms the derived identity"); + store.CurrentValue.Callsign.Should().Be("M9YYY-8", + "the walk reloads the store so every consumer sees the confirmed identity"); + var binds = server.ReceivedFrames.OfType().Select(b => b.Local).ToList(); + binds.Take(2).Should().Equal("M9YYY-7", "M9YYY-8"); + + // Restart: the persisted winner is the identity - binds -8 + // directly and never walks again, even though -7 is still taken. + await using var server2 = new MockRhpServer { Handler = RefuseListenOn("M9YYY-7") }; + server2.Start(); + using (var c = DbInfo.GetConnection()) + { + c.Execute("update systemoptions set value=? where option=?", server2.Endpoint.Port.ToString(), "RhpPort"); + } + var store2 = new SystemOptionsStore(NullLogger.Instance); + var service2 = NewService(store2); + await service2.StartAsync(ct); + try + { + var frames = WaitForFrames(server2, count: 3, TimeSpan.FromSeconds(5)); + frames[0].Should().BeOfType(); + frames[1].Should().BeOfType().Which.Local.Should().Be("M9YYY-8"); + frames[2].Should().BeOfType(); + } + finally + { + await service2.StopAsync(CancellationToken.None); + } + ReadOption("Callsign").Should().Be("M9YYY-8"); + } + + [Fact] + public async Task ExplicitCallsign_On9_LogsAndRetries_NeverWalks() + { + // No pending-derivation marker = the callsign is explicit + // (dashboard / DAPPS_CALLSIGN / already confirmed). A duplicate + // refusal must keep the existing retry/reconnect behaviour, not + // probe other SSIDs. + await using var server = new MockRhpServer { Handler = RefuseListenOn("G0DPB-1") }; + server.Start(); + SeedDb(server.Endpoint.Port, callsign: "G0DPB-1", pendingMarker: null); + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); // present but irrelevant: not a derivation + + var store = new SystemOptionsStore(NullLogger.Instance); + var service = NewService(store); + var ct = TestContext.Current.CancellationToken; + await service.StartAsync(ct); + try + { + // socket → bind → listen(9) → close, then park for the + // reconnect backoff. Give it a beat to prove no walk starts. + WaitForFrames(server, count: 4, TimeSpan.FromSeconds(5)); + await Task.Delay(300, ct); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + server.ReceivedFrames.OfType().Should() + .OnlyContain(b => b.Local == "G0DPB-1", "an explicitly configured callsign never walks"); + ReadOption("Callsign").Should().Be("G0DPB-1", "the stored identity is untouched"); + } + + [Fact] + public async Task NonDuplicateListenError_DoesNotWalk_EvenWhilePending() + { + // Only errCode 9 means "callsign taken". Any other refusal + // (here 13 "No buffers") keeps the existing reconnect handling: + // no probing, identity and pending marker untouched. + await using var server = new MockRhpServer + { + Handler = msg => msg is ListenMessage l + ? new ListenReplyMessage { Handle = l.Handle, ErrCode = RhpErrorCode.NoBuffers, ErrText = "No buffers" } + : null, + }; + server.Start(); + SeedDb(server.Endpoint.Port, callsign: "M9YYY-7", pendingMarker: "M9YYY-7"); + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY"); + + var store = new SystemOptionsStore(NullLogger.Instance); + var service = NewService(store); + var ct = TestContext.Current.CancellationToken; + await service.StartAsync(ct); + try + { + WaitForFrames(server, count: 3, TimeSpan.FromSeconds(5)); // socket → bind → listen + await Task.Delay(300, ct); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + server.ReceivedFrames.OfType().Should() + .OnlyContain(b => b.Local == "M9YYY-7", "non-9 errors must not trigger the SSID walk"); + ReadOption("Callsign").Should().Be("M9YYY-7"); + ReadOption(DbStartup.DerivedCallsignPendingKey).Should().Be("M9YYY-7", + "the derivation stays pending so a later start can still confirm or walk"); + } + + [Fact] + public async Task AllSsidsTaken_WalksInOrder_ThenRevertsToSetupRequired() + { + // Every listen answers 9. The walk must try start+1 … 15 then + // 1 … start−1 - skipping 0 and the node's own SSID (-2 here) - + // and then park in setup-required mode (placeholder callsign) + // instead of spinning against the node. + await using var server = new MockRhpServer { Handler = RefuseListenOn(call => true) }; + server.Start(); + SeedDb(server.Endpoint.Port, callsign: "M9YYY-7", pendingMarker: "M9YYY-7"); + Environment.SetEnvironmentVariable("PDN_NODE_CALLSIGN", "M9YYY-2"); + + var store = new SystemOptionsStore(NullLogger.Instance); + var service = NewService(store); + var ct = TestContext.Current.CancellationToken; + await service.StartAsync(ct); + try + { + WaitUntil(() => ReadOption("Callsign") == DbStartup.PlaceholderCallsign, TimeSpan.FromSeconds(10), + "exhausting every candidate must revert to setup-required mode"); + } + finally + { + await service.StopAsync(CancellationToken.None); + } + + ReadOption(DbStartup.DerivedCallsignPendingKey).Should().BeNull(); + store.CurrentValue.Callsign.Should().Be(DbStartup.PlaceholderCallsign, + "the reload parks every consumer in setup-required mode"); + var binds = server.ReceivedFrames.OfType().Select(b => b.Local).ToList(); + binds.Should().Equal( + "M9YYY-7", "M9YYY-8", "M9YYY-9", "M9YYY-10", "M9YYY-11", "M9YYY-12", "M9YYY-13", + "M9YYY-14", "M9YYY-15", "M9YYY-1", "M9YYY-3", "M9YYY-4", "M9YYY-5", "M9YYY-6"); + } + + [Fact] + public void SsidProbeCandidates_WalksForwardThenWraps_SkippingZeroAndNodeSsid() + { + Rhpv2InboundService.SsidProbeCandidates("M9YYY-7", "M9YYY").Should().Equal( + "M9YYY-7", "M9YYY-8", "M9YYY-9", "M9YYY-10", "M9YYY-11", "M9YYY-12", "M9YYY-13", + "M9YYY-14", "M9YYY-15", "M9YYY-1", "M9YYY-2", "M9YYY-3", "M9YYY-4", "M9YYY-5", "M9YYY-6"); + + Rhpv2InboundService.SsidProbeCandidates("M9YYY-7", "M9YYY-2").Should().NotContain("M9YYY-2", + "the SSID the node itself uses is skipped"); + + Rhpv2InboundService.SsidProbeCandidates("M9YYY-13", null).Should().Equal( + "M9YYY-13", "M9YYY-14", "M9YYY-15", "M9YYY-1", "M9YYY-2", "M9YYY-3", "M9YYY-4", + "M9YYY-5", "M9YYY-6", "M9YYY-7", "M9YYY-8", "M9YYY-9", "M9YYY-10", "M9YYY-11", "M9YYY-12"); + + Rhpv2InboundService.SsidProbeCandidates("M9YYY-7", "M9YYY") + .Should().NotContain(c => c.EndsWith("-0"), "SSID 0 is the node's bare callsign"); + } + + /// Mock-server handler playing the pdn role for one taken + /// callsign: listens on it answer errCode 9 "Duplicate socket" + /// (docs/rhp2-server.md D5); everything else gets the defaults. + private static Func RefuseListenOn(string takenCallsign) => + RefuseListenOn(call => string.Equals(call, takenCallsign, StringComparison.OrdinalIgnoreCase)); + + private static Func RefuseListenOn(Func isTaken) + { + var handleToCallsign = new ConcurrentDictionary(); + return msg => + { + switch (msg) + { + case BindMessage { Local: { } local } b: + handleToCallsign[b.Handle] = local; + return null; // default Ok reply + case ListenMessage l when handleToCallsign.TryGetValue(l.Handle, out var call) && isTaken(call): + return new ListenReplyMessage + { + Handle = l.Handle, + ErrCode = RhpErrorCode.DuplicateSocket, + ErrText = "Duplicate socket", + }; + default: + return null; + } + }; + } + + private static Rhpv2InboundService NewService(SystemOptionsStore store) + { + var database = new Database(NullLogger.Instance, store); + return new Rhpv2InboundService( + store, NullLoggerFactory.Instance, NullLogger.Instance, + database, new NoopInbox(), new OperationalMetrics()); + } + + private static void SeedDb(int rhpPort, string callsign, string? pendingMarker) + { + using var c = DbInfo.GetConnection(); + c.CreateTable(); + c.InsertOrReplace(new DbSystemOption { Option = "Callsign", Value = callsign }); + c.InsertOrReplace(new DbSystemOption { Option = "NodeBearer", Value = "rhpv2" }); + c.InsertOrReplace(new DbSystemOption { Option = "NodeHost", Value = "127.0.0.1" }); + c.InsertOrReplace(new DbSystemOption { Option = "RhpPort", Value = rhpPort.ToString() }); + if (pendingMarker is not null) + { + c.InsertOrReplace(new DbSystemOption + { + Option = DbStartup.DerivedCallsignPendingKey, + Value = pendingMarker, + }); + } + } + + private static string? ReadOption(string key) + { + using var c = DbInfo.GetConnection(); + return c.Query("select * from systemoptions where option = ? collate nocase;", key) + .FirstOrDefault()?.Value; + } + + private static void WaitUntil(Func condition, TimeSpan timeout, string because) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + Thread.Sleep(20); + } + throw new TimeoutException($"Condition not met within {timeout}: {because}"); + } + + private static List WaitForFrames(MockRhpServer server, int count, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (server.ReceivedFrames.Count >= count) return server.ReceivedFrames.Take(count).ToList(); + Thread.Sleep(10); + } + throw new TimeoutException( + $"Did not observe {count} frames within {timeout}; saw [{string.Join(", ", server.ReceivedFrames.Select(f => f.GetType().Name))}]"); + } + + private sealed class NoopInbox : IBackhaulInbox + { + public Task DeliverAsync(BackhaulMessage message, string sourceCallsign, CancellationToken ct) + => Task.CompletedTask; + } +} diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml b/src/dapps/dapps.core/Pages/Settings.cshtml index 11527e0..43ebd35 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml +++ b/src/dapps/dapps.core/Pages/Settings.cshtml @@ -240,11 +240,13 @@ async function dappsRotatePassword(ev) { } // --- Deployment-managed (env-set) fields --- -// Any option whose DAPPS_* env var is set on the daemon's environment -// is re-applied from the environment at EVERY start (the pdn -// supervised-app case), so a dashboard edit would be silently -// overridden on the next restart. Badge those fields and make them -// read-only so the operator isn't invited to fight the deployment. +// Under DAPPS_ENV_MANAGED=true (the pdn supervised-app mode), any +// option whose DAPPS_* env var is set on the daemon's environment is +// re-applied from the environment at EVERY start, so a dashboard edit +// would be silently overridden on the next restart. Badge those +// fields and make them read-only so the operator isn't invited to +// fight the deployment. In the standalone default mode the list is +// always empty - env vars only seed first-start values there. // The form still serialises their values on save (the JS reads .value // // .checked directly, which works on readonly/disabled controls), // so saving unrelated fields keeps env-managed ones intact. diff --git a/src/dapps/dapps.core/Pages/Settings.cshtml.cs b/src/dapps/dapps.core/Pages/Settings.cshtml.cs index e66404b..c19fff3 100644 --- a/src/dapps/dapps.core/Pages/Settings.cshtml.cs +++ b/src/dapps/dapps.core/Pages/Settings.cshtml.cs @@ -12,11 +12,14 @@ namespace dapps.core.Pages; /// "Admin password" panels. Posts JSON to /Config; the daemon /// hot-reloads everything except the MQTT and UDP listener ports. /// -/// Fields whose DAPPS_* env var is set on the daemon's -/// environment are deployment-managed: -/// re-applies the env value at every start, so dashboard edits would -/// be silently overridden. feeds the page -/// script that badges those fields and makes them read-only. +/// Under DAPPS_ENV_MANAGED=true (the pdn supervised-app +/// mode), fields whose DAPPS_* env var is set are deployment- +/// managed: re-applies the env value at every +/// start, so dashboard edits would be silently overridden. +/// feeds the page script that badges those +/// fields and makes them read-only. In the standalone default mode it +/// is always empty - env vars only seed first-start values there and +/// the dashboard owns everything. /// public sealed class SettingsModel(IOptionsMonitor options) : PageModel { diff --git a/src/dapps/dapps.core/Pages/Setup.cshtml.cs b/src/dapps/dapps.core/Pages/Setup.cshtml.cs index efb3647..bf7ccc8 100644 --- a/src/dapps/dapps.core/Pages/Setup.cshtml.cs +++ b/src/dapps/dapps.core/Pages/Setup.cshtml.cs @@ -64,9 +64,11 @@ public enum SetupStep { Password, Bearer } public string? Error { get; private set; } - /// True when the option's DAPPS_* env var is set: - /// the value is deployment-managed (re-applied at every start), so - /// the form badges it. See . + /// True when the daemon runs under + /// DAPPS_ENV_MANAGED=true and the option's DAPPS_* + /// env var is set: the value is deployment-managed (re-applied at + /// every start), so the form badges it. Always false in the + /// standalone default mode. See . public static bool IsEnvManaged(string key) => DbStartup.IsEnvManaged(key); public async Task OnGetAsync() diff --git a/src/dapps/dapps.core/Services/DbStartup.cs b/src/dapps/dapps.core/Services/DbStartup.cs index 0a19e9a..56e6bb7 100644 --- a/src/dapps/dapps.core/Services/DbStartup.cs +++ b/src/dapps/dapps.core/Services/DbStartup.cs @@ -32,6 +32,22 @@ public static class DbStartup /// placeholder never go on the air. public const string PlaceholderCallsign = "N0CALL"; + /// Mode switch (read from the environment at every start, + /// never stored): when set to true the deployment - not the + /// dashboard - owns every option whose DAPPS_* env var is + /// set. See . + public const string EnvManagedModeVar = "DAPPS_ENV_MANAGED"; + + /// Option key holding a callsign that was derived from the + /// host node () but has not yet been + /// confirmed free by a successful RHPv2 listen. While this row + /// matches the stored callsign, Rhpv2InboundService may + /// probe-walk to a free SSID if the node refuses the derived one + /// (errCode 9 "Duplicate socket"); once a listen succeeds the row + /// is cleared and the confirmed identity is stable on every later + /// start. Not a seeded option - no env var, no dashboard field. + public const string DerivedCallsignPendingKey = "DerivedCallsignPending"; + /// Option key holding the SSID used when deriving this /// instance's callsign from the host node's callsign (see /// ). Seeded to ; @@ -93,12 +109,31 @@ private static readonly (string Key, string Default)[] SeededOptions = (SsidOptionKey, DefaultSsid), ]; + /// True when is set to + /// true (or 1): the deployment-managed mode, opted + /// into by pdn-supervised installs via pdn-app.yaml. In this mode + /// every set DAPPS_* env var is re-applied over the stored + /// row at every start and the dashboard badges those fields + /// read-only. Unset / any other value = the standalone default: + /// env vars seed missing rows on first start only and the + /// dashboard owns everything thereafter (the shipped + /// scripts/dapps.service flow keeps /etc/dapps.env applied + /// forever, so re-applying would revert dashboard edits there). + public static bool IsEnvManagedMode + { + get + { + var v = Environment.GetEnvironmentVariable(EnvManagedModeVar); + return string.Equals(v, "true", StringComparison.OrdinalIgnoreCase) || v == "1"; + } + } + /// /// Create every table the daemon needs, seed the first-run /// systemoptions defaults (env-var overrides → hardcoded fallback), - /// and re-apply any env-set values to existing rows (deployment- - /// managed config). Safe to call multiple times - every step is - /// idempotent. + /// and - only when - re-apply any + /// env-set values to existing rows (deployment-managed config). + /// Safe to call multiple times - every step is idempotent. /// /// Called once from Program.cs *before* builder.Build() so /// the eager DI materialisation of hosted services (which transit @@ -134,14 +169,21 @@ public static void EnsureSchemaAndSeed(ILogger? logger = null) var optionsTable = db.Table().Table.TableName; var options = db.Query($"select * from {optionsTable};"); - // Seeded defaults. When an env var DAPPS_ is set, it wins - - // on EVERY start, not just the first: a set env var is - // deployment-managed config (the pdn supervised-app case) and is - // re-applied over whatever the row holds. An UNSET env var never - // touches an existing row, so the standalone flow (no DAPPS_* - // env; configure via /Setup // /Config) is unchanged, and a - // standalone operator who seeds once via env then unsets it - // keeps dashboard control exactly as before. + // Seeded defaults. A set env var DAPPS_ always wins for a + // row that doesn't exist yet (first start). What happens to an + // EXISTING row depends on the DAPPS_ENV_MANAGED mode switch: + // + // unset/false (the standalone default): env vars seed only; + // stored (dashboard-edited) values are never touched on + // later starts. The shipped standalone flow + // (scripts/dapps.service + EnvironmentFile=/etc/dapps.env) + // keeps DAPPS_* set permanently, so re-applying would + // silently revert every dashboard edit - hence opt-in. + // + // true (the pdn supervised-app case; set in pdn-app.yaml): + // every set DAPPS_ is deployment-managed config and is + // re-applied over whatever the row holds at EVERY start, + // and the dashboard badges those fields read-only. foreach (var (key, defaultValue) in SeededOptions) { SeedOrApplyEnv(db, options, key, defaultValue, logger); @@ -172,7 +214,7 @@ private static void SeedOrApplyEnv(SQLiteConnection db, List opt return; } - if (!string.IsNullOrEmpty(envValue) && !string.Equals(existing.Value, envValue, StringComparison.Ordinal)) + if (IsEnvManagedMode && !string.IsNullOrEmpty(envValue) && !string.Equals(existing.Value, envValue, StringComparison.Ordinal)) { existing.Value = envValue; db.Update(existing); @@ -196,23 +238,37 @@ private static void SeedOrApplyEnv(SQLiteConnection db, List opt /// private static void DeriveCallsignFromHostNodeIfUnset(SQLiteConnection db, ILogger? logger) { + var options = db.Query("select * from systemoptions;"); + var callsignRow = options.FirstOrDefault( + o => string.Equals(o.Option, "Callsign", StringComparison.OrdinalIgnoreCase)); + var markerRow = options.FirstOrDefault( + o => string.Equals(o.Option, DerivedCallsignPendingKey, StringComparison.OrdinalIgnoreCase)); + var stored = callsignRow?.Value ?? ""; + // An explicit DAPPS_CALLSIGN wins over derivation. (It was // already applied above; this guard also keeps a pathological // DAPPS_CALLSIGN=N0CALL from being re-derived underneath.) if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvVarFor("Callsign")))) { + ClearPendingMarker(db, markerRow); // identity explicitly pinned - nothing pending return; } - var options = db.Query("select * from systemoptions;"); - var callsignRow = options.FirstOrDefault( - o => string.Equals(o.Option, "Callsign", StringComparison.OrdinalIgnoreCase)); - var stored = callsignRow?.Value ?? ""; var unset = string.IsNullOrWhiteSpace(stored) || string.Equals(stored, PlaceholderCallsign, StringComparison.OrdinalIgnoreCase); if (!unset) { - return; // a real stored callsign always wins + // A real stored callsign always wins. Keep the pending + // marker only while it still matches the stored value (= + // a derivation from a previous start that the RHPv2 + // listener hasn't confirmed yet); a dashboard-configured + // callsign clears it so an explicit identity never walks. + if (markerRow is not null + && !string.Equals(markerRow.Value, stored, StringComparison.OrdinalIgnoreCase)) + { + ClearPendingMarker(db, markerRow); + } + return; } var ssidRow = options.FirstOrDefault( @@ -221,7 +277,11 @@ private static void DeriveCallsignFromHostNodeIfUnset(SQLiteConnection db, ILogg var derived = DeriveCallsignFromHostNode(ssid); if (derived is null) { - return; // not pdn-hosted - standalone setup-required flow as before + // Not pdn-hosted - standalone setup-required flow as before. + // A leftover marker (e.g. the host stopped injecting + // PDN_NODE_CALLSIGN) can't match the placeholder; drop it. + ClearPendingMarker(db, markerRow); + return; } if (callsignRow is null) @@ -234,12 +294,84 @@ private static void DeriveCallsignFromHostNodeIfUnset(SQLiteConnection db, ILogg db.Update(callsignRow); } + // Mark the derivation as not-yet-confirmed: if the node refuses + // the listen with errCode 9 (callsign already claimed there), + // Rhpv2InboundService probe-walks the SSIDs for a free one and + // persists the winner; the first successful listen clears this. + if (markerRow is null) + { + db.Insert(new DbSystemOption { Option = DerivedCallsignPendingKey, Value = derived }); + } + else if (!string.Equals(markerRow.Value, derived, StringComparison.Ordinal)) + { + markerRow.Value = derived; + db.Update(markerRow); + } + logger?.LogInformation( "Callsign {Derived} derived from the host node ({EnvVar}={NodeCall}, SSID {Ssid}). " + + "If the node already has a {Derived} the RHPv2 listener will probe for a free SSID and keep it. " + "Set DAPPS_CALLSIGN or configure a callsign via the dashboard to pin a different identity.", derived, NodeCallsignEnvVar, Environment.GetEnvironmentVariable(NodeCallsignEnvVar), ssid); } + private static void ClearPendingMarker(SQLiteConnection db, DbSystemOption? markerRow) + { + if (markerRow is not null) + { + db.Execute("delete from systemoptions where option = ?;", markerRow.Option); + } + } + + /// The derived callsign awaiting its first successful + /// listen, or null when none is pending. See + /// . + public static string? ReadPendingDerivedCallsign() + { + using var db = DbInfo.GetConnection(); + db.CreateTable(); + var row = db.Query( + "select * from systemoptions where option = ? collate nocase;", DerivedCallsignPendingKey) + .FirstOrDefault(); + return string.IsNullOrWhiteSpace(row?.Value) ? null : row!.Value; + } + + /// A listen succeeded on : + /// persist it as the stored callsign (it may differ from the + /// original derivation after a probe-walk) and clear the pending + /// marker so the identity is stable on every later start - a + /// persisted, confirmed callsign never walks again. + public static void ConfirmDerivedCallsign(string confirmed) + { + using var db = DbInfo.GetConnection(); + var callsignRow = db.Query( + "select * from systemoptions where option = ? collate nocase;", "Callsign") + .FirstOrDefault(); + if (callsignRow is null) + { + db.Insert(new DbSystemOption { Option = "Callsign", Value = confirmed }); + } + else if (!string.Equals(callsignRow.Value, confirmed, StringComparison.Ordinal)) + { + callsignRow.Value = confirmed; + db.Update(callsignRow); + } + db.Execute("delete from systemoptions where option = ? collate nocase;", DerivedCallsignPendingKey); + } + + /// Every candidate SSID was taken on the node: drop back + /// to the placeholder (setup-required mode - the bearer gates idle + /// instead of hammering the node) and clear the pending marker. + /// The operator pins an identity via the dashboard / DAPPS_CALLSIGN, + /// or the next daemon restart re-derives and probes again. + public static void AbandonDerivedCallsign() + { + using var db = DbInfo.GetConnection(); + db.Execute("update systemoptions set value = ? where option = ? collate nocase;", + PlaceholderCallsign, "Callsign"); + db.Execute("delete from systemoptions where option = ? collate nocase;", DerivedCallsignPendingKey); + } + /// Compose the conventional pdn-hosted DAPPS callsign: /// base of the host node's callsign (any SSID stripped, upper-cased) /// + . Null when @@ -279,14 +411,19 @@ private static void DeriveCallsignFromHostNodeIfUnset(SQLiteConnection db, ILogg /// e.g. NodeHostDAPPS_NODE_HOST. public static string EnvVarFor(string key) => "DAPPS_" + ToScreamingSnake(key); - /// True when the given option key's DAPPS_* env var - /// is currently set (non-empty) - i.e. the value is deployment- - /// managed and re-applied at every start. + /// True when the daemon runs in deployment-managed mode + /// () AND the given option key's + /// DAPPS_* env var is currently set (non-empty) - i.e. the + /// value is re-applied from the environment at every start. Always + /// false in the standalone default mode, where a set env var only + /// seeds first-start values and the dashboard stays in charge. public static bool IsEnvManaged(string key) => - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvVarFor(key))); + IsEnvManagedMode && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(EnvVarFor(key))); - /// Seeded option keys whose env var is currently set, for - /// the dashboard's "managed by environment" field markers. + /// Seeded option keys that are deployment-managed right + /// now (), for the dashboard's "managed + /// by environment" field markers. Empty outside + /// . public static IReadOnlyList EnvManagedKeys() => SeededOptions.Select(s => s.Key).Where(IsEnvManaged).ToArray(); diff --git a/src/dapps/dapps.core/Services/Rhpv2InboundService.cs b/src/dapps/dapps.core/Services/Rhpv2InboundService.cs index 1a91470..e49da9c 100644 --- a/src/dapps/dapps.core/Services/Rhpv2InboundService.cs +++ b/src/dapps/dapps.core/Services/Rhpv2InboundService.cs @@ -117,10 +117,15 @@ private async Task RunOnce(CancellationToken stoppingToken) // socket + bind + listen for inbound AX.25 streams to our callsign. // Port omitted = listen across all configured XRouter ports. - var listenerHandle = await rhp.SocketAsync(ProtocolFamily.Ax25, SocketMode.Stream, stoppingToken); - await rhp.BindAsync(listenerHandle, local: opts.Callsign, port: null, stoppingToken); - await rhp.ListenAsync(listenerHandle, OpenFlags.Passive, stoppingToken); - logger.LogInformation("RHP inbound: listener bound to {call} on handle {h}", opts.Callsign, listenerHandle); + // A callsign freshly derived from PDN_NODE_CALLSIGN may probe- + // walk to a free SSID here; see BindListenerAsync. + var bound = await BindListenerAsync(rhp, opts, stoppingToken); + if (bound is null) + { + return; // logged inside; the ExecuteAsync loop retries after ReconnectBackoff + } + var (listenerHandle, boundCallsign) = bound.Value; + logger.LogInformation("RHP inbound: listener bound to {call} on handle {h}", boundCallsign, listenerHandle); var sessions = new ConcurrentDictionary(); var disconnect = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); @@ -223,4 +228,149 @@ private async Task RunOnce(CancellationToken stoppingToken) sessions.Clear(); } } + + /// + /// Bind + listen the daemon's callsign, probing for a free SSID + /// when the identity is a not-yet-confirmed derivation. + /// + /// pdn answers a listen on an already-claimed callsign - including + /// the node's own - with errCode 9 "Duplicate socket", + /// deterministically (packet.net docs/rhp2-server.md deviation D5). + /// That turns the callsign derived from PDN_NODE_CALLSIGN into a + /// probe: while + /// still matches the callsign in use (placeholder + PDN_NODE_CALLSIGN + /// at boot, no explicit DAPPS_CALLSIGN, no successful listen yet), a + /// 9 walks the candidate SSIDs - start+1 … 15, then 1 … start−1, + /// skipping 0 and the SSID the node itself uses - and the first + /// successful listen wins. The winner is persisted as the stored + /// callsign, so the identity is stable on every later start: a + /// persisted, confirmed callsign never walks again. + /// + /// An explicitly configured callsign (DAPPS_CALLSIGN, dashboard, or + /// an already-confirmed derivation) NEVER walks: a 9 logs the + /// refusal and keeps the existing retry/reconnect behaviour. + /// + /// Against a server that answers duplicate listens Ok (live XRouter + /// does - D5 is pdn's deviation from it), the very first listen + /// succeeds and the walk simply never triggers. That's fine: + /// derivation only ever runs under pdn supervision, because only a + /// pdn host injects PDN_NODE_CALLSIGN. + /// + /// The listener handle and the callsign it is bound to, or + /// null when no listener could be established (logged; the caller + /// returns into the reconnect loop). + private async Task<(int Handle, string Callsign)?> BindListenerAsync( + RhpClient rhp, SystemOptions opts, CancellationToken ct) + { + var pending = DbStartup.ReadPendingDerivedCallsign(); + var walkEligible = + pending is not null + && string.Equals(pending, opts.Callsign, StringComparison.OrdinalIgnoreCase) + && string.IsNullOrEmpty(Environment.GetEnvironmentVariable(DbStartup.EnvVarFor("Callsign"))); + + IReadOnlyList candidates = walkEligible + ? SsidProbeCandidates(opts.Callsign, Environment.GetEnvironmentVariable(DbStartup.NodeCallsignEnvVar)) + : [opts.Callsign]; + var taken = new List(); + + foreach (var candidate in candidates) + { + var handle = await rhp.SocketAsync(ProtocolFamily.Ax25, SocketMode.Stream, ct); + try + { + await rhp.BindAsync(handle, local: candidate, port: null, ct); + await rhp.ListenAsync(handle, OpenFlags.Passive, ct); + } + catch (RhpServerException ex) when (ex.ErrorCode == RhpErrorCode.DuplicateSocket) + { + try { await rhp.CloseAsync(handle, ct); } + catch (RhpProtocolException) { /* refused handles may already be gone server-side */ } + + if (!walkEligible) + { + logger.LogWarning( + "RHP inbound: callsign {call} is already claimed on the node (errCode 9 'Duplicate socket'). " + + "It is explicitly configured, so not probing for a free SSID; retrying in {s}s. " + + "Pick a different callsign via the dashboard or DAPPS_CALLSIGN if this persists.", + candidate, ReconnectBackoff.TotalSeconds); + return null; + } + + taken.Add(candidate); + continue; + } + + if (walkEligible) + { + // First successful listen confirms the derived identity - + // persist it so every later start binds it directly. + DbStartup.ConfirmDerivedCallsign(candidate); + if (taken.Count > 0) + { + logger.LogInformation( + "RHP inbound: derived callsign {winner} — {taken} was taken on the node", + candidate, string.Join(", ", taken.Select(t => $"-{t.Split('-')[^1]}"))); + // Reload so every consumer (outbound forwarder, + // beacons, UI) sees the confirmed identity. The + // OnChange this fires cancels the current cycle; the + // reconnect binds the winner via the normal + // non-walking path (the marker is cleared). + ReloadOptionsStore(); + } + } + + return (handle, candidate); + } + + // Every candidate SSID is taken on the node. Park in setup- + // required mode rather than hammering the node every reconnect; + // the operator pins an identity via the dashboard / + // DAPPS_CALLSIGN, or the next daemon restart re-derives and + // probes again. + logger.LogError( + "RHP inbound: no free SSID for the derived callsign {call} — every candidate ({candidates}) is taken " + + "on the node. Reverting to setup-required mode; configure a callsign via the dashboard or DAPPS_CALLSIGN.", + opts.Callsign, string.Join(", ", candidates)); + DbStartup.AbandonDerivedCallsign(); + ReloadOptionsStore(); + return null; + } + + /// + /// The SSID probe order for a derived callsign: the derivation + /// itself first, then the SSIDs after it in order (start+1 … 15, + /// then wrapping to 1 … start−1), skipping 0 (the node's bare + /// callsign) and the SSID the node itself uses (parsed off + /// PDN_NODE_CALLSIGN). + /// + internal static IReadOnlyList SsidProbeCandidates(string derivedCallsign, string? nodeCallsign) + { + var dash = derivedCallsign.LastIndexOf('-'); + var baseCall = dash > 0 ? derivedCallsign[..dash] : derivedCallsign; + var start = dash > 0 && int.TryParse(derivedCallsign[(dash + 1)..], out var s) ? s : 0; + + var nodeSsid = 0; + if (!string.IsNullOrWhiteSpace(nodeCallsign)) + { + var nodeDash = nodeCallsign.LastIndexOf('-'); + if (nodeDash > 0 && int.TryParse(nodeCallsign[(nodeDash + 1)..], out var ns)) nodeSsid = ns; + } + + var candidates = new List(16) { derivedCallsign }; + for (var offset = 1; offset <= 15; offset++) + { + var ssid = ((start - 1 + offset) % 15) + 1; // start+1 … 15, then 1 … start−1; never 0 + if (ssid == start || ssid == nodeSsid) continue; + candidates.Add($"{baseCall}-{ssid}"); + } + return candidates; + } + + private void ReloadOptionsStore() + { + // In production IOptionsMonitor is the + // SystemOptionsStore singleton; re-read it so CurrentValue + // reflects what the probe just persisted. + (options as SystemOptionsStore)?.Reload(); + } } From f0f0e57d2cfd2c442311fb51adce79d777c5d7d7 Mon Sep 17 00:00:00 2001 From: Tom M0LTE Date: Thu, 11 Jun 2026 11:25:33 +0000 Subject: [PATCH 3/3] Bump version to 0.34.0 for release DAPPS_ENV_MANAGED opt-in deployment-managed config + node-derived callsign with SSID probe-walk + pdn-app.yaml release asset. Co-Authored-By: Claude Opus 4.8 (1M context) --- pdn-app.yaml | 2 +- src/dapps/Directory.Build.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pdn-app.yaml b/pdn-app.yaml index 6e1bb0a..eeab9b9 100644 --- a/pdn-app.yaml +++ b/pdn-app.yaml @@ -12,7 +12,7 @@ manifest: 1 id: dapps # must equal the package directory name name: DAPPS -version: "0.33.12" # informational, shown in pdn's UI — should track the release tag +version: "0.34.0" # informational, shown in pdn's UI — should track the release tag description: Distributed Asynchronous Packet Pub/Sub — store-and-forward messaging over packet radio. icon: inbox capabilities: [network, web] # binds its own callsigns over RHPv2; serves its own web UI diff --git a/src/dapps/Directory.Build.props b/src/dapps/Directory.Build.props index 2990554..f2c20e6 100644 --- a/src/dapps/Directory.Build.props +++ b/src/dapps/Directory.Build.props @@ -8,7 +8,7 @@ Release already exists for v$(Version). Bump this in the same PR as the change you want released. --> - 0.33.12 + 0.34.0