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 +}