From eda79e6662b8b0cdf4c8dad1c29f0c78df94940f Mon Sep 17 00:00:00 2001 From: Konrad Neitzel Date: Mon, 5 Oct 2026 20:13:25 +0200 Subject: [PATCH] Add a local SvxReflector Docker lab for headset testing. Funknetz needs a protocol 2.0 reflector without production credentials; the compose stack and smoke test prove the station can log in and select a talk group. Co-authored-by: Cursor --- docs/svxlink.md | 121 ++++++++++++++++++++++++ station/lab_reflector_test.go | 56 +++++++++++ svxlink-docker/.dockerignore | 7 ++ svxlink-docker/.env.example | 18 ++++ svxlink-docker/.gitignore | 6 ++ svxlink-docker/Dockerfile | 77 +++++++++++++++ svxlink-docker/config/svxreflector.conf | 24 +++++ svxlink-docker/data/.gitkeep | 0 svxlink-docker/docker-compose.yml | 33 +++++++ svxlink-docker/entrypoint.sh | 113 ++++++++++++++++++++++ svxlink-docker/logs/.gitkeep | 0 11 files changed, 455 insertions(+) create mode 100644 docs/svxlink.md create mode 100644 station/lab_reflector_test.go create mode 100644 svxlink-docker/.dockerignore create mode 100644 svxlink-docker/.env.example create mode 100644 svxlink-docker/.gitignore create mode 100644 svxlink-docker/Dockerfile create mode 100644 svxlink-docker/config/svxreflector.conf create mode 100644 svxlink-docker/data/.gitkeep create mode 100644 svxlink-docker/docker-compose.yml create mode 100755 svxlink-docker/entrypoint.sh create mode 100644 svxlink-docker/logs/.gitkeep diff --git a/docs/svxlink.md b/docs/svxlink.md new file mode 100644 index 0000000..d5e9769 --- /dev/null +++ b/docs/svxlink.md @@ -0,0 +1,121 @@ +# 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. + +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. + +## 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. + +```sh +cd svxlink-docker +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` | +| 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/`) | +| 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): + +```json +{ + "host": "127.0.0.1", + "port": 5300, + "callsign": "DL0FN-1", + "authKey": "local-lab-secret-change-me", + "talkGroup": 9, + "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. + +## 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. Upstream’s man page treats AUTH_KEY as deprecated for “modern” reflectors; that is a different target than the live network’s written instruction. + +## 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. + +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**. + +## Linking and talk-group sharing + +`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 | +| --- | --- | +| 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. diff --git a/station/lab_reflector_test.go b/station/lab_reflector_test.go new file mode 100644 index 0000000..8e5f1e3 --- /dev/null +++ b/station/lab_reflector_test.go @@ -0,0 +1,56 @@ +package station_test + +import ( + "context" + "net" + "os" + "strconv" + "testing" + "time" + + "gitea.neitzel.de/konrad/funknetz/station" +) + +// Lab defaults match svxlink-docker/.env and config/svxreflector.conf. +const ( + labHost = "127.0.0.1" + labPort = 5300 + labCallsign = "DL0FN-1" + labAuthKey = "local-lab-secret-change-me" + labTalkGroup = 9 +) + +func TestConnectsToLabReflector(t *testing.T) { + if os.Getenv("FUNKNETZ_LAB") == "0" { + t.Skip("FUNKNETZ_LAB=0") + } + addr := net.JoinHostPort(labHost, strconv.Itoa(labPort)) + conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond) + if err != nil { + t.Skipf("lab reflector not listening on %s: %v", addr, err) + } + conn.Close() + + path := writeConfigFile(t, configJSON(labHost, labPort, labCallsign, labAuthKey, labTalkGroup)) + st, err := station.New(station.Options{ConfigPath: path}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + go func() { _ = st.Run(ctx) }() + + state, err := st.Wait(ctx, func(s station.State) bool { + return s.Connection == "Connected" + }) + if err != nil { + t.Fatalf("did not reach Connected: %v (last state %#v)", err, st.State()) + } + if state.Callsign != labCallsign { + t.Fatalf("callsign = %q, want %q", state.Callsign, labCallsign) + } + if state.TalkGroup != labTalkGroup { + t.Fatalf("talkGroup = %d, want %d", state.TalkGroup, labTalkGroup) + } +} diff --git a/svxlink-docker/.dockerignore b/svxlink-docker/.dockerignore new file mode 100644 index 0000000..5b1f1c2 --- /dev/null +++ b/svxlink-docker/.dockerignore @@ -0,0 +1,7 @@ +logs/ +data/ +.git +.gitignore +*.md +*.log +.DS_Store diff --git a/svxlink-docker/.env.example b/svxlink-docker/.env.example new file mode 100644 index 0000000..1d590c2 --- /dev/null +++ b/svxlink-docker/.env.example @@ -0,0 +1,18 @@ +# Copy to .env and adjust. Auth secrets must match config/svxreflector.conf. + +TZ=Europe/Berlin + +LISTEN_PORT=5300 + +START_SVXLINK=0 +START_REMOTETRX=0 +START_SVXREFLECTOR=1 + +SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.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 diff --git a/svxlink-docker/.gitignore b/svxlink-docker/.gitignore new file mode 100644 index 0000000..e4d800c --- /dev/null +++ b/svxlink-docker/.gitignore @@ -0,0 +1,6 @@ +logs/* +!logs/.gitkeep +data/* +!data/.gitkeep +.env +*.log diff --git a/svxlink-docker/Dockerfile b/svxlink-docker/Dockerfile new file mode 100644 index 0000000..ea6f25f --- /dev/null +++ b/svxlink-docker/Dockerfile @@ -0,0 +1,77 @@ +# Build and run SvxReflector for the Funknetz local lab. +# Provenance: patterned on f4hlv/svxlink-docker (community); builds from upstream sm0svx/svxlink. + +# ========================================================= +# STAGE 1 — BUILD +# ========================================================= +FROM debian:trixie-slim 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 \ + && rm -rf /var/lib/apt/lists/* + +ARG GIT_URL=https://github.com/sm0svx/svxlink.git +ARG GIT_BRANCH=master +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 \ + -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 + +# ========================================================= +# STAGE 2 — RUNTIME +# ========================================================= +FROM debian:trixie-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 \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /build/svxlink/build/*.deb /tmp/svxlink.deb + +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 && \ + 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 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/svxlink-docker/config/svxreflector.conf b/svxlink-docker/config/svxreflector.conf new file mode 100644 index 0000000..b4e4832 --- /dev/null +++ b/svxlink-docker/config/svxreflector.conf @@ -0,0 +1,24 @@ +################################################################### +# Local lab SvxReflector for Funknetz (protocol 2.0 / ReflectorV2) +# Secrets here are placeholders — do not reuse production keys. +################################################################### + +[GLOBAL] +TIMESTAMP_FORMAT="%c" +LISTEN_PORT=5300 +TG_FOR_V1_CLIENTS=999 +# CODECS=OPUS +COMMAND_PTY=/dev/shm/reflector_ctrl + +# 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. +[SERVER_CERT] +COMMON_NAME=funknetz-lab.local +SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1 + +[USERS] +DL0FN-1=LocalTest +DL0FN-2=LocalTest + +[PASSWORDS] +LocalTest="local-lab-secret-change-me" diff --git a/svxlink-docker/data/.gitkeep b/svxlink-docker/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/svxlink-docker/docker-compose.yml b/svxlink-docker/docker-compose.yml new file mode 100644 index 0000000..02da971 --- /dev/null +++ b/svxlink-docker/docker-compose.yml @@ -0,0 +1,33 @@ +# Local SvxReflector lab for Funknetz. +# Build once, then: docker compose up -d +# Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*). + +services: + svxreflector: + build: + context: . + dockerfile: Dockerfile + args: + GIT_URL: https://github.com/sm0svx/svxlink.git + GIT_BRANCH: master + image: funknetz-svxreflector:local + container_name: funknetz-svxreflector + restart: unless-stopped + env_file: + - .env + ports: + # Host LISTEN_PORT → container 5300 (must match config/svxreflector.conf). + # Control is TCP; audio and UDP keepalives use the same number. + - "${LISTEN_PORT:-5300}:5300/tcp" + - "${LISTEN_PORT:-5300}:5300/udp" + environment: + TZ: ${TZ:-Europe/Berlin} + START_SVXLINK: ${START_SVXLINK:-0} + START_REMOTETRX: ${START_REMOTETRX:-0} + START_SVXREFLECTOR: ${START_SVXREFLECTOR:-1} + SVXREFLECTOR_CONF: ${SVXREFLECTOR_CONF:-/etc/svxlink/svxreflector.conf} + SVXREFLECTOR_ARGS: ${SVXREFLECTOR_ARGS:-} + volumes: + - ./config/svxreflector.conf:/etc/svxlink/svxreflector.conf:ro + - ./logs:/var/log/svxlink + - ./data:/var/lib/svxlink diff --git a/svxlink-docker/entrypoint.sh b/svxlink-docker/entrypoint.sh new file mode 100755 index 0000000..c184f39 --- /dev/null +++ b/svxlink-docker/entrypoint.sh @@ -0,0 +1,113 @@ +#!/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_ARGS="${SVXREFLECTOR_ARGS:-}" + +pids="" + +log() { + echo "[$(date -Iseconds)] $*" +} + +die() { + log "ERROR: $*" + 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 + log "Starting: $name -> $*" + "$@" & + pid="$!" + pids="$pids $pid" + log "$name started (pid=$pid)" +} + +stop_all() { + log "Stop requested; stopping: $pids" + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + + i=0 + while [ $i -lt 10 ]; do + alive=0 + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + alive=1 + fi + done + [ "$alive" -eq 0 ] && break + i=$((i + 1)) + sleep 1 + done + + for pid in $pids; do + kill -KILL "$pid" 2>/dev/null || true + done + log "Stop complete." +} + +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" +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" + +if [ "$START_SVXREFLECTOR" = "1" ]; then + start_bg "svxreflector" svxreflector --config "$SVXREFLECTOR_CONF" $SVXREFLECTOR_ARGS +fi + +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…" + +exit_code=0 +for pid in $pids; do + if ! wait "$pid"; then + exit_code=1 + fi +done + +log "Exit (exit_code=$exit_code)" +exit "$exit_code" diff --git a/svxlink-docker/logs/.gitkeep b/svxlink-docker/logs/.gitkeep new file mode 100644 index 0000000..e69de29