# SvxLink lab for Funknetz 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. The lab runs **[GeuReflector](https://github.com/audric/GeuReflector)** (IW1GEU), 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 the parent operator provides parameters. ## Running a local reflector Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds GeuReflector, 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 | | --- | --- | | Binary | GeuReflector → `/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 (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) | ### 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 { "host": "127.0.0.1", "port": 5300, "callsign": "DL0FN-1", "authKey": "local-lab-secret-change-me", "talkGroup": 9, "protocol": "v2", "enableLogging": false, "logFile": "/tmp/funknetz.log" } ``` 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 the parent operator 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──► GeuReflector (lab) ──outbound :5303──► FM-Funknetz parent ``` Fill under `[GLOBAL]` in the link stub (GeuReflector README shape). `SATELLITE_ID` may contain only letters, digits, and dashes: - `SATELLITE_OF=parent.example.org` - `SATELLITE_PORT=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 the parent operator asks for Trunk. Research baseline: [docs/research/2026-10-11-geureflector-lab-baseline.md](../../docs/research/2026-10-11-geureflector-lab-baseline.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`. | Check | Behavior | | --- | --- | | Protocol version | Client announces major/minor. Server accepts about **0.6** through **3.0**. Major ≥ 3 takes the certificate/TLS path; older clients get an HMAC challenge. | | Callsign | Must match `ACCEPT_CALLSIGN` (default: typical ham callsign, optional `-suffix`); optional `REJECT_CALLSIGN` blocks. | | Auth key (protocol < 3) | Callsign must map in `[USERS]` to a `[PASSWORDS]` secret. Client proves it with HMAC-SHA1 over a 20-byte challenge. Wrong or missing → “Access denied.” | | Certificates (protocol ≥ 3) | Mutual TLS; peer cert CN is the callsign and must pass the same callsign checks. | | Unique session | A second concurrent login with the same callsign is denied. | | 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. ## Wire protocol The byte-level specification is [rfc.md](./rfc.md). It describes the protocol as implemented by SvxLink. The contract in that source is the message types in SvxLink / GeuReflector (`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: 1. TCP connect to the listen port. 2. Station → `MsgProtoVer` major **2**, minor **0**. 3. Reflector → `MsgAuthChallenge` (20 random bytes). 4. Station → `MsgAuthResponse` (callsign + HMAC-SHA1 digest over the challenge; the auth key is never sent as its own field). 5. Reflector → `MsgAuthOk`, then `MsgServerInfo` (client id, codec list, connected nodes). 6. Station opens UDP to the same host/port; sends heartbeats; selects or monitors talk groups; sends audio as `MsgUdpAudio`. User messages (type ≥ 100) matter only after the session is connected. Unknown types are ignored for forward compatibility. One talker per talk group; other stations’ audio on that group is ignored until the talker stops. ### TCP messages | Type | Name | Role | | --- | --- | --- | | 1 | `MsgHeartbeat` | Keep the TCP session alive | | 5 | `MsgProtoVer` | Station announces major/minor (first message) | | 6 | `MsgProtoVerDowngrade` | Reflector asks for a lower version | | 10 | `MsgAuthChallenge` | Starts auth-key auth | | 11 | `MsgAuthResponse` | Callsign + HMAC digest | | 12 | `MsgAuthOk` | Auth succeeded | | 13 | `MsgError` | Fatal; peer should disconnect | | 14–21 | Encryption / CSR / CA | Protocol 3 certificate setup | | 100 | `MsgServerInfo` | Client id, nodes, codecs | | 102 / 103 | `MsgNodeJoined` / `MsgNodeLeft` | Presence | | 104 / 105 | `MsgTalkerStart` / `MsgTalkerStop` | Talker on a talk group (protocol ≥ 2 includes TG) | | 106 | `MsgSelectTG` | Station selects talk group | | 107 | `MsgTgMonitor` | Station sets monitored talk groups | | 109 | `MsgRequestQsy` | Request QSY (`0` = reflector chooses) | | 110–113 | State / signal / TX status | Optional station telemetry | | 111 | `MsgNodeInfoV2` | JSON node info (protocol 2 body) | | 114 | `MsgStartUdpEncryption` | Protocol 3: OK to encrypt UDP | ### UDP messages | Type | Name | Role | | --- | --- | --- | | 1 | `MsgUdpHeartbeat` | Keepalive / NAT binding | | 101 | `MsgUdpAudio` | Encoded audio bytes (no talk-group field in the active class) | | 102 | `MsgUdpFlushSamples` | End of audio stream | | 103 | `MsgUdpAllSamplesFlushed` | Peer has flushed | | 104 | `MsgUdpSignalStrengthValues` | Signal reports over UDP | The codec name is negotiated in `MsgServerInfo`, not per audio packet. When Opus is available and `CODECS` is unset, the reflector prefers **OPUS**. ## Reflector linking (lab context) | Concept | What it is in this lab | | --- | --- | | Stock `sm0svx` peering | **Unsupported** — not what we build. | | GeuReflector **Satellite** | Lab dials parent `:5303`; optional `SATELLITE_FILTER` for some TGs. Outbound only. | | GeuReflector **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. |