Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/configure.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ The admin password (for the dashboard cookie) is set on `/Setup` first-run flow,
| Fragment threshold (bytes) | `DAPPS_FRAGMENT_THRESHOLD_BYTES` | `4096` | Payloads strictly larger than this get split into N fragments at submit. `0` disables. |
| Fragment reassembly timeout (s) | `DAPPS_FRAGMENT_REASSEMBLY_TIMEOUT_SECONDS` | `604800` (7 d) | Drop incomplete reassembly buffers older than this. |

### Route gossip

| Name | Env var | Default | What it does |
|-----------------------------------|------------------------------------------|-------------|-----------------------------------------------------------------------------------------|
| Route gossip staleness (hours) | `DAPPS_ROUTE_GOSSIP_STALENESS_HOURS` | `6` | Minimum hours between consecutive `routes` pulls from the same neighbour. The piggyback gate skips gossip if the previous pull is younger than this. `0` disables route gossip entirely. |

### Updates

| Name | Env var | Default | What it does |
Expand Down
14 changes: 14 additions & 0 deletions docs/discovery-and-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,24 @@ Works well on small meshes. The trade-off is no proactive route discovery - dest

DSR-flavoured. The sender stamps the path on the message; intermediate nodes follow it. Adds a few bytes per message; works better when the topology is volatile, when you want explicit per-message path control, or when you're emulating MeshCore-on-AX.25 for testing.

## Route gossip

Once two DAPPS nodes have a session open for real work (a push, a probe, a reverse poll), they piggyback a `routes` exchange to share what they know about further-reach destinations. The receiver writes each gossiped route into its `learnedroutes` table marked `source=gossip`; the existing failure-counter machinery invalidates anything that turns out not to work.

Bounded by a per-(local, remote) staleness gate: at most one pull per `RouteGossipStalenessHours` (default 6h) per neighbour, regardless of session frequency. No scheduled transmission - if there's no traffic and no probes, no gossip exchange happens. Setting `RouteGossipStalenessHours=0` disables it entirely.

