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
27 changes: 7 additions & 20 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
24 changes: 16 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,30 @@
# 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 \
libudev-dev libhamlib-dev portaudio19-dev \
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
Expand Down
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand All @@ -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 <frame> 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

Expand Down
8 changes: 6 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
251 changes: 251 additions & 0 deletions internal/tnc/ackmode_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading