Merge branch 'lab/jayreflector'

This commit is contained in:
2026-10-07 12:14:41 +02:00
24 changed files with 847 additions and 225 deletions
+3 -14
View File
@@ -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})
}
+68 -36
View File
@@ -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
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)
+42
View File
@@ -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
}
+124
View File
@@ -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)
}
}
+1 -1
View File
@@ -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.
+218
View File
@@ -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 |
+63 -23
View File
@@ -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. |
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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 {
+6 -4
View File
@@ -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)
+30 -19
View File
@@ -85,31 +85,40 @@ func TestExistingConfigIsNotRewritten(t *testing.T) {
func TestInvalidConfigNamesTheField(t *testing.T) {
tests := []struct {
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)
}
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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"`
}{
+5 -4
View File
@@ -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
+4
View File
@@ -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
+40 -45
View File
@@ -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"]
+11 -4
View File
@@ -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
@@ -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
+19 -9
View File
@@ -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
+27 -45
View File
@@ -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
Executable
+124
View File
@@ -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" <<EOF
{
"host": $host_json,
"port": $FUNKNETZ_PORT,
"callsign": $call_json,
"authKey": $key_json,
"talkGroup": $FUNKNETZ_TALK_GROUP,
"enableLogging": true,
"logFile": $log_json
}
EOF
chmod 600 "$GENERATED"
needs_build=0
if [[ ! -x "$APP_BIN" ]]; then
needs_build=1
else
newer="$(find "$ROOT" \
\( -path "$ROOT/build" -o -path "$ROOT/svxlink-docker" -o -path "$ROOT/.git" \) -prune -o \
\( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' -o -name 'wails.json' \) -newer "$APP_BIN" -print \
-o -path "$ROOT/frontend/dist/*" -newer "$APP_BIN" -print \
2>/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"