The advertiser filters: only routes whose failure counter is zero, only routes the daemon itself has actually traversed, never gossip-imported routes (don't re-export hearsay). Manual neighbours are always advertised since they're the most-trusted class.

The dashboard's learned-routes view marks gossip-sourced rows distinctly from traffic-learned ones. Resolution priority unchanged: hint → neighbour → discovered → learned (gossip and traffic share the learned tier).

## Route hints

A manual override. The `/RouteHints` endpoint (and dashboard panel) let you say "for messages destined for X, always try Y first." Useful for steering around a known-broken link, or for asymmetric routing where the natural route in one direction differs from the other.

## Reaching nodes through non-DAPPS intermediates

When the only path to a peer runs through bare packet nodes that don't speak DAPPS or NET/ROM, DAPPS supports a **connect-script** on the neighbour row - a series of `(send, expect)` steps the daemon plays before falling into the DAPPS prompt. Same use case as the operator's manual `C node1 / C node2 / ... / DAPPS` chain, automated. See [Multi-hop via non-DAPPS nodes](multi-hop.md).

## What you actually do

For most operators, the simplest workable setup is:
Expand Down
32 changes: 31 additions & 1 deletion docs/implement.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The reference implementation in this repo is the canonical source of truth. Wher
The page is in two parts:

- [**Bare essentials**](#bare-essentials) - the smallest set of behaviours that lets two implementations exchange a message and not deadlock. If you implement only this, you get a node that pushes messages, accepts inbound messages, and is invisible to discovery / routing optimisations.
- [**Full interoperability**](#full-interoperability) - feature by feature, what to add to that minimum to be fully indistinguishable from the reference daemon: end-to-end source tracking, multi-part fragmentation, opt-in ordering, polling, peer exchange, discovery beacons, and the datagram codec.
- [**Full interoperability**](#full-interoperability) - feature by feature, what to add to that minimum to be fully indistinguishable from the reference daemon: end-to-end source tracking, multi-part fragmentation, opt-in ordering, polling, peer exchange, route gossip, discovery beacons, and the datagram codec.

## Bare essentials

Expand Down Expand Up @@ -258,6 +258,36 @@ A receiver that doesn't implement `rev` should respond `eh?\n` to the command. S

Reference: [InboundConnectionHandler.cs:275-343](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.core/Services/InboundConnectionHandler.cs#L275-L343), [DappsProtocolClient.cs:283-369](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.client/DappsProtocolClient.cs#L283-L369).

### `routes` exchange

```
C: routes\n
S: route M0LTE-9 hops=1\n
S: route GB7RDG hops=2 ageSeconds=300\n
S: route G7VVK hops=2 ageSeconds=900\n
S: end\n
```

The connecting peer asks "what destinations can you reach?"; the server emits one `route` line per known-good destination, then `end\n`.

Per-line format:

```
route <destBaseCallsign> [hops=<int>] [ageSeconds=<int>]
```

- `destBaseCallsign`: the destination's base callsign (no SSID).
- `hops` (optional): a hint for the receiver's cost calculation. The reference daemon emits `1` for direct neighbours, `2` for traffic-learned routes (via one intermediate). Receivers that don't care about hops ignore it.
- `ageSeconds` (optional): how long ago the responder last saw evidence the route works. Helps the receiver decide whether to trust it.

Receivers import each row as a learned route via the responding peer, marked as gossip-sourced. Failures invalidate via the same per-row failure counter the daemon already maintains for traffic-learned routes; gossip-sourced rows don't get re-exported (only direct observation gets advertised, to avoid distance-vector loops).

The reference daemon's emitter filters: only routes whose failure counter is zero, and only routes the daemon itself has actually used (not just heard about). Manual neighbours are always advertised; traffic-learned routes are advertised only when proven; gossip-imported routes are never advertised (don't re-export hearsay).

Implementations that don't care about route gossip should respond `eh?\n` to the command. Senders treat `eh?` as "this peer doesn't gossip" and stop trying.

Reference: [InboundConnectionHandler.HandleRoutes](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.core/Services/InboundConnectionHandler.cs), [DappsProtocolClient.RequestRoutesAsync](https://github.com/M0LTE/dapps/blob/master/src/dapps/dapps.client/DappsProtocolClient.cs).

### Quit / help

```
Expand Down
108 changes: 108 additions & 0 deletions docs/multi-hop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Reaching a peer through intermediate nodes

DAPPS discovers other DAPPS nodes via beacons (RF-direct) and via the `peers` / `routes` commands (transitive among DAPPS-aware peers). Both mechanisms break down when the *only* path between two DAPPS nodes runs through one or more **non-DAPPS** packet nodes.

A typical example: A and C are DAPPS nodes, neither in RF range of the other; B is a regular BPQ packet node that hears both, but B doesn't speak DAPPS and isn't running NET/ROM either. A's beacons never reach C; C is invisible to A's discovery layer. The path *exists* - operator could manually `C B`, then `C C` from B's prompt, and reach C - but A's daemon doesn't know to try.

This page documents the two ways DAPPS bridges that gap: the **node-prompt probe** for the one-intermediate case, and the **connect-script** for arbitrary chains.

## Single intermediate: node-prompt probing

When the intermediate B is a BPQ-style packet node and C's BPQ has DAPPS registered as an APPLICATION, the existing node-prompt probe handles this. A connects to B's AGW slot, types `DAPPS` (or whatever `NodePromptApplicationCommand` is set to), and BPQ's APPLICATION dispatcher routes the connection through to C's DAPPS slot. The probe runs end-to-end on that path; on success, C is added as a known peer.

Turn this on with:

```
DAPPS_AUTO_DISCOVER_VIA_NODE_CALL=true
```

Probing must also be enabled (`DAPPS_PROBING_ENABLED=true`). Once both are on, every AGW DAPPS beacon A hears seeds a node-prompt-probe candidate for the source's base callsign. See [Discovery & routing](discovery-and-routing.md) for the full probe taxonomy.

This handles the one-intermediate case automatically. Where it breaks down: A has to *first hear a beacon from somewhere related to C* for the candidate to land in the probe pool. If C is two hops away through bare packet nodes that don't propagate UI frames, A may never hear that beacon.

## Multi-hop chains: connect-scripts

A connect-script automates the operator's manual `C node1 / C node2 / ... / DAPPS` sequence. It's a property of a **manually-added neighbour** row: when the daemon goes to forward to that neighbour, it plays the script over the AGW connection before falling into the DAPPSv1 prompt.

### Topology

```
A (DAPPS) ←RF→ G0NODE2 ←RF→ G0NODE3 ←RF→ G0NODE4 ←RF→ C (DAPPS)
```

A and C can't hear each other. G0NODE2/3/4 are bare packet nodes that don't speak DAPPS. The operator's manual chain is:

```
A: connect to G0NODE2 (AGW)
"Connected to G0NODE2"
A types: C G0NODE3
"Connected to G0NODE3"
A types: C G0NODE4
"Connected to G0NODE4"
A types: C C
"Connected to C"
A types: DAPPS
"DAPPSv1>"
```

That sequence becomes a connect-script.

### Configuring a connect-script

In the dashboard's **Add / update neighbour** form (or `POST /Neighbours`):

- **Callsign**: the far-end DAPPS node (`C` in the example).
- **Bearer port**: the AGW port to use for the *first* hop (G0NODE2).
- **Connect script**: one step per line, `SEND|EXPECT[|TIMEOUT_SECONDS]`:

```
C G0NODE3|Connected to G0NODE3
C G0NODE4|Connected to G0NODE4
C C|Connected to C
DAPPS|DAPPSv1>|60
```

Notes:

- Each `SEND` is transmitted with a `\r` line terminator (BPQ-style node prompts use CR, not LF).
- Each `EXPECT` is a substring match against the inbound bytes; case-sensitive. Pick something distinctive enough that earlier banner text won't accidentally match.
- `TIMEOUT_SECONDS` is per-step; default 30s. The final step that lands on `DAPPSv1>` may want longer because the application command takes a moment to dispatch on the far-end node.
- The first step is *not* "C G0NODE2" - that's the regular AGW connect, handled by the bearer port. The script picks up after the AGW connection lands at G0NODE2's prompt.
- The script's last step **must** end on a substring containing `DAPPSv1>`; the protocol client takes over from there.

Lines beginning with `#` are comments. Blank lines are ignored.

### What happens on send

When the outbound forwarder picks a message destined for C:

1. Resolves the route to the C-neighbour row, which has the connect-script attached.
2. Opens an AGW connection to the *first hop* (G0NODE2) via the configured bearer port.
3. Plays the script: send line, wait for substring, send line, wait, ... until `DAPPSv1>`.
4. Falls into the regular `ihave` / `data` / `ack` exchange.
5. On success, all the usual things happen: opportunistic `rev` poll, route gossip pull (subject to staleness gate), audit log entry.

If any step times out (default 30s) or the stream closes, the script aborts and the forward fails like any other transport failure. The route's failure counter increments; after enough consecutive failures, the daemon falls back to whatever else is available.

### Bidirectional setup

Connect-scripts are one-sided: configuring A's script for C lets A push to C. For C to reach A, C also needs a connect-script (for the reverse chain) - configured by the operator at C, the same way.

What you get for free, once messages flow either way:

- **Reverse passive learning**. The first message A pushes to C carries `src=A`; C's passive-flood algorithm learns A as a route. C can now reply to A via the gossip-imported route, no separate operator config required.
- **Route gossip propagation**. A's next session with C piggybacks a `routes` pull (subject to the per-neighbour staleness gate, default 6h). C tells A about whatever destinations C can reach; A learns about peers behind C without needing to script every chain.

### Probes use the same script

Once a neighbour has a connect-script, both forwarder *and* probes (when probing is enabled) play it. A green probe indicates the chain is currently working end-to-end - same liveness signal as for direct neighbours, with no special handling required by the operator.

### Dashboard / inspection

The Neighbours panel shows a "Connect script" column with the step count for each neighbour. Re-submit the form with the same callsign to update the script; submit with the connect-script box empty to clear it (the row falls back to direct connection).

A failed script run logs each step's last 200 chars of received text via the daemon's normal logging channel, so the operator can see exactly which expect didn't match.

### When *not* to use a connect-script

If B is itself a DAPPS node, or if B runs NET/ROM and has C in its routes table, you don't need a script - DAPPS can either reach C via the existing single-step node-prompt probe or via NET/ROM transparent routing. Connect-scripts are specifically for chains of *bare* packet nodes where the operator would otherwise be typing the chain by hand.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ nav:
- Run: run.md
- Tune: tune.md
- Discovery & routing: discovery-and-routing.md
- Multi-hop via non-DAPPS nodes: multi-hop.md
- Operate: operate.md
- Audit log: audit.md
- Update: update.md
Expand Down
3 changes: 2 additions & 1 deletion src/dapps/dapps.client/Backhaul/BackhaulRoute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ namespace dapps.client.Backhaul;
public sealed record BackhaulRoute(
string Callsign,
int? BearerPort = null,
string? UdpEndpoint = null);
string? UdpEndpoint = null,
ConnectScript? ConnectScript = null);
142 changes: 142 additions & 0 deletions src/dapps/dapps.client/Backhaul/ConnectScriptRunner.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Text;
using Microsoft.Extensions.Logging;

namespace dapps.client.Backhaul;

/// <summary>
/// Plays a <see cref="ConnectScript"/> over a duplex byte stream:
/// for each step, write the send line + carriage return, read the
/// stream until the expected substring appears (or per-step timeout
/// elapses), and proceed. When the final step's expect lands on
/// <c>DAPPSv1&gt;</c>, the stream is positioned just past the prompt
/// and ready for the normal DAPPS protocol exchange.
///
/// <para>
/// Used by <see cref="Dappsv1SessionBackhaul"/> when a route's
/// <see cref="BackhaulRoute.ConnectScript"/> is set, to reach far-end
/// DAPPS nodes via a chain of intermediate non-DAPPS packet nodes
/// (the operator's manual "C node1 / C node2 / ... / DAPPS" sequence,
/// automated). Used the same way by the prober.
/// </para>
///
/// <para>
/// Carriage return (<c>\r</c>, 0x0D) is appended to each <c>Send</c>
/// because BPQ-derived node prompts treat CR as line-end. LF would
/// not advance the prompt on most node software.
/// </para>
/// </summary>
public static class ConnectScriptRunner
{
/// <summary>
/// Play the script. Returns the captured pre-script-end transcript
/// (everything received during the script, useful for the test-now
/// dashboard button) on success. Throws on timeout or stream EOF.
/// </summary>
public static async Task<string> RunAsync(
Stream stream,
ConnectScript script,
ILogger? logger,
CancellationToken ct)
{
var transcript = new StringBuilder();
for (var i = 0; i < script.Steps.Count; i++)
{
var step = script.Steps[i];
var stepTimeout = TimeSpan.FromSeconds(
step.TimeoutSeconds ?? ConnectScript.DefaultStepTimeoutSeconds);

var line = step.Send + "\r";
await stream.WriteAsync(Encoding.UTF8.GetBytes(line), ct);
await stream.FlushAsync(ct);
logger?.LogDebug("connect-script step {0}/{1}: sent {2}", i + 1, script.Steps.Count, step.Send);

var observed = await ReadUntilSubstringAsync(stream, step.Expect, stepTimeout, transcript, ct);
if (!observed)
{
throw new ConnectScriptException(
$"connect-script step {i + 1}/{script.Steps.Count}: " +
$"timed out waiting for '{step.Expect}' after {stepTimeout.TotalSeconds:F0}s. " +
$"Last 200 chars received: {Tail(transcript, 200)}");
}
logger?.LogDebug("connect-script step {0}/{1}: matched '{2}'", i + 1, script.Steps.Count, step.Expect);
}
return transcript.ToString();
}

/// <summary>
/// Read from the stream byte-by-byte until <paramref name="expected"/>
/// is observed in a sliding window of received bytes. Appends
/// everything read to <paramref name="transcript"/> for diagnostics.
/// Returns true on match; false on timeout. Throws <see cref="EndOfStreamException"/>
/// on EOF before match (callers translate to script failure).
/// </summary>
private static async Task<bool> ReadUntilSubstringAsync(
Stream stream, string expected, TimeSpan timeout, StringBuilder transcript, CancellationToken outer)
{
if (string.IsNullOrEmpty(expected))
{
// Empty expect would match immediately; treat as a config
// error rather than silently accepting any byte (or none).
throw new ArgumentException("connect-script step expect must be non-empty", nameof(expected));
}

using var cts = CancellationTokenSource.CreateLinkedTokenSource(outer);
cts.CancelAfter(timeout);
var buffer = new byte[1];
// Keep a sliding window of the last `expected.Length` bytes so we
// detect the substring without buffering the whole transcript.
var window = new char[expected.Length];
var windowFill = 0;

try
{
while (true)
{
int n;
try
{
n = await stream.ReadAsync(buffer, cts.Token);
}
catch (OperationCanceledException) when (!outer.IsCancellationRequested)
{
return false;
}
if (n == 0)
{
throw new EndOfStreamException(
$"connect-script: stream closed while waiting for '{expected}'. Transcript: {Tail(transcript, 200)}");
}
var c = (char)buffer[0];
transcript.Append(c);
if (windowFill < expected.Length)
{
window[windowFill++] = c;
}
else
{
Array.Copy(window, 1, window, 0, expected.Length - 1);
window[expected.Length - 1] = c;
}
if (windowFill == expected.Length && new string(window) == expected)
{
return true;
}
}
}
catch (EndOfStreamException)
{
throw;
}
}

private static string Tail(StringBuilder sb, int chars)
{
if (sb.Length <= chars) return sb.ToString();
return sb.ToString(sb.Length - chars, chars);
}
}

/// <summary>Thrown when a connect-script step times out or otherwise
/// fails. The session callsite catches and surfaces as a forward
/// failure.</summary>
public sealed class ConnectScriptException(string message) : Exception(message);
Loading