From 12f57a69aa026c43c4cb1da6c9ea450ca50fb5a6 Mon Sep 17 00:00:00 2001 From: M0LTE Date: Thu, 11 Jun 2026 21:02:37 +0000 Subject: [PATCH] Support KISS ACKMODE end to end; pin samoyed to the ACKMODE build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit net-sim itself never parses KISS frames — the host talks KISS straight to each samoyed child — so "ackmode support" here is twofold: build a samoyed that implements ACKMODE, and prove the round trip works through a real child. - Pin the samoyed source to the ACKMODE build (M0LTE/samoyed @ 6b4f5c7, a fork branch; not yet in doismellburning/samoyed) via new SAMOYED_REPO + SAMOYED_REF build args in the Dockerfile, and matching defaults in install.sh. The pin is a fixed SHA so release images are reproducible, with a clear revert-to-upstream path for when ACKMODE lands upstream. The docker workflow no longer resolves a floating samoyed main HEAD; the Dockerfile is now the single source of truth for which samoyed gets built. - Add internal/tnc/ackmode_test.go: an end-to-end integration test that spawns a real samoyed child, connects to its KISS TCP port, sends an ACKMODE data frame (command nibble 0x0C with two id bytes and a raw AX.25 UI frame), and asserts samoyed transmits it and echoes the same two id bytes back with command nibble 0x0C. It skips (not fails) when no samoyed binary is present, so the existing suite stays green where samoyed isn't built; point it at a binary with SAMOYED_BIN to run it. - Update the README: ACKMODE moves from "Known limitations" to supported, and the install env-var table documents the SAMOYED_REPO/REF pin. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docker.yml | 27 +--- Dockerfile | 24 ++-- README.md | 25 ++-- install.sh | 8 +- internal/tnc/ackmode_test.go | 251 +++++++++++++++++++++++++++++++++++ 5 files changed, 296 insertions(+), 39 deletions(-) create mode 100644 internal/tnc/ackmode_test.go diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cd04155..8fcf135 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,30 +45,17 @@ jobs: type=semver,pattern={{major}} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - # Resolve samoyed's current main HEAD SHA so it can be passed - # as a build-arg. Without this, the Dockerfile's - # RUN git clone --depth 1 --branch main https://...samoyed.git - # is a layer whose command text never changes — GHA cache hits it - # every build and the bundled samoyed snapshot gets frozen at - # whatever it was the first time the layer ran. Pinning to a SHA - # in the build-args changes the layer's cache key whenever - # samoyed/main moves, so we pick up bugfixes in lockstep. - - name: Resolve samoyed main SHA - id: samoyed - run: | - sha=$(curl -fsSL \ - -H "Accept: application/vnd.github.v3.sha" \ - -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ - https://api.github.com/repos/doismellburning/samoyed/commits/main) - echo "sha=$sha" >> "$GITHUB_OUTPUT" - echo "samoyed main = $sha" - + # samoyed source (SAMOYED_REPO + SAMOYED_REF) is pinned to a fixed commit + # in the Dockerfile, so we no longer resolve a floating main HEAD here. + # A fixed SHA gives a stable layer cache key and reproducible release + # images. The pin is currently the ACKMODE fork branch + # (M0LTE/samoyed @ 6b4f5c7) pending doismellburning/samoyed#528 — see the + # Dockerfile comment for the revert-to-upstream instructions. To rebuild + # against a different samoyed, override the build-args there or here. - name: Build and push uses: docker/build-push-action@v7 with: context: . - build-args: | - SAMOYED_REF=${{ steps.samoyed.outputs.sha }} # PR builds verify the Dockerfile still works but don't publish. push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile b/Dockerfile index e1e1732..094eb45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,17 @@ # with samoyed's minimum. FROM golang:1.25-bookworm AS builder -ARG SAMOYED_REF=main +# samoyed source, pinned via build args. Both default here so every build +# path (local `docker build`, install.sh, CI) gets the same samoyed. +# +# TEMPORARY PIN: KISS ACKMODE is not yet in doismellburning/samoyed; it lives +# on the fork branch below (M0LTE/samoyed feat/ackmode). When ACKMODE lands in +# doismellburning/samoyed, revert SAMOYED_REPO to +# https://github.com/doismellburning/samoyed.git and set SAMOYED_REF back to +# `main` (or that commit). Pinned to a fixed SHA so release images are +# reproducible rather than tracking a floating branch. +ARG SAMOYED_REPO=https://github.com/M0LTE/samoyed.git +ARG SAMOYED_REF=6b4f5c7aef633041cb2e55ab063f29ff0bacbefa RUN apt-get update && apt-get install -y --no-install-recommends \ git make pkg-config gcc libc6-dev \ @@ -35,14 +45,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libavahi-client-dev libbsd-dev libgps-dev libasound2-dev \ && rm -rf /var/lib/apt/lists/* -# samoyed (pinned via SAMOYED_REF build arg). Accepts either a branch -# name OR a commit SHA: we always init+fetch a single commit, so SHAs -# work without `git clone --branch` (which rejects them). -# -# CI passes the current samoyed/main HEAD SHA so this RUN's cache key -# moves whenever samoyed/main moves — see .github/workflows/docker.yml. +# Accepts either a branch name OR a commit SHA in SAMOYED_REF: we always +# init+fetch a single commit, so SHAs work without `git clone --branch` +# (which rejects them). A pinned SHA keeps this layer's cache key stable, so +# rebuilding a given net-sim tag reproduces the same samoyed snapshot. RUN git init --quiet /src/samoyed \ - && git -C /src/samoyed remote add origin https://github.com/doismellburning/samoyed.git \ + && git -C /src/samoyed remote add origin "${SAMOYED_REPO}" \ && git -C /src/samoyed fetch --depth 1 origin "${SAMOYED_REF}" \ && git -C /src/samoyed checkout --quiet FETCH_HEAD \ && make -C /src/samoyed cmds diff --git a/README.md b/README.md index de9c5e0..7a5c176 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,8 @@ Override knobs (set as env vars before `sudo bash`): | `NETWORK_YAML` | `/etc/sim/network.yaml` | the active config | | `WEB_PORT` | `8080` | sim-web listen port | | `SYSTEMD` | `1` | set to `0` to skip the unit | -| `SIM_REF` / `SAMOYED_REF` | `main` | git ref to check out | +| `SIM_REF` | `main` | net-sim git ref to check out | +| `SAMOYED_REPO` / `SAMOYED_REF` | `M0LTE/samoyed` @ ACKMODE commit | samoyed source — temporarily pinned to the ACKMODE fork (see "Known limitations"); revert to `doismellburning/samoyed` `main` once ACKMODE lands upstream | The script is idempotent — re-run it to update to a newer `main`. It does **not** install pulseaudio / pipewire / jackd; samoyed initialises @@ -411,8 +412,8 @@ qualitatively different things from the same code. ## Known limitations (samoyed-side, expected to be fixed upstream) -These are gaps in the current samoyed build that affect what you can -test against; both will likely land in samoyed soon and we'll bump the +This is a gap in the current samoyed build that affects what you can +test against; it will likely land in samoyed soon and we'll bump the pin then. Tracker issues: [net-sim#1](https://github.com/packethacking/net-sim/issues/1) / [net-sim#2](https://github.com/packethacking/net-sim/issues/2). @@ -425,12 +426,18 @@ pin then. Tracker issues: the wire, this rig won't reproduce it yet. (The field used to be called `crc`, which was misleading — renamed to `fec` to match what it actually does.) -- **No KISS ACKMODE.** Samoyed's KISS layer explicitly refuses XKISS - opcodes (`12 = ACKMODE data`, `14 = poll`) — sending one logs - `Using ACKMODE will cause this error.` and the frame is dropped. - Anything that depends on tracked-frame ACKs from the TNC (some BPQ - configurations, certain `ax25d` setups) won't work against simulated - ports. Use NORMAL KISS only. +**KISS ACKMODE — now supported.** Previously samoyed's KISS layer refused +the XKISS ACKMODE opcode and dropped the frame; the pinned samoyed build now +implements G8BPQ extended-KISS ACKMODE (command nibble `0x0C`). Send a data +frame with two leading id bytes (`C0 xC aa bb C0`) and the TNC echoes +those two bytes back (`C0 xC aa bb C0`) once the frame has actually been +transmitted — so a host (some BPQ configurations, certain `ax25d` setups) can +start FRACK from the real on-air moment instead of from hand-off. The id bytes +are echoed verbatim. (The implementation currently lives on a samoyed fork, +[M0LTE/samoyed](https://github.com/M0LTE/samoyed/tree/feat/ackmode); net-sim +pins that build and will switch back to upstream samoyed once ACKMODE lands +there. XKISS poll mode and checksum mode remain unimplemented.) An end-to-end +round-trip test lives in `internal/tnc/ackmode_test.go`. ## What's not in v1 diff --git a/install.sh b/install.sh index f1837cf..bb1c558 100755 --- a/install.sh +++ b/install.sh @@ -18,8 +18,12 @@ SIM_REPO="${SIM_REPO:-https://github.com/packethacking/net-sim.git}" SIM_REF="${SIM_REF:-main}" SIM_DIR="${SIM_DIR:-/opt/sim}" -SAMOYED_REPO="${SAMOYED_REPO:-https://github.com/doismellburning/samoyed.git}" -SAMOYED_REF="${SAMOYED_REF:-main}" +# TEMPORARY PIN: KISS ACKMODE isn't in doismellburning/samoyed yet; it's on the +# fork branch below (M0LTE/samoyed feat/ackmode). When ACKMODE lands upstream, +# set SAMOYED_REPO back to https://github.com/doismellburning/samoyed.git and +# SAMOYED_REF to `main`. +SAMOYED_REPO="${SAMOYED_REPO:-https://github.com/M0LTE/samoyed.git}" +SAMOYED_REF="${SAMOYED_REF:-6b4f5c7aef633041cb2e55ab063f29ff0bacbefa}" SAMOYED_DIR="${SAMOYED_DIR:-/opt/samoyed}" WEB_PORT="${WEB_PORT:-8080}" diff --git a/internal/tnc/ackmode_test.go b/internal/tnc/ackmode_test.go new file mode 100644 index 0000000..61c5fbc --- /dev/null +++ b/internal/tnc/ackmode_test.go @@ -0,0 +1,251 @@ +package tnc + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "testing" + "time" + + "github.com/packethacking/net-sim/internal/config" +) + +// KISS framing bytes. +const ( + kissFEND = 0xC0 + kissFESC = 0xDB + kissTFEND = 0xDC + kissTFESC = 0xDD +) + +// xkissCmdData is the KISS ACKMODE command nibble (G8BPQ extended KISS). +const xkissCmdData = 0x0C + +// TestSamoyedAckmodeRoundTrip drives a real samoyed child end to end over its +// KISS TCP port: it sends an ACKMODE data frame (command nibble 0x0C with two +// opaque id bytes) and asserts that, once the frame has actually been +// transmitted, samoyed echoes the command byte and the same two id bytes back. +// +// This is the feature net-sim depends on. It needs an ackmode-capable samoyed +// binary; set SAMOYED_BIN, or install it on PATH / at /opt/samoyed. The test +// skips (rather than fails) when no binary is available so the rest of the +// suite stays green in environments without samoyed built. +func TestSamoyedAckmodeRoundTrip(t *testing.T) { + bin := findSamoyedBinary(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + spec := Spec{ + Backend: BackendSamoyed, + NodeID: "ack", + PortID: "p0", + Modem: config.Modem{Mode: config.ModeAFSK1200}, + KissPort: freeTCPPort(t), + RxAudioUDPPort: freeUDPPort(t), + SamoyedBin: bin, + WorkDir: t.TempDir(), + } + + child, err := Start(ctx, spec) + if err != nil { + t.Fatalf("start samoyed: %v", err) + } + defer child.Stop() + + // Drain the TX audio stream so nothing backs up while samoyed transmits. + go func() { _, _ = io.Copy(io.Discard, child.TXAudio()) }() + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", spec.KissPort), 2*time.Second) + if err != nil { + t.Fatalf("dial kiss: %v", err) + } + defer conn.Close() + + wantID := [2]byte{0xAB, 0xCD} + + // ACKMODE data frame: command nibble 0x0C (channel 0), two id bytes, then + // a raw AX.25 UI frame. + payload := []byte{xkissCmdData, wantID[0], wantID[1]} + payload = append(payload, ax25UIFrame("Q2TEST", "Q1TEST", "ackmode round-trip")...) + + if _, err := conn.Write(kissEncode(payload)); err != nil { + t.Fatalf("write ackmode frame: %v", err) + } + + // The acknowledgement must come back once the frame is on the air. Give it + // plenty of time (TXDELAY + CSMA persist/slottime + render). + ack := readKISSFrame(t, conn, 20*time.Second, func(d []byte) bool { + return len(d) == 3 && d[0]&0x0F == xkissCmdData + }) + + if got := ack[0] & 0x0F; got != xkissCmdData { + t.Errorf("ack command nibble = %#x, want %#x (0x0E would mean the buggy poll opcode)", got, xkissCmdData) + } + if ack[1] != wantID[0] || ack[2] != wantID[1] { + t.Errorf("ack id bytes = % x, want % x", ack[1:3], wantID[:]) + } +} + +// --- helpers --------------------------------------------------------------- + +func findSamoyedBinary(t *testing.T) string { + t.Helper() + if b := os.Getenv("SAMOYED_BIN"); b != "" { + return b + } + if p, err := exec.LookPath("samoyed-direwolf"); err == nil { + return p + } + for _, p := range []string{ + "/opt/samoyed/dist/samoyed-direwolf", + "/usr/local/bin/samoyed-direwolf", + } { + if _, err := os.Stat(p); err == nil { + return p + } + } + t.Skip("samoyed-direwolf binary not found (set SAMOYED_BIN); skipping ACKMODE integration test") + return "" +} + +func freeTCPPort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("free tcp port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +func freeUDPPort(t *testing.T) int { + t.Helper() + c, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatalf("free udp port: %v", err) + } + defer c.Close() + return c.LocalAddr().(*net.UDPAddr).Port +} + +// kissEncode wraps payload in a KISS frame, applying SLIP-style escaping. +func kissEncode(payload []byte) []byte { + out := []byte{kissFEND} + for _, b := range payload { + switch b { + case kissFEND: + out = append(out, kissFESC, kissTFEND) + case kissFESC: + out = append(out, kissFESC, kissTFESC) + default: + out = append(out, b) + } + } + return append(out, kissFEND) +} + +// readKISSFrame reads from conn until a decoded KISS frame satisfies match, or +// the timeout elapses (a fatal failure). Non-matching frames are logged and +// skipped. +func readKISSFrame(t *testing.T, conn net.Conn, timeout time.Duration, match func([]byte) bool) []byte { + t.Helper() + _ = conn.SetReadDeadline(time.Now().Add(timeout)) + + var acc []byte + tmp := make([]byte, 4096) + for { + n, err := conn.Read(tmp) + if n > 0 { + acc = append(acc, tmp[:n]...) + for { + frame, rest, ok := nextKISSFrame(acc) + if !ok { + break + } + acc = rest + if match(frame) { + return frame + } + t.Logf("ignoring non-matching KISS frame from TNC: % x", frame) + } + } + if err != nil { + t.Fatalf("waiting for ACKMODE ack: %v (buffered: % x)", err, acc) + } + } +} + +// nextKISSFrame extracts the first complete, de-escaped KISS frame from buf, +// returning it along with the unconsumed remainder. Empty frames (back-to-back +// FENDs) are skipped. +func nextKISSFrame(buf []byte) (frame, rest []byte, ok bool) { + start := bytes.IndexByte(buf, kissFEND) + if start < 0 { + return nil, buf, false + } + end := bytes.IndexByte(buf[start+1:], kissFEND) + if end < 0 { + return nil, buf, false + } + end += start + 1 + + raw := buf[start+1 : end] + rest = buf[end:] // leave the closing FEND to open the next frame + + var dec []byte + for k := 0; k < len(raw); k++ { + if raw[k] == kissFESC && k+1 < len(raw) { + k++ + switch raw[k] { + case kissTFEND: + dec = append(dec, kissFEND) + case kissTFESC: + dec = append(dec, kissFESC) + default: + dec = append(dec, raw[k]) + } + } else { + dec = append(dec, raw[k]) + } + } + if len(dec) == 0 { + return nextKISSFrame(rest) + } + return dec, rest, true +} + +// ax25UIFrame builds a raw (FCS-less) AX.25 UI frame: dest>src, control 0x03 +// (UI), pid 0xF0 (no layer 3), then the info text. +func ax25UIFrame(dest, src, info string) []byte { + f := encodeAX25Addr(dest, 0, true, false) // destination: command bit set, not last + f = append(f, encodeAX25Addr(src, 0, false, true)...) // source: response bit, last address + f = append(f, 0x03, 0xF0) + return append(f, []byte(info)...) +} + +// encodeAX25Addr encodes one 7-byte AX.25 address field. cbit sets the +// command/response bit (bit 7); last sets the HDLC end-of-address bit (bit 0). +func encodeAX25Addr(call string, ssid byte, cbit, last bool) []byte { + b := make([]byte, 7) + for i := 0; i < 6; i++ { + c := byte(' ') + if i < len(call) { + c = call[i] + } + b[i] = c << 1 + } + ss := byte(0x60) | (ssid&0x0F)<<1 // reserved bits 6,5 set, SSID in bits 4-1 + if cbit { + ss |= 0x80 + } + if last { + ss |= 0x01 + } + b[6] = ss + return b +}