From f8cad55e9c3f3df782083dcd0c9747ad2f3b9052 Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Wed, 7 Oct 2026 12:14:41 +0200 Subject: [PATCH] Switch the lab reflector to jayReflector and harden local station use. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build DJ1JAY’s fork with an optional Satellite/Trunk stub, allow -c/--config, open capture and playback as separate devices, and add a lab start helper. Co-authored-by: Cursor --- .gitignore | 2 +- app.go | 17 +- audio/device.go | 114 ++++++---- config_path.go | 42 ++++ config_path_test.go | 124 +++++++++++ docs/adr/0002-home-directory-config.md | 2 +- docs/code-review.md | 218 +++++++++++++++++++ docs/svxlink.md | 86 ++++++-- main.go | 2 +- station/behavior_test.go | 10 +- station/config.go | 10 +- station/config_test.go | 55 +++-- station/lab_reflector_test.go | 2 +- station/log_test.go | 4 +- station/session_test.go | 2 +- station/software.go | 4 +- svxlink-docker/.env.example | 9 +- svxlink-docker/.gitignore | 4 + svxlink-docker/Dockerfile | 85 ++++---- svxlink-docker/config/svxreflector.conf | 15 +- svxlink-docker/config/svxreflector.link.conf | 41 ++++ svxlink-docker/docker-compose.yml | 28 ++- svxlink-docker/entrypoint.sh | 72 +++--- teststart.sh | 124 +++++++++++ 24 files changed, 847 insertions(+), 225 deletions(-) create mode 100644 config_path.go create mode 100644 config_path_test.go create mode 100644 docs/code-review.md create mode 100644 svxlink-docker/config/svxreflector.link.conf create mode 100755 teststart.sh diff --git a/.gitignore b/.gitignore index 5e1078d..c5c34f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ build/bin/ -frontend/wailsjs/ +frontend/wailsjs/ \ No newline at end of file diff --git a/app.go b/app.go index 036413f..483ffbc 100644 --- a/app.go +++ b/app.go @@ -2,9 +2,6 @@ package main import ( "context" - "fmt" - "os" - "path/filepath" "gitea.neitzel.de/konrad/funknetz/audio" "gitea.neitzel.de/konrad/funknetz/station" @@ -47,18 +44,10 @@ func (a *App) HoldSpace() { a.station.HoldSpace() } // ReleaseSpace reports that Space was released. func (a *App) ReleaseSpace() { a.station.ReleaseSpace() } -func configPath() (string, error) { - home, err := os.UserHomeDir() +func openStation(args []string) (*station.Station, error) { + path, err := resolveConfigPath(args) if err != nil { - return "", err - } - return filepath.Join(home, ".funknetz.json"), nil -} - -func openStation() (*station.Station, error) { - path, err := configPath() - if err != nil { - return nil, fmt.Errorf("could not be read") + return nil, err } return station.New(station.Options{ConfigPath: path}) } diff --git a/audio/device.go b/audio/device.go index 0345812..2a21d38 100644 --- a/audio/device.go +++ b/audio/device.go @@ -12,47 +12,51 @@ import ( const stationRate = 16000 -// Start opens the default input and output and converts them to and from -// 16 kHz mono. It returns when the devices are running. They close when ctx ends. +// Start opens the default capture device and the default playback device and +// converts them to and from 16 kHz mono. It returns when both devices are +// running. They close when ctx ends. func Start(ctx context.Context, capture func([]int16), playback func() []int16) error { mctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil) if err != nil { return err } - cfg := malgo.DefaultDeviceConfig(malgo.Duplex) - cfg.SampleRate = stationRate - cfg.Capture.Format = malgo.FormatS16 - cfg.Capture.Channels = 1 - cfg.Playback.Format = malgo.FormatS16 - cfg.Playback.Channels = 1 - var playRes, capRes converter + capCfg := malgo.DefaultDeviceConfig(malgo.Capture) + capCfg.SampleRate = stationRate + capCfg.Capture.Format = malgo.FormatS16 + capCfg.Capture.Channels = 1 + + playCfg := malgo.DefaultDeviceConfig(malgo.Playback) + playCfg.SampleRate = stationRate + playCfg.Playback.Format = malgo.FormatS16 + playCfg.Playback.Channels = 1 + + var capRes, playRes converter var playBuf []int16 - callbacks := malgo.DeviceCallbacks{ - Data: func(output, input []byte, framecount uint32) { - if len(input) > 0 { - channels := 1 - if framecount > 0 { - channels = len(input) / int(framecount) / 2 - } - if channels < 1 { - channels = 1 - } - pcm := capRes.Convert(downmix(bytesToS16(input), channels)) - if len(pcm) > 0 { - capture(pcm) - } + + capDev, err := malgo.InitDevice(mctx.Context, capCfg, malgo.DeviceCallbacks{ + Data: func(_, input []byte, framecount uint32) { + if len(input) == 0 { + return } + channels := channelCount(len(input), framecount) + pcm := capRes.Convert(downmix(bytesToS16(input), channels)) + if len(pcm) > 0 { + capture(pcm) + } + }, + }) + if err != nil { + mctx.Free() + return err + } + + playDev, err := malgo.InitDevice(mctx.Context, playCfg, malgo.DeviceCallbacks{ + Data: func(output, _ []byte, framecount uint32) { if len(output) == 0 { return } - channels := 1 - if framecount > 0 { - channels = len(output) / int(framecount) / 2 - } - if channels < 1 { - channels = 1 - } + channels := channelCount(len(output), framecount) playBuf = append(playBuf, playRes.Convert(playback())...) need := int(framecount) var frame []int16 @@ -66,32 +70,60 @@ func Start(ctx context.Context, capture func([]int16), playback func() []int16) } writeS16(output, frame, channels) }, - } - dev, err := malgo.InitDevice(mctx.Context, cfg, callbacks) + }) if err != nil { + capDev.Uninit() mctx.Free() return err } - rate := int(dev.SampleRate()) - if rate == 0 { - rate = stationRate + + capRate := int(capDev.SampleRate()) + if capRate == 0 { + capRate = stationRate } - capRes = converter{from: rate, to: stationRate} - playRes = converter{from: stationRate, to: rate} - if err := dev.Start(); err != nil { - dev.Uninit() + playRate := int(playDev.SampleRate()) + if playRate == 0 { + playRate = stationRate + } + capRes = converter{from: capRate, to: stationRate} + playRes = converter{from: stationRate, to: playRate} + + if err := capDev.Start(); err != nil { + playDev.Uninit() + capDev.Uninit() mctx.Free() return err } + if err := playDev.Start(); err != nil { + capDev.Stop() + playDev.Uninit() + capDev.Uninit() + mctx.Free() + return err + } + go func() { <-ctx.Done() - dev.Stop() - dev.Uninit() + _ = playDev.Stop() + _ = capDev.Stop() + playDev.Uninit() + capDev.Uninit() mctx.Free() }() return nil } +func channelCount(bytes int, framecount uint32) int { + channels := 1 + if framecount > 0 { + channels = bytes / int(framecount) / 2 + } + if channels < 1 { + return 1 + } + return channels +} + func bytesToS16(b []byte) []int16 { n := len(b) / 2 out := make([]int16, n) diff --git a/config_path.go b/config_path.go new file mode 100644 index 0000000..424bdd3 --- /dev/null +++ b/config_path.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" +) + +func defaultConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".funknetz.json"), nil +} + +// resolveConfigPath picks the config file from -c / --config in args (no program name). +// The default is ~/.funknetz.json. A relative path is resolved against the process CWD. +func resolveConfigPath(args []string) (string, error) { + fs := flag.NewFlagSet("funknetz", flag.ContinueOnError) + fs.SetOutput(io.Discard) + var config string + fs.StringVar(&config, "config", "", "path to the station config file") + fs.StringVar(&config, "c", "", "path to the station config file") + if err := fs.Parse(args); err != nil { + return "", err + } + if config == "" { + path, err := defaultConfigPath() + if err != nil { + return "", fmt.Errorf("config path: %w", err) + } + return path, nil + } + abs, err := filepath.Abs(config) + if err != nil { + return "", fmt.Errorf("config path: %w", err) + } + return abs, nil +} diff --git a/config_path_test.go b/config_path_test.go new file mode 100644 index 0000000..8555dbf --- /dev/null +++ b/config_path_test.go @@ -0,0 +1,124 @@ +//go:build !bindings + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + +) + +func TestResolveConfigPathDefaultIsHome(t *testing.T) { + got, err := resolveConfigPath(nil) + if err != nil { + t.Fatal(err) + } + home, err := os.UserHomeDir() + if err != nil { + t.Fatal(err) + } + want := filepath.Join(home, ".funknetz.json") + if got != want { + t.Fatalf("resolveConfigPath(nil) = %q, want %q", got, want) + } +} + +func TestResolveConfigPathLongAndShort(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "lab.json") + + got, err := resolveConfigPath([]string{"--config", abs}) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("--config = %q, want %q", got, abs) + } + + got, err = resolveConfigPath([]string{"-c", abs}) + if err != nil { + t.Fatal(err) + } + if got != abs { + t.Fatalf("-c = %q, want %q", got, abs) + } +} + +func TestResolveConfigPathRelativeBecomesAbsolute(t *testing.T) { + dir := t.TempDir() + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + got, err := resolveConfigPath([]string{"-c", "./funknetz.json"}) + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks(filepath.Join(dir, "funknetz.json")) + if err != nil { + // File need not exist; resolve the directory and join. + base, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + want = filepath.Join(base, "funknetz.json") + } + gotEval, err := filepath.EvalSymlinks(filepath.Dir(got)) + if err != nil { + t.Fatal(err) + } + got = filepath.Join(gotEval, filepath.Base(got)) + if got != want { + t.Fatalf("relative -c = %q, want %q", got, want) + } +} + +func TestOpenStationUsesResolvedPath(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "custom.json") + + _, err := openStation([]string{"-c", path}) + if err == nil { + t.Fatal("openStation() error = nil, want rejection after creating the file") + } + if !strings.Contains(err.Error(), "created") { + t.Fatalf("openStation() error = %q, want it to say the file was created", err.Error()) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want { + t.Fatalf("mode = %o, want %o", got, want) + } + + // Same rules as the home file: a second open with a filled config works. + body := `{ + "host": "127.0.0.1", + "port": 5300, + "callsign": "DL0FN-1", + "authKey": "local-lab-secret-change-me", + "talkGroup": 9, + "enableLogging": false, + "logFile": "` + filepath.Join(dir, "funknetz.log") + `" +} +` + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + st, err := openStation([]string{"--config", path}) + if err != nil { + t.Fatal(err) + } + if st.State().Callsign != "DL0FN-1" { + t.Fatalf("callsign = %q, want DL0FN-1", st.State().Callsign) + } +} diff --git a/docs/adr/0002-home-directory-config.md b/docs/adr/0002-home-directory-config.md index 64acd8c..c896c48 100644 --- a/docs/adr/0002-home-directory-config.md +++ b/docs/adr/0002-home-directory-config.md @@ -1,3 +1,3 @@ # Hand-edited config in the home directory -The operator edits connection settings by hand. There is no settings window in version 1. The file is `~/.funknetz.json`, created mode 0600 when missing. Application Support would match other Mac apps and would be a worse place to open in an editor. That path is part of the station's contract; moving it later strands existing files. +The operator edits connection settings by hand. There is no settings window in version 1. The default file is `~/.funknetz.json`, created mode 0600 when missing. Application Support would match other Mac apps and would be a worse place to open in an editor. That default path is part of the station's contract; moving it later strands existing files. `-c` / `--config` may select another file; create, rewrite, and validate rules are the same—only the path changes. diff --git a/docs/code-review.md b/docs/code-review.md new file mode 100644 index 0000000..4fe8f01 --- /dev/null +++ b/docs/code-review.md @@ -0,0 +1,218 @@ +# Code review: funknetz + +**Date:** 2026-10-07 +**Scope:** Full review of the funknetz checkout (headset station + local SvxReflector lab). +**Fixed point:** `8e75515` (`Record the station glossary and the window and config decisions.`) +**Compared tip:** `HEAD` = `f90b615` +**Diff:** `git diff 8e75515...HEAD` (4222 insertions across 38 files) +**Spec:** [docs/issues/2026-10-03-funknetz-station/spec.md](../../docs/issues/2026-10-03-funknetz-station/spec.md) (workspace issue tracker) +**Standards:** [knowledge/engineering/go/](../../knowledge/engineering/go/), [CONTEXT.md](../CONTEXT.md), [docs/adr/](./adr/) + +Two axes below are independent. Findings are not merged or ranked across axes. + +### Commits in range (`8e75515..HEAD`) + +| Commit | Summary | +| --- | --- | +| `9c4f662` | Join one FM-Funknetz talk group from a headset window | +| `6233292` | Pause playback while transmitting and declare microphone use | +| `ec5f660` | Append an optional station log and announce the software after login | +| `eda79e6` | Add a local SvxReflector Docker lab for headset testing | +| `f90b615` | Merge branch `lab/svxlink-docker` | + +### Working tree (not in `HEAD` diff) + +At review time, uncommitted edits touched `docs/svxlink.md`, `svxlink-docker/` (Dockerfile, compose, entrypoint, conf, `.env.example`, `.gitignore`), root `.gitignore`, and an untracked `svxlink-docker/config/svxreflector.link.conf`. Protocol and lab notes in this report include that working tree where it clarifies jayReflector Satellite/Trunk; Standards and Spec axes stay on `8e75515...HEAD` only. + +### Tests run + +```sh +GOWORK=off go test ./station/ -count=1 # ok (~10s) +``` + +`main`, `audio`, and `internal/opus` have no package tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`). + +--- + +## Standards + +### Documented standards + +**Hard — `modern.md` (new JSON → `encoding/json/v2`)** +`station/software.go` marshals with v1 `encoding/json` while `station/config.go` already uses `encoding/json/v2`. Same module, new code: prefer v2. + +**Hard — `errors.md` (wrap with `%w`; don’t drop causes)** +Underlying failures are replaced with a bare string: +- `app.go` / config path resolution paths that surface `could not be read` +- `station/config.go` `loadConfig` / `parseConfig` / rewrite path: same `"could not be read"` without `%w` + +**Hard — `testing.md` (prefer `t.Fatal` over `panic`)** +`station/config_test.go` `configJSON` / `configWithLog`: `panic(err)` on marshal failure. + +**Judgement — `errors.md` / `modern.md` (`errors.Is`)** +`station/config.go`: `os.IsNotExist(err)` — prefer `errors.Is(err, fs.ErrNotExist)`. + +**Judgement — `concurrency-context.md` + `functions-methods.md` (synchronous APIs; obvious goroutine lifetime)** +`audio.Start` returns after starting a cleanup goroutine on `ctx.Done()`. `app.go` fire-and-forgets `Run` with `_ =` (comment present; discard rule satisfied). + +**Judgement — `modern.md` (`slices.Contains`)** +`station/protocol.go` `hasOPUS` hand-loops instead of `slices.Contains`. + +**OK / tooling skip** +`_ = sendUDP(...)` sites carry justifying comments (`errors.md`). `gofmt`/layout not reviewed. `App` pass-through methods are required by ADR `0001-wails-station-window.md` (overrides Middle Man). + +### Baseline smells (all judgement) + +**Divergent Change** — `station.Station` / `session.go`: connection, TX, Opus, UDP, UI state, logging in one type. + +**Data Clumps** — `onTCP` / `onServerInfo` pass `udpCh`, `udpErr`, `udpReady`, `udpRx`, `lastTCP` as pointers together. + +**Duplicated Code** — `audio/device.go` repeats channel-count logic for capture vs playback; `reflector_test.go` reimplements append/parse helpers already in `protocol.go`. + +**Mysterious Name** — `loaded`, `fileShape`, `up` / `outage` don’t say what they hold. + +**Primitive Obsession** — `State.Connection` as free strings (`"Connecting"` / `"Connected"` / …); session `reason` as `"tcp"`/`"udp"`/`"timer"`. + +**Middle Man** — `Fail` → `stop` → `endFatal` (thin hop). + +--- + +## Spec + +**Tests:** `GOWORK=off go test ./...` — `station` OK (~10s); `main` / `audio` / `internal/opus` have no tests. `TestConnectsToLabReflector` skips when `127.0.0.1:5300` is down (or `FUNKNETZ_LAB=0`). + +### (a) Missing / partial + +- **Separate default devices (partial).** Spec: “A device adapter behind the station talks to the operating system’s default input and default output” / US 52. `audio.Start` opens one `malgo.Duplex` device, not independent default input and default output—fails if duplex is unavailable even when separate devices would work. +- **Broken-JSON wording (partial).** Spec: “quit and say the file could not be read” (US 10). Implementation exits with `could not be read` (no “the file”). + +Stories 1–9, 11–50, 53–62, 64–74 and the protocol / log Implementation Decisions look covered at the station seam (config, login, TG select, OPUS, PTT, heartbeats, retries, fatalities, log). + +### (b) Scope creep + +- **Local reflector lab.** Spec Solution / US 1–74 never ask for Docker or SvxReflector; Testing Decisions: “An in-process reflector double is the stand-in for TCP and UDP.” Diff adds `svxlink-docker/`, `docs/svxlink.md`, and `station/lab_reflector_test.go` (real TCP to `:5300`). +- **Audio-open fatal.** Spec never defines device-open failure copy. `app.go` calls `Fail("could not open the audio device")`, inventing an English sentence and stopping retries outside US 57–59. + +### (c) Looks implemented, looks wrong + +- **Duplex vs defaults (same as (a)).** Spec wants default **input and** default **output**; duplex is a different contract and can reject a valid headset/speakers pair. +- No other clear behavioral mismatches found against US 1–74 / protocol / log rules at HEAD (`8e75515...HEAD`). Wails labels, callsign/TG, PTT OR of button+Space, flush-on-release, busy-room inert PTT, and log line shapes match the quoted decisions. + +--- + +## Protocol deep dive: SvxLink vs funknetz + +There is no separate RFC. The contract is SvxLink / jayReflector message types (`ReflectorMsg.h` and client logics). Funknetz targets **protocol 2.0** (ReflectorV2 / AUTH_KEY), matching FM-Funknetz’s published guidance. Background: [docs/research/2026-10-03-fm-funknetz-go-client.md](../../docs/research/2026-10-03-fm-funknetz-go-client.md), lab notes in [docs/svxlink.md](./svxlink.md). + +### 1. What funknetz implements + +**Framing.** TCP: 4-byte BE length + `uint16` type + body (`station/protocol.go`). UDP V2 header: type, client id, sequence (`station/session.go`). Strings/blobs: `uint16` length + bytes. Max TCP frame 32768. + +**TCP types handled or sent** (`protocol.go`, `session.go`, `software.go`): + +| Type | Role in funknetz | +| --- | --- | +| 5 | Send ProtoVer **2.0** first | +| 10 / 11 / 12 | HMAC-SHA1 challenge (20 B) → callsign + digest; AuthOK is a no-op | +| 13 | Fatal string → stop retries | +| 100 | Parse reserved, client id, nodes, codecs; require `"OPUS"` | +| 111 | Send NodeInfoV2 JSON `{"sw","swVer"}` only | +| 106 | Send configured TG once after 111 | +| 104 / 105 | Talker start/stop: honor only matching TG; own callsign start ignored | +| 1 | TCP heartbeat | + +Unknown TCP types are ignored. + +**UDP:** send/recv type **1** heartbeat; **101** audio (`uint16` len + Opus); **102** flush on PTT release (after padding a partial frame). Drop datagrams whose client id ≠ ours; **no** sequence check on RX. While transmitting, discard RX audio. + +**Auth / codecs / Opus.** Auth key never on the wire. Opus-only: 16 kHz mono, 320 samples / 20 ms, 20 kbit/s VBR, voice, medium-band, DTX off (`internal/opus/opus.go`). + +**Heartbeats / talker rules.** Match ReflectorV2 counters: TCP TX 10 s / RX 15 s; UDP TX 15 s / RX 60 s. Room busy from talker-start (matching TG) or any accepted RX audio; PTT inert while busy; mic never looped to playback. + +**Tests assert:** login ProtoVer 2.0, HMAC digest, NodeInfo keys, SelectTG, initial UDP HB seq 0; heartbeats, retries, MsgError / no-OPUS stop retry, TCP/UDP timeouts, TG-filtered talker, busy blocks TX, PTT + flush 102, seq wrap, wrong-client-id audio dropped. + +### 2. Parity with SvxLink ReflectorV2 + +| Capability | Upstream (ReflectorV2 / research) | funknetz | +| --- | --- | --- | +| Proto 2.0 + AUTH_KEY HMAC-SHA1 | ReflectorV2Logic | Yes | +| Framed TCP + UDP same host/port | AsyncFramedTcp / V2 UDP | Yes | +| MsgServerInfo → client id + codecs | type 100 | Yes (OPUS required) | +| MsgSelectTG | 106 | Yes | +| MsgNodeInfoV2 | 111 JSON | Yes (minimal) | +| TalkerStart/Stop with TG | 104 / 105 (≥ proto 2) | Yes | +| UDP HB / Audio / FlushSamples | 1 / 101 / 102 | Yes | +| One talker/TG; client mute when busy | Reflector + client | Client-side busy | +| Heartbeat timers 10 / 15 / 15 / 60 | ReflectorV2Logic.h | Yes | +| Opus defaults 16 kHz / 20 ms / 20 kbit | AsyncAudioEncoderOpus | Yes | + +### 3. Differences + +**Intentional (spec Out of Scope):** protocol 3 / certs / AES-GCM UDP; MsgTgMonitor / QSY / DTMF; DNS SRV / baked-in host; GSM / Speex / RAW; rich node-info; multi-TG; station list / siglev UI. + +**Thinner than ReflectorV2 client:** + +| Topic | Upstream V2 | funknetz | +| --- | --- | --- | +| MsgProtoVerDowngrade (6) | Client should retry lower ver | Ignored | +| RX UDP sequence / peer filter | Drop behind-seq; peer = TCP peer | Client-id only; connected UDP socket | +| MsgUdpAllSamplesFlushed (103) | Flush handshake | Not sent/handled | +| TalkerStop vs late audio | Audio may still arrive after 105 | Stop clears busy; **any later 101 re-sets `roomBusy`** without a named talker (`session.go` `onDatagram`) — can strand PTT | +| Pre-auth size cap | 64 B | Always up to 32768 | +| MONITOR_TGS / StateEvent / Signal / TxStatus | Optional client telemetry | Absent | +| Codecs | First mutually supported | Hard-fail if no OPUS | +| jayReflector type 121 trunk node list | Extra | Ignored (OK) | + +### 4. Currently missing + +**(A) Intentionally out of scope for v1 headset** — Proto 3 / encryption; MsgTgMonitor; MsgRequestQsy; NodeJoined / Left UI; StateEvent / SignalStrength / TxStatus; DNS SRV; non-OPUS codecs; rich NODE_INFO; multi-TG. + +**(B) Gaps that can still affect ReflectorV2 / FM-Funknetz / jayReflector headset use** + +1. **Late audio after TalkerStop** re-busying the room without a named talker (upstream documents post-stop audio). +2. **No ProtoVerDowngrade** handling (unlikely on FM’s V2 servers; breaks if a server asks to step down). +3. **No UDP flush / all-flushed** — usually fine for app clients; edge cases if a peer expects 103. +4. **No SRV** — operators must set host/port (FM docs often use 5308 + `DNS_DOMAIN` on nodes). +5. **No MsgTgMonitor** — idle monitoring of other TGs unavailable; single SelectTG is enough for one configured TG. +6. **Stricter RX sequence** not implemented — more permissive than V2; rarely blocks interop. + +Core login + SelectTG + Opus UDP path is aligned with FM-Funknetz’s ReflectorV2 guidance. + +### 5. Lab stack + +| Stack | Role | +| --- | --- | +| **funknetz `svxlink-docker`** | Builds **jayReflector** → `svxreflector`; TCP+UDP **5300**; `[USERS]` / `[PASSWORDS]` for proto 2; optional **Satellite** (outbound **5303**) / **Trunk** (**5302**); clients stay on local `:5300` | +| **Stock sm0svx SvxReflector** | Same client wire protocol; **no** reflector peering | +| **jayReflector Satellite / Trunk** | Server-side mesh / uplink; Funknetz does **not** speak Trunk / Satellite | + +Headset interop testing needs a ReflectorV2-capable `svxreflector` with OPUS and AUTH_KEY users. Satellite / Trunk only matter when exercising FM uplink, not the client protocol itself. + +### Message coverage (quick map) + +| Direction | Implemented | Not implemented (by design unless noted) | +| --- | --- | --- | +| TCP send | 5, 11, 1, 111, 106 | 14–21 (proto 3), 107 monitor, 109 QSY, 110–113 telemetry | +| TCP recv | 1, 10, 12, 13, 100, 104, 105 | 6 downgrade (**gap**), 101 NodeList (legacy), 102/103 presence, 114 UDP encrypt | +| UDP send | 1, 101, 102 | 103 all-flushed, 104 signal | +| UDP recv | 1 (timer), 101 | sequence validation, 102/103 handshake | + +--- + +## Summary + +**Standards:** 3 hard findings (json/v2 in `software.go`; bare `could not be read` dropping causes; `panic` in test helpers); several judgement smells — worst hard issue is inconsistent JSON API / dropped error causes on config read. +**Spec:** 2 partial gaps (duplex vs separate default devices; broken-JSON wording) + 2 scope-creep items (Docker lab; audio-open fatal copy) — worst issue is duplex audio vs the specified separate default input/output devices. + +### Follow-up (2026-10-07) + +Hard Standards and Spec gaps addressed in code where possible: + +| Finding | Status | +| --- | --- | +| `software.go` → `encoding/json/v2` | Fixed | +| Config / path errors wrap with `%w`; US 10 wording `the file could not be read` | Fixed | +| Test helpers use `t.Fatal` instead of `panic` | Fixed | +| Separate default Capture + Playback devices (not Duplex) | Fixed | +| Docker lab scope creep | Left in place — intentional engineering aid, not a product story | +| Audio-open `Fail("could not open the audio device")` | Left — spec has no copy; stopping without audio is still required UX | diff --git a/docs/svxlink.md b/docs/svxlink.md index d5e9769..f06c4c2 100644 --- a/docs/svxlink.md +++ b/docs/svxlink.md @@ -1,29 +1,42 @@ # SvxLink lab for Funknetz -Funknetz must be proven against a local SvxReflector before it touches the live network. This document is that lab: how to run the reflector, what it will accept from a station, and what the wire protocol actually does—so a headset client can be tested without FM-Funknetz credentials. +Funknetz must be proven against a local reflector before it touches the live network. This document is that lab: how to run the reflector, what it will accept from a station, and what the wire protocol actually does—so a headset client can be tested without FM-Funknetz credentials. -Upstream SvxLink is two processes with different jobs. `svxlink` is a node: logics, optional radio hardware, modules. `svxreflector` is the conference: stations connect over IP, and it forwards audio among those on the same talk group—one talker at a time. Funknetz is a station on that conference; the lab runs only `svxreflector`. You do not need a full RF node, LocalRx/LocalTx, or EchoLink to exercise the headset client. +The lab runs **[jayReflector](https://github.com/dj1jay/jayReflector)** (DJ1JAY / FM-Funknetz), a Trunk/Satellite-capable fork of SvxReflector. The process is still named `svxreflector`. You do **not** need the `svxlink` node binary, LocalRx/LocalTx, or EchoLink to exercise the headset client. + +Funknetz is a station on the conference: it connects to the local reflector on TCP+UDP. Optional FM-Funknetz uplink is Satellite (outbound) or Trunk — configured separately when DJ1JAY provides parameters. ## Running a local reflector -Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds upstream SvxLink (community Docker pattern from [f4hlv/svxlink-docker](https://github.com/f4hlv/svxlink-docker)), mounts a minimal `svxreflector.conf`, publishes TCP **and** UDP on the listen port, and runs only `svxreflector`. There is no first-party `sm0svx` image on Docker Hub. +Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds jayReflector, mounts a minimal `svxreflector.conf`, publishes TCP **and** UDP on the listen port, and runs only `svxreflector`. ```sh cd svxlink-docker +cp -n .env.example .env # if needed docker compose up -d --build ``` | Item | Lab value | | --- | --- | -| Listen port | **5300** TCP **and** UDP (upstream default; FM-Funknetz production uses 5308) | -| Config | `svxlink-docker/config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` | +| Binary | jayReflector → `/usr/bin/svxreflector` | +| Listen port | **5300** TCP **and** UDP (local clients; FM-Funknetz production client port is often 5308) | +| Config | `config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` | | Auth (protocol 2.0) | `[USERS]` → `[PASSWORDS]`; station `authKey` matches that secret | -| Server cert | `[SERVER_CERT] COMMON_NAME=…` required on first start (current upstream bootstraps a local PKI even for AUTH_KEY clients) | -| Protocol 3 PKI | Material under `/var/lib/svxlink/pki` (compose volume `data/`) | +| Server cert | `[SERVER_CERT] COMMON_NAME=…` required on first start (PKI under `data/pki` → `/etc/svxlink/pki`) | | Codec | Default **OPUS** when compiled in (`CODECS` optional) | | Go smoke test | `GOWORK=off go test ./station/ -run TestConnectsToLabReflector -v` (needs libopus; skips if port 5300 is down) | -Point `~/.funknetz.json` at the lab (values match `.env` / the mounted conf; secrets are local-test placeholders): +### Lab app start + +With the reflector already up, from the funknetz repo root: + +```sh +./teststart.sh +``` + +That script requires `svxlink-docker/.env` (it will not copy `.env.example`), checks `FUNKNETZ_*` against `config/svxreflector.conf`, writes `svxlink-docker/.funknetz.generated.json` (gitignored), enables logging to `svxlink-docker/logs/funknetz-lab.log`, builds `funknetz.app` if missing or stale, and launches the binary with `-c` pointing at the generated file. The default operator config remains `~/.funknetz.json`; `-c` / `--config` only select another path (same create/rewrite rules). See [ADR 0002](./adr/0002-home-directory-config.md). + +Manual lab JSON (values match `.env` / the mounted conf; secrets are local-test placeholders): ```json { @@ -39,6 +52,39 @@ Point `~/.funknetz.json` at the lab (values match `.env` / the mounted conf; sec A second station can use callsign `DL0FN-2` with the same auth key. Do not reuse production FM-Funknetz credentials in the lab. +## Optional FM-Funknetz link (Satellite / Trunk) + +Default: `ENABLE_FM_LINK=0` — local island only. No network uplink. + +When DJ1JAY provides parameters: + +1. Edit [`config/svxreflector.link.conf`](../svxlink-docker/config/svxreflector.link.conf) (prefer **Satellite** for home labs). +2. Set `ENABLE_FM_LINK=1` in `.env`. +3. `docker compose up -d` (rebuild only if the image changed). + +The entrypoint appends the link file after the base conf. Local Funknetz settings (`FUNKNETZ_*`, port 5300) stay the same. + +### Satellite (recommended): outbound only + +With Satellite, **your container dials out** to the parent on TCP **5303**. The network does **not** open a connection toward you. You do **not** need public DNS, a static IP, or inbound port-forwarding for the uplink. Only your **local** clients need to reach host `:5300` (typically localhost). + +```text +Funknetz ──:5300──► jayReflector (lab) ──outbound :5303──► FM-Funknetz parent +``` + +Fill from DJ1JAY (jayReflector README shape): + +- `SATELLITE_OF=parent.example.org:5303` +- `SATELLITE_ID=…` +- `SATELLITE_SECRET=…` +- optional `SATELLITE_FILTER=…` for “only some TGs” + +### Trunk (only if agreed) + +Equal mesh peer: mutual TCP **5302**, matching `[TRUNK_x]` section name and `SECRET`, plus filters (`ALLOW_TGS` / `TG_MAP`). That **does** require the peer to reach your trunk port — not the default lab layout. Prefer Satellite unless DJ1JAY asks for Trunk. + +Research baseline: [docs/research/2026-10-07-jayreflector-fm-funknetz.md](../../docs/research/2026-10-07-jayreflector-fm-funknetz.md). + ## What the reflector admits The binary does not know “Funknetz” or “HamLink.” It admits a station by protocol version, callsign shape, proof of the auth key (or a certificate on protocol 3), and uniqueness of that callsign on the session. App names are never checked. FM-Funknetz’s tickets, issued keys, and the published rule to use ReflectorV2 are **network policy** layered on top of the same knobs—not a separate allowlist inside `svxreflector`. @@ -53,11 +99,11 @@ The binary does not know “Funknetz” or “HamLink.” It admits a station by | Talk group | Optional per-TG `ALLOW` / `ALLOW_MONITOR` regexes after login. | | Software / version string | **None.** Optional node-info JSON is for status only. | -For the lab and for production as FM-Funknetz documents it, the station speaks **protocol 2.0** (ReflectorV2): auth key, not certificates. Upstream’s man page treats AUTH_KEY as deprecated for “modern” reflectors; that is a different target than the live network’s written instruction. +For the lab and for production as FM-Funknetz documents it, the station speaks **protocol 2.0** (ReflectorV2): auth key, not certificates. ## Wire protocol -There is no separate RFC. The contract is the message types in upstream SvxLink (`ReflectorMsg.h` and the client logics). Control rides **TCP** as length-prefixed frames (4-byte big-endian length, then payload). Audio and UDP keepalives ride **UDP** to the same host and port. Message types are numeric. Protocol **2.0** authenticates with the auth-key HMAC and sends plaintext UDP with a header of type, client id, and sequence. Protocol **3.0** uses X.509/TLS and encrypts UDP (AES-128-GCM). Funknetz targets 2.0. +There is no separate RFC. The contract is the message types in SvxLink / jayReflector (`ReflectorMsg.h` and the client logics). Control rides **TCP** as length-prefixed frames (4-byte big-endian length, then payload). Audio and UDP keepalives ride **UDP** to the same host and port. Message types are numeric. Protocol **2.0** authenticates with the auth-key HMAC and sends plaintext UDP with a header of type, client id, and sequence. Protocol **3.0** uses X.509/TLS and encrypts UDP (AES-128-GCM). Funknetz targets 2.0. Typical protocol 2.0 login: @@ -104,18 +150,12 @@ User messages (type ≥ 100) matter only after the session is connected. Unknown The codec name is negotiated in `MsgServerInfo`, not per audio packet. When Opus is available and `CODECS` is unset, the reflector prefers **OPUS**. -## Linking and talk-group sharing +## Reflector linking (lab context) -`svxreflector` interconnects **stations** on talk groups. It has no peer-reflector configuration: two reflector processes do not mesh. Upstream has discussed multi-reflector interconnect (GitHub issue #597); it is not shipped in the tree this lab tracks. FM-Funknetz states the same limit and attaches other networks by **talk-group sharing / bridge**—audio continuity on a designated talk-group number, implemented by a **client** present on both sides (typically an extra `svxlink` instance on the server), not by reflector peering. - -A Funknetz station only **consumes** a shared talk group (select or monitor it). Creating a bridge on the live network needs coordination with the FM-Funknetz team (loop risk). Named cross-network talk groups in their prose include **2621** (Berlin), **2629** (Sachsen), and possibly **2624**; app ingress paths such as Zello, FRN, and Teamspeak often default to **262**. - -| Concept | What it actually is | +| Concept | What it is in this lab | | --- | --- | -| Reflector peering | **Unsupported.** Not how you join two Verbünde. | -| `LINKS=` / ReflectorLink | Audio link between logics **inside one** `svxlink` process (e.g. radio ↔ ReflectorLogic). Not two `svxreflector` servers. | -| `MONITOR_TGS` / select TG | Watching or joining a talk group **on one** reflector. Uses a bridge TG if operators already bridge it; does not create the path. | -| EchoLink / Zello / FRN / Teamspeak | Alternate **ingress** into FM-Funknetz. Different protocols; EchoLink cannot switch talk groups. | -| Same reflector, same TG, two stations | Normal conference behavior—not a bridge test. | - -To **simulate** sharing locally: run two `svxreflector` instances on distinct ports and users, plus one bridging `svxlink` (two ReflectorLogic sections or two processes) linked so audio crosses on chosen talk groups. Prove with a third station on each side. Exact dual-ReflectorLogic INIs for FM-Funknetz’s production bridge are not published; compose the lab from upstream logic-linking docs. +| Stock `sm0svx` peering | **Unsupported** — not what we build. | +| jayReflector **Satellite** | Lab dials parent `:5303`; optional `SATELLITE_FILTER` for some TGs. Outbound only. | +| jayReflector **Trunk** | Mesh peer on `:5302` with filters / `TG_MAP`. Needs mutual reachability. | +| `svxlink` dual ReflectorLogic bridge | Not required here; Satellite/Trunk replace that for FM-Funknetz attach. | +| Same reflector, same TG, two stations | Normal conference — local test without any uplink. | diff --git a/main.go b/main.go index 0532571..a122f98 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ import ( var assets embed.FS func main() { - st, err := openStation() + st, err := openStation(os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, err.Error()) os.Exit(1) diff --git a/station/behavior_test.go b/station/behavior_test.go index 827846c..0c9e6f2 100644 --- a/station/behavior_test.go +++ b/station/behavior_test.go @@ -31,9 +31,9 @@ func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []strin func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []string, logPath string) loggedIn { t.Helper() refl := newReflector(t) - file := configJSON("127.0.0.1", refl.port, callsign, key, tg) + file := configJSON(t, "127.0.0.1", refl.port, callsign, key, tg) if logPath != "" { - file = configWithLog("127.0.0.1", refl.port, callsign, key, tg, logPath) + file = configWithLog(t, "127.0.0.1", refl.port, callsign, key, tg, logPath) } path := writeConfigFile(t, file) clock := newFakeClock() @@ -151,7 +151,7 @@ func TestRefusedConnectionRetriesUntilQuit(t *testing.T) { port := ln.Addr().(*net.TCPAddr).Port ln.Close() - path := writeConfigFile(t, configJSON("127.0.0.1", port, "DL1ABC", "key", 4)) + path := writeConfigFile(t, configJSON(t, "127.0.0.1", port, "DL1ABC", "key", 4)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { @@ -196,7 +196,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) { for _, msg := range []string{"Access denied", "no such station"} { t.Run(msg, func(t *testing.T) { refl := newReflector(t) - path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20)) + path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { @@ -230,7 +230,7 @@ func TestReflectorErrorStopsRetries(t *testing.T) { func TestMissingOpusStopsRetries(t *testing.T) { refl := newReflector(t) - path := writeConfigFile(t, configJSON("127.0.0.1", refl.port, "DL1ABC", "key", 20)) + path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { diff --git a/station/config.go b/station/config.go index 21bc583..d6c8bd3 100644 --- a/station/config.go +++ b/station/config.go @@ -1,7 +1,9 @@ package station import ( + "errors" "fmt" + "io/fs" "math" "os" "path/filepath" @@ -48,13 +50,13 @@ var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z func loadConfig(path string) (loaded, error) { body, err := os.ReadFile(path) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { if err := writeTemplate(path); err != nil { return loaded{}, err } return loaded{}, fmt.Errorf("created %s", path) } - return loaded{}, fmt.Errorf("could not be read") + return loaded{}, fmt.Errorf("the file could not be read: %w", err) } return parseConfig(path, body) } @@ -83,7 +85,7 @@ func writeTemplate(path string) error { func parseConfig(path string, body []byte) (loaded, error) { var shape fileShape if err := jsonv2.Unmarshal(body, &shape); err != nil { - return loaded{}, fmt.Errorf("could not be read") + return loaded{}, fmt.Errorf("the file could not be read: %w", err) } if block, notice := blockedLog(shape); block { cfg, err := parseOperational(shape) @@ -99,7 +101,7 @@ func parseConfig(path string, body []byte) (loaded, error) { } shape = withLogDefaults(shape) if err := rewriteConfig(path, shape); err != nil { - return loaded{}, fmt.Errorf("could not be read") + return loaded{}, fmt.Errorf("the file could not be read: %w", err) } } cfg, err := parseOperational(shape) diff --git a/station/config_test.go b/station/config_test.go index bca58f2..f40134c 100644 --- a/station/config_test.go +++ b/station/config_test.go @@ -84,32 +84,41 @@ func TestExistingConfigIsNotRewritten(t *testing.T) { func TestInvalidConfigNamesTheField(t *testing.T) { tests := []struct { - name string - body string - want string + name string + host string + port int + callsign string + authKey string + talkGroup int + body string + want string }{ - {name: "empty host", body: configJSON("", 5308, "DL1ABC", "key", 1), want: "host is empty"}, - {name: "blank host", body: configJSON(" ", 5308, "DL1ABC", "key", 1), want: "host is empty"}, + {name: "empty host", host: "", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"}, + {name: "blank host", host: " ", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"}, {name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"}, - {name: "empty callsign", body: configJSON("127.0.0.1", 5308, "", "key", 1), want: "callsign is empty"}, + {name: "empty callsign", host: "127.0.0.1", port: 5308, callsign: "", authKey: "key", talkGroup: 1, want: "callsign is empty"}, {name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"}, - {name: "empty auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", "", 1), want: "authKey is empty"}, - {name: "whitespace auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", " \t", 1), want: "authKey is empty"}, + {name: "empty auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "", talkGroup: 1, want: "authKey is empty"}, + {name: "whitespace auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: " \t", talkGroup: 1, want: "authKey is empty"}, {name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"}, - {name: "port zero", body: configJSON("127.0.0.1", 0, "DL1ABC", "key", 1), want: "port is invalid"}, - {name: "port negative", body: configJSON("127.0.0.1", -1, "DL1ABC", "key", 1), want: "port is invalid"}, - {name: "port too high", body: configJSON("127.0.0.1", 65536, "DL1ABC", "key", 1), want: "port is invalid"}, + {name: "port zero", host: "127.0.0.1", port: 0, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, + {name: "port negative", host: "127.0.0.1", port: -1, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, + {name: "port too high", host: "127.0.0.1", port: 65536, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"}, {name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"}, {name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"}, - {name: "talk group zero", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", 0), want: "talkGroup is invalid"}, - {name: "talk group negative", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", -3), want: "talkGroup is invalid"}, + {name: "talk group zero", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 0, want: "talkGroup is invalid"}, + {name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"}, {name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"}, - {name: "bad callsign", body: configJSON("127.0.0.1", 5308, "HELLO", "key", 1), want: "callsign is invalid"}, + {name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + body := tt.body + if body == "" { + body = configJSON(t, tt.host, tt.port, tt.callsign, tt.authKey, tt.talkGroup) + } path := filepath.Join(t.TempDir(), "funknetz.json") - if err := os.WriteFile(path, []byte(tt.body), 0o600); err != nil { + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } before, err := os.ReadFile(path) @@ -156,8 +165,8 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) { if err == nil { t.Fatal("New() error = nil, want a read failure") } - if err.Error() != "could not be read" { - t.Fatalf("New() error = %q, want %q", err.Error(), "could not be read") + if !strings.Contains(err.Error(), "the file could not be read") { + t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error()) } if strings.Contains(err.Error(), "callsign") { t.Fatalf("New() error = %q, want no field name", err.Error()) @@ -166,7 +175,7 @@ func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) { func TestCallsignIsTrimmedAndUppercased(t *testing.T) { path := filepath.Join(t.TempDir(), "funknetz.json") - body := configJSON(" 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20) + body := configJSON(t, " 127.0.0.1 ", 5308, " dl1abc-r ", "a b", 20) if err := os.WriteFile(path, []byte(body), 0o600); err != nil { t.Fatal(err) } @@ -359,7 +368,8 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) { }) } -func configJSON(host string, port int, callsign, authKey string, talkGroup int) string { +func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string { + t.Helper() body, err := jsonv2.Marshal(map[string]any{ "host": host, "port": port, @@ -368,12 +378,13 @@ func configJSON(host string, port int, callsign, authKey string, talkGroup int) "talkGroup": talkGroup, }) if err != nil { - panic(err) + t.Fatal(err) } return string(body) } -func configWithLog(host string, port int, callsign, authKey string, talkGroup int, logPath string) string { +func configWithLog(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, logPath string) string { + t.Helper() body, err := jsonv2.Marshal(map[string]any{ "host": host, "port": port, @@ -384,7 +395,7 @@ func configWithLog(host string, port int, callsign, authKey string, talkGroup in "logFile": logPath, }) if err != nil { - panic(err) + t.Fatal(err) } return string(body) } diff --git a/station/lab_reflector_test.go b/station/lab_reflector_test.go index 8e5f1e3..d7f9f1a 100644 --- a/station/lab_reflector_test.go +++ b/station/lab_reflector_test.go @@ -31,7 +31,7 @@ func TestConnectsToLabReflector(t *testing.T) { } conn.Close() - path := writeConfigFile(t, configJSON(labHost, labPort, labCallsign, labAuthKey, labTalkGroup)) + path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup)) st, err := station.New(station.Options{ConfigPath: path}) if err != nil { t.Fatal(err) diff --git a/station/log_test.go b/station/log_test.go index d705908..a34f670 100644 --- a/station/log_test.go +++ b/station/log_test.go @@ -123,7 +123,7 @@ func TestLogFailureStaysInTheIdleSlot(t *testing.T) { func TestFirstDialFailureIsLoggedOnce(t *testing.T) { logPath := filepath.Join(t.TempDir(), "funknetz.log") ln := listenClosed(t) - path := writeConfigFile(t, configWithLog("127.0.0.1", ln, "DL1ABC", "key", 4, logPath)) + path := writeConfigFile(t, configWithLog(t, "127.0.0.1", ln, "DL1ABC", "key", 4, logPath)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { @@ -152,7 +152,7 @@ func TestFirstDialFailureIsLoggedOnce(t *testing.T) { func TestReflectorErrorsAreOneLogLine(t *testing.T) { logPath := filepath.Join(t.TempDir(), "funknetz.log") refl := newReflector(t) - path := writeConfigFile(t, configWithLog("127.0.0.1", refl.port, "DL1ABC", "key", 20, logPath)) + path := writeConfigFile(t, configWithLog(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, logPath)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { diff --git a/station/session_test.go b/station/session_test.go index 6a384c9..1c7d855 100644 --- a/station/session_test.go +++ b/station/session_test.go @@ -13,7 +13,7 @@ import ( func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) { refl := newReflector(t) - path := writeConfigFile(t, configJSON(" 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20)) + path := writeConfigFile(t, configJSON(t, " 127.0.0.1 ", refl.port, " dl1abc ", "auth key with spaces", 20)) clock := newFakeClock() st, err := station.New(station.Options{ConfigPath: path, Clock: clock}) if err != nil { diff --git a/station/software.go b/station/software.go index e39b992..d772f87 100644 --- a/station/software.go +++ b/station/software.go @@ -1,6 +1,6 @@ package station -import "encoding/json" +import jsonv2 "encoding/json/v2" // SoftwareName and SoftwareVersion are the identity sent to the reflector // after login. Edit them here. @@ -10,7 +10,7 @@ const ( ) func softwareIdentityBody() ([]byte, error) { - raw, err := json.Marshal(struct { + raw, err := jsonv2.Marshal(struct { SW string `json:"sw"` SWVer string `json:"swVer"` }{ diff --git a/svxlink-docker/.env.example b/svxlink-docker/.env.example index 1d590c2..97aa552 100644 --- a/svxlink-docker/.env.example +++ b/svxlink-docker/.env.example @@ -4,15 +4,16 @@ TZ=Europe/Berlin LISTEN_PORT=5300 -START_SVXLINK=0 -START_REMOTETRX=0 -START_SVXREFLECTOR=1 +# 0 = local lab only (default). 1 = append config/svxreflector.link.conf +# (Satellite/Trunk from DJ1JAY). Satellite dials out — no public DNS needed. +ENABLE_FM_LINK=0 SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf +SVXREFLECTOR_LINK_CONF=/etc/svxlink/svxreflector.link.conf SVXREFLECTOR_ARGS=--logfile=/var/log/svxlink/svxreflector.log FUNKNETZ_HOST=127.0.0.1 FUNKNETZ_PORT=5300 FUNKNETZ_CALLSIGN=DL0FN-1 FUNKNETZ_AUTH_KEY=local-lab-secret-change-me -FUNKNETZ_TALK_GROUP=9 +FUNKNETZ_TALK_GROUP=20 diff --git a/svxlink-docker/.gitignore b/svxlink-docker/.gitignore index e4d800c..7d0a3f9 100644 --- a/svxlink-docker/.gitignore +++ b/svxlink-docker/.gitignore @@ -4,3 +4,7 @@ data/* !data/.gitkeep .env *.log +.funknetz.generated.json +# Optional: keep a private link file with real DJ1JAY secrets untracked. +# The committed config/svxreflector.link.conf is a commented stub only. +config/svxreflector.link.local.conf diff --git a/svxlink-docker/Dockerfile b/svxlink-docker/Dockerfile index ea6f25f..a99083b 100644 --- a/svxlink-docker/Dockerfile +++ b/svxlink-docker/Dockerfile @@ -1,77 +1,72 @@ -# Build and run SvxReflector for the Funknetz local lab. -# Provenance: patterned on f4hlv/svxlink-docker (community); builds from upstream sm0svx/svxlink. +# Build jayReflector (DJ1JAY / FM-Funknetz) for the Funknetz local lab. +# Binary name remains svxreflector; Trunk/Satellite are opt-in via config. +# Upstream pattern: dj1jay/jayReflector Dockerfile (clone + cmake, not stock sm0svx). # ========================================================= # STAGE 1 — BUILD # ========================================================= -FROM debian:trixie-slim AS builder +FROM debian:bookworm AS builder ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get -y install --no-install-recommends \ git ca-certificates \ - cmake g++ make dpkg-dev \ - libsigc++-2.0-dev libgsm1-dev libpopt-dev tcl8.6-dev \ - libgcrypt20-dev libspeex-dev libasound2-dev \ - libopus-dev librtlsdr-dev libjsoncpp-dev \ - libcurl4-openssl-dev libgpiod-dev libogg-dev \ - ladspa-sdk libssl-dev \ + build-essential cmake \ + libsigc++-2.0-dev libssl-dev libjsoncpp-dev libpopt-dev \ + libopus-dev libgsm1-dev \ && rm -rf /var/lib/apt/lists/* -ARG GIT_URL=https://github.com/sm0svx/svxlink.git -ARG GIT_BRANCH=master +ARG GIT_URL=https://github.com/dj1jay/jayReflector.git +ARG GIT_BRANCH=main +ARG GIT_REF= ARG NUM_CORES=4 WORKDIR /build -RUN git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" svxlink -WORKDIR /build/svxlink - RUN set -eux; \ - SVX_VER="$(awk -F= '$1=="SVXSERVER"{gsub(/[[:space:]]/,"",$2); print $2}' src/versions)"; \ - echo "SVXSERVER version from src/versions: ${SVX_VER}"; \ - rm -rf build && mkdir -p build && cd build; \ - cmake \ - -DCMAKE_INSTALL_PREFIX=/usr \ - -DCMAKE_INSTALL_SYSCONFDIR=/etc \ - -DCMAKE_INSTALL_LOCALSTATEDIR=/var \ + if [ -n "${GIT_REF}" ]; then \ + git clone --recursive "${GIT_URL}" jayReflector; \ + cd jayReflector && git checkout "${GIT_REF}"; \ + else \ + git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" jayReflector; \ + fi + +WORKDIR /build/jayReflector +RUN set -eux; \ + cmake -S src -B build \ + -DLOCAL_STATE_DIR=/var \ -DUSE_QT=OFF \ - -DCMAKE_BUILD_TYPE=Release \ - -DCPACK_GENERATOR=DEB \ - -DCPACK_PACKAGE_VERSION="${SVX_VER}" \ - -DCPACK_DEBIAN_PACKAGE_VERSION="${SVX_VER}" \ - ../src; \ - make -j"${NUM_CORES}"; \ - make package; \ - ls -lh *.deb + -DUSE_ALSA=OFF \ + -DCMAKE_BUILD_TYPE=Release; \ + cmake --build build --target svxreflector -j"${NUM_CORES}" # ========================================================= # STAGE 2 — RUNTIME # ========================================================= -FROM debian:trixie-slim +FROM debian:bookworm-slim ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get -y install --no-install-recommends \ - adduser init-system-helpers ca-certificates openssl \ - libasyncaudio1.6t64 libasynccore1.6t64 libasynccpp1.6t64 libcurl4t64 \ - libgcrypt20 libgsm1 libjsoncpp26 libpopt0 librtlsdr0 \ - libsigc++-2.0-0v5 libtcl8.6 libgcc-s1 libstdc++6 \ - libasound2 libgpiod3 libogg0 libopus0 libspeex1 \ + ca-certificates openssl \ + libsigc++-2.0-0v5 libssl3 libjsoncpp25 libpopt0 \ + libopus0 libgsm1 \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /build/svxlink/build/*.deb /tmp/svxlink.deb +COPY --from=builder /build/jayReflector/build/bin/svxreflector /usr/bin/svxreflector +COPY --from=builder /build/jayReflector/build/lib/lib*.so* /usr/lib/ -RUN apt-get update && \ - apt-get -y install --no-install-recommends /tmp/svxlink.deb && \ - rm -f /tmp/svxlink.deb && \ - rm -rf /var/lib/apt/lists/* - -RUN mkdir -p /etc/svxlink /var/log/svxlink /var/lib/svxlink && \ +RUN mkdir -p /etc/svxlink /etc/svxlink/pki /var/log/svxlink /var/lib/svxlink && \ + ldconfig && \ useradd -r -u 1000 -m -d /home/svx svx || true -ENV SVXLINK_CONF=/etc/svxlink/svxlink.conf \ - REMOTETRX_CONF=/etc/svxlink/remotetrx.conf \ - SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf +ENV SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf \ + SVXREFLECTOR_LINK_CONF=/etc/svxlink/svxreflector.link.conf \ + ENABLE_FM_LINK=0 COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh +# 5300: local Funknetz / SvxLink clients (TCP+UDP) +# Satellite mode dials out to parent :5303 — no inbound peer port required. +# Trunk mode would need 5302/tcp inbound; not published by default in compose. +EXPOSE 5300/tcp 5300/udp + ENTRYPOINT ["/entrypoint.sh"] diff --git a/svxlink-docker/config/svxreflector.conf b/svxlink-docker/config/svxreflector.conf index b4e4832..5b8863c 100644 --- a/svxlink-docker/config/svxreflector.conf +++ b/svxlink-docker/config/svxreflector.conf @@ -1,6 +1,10 @@ ################################################################### -# Local lab SvxReflector for Funknetz (protocol 2.0 / ReflectorV2) +# Local lab jayReflector for Funknetz (protocol 2.0 / ReflectorV2) # Secrets here are placeholders — do not reuse production keys. +# +# Standalone by default: local clients only. FM-Funknetz Satellite/Trunk +# keys live in svxreflector.link.conf and are appended when +# ENABLE_FM_LINK=1 (see .env and docker-compose). ################################################################### [GLOBAL] @@ -8,10 +12,13 @@ TIMESTAMP_FORMAT="%c" LISTEN_PORT=5300 TG_FOR_V1_CLIENTS=999 # CODECS=OPUS -COMMAND_PTY=/dev/shm/reflector_ctrl +COMMAND_PTY=/dev/shm/svxreflector_ctrl +CERT_PKI_DIR=/etc/svxlink/pki/ +# Optional status JSON (publish 8080 in compose if you enable this): +# HTTP_SRV_PORT=8080 -# Newer SvxReflector bootstraps a local PKI even for protocol 2.0 AUTH_KEY clients. -# COMMON_NAME is required so the server certificate can be generated on first start. +# jayReflector / modern SvxReflector bootstraps a local PKI even for +# protocol 2.0 AUTH_KEY clients. COMMON_NAME is required on first start. [SERVER_CERT] COMMON_NAME=funknetz-lab.local SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1 diff --git a/svxlink-docker/config/svxreflector.link.conf b/svxlink-docker/config/svxreflector.link.conf new file mode 100644 index 0000000..c6a5844 --- /dev/null +++ b/svxlink-docker/config/svxreflector.link.conf @@ -0,0 +1,41 @@ +################################################################### +# Optional FM-Funknetz link stub (jayReflector) +# +# Default: leave ENABLE_FM_LINK=0 in .env — this file is ignored. +# When DJ1JAY provides values: +# 1. Fill ONE of the blocks below (prefer Satellite for home/NAT labs). +# 2. Set ENABLE_FM_LINK=1 in .env +# 3. docker compose up -d +# +# Satellite (recommended): this reflector dials OUT to the parent on +# TCP 5303. No public DNS, no inbound peer port, no port-forward required +# for the link. Local Funknetz clients still use host :5300 only. +# +# Trunk: equal mesh peer; needs mutual reachability on TCP 5302 and a +# matching [TRUNK_x] section name + SECRET on both sides. +# +# Do not commit real secrets. Prefer a local override or keep secrets +# only in your private .env workflow if you copy this file. +# Docs: docs/research/2026-10-07-jayreflector-fm-funknetz.md +# README: https://github.com/dj1jay/jayReflector +################################################################### + +# --- Satellite (fill from DJ1JAY; uncomment to use) --- +# SATELLITE_OF=parent.example.org:5303 +# SATELLITE_ID=lab-yourcall +# SATELLITE_SECRET="replace-with-secret-from-dj1jay" +# Only these TGs cross to/from the parent (bidirectional). Omit for all TGs. +# SATELLITE_FILTER=2621,2629,20 + +# --- Trunk peer alternative (fill from DJ1JAY; usually leave commented) --- +# LOCAL_PREFIX=9 +# TRUNK_LISTEN_PORT=5302 +# +# [TRUNK_LAB_FM] +# HOST=fm-parent.example.org +# PORT=5302 +# SECRET="replace-with-agreed-trunk-secret" +# REMOTE_PREFIX=262 +# ALLOW_TGS=2621,2629,20 +# # TG_MAP=1:2624100 +# MQTT_NAME=Lab-YourCall diff --git a/svxlink-docker/docker-compose.yml b/svxlink-docker/docker-compose.yml index 02da971..f5ce23c 100644 --- a/svxlink-docker/docker-compose.yml +++ b/svxlink-docker/docker-compose.yml @@ -1,6 +1,9 @@ -# Local SvxReflector lab for Funknetz. -# Build once, then: docker compose up -d +# Local jayReflector lab for Funknetz. +# Build once, then: docker compose up -d --build # Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*). +# +# FM-Funknetz link (optional): fill config/svxreflector.link.conf from DJ1JAY, +# set ENABLE_FM_LINK=1, recreate. Satellite mode is outbound-only (no DNS/inbound). services: svxreflector: @@ -8,9 +11,11 @@ services: context: . dockerfile: Dockerfile args: - GIT_URL: https://github.com/sm0svx/svxlink.git - GIT_BRANCH: master - image: funknetz-svxreflector:local + GIT_URL: https://github.com/dj1jay/jayReflector.git + GIT_BRANCH: main + # Optional pin (overrides branch clone when set), e.g.: + # GIT_REF: 50997ed21a37c7c05c132eca5ebb07bab4b80fc7 + image: funknetz-jayreflector:local container_name: funknetz-svxreflector restart: unless-stopped env_file: @@ -18,16 +23,21 @@ services: ports: # Host LISTEN_PORT → container 5300 (must match config/svxreflector.conf). # Control is TCP; audio and UDP keepalives use the same number. + # Satellite uplink uses outbound TCP 5303 — not published here. - "${LISTEN_PORT:-5300}:5300/tcp" - "${LISTEN_PORT:-5300}:5300/udp" + # Optional HTTP /status when HTTP_SRV_PORT=8080 is set in conf: + # - "8080:8080/tcp" + # Trunk peer only (not needed for Satellite): + # - "5302:5302/tcp" environment: TZ: ${TZ:-Europe/Berlin} - START_SVXLINK: ${START_SVXLINK:-0} - START_REMOTETRX: ${START_REMOTETRX:-0} - START_SVXREFLECTOR: ${START_SVXREFLECTOR:-1} + ENABLE_FM_LINK: ${ENABLE_FM_LINK:-0} SVXREFLECTOR_CONF: ${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf} + SVXREFLECTOR_LINK_CONF: ${SVXREFLECTOR_LINK_CONF:-/etc/svxlink/svxreflector.link.conf} SVXREFLECTOR_ARGS: ${SVXREFLECTOR_ARGS:-} volumes: - ./config/svxreflector.conf:/etc/svxlink/svxreflector.conf:ro + - ./config/svxreflector.link.conf:/etc/svxlink/svxreflector.link.conf:ro - ./logs:/var/log/svxlink - - ./data:/var/lib/svxlink + - ./data/pki:/etc/svxlink/pki diff --git a/svxlink-docker/entrypoint.sh b/svxlink-docker/entrypoint.sh index c184f39..0cf65ec 100755 --- a/svxlink-docker/entrypoint.sh +++ b/svxlink-docker/entrypoint.sh @@ -1,17 +1,11 @@ #!/bin/sh set -eu -SVXLINK_CONF="${SVXLINK_CONF:-/etc/svxlink/svxlink.conf}" -REMOTETRX_CONF="${REMOTETRX_CONF:-/etc/svxlink/remotetrx.conf}" SVXREFLECTOR_CONF="${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf}" - -START_SVXLINK="${START_SVXLINK:-0}" -START_REMOTETRX="${START_REMOTETRX:-0}" -START_SVXREFLECTOR="${START_SVXREFLECTOR:-1}" - -SVXLINK_ARGS="${SVXLINK_ARGS:-}" -REMOTETRX_ARGS="${REMOTETRX_ARGS:-}" +SVXREFLECTOR_LINK_CONF="${SVXREFLECTOR_LINK_CONF:-/etc/svxlink/svxreflector.link.conf}" SVXREFLECTOR_ARGS="${SVXREFLECTOR_ARGS:-}" +ENABLE_FM_LINK="${ENABLE_FM_LINK:-0}" +RUNTIME_CONF="/tmp/svxreflector.runtime.conf" pids="" @@ -24,15 +18,6 @@ die() { exit 1 } -need_file_if_enabled() { - enabled="$1" - file="$2" - name="$3" - if [ "$enabled" = "1" ] && [ ! -f "$file" ]; then - die "Config for $name not found: $file (expected volume mount)" - fi -} - start_bg() { name="$1" shift @@ -70,37 +55,34 @@ stop_all() { trap 'stop_all; exit 0' INT TERM -if [ "$START_SVXLINK" = "1" ] && ! command -v svxlink >/dev/null 2>&1; then - die "svxlink binary not found" -fi -if [ "$START_REMOTETRX" = "1" ] && ! command -v remotetrx >/dev/null 2>&1; then - die "remotetrx binary not found" -fi -if [ "$START_SVXREFLECTOR" = "1" ] && ! command -v svxreflector >/dev/null 2>&1; then - die "svxreflector binary not found" +command -v svxreflector >/dev/null 2>&1 || die "svxreflector binary not found (jayReflector build)" +[ -f "$SVXREFLECTOR_CONF" ] || die "Config not found: $SVXREFLECTOR_CONF (expected volume mount)" + +# Local-only by default. When ENABLE_FM_LINK=1, append the link stub +# (Satellite or Trunk keys from DJ1JAY) after the base lab conf. +if [ "$ENABLE_FM_LINK" = "1" ]; then + [ -f "$SVXREFLECTOR_LINK_CONF" ] || die "ENABLE_FM_LINK=1 but link config missing: $SVXREFLECTOR_LINK_CONF" + { + cat "$SVXREFLECTOR_CONF" + echo + echo "###################################################################" + echo "# Appended FM-Funknetz link stub (ENABLE_FM_LINK=1)" + echo "###################################################################" + cat "$SVXREFLECTOR_LINK_CONF" + } >"$RUNTIME_CONF" + conf_path="$RUNTIME_CONF" + log "FM link enabled: using $SVXREFLECTOR_CONF + $SVXREFLECTOR_LINK_CONF" +else + conf_path="$SVXREFLECTOR_CONF" + log "FM link disabled (ENABLE_FM_LINK=0): local lab reflector only" fi -need_file_if_enabled "$START_SVXLINK" "$SVXLINK_CONF" "svxlink" -need_file_if_enabled "$START_REMOTETRX" "$REMOTETRX_CONF" "remotetrx" -need_file_if_enabled "$START_SVXREFLECTOR" "$SVXREFLECTOR_CONF" "svxreflector" +# Ensure PKI dir exists for first-start cert bootstrap. +mkdir -p /etc/svxlink/pki /var/lib/svxlink/pki -if [ "$START_SVXREFLECTOR" = "1" ]; then - start_bg "svxreflector" svxreflector --config "$SVXREFLECTOR_CONF" $SVXREFLECTOR_ARGS -fi +start_bg "svxreflector" svxreflector --config "$conf_path" $SVXREFLECTOR_ARGS -if [ "$START_REMOTETRX" = "1" ]; then - start_bg "remotetrx" remotetrx --config "$REMOTETRX_CONF" $REMOTETRX_ARGS -fi - -if [ "$START_SVXLINK" = "1" ]; then - start_bg "svxlink" svxlink --config "$SVXLINK_CONF" $SVXLINK_ARGS -fi - -if ! echo "$pids" | grep -q '[0-9]'; then - die "No service enabled. Set START_SVXREFLECTOR=1 (lab default) and/or START_SVXLINK / START_REMOTETRX" -fi - -log "Requested services are running. Waiting…" +log "jayReflector (svxreflector) is running. Waiting…" exit_code=0 for pid in $pids; do diff --git a/teststart.sh b/teststart.sh new file mode 100755 index 0000000..271577f --- /dev/null +++ b/teststart.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Build (if needed) and start funknetz against the local svxlink-docker lab. +# Does not start Docker — run: (cd svxlink-docker && docker compose up -d) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +LAB="$ROOT/svxlink-docker" +ENV_FILE="$LAB/.env" +CONF="$LAB/config/svxreflector.conf" +GENERATED="$LAB/.funknetz.generated.json" +LOG_DIR="$LAB/logs" +LOG_FILE="$LOG_DIR/funknetz-lab.log" +APP_BIN="$ROOT/build/bin/funknetz.app/Contents/MacOS/funknetz" + +die() { + echo "teststart: $*" >&2 + exit 1 +} + +[[ -f "$ENV_FILE" ]] || die "missing $ENV_FILE (copy from .env.example and edit; this script will not copy it)" + +# shellcheck disable=SC1090 +set -a +# shellcheck source=/dev/null +source "$ENV_FILE" +set +a + +missing=() +[[ -n "${FUNKNETZ_HOST:-}" ]] || missing+=(FUNKNETZ_HOST) +[[ -n "${FUNKNETZ_PORT:-}" ]] || missing+=(FUNKNETZ_PORT) +[[ -n "${FUNKNETZ_CALLSIGN:-}" ]] || missing+=(FUNKNETZ_CALLSIGN) +[[ -n "${FUNKNETZ_AUTH_KEY:-}" ]] || missing+=(FUNKNETZ_AUTH_KEY) +[[ -n "${FUNKNETZ_TALK_GROUP:-}" ]] || missing+=(FUNKNETZ_TALK_GROUP) +if ((${#missing[@]})); then + die "missing in .env: ${missing[*]}" +fi + +[[ -f "$CONF" ]] || die "missing $CONF" + +# Resolve FUNKNETZ_CALLSIGN → password group in [USERS], then secret in [PASSWORDS]. +callsign_upper="$(printf '%s' "$FUNKNETZ_CALLSIGN" | tr '[:lower:]' '[:upper:]')" +section="" +password_group="" +auth_secret="" +while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%%#*}" + line="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [[ -z "$line" ]] && continue + if [[ "$line" =~ ^\[(.+)\]$ ]]; then + section="${BASH_REMATCH[1]}" + continue + fi + case "$section" in + USERS) + key="${line%%=*}" + val="${line#*=}" + key="$(printf '%s' "$key" | sed -e 's/[[:space:]]*$//' | tr '[:lower:]' '[:upper:]')" + val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//')" + if [[ "$key" == "$callsign_upper" ]]; then + password_group="$val" + fi + ;; + PASSWORDS) + key="${line%%=*}" + val="${line#*=}" + key="$(printf '%s' "$key" | sed -e 's/[[:space:]]*$//')" + val="$(printf '%s' "$val" | sed -e 's/^[[:space:]]*//' -e 's/^"//' -e 's/"$//')" + if [[ -n "$password_group" && "$key" == "$password_group" ]]; then + auth_secret="$val" + fi + ;; + esac +done <"$CONF" + +[[ -n "$password_group" ]] || die "callsign $FUNKNETZ_CALLSIGN is not in [USERS] in $CONF" +[[ -n "$auth_secret" ]] || die "password group $password_group has no [PASSWORDS] entry in $CONF" +[[ "$FUNKNETZ_AUTH_KEY" == "$auth_secret" ]] || die "FUNKNETZ_AUTH_KEY does not match [PASSWORDS] for $FUNKNETZ_CALLSIGN" + +mkdir -p "$LOG_DIR" + +# JSON-escape a string value. +json_str() { + python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$1" +} + +host_json="$(json_str "$FUNKNETZ_HOST")" +call_json="$(json_str "$callsign_upper")" +key_json="$(json_str "$FUNKNETZ_AUTH_KEY")" +log_json="$(json_str "$LOG_FILE")" + +umask 077 +cat >"$GENERATED" </dev/null | head -n 1 || true)" + [[ -n "$newer" ]] && needs_build=1 +fi + +if ((needs_build)); then + echo "teststart: building funknetz.app…" + (cd "$ROOT" && GOWORK=off wails build) + [[ -x "$APP_BIN" ]] || die "build finished but binary missing: $APP_BIN" +fi + +echo "teststart: starting with -c $GENERATED" +exec "$APP_BIN" -c "$GENERATED"