Compare commits
12
Commits
f90b6150f3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c621d4b7f | ||
|
|
3533b97d00 | ||
|
|
d21e7051d7 | ||
|
|
775d1b5c82 | ||
|
|
e642b24be0 | ||
|
|
a405048f23 | ||
|
|
3d1a2a1669 | ||
|
|
0930d4b727 | ||
|
|
550a81e8e0 | ||
|
|
87e366e36c | ||
|
|
465a26911e | ||
|
|
f8cad55e9c |
+27
-3
@@ -17,7 +17,7 @@ The conference that forwards audio among stations that have joined the same talk
|
||||
_Avoid_: server, bridge
|
||||
|
||||
**Talk group**:
|
||||
A numbered audio room on the reflector. The station joins one talk group for the whole run.
|
||||
A numbered audio room on the reflector. The station is joined to one talk group at a time; changing it in the config starts a new session after Save.
|
||||
_Avoid_: channel, room
|
||||
|
||||
**Talker**:
|
||||
@@ -29,9 +29,33 @@ The uppercase amateur call sign that names this station on the reflector.
|
||||
_Avoid_: a lowercase spelling, username, node id
|
||||
|
||||
**Auth key**:
|
||||
The secret the network issues for this station’s callsign, used to prove identity to the reflector.
|
||||
_Avoid_: certificate, a password sent in the clear
|
||||
The shared secret the network issues for this station’s callsign. It proves the station on protocol 2.0, and on protocol 3.0 when the reflector has not signed a certificate yet.
|
||||
_Avoid_: a password sent in the clear, the certificate
|
||||
|
||||
**Certificate**:
|
||||
An X.509 certificate whose common name is this station’s callsign. It proves the station on protocol 3.0.
|
||||
_Avoid_: auth key, a PEM pasted into the config file
|
||||
|
||||
**Certificate directory**:
|
||||
The directory the station config names. It holds the CA bundle and this station’s key and certificate.
|
||||
_Avoid_: PEM text inside the config file
|
||||
|
||||
**CA bundle**:
|
||||
The reflector’s certificate authority, kept in the certificate directory. Protocol 3.0 uses it to check the reflector.
|
||||
_Avoid_: the station certificate
|
||||
|
||||
**Protocol**:
|
||||
The reflector session this station speaks: 2.0 or 3.0. The config chooses v2 or v3; v2 is the default.
|
||||
_Avoid_: TYPE
|
||||
|
||||
**Push-to-talk**:
|
||||
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
|
||||
_Avoid_: voice-operated transmit, a mute toggle
|
||||
|
||||
**Config**:
|
||||
The JSON file that names how this station joins a reflector (host, port, callsign, auth key, talk group, protocol, certificate directory, logging). Default path `~/.funknetz.json`; `-c` may select another file.
|
||||
_Avoid_: settings file, preferences, treating PEMs as config fields
|
||||
|
||||
**Config window**:
|
||||
The in-app form that edits the config. It fills the station window, which is resized to the form.
|
||||
_Avoid_: a separate OS window, settings window, preferences dialog, a raw JSON editor as the primary UI
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -3,8 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"io"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/audio"
|
||||
"gitea.neitzel.de/konrad/funknetz/station"
|
||||
@@ -12,11 +13,14 @@ import (
|
||||
|
||||
// App is the window's view of the station.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
station *station.Station
|
||||
quit func(context.Context)
|
||||
}
|
||||
|
||||
func (a *App) startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
a.cancel = cancel
|
||||
if err := audio.Start(runCtx, a.station.WriteMicrophone, a.station.TakePlayback); err != nil {
|
||||
@@ -47,18 +51,46 @@ 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()
|
||||
if err != nil {
|
||||
return "", err
|
||||
// Config returns the Config window form.
|
||||
func (a *App) Config() station.ConfigState { return a.station.Config() }
|
||||
|
||||
// OpenConfig opens the Config window, re-reading the file.
|
||||
func (a *App) OpenConfig() { a.station.OpenConfig() }
|
||||
|
||||
// Save validates and writes the config form.
|
||||
func (a *App) Save(form station.ConfigForm) error { return a.station.Save(form) }
|
||||
|
||||
// CancelConfig discards the form. When no config has loaded, it quits.
|
||||
func (a *App) CancelConfig() {
|
||||
if !a.station.Cancel() {
|
||||
return
|
||||
}
|
||||
quit := a.quit
|
||||
if quit == nil {
|
||||
quit = runtime.Quit
|
||||
}
|
||||
if a.ctx != nil {
|
||||
quit(a.ctx)
|
||||
}
|
||||
return filepath.Join(home, ".funknetz.json"), nil
|
||||
}
|
||||
|
||||
func openStation() (*station.Station, error) {
|
||||
path, err := configPath()
|
||||
func openStation(args []string) (*station.Station, error) {
|
||||
path, err := resolveConfigPath(args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not be read")
|
||||
return nil, err
|
||||
}
|
||||
return station.New(station.Options{ConfigPath: path})
|
||||
}
|
||||
|
||||
// openStationReported opens the station from args. A flag or path failure is
|
||||
// one line on stderr and the returned error. A missing or invalid config still
|
||||
// returns a station so the window can open.
|
||||
func openStationReported(args []string, stderr io.Writer) (*station.Station, error) {
|
||||
st, err := openStation(args)
|
||||
if err != nil {
|
||||
// The returned error is the failure. A write error does not replace it.
|
||||
_, _ = fmt.Fprintln(stderr, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
+73
-41
@@ -12,47 +12,51 @@ import (
|
||||
|
||||
const stationRate = 16000
|
||||
|
||||
// Start opens the default input and output and converts them to and from
|
||||
// 16 kHz mono. It returns when the devices are running. They close when ctx ends.
|
||||
// Start opens the default capture device and the default playback device and
|
||||
// converts them to and from 16 kHz mono. It returns when both devices are
|
||||
// running. They close when ctx ends.
|
||||
func Start(ctx context.Context, capture func([]int16), playback func() []int16) error {
|
||||
mctx, err := malgo.InitContext(nil, malgo.ContextConfig{}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := malgo.DefaultDeviceConfig(malgo.Duplex)
|
||||
cfg.SampleRate = stationRate
|
||||
cfg.Capture.Format = malgo.FormatS16
|
||||
cfg.Capture.Channels = 1
|
||||
cfg.Playback.Format = malgo.FormatS16
|
||||
cfg.Playback.Channels = 1
|
||||
|
||||
var playRes, capRes converter
|
||||
capCfg := malgo.DefaultDeviceConfig(malgo.Capture)
|
||||
capCfg.SampleRate = stationRate
|
||||
capCfg.Capture.Format = malgo.FormatS16
|
||||
capCfg.Capture.Channels = 1
|
||||
|
||||
playCfg := malgo.DefaultDeviceConfig(malgo.Playback)
|
||||
playCfg.SampleRate = stationRate
|
||||
playCfg.Playback.Format = malgo.FormatS16
|
||||
playCfg.Playback.Channels = 1
|
||||
|
||||
var capRes, playRes converter
|
||||
var playBuf []int16
|
||||
callbacks := malgo.DeviceCallbacks{
|
||||
Data: func(output, input []byte, framecount uint32) {
|
||||
if len(input) > 0 {
|
||||
channels := 1
|
||||
if framecount > 0 {
|
||||
channels = len(input) / int(framecount) / 2
|
||||
}
|
||||
if channels < 1 {
|
||||
channels = 1
|
||||
}
|
||||
pcm := capRes.Convert(downmix(bytesToS16(input), channels))
|
||||
if len(pcm) > 0 {
|
||||
capture(pcm)
|
||||
}
|
||||
|
||||
capDev, err := malgo.InitDevice(mctx.Context, capCfg, malgo.DeviceCallbacks{
|
||||
Data: func(_, input []byte, framecount uint32) {
|
||||
if len(input) == 0 {
|
||||
return
|
||||
}
|
||||
channels := channelCount(len(input), framecount)
|
||||
pcm := capRes.Convert(downmix(bytesToS16(input), channels))
|
||||
if len(pcm) > 0 {
|
||||
capture(pcm)
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
mctx.Free()
|
||||
return err
|
||||
}
|
||||
|
||||
playDev, err := malgo.InitDevice(mctx.Context, playCfg, malgo.DeviceCallbacks{
|
||||
Data: func(output, _ []byte, framecount uint32) {
|
||||
if len(output) == 0 {
|
||||
return
|
||||
}
|
||||
channels := 1
|
||||
if framecount > 0 {
|
||||
channels = len(output) / int(framecount) / 2
|
||||
}
|
||||
if channels < 1 {
|
||||
channels = 1
|
||||
}
|
||||
channels := channelCount(len(output), framecount)
|
||||
playBuf = append(playBuf, playRes.Convert(playback())...)
|
||||
need := int(framecount)
|
||||
var frame []int16
|
||||
@@ -66,32 +70,60 @@ func Start(ctx context.Context, capture func([]int16), playback func() []int16)
|
||||
}
|
||||
writeS16(output, frame, channels)
|
||||
},
|
||||
}
|
||||
dev, err := malgo.InitDevice(mctx.Context, cfg, callbacks)
|
||||
})
|
||||
if err != nil {
|
||||
capDev.Uninit()
|
||||
mctx.Free()
|
||||
return err
|
||||
}
|
||||
rate := int(dev.SampleRate())
|
||||
if rate == 0 {
|
||||
rate = stationRate
|
||||
|
||||
capRate := int(capDev.SampleRate())
|
||||
if capRate == 0 {
|
||||
capRate = stationRate
|
||||
}
|
||||
capRes = converter{from: rate, to: stationRate}
|
||||
playRes = converter{from: stationRate, to: rate}
|
||||
if err := dev.Start(); err != nil {
|
||||
dev.Uninit()
|
||||
playRate := int(playDev.SampleRate())
|
||||
if playRate == 0 {
|
||||
playRate = stationRate
|
||||
}
|
||||
capRes = converter{from: capRate, to: stationRate}
|
||||
playRes = converter{from: stationRate, to: playRate}
|
||||
|
||||
if err := capDev.Start(); err != nil {
|
||||
playDev.Uninit()
|
||||
capDev.Uninit()
|
||||
mctx.Free()
|
||||
return err
|
||||
}
|
||||
if err := playDev.Start(); err != nil {
|
||||
capDev.Stop()
|
||||
playDev.Uninit()
|
||||
capDev.Uninit()
|
||||
mctx.Free()
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
dev.Stop()
|
||||
dev.Uninit()
|
||||
_ = playDev.Stop()
|
||||
_ = capDev.Stop()
|
||||
playDev.Uninit()
|
||||
capDev.Uninit()
|
||||
mctx.Free()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func channelCount(bytes int, framecount uint32) int {
|
||||
channels := 1
|
||||
if framecount > 0 {
|
||||
channels = bytes / int(framecount) / 2
|
||||
}
|
||||
if channels < 1 {
|
||||
return 1
|
||||
}
|
||||
return channels
|
||||
}
|
||||
|
||||
func bytesToS16(b []byte) []int16 {
|
||||
n := len(b) / 2
|
||||
out := make([]int16, n)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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")
|
||||
rest := args
|
||||
for {
|
||||
if err := fs.Parse(rest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
rest = fs.Args()
|
||||
if len(rest) == 0 {
|
||||
break
|
||||
}
|
||||
// A non-flag word ends a parse. Skip it and keep later flags.
|
||||
rest = rest[1:]
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
//go:build !bindings
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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")
|
||||
|
||||
st, err := openStation([]string{"-c", path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat = %v, want the file absent until Save", statErr)
|
||||
}
|
||||
if !st.State().NeedsConfig || !st.State().ConfigOpen {
|
||||
t.Fatalf("State = %+v, want the Config window open", st.State())
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigEqualsFlagSelectsTheFile(t *testing.T) {
|
||||
abs := filepath.Join(t.TempDir(), "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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingFileOpensWithoutStderr(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
var stderr bytes.Buffer
|
||||
st, err := openStationReported([]string{"-c", path}, &stderr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil {
|
||||
t.Fatal("station = nil, want a station so the window can open")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||
}
|
||||
if !st.State().NeedsConfig || !st.State().ConfigOpen {
|
||||
t.Fatalf("State = %+v, want the Config window open", st.State())
|
||||
}
|
||||
form := st.Config().Form
|
||||
if form.Host != "" || form.Callsign != "" || form.AuthKey != "" || form.Port != 0 || form.TalkGroup != 0 || form.EnableLogging || form.CertificateDir != "./funknetz" {
|
||||
t.Fatalf("Form = %+v, want template defaults", form)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if form.LogFile != filepath.Join(home, "funknetz.log") {
|
||||
t.Fatalf("LogFile = %q, want funknetz.log in the home directory", form.LogFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaterFlagSelectsTheFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := filepath.Join(dir, "first.json")
|
||||
second := filepath.Join(dir, "second.json")
|
||||
|
||||
got, err := resolveConfigPath([]string{"-c", first, "--config", second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != second {
|
||||
t.Fatalf("later --config = %q, want %q", got, second)
|
||||
}
|
||||
|
||||
got, err = resolveConfigPath([]string{"--config", first, "-c", second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != second {
|
||||
t.Fatalf("later -c = %q, want %q", got, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyConfigFlagUsesHomeFile(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".funknetz.json")
|
||||
for _, args := range [][]string{
|
||||
{"-c", ""},
|
||||
{"--config", ""},
|
||||
{"--config="},
|
||||
} {
|
||||
got, err := resolveConfigPath(args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("resolveConfigPath(%q) = %q, want %q", args, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraWordsDoNotChangeTheSelectedFile(t *testing.T) {
|
||||
abs := filepath.Join(t.TempDir(), "lab.json")
|
||||
got, err := resolveConfigPath([]string{"-c", abs, "leftover"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != abs {
|
||||
t.Fatalf("with leftover = %q, want %q", got, abs)
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err = resolveConfigPath([]string{"note"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".funknetz.json")
|
||||
if got != want {
|
||||
t.Fatalf("bare word = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
got, err = resolveConfigPath([]string{"note", "-c", abs})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != abs {
|
||||
t.Fatalf("word before -c = %q, want %q", got, abs)
|
||||
}
|
||||
|
||||
second := filepath.Join(filepath.Dir(abs), "second.json")
|
||||
got, err = resolveConfigPath([]string{"-c", abs, "leftover", "--config", second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != second {
|
||||
t.Fatalf("flag after a word = %q, want %q", got, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedFileIsLeftByteForByte(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := openStation([]string{"--config", path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State().Callsign != "DL1ABC" {
|
||||
t.Fatalf("callsign = %q, want DL1ABC", st.State().Callsign)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("file = %q, want %q", got, original)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyHostOnSelectedFileOpensConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
body := []byte("{\"host\":\"\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
st, err := openStationReported([]string{"-c", path}, &stderr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || !st.State().ConfigOpen || !strings.Contains(st.State().Detail, "host") {
|
||||
t.Fatalf("State = %+v, want the Config window open naming host", st.State())
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelWithoutALoadedConfigQuits(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "missing.json")
|
||||
st, err := openStation([]string{"-c", path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quit := false
|
||||
app := &App{
|
||||
station: st,
|
||||
ctx: t.Context(),
|
||||
quit: func(context.Context) { quit = true },
|
||||
}
|
||||
app.CancelConfig()
|
||||
if !quit {
|
||||
t.Fatal("CancelConfig() left the app running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelAfterLoadKeepsTheSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := openStation([]string{"-c", path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quit := false
|
||||
app := &App{
|
||||
station: st,
|
||||
ctx: t.Context(),
|
||||
quit: func(context.Context) { quit = true },
|
||||
}
|
||||
app.CancelConfig()
|
||||
if quit {
|
||||
t.Fatal("CancelConfig() quit after a loaded config")
|
||||
}
|
||||
if st.State().ConfigOpen {
|
||||
t.Fatal("Config window stayed open")
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(body) {
|
||||
t.Fatalf("file = %s, want it left unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadFlagPrintsParserError(t *testing.T) {
|
||||
cases := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"-not-a-flag"}, "flag provided but not defined: -not-a-flag\n"},
|
||||
{[]string{"-c"}, "flag needs an argument: -c\n"},
|
||||
{[]string{"--config"}, "flag needs an argument: -config\n"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
var stderr bytes.Buffer
|
||||
st, err := openStationReported(tc.args, &stderr)
|
||||
if st != nil {
|
||||
t.Fatal("station = non-nil, want nil")
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("error = nil, want a flag error")
|
||||
}
|
||||
if got := stderr.String(); got != tc.want {
|
||||
t.Fatalf("stderr = %q, want %q", got, tc.want)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(stderr.String()), "usage") {
|
||||
t.Fatalf("stderr = %q, want no usage text", stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
# Hand-edited config in the home directory
|
||||
# Home-directory config and Config window
|
||||
|
||||
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 station config is a JSON file. The default path is `~/.funknetz.json`; that path is part of the contract because moving it later strands existing files. Application Support would match other Mac apps but is a worse place for hand-editing, which remains supported alongside `-c` / `--config`. The primary editor is the in-app Config window (a modal form over the station UI). When the resolved file is missing or has never loaded successfully, the app opens that window and does not create the file until Save (mode 0600 on first create); Cancel in that state quits. Create, rewrite, and validate rules are the same for every path—only the path changes.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Trust the first CA bundle
|
||||
|
||||
Protocol 3.0 checks the reflector with the CA bundle in the certificate directory. When that file is missing, the station stores the bundle after the signature check with the certificate inside the same message. A later digest that differs from the stored file leaves the file in place. Replacing a stored bundle automatically was rejected because the reflector could swap the trust anchor.
|
||||
@@ -0,0 +1,5 @@
|
||||
Superseded by [ADR 0006](0006-configured-protocol.md).
|
||||
|
||||
# Announce protocol 3.0, fall back only on an explicit 2.x maximum
|
||||
|
||||
Every TCP connection announces protocol 3.0. The station opens one 2.0 session only when the reflector sends a downgrade, or an unsupported-version error, whose maximum is at least 2.0 and below 3.0. The connection after that 2.0 session ends announces 3.0 again. A maximum whose major is below 2 stops the station with the reflector’s message. A reflector `MsgError` stops the station until the operator quits. A bad CA signature, a TLS or hostname failure, a heartbeat timeout, and a dropped connection retry on 3.0, and the window keeps the reason between attempts. The Protocol line shows 2.0 or 3.0 while the session is up, so a plaintext session is visible. Staying on 3.0 after every failure was rejected because a reflector that still expects 2.0 would never accept the station. Falling back on any handshake failure was rejected because a TLS or CA problem would then send plaintext audio.
|
||||
@@ -0,0 +1,3 @@
|
||||
# The config chooses the protocol; the other is one fallback session
|
||||
|
||||
The station announces the protocol named in the config. The default is v2. It opens one session of the other protocol only when the reflector refuses the configured version and names the other as acceptable: a downgrade or an “at most 2.x” error when v3 was announced, and a downgrade to 3.0 or a “must be at least 3.0” error when v2 was announced. The connection after that session announces the configured protocol again. A TLS, CA, or hostname failure retries the configured protocol and does not switch, so a certificate problem does not become a plaintext session. Always announcing 3.0 was rejected because the operator has to be able to stay on v2.
|
||||
@@ -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 |
|
||||
+895
@@ -0,0 +1,895 @@
|
||||
# SvxReflector protocol
|
||||
|
||||
**Date:** 2026-10-07
|
||||
**Scope:** The TCP control and UDP audio protocol a station uses to join one SvxReflector, as implemented by upstream SvxLink. Two self-contained memos: Protocol Version 2.0 and Protocol Version 3.0. Shared byte encoding is specified once and cited by both. Protocol 1.x is a short historical section because this server still accepts it and still emits a distinct talker body.
|
||||
**Not this:** A Go client; production FM-Funknetz credentials or a live probe of that reflector; reflector-to-reflector trunking; MQTT; talk-group mapping policy.
|
||||
**Upstream:** GitHub `sm0svx/svxlink` `master` HEAD on 2026-10-07 was still commit [`fcd13014810af75d1852b3f2cfc8d9806178d0ab`](https://github.com/sm0svx/svxlink/commit/fcd13014810af75d1852b3f2cfc8d9806178d0ab) (2026-07-26, “Prepare for release 26.05.1”). In that commit [`src/versions`](https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/src/versions): `PROJECT=26.05.1`, `SVXLINK=1.10.1`, `SVXREFLECTOR=1.4.1`. There is no IETF RFC. If this text and the cited source disagree, the source wins.
|
||||
**Fork check:** `dj1jay/jayReflector` `main` on 2026-10-07 was still [`50997ed21a37c7c05c132eca5ebb07bab4b80fc7`](https://github.com/dj1jay/jayReflector/commit/50997ed21a37c7c05c132eca5ebb07bab4b80fc7). Client-facing TCP and UDP message types match upstream; see the note after the memos. The lab binary is now [GeuReflector](https://github.com/audric/GeuReflector) at [`dbad88e8c3e0f1adb8342db190bb0b3ad00106f4`](https://github.com/audric/GeuReflector/commit/dbad88e8c3e0f1adb8342db190bb0b3ad00106f4); the station message table in this file is unchanged.
|
||||
|
||||
This note is **factual** except sections labeled **Inference**.
|
||||
|
||||
---
|
||||
|
||||
## How to read this file
|
||||
|
||||
Part A is the common encoding. Both memos use it normatively.
|
||||
|
||||
Part B is Protocol Version 2.0 (`TYPE=ReflectorV2`: HMAC-SHA1, plaintext UDP). An implementer of only version 2.0 reads Part A and Part B.
|
||||
|
||||
Part C is Protocol Version 3.0 (`TYPE=Reflector`: X.509, TLS on TCP, AES-128-GCM on UDP). An implementer of only version 3.0 reads Part A and Part C.
|
||||
|
||||
The keywords MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as in RFC 2119. “A client MUST” means the server at this commit rejects or drops the session otherwise, or the stock client will not interoperate. “This server does” describes `svxreflector` at the pinned commit and is not a requirement on other servers.
|
||||
|
||||
---
|
||||
|
||||
# Part A — Common encoding
|
||||
|
||||
## A.1 Status of this part
|
||||
|
||||
Implementation-derived description of serialization at commit `fcd13014810af75d1852b3f2cfc8d9806178d0ab`. Not an IETF standard.
|
||||
|
||||
## A.2 Protocol model
|
||||
|
||||
Roles:
|
||||
|
||||
| Role | What it is |
|
||||
| --- | --- |
|
||||
| Client | A station that opens TCP to one reflector and sends UDP audio. In this tree the clients are logic type `ReflectorV2` (announces 2.0) and logic type `Reflector` (announces 3.0). |
|
||||
| Reflector | The `svxreflector` process. It accepts many clients and forwards audio inside a talk group. |
|
||||
| Talk group | A `uint32` number. Audio from the current talker is copied to other clients that have selected that talk group, and talker indications are also sent to clients that monitor it. |
|
||||
| Talker | At most one client per talk group whose audio is forwarded. The server assigns the talker from the first audio datagram on that talk group. |
|
||||
|
||||
One TCP connection is one client. The server assigns a `uint16` client id in the range 1 through 65535, chosen with `std::mt19937` and skipped forward if that id is in use [ReflectorClient.cpp `newClientId`, lines 396–407].
|
||||
|
||||
## A.3 Byte order and primitive types
|
||||
|
||||
All multi-byte integers are big-endian. `Async::Msg` packs them with `htobe16`, `htobe32`, and `htobe64`, and unpacks with `be16toh`, `be32toh`, and `be64toh` [AsyncMsg.h `Packer16` / `Packer32` / `Packer64`, lines 286–385].
|
||||
|
||||
| Type in the C++ message | On the wire |
|
||||
| --- | --- |
|
||||
| `uint8_t`, `int8_t`, `char` | 1 byte, no endian swap |
|
||||
| `uint16_t`, `int16_t` | 2 bytes, big-endian |
|
||||
| `uint32_t`, `int32_t` | 4 bytes, big-endian |
|
||||
| `uint64_t`, `int64_t` | 8 bytes, big-endian |
|
||||
| `float` | 4 bytes, big-endian bit pattern (same overlay as `uint32_t`) |
|
||||
| `double` | 8 bytes, big-endian bit pattern (same overlay as `uint64_t`) |
|
||||
| `std::string` | `uint16` byte count, then that many bytes. The count is `val.size()`. No trailing NUL is included. A string longer than 65535 bytes fails to pack. Embedded NUL bytes are data. |
|
||||
| `std::vector<T>` | `uint16` element count, then each element packed as `T`. A `vector<uint8_t>` is therefore a `uint16` length and then raw bytes. |
|
||||
| `std::set<T>` | `uint16` element count, then each element in set order. For `set<uint32_t>` that order is ascending numeric order. |
|
||||
| `std::array<T,N>` and `T[N]` | N elements, no count prefix |
|
||||
|
||||
[AsyncMsg.h `MsgPacker<std::string>` lines 407–445; `MsgPacker<std::vector>` lines 447–496; `MsgPacker<std::set>` lines 498–555.]
|
||||
|
||||
A derived message class does **not** pack its base class unless it uses `ASYNC_MSG_DERIVED_FROM`. The reflector messages do not. The sender writes the type with a separate `ReflectorMsg` or `ReflectorUdpMsg` / `ReflectorUdpMsgV2` header, then packs the derived object, which writes only its own members [ReflectorClient.cpp `sendMsg` lines 272–288; ReflectorV2Logic.cpp `sendMsg` lines 1298–1328]. `ASYNC_MSG_NO_MEMBERS` therefore means a body of zero bytes after the type header.
|
||||
|
||||
## A.4 TCP framing
|
||||
|
||||
TCP carries a sequence of frames. Each frame is:
|
||||
|
||||
```
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| payload length (32 bits) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| |
|
||||
+ payload +
|
||||
| (length bytes) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
The length is the number of payload bytes and does not include itself. It is written as four big-endian bytes [AsyncFramedTcpConnection.h `QueueItem`, lines 292–307]. The receiver closes the connection with disconnect reason protocol error if the length is greater than its current maximum receive size [AsyncFramedTcpConnection.cpp lines 262–266].
|
||||
|
||||
The payload is one reflector message:
|
||||
|
||||
```
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| message type (16) | message body ... |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
The type is a `uint16`. The body is the packed members of that message class and nothing else.
|
||||
|
||||
Maximum payload sizes enforced by the reflector messages [ReflectorMsg.h lines 110–112]:
|
||||
|
||||
| Constant | Bytes | When this server applies it |
|
||||
| --- | --- | --- |
|
||||
| `MAX_PREAUTH_FRAME_SIZE` | 64 | Receive limit from TCP accept until protocol 3 starts TLS, and the version 2.0 client’s limit until `MsgAuthOk` |
|
||||
| `MAX_SSL_SETUP_FRAME_SIZE` | 4096 | Server receive limit after it has sent `MsgStartEncryption`, until authentication completes |
|
||||
| `MAX_POSTAUTH_FRAME_SIZE` | 32768 | Server transmit limit for the whole session. Server receive limit after authentication. Version 3.0 client limit for the whole session. Version 2.0 client limit after `MsgAuthOk`. |
|
||||
|
||||
The framing class’s own default, before those calls, is 1 MiB [AsyncFramedTcpConnection.h line 290].
|
||||
|
||||
Both sides unpack the type first. A payload shorter than 2 bytes fails that unpack. The version 2.0 client then disconnects. This server sends `MsgError` with `"Protocol error"` [ReflectorClient.cpp lines 506–513; ReflectorV2Logic.cpp lines 843–849].
|
||||
|
||||
Unknown type numbers are ignored after the type has been unpacked. Ignoring them still counts as traffic for the TCP receive timer. Application types (type ≥ 100) received before the authenticated state are specified in each memo.
|
||||
|
||||
## A.5 TCP message catalogue
|
||||
|
||||
Type numbers are the template argument of `ReflectorMsgBase<N>` in ReflectorMsg.h. Direction is who sends the message in this tree. “Either” means the class is handled on both sides.
|
||||
|
||||
| Type | Class | Body after the type | Direction |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `MsgHeartbeat` | empty | Both |
|
||||
| 5 | `MsgProtoVer` | `uint16` major, `uint16` minor | Client, first message |
|
||||
| 6 | `MsgProtoVerDowngrade` | `uint16` major, `uint16` minor | Server |
|
||||
| 10 | `MsgAuthChallenge` | `vector<uint8_t>` of 20 bytes | Server, major < 3, and one protocol 3 fallback |
|
||||
| 11 | `MsgAuthResponse` | `string` callsign, then `vector<uint8_t>` digest | Client |
|
||||
| 12 | `MsgAuthOk` | empty | Server |
|
||||
| 13 | `MsgError` | `string` message | Either. Receiver drops the connection. |
|
||||
| 14 | `MsgStartEncryptionRequest` | empty | Client, major ≥ 3 |
|
||||
| 15 | `MsgStartEncryption` | empty | Server, major ≥ 3 |
|
||||
| 16 | `MsgClientCsrRequest` | empty | Server, major ≥ 3 |
|
||||
| 17 | `MsgClientCsr` | `string` PEM | Client, major ≥ 3 |
|
||||
| 18 | `MsgClientCert` | `string` PEM | Server, major ≥ 3 |
|
||||
| 19 | `MsgCAInfo` | `uint16` PEM size, `vector<uint8_t>` SHA-256 | Server, major ≥ 3 |
|
||||
| 20 | `MsgCABundleRequest` | empty | Client, major ≥ 3 |
|
||||
| 21 | `MsgCABundle` | `string` CA PEM, `vector<uint8_t>` signature, `string` signing-certificate PEM | Server, major ≥ 3 |
|
||||
| 100 | `MsgServerInfo` | `uint16` reserved, `uint16` client id, `vector<string>` nodes, `vector<string>` codecs | Server, after login |
|
||||
| 101 | `MsgNodeList` | `vector<string>` callsigns | Server, only if client version < 0.7 |
|
||||
| 102 | `MsgNodeJoined` | `string` callsign | Server to other clients |
|
||||
| 103 | `MsgNodeLeft` | `string` callsign | Server to other clients |
|
||||
| 104 | `MsgTalkerStart` | `uint32` talk group, `string` callsign | Server to clients with major ≥ 2 |
|
||||
| 104 | `MsgTalkerStartV1` | `string` callsign | Server to clients with major = 1. Same type number, shorter body. |
|
||||
| 105 | `MsgTalkerStop` | `uint32` talk group, `string` callsign | Server to clients with major ≥ 2 |
|
||||
| 105 | `MsgTalkerStopV1` | `string` callsign | Server to clients with major = 1 |
|
||||
| 106 | `MsgSelectTG` | `uint32` talk group | Client |
|
||||
| 107 | `MsgTgMonitor` | `set<uint32>` talk groups | Client |
|
||||
| 108 | — | Not compiled. The old site-list `MsgNodeInfo` is inside `#if 0` [ReflectorMsg.h lines 944–1136]. | — |
|
||||
| 109 | `MsgRequestQsy` | `uint32` talk group. Zero means “server chooses”. | Client request; server fan-out to major ≥ 2 on that talk group |
|
||||
| 110 | `MsgStateEvent` | `string` src, `string` name, `string` msg | Client. This server logs it and does not fan it out. The stock clients’ send call is inside a comment. |
|
||||
| 111 | `MsgNodeInfo` | `vector<uint8_t>` IV random, `vector<uint8_t>` UDP key, `string` JSON | Client with major ≥ 3 |
|
||||
| 111 | `MsgNodeInfoV2` | `string` JSON | Client with major < 3. Same type number. |
|
||||
| 112 | `MsgSignalStrengthValues` | `vector` of receiver records | Client |
|
||||
| 113 | `MsgTxStatus` | `vector` of transmitter records | Client |
|
||||
| 114 | `MsgStartUdpEncryption` | empty | Server, major ≥ 3 |
|
||||
|
||||
`MsgAuthChallenge::LENGTH` is 20. The body is a vector, so the wire form is `uint16` 20 followed by 20 bytes from `RAND_bytes`. If `RAND_bytes` fails, the vector is cleared and this server disconnects without sending the message [ReflectorMsg.h lines 366–381; ReflectorClient.cpp `sendAuthChallenge` lines 1401–1412].
|
||||
|
||||
`MsgAuthResponse` member order in the pack macro is callsign, then digest, even though the fields are declared in the other order [ReflectorMsg.h line 464]. `DIGEST_LEN` is 20. The digest vector is `uint16` 20 followed by 20 bytes. How those 20 bytes are computed is in Part B.
|
||||
|
||||
`MsgServerInfo` always writes `m_reserved`, which this server leaves at 0 [ReflectorMsg.h lines 720–724]. The node list is filled after construction and includes every connected client that already has a callsign, including the client being welcomed, because the callsign is stored before `nodeList()` [ReflectorClient.cpp lines 1344–1358; Reflector.cpp `nodeList` lines 377–388]. Codec names are a vector; this server puts one name in it.
|
||||
|
||||
Receiver record inside type 112, and the same record inside UDP type 104 [ReflectorMsg.h lines 1270–1302]:
|
||||
|
||||
```
|
||||
0 1 2
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| id (char) | siglev (uint8)| flags |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
Flag bits, from bit 0: enabled, squelch open, active. The C++ constructor parameter is `int16_t`, and the stock client clamps the value to 0..100, but the packed field is `uint8_t`. A vector of these records is prefixed with a `uint16` count.
|
||||
|
||||
Transmitter record inside type 113 [ReflectorMsg.h lines 1341–1368]: `char` id, then `uint8` flags. Bit 0 means transmitting. A `uint16` count precedes the records.
|
||||
|
||||
Type 110’s three strings are independent length-prefixed strings.
|
||||
|
||||
## A.6 UDP message types
|
||||
|
||||
These type numbers live in a different namespace from the TCP types. They are the template argument of `ReflectorUdpMsgBase<N>`.
|
||||
|
||||
| Type | Class | Body after the UDP header of the version in use | Direction |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `MsgUdpHeartbeat` | empty | Both |
|
||||
| 101 | `MsgUdpAudio` | `vector<uint8_t>` encoded audio. No talk-group field. A variant that included a `uint32` talk group is inside `#if 0` and is not compiled [ReflectorMsg.h lines 1423–1444 and 1586–1618]. | Both |
|
||||
| 102 | `MsgUdpFlushSamples` | empty | Both. “End of this audio stream.” |
|
||||
| 103 | `MsgUdpAllSamplesFlushed` | empty | Both. “The samples have been written to the local audio sink.” |
|
||||
| 104 | `MsgUdpSignalStrengthValues` | same receiver-record vector as TCP type 112 | Client. This server accepts it. The stock client sends TCP type 112 instead; its UDP send is commented out. |
|
||||
|
||||
The header in front of that body depends on the major version. Part B specifies the version 2 header. Part C specifies the version 3 header and the cipher.
|
||||
|
||||
One audio datagram carries one encoder callback: the bytes from a single `writeEncodedSamples` call, stored in the vector. An empty vector is legal and is not passed to the decoder.
|
||||
|
||||
## A.7 Addresses
|
||||
|
||||
This server binds TCP and UDP to the same `LISTEN_PORT`. The code default, if the configuration key is absent, is the string `"5300"` for TCP and the integer 5300 for UDP [Reflector.cpp lines 284–300]. The shipped `svxreflector.conf.in` sets `LISTEN_PORT=5300`. The man page says clients do not open inbound ports [svxreflector.conf.5 `LISTEN_PORT`].
|
||||
|
||||
The client sends UDP to `remoteHost()` and `remotePort()` of the TCP connection, which are the reflector’s address and that same port [ReflectorV2Logic.cpp `sendUdpMsg` lines 1487–1488]. The client’s UDP socket is constructed with local port 0, so the kernel chooses an ephemeral port [AsyncUdpSocket.h line 134; ReflectorV2Logic.cpp line 1055].
|
||||
|
||||
This server learns that ephemeral port from the first acceptable UDP datagram and then requires later datagrams to use it [Reflector.cpp lines 1095–1107]. Until that datagram arrives, the server has no UDP destination and its UDP sends return immediately [ReflectorClient.cpp `sendUdpMsg` lines 323–328].
|
||||
|
||||
DNS, when the client is configured with `DNS_DOMAIN`, is a lookup of SRV service `svxreflector` / `tcp` [ReflectorV2Logic.cpp line 223; ReflectorLogic.cpp line 260]. The UDP port is the TCP SRV port. There is no separate UDP SRV name in this client.
|
||||
|
||||
## A.8 Version admission
|
||||
|
||||
`MsgProtoVer` is the first TCP payload the client sends. The class default is major 3, minor 0. The version 2.0 client constructs `(2, 0)` [ReflectorV2Logic.cpp line 127].
|
||||
|
||||
This server accepts a version that is greater than or equal to 0.6 and less than or equal to 3.0. Comparison is major, then minor [ProtoVer.h `operator<` lines 145–149; ReflectorClient.h lines 509–510; ReflectorClient.cpp `handleMsgProtoVer` lines 607–660].
|
||||
|
||||
| Announced version | This server |
|
||||
| --- | --- |
|
||||
| Greater than 3.0, and this is the first version message | Sends `MsgProtoVerDowngrade` with major 3, minor 0, and stays in the expect-version state |
|
||||
| Greater than 3.0 again | `MsgError` `"Unsupported protocol version M.N. May be at most 3.0."` |
|
||||
| Less than 0.6 | `MsgError` `"Unsupported protocol version M.N. Must be at least 0.6."` |
|
||||
| Major ≥ 3, within range | Part C |
|
||||
| Major < 3, within range | `MsgAuthChallenge`, then Part B’s authentication, with the version-dependent body variants in A.5 |
|
||||
|
||||
`ProtoVer::operator!=` compares the major number only [ProtoVer.h lines 141–144]. The admission tests do not use it.
|
||||
|
||||
A second `MsgProtoVer` outside the expect-version state produces `MsgError` `"Protocol version expected"`.
|
||||
|
||||
## A.9 Callsign grammar
|
||||
|
||||
This server applies `callsignOk` to the authentication callsign and to a protocol 3 certificate common name [Reflector.cpp lines 650–701].
|
||||
|
||||
The callsign MUST be non-empty. It MUST match `ACCEPT_CALLSIGN` as a full `std::regex_match`. If that key is missing or empty, the pattern compiled into the server is:
|
||||
|
||||
```
|
||||
[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?
|
||||
```
|
||||
|
||||
`std::regex` with no flags uses the ECMAScript grammar. The match is case-sensitive. This server does not rewrite case. A callsign that fails the pattern produces `MsgError` `"Invalid callsign"`.
|
||||
|
||||
If `REJECT_CALLSIGN` is set and the callsign matches it, the callsign is rejected the same way. The default configuration sets no reject pattern [svxreflector.conf.5].
|
||||
|
||||
A second simultaneous session with the same callsign produces `MsgError` `"Access denied"` [ReflectorClient.cpp lines 1336–1377].
|
||||
|
||||
## A.10 Codec names
|
||||
|
||||
`MsgServerInfo` carries codec names as strings. This server’s `CODECS` value is split on commas, and only the first name is kept. If `CODECS` is unset, the name is `OPUS` when Opus encode and decode are both compiled in, otherwise `SPEEX` when Speex is compiled in, otherwise `GSM` [ReflectorClient.cpp lines 214–239].
|
||||
|
||||
Names the audio library recognizes, when the corresponding library is compiled in [AsyncAudioEncoder.cpp `isAvailable` lines 123–134]: `NULL`, `RAW`, `S16`, `GSM`, `SPEEX`, `OPUS`, `DUMMY`. The man page lists `OPUS`, `SPEEX`, `GSM`, `S16` (signed 16-bit), and `RAW` (32-bit float) [svxreflector.conf.5 `CODECS`].
|
||||
|
||||
The client walks the server’s list and uses the first name for which both an encoder and a decoder exist. The selected name is not echoed. The audio datagram does not repeat it. Opus parameters are in each memo’s audio section; both clients use the same encoder class.
|
||||
|
||||
## A.11 Talk groups, talker, and QSY
|
||||
|
||||
`MsgSelectTG` replaces the client’s current talk group. If the number differs from the current one, and this client is the talker, this server sends `MsgUdpFlushSamples` to the other clients on that talk group. If someone else is the talker, this server sends `MsgUdpFlushSamples` to the client that is leaving. There is no TCP acknowledgement. If the section `TG#<n>` has `ALLOW` and the callsign does not match that regular expression, or if the expression is invalid, this server forces the client to talk group 0 and does not tell the client [ReflectorClient.cpp `handleSelectTG` lines 855–881 and `setTg` lines 1452–1466; TGHandler.cpp `allowTgSelection` lines 266–289]. The source comment marks the missing notification as a FIXME.
|
||||
|
||||
`MsgTgMonitor` replaces the idle-monitor set. This server drops talk group 0 and any talk group that fails `ALLOW` or `ALLOW_MONITOR`. It does not send an error for the dropped numbers [ReflectorClient.cpp lines 885–913].
|
||||
|
||||
Audio is forwarded only when the client’s selected talk group is non-zero, the client is not in a talker block, and the client is the talker for that talk group. The first acceptable audio datagram makes that client the talker [Reflector.cpp lines 1159–1192].
|
||||
|
||||
While a client remains the talker, each forwarded audio datagram refreshes the talker timestamp. A periodic 1-second timer clears the talker when `diff.tv_sec > 3` after the last refresh (`TALKER_AUDIO_TIMEOUT` is 3) [TGHandler.h line 180; TGHandler.cpp lines 363–371]. Clearing the talker sends TCP talker-stop and UDP flush as below.
|
||||
|
||||
On talker start, this server sends `MsgTalkerStart` (type 104, with talk group) to clients whose version is ≥ 2.0 and that have selected or monitor that talk group. On talker stop it sends `MsgTalkerStop` to the same filter, and `MsgUdpFlushSamples` to the other clients who have selected that talk group. Clients whose major version is 1, and only when the talk group equals `TG_FOR_V1_CLIENTS`, receive `MsgTalkerStartV1` / `MsgTalkerStopV1` (type 104 / 105, callsign only) [Reflector.cpp `onTalkerUpdated` lines 1297–1333]. The class comments say UDP audio may arrive before talker-start and after talker-stop, and that the TCP messages are informational [ReflectorMsg.h lines 797–805 and 848–856].
|
||||
|
||||
`TG_FOR_V1_CLIENTS` in the shipped configuration template is 999. If the key is absent, the binary member is initialized to 1 [Reflector.cpp line 232; svxreflector.conf.in line 14].
|
||||
|
||||
`MsgRequestQsy` from a client whose current talk group is 0 is ignored. Otherwise this server broadcasts `MsgRequestQsy` to version ≥ 2.0 clients that have selected the requester’s current talk group, including the requester. If the requested talk group is 0, the server walks `RANDOM_QSY_RANGE` for an empty talk group. If that range was not configured, it sends nothing [Reflector.cpp `requestQsy` lines 459–482 and `nextRandomQsyTg` lines 1409–1432]. The range format in the man page is `lower:count`.
|
||||
|
||||
`SQL_TIMEOUT` defaults to 0, which disables the long-transmission block. When it is non-zero, the 1-second timer counts it down while the client is talker and then calls `setBlock` for `SQL_TIMEOUT_BLOCKTIME` seconds. The code default for that block is 60, and the value is raised to at least 1 [Reflector.cpp lines 317–323; TGHandler.cpp lines 131–133 and 374–379]. The man page says the block default is one second. The code path above is what this binary does when the key is absent.
|
||||
|
||||
A blocked client’s UDP audio and UDP signal-strength datagrams are discarded. Other UDP types are still accepted. Receiving audio restarts the block timer if a block is already set [ReflectorClient.cpp lines 316–319].
|
||||
|
||||
## A.12 Heartbeat timers shared by both clients
|
||||
|
||||
Both stock clients use a periodic timer of 1000 ms, started when TCP connects [ReflectorV2Logic.cpp line 142; ReflectorLogic.cpp line 174].
|
||||
|
||||
| Counter | Reset value | Effect when it reaches zero |
|
||||
| --- | --- | --- |
|
||||
| TCP transmit | 10 | Send `MsgHeartbeat` |
|
||||
| TCP receive | 15 | Client disconnects |
|
||||
| UDP transmit | 15, or `UDP_HEARTBEAT_INTERVAL` if set | Send a UDP heartbeat (version 3 retransmits registration while waiting; see Part C) |
|
||||
| UDP receive | 60 | Client disconnects |
|
||||
|
||||
Any successful TCP send resets the TCP transmit counter. Any unpacked TCP frame resets the TCP receive counter. A successfully sequenced UDP datagram resets the UDP receive counter. These counters start at TCP connect, so the UDP receive limit is running before the UDP socket exists.
|
||||
|
||||
This server’s timer also fires every 1000 ms, and it is enabled when the client object is constructed, which is at TCP accept [ReflectorClient.cpp lines 188–191; AsyncTimer.h line 142 default `enabled=true`].
|
||||
|
||||
| Server counter | Reset | Effect at zero |
|
||||
| --- | --- | --- |
|
||||
| TCP transmit | 10 | `MsgHeartbeat` |
|
||||
| TCP receive | 15 | `MsgError` `"TCP heartbeat timeout"`, then close |
|
||||
| UDP transmit | 15 | `MsgUdpHeartbeat`, if a UDP destination is known |
|
||||
| UDP receive | 120 | `MsgError` `"UDP heartbeat timeout"`, then close |
|
||||
|
||||
[ReflectorClient.h lines 512–515; ReflectorClient.cpp `handleHeartbeat` lines 1259–1298.]
|
||||
|
||||
`MsgError` disables the server heartbeat timer, sends the error, and arms a one-shot disconnect timer of 10000 ms [ReflectorClient.cpp lines 1210–1226]. The client closes as soon as it unpacks `MsgError`.
|
||||
|
||||
## A.13 Historical protocol 1
|
||||
|
||||
This commit has no `ReflectorV1` client. The server still accepts major version 1, provided the minor version is at least 6 when the major is 0, and any minor when the major is 1, up through 3.0.
|
||||
|
||||
What is distinct for major version 1:
|
||||
|
||||
- UDP uses the version 2 header in Part B, because the encrypted header starts at major 3.
|
||||
- Authentication is the HMAC challenge in Part B.
|
||||
- After login the server sets the talk group to `TG_FOR_V1_CLIENTS`. The client cannot rely on its own `MsgSelectTG` staying in force; the next server-side `setTg` for that client uses the forced value at login [ReflectorClient.cpp lines 1366–1369].
|
||||
- Talker TCP messages are types 104 and 105 with a callsign and no talk-group field.
|
||||
- `MsgRequestQsy` is not sent to these clients.
|
||||
- If the version is below 0.7, `MsgServerInfo` is followed by `MsgNodeList` (type 101) carrying the same callsign vector.
|
||||
|
||||
Major 0, minor ≥ 6, is also on the challenge path and the version 2 UDP header. It is not in the “version 1” talker filter (`ProtoVer(1,0)` through `ProtoVer(1,999)`) and it is not in the “≥ 2.0” filter, so this server does not send it talker-start or talker-stop. It can still send `MsgSelectTG`. `MsgNodeList` is sent when the version is below 0.7 [Reflector.cpp lines 195–200 and 1360–1364].
|
||||
|
||||
## A.14 References for Part A
|
||||
|
||||
Blob prefix: `https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/`
|
||||
|
||||
| Topic | Symbol | Path |
|
||||
| --- | --- | --- |
|
||||
| Integer, string, vector, set packing | `MsgPacker`, `ASYNC_MSG_MEMBERS` | `src/async/core/AsyncMsg.h` |
|
||||
| TCP length prefix and max frame | `FramedTcpConnection::QueueItem`, `onDataReceived` | `src/async/core/AsyncFramedTcpConnection.h`, `.cpp` |
|
||||
| Type numbers and bodies | `ReflectorMsgBase`, `ReflectorUdpMsgBase` | `src/svxlink/reflector/ReflectorMsg.h` |
|
||||
| Header then body | `ReflectorClient::sendMsg` | `src/svxlink/reflector/ReflectorClient.cpp` |
|
||||
| Version limits | `MIN_MAJOR_VER`, `handleMsgProtoVer` | `src/svxlink/reflector/ReflectorClient.h`, `.cpp` |
|
||||
| Version comparison | `ProtoVer::operator<` | `src/svxlink/reflector/ProtoVer.h` |
|
||||
| Ports | `Reflector::initialize` | `src/svxlink/reflector/Reflector.cpp` |
|
||||
| Callsign | `Reflector::callsignOk` | `src/svxlink/reflector/Reflector.cpp` |
|
||||
| Codec list | `ReflectorClient` constructor | `src/svxlink/reflector/ReflectorClient.cpp` |
|
||||
| Talker and QSY | `onTalkerUpdated`, `requestQsy` | `src/svxlink/reflector/Reflector.cpp` |
|
||||
| Talker timeout | `TALKER_AUDIO_TIMEOUT`, `checkTimers` | `src/svxlink/reflector/TGHandler.h`, `TGHandler.cpp` |
|
||||
| Man-page defaults | `LISTEN_PORT`, `CODECS`, `ACCEPT_CALLSIGN` | `src/doc/man/svxreflector.conf.5` |
|
||||
|
||||
---
|
||||
|
||||
# Part B — SvxReflector Protocol Version 2.0
|
||||
|
||||
## B.1 Status of this memo
|
||||
|
||||
Implementation-derived description of the version 2.0 session at commit `fcd13014810af75d1852b3f2cfc8d9806178d0ab`. Not an IETF standard. Encoding rules, the message catalogue, callsign grammar, talk-group rules, and the server timers are Part A. This memo does not use the certificate or UDP-cipher procedures in Part C.
|
||||
|
||||
## B.2 Abstract
|
||||
|
||||
A client opens one TCP connection, sends protocol version 2.0, proves knowledge of a shared key with HMAC-SHA1 over a server nonce, receives a client id and a codec name, then exchanges plaintext UDP datagrams with that reflector on the same host and port. Audio for a talk group has a single talker. The UDP datagrams are not encrypted and are not authenticated beyond the source address and the client id.
|
||||
|
||||
## B.3 Table of contents
|
||||
|
||||
- B.4 Introduction
|
||||
- B.5 Terminology
|
||||
- B.6 Transport
|
||||
- B.7 UDP header
|
||||
- B.8 Session
|
||||
- B.9 State diagram
|
||||
- B.10 Timing
|
||||
- B.11 Audio
|
||||
- B.12 Security considerations
|
||||
- B.13 Differences from version 3.0
|
||||
- B.14 References
|
||||
- B.15 Unresolved
|
||||
|
||||
## B.4 Introduction
|
||||
|
||||
Version 2.0 is the session spoken by logic type `ReflectorV2`. On TCP connect that client sends `MsgProtoVer` with major 2 and minor 0 [ReflectorV2Logic.cpp lines 127 and 785–801]. FM-Funknetz’s public instruction to set `TYPE=ReflectorV2` selects this session. Whether a given production reflector still accepts it was not measured for this memo.
|
||||
|
||||
A client that only implements this memo MUST send major 2 (or another major less than 3 that this server accepts) and MUST implement the HMAC response. Sending major 3 selects Part C on this server.
|
||||
|
||||
## B.5 Terminology
|
||||
|
||||
The Part A keyword rule applies.
|
||||
|
||||
A client MUST send version 2.0 as its first TCP payload if it wants this session on this server. This server answers major values less than 3, down to 0.6, with the same HMAC exchange. Major 1 additionally follows Part A.13.
|
||||
|
||||
This server checks the HMAC. A client that sends a wrong digest receives `MsgError` `"Access denied"` and is not logged in.
|
||||
|
||||
## B.6 Transport
|
||||
|
||||
The client MUST connect TCP to the reflector. The stock client requires `HOSTS` or `DNS_DOMAIN` [ReflectorV2Logic.cpp lines 213–223]. UDP is sent to the TCP peer’s address and port, from an ephemeral local port, as in Part A.7.
|
||||
|
||||
Until `MsgAuthOk`, the stock client sets the maximum TCP payload to 64 bytes [ReflectorV2Logic.cpp line 801]. A version 2.0 `MsgAuthResponse` for a callsign that matches the default grammar fits: type (2) + callsign (2 + at most 12) + digest (2 + 20) is at most 38 bytes. After `MsgAuthOk` the client raises the maximum to 32768 [ReflectorV2Logic.cpp line 986].
|
||||
|
||||
This server’s receive maximum stays 64 until authentication, then becomes 32768. Its transmit maximum is 32768 from accept [ReflectorClient.cpp lines 199–200 and 1343].
|
||||
|
||||
## B.7 UDP header
|
||||
|
||||
For every major version less than 3, including 2.0, each UDP datagram is:
|
||||
|
||||
```
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| type (16) | client id (16) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| sequence (16) | |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +
|
||||
| message body |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
`type`, `client id`, and `sequence` are big-endian `uint16` [ReflectorUdpMsgV2, ReflectorMsg.h lines 210–257]. The body is A.6. The client id is the value from `MsgServerInfo`. The sender increments a `uint16` sequence once per datagram it transmits, starting at 0 when the TCP connection is established [ReflectorV2Logic.cpp lines 797–798 and 1479].
|
||||
|
||||
The receiver computes `uint16 diff = received - expected`, wrapping modulo 65536.
|
||||
|
||||
- If `diff > 32767`, the datagram is old. The stock client and this server drop it and do not advance the expected sequence.
|
||||
- If `diff` is 1 or more and at most 32767, the datagram is accepted and the expected sequence becomes `received + 1`. Lost numbers are not retransmitted.
|
||||
- If `diff` is 0, the datagram is the expected one and the expected sequence becomes `received + 1`.
|
||||
|
||||
[ReflectorV2Logic.cpp lines 1400–1417; Reflector.cpp lines 1129–1147.]
|
||||
|
||||
The stock client also drops a datagram whose source address is not the TCP peer, whose source port is not the TCP peer’s port, or whose client id is not the id from `MsgServerInfo` [ReflectorV2Logic.cpp lines 1366–1398].
|
||||
|
||||
This server, for a version less than 3, looks up the client by the header’s client id, drops the datagram if the source address is not that client’s TCP address, remembers the source port from the first accepted datagram, and then requires that port [Reflector.cpp lines 1056–1107]. The lookup does not require the datagram to be encrypted. `sendUdpDatagram` for version less than 3 writes the version 2 header and calls the plaintext UDP write. The sequence it writes is `udpCipherIVCntrNext() & 0xffff`, a `uint32` counter that starts at 0 and is post-incremented, truncated to 16 bits [Reflector.cpp lines 431–439; ReflectorClient.h line 481].
|
||||
|
||||
## B.8 Session
|
||||
|
||||
The stock client states that are actually assigned are: disconnected, expect authentication challenge, expect authentication ok, expect server info, connected [ReflectorV2Logic.h lines 198–202]. `STATE_AUTHENTICATED` sits in the enum and is not assigned; server-info handling requires the expect-server-info state, which is already past it, so node information is allowed.
|
||||
|
||||
### B.8.1 Connect and version
|
||||
|
||||
1. The client opens TCP. On connect it sends `MsgProtoVer(2, 0)`, zeros both UDP sequences, starts the 1-second timer, and waits for a challenge [ReflectorV2Logic.cpp lines 785–801].
|
||||
2. This server, for major 2, sends `MsgAuthChallenge`.
|
||||
|
||||
If the server sends `MsgProtoVerDowngrade`, this client prints that it cannot downgrade and disconnects [ReflectorV2Logic.cpp lines 926–940]. A client that wants to stay on 2.0 MUST treat a downgrade request as fatal or MUST send a new `MsgProtoVer` no higher than the server’s number and then speak only that version. This particular client does the former. This server will accept a following `MsgProtoVer(2, 0)`.
|
||||
|
||||
### B.8.2 Authentication
|
||||
|
||||
The challenge body is 20 random bytes (Part A.5). The client rejects a challenge whose vector length is not 20 [ReflectorV2Logic.cpp lines 962–968].
|
||||
|
||||
The client computes the digest as follows [ReflectorMsg.h `calcHMAC` lines 470–484]:
|
||||
|
||||
1. The key is the `AUTH_KEY` string’s raw bytes. The stock client refuses to start if `AUTH_KEY` is missing, empty, or the placeholder `Change this key now!` [ReflectorV2Logic.cpp lines 263–274]. The key is not hex-decoded and is not sent on the wire.
|
||||
2. The MAC is HMAC-SHA1: OpenSSL `EVP_PKEY_HMAC` via `newRawPrivateKey`, then `signInit("sha1")`, over exactly the 20 challenge bytes (`MsgAuthChallenge::LENGTH`). The callsign is not an input to the MAC.
|
||||
3. The MAC output is 20 bytes.
|
||||
|
||||
The client sends `MsgAuthResponse`: the callsign string, then the digest vector. The callsign is the configured string, without case conversion [ReflectorV2Logic.cpp line 970].
|
||||
|
||||
This server then:
|
||||
|
||||
1. Requires state expect-auth-response. Otherwise `MsgError` `"Authentication response unexpected"`.
|
||||
2. Requires the body to unpack. Otherwise `"Illegal MsgAuthResponse protocol message received"`.
|
||||
3. Requires `callsignOk` (Part A.9). Otherwise `"Invalid callsign"`.
|
||||
4. Looks up `[USERS]` for that exact callsign to get a password-group name, then `[PASSWORDS]` for that group to get the key string. It recomputes HMAC-SHA1 and compares with `CRYPTO_memcmp`. Empty key or a mismatch yields `"Access denied"` [ReflectorClient.cpp lines 718–781 and `lookupUserKey` lines 1315–1333].
|
||||
5. If the callsign is already in the connected-callsign map, `"Access denied"`.
|
||||
|
||||
On success this server sets the receive frame maximum to 32768, sends `MsgAuthOk`, sends `MsgServerInfo`, sends `MsgNodeList` only when the version is < 0.7, forces the talk group when the version is < 2.0, and broadcasts `MsgNodeJoined` to the other clients [ReflectorClient.cpp lines 1336–1371].
|
||||
|
||||
### B.8.3 Server info, codec, node info, talk group
|
||||
|
||||
The client accepts `MsgServerInfo` only in the expect-server-info state. It stores the client id. It selects the first codec name it can both encode and decode (Part A.10). If none match, it logs that and continues; it does not disconnect. The encoder remains the `DUMMY` encoder installed at startup, so it will not emit audio [ReflectorV2Logic.cpp lines 990–1052 and 341].
|
||||
|
||||
It then builds a single-line JSON object and sends it as `MsgNodeInfoV2` (type 111, one string). The stock object contains at least:
|
||||
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| `sw` | `"SvxLink"` |
|
||||
| `swVer` | the SvxLink application version string |
|
||||
| `projVer` | the project version string |
|
||||
|
||||
If `NODE_INFO_FILE` is set, that JSON is parsed first and those three keys are then overwritten. Version 2.0 does not add `machineArch` [ReflectorV2Logic.cpp lines 425–460 and 1061–1069].
|
||||
|
||||
A client SHOULD send type 111 in the version 2 layout (a single string). This server selects the layout from the announced major version. A major below 3 that sends the version 3 layout fails the unpack and receives `"Illegal MsgNodeInfo protocol message received"` [ReflectorClient.cpp lines 917–950]. A JSON value this server cannot parse produces a warning and does not drop the connection. No JSON keys are required for audio.
|
||||
|
||||
If the client’s configured talk group is non-zero, it sends `MsgSelectTG`. If it has monitor talk groups, it sends `MsgTgMonitor`. It then sends a UDP heartbeat [ReflectorV2Logic.cpp lines 1117–1128].
|
||||
|
||||
`MsgSelectTG` and `MsgTgMonitor` may be sent again later. The stock client also sends `MsgRequestQsy` when its local command logic asks for a QSY [ReflectorV2Logic.cpp lines 516–525]. A client that never sends type 109 is accepted.
|
||||
|
||||
### B.8.4 Audio, flush, and talker indications
|
||||
|
||||
The client sends `MsgUdpAudio` when the encoder emits a frame, and `MsgUdpFlushSamples` when the encoder finishes a transmission. It arms a 3000 ms one-shot timer on flush. If that timer fires, it tells the encoder that all samples are flushed, which causes `MsgUdpAllSamplesFlushed` [ReflectorV2Logic.cpp lines 1331–1355 and 1529–1538].
|
||||
|
||||
This server, on `MsgUdpFlushSamples` from the current talker, clears the talker and immediately sends `MsgUdpAllSamplesFlushed` back to that client. It does not wait for the other clients. The source comment says waiting would not scale [Reflector.cpp lines 1232–1250]. Incoming `MsgUdpAllSamplesFlushed` is ignored by the server. The client uses it to complete the local encoder flush [ReflectorV2Logic.cpp lines 1449–1451].
|
||||
|
||||
`MsgTalkerStart` for version 2.0 includes the talk group. The stock client, if it is idle, selects that talk group. If it is using monitor priorities and the new talk group has a higher priority, it switches. It does not drop the TCP session when it ignores a talker message [ReflectorV2Logic.cpp lines 1192–1234]. A client that has already selected one talk group and does not want to follow other talk groups MUST still accept the message, and MAY ignore it. Audio does not wait for the TCP message: the client writes any non-empty audio payload from an accepted UDP datagram into the decoder [ReflectorV2Logic.cpp lines 1426–1441].
|
||||
|
||||
`MsgTalkerStop` is logged and emitted as a local event. The stock client does not treat it as the end of audio by itself. The decoder is flushed by `MsgUdpFlushSamples`, or by the local 3-second gap rule in B.10.
|
||||
|
||||
`MsgNodeJoined` and `MsgNodeLeft` are informational. A failed unpack disconnects the stock client.
|
||||
|
||||
### B.8.5 Errors and disconnect
|
||||
|
||||
This client disconnects on `MsgError`, on a message that fails to unpack, on an unexpected challenge, authentication-ok, or server-info, and on a TCP or UDP heartbeat timeout [ReflectorV2Logic.cpp `handleTimerTick` lines 1542–1576]. It does not send its own `MsgError` in those paths.
|
||||
|
||||
This server’s `MsgError` strings for this session are:
|
||||
|
||||
| String | Condition |
|
||||
| --- | --- |
|
||||
| `Protocol error` | TCP type field failed to unpack |
|
||||
| `Protocol version expected` | `MsgProtoVer` outside the expect-version state |
|
||||
| `Illegal MsgProtoVer protocol message received` | Version body failed to unpack |
|
||||
| `Unsupported protocol version M.N. May be at most 3.0.` | Version above 3.0 after a downgrade was already offered |
|
||||
| `Unsupported protocol version M.N. Must be at least 0.6.` | Version below 0.6 |
|
||||
| `Authentication response unexpected` | Response in the wrong state |
|
||||
| `Illegal MsgAuthResponse protocol message received` | Response body failed to unpack |
|
||||
| `Invalid callsign` | `callsignOk` failed |
|
||||
| `Access denied` | Unknown user, bad HMAC, or callsign already connected |
|
||||
| `Illegal MsgSelectTG protocol message received` | Body failed to unpack |
|
||||
| `Illegal MsgTgMonitor protocol message received` | Body failed to unpack |
|
||||
| `Illegal MsgNodeInfo protocol message received` | Body failed to unpack |
|
||||
| `Illegal MsgRequestQsy protocol message received` | Body failed to unpack |
|
||||
| `Illegal MsgStateEvent protocol message received` | Body failed to unpack |
|
||||
| `TCP heartbeat timeout` | No TCP frame for 15 seconds |
|
||||
| `UDP heartbeat timeout` | No accepted UDP datagram for 120 seconds |
|
||||
|
||||
Application messages (type ≥ 100) before authentication are discarded with a warning. This server does not send `MsgError` for them [ReflectorClient.cpp lines 516–526]. The stock client disconnects if it receives type ≥ 100 before `MsgAuthOk` [ReflectorV2Logic.cpp lines 852–857]. The stock client also refuses to send type ≥ 100 before that point [ReflectorV2Logic.cpp lines 1304–1310].
|
||||
|
||||
There is no application-level close message. Either side closes the TCP connection. This server then broadcasts `MsgNodeLeft` if the client had a callsign [Reflector.cpp lines 865–869].
|
||||
|
||||
### B.8.6 Messages a minimal client may ignore
|
||||
|
||||
A client that only talks on one preselected talk group still needs types 1, 5, 6, 10, 11, 12, 13, 100, and 111 in the version 2 layout, plus UDP types 1, 101, 102, and 103. It SHOULD send 106. It MAY ignore, after a successful unpack, types 101, 102, 103, 104, 105, 109, and 110. It MAY omit 107, 109, 110, 112, and 113. If it sends 112 or 113, the bodies are Part A.5. This server will not send types 14–21 or 114 to a version 2.0 client.
|
||||
|
||||
## B.9 State diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> ExpectChallenge: TCP connected, send version 2.0
|
||||
ExpectChallenge --> ExpectAuthOk: MsgAuthChallenge, send HMAC response
|
||||
ExpectAuthOk --> ExpectServerInfo: MsgAuthOk
|
||||
ExpectServerInfo --> Connected: MsgServerInfo, send node info, UDP heartbeat
|
||||
ExpectChallenge --> [*]: MsgError, downgrade, or timeout
|
||||
ExpectAuthOk --> [*]: unexpected message or timeout
|
||||
ExpectServerInfo --> [*]: unexpected message or timeout
|
||||
Connected --> [*]: MsgError, timeout, or TCP close
|
||||
```
|
||||
|
||||
This server’s states for this session are expect-version, then expect-auth-response, then connected. The certificate states in Part C are not used.
|
||||
|
||||
## B.10 Timing
|
||||
|
||||
Client constants are Part A.12. Additional client constants [ReflectorV2Logic.h lines 207–212; ReflectorV2Logic.cpp lines 142–143]:
|
||||
|
||||
| Timer | Value |
|
||||
| --- | --- |
|
||||
| Heartbeat tick | 1000 ms |
|
||||
| Encoder flush timeout | 3000 ms, one-shot |
|
||||
| Local gap after the last received audio | `diff.tv_sec > 3`, then the decoder is flushed |
|
||||
| Default talk-group select timeout | 30 s |
|
||||
| Default temporary monitor timeout | 3600 s |
|
||||
|
||||
The select and monitor timeouts are local UI behavior of the SvxLink logic. This server does not time out a selected talk group.
|
||||
|
||||
Server talker and heartbeat limits are Part A.11 and A.12. The first UDP heartbeat from the client both creates the return path and resets the server’s UDP receive counter. A client that waits longer than 120 seconds after TCP accept before sending any UDP datagram receives `"UDP heartbeat timeout"`. The client’s own UDP receive limit is 60 seconds from TCP connect.
|
||||
|
||||
## B.11 Audio
|
||||
|
||||
The codec name is chosen in B.8.3. The payload of UDP type 101 is a `uint16` length and then that many codec bytes.
|
||||
|
||||
When the name is `OPUS`, the encoder in this tree is libopus with these settings [AsyncAudioEncoderOpus.cpp constructor lines 117–138; `writeSamples` lines 632–647; src/CMakeLists.txt lines 50–53]:
|
||||
|
||||
| Parameter | Value |
|
||||
| --- | --- |
|
||||
| Sample rate | `INTERNAL_SAMPLE_RATE`, CMake default 16000 |
|
||||
| Channels | 1 |
|
||||
| Application | `OPUS_APPLICATION_AUDIO` |
|
||||
| Frame size | 20 ms, which is 320 samples at 16000 Hz |
|
||||
| Bitrate | 20000 bit/s |
|
||||
| VBR | enabled |
|
||||
| Maximum bandwidth | `OPUS_BANDWIDTH_MEDIUMBAND` |
|
||||
| Bandwidth | `OPUS_AUTO` |
|
||||
| Signal type | `OPUS_SIGNAL_VOICE` |
|
||||
| DTX | disabled |
|
||||
| LSB depth | 16 when `OPUS_MAJOR > 0` |
|
||||
| API | `opus_encode_float` on float samples |
|
||||
|
||||
The bytes passed to `writeEncodedSamples` are one raw Opus packet, not an Ogg page. A positive `nbytes` becomes one UDP datagram. Complexity, in-band FEC, and expected packet-loss percent are not set in the constructor; they stay at the libopus defaults unless the client configuration sets `OPUS_ENC_<OPTION>`. A peer MUST decode that Opus packet at 16000 Hz, mono. `opus_encode` of int16 PCM with the same controls produces the same packet format.
|
||||
|
||||
`SPEEX`, `GSM`, `S16`, and `RAW` are alternative names. Each datagram is one output of that encoder, with the same `uint16` length prefix. This memo does not restate those encoders’ internal frames. The stock server prefers `OPUS` when it is compiled in.
|
||||
|
||||
There is no packet-loss concealment in the reflector session. A gap advances the sequence and the next packet is decoded when it arrives. The stock client logs the gap and writes the received packet to the decoder [ReflectorV2Logic.cpp lines 1409–1440].
|
||||
|
||||
## B.12 Security considerations
|
||||
|
||||
The shared key is authenticated at login and is not transmitted. The proof is HMAC-SHA1 over the 20-byte nonce, compared with `CRYPTO_memcmp` [ReflectorMsg.h lines 456–484]. SHA-1 is the algorithm this code names (`"sha1"`). The challenge is 20 bytes from `RAND_bytes`.
|
||||
|
||||
After login, TCP frames and UDP datagrams are plaintext. They have no MAC. An on-path attacker can read audio and callsigns, and can change, drop, or insert TCP frames and UDP datagrams. The server accepts a version 2 UDP datagram when the client id is known and the source address equals the TCP peer. The client accepts a datagram when the source address and port equal the TCP peer. Those checks bind the datagram to the path’s addresses; they do not authenticate the payload.
|
||||
|
||||
The sequence number rejects a datagram that looks more than half the `uint16` space behind the expected value. It is not a security association. A later sequence number is accepted and moves the window.
|
||||
|
||||
The login HMAC is not repeated on later messages. A captured `MsgAuthResponse` does not reveal the key, and it does not by itself open a new session, because the server generates a new challenge. Replaying it against a new challenge fails the MAC.
|
||||
|
||||
Callsigns and node JSON travel in the clear. `MsgError` strings in B.8.5 distinguish an illegal callsign from a bad key.
|
||||
|
||||
## B.13 Differences from version 3.0
|
||||
|
||||
Version 2.0 announces major 2. It authenticates with HMAC-SHA1 and `AUTH_KEY`. Its UDP header is the 6-byte plaintext header in B.7. Node information is type 111 with only a JSON string. It never sends types 14–21 or 114, and this server does not send those to it. Version 3.0 is Part C.
|
||||
|
||||
## B.14 References
|
||||
|
||||
Blob prefix: `https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/`
|
||||
|
||||
| Section | Symbol | Path and lines read |
|
||||
| --- | --- | --- |
|
||||
| B.4, B.8.1 | `proto_ver(2, 0)`, `onConnected` | `src/svxlink/svxlink/ReflectorV2Logic.cpp` 127, 785–801 |
|
||||
| B.7 | `ReflectorUdpMsgV2`, `sendUdpMsg`, `udpDatagramReceived` | `ReflectorMsg.h` 210–257; `ReflectorV2Logic.cpp` 1358–1489; `Reflector.cpp` 406–440, 1056–1147 |
|
||||
| B.8.2 | `MsgAuthResponse::calcHMAC`, `handleMsgAuthResponse` | `ReflectorMsg.h` 412–485; `ReflectorClient.cpp` 718–781; `ReflectorV2Logic.cpp` 944–972 |
|
||||
| B.8.3 | `handleMsgServerInfo` | `ReflectorV2Logic.cpp` 990–1130 |
|
||||
| B.8.4 | `sendEncodedAudio`, `flushEncodedAudio`, `handleMsgTalkerStart` | `ReflectorV2Logic.cpp` 1192–1254, 1331–1355; `Reflector.cpp` 1159–1250 |
|
||||
| B.8.5 | `sendError`, `handleHeartbeat` | `ReflectorClient.cpp` 1210–1298 |
|
||||
| B.10 | timer constants | `ReflectorV2Logic.h` 207–212 |
|
||||
| B.11 | `AudioEncoderOpus` | `src/async/audio/AsyncAudioEncoderOpus.cpp` 117–138, 632–647; `src/CMakeLists.txt` 50–53 |
|
||||
|
||||
Part A references apply to every encoding claim.
|
||||
|
||||
## B.15 Unresolved
|
||||
|
||||
- **Inference:** Whether the production FM-Funknetz reflector still speaks version 2.0. The network’s pages tell operators to use `TYPE=ReflectorV2`. This memo did not open a connection to that reflector.
|
||||
- Packet-loss concealment is outside this protocol. The implementation logs a sequence gap and delivers the packets it has.
|
||||
- The man page’s one-second default for `SQL_TIMEOUT_BLOCKTIME` does not match the binary default of 60 seconds (Part A.11).
|
||||
- `TG_FOR_V1_CLIENTS` is 1 in the binary initializer and 999 in the shipped configuration template.
|
||||
- The stock version 2.0 client does not disconnect when it understands no advertised codec. What a different server does in that case is not defined here.
|
||||
- `ProtoVer::operator!=` ignores the minor number. The version 2.0 handshake does not call it.
|
||||
|
||||
---
|
||||
|
||||
# Part C — SvxReflector Protocol Version 3.0
|
||||
|
||||
## C.1 Status of this memo
|
||||
|
||||
Implementation-derived description of the version 3.0 session at commit `fcd13014810af75d1852b3f2cfc8d9806178d0ab`. Not an IETF standard. Encoding, the catalogue, callsigns, talk groups, and server timers are Part A. HMAC-SHA1 as the primary login of version 2.0 is Part B. This memo specifies the certificate session and the UDP cipher. A reader does not need Part B’s UDP header to implement version 3.0.
|
||||
|
||||
## C.2 Abstract
|
||||
|
||||
A client opens one TCP connection, announces protocol 3.0, optionally fetches a CA bundle, and then both sides speak TLS 1.2 or later on that TCP connection. The client is identified by the common name of its certificate, or, if it has none yet, by a certificate signing request and a possible HMAC fallback. It sends the reflector a random AES-128 key inside TLS. UDP datagrams are AES-128-GCM with an 8-byte tag. Audio forwarding rules are the same as Part A.11.
|
||||
|
||||
## C.3 Table of contents
|
||||
|
||||
- C.4 Introduction
|
||||
- C.5 Terminology
|
||||
- C.6 Transport
|
||||
- C.7 UDP datagram
|
||||
- C.8 Session
|
||||
- C.9 State diagram
|
||||
- C.10 Timing
|
||||
- C.11 Audio
|
||||
- C.12 Security considerations
|
||||
- C.13 Differences from version 2.0
|
||||
- C.14 References
|
||||
- C.15 Unresolved
|
||||
|
||||
## C.4 Introduction
|
||||
|
||||
Version 3.0 is the session spoken by logic type `Reflector`. The `MsgProtoVer` class defaults are major 3 and minor 0, and this client sends that default [ReflectorLogic.cpp line 159 and lines 984–1003]. A downgrade to version 2 is present only inside `#if 0`. This client disconnects on `MsgProtoVerDowngrade` [ReflectorLogic.cpp lines 1226–1255].
|
||||
|
||||
This server treats major ≥ 3 as this session [ReflectorClient.cpp lines 649–656].
|
||||
|
||||
## C.5 Terminology
|
||||
|
||||
A client MUST send major 3 and minor 0, or another version that this server maps onto this session (major ≥ 3 and ≤ 3.0), as its first TCP payload.
|
||||
|
||||
A client MUST complete TLS after `MsgStartEncryption` before sending `MsgNodeInfo`. A client MUST put a 16-byte AES-128 key and a 6-byte IV random in that message. This server stores those fields and uses them as the UDP cipher key and IV prefix.
|
||||
|
||||
The HMAC challenge is not the normal login of this session. It remains a server fallback after a signing request, specified in C.8.4.
|
||||
|
||||
## C.6 Transport
|
||||
|
||||
TCP and UDP addresses are Part A.7. The version 3.0 client sets the TCP payload maximum to 32768 at construction and leaves it there [ReflectorLogic.cpp lines 219–220]. This server still starts at a receive maximum of 64. It raises that to 4096 when it sends `MsgStartEncryption`, and to 32768 when authentication completes [ReflectorClient.cpp lines 199–200, 711, and 1343].
|
||||
|
||||
Messages before `MsgStartEncryption` are plaintext frames from Part A.4. After both sides call `enableSsl`, further writes are TLS application data. The 4-byte length and the reflector payload sit inside the TLS records. The TLS object is `TLS_method()` with minimum version `TLS1_2_VERSION` and `SSL_OP_ALL` [AsyncSslContext.h lines 138–147]. `enableSsl` uses `SSL_set_accept_state` on the server and `SSL_set_connect_state` on the client, and sets `SSL_VERIFY_PEER` [AsyncTcpConnection.cpp lines 251–280]. `SSL_VERIFY_FAIL_IF_NO_PEER_CERT` is not set, so a client certificate is optional at the TLS layer.
|
||||
|
||||
The client’s UDP socket is `EncryptedUdpSocket` with local port 0 [ReflectorLogic.cpp lines 1712–1714].
|
||||
|
||||
## C.7 UDP datagram
|
||||
|
||||
### C.7.1 Cipher
|
||||
|
||||
The cipher name is the string `AES-128-GCM` [ReflectorMsg.h `UdpCipher::NAME`, line 1504]. The key length is the OpenSSL key length of that cipher, which is 16 bytes. The client generates it with `randomBytes` and `setCipherKey()` [ReflectorLogic.cpp lines 1715–1722; AsyncEncryptedUdpSocket.cpp lines 218–227].
|
||||
|
||||
Constants [ReflectorMsg.h lines 1504–1509]:
|
||||
|
||||
| Name | Value |
|
||||
| --- | --- |
|
||||
| Associated data for an ordinary datagram | 4 bytes |
|
||||
| Tag | 8 bytes |
|
||||
| IV | 12 bytes |
|
||||
| IV random prefix | 12 − 4 − 2 = 6 bytes |
|
||||
|
||||
The tag is the GCM tag truncated by `EVP_CTRL_AEAD_GET_TAG` / `EVP_CTRL_AEAD_SET_TAG` to 8 bytes [AsyncEncryptedUdpSocket.cpp lines 311–315 and 397–412]. Both sides call `setTagLength(8)` [Reflector.cpp lines 310–311; ReflectorLogic.cpp line 1732].
|
||||
|
||||
### C.7.2 IV
|
||||
|
||||
The 12-byte IV is packed as [ReflectorMsg.h `UdpCipher::IV`, lines 1528–1581]:
|
||||
|
||||
```
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| random prefix (6 bytes) |
|
||||
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| | client id (16) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| counter (32, big-endian) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
The random prefix is the 6 bytes from the client’s `MsgNodeInfo`. If the vector is shorter, the remaining prefix bytes are 0. Extra bytes are ignored.
|
||||
|
||||
Direction changes the client-id field:
|
||||
|
||||
- Client to server: the client id from `MsgServerInfo` [ReflectorLogic.cpp `sendUdpMsg` lines 2252–2253].
|
||||
- Server to client: the client-id field is 0 [ReflectorClient.cpp `udpCipherIV` lines 365–367]. The client’s receive path sets the same field to 0 [ReflectorLogic.cpp lines 2090–2091].
|
||||
|
||||
The counter is a `uint32`.
|
||||
|
||||
### C.7.3 Associated data and on-wire order
|
||||
|
||||
Ordinary associated data is the counter alone, 4 bytes, big-endian [ReflectorMsg.h `UdpCipher::AAD`, lines 1511–1517].
|
||||
|
||||
The client’s first datagram uses `InitialAAD`, which packs the parent counter (value 0) and then the real client id as a `uint16`. That associated data is 6 bytes [ReflectorMsg.h lines 1519–1526; ReflectorLogic.cpp `sendUdpRegisterMsg` lines 2278–2281].
|
||||
|
||||
`EncryptedUdpSocket::write` lays the datagram out as associated data, then the 8-byte tag, then the ciphertext [AsyncEncryptedUdpSocket.cpp lines 278–329]:
|
||||
|
||||
```
|
||||
0 1 2 3
|
||||
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| associated data (4 bytes, or 6 on registration) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| GCM tag (8 bytes) |
|
||||
+ +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
| | |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +
|
||||
| ciphertext of (uint16 type || message body) |
|
||||
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
||||
```
|
||||
|
||||
The plaintext inside the cipher is a version 3 UDP message: `uint16` type, then the A.6 body. There is no client id and no sequence in that plaintext. The counter in the IV and in the associated data is the sequence.
|
||||
|
||||
GCM additional data is the associated-data bytes. They are copied in the clear and also passed to `EVP_EncryptUpdate` as additional data. The plaintext is encrypted with `EVP_EncryptUpdate` / `EVP_EncryptFinal_ex`. The receiver checks the tag with `EVP_DecryptFinal_ex`. A failed tag is not delivered upward [AsyncEncryptedUdpSocket.cpp lines 370–433].
|
||||
|
||||
### C.7.4 Sequence
|
||||
|
||||
The client’s registration uses counter 0 and does not consume the send counter. That send counter is set to 1 before the socket is created, and later datagrams post-increment it [ReflectorLogic.cpp lines 1713 and 2268–2274].
|
||||
|
||||
This server’s send counter starts at 0 and is post-incremented, so the first server datagram uses counter 0 [ReflectorClient.cpp line 197; ReflectorClient.h line 481].
|
||||
|
||||
On receive, a counter less than the expected counter is dropped. A counter greater than expected is accepted and the expected value becomes `received + 1`. The comparison is unsigned and has no 32767-wide window [ReflectorLogic.cpp lines 2144–2160; Reflector.cpp lines 1109–1127]. The client’s expected counter starts at 0, which matches the server’s first datagram.
|
||||
|
||||
This server peeks the first 4 bytes. If that counter is 0, it treats the datagram as registration: the associated-data length is 6, the IV’s client-id field is the real client id, and the client id is taken from those extra two bytes [Reflector.cpp `udpCipherDataReceived` lines 899–942]. Any other counter uses associated-data length 4 and looks up the client by source address and port. A counter of 0 always executes `setUdpRxSeq(0)` before the rest of the receive path, including when the client was already registered [Reflector.cpp lines 1014–1044].
|
||||
|
||||
The client’s receive path always uses a 4-byte associated data and an IV client-id of 0, because this server does not send `InitialAAD`.
|
||||
|
||||
While the client is still waiting for the first server UDP heartbeat, expiry of the UDP transmit interval sends the registration datagram again (`sendUdpRegisterMsg`), which repeats counter 0 under the same key and the same IV [ReflectorLogic.cpp lines 2350–2355].
|
||||
|
||||
## C.8 Session
|
||||
|
||||
### C.8.1 Plaintext preface
|
||||
|
||||
1. The client connects TCP and sends `MsgProtoVer` 3.0. It waits for `MsgCAInfo` [ReflectorLogic.cpp lines 984–1003].
|
||||
2. This server sends `MsgCAInfo`: a `uint16` equal to the CA bundle PEM size, and a `vector<uint8_t>` that is SHA-256 of those PEM bytes (`MsgCABundle::MD_ALG` is `"sha256"`) [Reflector.cpp lines 1710–1720; ReflectorMsg.h line 680].
|
||||
3. The client requests `MsgCABundle` only when `CERT_DOWNLOAD_CA_BUNDLE` is true (the default) and the local CA file is missing or unreadable. If the file exists and its SHA-256 differs, the client logs a warning, does not request a replacement, and continues with the file it has [ReflectorLogic.cpp lines 1328–1392]. The source comment says the new bundle is not verified against the old one, so an existing file is kept.
|
||||
4. On a bundle request, this server sends `MsgCABundle`: CA PEM, a signature, and the issuing certificate PEM. The signature is SHA-256 over the CA PEM bytes, produced with the issuing CA private key [Reflector.cpp lines 1719–1720]. The client verifies that signature with the public key in the supplied certificate PEM [ReflectorLogic.cpp lines 1433–1449]. The following source comment says the signing certificate is not checked against the previous CA. On success the client writes the PEM and continues. On failure it disconnects.
|
||||
5. The client sends `MsgStartEncryptionRequest`.
|
||||
6. This server, in the expect-start-encryption state, raises the receive maximum to 4096, sends `MsgStartEncryption`, and calls `enableSsl(true)` [ReflectorClient.cpp lines 684–714].
|
||||
7. The client, on `MsgStartEncryption`, calls `enableSsl(true)` and waits for the TLS handshake [ReflectorLogic.cpp lines 1485–1501].
|
||||
|
||||
`MsgCABundleRequest` or `MsgStartEncryptionRequest` in any other server state closes the TCP connection without `MsgError`.
|
||||
|
||||
### C.8.2 TLS identity
|
||||
|
||||
The client generates a 2048-bit RSA key if `CERT_KEYFILE` does not exist [ReflectorLogic.cpp lines 323–329]. Its CSR is version 1, subject CN equal to `CALLSIGN`, basic constraints `critical, CA:FALSE`, key usage `critical, digitalSignature, keyEncipherment, keyAgreement`, and extended key usage `clientAuth`. Optional `CERT_EMAIL` values are subject-alternative names of type email. The CSR is signed by the client’s own key [ReflectorLogic.cpp lines 346–397].
|
||||
|
||||
The client checks the server certificate after the handshake:
|
||||
|
||||
- OpenSSL verification must return `X509_V_OK`.
|
||||
- The peer certificate must match the remote hostname (`matchHost`, with one trailing dot removed) or the remote IP (`matchIp`).
|
||||
|
||||
Otherwise the client disconnects [ReflectorLogic.cpp lines 1058–1103].
|
||||
|
||||
This server, when the handshake completes:
|
||||
|
||||
- If the peer certificate is null, it sends `MsgClientCsrRequest` and waits for `MsgClientCsr` [ReflectorClient.cpp lines 425–432].
|
||||
- If a certificate is present, the common name must pass `callsignOk`. Failure closes the connection without `MsgError`. Success authenticates that common name as the callsign and continues at C.8.5 [ReflectorClient.cpp lines 447–477].
|
||||
|
||||
### C.8.3 Certificate provisioning
|
||||
|
||||
`MsgClientCsr` carries the PEM. This server accepts it in the expect-CSR state or after the client is already connected. It rejects a PEM that is not a CSR with `"Invalid CSR received"`. `checkCsr` returns one of these strings, which are sent as `MsgError` [Reflector.cpp lines 741–754]:
|
||||
|
||||
- `Certificate signing request with invalid callsign '<CN>'`
|
||||
- `Certificate signing request with no or invalid CERT_EMAIL`
|
||||
|
||||
If the server has a certificate whose public key matches the pending CSR, it sends `MsgClientCert` (PEM) and waits to disconnect. The stock client writes that certificate and reconnects [ReflectorLogic.cpp `handleMsgClientCert` around lines 1532–1617; ReflectorClient.cpp lines 833–844].
|
||||
|
||||
If this server is still in expect-CSR and has no matching certificate, it logs that fact and falls through to `sendAuthChallenge` [ReflectorClient.cpp lines 845–850].
|
||||
|
||||
### C.8.4 HMAC fallback
|
||||
|
||||
The challenge and HMAC-SHA1 construction are Part B.8.2. This server sends that challenge only on the path in C.8.3, after TLS is up. The version 3.0 client handles `MsgAuthChallenge` only in the expect-auth-answer state, which is where it sits after the handshake and after a CSR request (the CSR handler does not change state). It reads `AUTH_KEY` if present and sends `MsgAuthResponse` [ReflectorLogic.cpp lines 1259–1291]. `AUTH_KEY` is not required for the client to start. An empty key is what gets signed if the key is unset.
|
||||
|
||||
`MsgAuthOk` in the expect-auth-answer state moves the client to expect-server-info. If the local CSR file is newer than the certificate file, the client sends `MsgClientCsr` again [ReflectorLogic.cpp lines 1295–1324].
|
||||
|
||||
### C.8.5 Server info and UDP key
|
||||
|
||||
`MsgAuthOk` and `MsgServerInfo` follow a successful certificate common name or a successful HMAC, as in Part B.8.2, including `MsgNodeJoined` and the version < 0.7 node list. A version 3.0 client is not forced onto `TG_FOR_V1_CLIENTS`.
|
||||
|
||||
On `MsgServerInfo` the client selects a codec. If no name works, it disconnects. That is different from version 2.0 [ReflectorLogic.cpp lines 1662–1684].
|
||||
|
||||
It then creates the encrypted UDP socket, generates the 6-byte IV prefix and the 16-byte key, and sends `MsgNodeInfo` (type 111):
|
||||
|
||||
1. `vector<uint8_t>` IV random (6 bytes)
|
||||
2. `vector<uint8_t>` AES key (16 bytes)
|
||||
3. `string` JSON
|
||||
|
||||
The JSON is single-line and contains `sw`, `swVer`, `projVer`, and, when `uname` succeeds, `machineArch`. `NODE_INFO_FILE` is merged first, then those keys are overwritten [ReflectorLogic.cpp lines 640–648 and 1740–1749].
|
||||
|
||||
This server, for major ≥ 3, stores the IV random and the key and then sends `MsgStartUdpEncryption` before it parses the JSON. A JSON parse error is a warning. An unpack failure of type 111 is `"Illegal MsgNodeInfo protocol message received"` [ReflectorClient.cpp lines 917–937].
|
||||
|
||||
The client, on `MsgStartUdpEncryption`, sends the registration datagram: `InitialAAD` with its client id, plaintext UDP type 1 (heartbeat), encrypted as in C.7 [ReflectorLogic.cpp lines 1983–2004].
|
||||
|
||||
This server’s first accepted registration datagram records the UDP source and sends a UDP heartbeat back (counter 0, IV client-id field 0). The client accepts that datagram only from the TCP peer’s address and port. When the plaintext type is heartbeat and the client is waiting for one, it marks the connection established, sends `MsgSelectTG` if its talk group is non-zero, and sends `MsgTgMonitor` if it has monitor talk groups [ReflectorLogic.cpp lines 2164–2183]. Audio sent before that heartbeat is dropped, because `isLoggedIn` is still false [ReflectorLogic.cpp lines 2186–2189].
|
||||
|
||||
### C.8.6 Audio and the rest of the catalogue
|
||||
|
||||
After the state is connected, `MsgUdpAudio`, `MsgUdpFlushSamples`, and `MsgUdpAllSamplesFlushed` have the same talk-group, talker, and flush behavior as Part A.11 and Part B.8.4. The bytes are the plaintext of C.7, not the version 2 header.
|
||||
|
||||
Talker TCP messages for this client use the major ≥ 2 layout (talk group plus callsign). `MsgRequestQsy` is sent to this client when it is on the requester’s talk group.
|
||||
|
||||
TCP types 102, 103, 104, 105, and 109 are informational in the same way as in Part B. Signal strength and transmitter status are TCP 112 and 113. The stock client sends them from local receiver and transmitter events; a headset client MAY omit them.
|
||||
|
||||
### C.8.7 Errors and disconnect
|
||||
|
||||
`MsgError` handling and the server strings in Part B.8.5 apply, plus the CSR strings in C.8.3. Additional version 3.0 client disconnects, without a reflector `MsgError`, are: unexpected CA, bundle, or start-encryption messages; a bad CA signature; TLS verification failure; hostname mismatch; an unexpected `MsgStartUdpEncryption`; failure to construct the AES-GCM socket.
|
||||
|
||||
Unexpected `MsgCABundleRequest` and `MsgStartEncryptionRequest` make this server call `disconnect()` with no `MsgError` [ReflectorClient.cpp lines 668–674 and 689–695].
|
||||
|
||||
Application types before authentication are discarded by this server with a warning, and they make this client disconnect [ReflectorLogic.cpp lines 1132–1138].
|
||||
|
||||
## C.9 State diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> ExpectCA: TCP connected, send version 3.0
|
||||
ExpectCA --> ExpectBundle: local CA missing, send bundle request
|
||||
ExpectBundle --> ExpectStartEnc: bundle stored, send start-encryption request
|
||||
ExpectCA --> ExpectStartEnc: local CA usable, send start-encryption request
|
||||
ExpectStartEnc --> ExpectTls: MsgStartEncryption, start TLS
|
||||
ExpectTls --> ExpectAuthAnswer: TLS ready, peer verified
|
||||
ExpectAuthAnswer --> ExpectServerInfo: certificate CN accepted, or HMAC MsgAuthOk
|
||||
ExpectAuthAnswer --> ExpectAuthAnswer: CSR requested or certificate PEM received
|
||||
ExpectServerInfo --> ExpectUdpEnc: MsgServerInfo, send type 111 with key
|
||||
ExpectUdpEnc --> ExpectUdpHeartbeat: MsgStartUdpEncryption, send registration
|
||||
ExpectUdpHeartbeat --> Connected: UDP heartbeat from the TCP peer
|
||||
Connected --> [*]: MsgError, timeout, or TCP close
|
||||
```
|
||||
|
||||
This server’s states for this session are: expect-version, expect-start-encryption, expect-TLS-ready, expect-CSR or expect-auth-response, then connected. UDP encryption is not a separate TCP state on the server; it starts when type 111 has been stored and type 114 has been sent.
|
||||
|
||||
## C.10 Timing
|
||||
|
||||
The 1000 ms tick and the client counters in Part A.12 apply. The version 3.0 UDP transmit counter, while the client is still in expect-UDP-heartbeat, retransmits the counter-0 registration instead of sending a new counter [ReflectorLogic.cpp lines 2350–2355].
|
||||
|
||||
The flush timer is 3000 ms. The local audio gap rule is `diff.tv_sec > 3` [ReflectorLogic.cpp lines 2341–2346]. Server limits are Part A.11 and A.12.
|
||||
|
||||
The client’s 60-second UDP receive counter starts at TCP connect, so the TLS handshake and the UDP registration have to finish inside that window or the client disconnects. The server’s 120-second UDP receive counter starts at TCP accept.
|
||||
|
||||
Certificate renewal is a server timer. It is at least 10 minutes after connect, and otherwise one minute after the certificate’s renewal time. The server then sends `MsgClientCert` and expects the client to disconnect [ReflectorClient.cpp lines 462–474 and 1416–1427]. The fraction `RENEW_AFTER` is used to compute that time from the certificate’s validity; the wire effect is a later `MsgClientCert`, not a new message type.
|
||||
|
||||
## C.11 Audio
|
||||
|
||||
Codec negotiation matches Part A.10, except a version 3.0 client that finds no usable name disconnects before creating the UDP socket [ReflectorLogic.cpp lines 1676–1684].
|
||||
|
||||
The Opus parameters are the same encoder as Part B.11: mono, 16000 Hz by default, 20 ms, 20000 bit/s, VBR, voice, maximum bandwidth medium-band, `opus_encode_float`, one raw Opus packet per UDP plaintext. The packet is then encrypted as C.7. Loss behavior is the counter rule in C.7.4. No concealment step is specified.
|
||||
|
||||
## C.12 Security considerations
|
||||
|
||||
Before `MsgStartEncryption`, the version number, CA size, CA SHA-256, and optional CA bundle are plaintext. The bundle signature is SHA-256 over the PEM, verified with the public key in the certificate the server sends in the same message. This client does not chain that certificate to a previously trusted CA when the local file is absent. When a local file already exists and the digest differs, this client keeps the old file.
|
||||
|
||||
After `enableSsl`, TCP frames are TLS with a minimum of TLS 1.2. The server authenticates with a certificate that this client checks against the hostname or IP. The client authenticates with a certificate common name, or with the HMAC fallback inside the TLS session, or by sending a CSR and waiting for an operator to sign it. The CSR itself is not proof of a callsign beyond the later checks.
|
||||
|
||||
The UDP key and IV prefix travel in `MsgNodeInfo` after TLS is up. UDP uses AES-128-GCM. The associated data and the 8-byte tag authenticate the counter and the ciphertext. An on-path attacker who does not have the key cannot read the audio of a datagram that verifies, and a modified datagram fails `EVP_DecryptFinal_ex` and is dropped.
|
||||
|
||||
The GCM tag is 8 bytes, not 16. That is the length passed to `EVP_CTRL_AEAD_SET_TAG`.
|
||||
|
||||
Client-to-server and server-to-client IVs differ in the client-id field (the real id versus 0), so the same counter in the two directions is not the same IV. The registration datagram and every retransmission of it use counter 0, the same random prefix, and the same key, so those transmissions repeat an IV. Ordinary audio counters increase by one per datagram until the `uint32` wraps. A later counter of 0 is also the registration peek on this server.
|
||||
|
||||
The server accepts UDP only after it can decrypt with the key from type 111 and, after registration, only from the recorded source address and port. The client accepts UDP only from the TCP peer’s address and port, decrypted with the same key.
|
||||
|
||||
Login HMAC, when the fallback runs, has the properties in Part B.12 and is carried inside TLS.
|
||||
|
||||
## C.13 Differences from version 2.0
|
||||
|
||||
Version 3.0 announces major 3. The plaintext phase ends at `MsgStartEncryption`. Identity is a certificate common name, with HMAC-SHA1 only as the fallback in C.8.4. UDP is AES-128-GCM rather than the 6-byte header in Part B.7. Type 111 carries the key and IV prefix plus JSON, and the JSON adds `machineArch` when `uname` works. The client waits for a decrypted UDP heartbeat before it selects a talk group or emits audio. A codec it cannot use ends the session.
|
||||
|
||||
## C.14 References
|
||||
|
||||
Blob prefix: `https://github.com/sm0svx/svxlink/blob/fcd13014810af75d1852b3f2cfc8d9806178d0ab/`
|
||||
|
||||
| Section | Symbol | Path and lines read |
|
||||
| --- | --- | --- |
|
||||
| C.4 | `MsgProtoVer` default, `onConnected` | `ReflectorMsg.h` 311–316; `ReflectorLogic.cpp` 159, 984–1003, 1226–1255 |
|
||||
| C.6 | `SslContext`, `enableSsl` | `src/async/core/AsyncSslContext.h` 133–147; `AsyncTcpConnection.cpp` 251–280 |
|
||||
| C.7 | `UdpCipher`, `EncryptedUdpSocket::write` | `ReflectorMsg.h` 1499–1582; `AsyncEncryptedUdpSocket.cpp` 244–329, 370–433 |
|
||||
| C.7.4 | `udpCipherDataReceived`, `sendUdpRegisterMsg` | `Reflector.cpp` 899–970, 996–1127; `ReflectorLogic.cpp` 2071–2092, 2278–2281, 2350–2355 |
|
||||
| C.8.1 | `handleMsgCAInfo`, `handleMsgCABundle`, `handleMsgStartEncryption` | `ReflectorLogic.cpp` 1328–1501; `ReflectorClient.cpp` 649–715 |
|
||||
| C.8.1 | CA digest and signature | `Reflector.cpp` 1710–1720 |
|
||||
| C.8.2 | CSR fields, `onSslConnectionReady` | `ReflectorLogic.cpp` 323–397, 1058–1103; `ReflectorClient.cpp` 410–477 |
|
||||
| C.8.5 | `handleMsgServerInfo`, `handleNodeInfo` | `ReflectorLogic.cpp` 1621–1749, 1983–2004; `ReflectorClient.cpp` 917–937 |
|
||||
| C.11 | `AudioEncoderOpus` | `AsyncAudioEncoderOpus.cpp` 117–138, 632–647 |
|
||||
|
||||
## C.15 Unresolved
|
||||
|
||||
- **Inference:** Whether any given production network, including FM-Funknetz, has switched its reflector to version 3.0. This memo did not connect to one.
|
||||
- The source records a FIXME: a received CA bundle’s signing certificate is not verified against the CA the client already holds, and a digest mismatch on an existing file does not download a replacement [ReflectorLogic.cpp lines 1369–1373 and 1452–1453].
|
||||
- Registration retransmission repeats the AES-GCM IV (C.7.4). How long a deployment can run before a `uint32` audio counter wraps is a function of the packet rate; the protocol text does not define wrap behavior beyond the comparisons in the source.
|
||||
- Packet-loss concealment is not specified. A gap updates the expected counter and the next decrypted packet is delivered to the decoder.
|
||||
- The man-page and binary defaults for `SQL_TIMEOUT_BLOCKTIME` differ (Part A.11).
|
||||
|
||||
---
|
||||
|
||||
# jayReflector
|
||||
|
||||
`dj1jay/jayReflector` at `50997ed21a37c7c05c132eca5ebb07bab4b80fc7` uses the same client-facing TCP type numbers 1, 5, 6, 10–21, and 100–114, and the same UDP type numbers 1 and 101–104, with the same version 2 header versus version 3 cipher split. `ReflectorClient.cpp` in that tree switches on the same `Msg*` cases as upstream. Types 115–122 (`MsgTrunkHello`, `MsgTrunkTalkerStart`, `MsgTrunkTalkerStop`, `MsgTrunkAudio`, `MsgTrunkFlush`, `MsgTrunkHeartbeat`, `MsgTrunkNodeList`, `MsgTrunkFilter`) are sent on the trunk and satellite TCP sessions (`TrunkLink.cpp`, `SatelliteLink.cpp`), not on the node client connection. They are not part of Protocol Version 2.0 or 3.0 as a station speaks them. MQTT, talk-group mapping, and satellite policy are outside this file.
|
||||
+65
-23
@@ -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 **[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 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 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 |
|
||||
| --- | --- |
|
||||
| Listen port | **5300** TCP **and** UDP (upstream default; FM-Funknetz production uses 5308) |
|
||||
| Config | `svxlink-docker/config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` |
|
||||
| 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 (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
|
||||
{
|
||||
@@ -32,6 +45,7 @@ Point `~/.funknetz.json` at the lab (values match `.env` / the mounted conf; sec
|
||||
"callsign": "DL0FN-1",
|
||||
"authKey": "local-lab-secret-change-me",
|
||||
"talkGroup": 9,
|
||||
"protocol": "v2",
|
||||
"enableLogging": false,
|
||||
"logFile": "/tmp/funknetz.log"
|
||||
}
|
||||
@@ -39,6 +53,40 @@ 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 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`.
|
||||
@@ -53,11 +101,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.
|
||||
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:
|
||||
|
||||
@@ -104,18 +152,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. |
|
||||
| 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. |
|
||||
|
||||
Vendored
+168
-3
@@ -1,25 +1,63 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #101418;
|
||||
color: #f2f4f6;
|
||||
font: 16px/1.4 system-ui, sans-serif;
|
||||
}
|
||||
|
||||
main {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.config-open main {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
#connection {
|
||||
margin: 0 0 16px;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#gear {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #f2f4f6;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
#protocol {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#detail {
|
||||
min-height: 1.4em;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
button {
|
||||
#ptt {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
@@ -31,6 +69,133 @@ button {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
button.down {
|
||||
#ptt.down {
|
||||
background: #9b2226;
|
||||
}
|
||||
|
||||
#ptt:disabled {
|
||||
background: #3a4248;
|
||||
color: #9aa3ab;
|
||||
}
|
||||
|
||||
#config {
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
background: #101418;
|
||||
scrollbar-color: #8b98a3 #0c1014;
|
||||
scrollbar-width: auto;
|
||||
}
|
||||
|
||||
#config::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
#config::-webkit-scrollbar-track {
|
||||
background: #0c1014;
|
||||
}
|
||||
|
||||
#config::-webkit-scrollbar-thumb {
|
||||
background: #8b98a3;
|
||||
border: 2px solid #0c1014;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#config[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#config form {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #2c3640;
|
||||
border-radius: 8px;
|
||||
background: #101418;
|
||||
}
|
||||
|
||||
#config h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#config-error {
|
||||
margin: 0 0 12px;
|
||||
color: #f4a261;
|
||||
}
|
||||
|
||||
#config label {
|
||||
display: block;
|
||||
margin: 0 0 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#config input,
|
||||
#config select {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 8px;
|
||||
border: 1px solid #2c3640;
|
||||
border-radius: 6px;
|
||||
background: #0c1014;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#config .check input {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
margin: 0 0 0 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.secret {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.secret input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#auth-toggle,
|
||||
#cancel,
|
||||
#save {
|
||||
height: 40px;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#auth-toggle {
|
||||
width: 72px;
|
||||
background: #243038;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#cancel {
|
||||
background: #243038;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#save {
|
||||
background: #2d6a4f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
Vendored
+191
-1
@@ -1,4 +1,13 @@
|
||||
const button = document.getElementById("ptt");
|
||||
const config = document.getElementById("config");
|
||||
const configForm = document.getElementById("config-form");
|
||||
const configError = document.getElementById("config-error");
|
||||
const authKey = document.getElementById("authKey");
|
||||
const authToggle = document.getElementById("auth-toggle");
|
||||
|
||||
let savedSize = null;
|
||||
let configLoading = false;
|
||||
let sizeToken = 0;
|
||||
|
||||
function api() {
|
||||
if (!window.go || !window.go.main || !window.go.main.App) {
|
||||
@@ -17,12 +26,146 @@ function line(state) {
|
||||
return "Idle";
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
return window.runtime || null;
|
||||
}
|
||||
|
||||
function fitConfigWindow() {
|
||||
const rt = runtime();
|
||||
if (!rt || !rt.WindowGetSize || !rt.WindowSetSize) {
|
||||
return;
|
||||
}
|
||||
const token = ++sizeToken;
|
||||
requestAnimationFrame(function () {
|
||||
if (token !== sizeToken || config.hidden) {
|
||||
return;
|
||||
}
|
||||
rt.WindowGetSize().then(function (size) {
|
||||
if (token !== sizeToken || config.hidden || !size) {
|
||||
return;
|
||||
}
|
||||
if (!savedSize) {
|
||||
savedSize = size;
|
||||
}
|
||||
const needed = Math.ceil(configForm.getBoundingClientRect().height) + 32;
|
||||
const width = Math.max(size.w, 420);
|
||||
const applied = rt.WindowSetSize(width, needed);
|
||||
const after = applied && applied.then ? applied.then(function () {
|
||||
return rt.WindowGetSize();
|
||||
}) : Promise.resolve(null);
|
||||
return after.then(function (now) {
|
||||
if (!now || token !== sizeToken || config.hidden) {
|
||||
return;
|
||||
}
|
||||
const extra = Math.ceil(document.documentElement.scrollHeight - window.innerHeight);
|
||||
if (extra > 1) {
|
||||
rt.WindowSetSize(now.w, now.h + extra);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function restoreWindow() {
|
||||
sizeToken++;
|
||||
const rt = runtime();
|
||||
if (!savedSize || !rt || !rt.WindowSetSize) {
|
||||
savedSize = null;
|
||||
return;
|
||||
}
|
||||
rt.WindowSetSize(savedSize.w, savedSize.h);
|
||||
savedSize = null;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
configError.hidden = !message;
|
||||
configError.textContent = message || "";
|
||||
if (!config.hidden) {
|
||||
fitConfigWindow();
|
||||
}
|
||||
}
|
||||
|
||||
function fillForm(form) {
|
||||
document.getElementById("host").value = form.host || "";
|
||||
document.getElementById("port").value = String(form.port || 0);
|
||||
document.getElementById("callsign-field").value = form.callsign || "";
|
||||
authKey.value = form.authKey || "";
|
||||
document.getElementById("talkGroup").value = String(form.talkGroup || 0);
|
||||
document.getElementById("protocol-field").value = form.protocol === "v3" ? "v3" : "v2";
|
||||
document.getElementById("certificateDir").value = form.certificateDir || "";
|
||||
document.getElementById("enableLogging").checked = !!form.enableLogging;
|
||||
document.getElementById("logFile").value = form.logFile || "";
|
||||
authKey.type = "password";
|
||||
authToggle.textContent = "Show";
|
||||
}
|
||||
|
||||
function readForm() {
|
||||
return {
|
||||
host: document.getElementById("host").value,
|
||||
port: numberValue("port"),
|
||||
callsign: document.getElementById("callsign-field").value,
|
||||
authKey: authKey.value,
|
||||
talkGroup: numberValue("talkGroup"),
|
||||
protocol: document.getElementById("protocol-field").value,
|
||||
certificateDir: document.getElementById("certificateDir").value,
|
||||
enableLogging: document.getElementById("enableLogging").checked,
|
||||
logFile: document.getElementById("logFile").value
|
||||
};
|
||||
}
|
||||
|
||||
function numberValue(id) {
|
||||
const text = document.getElementById(id).value.trim();
|
||||
if (text === "") {
|
||||
return 0;
|
||||
}
|
||||
const n = Number(text);
|
||||
if (!Number.isInteger(n)) {
|
||||
return 0;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function showConfig() {
|
||||
const app = api();
|
||||
if (!app || configLoading) {
|
||||
return;
|
||||
}
|
||||
configLoading = true;
|
||||
app.Config().then(function (cfg) {
|
||||
if (!cfg.open) {
|
||||
return;
|
||||
}
|
||||
fillForm(cfg.form || {});
|
||||
config.hidden = false;
|
||||
document.body.classList.add("config-open");
|
||||
showError(cfg.error);
|
||||
}).catch(function () {}).then(function () {
|
||||
configLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
function hideConfig() {
|
||||
config.hidden = true;
|
||||
document.body.classList.remove("config-open");
|
||||
configError.hidden = true;
|
||||
configError.textContent = "";
|
||||
restoreWindow();
|
||||
}
|
||||
|
||||
function render(state) {
|
||||
document.getElementById("connection").textContent = state.connection;
|
||||
document.getElementById("callsign").textContent = state.callsign;
|
||||
document.getElementById("talkgroup").textContent = String(state.talkGroup);
|
||||
const protocol = document.getElementById("protocol");
|
||||
protocol.textContent = state.protocol ? "Protocol " + state.protocol : "";
|
||||
document.getElementById("detail").textContent = line(state);
|
||||
button.classList.toggle("down", !!state.transmitting);
|
||||
button.disabled = !!state.configOpen;
|
||||
if (state.configOpen && config.hidden) {
|
||||
showConfig();
|
||||
} else if (!state.configOpen && !config.hidden) {
|
||||
hideConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -33,7 +176,47 @@ function refresh() {
|
||||
app.State().then(render).catch(function () {});
|
||||
}
|
||||
|
||||
function cancelConfig() {
|
||||
const app = api();
|
||||
if (app) {
|
||||
app.CancelConfig();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("gear").addEventListener("click", function () {
|
||||
const app = api();
|
||||
if (app) {
|
||||
app.OpenConfig();
|
||||
}
|
||||
});
|
||||
|
||||
configForm.addEventListener("submit", function (event) {
|
||||
event.preventDefault();
|
||||
const app = api();
|
||||
if (!app) {
|
||||
return;
|
||||
}
|
||||
app.Save(readForm()).then(function () {
|
||||
refresh();
|
||||
}).catch(function () {
|
||||
app.Config().then(function (cfg) {
|
||||
showError(cfg.error);
|
||||
}).catch(function () {});
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById("cancel").addEventListener("click", cancelConfig);
|
||||
|
||||
authToggle.addEventListener("click", function () {
|
||||
const show = authKey.type === "password";
|
||||
authKey.type = show ? "text" : "password";
|
||||
authToggle.textContent = show ? "Hide" : "Show";
|
||||
});
|
||||
|
||||
button.addEventListener("pointerdown", function (event) {
|
||||
if (button.disabled) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
const app = api();
|
||||
@@ -55,11 +238,18 @@ button.addEventListener("pointercancel", releaseButton);
|
||||
let spaceHeld = false;
|
||||
|
||||
window.addEventListener("keydown", function (event) {
|
||||
if (event.code === "Escape") {
|
||||
if (!config.hidden) {
|
||||
event.preventDefault();
|
||||
cancelConfig();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.code !== "Space") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.repeat || spaceHeld) {
|
||||
if (!config.hidden || button.disabled || event.repeat || spaceHeld) {
|
||||
return;
|
||||
}
|
||||
spaceHeld = true;
|
||||
|
||||
Vendored
+34
-1
@@ -7,12 +7,45 @@
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<p id="connection">Disconnected</p>
|
||||
<div class="status">
|
||||
<p id="connection">Disconnected</p>
|
||||
<button id="gear" type="button" aria-label="Config">⚙</button>
|
||||
</div>
|
||||
<p>Callsign <span id="callsign"></span></p>
|
||||
<p>Talk group <span id="talkgroup"></span></p>
|
||||
<p id="protocol"></p>
|
||||
<p id="detail">Idle</p>
|
||||
<button id="ptt" type="button">Push to talk</button>
|
||||
</main>
|
||||
<div id="config" hidden>
|
||||
<form id="config-form">
|
||||
<h1>Config</h1>
|
||||
<p id="config-error" hidden></p>
|
||||
<label>Host <input id="host" name="host" autocomplete="off"></label>
|
||||
<label>Port <input id="port" name="port" inputmode="numeric" autocomplete="off"></label>
|
||||
<label>Callsign <input id="callsign-field" name="callsign" autocomplete="off"></label>
|
||||
<label>Auth key
|
||||
<span class="secret">
|
||||
<input id="authKey" name="authKey" type="password" autocomplete="off">
|
||||
<button id="auth-toggle" type="button">Show</button>
|
||||
</span>
|
||||
</label>
|
||||
<label>Talk group <input id="talkGroup" name="talkGroup" inputmode="numeric" autocomplete="off"></label>
|
||||
<label>Protocol
|
||||
<select id="protocol-field" name="protocol">
|
||||
<option value="v2">v2</option>
|
||||
<option value="v3">v3</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Certificate directory <input id="certificateDir" name="certificateDir" autocomplete="off"></label>
|
||||
<label class="check">Enable logging <input id="enableLogging" name="enableLogging" type="checkbox"></label>
|
||||
<label>Log file <input id="logFile" name="logFile" autocomplete="off"></label>
|
||||
<div class="actions">
|
||||
<button id="cancel" type="button">Cancel</button>
|
||||
<button id="save" type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package gcm64 seals and opens AES-GCM with the 8-byte tag this protocol uses.
|
||||
package gcm64
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// IV is the 12-byte nonce: a 6-byte prefix, the client id, and the counter.
|
||||
func IV(prefix []byte, clientID uint16, counter uint32) []byte {
|
||||
iv := make([]byte, 12)
|
||||
copy(iv, prefix)
|
||||
binary.BigEndian.PutUint16(iv[6:8], clientID)
|
||||
binary.BigEndian.PutUint32(iv[8:12], counter)
|
||||
return iv
|
||||
}
|
||||
|
||||
// Seal returns an 8-byte tag and the ciphertext. The tag is the leading 8
|
||||
// bytes of the ordinary 16-byte GCM tag, because crypto/cipher only builds
|
||||
// tags of 12 to 16 bytes.
|
||||
func Seal(key, nonce, plain, aad []byte) (tag, ct []byte, err error) {
|
||||
g, err := full(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out := g.Seal(nil, nonce, plain, aad)
|
||||
ct = append([]byte(nil), out[:len(plain)]...)
|
||||
tag = append([]byte(nil), out[len(plain):len(plain)+8]...)
|
||||
return tag, ct, nil
|
||||
}
|
||||
|
||||
// Open checks the 8-byte tag and returns the plaintext.
|
||||
func Open(key, nonce, tag, ct, aad []byte) ([]byte, error) {
|
||||
if len(tag) != 8 {
|
||||
return nil, errors.New("tag")
|
||||
}
|
||||
g, err := full(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stream := g.Seal(nil, nonce, make([]byte, len(ct)), aad)
|
||||
plain := make([]byte, len(ct))
|
||||
subtle.XORBytes(plain, ct, stream[:len(ct)])
|
||||
check := g.Seal(nil, nonce, plain, aad)
|
||||
var got, want [8]byte
|
||||
copy(got[:], tag)
|
||||
copy(want[:], check[len(plain):len(plain)+8])
|
||||
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
|
||||
return nil, errors.New("tag")
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func full(key []byte) (cipher.AEAD, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
@@ -5,6 +5,10 @@ package opus
|
||||
#cgo pkg-config: opus
|
||||
#include <opus.h>
|
||||
|
||||
static void reset_decoder(OpusDecoder *dec) {
|
||||
opus_decoder_ctl(dec, OPUS_RESET_STATE);
|
||||
}
|
||||
|
||||
static int configure_encoder(OpusEncoder *enc) {
|
||||
int err;
|
||||
err = opus_encoder_ctl(enc, OPUS_SET_BITRATE(20000));
|
||||
@@ -114,6 +118,14 @@ func (d *Decoder) Decode(packet []byte) ([]int16, error) {
|
||||
return pcm, nil
|
||||
}
|
||||
|
||||
// Reset clears decoder state after a gap in the received audio.
|
||||
func (d *Decoder) Reset() {
|
||||
if d == nil || d.dec == nil {
|
||||
return
|
||||
}
|
||||
C.reset_decoder(d.dec)
|
||||
}
|
||||
|
||||
// Close releases the decoder.
|
||||
func (d *Decoder) Close() {
|
||||
if d == nil || d.dec == nil {
|
||||
|
||||
@@ -17,16 +17,17 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
st, err := openStation()
|
||||
st, err := openStationReported(os.Args[1:], os.Stderr)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
app := &App{station: st}
|
||||
err = wails.Run(&options.App{
|
||||
Title: "funknetz",
|
||||
Width: 420,
|
||||
Height: 280,
|
||||
Height: 480,
|
||||
MinWidth: 420,
|
||||
MinHeight: 480,
|
||||
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
|
||||
AssetServer: &assetserver.Options{Assets: assets},
|
||||
OnStartup: app.startup,
|
||||
|
||||
+73
-23
@@ -21,6 +21,7 @@ type loggedIn struct {
|
||||
refl *reflector
|
||||
cancel context.CancelFunc
|
||||
id uint16
|
||||
path string
|
||||
}
|
||||
|
||||
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn {
|
||||
@@ -31,9 +32,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()
|
||||
@@ -43,19 +44,18 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
t.Cleanup(cancel)
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || len(body) != 4 || body[1] != 2 {
|
||||
t.Fatalf("protocol frame type %d body %v", typ, body)
|
||||
}
|
||||
expectVersion(t, refl, 2, 0)
|
||||
challenge := make([]byte, 20)
|
||||
for i := range challenge {
|
||||
challenge[i] = byte(i + 1)
|
||||
}
|
||||
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
||||
typ, body = refl.readFrame(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 11 {
|
||||
t.Fatalf("auth type %d", typ)
|
||||
}
|
||||
@@ -88,10 +88,47 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
|
||||
if utyp != 1 || gotID != id || seq != 0 || len(ubody) != 0 {
|
||||
t.Fatalf("udp heartbeat type %d id %d seq %d len %d", utyp, gotID, seq, len(ubody))
|
||||
}
|
||||
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" }); err != nil {
|
||||
got, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id}
|
||||
if got.Protocol != "2.0" {
|
||||
t.Fatalf("Protocol = %q, want 2.0", got.Protocol)
|
||||
}
|
||||
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id, path: path}
|
||||
}
|
||||
|
||||
func expectVersion(t *testing.T, refl *reflector, major, minor uint16) {
|
||||
t.Helper()
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, major, minor) {
|
||||
t.Fatalf("version type %d body %v, want %d.%d", typ, body, major, minor)
|
||||
}
|
||||
}
|
||||
|
||||
func declineTo2(t *testing.T, refl *reflector) {
|
||||
t.Helper()
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 3, 0) {
|
||||
t.Fatalf("first version type %d body %v, want 3.0", typ, body)
|
||||
}
|
||||
refl.writeFrame(t, 6, versionWire(2, 0))
|
||||
refl.accept(t)
|
||||
typ, body = refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("fallback version type %d body %v, want 2.0", typ, body)
|
||||
}
|
||||
}
|
||||
|
||||
func versionWire(major, minor uint16) []byte {
|
||||
return appendU16(appendU16(nil, major), minor)
|
||||
}
|
||||
|
||||
func versionIs(body []byte, major, minor uint16) bool {
|
||||
if len(body) != 4 {
|
||||
return false
|
||||
}
|
||||
return binary.BigEndian.Uint16(body[0:2]) == major && binary.BigEndian.Uint16(body[2:4]) == minor
|
||||
}
|
||||
|
||||
func TestQuietSessionSendsHeartbeats(t *testing.T) {
|
||||
@@ -127,12 +164,15 @@ func TestDropRetriesUntilQuit(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := in.st.State(); got.Protocol != "" {
|
||||
t.Fatalf("Protocol = %q, want blank while disconnected", got.Protocol)
|
||||
}
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
in.clock.Advance(2 * time.Second)
|
||||
in.refl.accept(t)
|
||||
typ, body := in.refl.readFrame(t)
|
||||
if typ != 5 || body[1] != 2 {
|
||||
t.Fatalf("retry protocol type %d body %v", typ, body)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("retry protocol type %d body %v, want 2.0", typ, body)
|
||||
}
|
||||
in.cancel()
|
||||
in.clock.Advance(2 * time.Second)
|
||||
@@ -151,7 +191,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 {
|
||||
@@ -159,7 +199,10 @@ func TestRefusedConnectionRetriesUntilQuit(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -196,7 +239,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 {
|
||||
@@ -204,7 +247,10 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
refl.readFrame(t)
|
||||
refl.writeFrame(t, 13, appendStr(nil, msg))
|
||||
@@ -230,7 +276,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 {
|
||||
@@ -238,9 +284,12 @@ func TestMissingOpusStopsRetries(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
refl.readFrame(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
challenge := make([]byte, 20)
|
||||
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
||||
refl.readFrame(t)
|
||||
@@ -508,14 +557,15 @@ func expectAudio(t *testing.T, refl *reflector, dec *opus.Decoder, id, seq uint1
|
||||
}
|
||||
|
||||
func tryFrame(r *reflector) (uint16, []byte, bool) {
|
||||
_ = r.tcp.SetReadDeadline(time.Now().Add(30 * time.Millisecond))
|
||||
conn := r.conn()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Millisecond))
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r.tcp, hdr[:]); err != nil {
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r.tcp, buf); err != nil {
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return 0, nil, false
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/asn1"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
caFileName = "ca.pem"
|
||||
keyFileName = "key.pem"
|
||||
csrFileName = "csr.pem"
|
||||
certFileName = "cert.pem"
|
||||
)
|
||||
|
||||
var (
|
||||
oidBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19}
|
||||
oidKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 15}
|
||||
oidExtKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37}
|
||||
oidClientAuth = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2}
|
||||
)
|
||||
|
||||
func ensureCertDir(path string) error {
|
||||
err := os.Mkdir(path, 0o700)
|
||||
if err != nil {
|
||||
if !errors.Is(err, fs.ErrExist) {
|
||||
return err
|
||||
}
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
return statErr
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return os.Chmod(path, 0o700)
|
||||
}
|
||||
|
||||
func caDigest(dir string) (sum [32]byte, ok bool, err error) {
|
||||
body, err := os.ReadFile(filepath.Join(dir, caFileName))
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return sum, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sum, false, err
|
||||
}
|
||||
return sha256.Sum256(body), true, nil
|
||||
}
|
||||
|
||||
func verifyCABundle(caPEM string, sig []byte, certPEM string) error {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return errors.New("signing certificate")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return errors.New("signing certificate")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(caPEM))
|
||||
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig)
|
||||
}
|
||||
|
||||
func writeCABundle(dir, caPEM string) error {
|
||||
return writeMode(filepath.Join(dir, caFileName), []byte(caPEM), 0o644)
|
||||
}
|
||||
|
||||
func writeClientCert(dir, certPEM string) error {
|
||||
return writeMode(filepath.Join(dir, certFileName), []byte(certPEM), 0o644)
|
||||
}
|
||||
|
||||
func csrNewerThanCert(dir string) (bool, error) {
|
||||
csrPath := filepath.Join(dir, csrFileName)
|
||||
certPath := filepath.Join(dir, certFileName)
|
||||
csr, err := os.Stat(csrPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return false, fmt.Errorf("stat %s: %w", csrPath, err)
|
||||
}
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
cert, err := os.Stat(certPath)
|
||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return false, fmt.Errorf("stat %s: %w", certPath, err)
|
||||
}
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
return csr.ModTime().After(cert.ModTime()), nil
|
||||
}
|
||||
|
||||
func readCSR(dir string) (string, error) {
|
||||
body, err := os.ReadFile(filepath.Join(dir, csrFileName))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// prepareIdentity checks a saved certificate or creates the key and signing
|
||||
// request. fatal is a window message when the station must stop.
|
||||
func prepareIdentity(dir, callsign string) (tls.Certificate, bool, string, error) {
|
||||
certPath := filepath.Join(dir, certFileName)
|
||||
keyPath := filepath.Join(dir, keyFileName)
|
||||
_, certErr := os.Stat(certPath)
|
||||
_, keyErr := os.Stat(keyPath)
|
||||
certExists := certErr == nil
|
||||
keyExists := keyErr == nil
|
||||
if certErr != nil && !errors.Is(certErr, fs.ErrNotExist) {
|
||||
return tls.Certificate{}, false, "Cannot create " + certPath, fmt.Errorf("stat certificate: %w", certErr)
|
||||
}
|
||||
if keyErr != nil && !errors.Is(keyErr, fs.ErrNotExist) {
|
||||
return tls.Certificate{}, false, "Cannot create " + keyPath, fmt.Errorf("stat key: %w", keyErr)
|
||||
}
|
||||
if certExists && !keyExists {
|
||||
return tls.Certificate{}, false, "Certificate key is missing", nil
|
||||
}
|
||||
if certExists && keyExists {
|
||||
pair, msg, err := loadPair(certPath, keyPath, callsign)
|
||||
if msg != "" {
|
||||
return tls.Certificate{}, false, msg, err
|
||||
}
|
||||
if msg, err := ensureCSR(dir, callsign); msg != "" {
|
||||
return tls.Certificate{}, false, msg, err
|
||||
}
|
||||
return pair, true, "", nil
|
||||
}
|
||||
if msg, err := ensureKeyAndCSR(dir, callsign, !keyExists); msg != "" {
|
||||
return tls.Certificate{}, false, msg, err
|
||||
}
|
||||
return tls.Certificate{}, false, "", nil
|
||||
}
|
||||
|
||||
func loadPair(certPath, keyPath, callsign string) (tls.Certificate, string, error) {
|
||||
cert, err := loadCert(certPath)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "Cannot create " + certPath, fmt.Errorf("read certificate: %w", err)
|
||||
}
|
||||
key, err := loadKey(keyPath)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, "Cannot create " + keyPath, fmt.Errorf("read key: %w", err)
|
||||
}
|
||||
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok || !pub.Equal(&key.PublicKey) {
|
||||
return tls.Certificate{}, "Certificate does not match the key", nil
|
||||
}
|
||||
if cert.Subject.CommonName != callsign {
|
||||
return tls.Certificate{}, "Certificate callsign does not match", nil
|
||||
}
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{cert.Raw},
|
||||
PrivateKey: key,
|
||||
Leaf: cert,
|
||||
}, "", nil
|
||||
}
|
||||
|
||||
func ensureCSR(dir, callsign string) (string, error) {
|
||||
csrPath := filepath.Join(dir, csrFileName)
|
||||
_, err := os.Stat(csrPath)
|
||||
if err == nil {
|
||||
return "", nil
|
||||
}
|
||||
if !errors.Is(err, fs.ErrNotExist) {
|
||||
return "Cannot create " + csrPath, fmt.Errorf("stat signing request: %w", err)
|
||||
}
|
||||
key, err := loadKey(filepath.Join(dir, keyFileName))
|
||||
if err != nil {
|
||||
return "Cannot create " + csrPath, fmt.Errorf("read key: %w", err)
|
||||
}
|
||||
if err := writeCSR(csrPath, key, callsign); err != nil {
|
||||
return "Cannot create " + csrPath, err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func ensureKeyAndCSR(dir, callsign string, created bool) (string, error) {
|
||||
keyPath := filepath.Join(dir, keyFileName)
|
||||
var key *rsa.PrivateKey
|
||||
var err error
|
||||
if created {
|
||||
key, err = rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return "Cannot create " + keyPath, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
der := x509.MarshalPKCS1PrivateKey(key)
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
|
||||
if err := writeNew(keyPath, body, 0o600); err != nil {
|
||||
return "Cannot create " + keyPath, err
|
||||
}
|
||||
} else {
|
||||
key, err = loadKey(keyPath)
|
||||
if err != nil {
|
||||
return "Cannot create " + keyPath, fmt.Errorf("read key: %w", err)
|
||||
}
|
||||
}
|
||||
csrPath := filepath.Join(dir, csrFileName)
|
||||
_, csrErr := os.Stat(csrPath)
|
||||
if csrErr != nil && !errors.Is(csrErr, fs.ErrNotExist) {
|
||||
return "Cannot create " + csrPath, fmt.Errorf("stat signing request: %w", csrErr)
|
||||
}
|
||||
if created || errors.Is(csrErr, fs.ErrNotExist) {
|
||||
if err := writeCSR(csrPath, key, callsign); err != nil {
|
||||
return "Cannot create " + csrPath, err
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func writeCSR(path string, key *rsa.PrivateKey, callsign string) error {
|
||||
bc, err := asn1.Marshal(struct {
|
||||
CA bool
|
||||
}{CA: false})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// digitalSignature, keyEncipherment, keyAgreement: bits 0, 2, and 4.
|
||||
ku, err := asn1.Marshal(asn1.BitString{Bytes: []byte{0xA8}, BitLength: 5})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eku, err := asn1.Marshal([]asn1.ObjectIdentifier{oidClientAuth})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: callsign},
|
||||
ExtraExtensions: []pkix.Extension{
|
||||
{Id: oidBasicConstraints, Critical: true, Value: bc},
|
||||
{Id: oidKeyUsage, Critical: true, Value: ku},
|
||||
{Id: oidExtKeyUsage, Critical: false, Value: eku},
|
||||
},
|
||||
}, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})
|
||||
return writeMode(path, body, 0o644)
|
||||
}
|
||||
|
||||
func loadCert(path string) (*x509.Certificate, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(body)
|
||||
if block == nil {
|
||||
return nil, errors.New("certificate")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func loadKey(path string) (*rsa.PrivateKey, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(body)
|
||||
if block == nil {
|
||||
return nil, errors.New("key")
|
||||
}
|
||||
switch block.Type {
|
||||
case "RSA PRIVATE KEY":
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
case "PRIVATE KEY":
|
||||
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rk, ok := k.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("key")
|
||||
}
|
||||
return rk, nil
|
||||
default:
|
||||
return nil, errors.New("key")
|
||||
}
|
||||
}
|
||||
|
||||
func writeNew(path string, body []byte, mode os.FileMode) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, werr := f.Write(body)
|
||||
cerr := f.Close()
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
|
||||
func writeMode(path string, body []byte, mode os.FileMode) error {
|
||||
if err := os.WriteFile(path, body, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, mode)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/internal/gcm64"
|
||||
)
|
||||
|
||||
func counterAAD(counter uint32) []byte {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], counter)
|
||||
return b[:]
|
||||
}
|
||||
|
||||
func registrationAAD(clientID uint16) []byte {
|
||||
b := counterAAD(0)
|
||||
return appendU16(b, clientID)
|
||||
}
|
||||
|
||||
func sealDatagram(key, prefix []byte, clientID uint16, counter uint32, aad, plain []byte) ([]byte, error) {
|
||||
tag, ct, err := gcm64.Seal(key, gcm64.IV(prefix, clientID, counter), plain, aad)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wire := make([]byte, 0, len(aad)+len(tag)+len(ct))
|
||||
wire = append(wire, aad...)
|
||||
wire = append(wire, tag...)
|
||||
return append(wire, ct...), nil
|
||||
}
|
||||
|
||||
func openDatagram(key, prefix []byte, clientID uint16, counter uint32, aad, tag, ct []byte) ([]byte, error) {
|
||||
return gcm64.Open(key, gcm64.IV(prefix, clientID, counter), tag, ct, aad)
|
||||
}
|
||||
+275
-73
@@ -1,7 +1,9 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -13,100 +15,203 @@ import (
|
||||
)
|
||||
|
||||
type loaded struct {
|
||||
host string
|
||||
port int
|
||||
callsign string
|
||||
authKey string
|
||||
talkGroup int
|
||||
enableLogging bool
|
||||
logPath string
|
||||
logNotice string
|
||||
host string
|
||||
port int
|
||||
callsign string
|
||||
authKey string
|
||||
talkGroup int
|
||||
protocolMajor uint16
|
||||
certificateDir string
|
||||
certFatal string
|
||||
enableLogging bool
|
||||
logPath string
|
||||
logNotice string
|
||||
}
|
||||
|
||||
type fileShape struct {
|
||||
Host jsontext.Value `json:"host"`
|
||||
Port jsontext.Value `json:"port"`
|
||||
Callsign jsontext.Value `json:"callsign"`
|
||||
AuthKey jsontext.Value `json:"authKey"`
|
||||
TalkGroup jsontext.Value `json:"talkGroup"`
|
||||
EnableLogging jsontext.Value `json:"enableLogging"`
|
||||
LogFile jsontext.Value `json:"logFile"`
|
||||
Host jsontext.Value `json:"host"`
|
||||
Port jsontext.Value `json:"port"`
|
||||
Callsign jsontext.Value `json:"callsign"`
|
||||
AuthKey jsontext.Value `json:"authKey"`
|
||||
TalkGroup jsontext.Value `json:"talkGroup"`
|
||||
Protocol jsontext.Value `json:"protocol"`
|
||||
CertificateDir jsontext.Value `json:"certificateDir"`
|
||||
EnableLogging jsontext.Value `json:"enableLogging"`
|
||||
LogFile jsontext.Value `json:"logFile"`
|
||||
}
|
||||
|
||||
type templateFile struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Callsign string `json:"callsign"`
|
||||
AuthKey string `json:"authKey"`
|
||||
TalkGroup int `json:"talkGroup"`
|
||||
EnableLogging bool `json:"enableLogging"`
|
||||
LogFile string `json:"logFile"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Callsign string `json:"callsign"`
|
||||
AuthKey string `json:"authKey"`
|
||||
TalkGroup int `json:"talkGroup"`
|
||||
Protocol string `json:"protocol"`
|
||||
CertificateDir string `json:"certificateDir"`
|
||||
EnableLogging bool `json:"enableLogging"`
|
||||
LogFile string `json:"logFile"`
|
||||
}
|
||||
|
||||
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
|
||||
|
||||
func loadConfig(path string) (loaded, error) {
|
||||
// configLoad is the result of reading a config path. err from loadConfig is
|
||||
// only a failure that still exits before the window, such as an unknown home
|
||||
// directory. A missing or invalid file is reported in detail with loaded false.
|
||||
type configLoad struct {
|
||||
cfg loaded
|
||||
form ConfigForm
|
||||
loaded bool
|
||||
detail string
|
||||
}
|
||||
|
||||
func loadConfig(path string) (configLoad, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := writeTemplate(path); err != nil {
|
||||
return loaded{}, err
|
||||
}
|
||||
return loaded{}, fmt.Errorf("created %s", path)
|
||||
form, ferr := templateForm()
|
||||
if ferr != nil {
|
||||
return configLoad{}, ferr
|
||||
}
|
||||
return loaded{}, fmt.Errorf("could not be read")
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return configLoad{form: form}, nil
|
||||
}
|
||||
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
|
||||
}
|
||||
return parseConfig(path, body)
|
||||
return parseConfig(path, body, true)
|
||||
}
|
||||
|
||||
func writeTemplate(path string) error {
|
||||
body, err := jsonv2.Marshal(templateFile{LogFile: defaultLogPath()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, werr := f.Write(body)
|
||||
cerr := f.Close()
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
return os.Chmod(path, 0o600)
|
||||
// ConfigForm is the fields the Config window edits.
|
||||
type ConfigForm struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Callsign string `json:"callsign"`
|
||||
AuthKey string `json:"authKey"`
|
||||
TalkGroup int `json:"talkGroup"`
|
||||
Protocol string `json:"protocol"`
|
||||
CertificateDir string `json:"certificateDir"`
|
||||
EnableLogging bool `json:"enableLogging"`
|
||||
LogFile string `json:"logFile"`
|
||||
}
|
||||
|
||||
func parseConfig(path string, body []byte) (loaded, error) {
|
||||
// ConfigState is what the Config window shows.
|
||||
type ConfigState struct {
|
||||
Open bool `json:"open"`
|
||||
NeedsConfig bool `json:"needsConfig"`
|
||||
Error string `json:"error"`
|
||||
Form ConfigForm `json:"form"`
|
||||
}
|
||||
|
||||
func templateForm() (ConfigForm, error) {
|
||||
logFile, err := defaultLogPath()
|
||||
if err != nil {
|
||||
return ConfigForm{}, err
|
||||
}
|
||||
return ConfigForm{Protocol: "v2", CertificateDir: "./funknetz", LogFile: logFile}, nil
|
||||
}
|
||||
|
||||
func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
|
||||
var shape fileShape
|
||||
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
||||
return loaded{}, fmt.Errorf("could not be read")
|
||||
form, ferr := templateForm()
|
||||
if ferr != nil {
|
||||
return configLoad{}, ferr
|
||||
}
|
||||
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
|
||||
}
|
||||
if block, notice := blockedLog(shape); block {
|
||||
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
|
||||
protoBad := present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString
|
||||
if block, notice := blockedLog(shape); block || certBad || protoBad {
|
||||
cfg, err := parseOperational(shape)
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
return refused(shape, err)
|
||||
}
|
||||
cfg.logNotice = notice
|
||||
return cfg, nil
|
||||
}
|
||||
if missingLog(shape) {
|
||||
if !operationalKindsOK(shape) {
|
||||
return parseOperational(shape)
|
||||
if !block {
|
||||
cfg = applyLog(cfg, shape)
|
||||
}
|
||||
shape = withLogDefaults(shape)
|
||||
if err := rewriteConfig(path, shape); err != nil {
|
||||
return loaded{}, fmt.Errorf("could not be read")
|
||||
return finish(shape, applyCertificate(cfg, shape, path))
|
||||
}
|
||||
if missingCertificate(shape) || missingLog(shape) || missingProtocol(shape) {
|
||||
if !operationalKindsOK(shape) {
|
||||
_, err := parseOperational(shape)
|
||||
if err != nil {
|
||||
return refused(shape, err)
|
||||
}
|
||||
}
|
||||
var err error
|
||||
shape, err = withLogDefaults(shape)
|
||||
if err != nil {
|
||||
return configLoad{}, err
|
||||
}
|
||||
if rewrite {
|
||||
if err := rewriteConfig(path, shape); err != nil {
|
||||
return configLoad{}, fmt.Errorf("the file could not be read: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg, err := parseOperational(shape)
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
return refused(shape, err)
|
||||
}
|
||||
return applyLog(cfg, shape), nil
|
||||
return finish(shape, applyLog(applyCertificate(cfg, shape, path), shape))
|
||||
}
|
||||
|
||||
func finish(shape fileShape, cfg loaded) (configLoad, error) {
|
||||
form := formFromShape(shape)
|
||||
if cfg.certFatal != "" {
|
||||
return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.certFatal}, nil
|
||||
}
|
||||
return configLoad{cfg: cfg, form: form, loaded: true, detail: cfg.logNotice}, nil
|
||||
}
|
||||
|
||||
func refused(shape fileShape, cause error) (configLoad, error) {
|
||||
return configLoad{form: formFromShape(shape), detail: cause.Error()}, nil
|
||||
}
|
||||
|
||||
func formFromShape(shape fileShape) ConfigForm {
|
||||
fallback, err := templateForm()
|
||||
if err != nil {
|
||||
fallback = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
|
||||
}
|
||||
return mergeSalvage(shape, fallback)
|
||||
}
|
||||
|
||||
func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
|
||||
if s, err := stringField("host", shape.Host); err == nil && present(shape.Host) {
|
||||
fallback.Host = s
|
||||
}
|
||||
if present(shape.Port) && shape.Port.Kind() == jsontext.KindNumber {
|
||||
var n int
|
||||
if jsonv2.Unmarshal(shape.Port, &n) == nil {
|
||||
fallback.Port = n
|
||||
}
|
||||
}
|
||||
if s, err := stringField("callsign", shape.Callsign); err == nil && present(shape.Callsign) {
|
||||
fallback.Callsign = s
|
||||
}
|
||||
if s, err := stringField("authKey", shape.AuthKey); err == nil && present(shape.AuthKey) {
|
||||
fallback.AuthKey = s
|
||||
}
|
||||
if present(shape.TalkGroup) && shape.TalkGroup.Kind() == jsontext.KindNumber {
|
||||
var n int
|
||||
if jsonv2.Unmarshal(shape.TalkGroup, &n) == nil {
|
||||
fallback.TalkGroup = n
|
||||
}
|
||||
}
|
||||
if s, err := stringField("protocol", shape.Protocol); err == nil && present(shape.Protocol) {
|
||||
if label, ok := protocolLabel(s); ok {
|
||||
fallback.Protocol = label
|
||||
}
|
||||
}
|
||||
if s, err := stringField("certificateDir", shape.CertificateDir); err == nil && present(shape.CertificateDir) {
|
||||
fallback.CertificateDir = s
|
||||
}
|
||||
if present(shape.EnableLogging) && isBool(shape.EnableLogging) {
|
||||
fallback.EnableLogging = shape.EnableLogging.Kind() == jsontext.KindTrue
|
||||
}
|
||||
if s, err := stringField("logFile", shape.LogFile); err == nil && present(shape.LogFile) {
|
||||
fallback.LogFile = s
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseOperational(shape fileShape) (loaded, error) {
|
||||
@@ -130,6 +235,10 @@ func parseOperational(shape fileShape) (loaded, error) {
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
}
|
||||
protocolMajor, err := protocolField(shape.Protocol)
|
||||
if err != nil {
|
||||
return loaded{}, err
|
||||
}
|
||||
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
@@ -142,7 +251,45 @@ func parseOperational(shape fileShape) (loaded, error) {
|
||||
if !callsignPattern.MatchString(call) {
|
||||
return loaded{}, fmt.Errorf("callsign is invalid")
|
||||
}
|
||||
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg}, nil
|
||||
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg, protocolMajor: protocolMajor}, nil
|
||||
}
|
||||
|
||||
func protocolField(v jsontext.Value) (uint16, error) {
|
||||
if !present(v) {
|
||||
return 2, nil
|
||||
}
|
||||
s, err := stringField("protocol", v)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return 2, nil
|
||||
}
|
||||
if _, ok := protocolLabel(s); !ok {
|
||||
return 0, fmt.Errorf("protocol is invalid")
|
||||
}
|
||||
if strings.TrimSpace(s) == "v3" {
|
||||
return 3, nil
|
||||
}
|
||||
return 2, nil
|
||||
}
|
||||
|
||||
func protocolLabel(s string) (string, bool) {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "v2":
|
||||
return "v2", true
|
||||
case "v3":
|
||||
return "v3", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func protocolName(major uint16) string {
|
||||
if major == 3 {
|
||||
return "v3"
|
||||
}
|
||||
return "v2"
|
||||
}
|
||||
|
||||
func stringField(name string, v jsontext.Value) (string, error) {
|
||||
@@ -187,12 +334,14 @@ func intField(name string, v jsontext.Value, min, max int) (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func defaultLogPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
var userHomeDir = os.UserHomeDir
|
||||
|
||||
func defaultLogPath() (string, error) {
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
return "", fmt.Errorf("config path: %w", err)
|
||||
}
|
||||
return filepath.Join(home, "funknetz.log")
|
||||
return filepath.Join(home, "funknetz.log"), nil
|
||||
}
|
||||
|
||||
func present(v jsontext.Value) bool { return len(v) > 0 }
|
||||
@@ -205,6 +354,18 @@ func missingLog(shape fileShape) bool {
|
||||
return !present(shape.EnableLogging) || !present(shape.LogFile)
|
||||
}
|
||||
|
||||
func missingCertificate(shape fileShape) bool {
|
||||
return !present(shape.CertificateDir)
|
||||
}
|
||||
|
||||
func missingProtocol(shape fileShape) bool {
|
||||
if !present(shape.Protocol) {
|
||||
return true
|
||||
}
|
||||
s, err := stringField("protocol", shape.Protocol)
|
||||
return err == nil && strings.TrimSpace(s) == ""
|
||||
}
|
||||
|
||||
func operationalKindsOK(shape fileShape) bool {
|
||||
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
||||
return false
|
||||
@@ -221,6 +382,9 @@ func operationalKindsOK(shape fileShape) bool {
|
||||
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
||||
return false
|
||||
}
|
||||
if present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -241,21 +405,29 @@ func blockedLog(shape fileShape) (block bool, notice string) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func withLogDefaults(shape fileShape) fileShape {
|
||||
func withLogDefaults(shape fileShape) (fileShape, error) {
|
||||
shape.Host = rawOr(shape.Host, `""`)
|
||||
shape.Port = rawOr(shape.Port, "0")
|
||||
shape.Callsign = rawOr(shape.Callsign, `""`)
|
||||
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
||||
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
||||
if missingProtocol(shape) {
|
||||
shape.Protocol = jsontext.Value(`"v2"`)
|
||||
}
|
||||
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
|
||||
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
||||
if !present(shape.LogFile) {
|
||||
raw, err := jsonv2.Marshal(defaultLogPath())
|
||||
logFile, err := defaultLogPath()
|
||||
if err != nil {
|
||||
raw = []byte(`""`)
|
||||
return fileShape{}, err
|
||||
}
|
||||
raw, err := jsonv2.Marshal(logFile)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
shape.LogFile = jsontext.Value(raw)
|
||||
}
|
||||
return shape
|
||||
return shape, nil
|
||||
}
|
||||
|
||||
func rawOr(v jsontext.Value, fallback string) jsontext.Value {
|
||||
@@ -275,13 +447,17 @@ func rewriteConfig(path string, shape fileShape) error {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
return rewriteBytes(path, body, info.Mode().Perm())
|
||||
}
|
||||
|
||||
func rewriteBytes(path string, body []byte, mode fs.FileMode) error {
|
||||
tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
_, werr := tmp.Write(body)
|
||||
cerr := tmp.Chmod(info.Mode().Perm())
|
||||
cerr := tmp.Chmod(mode)
|
||||
clerr := tmp.Close()
|
||||
if werr != nil || cerr != nil || clerr != nil {
|
||||
os.Remove(tmpName)
|
||||
@@ -300,6 +476,32 @@ func rewriteConfig(path string, shape fileShape) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCertificate(cfg loaded, shape fileShape, configPath string) loaded {
|
||||
if !present(shape.CertificateDir) {
|
||||
cfg.certificateDir = resolveCertDir(configPath, "./funknetz")
|
||||
return cfg
|
||||
}
|
||||
dir, err := stringField("certificateDir", shape.CertificateDir)
|
||||
if err != nil {
|
||||
cfg.certFatal = err.Error()
|
||||
return cfg
|
||||
}
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
cfg.certFatal = "certificateDir is empty"
|
||||
return cfg
|
||||
}
|
||||
cfg.certificateDir = resolveCertDir(configPath, dir)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveCertDir(configPath, dir string) string {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if filepath.IsAbs(dir) {
|
||||
return filepath.Clean(dir)
|
||||
}
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(configPath), dir))
|
||||
}
|
||||
|
||||
func applyLog(cfg loaded, shape fileShape) loaded {
|
||||
if shape.EnableLogging.Kind() != jsontext.KindTrue {
|
||||
return cfg
|
||||
|
||||
+363
-70
@@ -1,64 +1,68 @@
|
||||
package station_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jsonv2 "encoding/json/v2"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/station"
|
||||
)
|
||||
|
||||
func TestMissingConfigIsCreatedAndRejected(t *testing.T) {
|
||||
func TestMissingConfigOpensWithoutCreating(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".funknetz.json")
|
||||
|
||||
_, err := station.New(station.Options{ConfigPath: path})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want a rejection after creating the file")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "created") {
|
||||
t.Fatalf("New() error = %q, want it to say the file was created", err.Error())
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
st, err := station.New(station.Options{ConfigPath: 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)
|
||||
t.Cleanup(st.Close)
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
got := st.State()
|
||||
if !got.NeedsConfig || !got.ConfigOpen || got.Connection != "Disconnected" {
|
||||
t.Fatalf("State = %+v, want Disconnected with the Config window open", got)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("created file is not JSON: %v\n%s", err, body)
|
||||
}
|
||||
if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" {
|
||||
t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got)
|
||||
}
|
||||
if got["port"] != float64(0) || got["talkGroup"] != float64(0) {
|
||||
t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got)
|
||||
cfg := st.Config()
|
||||
if !cfg.Open || !cfg.NeedsConfig || cfg.Error != "" {
|
||||
t.Fatalf("Config = %+v, want an open needs-config form and no error", cfg)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["enableLogging"] != false {
|
||||
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
|
||||
form := cfg.Form
|
||||
if form.Host != "" || form.Callsign != "" || form.AuthKey != "" {
|
||||
t.Fatalf("string fields = %+v, want empty host, callsign, and authKey", form)
|
||||
}
|
||||
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"])
|
||||
if form.Port != 0 || form.TalkGroup != 0 {
|
||||
t.Fatalf("numeric fields = %+v, want port and talkGroup at zero", form)
|
||||
}
|
||||
if form.EnableLogging {
|
||||
t.Fatal("EnableLogging = true, want false")
|
||||
}
|
||||
if form.Protocol != "v2" {
|
||||
t.Fatalf("Protocol = %q, want v2", form.Protocol)
|
||||
}
|
||||
if form.CertificateDir != "./funknetz" {
|
||||
t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir)
|
||||
}
|
||||
if form.LogFile != filepath.Join(home, "funknetz.log") {
|
||||
t.Fatalf("LogFile = %q, want funknetz.log in the home directory", form.LogFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -84,44 +88,60 @@ func TestExistingConfigIsNotRewritten(t *testing.T) {
|
||||
|
||||
func TestInvalidConfigNamesTheField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
name string
|
||||
host string
|
||||
port int
|
||||
callsign string
|
||||
authKey string
|
||||
talkGroup int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "empty host", body: configJSON("", 5308, "DL1ABC", "key", 1), want: "host is empty"},
|
||||
{name: "blank host", body: configJSON(" ", 5308, "DL1ABC", "key", 1), want: "host is empty"},
|
||||
{name: "empty host", host: "", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
|
||||
{name: "blank host", host: " ", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "host is empty"},
|
||||
{name: "host type", body: `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "host has the wrong type"},
|
||||
{name: "empty callsign", body: configJSON("127.0.0.1", 5308, "", "key", 1), want: "callsign is empty"},
|
||||
{name: "empty callsign", host: "127.0.0.1", port: 5308, callsign: "", authKey: "key", talkGroup: 1, want: "callsign is empty"},
|
||||
{name: "callsign type", body: `{"host":"127.0.0.1","port":5308,"callsign":1,"authKey":"key","talkGroup":1}`, want: "callsign has the wrong type"},
|
||||
{name: "empty auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", "", 1), want: "authKey is empty"},
|
||||
{name: "whitespace auth key", body: configJSON("127.0.0.1", 5308, "DL1ABC", " \t", 1), want: "authKey is empty"},
|
||||
{name: "empty auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "", talkGroup: 1, want: "authKey is empty"},
|
||||
{name: "whitespace auth key", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: " \t", talkGroup: 1, want: "authKey is empty"},
|
||||
{name: "auth key type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":1,"talkGroup":1}`, want: "authKey has the wrong type"},
|
||||
{name: "port zero", body: configJSON("127.0.0.1", 0, "DL1ABC", "key", 1), want: "port is invalid"},
|
||||
{name: "port negative", body: configJSON("127.0.0.1", -1, "DL1ABC", "key", 1), want: "port is invalid"},
|
||||
{name: "port too high", body: configJSON("127.0.0.1", 65536, "DL1ABC", "key", 1), want: "port is invalid"},
|
||||
{name: "port zero", host: "127.0.0.1", port: 0, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||
{name: "port negative", host: "127.0.0.1", port: -1, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||
{name: "port too high", host: "127.0.0.1", port: 65536, callsign: "DL1ABC", authKey: "key", talkGroup: 1, want: "port is invalid"},
|
||||
{name: "port type", body: `{"host":"127.0.0.1","port":"5308","callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port has the wrong type"},
|
||||
{name: "port fraction", body: `{"host":"127.0.0.1","port":5308.5,"callsign":"DL1ABC","authKey":"key","talkGroup":1}`, want: "port is invalid"},
|
||||
{name: "talk group zero", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", 0), want: "talkGroup is invalid"},
|
||||
{name: "talk group negative", body: configJSON("127.0.0.1", 5308, "DL1ABC", "key", -3), want: "talkGroup is invalid"},
|
||||
{name: "talk group zero", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: 0, want: "talkGroup is invalid"},
|
||||
{name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"},
|
||||
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
|
||||
{name: "bad callsign", body: configJSON("127.0.0.1", 5308, "HELLO", "key", 1), want: "callsign is invalid"},
|
||||
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
|
||||
{name: "protocol type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":2}`, want: "protocol has the wrong type"},
|
||||
{name: "protocol value", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":"v1"}`, want: "protocol 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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = station.New(station.Options{ConfigPath: path})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want a field rejection")
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err.Error() != tt.want {
|
||||
t.Fatalf("New() error = %q, want %q", err.Error(), tt.want)
|
||||
t.Cleanup(st.Close)
|
||||
state := st.State()
|
||||
if !state.NeedsConfig || !state.ConfigOpen || state.Detail != tt.want {
|
||||
t.Fatalf("State = %+v, want the Config window open with detail %q", state, tt.want)
|
||||
}
|
||||
if st.Config().Error != tt.want {
|
||||
t.Fatalf("Config error = %q, want %q", st.Config().Error, tt.want)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -147,26 +167,61 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenFileSalvagesTheOtherFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
body := `{"host":1,"port":5308,"callsign":"DL1ABC","authKey":"a b","talkGroup":20,"certificateDir":"./radios","enableLogging":true,"logFile":"/tmp/funknetz.log"}`
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
form := st.Config().Form
|
||||
if form.Host != "" || form.Port != 5308 || form.Callsign != "DL1ABC" || form.AuthKey != "a b" || form.TalkGroup != 20 {
|
||||
t.Fatalf("Form = %+v, want the valid fields kept and host cleared", form)
|
||||
}
|
||||
if form.CertificateDir != "./radios" || !form.EnableLogging || form.LogFile != "/tmp/funknetz.log" {
|
||||
t.Fatalf("Form = %+v, want certificate, logging, and log file kept", form)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != body {
|
||||
t.Fatalf("file = %s, want it left untouched", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := station.New(station.Options{ConfigPath: path})
|
||||
if err == nil {
|
||||
t.Fatal("New() error = nil, want a read failure")
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err.Error() != "could not be read" {
|
||||
t.Fatalf("New() error = %q, want %q", err.Error(), "could not be read")
|
||||
t.Cleanup(st.Close)
|
||||
got := st.Config()
|
||||
if !got.Open || !got.NeedsConfig {
|
||||
t.Fatalf("Config = %+v, want the Config window open", got)
|
||||
}
|
||||
if strings.Contains(err.Error(), "callsign") {
|
||||
t.Fatalf("New() error = %q, want no field name", err.Error())
|
||||
if !strings.Contains(got.Error, "the file could not be read") {
|
||||
t.Fatalf("Config error = %q, want it to say the file could not be read", got.Error)
|
||||
}
|
||||
if strings.Contains(got.Error, "callsign") {
|
||||
t.Fatalf("Config error = %q, want no field name", got.Error)
|
||||
}
|
||||
if got.Form.Host != "" || got.Form.Callsign != "" || got.Form.Port != 0 || got.Form.CertificateDir != "./funknetz" {
|
||||
t.Fatalf("Form = %+v, want template defaults", got.Form)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -231,6 +286,13 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
|
||||
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||
t.Fatalf("logFile = %#v", got["logFile"])
|
||||
}
|
||||
if got["protocol"] != "v2" {
|
||||
t.Fatalf("protocol = %#v, want v2", got["protocol"])
|
||||
}
|
||||
if got["certificateDir"] != "./funknetz" {
|
||||
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
||||
}
|
||||
assertKeyOrder(t, body)
|
||||
if st.State().Detail != "Idle" {
|
||||
t.Fatalf("Detail = %q, want Idle", st.State().Detail)
|
||||
}
|
||||
@@ -305,9 +367,9 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
||||
detail string
|
||||
same bool
|
||||
}{
|
||||
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
|
||||
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
|
||||
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
|
||||
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
|
||||
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
|
||||
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
|
||||
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
|
||||
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
|
||||
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
|
||||
@@ -339,15 +401,20 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("bad host still quits", func(t *testing.T) {
|
||||
t.Run("bad host opens the config window", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`)
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := station.New(station.Options{ConfigPath: path})
|
||||
if err == nil || err.Error() != "host is empty" {
|
||||
t.Fatalf("New() error = %v, want host is empty", err)
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
got := st.State()
|
||||
if !got.NeedsConfig || !got.ConfigOpen || got.Detail != "host is empty" {
|
||||
t.Fatalf("State = %+v, want the Config window open with host is empty", got)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -359,21 +426,247 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func configJSON(host string, port int, callsign, authKey string, talkGroup int) string {
|
||||
body, err := jsonv2.Marshal(map[string]any{
|
||||
func assertKeyOrder(t *testing.T, body []byte) {
|
||||
t.Helper()
|
||||
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"protocol"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
|
||||
last := -1
|
||||
for _, key := range keys {
|
||||
i := strings.Index(string(body), key)
|
||||
if i < 0 || i < last {
|
||||
t.Fatalf("key %s out of order in %s", key, body)
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSaveWritesNothingAndDoesNotConnect(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
err = st.Save(station.ConfigForm{
|
||||
Host: "",
|
||||
Port: port,
|
||||
Callsign: "DL1ABC",
|
||||
AuthKey: "key",
|
||||
TalkGroup: 20,
|
||||
CertificateDir: "./funknetz",
|
||||
LogFile: "/tmp/funknetz.log",
|
||||
})
|
||||
if err == nil || err.Error() != "host is empty" {
|
||||
t.Fatalf("Save() error = %v, want host is empty", err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||
}
|
||||
cfg := st.Config()
|
||||
if !cfg.Open || cfg.Error != "host is empty" || cfg.Form.Callsign != "DL1ABC" {
|
||||
t.Fatalf("Config = %+v, want the window open with the host error and the edited callsign", cfg)
|
||||
}
|
||||
waitCtx, waitCancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer waitCancel()
|
||||
if _, err := st.Wait(waitCtx, func(s station.State) bool { return s.Connection != "Disconnected" }); err == nil {
|
||||
t.Fatalf("State = %+v, want it to stay Disconnected", st.State())
|
||||
}
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if conn, err := ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("opened a socket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidSaveCreatesFileAndConnects(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "funknetz.json")
|
||||
logFile := filepath.Join(dir, "funknetz.log")
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
err = st.Save(station.ConfigForm{
|
||||
Host: " 127.0.0.1 ",
|
||||
Port: port,
|
||||
Callsign: " dl1abc ",
|
||||
AuthKey: "a b",
|
||||
TalkGroup: 20,
|
||||
CertificateDir: "./funknetz",
|
||||
LogFile: logFile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Config().Open || st.State().NeedsConfig {
|
||||
t.Fatalf("State = %+v, Config open = %v, want the Config window closed", st.State(), st.Config().Open)
|
||||
}
|
||||
if st.State().Callsign != "DL1ABC" || st.State().TalkGroup != 20 {
|
||||
t.Fatalf("State = %+v, want callsign DL1ABC talk group 20", st.State())
|
||||
}
|
||||
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)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := fmt.Sprintf("{\"host\":\"127.0.0.1\",\"port\":%d,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":%q}\n", port, logFile)
|
||||
if string(body) != want {
|
||||
t.Fatalf("file = %s, want %s", body, want)
|
||||
}
|
||||
assertKeyOrder(t, body)
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(2 * time.Second))
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := station.New(station.Options{ConfigPath: path}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want {
|
||||
t.Fatalf("mode = %o, want %o", got, want)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]any
|
||||
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" || got["protocol"] != "v2" {
|
||||
t.Fatalf("fields = %#v", got)
|
||||
}
|
||||
if strings.Contains(string(body), t.TempDir()) {
|
||||
t.Fatalf("file = %s, want the stored directory to stay relative", body)
|
||||
}
|
||||
assertKeyOrder(t, body)
|
||||
}
|
||||
|
||||
func TestCertificateDirStopsBeforeASocket(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dir string
|
||||
want string
|
||||
}{
|
||||
{name: "wrong type", dir: "1", want: "certificateDir has the wrong type"},
|
||||
{name: "empty", dir: `""`, want: "certificateDir is empty"},
|
||||
{name: "whitespace", dir: `" "`, want: "certificateDir is empty"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
got := st.State()
|
||||
if got.Detail != tt.want || got.Connection != "Disconnected" || got.Callsign != "DL1ABC" {
|
||||
t.Fatalf("State = %+v, want detail %q", got, tt.want)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(after) != body {
|
||||
t.Fatalf("file = %s, want it left untouched", after)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if conn, err := ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("opened a socket")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
||||
t.Helper()
|
||||
return configJSONProtocol(t, host, port, callsign, authKey, talkGroup, "")
|
||||
}
|
||||
|
||||
func configJSONProtocol(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, protocol string) string {
|
||||
t.Helper()
|
||||
fields := map[string]any{
|
||||
"host": host,
|
||||
"port": port,
|
||||
"callsign": callsign,
|
||||
"authKey": authKey,
|
||||
"talkGroup": talkGroup,
|
||||
})
|
||||
}
|
||||
if protocol != "" {
|
||||
fields["protocol"] = protocol
|
||||
}
|
||||
body, err := jsonv2.Marshal(fields)
|
||||
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 +677,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"encoding/json/jsontext"
|
||||
jsonv2 "encoding/json/v2"
|
||||
)
|
||||
|
||||
// Cancel discards the Config window. It reports whether the app should quit
|
||||
// because no config has loaded successfully.
|
||||
func (s *Station) Cancel() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.loaded {
|
||||
return true
|
||||
}
|
||||
s.configOpen = false
|
||||
s.configErr = ""
|
||||
s.publish()
|
||||
return false
|
||||
}
|
||||
|
||||
// OpenConfig re-reads the config file into the form. The live session is kept
|
||||
// when the re-read fails.
|
||||
func (s *Station) OpenConfig() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.configOpen {
|
||||
return
|
||||
}
|
||||
if s.buttonHeld || s.spaceHeld {
|
||||
s.openBlocked = "Release push-to-talk"
|
||||
s.publish()
|
||||
return
|
||||
}
|
||||
s.openBlocked = ""
|
||||
form, detail, ok := readForm(s.path)
|
||||
s.configOpen = true
|
||||
s.form = form
|
||||
if ok {
|
||||
s.configErr = ""
|
||||
} else {
|
||||
s.configErr = detail
|
||||
}
|
||||
s.publish()
|
||||
}
|
||||
|
||||
// Save validates form, writes the config file, and closes the Config window.
|
||||
// An invalid form or a write error leaves the window open and the session unchanged.
|
||||
func (s *Station) Save(form ConfigForm) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next, stored, err := validateForm(s.path, form)
|
||||
if err != nil {
|
||||
s.keepForm(form, err.Error())
|
||||
return err
|
||||
}
|
||||
if err := writeForm(s.path, stored); err != nil {
|
||||
msg := fmt.Errorf("the file could not be written: %w", err).Error()
|
||||
s.keepForm(form, msg)
|
||||
return errors.New(msg)
|
||||
}
|
||||
prev := s.cfg
|
||||
wasLoaded := s.loaded
|
||||
s.cfg = next
|
||||
s.form = stored
|
||||
s.loaded = true
|
||||
s.configErr = ""
|
||||
s.configOpen = false
|
||||
s.logPath = next.logPath
|
||||
s.logNotice = next.logNotice
|
||||
reconnect := wasLoaded && connectionChanged(prev, next)
|
||||
if next.certFatal != "" {
|
||||
s.fatal = next.certFatal
|
||||
s.closeLogLocked()
|
||||
if next.enableLogging {
|
||||
s.openLog()
|
||||
}
|
||||
if wasLoaded {
|
||||
s.kickReconnect()
|
||||
}
|
||||
} else if reconnect || !wasLoaded {
|
||||
if !wasLoaded || s.fatal == prev.certFatal {
|
||||
s.fatal = ""
|
||||
}
|
||||
s.closeLogLocked()
|
||||
if next.enableLogging {
|
||||
s.openLog()
|
||||
}
|
||||
if reconnect {
|
||||
s.kickReconnect()
|
||||
}
|
||||
} else {
|
||||
s.applyLogging(prev, next)
|
||||
}
|
||||
s.publish()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) kickReconnect() {
|
||||
s.epoch++
|
||||
s.kick = true
|
||||
if s.cancelDial != nil {
|
||||
s.cancelDial()
|
||||
}
|
||||
if s.conn != nil {
|
||||
// Close unblocks the session read so Run can dial again.
|
||||
_ = s.conn.Close()
|
||||
}
|
||||
select {
|
||||
case s.apply <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) drainApply() {
|
||||
select {
|
||||
case <-s.apply:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) applyLogging(prev, next loaded) {
|
||||
if prev.enableLogging == next.enableLogging && prev.logPath == next.logPath && prev.logNotice == next.logNotice {
|
||||
return
|
||||
}
|
||||
s.closeLogLocked()
|
||||
s.logNotice = next.logNotice
|
||||
s.logPath = next.logPath
|
||||
if next.enableLogging && next.logNotice == "" {
|
||||
s.openLog()
|
||||
}
|
||||
}
|
||||
|
||||
func connectionChanged(prev, next loaded) bool {
|
||||
return prev.host != next.host || prev.port != next.port || prev.callsign != next.callsign ||
|
||||
prev.authKey != next.authKey || prev.talkGroup != next.talkGroup || prev.protocolMajor != next.protocolMajor ||
|
||||
prev.certificateDir != next.certificateDir
|
||||
}
|
||||
|
||||
func readForm(path string) (ConfigForm, string, bool) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
form, ferr := templateForm()
|
||||
if ferr != nil {
|
||||
form = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
|
||||
}
|
||||
return form, fmt.Errorf("the file could not be read: %w", err).Error(), false
|
||||
}
|
||||
out, err := parseConfig(path, body, false)
|
||||
if err != nil {
|
||||
return out.form, err.Error(), false
|
||||
}
|
||||
if !out.loaded {
|
||||
msg := out.detail
|
||||
if msg == "" {
|
||||
msg = "the file could not be read"
|
||||
}
|
||||
return out.form, msg, false
|
||||
}
|
||||
return out.form, "", true
|
||||
}
|
||||
|
||||
func (s *Station) keepForm(form ConfigForm, msg string) {
|
||||
s.form = form
|
||||
s.configErr = msg
|
||||
s.configOpen = true
|
||||
s.publish()
|
||||
}
|
||||
|
||||
func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error) {
|
||||
shape, err := shapeFromForm(form)
|
||||
if err != nil {
|
||||
return loaded{}, form, err
|
||||
}
|
||||
cfg, err := parseOperational(shape)
|
||||
if err != nil {
|
||||
return loaded{}, form, err
|
||||
}
|
||||
cfg = applyCertificate(cfg, shape, configPath)
|
||||
cfg = applyLog(cfg, shape)
|
||||
stored := ConfigForm{
|
||||
Host: cfg.host,
|
||||
Port: cfg.port,
|
||||
Callsign: cfg.callsign,
|
||||
AuthKey: cfg.authKey,
|
||||
TalkGroup: cfg.talkGroup,
|
||||
Protocol: protocolName(cfg.protocolMajor),
|
||||
CertificateDir: strings.TrimSpace(form.CertificateDir),
|
||||
EnableLogging: form.EnableLogging,
|
||||
LogFile: form.LogFile,
|
||||
}
|
||||
return cfg, stored, nil
|
||||
}
|
||||
|
||||
func shapeFromForm(form ConfigForm) (fileShape, error) {
|
||||
host, err := jsonValue(form.Host)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
port, err := jsonValue(form.Port)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
call, err := jsonValue(form.Callsign)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
key, err := jsonValue(form.AuthKey)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
tg, err := jsonValue(form.TalkGroup)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
protocol, err := jsonValue(form.Protocol)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
dir, err := jsonValue(form.CertificateDir)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
en, err := jsonValue(form.EnableLogging)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
logFile, err := jsonValue(form.LogFile)
|
||||
if err != nil {
|
||||
return fileShape{}, err
|
||||
}
|
||||
return fileShape{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Callsign: call,
|
||||
AuthKey: key,
|
||||
TalkGroup: tg,
|
||||
Protocol: protocol,
|
||||
CertificateDir: dir,
|
||||
EnableLogging: en,
|
||||
LogFile: logFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func jsonValue(v any) (jsontext.Value, error) {
|
||||
raw, err := jsonv2.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jsontext.Value(raw), nil
|
||||
}
|
||||
|
||||
func writeForm(path string, form ConfigForm) error {
|
||||
body, err := jsonv2.Marshal(templateFile{
|
||||
Host: form.Host,
|
||||
Port: form.Port,
|
||||
Callsign: form.Callsign,
|
||||
AuthKey: form.AuthKey,
|
||||
TalkGroup: form.TalkGroup,
|
||||
Protocol: form.Protocol,
|
||||
CertificateDir: form.CertificateDir,
|
||||
EnableLogging: form.EnableLogging,
|
||||
LogFile: form.LogFile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, '\n')
|
||||
info, err := os.Stat(path)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return createExclusive(path, body)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rewriteBytes(path, body, info.Mode().Perm())
|
||||
}
|
||||
|
||||
func createExclusive(path string, body []byte) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, werr := f.Write(body)
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
os.Remove(path)
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
return cerr
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
os.Remove(path)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package station_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/station"
|
||||
)
|
||||
|
||||
func TestOpenConfigRereadsTheFile(t *testing.T) {
|
||||
path := writeConfigFile(t, fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, "/tmp/funknetz.log"))
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
edited := fullConfig("127.0.0.1", 5308, "DL1ABD", "key", 21, "/tmp/funknetz.log")
|
||||
if err := os.WriteFile(path, []byte(edited), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.OpenConfig()
|
||||
cfg := st.Config()
|
||||
if !cfg.Open || cfg.Error != "" || cfg.Form.Callsign != "DL1ABD" || cfg.Form.TalkGroup != 21 {
|
||||
t.Fatalf("Config = %+v, want the hand-edited callsign and talk group", cfg)
|
||||
}
|
||||
if st.State().Callsign != "DL1ABC" || st.State().TalkGroup != 20 {
|
||||
t.Fatalf("State = %+v, want the session to keep the loaded config", st.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedRereadKeepsTheSession(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
if err := os.WriteFile(in.path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.st.OpenConfig()
|
||||
cfg := in.st.Config()
|
||||
if !cfg.Open || !strings.Contains(cfg.Error, "the file could not be read") {
|
||||
t.Fatalf("Config = %+v, want an open window and a read error", cfg)
|
||||
}
|
||||
got := in.st.State()
|
||||
if got.Connection != "Connected" || got.Callsign != "DL1ABC" || got.TalkGroup != 20 {
|
||||
t.Fatalf("State = %+v, want the live session kept", got)
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelAfterLoadLeavesFileAndSession(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
before, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.st.OpenConfig()
|
||||
outside := []byte(fullConfig("127.0.0.1", in.refl.port, "DL1ABD", "other", 21, "/tmp/funknetz.log"))
|
||||
if err := os.WriteFile(in.path, outside, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.st.Cancel() {
|
||||
t.Fatal("Cancel() quit after a loaded config")
|
||||
}
|
||||
if in.st.State().ConfigOpen || in.st.State().Connection != "Connected" || in.st.State().Callsign != "DL1ABC" {
|
||||
t.Fatalf("State = %+v, want the window closed and the session kept", in.st.State())
|
||||
}
|
||||
got, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(outside) {
|
||||
t.Fatalf("file = %s, want the outside edit left in place", got)
|
||||
}
|
||||
if string(before) == string(got) {
|
||||
t.Fatal("file did not change, so the test did not prove Cancel left it alone")
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveRewritesAndReconnects(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
if err := os.Chmod(in.path, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
form.TalkGroup = 21
|
||||
if err := in.st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.st.Config().Open {
|
||||
t.Fatal("Config window stayed open")
|
||||
}
|
||||
info, err := os.Stat(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want {
|
||||
t.Fatalf("mode = %o, want %o", got, want)
|
||||
}
|
||||
body, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"talkGroup":21`) || !strings.HasSuffix(string(body), "\n") {
|
||||
t.Fatalf("file = %s, want talk group 21 and a trailing newline", body)
|
||||
}
|
||||
assertKeyOrder(t, body)
|
||||
|
||||
in.refl.accept(t)
|
||||
expectVersion(t, in.refl, 2, 0)
|
||||
challenge := make([]byte, 20)
|
||||
for i := range challenge {
|
||||
challenge[i] = byte(i + 1)
|
||||
}
|
||||
in.refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
||||
typ, _ := in.refl.readFrame(t)
|
||||
if typ != 11 {
|
||||
t.Fatalf("auth type %d, want 11", typ)
|
||||
}
|
||||
in.refl.writeFrame(t, 12, nil)
|
||||
in.refl.writeFrame(t, 100, serverInfo(7, []string{"db0abc"}, []string{"OPUS"}))
|
||||
typ, _ = in.refl.readFrame(t)
|
||||
if typ != 111 {
|
||||
t.Fatalf("type %d, want software identity", typ)
|
||||
}
|
||||
typ, tgBody := in.refl.readFrame(t)
|
||||
if typ != 106 {
|
||||
t.Fatalf("type %d, want select talk group", typ)
|
||||
}
|
||||
gotTG, _ := takeU32(t, tgBody)
|
||||
if gotTG != 21 {
|
||||
t.Fatalf("talk group %d, want 21", gotTG)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
state, err := in.st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" && s.TalkGroup == 21 })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if state.Callsign != "DL1ABC" {
|
||||
t.Fatalf("Callsign = %q, want DL1ABC", state.Callsign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidSaveLeavesTheSession(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
before, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
form.Host = ""
|
||||
err = in.st.Save(form)
|
||||
if err == nil || err.Error() != "host is empty" {
|
||||
t.Fatalf("Save() error = %v, want host is empty", err)
|
||||
}
|
||||
got, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(before) {
|
||||
t.Fatalf("file = %s, want it unchanged", got)
|
||||
}
|
||||
state := in.st.State()
|
||||
if !state.ConfigOpen || state.Connection != "Connected" || state.Callsign != "DL1ABC" || state.Detail != "host is empty" {
|
||||
t.Fatalf("State = %+v, want the window open, the session up, and the host error", state)
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushToTalkIsOffWhileConfigIsOpen(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.OpenConfig()
|
||||
in.st.HoldButton()
|
||||
if in.st.State().Transmitting || in.st.State().Connection != "Connected" {
|
||||
t.Fatalf("State = %+v, want listening with push-to-talk off", in.st.State())
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigStaysClosedWhilePushToTalkIsHeld(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.HoldButton()
|
||||
if !in.st.State().Transmitting {
|
||||
t.Fatal("Transmitting = false, want the button held")
|
||||
}
|
||||
in.st.OpenConfig()
|
||||
state := in.st.State()
|
||||
if state.ConfigOpen || state.Detail != "Release push-to-talk" || !state.Transmitting {
|
||||
t.Fatalf("State = %+v, want no Config window and a push-to-talk notice", state)
|
||||
}
|
||||
in.st.ReleaseButton()
|
||||
if in.st.State().Detail == "Release push-to-talk" || in.st.State().Transmitting {
|
||||
t.Fatalf("State = %+v, want the notice cleared after release", in.st.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertificateProblemStaysOnTheStation(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
body := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"certificateDir\":\"\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
got := st.State()
|
||||
if got.ConfigOpen || got.NeedsConfig || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" {
|
||||
t.Fatalf("State = %+v, want the station window with the certificate detail", got)
|
||||
}
|
||||
if st.Cancel() {
|
||||
t.Fatal("Cancel() quit after a certificate-directory problem")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenConfigDoesNotRewriteAHandEdit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "funknetz.json")
|
||||
body := []byte(fullConfig("127.0.0.1", 5308, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log")))
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
edited := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20,\"enableLogging\":false,\"logFile\":\"" + filepath.Join(dir, "funknetz.log") + "\"}\n")
|
||||
if err := os.WriteFile(path, edited, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.OpenConfig()
|
||||
if st.Cancel() {
|
||||
t.Fatal("Cancel() quit")
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(edited) {
|
||||
t.Fatalf("file = %s, want the hand-edit left unchanged", got)
|
||||
}
|
||||
if st.State().Callsign != "DL1ABC" || st.State().ConfigOpen {
|
||||
t.Fatalf("State = %+v, want the loaded session and a closed Config window", st.State())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveEmptyCertificateDirDoesNotConnect(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
err = st.Save(station.ConfigForm{
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
Callsign: "DL1ABC",
|
||||
AuthKey: "key",
|
||||
TalkGroup: 20,
|
||||
CertificateDir: "",
|
||||
LogFile: "/tmp/funknetz.log",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := st.State()
|
||||
if got.ConfigOpen || got.Detail != "certificateDir is empty" || got.Connection != "Disconnected" {
|
||||
t.Fatalf("State = %+v, want the window closed and the certificate detail", got)
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %o, want 600", info.Mode().Perm())
|
||||
}
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if conn, err := ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("opened a socket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingOnlySaveKeepsTheSession(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
logPath := filepath.Join(t.TempDir(), "station.log")
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
form.EnableLogging = true
|
||||
form.LogFile = logPath
|
||||
if err := in.st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.st.Config().Open {
|
||||
t.Fatal("Config window stayed open")
|
||||
}
|
||||
state := in.st.State()
|
||||
if state.Connection != "Connected" || state.TalkGroup != 20 || state.Callsign != "DL1ABC" {
|
||||
t.Fatalf("State = %+v, want the session kept", state)
|
||||
}
|
||||
if _, err := os.Stat(logPath); err != nil {
|
||||
t.Fatalf("log file: %v", err)
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggingSaveDoesNotClearAFatalStop(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
dir := t.TempDir()
|
||||
path := writeConfigFile(t, fullConfig("127.0.0.1", port, "DL1ABC", "key", 20, filepath.Join(dir, "funknetz.log")))
|
||||
st, err := station.New(station.Options{ConfigPath: path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(st.Close)
|
||||
st.Fail("could not open the audio device")
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
st.OpenConfig()
|
||||
form := st.Config().Form
|
||||
form.EnableLogging = true
|
||||
form.LogFile = filepath.Join(dir, "station.log")
|
||||
if err := st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.State().Detail != "could not open the audio device" || st.State().Connection != "Disconnected" {
|
||||
t.Fatalf("State = %+v, want the fatal stop kept", st.State())
|
||||
}
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
if conn, err := ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("opened a socket")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedSaveDoesNotReconnect(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
if err := in.st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.st.Config().Open || in.st.State().Connection != "Connected" {
|
||||
t.Fatalf("State = %+v, Config open = %v, want the window closed and the session kept", in.st.State(), in.st.Config().Open)
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveWriteFailureKeepsTheSession(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
before, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(in.path)
|
||||
if err := os.Chmod(dir, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
// Restore the directory so the test temp dir can be removed.
|
||||
_ = os.Chmod(dir, 0o755)
|
||||
})
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
form.TalkGroup = 21
|
||||
err = in.st.Save(form)
|
||||
if err == nil || !strings.Contains(err.Error(), "the file could not be written") {
|
||||
t.Fatalf("Save() error = %v, want a write failure", err)
|
||||
}
|
||||
state := in.st.State()
|
||||
if !state.ConfigOpen || state.Connection != "Connected" || state.TalkGroup != 20 || !strings.Contains(state.Detail, "the file could not be written") {
|
||||
t.Fatalf("State = %+v, want the window open, the session kept, and the write error", state)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(in.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(before) {
|
||||
t.Fatalf("file = %s, want it unchanged", got)
|
||||
}
|
||||
if err := connStillOpen(in.refl.conn()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachConnectionFieldReconnects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*station.ConfigForm, int)
|
||||
}{
|
||||
{name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }},
|
||||
{name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }},
|
||||
{name: "authKey", edit: func(f *station.ConfigForm, _ int) { f.AuthKey = "other-key" }},
|
||||
{name: "protocol", edit: func(f *station.ConfigForm, _ int) { f.Protocol = "v3" }},
|
||||
{name: "certificateDir", edit: func(f *station.ConfigForm, _ int) { f.CertificateDir = "./radios" }},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
tt.edit(&form, in.refl.port)
|
||||
if err := in.st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if in.st.Config().Open {
|
||||
t.Fatal("Config window stayed open")
|
||||
}
|
||||
old := in.refl.conn()
|
||||
in.refl.accept(t)
|
||||
if err := connStillOpen(old); err == nil {
|
||||
t.Fatal("old session stayed open")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("port", func(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.OpenConfig()
|
||||
form := in.st.Config().Form
|
||||
form.Port = ln.Addr().(*net.TCPAddr).Port
|
||||
if err := in.st.Save(form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := in.refl.conn()
|
||||
ln.(*net.TCPListener).SetDeadline(time.Now().Add(2 * time.Second))
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
if err := connStillOpen(old); err == nil {
|
||||
t.Fatal("old session stayed open")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func fullConfig(host string, port int, callsign, authKey string, talkGroup int, logFile string) string {
|
||||
return "{\"host\":\"" + host + "\",\"port\":" + strconv.Itoa(port) + ",\"callsign\":\"" + callsign + "\",\"authKey\":\"" + authKey + "\",\"talkGroup\":" + strconv.Itoa(talkGroup) + ",\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n"
|
||||
}
|
||||
|
||||
func connStillOpen(conn net.Conn) error {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil {
|
||||
return err
|
||||
}
|
||||
var hdr [4]byte
|
||||
_, err := io.ReadFull(conn, hdr[:])
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMissingHomeDoesNotCreateConfig(t *testing.T) {
|
||||
orig := userHomeDir
|
||||
t.Cleanup(func() { userHomeDir = orig })
|
||||
userHomeDir = func() (string, error) {
|
||||
return "", errors.New("no home")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
_, err := New(Options{ConfigPath: path})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "config path:") {
|
||||
t.Fatalf("New() error = %v, want a config path failure", err)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !errors.Is(statErr, fs.ErrNotExist) {
|
||||
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingHomeDoesNotRewriteConfig(t *testing.T) {
|
||||
orig := userHomeDir
|
||||
t.Cleanup(func() { userHomeDir = orig })
|
||||
userHomeDir = func() (string, error) {
|
||||
return "", errors.New("no home")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "lab.json")
|
||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"key\",\"talkGroup\":20}\n")
|
||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := New(Options{ConfigPath: path})
|
||||
if err == nil || !strings.HasPrefix(err.Error(), "config path:") {
|
||||
t.Fatalf("New() error = %v, want a config path failure", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(original) {
|
||||
t.Fatalf("file = %q, want %q", got, original)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package station_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -30,8 +32,11 @@ func TestConnectsToLabReflector(t *testing.T) {
|
||||
t.Skipf("lab reflector not listening on %s: %v", addr, err)
|
||||
}
|
||||
conn.Close()
|
||||
if !labUDPReachable() {
|
||||
t.Skipf("lab UDP port %s is not reachable", addr)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -39,7 +44,10 @@ func TestConnectsToLabReflector(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
state, err := st.Wait(ctx, func(s station.State) bool {
|
||||
return s.Connection == "Connected"
|
||||
@@ -54,3 +62,20 @@ func TestConnectsToLabReflector(t *testing.T) {
|
||||
t.Fatalf("talkGroup = %d, want %d", state.TalkGroup, labTalkGroup)
|
||||
}
|
||||
}
|
||||
|
||||
func labUDPReachable() bool {
|
||||
peer := &net.UDPAddr{IP: net.ParseIP(labHost), Port: labPort}
|
||||
conn, err := net.DialUDP("udp", nil, peer)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
_, _ = conn.Write([]byte{0})
|
||||
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
|
||||
_, err = conn.Read(make([]byte, 1))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
var op *net.OpError
|
||||
return !errors.As(err, &op) || !errors.Is(op.Err, syscall.ECONNREFUSED)
|
||||
}
|
||||
|
||||
+9
-1
@@ -93,6 +93,10 @@ func (s *Station) noteDownLocked(now time.Time, dial bool) {
|
||||
}
|
||||
|
||||
func (s *Station) endFatal(msg string, log bool) {
|
||||
s.endFatalCause(msg, nil, log)
|
||||
}
|
||||
|
||||
func (s *Station) endFatalCause(msg string, cause error, log bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.fatal != "" {
|
||||
@@ -107,7 +111,11 @@ func (s *Station) endFatal(msg string, log bool) {
|
||||
if log {
|
||||
s.up = false
|
||||
s.outage = true
|
||||
s.writeLog(now, oneLine(msg))
|
||||
line := oneLine(msg)
|
||||
if cause != nil {
|
||||
line += ": " + oneLine(cause.Error())
|
||||
}
|
||||
s.writeLog(now, line)
|
||||
}
|
||||
s.publish()
|
||||
}
|
||||
|
||||
+10
-4
@@ -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 {
|
||||
@@ -131,7 +131,10 @@ func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
want := "2026-10-03T12:00:00Z disconnected\n"
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
@@ -152,7 +155,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 {
|
||||
@@ -160,7 +163,10 @@ func TestReflectorErrorsAreOneLogLine(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
refl.readFrame(t)
|
||||
refl.writeFrame(t, 13, appendStr(nil, "bad\nline"))
|
||||
|
||||
+103
-5
@@ -4,26 +4,47 @@ import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
tcpHeartbeat = 1
|
||||
tcpProtoVer = 5
|
||||
tcpProtoDown = 6
|
||||
tcpAuthChallenge = 10
|
||||
tcpAuthResponse = 11
|
||||
tcpAuthOK = 12
|
||||
tcpError = 13
|
||||
tcpStartEncReq = 14
|
||||
tcpStartEnc = 15
|
||||
tcpCsrReq = 16
|
||||
tcpCsr = 17
|
||||
tcpClientCert = 18
|
||||
tcpCAInfo = 19
|
||||
tcpCABundleReq = 20
|
||||
tcpCABundle = 21
|
||||
tcpServerInfo = 100
|
||||
tcpNodeList = 101
|
||||
tcpNodeJoined = 102
|
||||
tcpNodeLeft = 103
|
||||
tcpTalkerStart = 104
|
||||
tcpTalkerStop = 105
|
||||
tcpSelectTG = 106
|
||||
tcpRequestQSY = 109
|
||||
tcpStateEvent = 110
|
||||
tcpNodeInfo = 111
|
||||
tcpSignal = 112
|
||||
tcpTxStatus = 113
|
||||
tcpStartUDPEnc = 114
|
||||
|
||||
udpHeartbeat = 1
|
||||
udpAudio = 101
|
||||
udpFlush = 102
|
||||
udpHeartbeat = 1
|
||||
udpAudio = 101
|
||||
udpFlush = 102
|
||||
udpAllFlushed = 103
|
||||
|
||||
maxFrame = 32768
|
||||
maxFrame = 32768
|
||||
maxPreauth20 = 64
|
||||
)
|
||||
|
||||
type frame struct {
|
||||
@@ -45,12 +66,16 @@ func writeFrame(w io.Writer, typ uint16, body []byte) error {
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) (frame, error) {
|
||||
return readFrameMax(r, maxFrame)
|
||||
}
|
||||
|
||||
func readFrameMax(r io.Reader, max uint32) (frame, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return frame{}, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n < 2 || n > maxFrame {
|
||||
if n < 2 || n > max {
|
||||
return frame{}, fmt.Errorf("frame length %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
@@ -149,6 +174,79 @@ func parseServerInfo(b []byte) (uint16, []string, error) {
|
||||
return id, codecs, nil
|
||||
}
|
||||
|
||||
func versionWire(major, minor uint16) []byte {
|
||||
return appendU16(appendU16(nil, major), minor)
|
||||
}
|
||||
|
||||
func parseVersion(b []byte) (uint16, uint16, error) {
|
||||
major, rest, err := parseU16(b)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
minor, rest, err := parseU16(rest)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return 0, 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
return major, minor, nil
|
||||
}
|
||||
|
||||
var atMostVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. May be at most (\d+)\.(\d+)\.$`)
|
||||
var atLeastVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. Must be at least (\d+)\.(\d+)\.$`)
|
||||
|
||||
// wantsOther reports that the reflector refused the announced major and named the other one.
|
||||
func wantsOther(announced uint16, msg string) bool {
|
||||
if announced >= 3 {
|
||||
return fallback20(msg)
|
||||
}
|
||||
return fallback30(msg)
|
||||
}
|
||||
|
||||
// acceptsOther reports that a downgrade names the protocol this station did not announce.
|
||||
func acceptsOther(announced, gotMajor, gotMinor uint16) bool {
|
||||
if announced >= 3 {
|
||||
return gotMajor == 2
|
||||
}
|
||||
return gotMajor == 3 && gotMinor == 0
|
||||
}
|
||||
|
||||
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
|
||||
func fallback20(msg string) bool {
|
||||
m := atMostVersion.FindStringSubmatch(msg)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
major, err1 := strconv.Atoi(m[1])
|
||||
minor, err2 := strconv.Atoi(m[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
|
||||
}
|
||||
|
||||
// fallback30 reports that the reflector's minimum is exactly 3.0.
|
||||
func fallback30(msg string) bool {
|
||||
m := atLeastVersion.FindStringSubmatch(msg)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
major, err1 := strconv.Atoi(m[1])
|
||||
minor, err2 := strconv.Atoi(m[2])
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return major == 3 && minor == 0
|
||||
}
|
||||
|
||||
func versionBelow(major, minor, maj, min int) bool {
|
||||
if major != maj {
|
||||
return major < maj
|
||||
}
|
||||
return minor < min
|
||||
}
|
||||
|
||||
func hasOPUS(codecs []string) bool {
|
||||
for _, c := range codecs {
|
||||
if c == "OPUS" {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package station_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/internal/opus"
|
||||
"gitea.neitzel.de/konrad/funknetz/station"
|
||||
)
|
||||
|
||||
func TestProtocol2DropsASequenceTooFarBehind(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
enc, err := opus.NewEncoder()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer enc.Close()
|
||||
pkt, err := enc.Encode(make([]int16, opus.FrameSamples))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.refl.writeUDP(t, 101, 7, 32768, appendBytes(nil, pkt))
|
||||
in.refl.writeUDP(t, 101, 7, 4, appendBytes(nil, pkt))
|
||||
if _, err := in.st.WaitPlayback(t.Context(), opus.FrameSamples); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if extra := in.st.TakePlayback(); len(extra) != 0 {
|
||||
t.Fatalf("played %d extra samples from the old sequence", len(extra))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocol2FlushCompletesOnce(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.HoldButton()
|
||||
in.st.ReleaseButton()
|
||||
typ, id, _, body := in.refl.readUDP(t)
|
||||
if typ != 102 || id != 7 || len(body) != 0 {
|
||||
t.Fatalf("flush type %d id %d len %d", typ, id, len(body))
|
||||
}
|
||||
in.refl.writeUDP(t, 103, 7, 0, nil)
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
in.clock.Advance(3 * time.Second)
|
||||
if pkt, _, ok := tryUDP(in.refl); ok {
|
||||
t.Fatalf("sent %d bytes after flush-complete", len(pkt))
|
||||
}
|
||||
if got := in.st.State(); got.Connection != "Connected" || got.Protocol != "2.0" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocol2FlushTimesOutWithoutASecondFlush(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.st.HoldButton()
|
||||
in.st.ReleaseButton()
|
||||
typ, _, _, _ := in.refl.readUDP(t)
|
||||
if typ != 102 {
|
||||
t.Fatalf("type %d, want flush", typ)
|
||||
}
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
in.clock.Advance(3 * time.Second)
|
||||
if pkt, _, ok := tryUDP(in.refl); ok {
|
||||
t.Fatalf("sent %d bytes when flush-complete did not arrive", len(pkt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSilenceFreesPushToTalkAndKeepsTheTalker(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
enc, err := opus.NewEncoder()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer enc.Close()
|
||||
pkt, err := enc.Encode(make([]int16, opus.FrameSamples))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.refl.writeUDP(t, 101, 7, 0, appendBytes(nil, pkt))
|
||||
if _, err := in.st.WaitPlayback(t.Context(), opus.FrameSamples); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DB0XYZ"))
|
||||
if _, err := in.st.Wait(t.Context(), func(s station.State) bool { return s.Talker == "DB0XYZ" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
in.clock.Advance(3 * time.Second)
|
||||
in.st.HoldButton()
|
||||
in.st.WriteMicrophone(make([]int16, opus.FrameSamples))
|
||||
if _, _, ok := tryUDP(in.refl); ok {
|
||||
t.Fatal("push-to-talk sent audio at exactly 3 seconds")
|
||||
}
|
||||
in.st.ReleaseButton()
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
gen := in.clock.Generation()
|
||||
in.clock.Advance(time.Second)
|
||||
waitClock(t, in.clock, gen)
|
||||
if got := in.st.State(); got.Talker != "DB0XYZ" || got.Transmitting {
|
||||
t.Fatalf("State = %+v, want the talker kept and the button free", got)
|
||||
}
|
||||
in.st.HoldButton()
|
||||
if !in.st.State().Transmitting {
|
||||
t.Fatal("push-to-talk stayed blocked after the gap")
|
||||
}
|
||||
in.st.WriteMicrophone(make([]int16, opus.FrameSamples))
|
||||
utyp, id, _, _ := in.refl.readUDP(t)
|
||||
if utyp != 101 || id != 7 {
|
||||
t.Fatalf("audio type %d id %d", utyp, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
for step := 0; step < 6; step++ {
|
||||
clock.BlockUntilWaiting(t)
|
||||
clock.Advance(10 * time.Second)
|
||||
if st.State().Connection == "Disconnected" && step < 5 {
|
||||
t.Fatalf("dropped on step %d", step)
|
||||
}
|
||||
replyTCP(t, refl)
|
||||
}
|
||||
if _, err := st.Wait(t.Context(), func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := st.State().Detail; got != "udp receive timeout" {
|
||||
t.Fatalf("Detail = %q, want udp receive timeout", got)
|
||||
}
|
||||
clock.BlockUntilWaiting(t)
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.accept(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
payload := make([]byte, 65)
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], 65)
|
||||
if _, err := refl.tcp.Write(append(hdr[:], payload...)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock.BlockUntilWaiting(t)
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.accept(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
|
||||
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Connection != "Disconnected" || got.Protocol != "" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
clock.BlockUntilWaiting(t)
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.accept(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||
}
|
||||
if st.State().Detail != "Protocol error" {
|
||||
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIgnoredNoticesDoNotChangeTheStation(t *testing.T) {
|
||||
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
|
||||
in.refl.writeFrame(t, 101, appendStr(appendU16(nil, 1), "DB0XYZ"))
|
||||
in.refl.writeFrame(t, 102, appendStr(nil, "DB0XYZ"))
|
||||
in.refl.writeFrame(t, 103, appendStr(nil, "DB0XYZ"))
|
||||
in.refl.writeFrame(t, 109, appendU32(nil, 99))
|
||||
body := appendStr(nil, "src")
|
||||
body = appendStr(body, "name")
|
||||
body = appendStr(body, "msg")
|
||||
in.refl.writeFrame(t, 110, body)
|
||||
in.refl.writeFrame(t, 112, append(appendU16(nil, 1), []byte{1, 40, 1}...))
|
||||
in.refl.writeFrame(t, 113, append(appendU16(nil, 1), []byte{1, 1}...))
|
||||
in.clock.BlockUntilWaiting(t)
|
||||
in.clock.Advance(10 * time.Second)
|
||||
typ, _ := in.refl.readFrame(t)
|
||||
if typ != 1 {
|
||||
t.Fatalf("TCP type %d, want a heartbeat", typ)
|
||||
}
|
||||
got := in.st.State()
|
||||
if got.Connection != "Connected" || got.Protocol != "2.0" || got.TalkGroup != 20 || got.Talker != "" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
package station_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/station"
|
||||
)
|
||||
|
||||
func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
|
||||
for _, msg := range []string{
|
||||
"Unsupported protocol version 3.0. May be at most 2.0.",
|
||||
"Unsupported protocol version 3.0. May be at most 2.5.",
|
||||
} {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||
clock := newFakeClock()
|
||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 3, 0) {
|
||||
t.Fatalf("type %d body %v", typ, body)
|
||||
}
|
||||
refl.writeFrame(t, 13, appendStr(nil, msg))
|
||||
refl.accept(t)
|
||||
typ, body = refl.readFrame(t)
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("fallback type %d body %v", typ, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionErrorBelow2Stops(t *testing.T) {
|
||||
for _, msg := range []string{
|
||||
"Unsupported protocol version 3.0. May be at most 1.9.",
|
||||
"Unsupported protocol version 3.0. May be at most 0.6.",
|
||||
} {
|
||||
t.Run(msg, func(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||
clock := newFakeClock()
|
||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
refl.readFrame(t)
|
||||
refl.writeFrame(t, 13, appendStr(nil, msg))
|
||||
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == msg })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Connection != "Disconnected" || got.Protocol != "" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.ln.(*net.TCPListener).SetDeadline(time.Now().Add(150 * time.Millisecond))
|
||||
if conn, err := refl.ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("retried")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowngradeOtherThan2Stops(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
major, minor uint16
|
||||
want string
|
||||
}{
|
||||
{1, 0, "Unsupported protocol version 1.0"},
|
||||
{4, 2, "Unsupported protocol version 4.2"},
|
||||
} {
|
||||
t.Run(tc.want, func(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||
clock := newFakeClock()
|
||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
refl.readFrame(t)
|
||||
refl.writeFrame(t, 6, versionWire(tc.major, tc.minor))
|
||||
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == tc.want })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Protocol != "" || got.Connection != "Disconnected" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.ln.(*net.TCPListener).SetDeadline(time.Now().Add(150 * time.Millisecond))
|
||||
if conn, err := refl.ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("retried")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
dir := t.TempDir()
|
||||
path := dir + "/funknetz.json"
|
||||
body := configWithDir(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "./funknetz", "")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock()
|
||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
declineTo2(t, refl)
|
||||
if _, err := os.Stat(dir + "/funknetz"); err == nil {
|
||||
t.Fatal("created the certificate directory on a 2.0 fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredV2FallsBackTo3ThenReturns(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
refl.writeFrame(t, 13, appendStr(nil, "Unsupported protocol version 2.0. Must be at least 3.0."))
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 3, 0)
|
||||
refl.tcp.Close()
|
||||
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock.BlockUntilWaiting(t)
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
}
|
||||
|
||||
func TestV2DoesNotFallBackAbove3(t *testing.T) {
|
||||
refl := newReflector(t)
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
refl.accept(t)
|
||||
expectVersion(t, refl, 2, 0)
|
||||
msg := "Unsupported protocol version 2.0. Must be at least 3.1."
|
||||
refl.writeFrame(t, 13, appendStr(nil, msg))
|
||||
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == msg })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Connection != "Disconnected" || got.Protocol != "" {
|
||||
t.Fatalf("State = %+v", got)
|
||||
}
|
||||
clock.Advance(2 * time.Second)
|
||||
refl.ln.(*net.TCPListener).SetDeadline(time.Now().Add(150 * time.Millisecond))
|
||||
if conn, err := refl.ln.Accept(); err == nil {
|
||||
conn.Close()
|
||||
t.Fatal("retried")
|
||||
}
|
||||
}
|
||||
+82
-15
@@ -1,6 +1,7 @@
|
||||
package station_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
@@ -133,21 +134,39 @@ type reflector struct {
|
||||
ln net.Listener
|
||||
udp *net.UDPConn
|
||||
tcp net.Conn
|
||||
tls *tls.Conn
|
||||
peer *net.UDPAddr
|
||||
port int
|
||||
}
|
||||
|
||||
func (r *reflector) conn() net.Conn {
|
||||
if r.tls != nil {
|
||||
return r.tls
|
||||
}
|
||||
return r.tcp
|
||||
}
|
||||
|
||||
func newReflector(t *testing.T) *reflector {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
|
||||
if err != nil {
|
||||
var ln net.Listener
|
||||
var udp *net.UDPConn
|
||||
var port int
|
||||
for range 8 {
|
||||
var err error
|
||||
ln, err = net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port = ln.Addr().(*net.TCPAddr).Port
|
||||
udp, err = net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
ln.Close()
|
||||
t.Fatal(err)
|
||||
ln = nil
|
||||
}
|
||||
if udp == nil {
|
||||
t.Fatal("udp port stayed in use")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if ln != nil {
|
||||
@@ -166,16 +185,18 @@ func (r *reflector) accept(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.tcp = conn
|
||||
r.tls = nil
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
}
|
||||
|
||||
func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
|
||||
t.Helper()
|
||||
if err := r.tcp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
conn := r.conn()
|
||||
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r.tcp, hdr[:]); err != nil {
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
@@ -183,7 +204,7 @@ func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
|
||||
t.Fatalf("frame length %d", n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r.tcp, buf); err != nil {
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(buf) < 2 {
|
||||
@@ -199,14 +220,30 @@ func (r *reflector) writeFrame(t *testing.T, typ uint16, body []byte) {
|
||||
copy(payload[2:], body)
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
|
||||
if err := r.tcp.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
conn := r.conn()
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.tcp.Write(append(hdr[:], payload...)); err != nil {
|
||||
if _, err := conn.Write(append(hdr[:], payload...)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reflector) handshake(t *testing.T, cfg *tls.Config) error {
|
||||
t.Helper()
|
||||
r.tls = tls.Server(r.tcp, cfg)
|
||||
if err := r.tls.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
return err
|
||||
}
|
||||
err := r.tls.Handshake()
|
||||
// Clearing the deadline is best-effort; the handshake error is the result.
|
||||
_ = r.tls.SetDeadline(time.Time{})
|
||||
if err != nil {
|
||||
r.tls = nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
|
||||
t.Helper()
|
||||
if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
@@ -228,6 +265,33 @@ func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
|
||||
return typ, id, seq, body
|
||||
}
|
||||
|
||||
func (r *reflector) readRawUDP(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
buf := make([]byte, 2048)
|
||||
n, addr, err := r.udp.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.peer = addr
|
||||
return append([]byte(nil), buf[:n]...)
|
||||
}
|
||||
|
||||
func (r *reflector) writeRawUDP(t *testing.T, pkt []byte) {
|
||||
t.Helper()
|
||||
if r.peer == nil {
|
||||
t.Fatal("no udp peer")
|
||||
}
|
||||
if err := r.udp.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.udp.WriteToUDP(pkt, r.peer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reflector) writeUDP(t *testing.T, typ, id, seq uint16, body []byte) {
|
||||
t.Helper()
|
||||
if r.peer == nil {
|
||||
@@ -294,8 +358,11 @@ func requireSoftwareIdentity(t *testing.T, body []byte) {
|
||||
if err := json.Unmarshal([]byte(raw), &got); err != nil {
|
||||
t.Fatalf("software identity %q: %v", raw, err)
|
||||
}
|
||||
if len(got) != 2 || got["sw"] != station.SoftwareName || got["swVer"] != station.SoftwareVersion {
|
||||
t.Fatalf("software identity = %#v, want sw %q and swVer %q only", got, station.SoftwareName, station.SoftwareVersion)
|
||||
if len(got) != 3 || got["sw"] != station.SoftwareName || got["swVer"] != station.SoftwareVersion || got["projVer"] != station.SoftwareVersion {
|
||||
t.Fatalf("software identity = %#v, want sw, swVer, and projVer %q", got, station.SoftwareName)
|
||||
}
|
||||
if _, ok := got["machineArch"]; ok {
|
||||
t.Fatal("protocol 2.0 node information includes machineArch")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+542
-196
@@ -4,11 +4,16 @@ import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gitea.neitzel.de/konrad/funknetz/internal/opus"
|
||||
@@ -16,27 +21,58 @@ import (
|
||||
|
||||
const noOpus = "The reflector did not offer OPUS."
|
||||
|
||||
var errStopped = errors.New("session stopped")
|
||||
var (
|
||||
errStopped = errors.New("session stopped")
|
||||
errFallback = errors.New("fallback")
|
||||
errReconnect = errors.New("reconnect")
|
||||
)
|
||||
|
||||
// Run keeps the reflector session until ctx is canceled.
|
||||
func (s *Station) Run(ctx context.Context) error {
|
||||
defer s.Close()
|
||||
var major uint16
|
||||
holdMajor := false
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.fatal != "" {
|
||||
s.mu.Unlock()
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
if !holdMajor {
|
||||
major = s.configuredMajor()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if s.fatal != "" || !s.loaded {
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-s.wake:
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.kick = false
|
||||
s.state.Connection = "Connecting"
|
||||
s.publish()
|
||||
host, port := s.cfg.host, s.cfg.port
|
||||
epoch := s.epoch
|
||||
dialCtx, dialCancel := context.WithCancel(ctx)
|
||||
s.cancelDial = dialCancel
|
||||
s.mu.Unlock()
|
||||
|
||||
conn, err := dialStation(ctx, host, port)
|
||||
conn, err := dialStation(dialCtx, host, port)
|
||||
dialCancel()
|
||||
s.mu.Lock()
|
||||
s.cancelDial = nil
|
||||
stale := s.epoch != epoch
|
||||
s.mu.Unlock()
|
||||
if stale {
|
||||
holdMajor = false
|
||||
if conn != nil {
|
||||
// This dial used the config from before Save.
|
||||
_ = conn.Close()
|
||||
}
|
||||
s.drainApply()
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
@@ -50,12 +86,42 @@ func (s *Station) Run(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.session(ctx, conn)
|
||||
err = s.session(ctx, conn, major, epoch, major == s.configuredMajor())
|
||||
conn.Close()
|
||||
fallback := errors.Is(err, errFallback)
|
||||
reconnect := errors.Is(err, errReconnect)
|
||||
s.mu.Lock()
|
||||
s.closeUDP()
|
||||
if s.conn == conn {
|
||||
s.conn = nil
|
||||
}
|
||||
s.mic = nil
|
||||
s.play = nil
|
||||
s.audioHeard = false
|
||||
s.flushWaiting = false
|
||||
kicked := s.kick
|
||||
if kicked {
|
||||
s.kick = false
|
||||
s.drainApply()
|
||||
if s.up {
|
||||
s.noteDownLocked(s.clock.Now(), false)
|
||||
}
|
||||
}
|
||||
if fallback || reconnect || kicked {
|
||||
s.roomBusy = false
|
||||
s.remoteTalker = ""
|
||||
s.mu.Unlock()
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if fallback {
|
||||
major = otherMajor(major)
|
||||
holdMajor = true
|
||||
} else {
|
||||
holdMajor = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if s.fatal == "" {
|
||||
s.noteDownLocked(s.clock.Now(), false)
|
||||
} else {
|
||||
@@ -63,14 +129,11 @@ func (s *Station) Run(ctx context.Context) error {
|
||||
}
|
||||
fatal := s.fatal != ""
|
||||
s.mu.Unlock()
|
||||
holdMajor = false
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if fatal {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
if err != nil && errors.Is(err, errStopped) {
|
||||
if fatal || errors.Is(err, errStopped) {
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
@@ -94,10 +157,182 @@ func (s *Station) wait(ctx context.Context, d time.Duration) bool {
|
||||
return false
|
||||
case <-timer.C():
|
||||
return true
|
||||
case <-s.apply:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
type framed struct {
|
||||
conn net.Conn
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
frames chan frame
|
||||
errs chan error
|
||||
upgrade chan *tls.Config
|
||||
handshake chan error
|
||||
max atomic.Uint32
|
||||
}
|
||||
|
||||
// exchange is the state one TCP session's frame handlers share.
|
||||
type exchange struct {
|
||||
ph phase
|
||||
authed bool
|
||||
client tls.Certificate
|
||||
haveCert bool
|
||||
done chan struct{}
|
||||
udpCh chan []byte
|
||||
udpErr chan error
|
||||
udpReady bool
|
||||
lastTCP time.Time
|
||||
limit time.Time
|
||||
}
|
||||
|
||||
func newFramed(conn net.Conn, max uint32) *framed {
|
||||
f := &framed{
|
||||
conn: conn,
|
||||
w: conn,
|
||||
frames: make(chan frame, 8),
|
||||
errs: make(chan error, 1),
|
||||
upgrade: make(chan *tls.Config, 1),
|
||||
handshake: make(chan error, 1),
|
||||
}
|
||||
f.max.Store(max)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *framed) Write(p []byte) (int, error) {
|
||||
f.mu.Lock()
|
||||
w := f.w
|
||||
f.mu.Unlock()
|
||||
return w.Write(p)
|
||||
}
|
||||
|
||||
func (f *framed) setMax(n uint32) { f.max.Store(n) }
|
||||
|
||||
var errTLSTimeout = errors.New("tls timeout")
|
||||
|
||||
func (s *Station) startTLS(ctx context.Context, f *framed, cfg *tls.Config, until time.Time) error {
|
||||
select {
|
||||
case f.upgrade <- cfg:
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := f.conn.SetReadDeadline(time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
wait := until.Sub(s.clock.Now())
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
}
|
||||
timer := s.clock.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-f.handshake:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
if err := f.conn.SetDeadline(time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.Err()
|
||||
case <-timer.C():
|
||||
if err := f.conn.SetDeadline(time.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
select {
|
||||
case <-f.handshake:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return errTLSTimeout
|
||||
}
|
||||
}
|
||||
|
||||
func (f *framed) read(done <-chan struct{}) {
|
||||
src := io.Reader(f.conn)
|
||||
for {
|
||||
fr, err := readFrameMax(src, f.max.Load())
|
||||
if err != nil {
|
||||
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
|
||||
select {
|
||||
case cfg := <-f.upgrade:
|
||||
if derr := f.conn.SetReadDeadline(time.Time{}); derr != nil {
|
||||
f.handshake <- derr
|
||||
return
|
||||
}
|
||||
tc := tls.Client(f.conn, cfg)
|
||||
herr := tc.Handshake()
|
||||
if herr != nil {
|
||||
f.handshake <- herr
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.w = tc
|
||||
f.mu.Unlock()
|
||||
src = tc
|
||||
f.handshake <- nil
|
||||
continue
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
select {
|
||||
case f.errs <- err:
|
||||
case <-done:
|
||||
}
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.frames <- fr:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type phase int
|
||||
|
||||
const (
|
||||
phCA phase = iota
|
||||
phBundle
|
||||
phStart
|
||||
phIdent
|
||||
phServer
|
||||
phUDP
|
||||
phBeat
|
||||
phRun
|
||||
phChallenge
|
||||
)
|
||||
|
||||
func (s *Station) configuredMajor() uint16 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cfg.protocolMajor == 3 {
|
||||
return 3
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func otherMajor(major uint16) uint16 {
|
||||
if major == 2 {
|
||||
return 3
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoch uint64, allowFallback bool) error {
|
||||
s.mu.Lock()
|
||||
s.conn = conn
|
||||
stale := s.epoch != epoch
|
||||
s.mu.Unlock()
|
||||
if stale {
|
||||
return errReconnect
|
||||
}
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.conn == conn {
|
||||
s.conn = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
done := make(chan struct{})
|
||||
var closeOnce sync.Once
|
||||
closeConn := func() { closeOnce.Do(func() { conn.Close() }) }
|
||||
@@ -110,38 +345,56 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
}
|
||||
}()
|
||||
|
||||
tcpCh := make(chan frame, 8)
|
||||
tcpErr := make(chan error, 1)
|
||||
go readTCP(conn, tcpCh, tcpErr, done)
|
||||
frameMax := uint32(maxFrame)
|
||||
ex := &exchange{ph: phCA, done: done, lastTCP: s.clock.Now()}
|
||||
label := "3.0"
|
||||
if major < 3 {
|
||||
frameMax = maxPreauth20
|
||||
ex.ph = phChallenge
|
||||
label = "2.0"
|
||||
}
|
||||
wire := newFramed(conn, frameMax)
|
||||
go wire.read(done)
|
||||
s.mu.Lock()
|
||||
s.protocol = label
|
||||
s.rxSeq = 0
|
||||
s.rxCounter = 0
|
||||
s.txCounter = 1
|
||||
s.secure = false
|
||||
s.udpWait = false
|
||||
s.seq = 0
|
||||
s.ivPrefix = nil
|
||||
s.udpKey = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := writeFrame(conn, tcpProtoVer, []byte{0, 2, 0, 0}); err != nil {
|
||||
if err := writeFrame(wire, tcpProtoVer, versionWire(major, 0)); err != nil {
|
||||
return err
|
||||
}
|
||||
lastTCP := s.clock.Now()
|
||||
tcpRx := lastTCP.Add(15 * time.Second)
|
||||
|
||||
var udpCh chan []byte
|
||||
var udpErr chan error
|
||||
udpReady := false
|
||||
var udpRx time.Time
|
||||
now := ex.lastTCP
|
||||
tcpRx := now.Add(15 * time.Second)
|
||||
udpRx := now.Add(60 * time.Second)
|
||||
|
||||
for {
|
||||
s.mu.Lock()
|
||||
lastUDP := s.lastUDPSend
|
||||
silence, silenceOK := s.nextSilence()
|
||||
s.mu.Unlock()
|
||||
|
||||
next := lastTCP.Add(10 * time.Second)
|
||||
if tcpRx.Before(next) {
|
||||
next = tcpRx
|
||||
next := ex.lastTCP.Add(10 * time.Second)
|
||||
if t := tcpRx; t.Before(next) {
|
||||
next = t
|
||||
}
|
||||
if udpReady {
|
||||
if u := lastUDP.Add(15 * time.Second); u.Before(next) {
|
||||
next = u
|
||||
}
|
||||
if udpRx.Before(next) {
|
||||
next = udpRx
|
||||
if t := udpRx; t.Before(next) {
|
||||
next = t
|
||||
}
|
||||
if ex.udpReady {
|
||||
if t := lastUDP.Add(15 * time.Second); t.Before(next) {
|
||||
next = t
|
||||
}
|
||||
}
|
||||
if silenceOK && silence.Before(next) {
|
||||
next = silence
|
||||
}
|
||||
wait := next.Sub(s.clock.Now())
|
||||
if wait < 0 {
|
||||
wait = 0
|
||||
@@ -150,9 +403,9 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
|
||||
var udpRecv <-chan []byte
|
||||
var udpFail <-chan error
|
||||
if udpCh != nil {
|
||||
udpRecv = udpCh
|
||||
udpFail = udpErr
|
||||
if ex.udpCh != nil {
|
||||
udpRecv = ex.udpCh
|
||||
udpFail = ex.udpErr
|
||||
}
|
||||
|
||||
var reason string
|
||||
@@ -162,13 +415,13 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case err := <-tcpErr:
|
||||
case err := <-wire.errs:
|
||||
timer.Stop()
|
||||
return err
|
||||
case err := <-udpFail:
|
||||
timer.Stop()
|
||||
return err
|
||||
case tcpf = <-tcpCh:
|
||||
case tcpf = <-wire.frames:
|
||||
timer.Stop()
|
||||
reason = "tcp"
|
||||
case udpp = <-udpRecv:
|
||||
@@ -176,7 +429,7 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
reason = "udp"
|
||||
case <-timer.C():
|
||||
select {
|
||||
case tcpf = <-tcpCh:
|
||||
case tcpf = <-wire.frames:
|
||||
reason = "tcp"
|
||||
default:
|
||||
select {
|
||||
@@ -188,36 +441,47 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
}
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
now = s.clock.Now()
|
||||
switch reason {
|
||||
case "tcp":
|
||||
tcpRx = now.Add(15 * time.Second)
|
||||
if err := s.onTCP(conn, tcpf, done, &udpCh, &udpErr, &udpReady, &udpRx, &lastTCP); err != nil {
|
||||
ex.limit = tcpRx
|
||||
if udpRx.Before(ex.limit) {
|
||||
ex.limit = udpRx
|
||||
}
|
||||
err := s.onFrame(ctx, wire, major, tcpf, ex, allowFallback)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "udp":
|
||||
s.mu.Lock()
|
||||
if clientIDOf(udpp) == s.clientID {
|
||||
udpRx = now.Add(60 * time.Second)
|
||||
s.onDatagram(udpp)
|
||||
if err := s.onUDP(wire, udpp, ex, &udpRx, now); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
default:
|
||||
s.mu.Lock()
|
||||
s.onClock(now)
|
||||
s.mu.Unlock()
|
||||
if !now.Before(tcpRx) {
|
||||
return errors.New("tcp receive timeout")
|
||||
return s.retry("tcp receive timeout", nil)
|
||||
}
|
||||
if udpReady && !now.Before(udpRx) {
|
||||
return errors.New("udp receive timeout")
|
||||
if !now.Before(udpRx) {
|
||||
return s.retry("udp receive timeout", nil)
|
||||
}
|
||||
if !now.Before(lastTCP.Add(10 * time.Second)) {
|
||||
if err := writeFrame(conn, tcpHeartbeat, nil); err != nil {
|
||||
if !now.Before(ex.lastTCP.Add(10 * time.Second)) {
|
||||
if err := writeFrame(wire, tcpHeartbeat, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
lastTCP = s.clock.Now()
|
||||
ex.lastTCP = s.clock.Now()
|
||||
}
|
||||
s.mu.Lock()
|
||||
if udpReady && s.udp != nil && !s.clock.Now().Before(s.lastUDPSend.Add(15*time.Second)) {
|
||||
if err := s.sendUDP(udpHeartbeat, nil); err != nil {
|
||||
if ex.udpReady && s.udp != nil && !s.clock.Now().Before(s.lastUDPSend.Add(15*time.Second)) {
|
||||
var err error
|
||||
if s.udpWait {
|
||||
err = s.sendRegistration()
|
||||
} else {
|
||||
err = s.sendUDP(udpHeartbeat, nil)
|
||||
}
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
@@ -227,127 +491,172 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
|
||||
}
|
||||
}
|
||||
|
||||
func readTCP(conn net.Conn, out chan<- frame, errc chan<- error, done <-chan struct{}) {
|
||||
for {
|
||||
f, err := readFrame(conn)
|
||||
if err != nil {
|
||||
select {
|
||||
case errc <- err:
|
||||
case <-done:
|
||||
}
|
||||
return
|
||||
func (s *Station) retry(detail string, cause error) error {
|
||||
s.mu.Lock()
|
||||
s.retryReason = detail
|
||||
s.mu.Unlock()
|
||||
text := strings.ToLower(detail)
|
||||
if cause == nil {
|
||||
return errors.New(text)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", text, cause)
|
||||
}
|
||||
|
||||
func (s *Station) nextSilence() (time.Time, bool) {
|
||||
var next time.Time
|
||||
ok := false
|
||||
if s.audioHeard {
|
||||
next = s.lastAudio.Add(3*time.Second + time.Nanosecond)
|
||||
ok = true
|
||||
}
|
||||
if s.flushWaiting {
|
||||
at := s.flushAt.Add(3 * time.Second)
|
||||
if !ok || at.Before(next) {
|
||||
next = at
|
||||
ok = true
|
||||
}
|
||||
select {
|
||||
case out <- f:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
return next, ok
|
||||
}
|
||||
|
||||
func (s *Station) onClock(now time.Time) {
|
||||
if s.flushWaiting && !now.Before(s.flushAt.Add(3*time.Second)) {
|
||||
s.flushWaiting = false
|
||||
}
|
||||
if s.audioHeard && now.After(s.lastAudio.Add(3*time.Second)) {
|
||||
if s.dec != nil {
|
||||
s.dec.Reset()
|
||||
}
|
||||
s.audioHeard = false
|
||||
s.roomBusy = false
|
||||
s.publish()
|
||||
}
|
||||
}
|
||||
|
||||
func readUDP(conn *net.UDPConn, out chan<- []byte, errc chan<- error, done <-chan struct{}) {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
select {
|
||||
case errc <- err:
|
||||
case <-done:
|
||||
}
|
||||
return
|
||||
}
|
||||
pkt := append([]byte(nil), buf[:n]...)
|
||||
select {
|
||||
case out <- pkt:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) onTCP(conn net.Conn, f frame, done <-chan struct{}, udpCh *chan []byte, udpErr *chan error, udpReady *bool, udpRx *time.Time, lastTCP *time.Time) error {
|
||||
func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange, allowFallback bool) error {
|
||||
switch f.typ {
|
||||
case tcpHeartbeat, tcpAuthOK:
|
||||
return nil
|
||||
case tcpAuthChallenge:
|
||||
chal, _, err := parseBlob(f.body)
|
||||
if err != nil || len(chal) != 20 {
|
||||
return errors.New("bad challenge")
|
||||
}
|
||||
mac := hmac.New(sha1.New, []byte(s.cfg.authKey))
|
||||
mac.Write(chal)
|
||||
body := appendString(nil, s.cfg.callsign)
|
||||
body = appendBlob(body, mac.Sum(nil))
|
||||
if err := writeFrame(conn, tcpAuthResponse, body); err != nil {
|
||||
return err
|
||||
}
|
||||
*lastTCP = s.clock.Now()
|
||||
case tcpHeartbeat:
|
||||
return nil
|
||||
case tcpError:
|
||||
msg, _, err := parseString(f.body)
|
||||
if err != nil {
|
||||
msg = "could not be read"
|
||||
}
|
||||
s.endFatal(msg, true)
|
||||
return errStopped
|
||||
return s.onReflectorError(f.body, major, allowFallback)
|
||||
case tcpProtoDown:
|
||||
return s.onDowngrade(f.body, major, allowFallback)
|
||||
}
|
||||
if !ex.authed && f.typ >= 100 {
|
||||
return s.retry("Protocol error", nil)
|
||||
}
|
||||
if major < 3 {
|
||||
return s.onV2(wire, f, ex)
|
||||
}
|
||||
return s.onV3(ctx, wire, f, ex)
|
||||
}
|
||||
|
||||
func (s *Station) onReflectorError(body []byte, major uint16, allowFallback bool) error {
|
||||
msg, _, err := parseString(body)
|
||||
if err != nil {
|
||||
s.endFatalCause("could not be read", err, true)
|
||||
return fmt.Errorf("could not be read: %w", err)
|
||||
}
|
||||
if allowFallback && wantsOther(major, msg) {
|
||||
return errFallback
|
||||
}
|
||||
s.endFatal(msg, true)
|
||||
return errStopped
|
||||
}
|
||||
|
||||
func (s *Station) onDowngrade(body []byte, major uint16, allowFallback bool) error {
|
||||
gotMajor, gotMinor, err := parseVersion(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if allowFallback && acceptsOther(major, gotMajor, gotMinor) {
|
||||
return errFallback
|
||||
}
|
||||
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
|
||||
return errStopped
|
||||
}
|
||||
|
||||
func strconvU(v uint16) string {
|
||||
return strconv.FormatUint(uint64(v), 10)
|
||||
}
|
||||
|
||||
func (s *Station) onV2(wire *framed, f frame, ex *exchange) error {
|
||||
switch f.typ {
|
||||
case tcpAuthOK:
|
||||
ex.authed = true
|
||||
ex.ph = phServer
|
||||
wire.setMax(maxFrame)
|
||||
return nil
|
||||
case tcpAuthChallenge:
|
||||
return s.answerChallenge(wire, f.body, ex)
|
||||
case tcpServerInfo:
|
||||
return s.onServerInfo(conn, f.body, done, udpCh, udpErr, udpReady, udpRx, lastTCP)
|
||||
if ex.ph != phServer {
|
||||
return s.retry("Protocol error", nil)
|
||||
}
|
||||
return s.finishV2(wire, f.body, ex)
|
||||
case tcpTalkerStart:
|
||||
s.onTalker(f.body, true)
|
||||
return nil
|
||||
case tcpTalkerStop:
|
||||
s.onTalker(f.body, false)
|
||||
return nil
|
||||
case tcpNodeList, tcpNodeJoined, tcpNodeLeft, tcpRequestQSY, tcpStateEvent, tcpSignal, tcpTxStatus:
|
||||
if err := unpackIgnored(f); err != nil {
|
||||
return s.retry("Protocol error", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{}, udpCh *chan []byte, udpErr *chan error, udpReady *bool, udpRx *time.Time, lastTCP *time.Time) error {
|
||||
id, codecs, err := parseServerInfo(body)
|
||||
func (s *Station) answerChallenge(w io.Writer, body []byte, ex *exchange) error {
|
||||
chal, _, err := parseBlob(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bad challenge: %w", err)
|
||||
}
|
||||
if len(chal) != 20 {
|
||||
return errors.New("bad challenge")
|
||||
}
|
||||
mac := hmac.New(sha1.New, []byte(s.cfg.authKey))
|
||||
mac.Write(chal)
|
||||
out := appendString(nil, s.cfg.callsign)
|
||||
out = appendBlob(out, mac.Sum(nil))
|
||||
if err := writeFrame(w, tcpAuthResponse, out); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) finishV2(wire *framed, body []byte, ex *exchange) error {
|
||||
id, enc, dec, err := s.acceptServer(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasOPUS(codecs) {
|
||||
s.endFatal(noOpus, true)
|
||||
if enc == nil {
|
||||
return errStopped
|
||||
}
|
||||
enc, err := opus.NewEncoder()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dec, err := opus.NewDecoder()
|
||||
if err != nil {
|
||||
enc.Close()
|
||||
return err
|
||||
}
|
||||
info, err := softwareIdentityBody()
|
||||
if err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return err
|
||||
}
|
||||
if err := writeFrame(conn, tcpNodeInfo, info); err != nil {
|
||||
if err := writeFrame(wire, tcpNodeInfo, info); err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return err
|
||||
}
|
||||
var tg [4]byte
|
||||
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
|
||||
if err := writeFrame(conn, tcpSelectTG, tg[:]); err != nil {
|
||||
if err := writeFrame(wire, tcpSelectTG, tg[:]); err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return err
|
||||
}
|
||||
*lastTCP = s.clock.Now()
|
||||
|
||||
remote, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return errors.New("tcp peer has no address")
|
||||
}
|
||||
udpConn, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: remote.IP, Port: remote.Port})
|
||||
ex.lastTCP = s.clock.Now()
|
||||
udpConn, peer, err := dialAudio(wire.conn)
|
||||
if err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
@@ -355,14 +664,16 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
||||
}
|
||||
ch := make(chan []byte, 32)
|
||||
errc := make(chan error, 1)
|
||||
go readUDP(udpConn, ch, errc, done)
|
||||
|
||||
go readUDP(udpConn, peer, ch, errc, ex.done)
|
||||
s.mu.Lock()
|
||||
s.clientID = id
|
||||
s.udpPeer = peer
|
||||
s.seq = 0
|
||||
s.rxSeq = 0
|
||||
s.udp = udpConn
|
||||
s.enc = enc
|
||||
s.dec = dec
|
||||
s.secure = false
|
||||
err = s.sendUDP(udpHeartbeat, nil)
|
||||
if err != nil {
|
||||
s.mu.Unlock()
|
||||
@@ -378,14 +689,33 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
|
||||
s.writeLog(s.clock.Now(), "connected")
|
||||
s.publish()
|
||||
s.mu.Unlock()
|
||||
|
||||
*udpCh = ch
|
||||
*udpErr = errc
|
||||
*udpReady = true
|
||||
*udpRx = s.clock.Now().Add(60 * time.Second)
|
||||
ex.udpCh = ch
|
||||
ex.udpErr = errc
|
||||
ex.udpReady = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) acceptServer(body []byte) (uint16, *opus.Encoder, *opus.Decoder, error) {
|
||||
id, codecs, err := parseServerInfo(body)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
if !hasOPUS(codecs) {
|
||||
s.endFatal(noOpus, true)
|
||||
return 0, nil, nil, errStopped
|
||||
}
|
||||
enc, err := opus.NewEncoder()
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
dec, err := opus.NewDecoder()
|
||||
if err != nil {
|
||||
enc.Close()
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
return id, enc, dec, nil
|
||||
}
|
||||
|
||||
func (s *Station) stop(msg string) { s.endFatal(msg, false) }
|
||||
|
||||
// Fail stops retries and shows message until the operator quits.
|
||||
@@ -425,69 +755,21 @@ func (s *Station) onTalker(body []byte, start bool) {
|
||||
s.publish()
|
||||
}
|
||||
|
||||
func (s *Station) onDatagram(pkt []byte) {
|
||||
if len(pkt) < 6 {
|
||||
return
|
||||
}
|
||||
typ := binary.BigEndian.Uint16(pkt[:2])
|
||||
if typ != udpAudio || s.dec == nil {
|
||||
return
|
||||
}
|
||||
if s.transmitting() {
|
||||
return
|
||||
}
|
||||
packet, _, err := parseBlob(pkt[6:])
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pcm, err := s.dec.Decode(packet)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.play = append(s.play, pcm...)
|
||||
s.roomBusy = true
|
||||
s.publish()
|
||||
}
|
||||
|
||||
func clientIDOf(pkt []byte) uint16 {
|
||||
if len(pkt) < 6 {
|
||||
return ^uint16(0)
|
||||
}
|
||||
return binary.BigEndian.Uint16(pkt[2:4])
|
||||
}
|
||||
|
||||
func (s *Station) sendUDP(typ uint16, body []byte) error {
|
||||
if s.udp == nil {
|
||||
return errors.New("udp is down")
|
||||
}
|
||||
buf := make([]byte, 6+len(body))
|
||||
binary.BigEndian.PutUint16(buf[0:2], typ)
|
||||
binary.BigEndian.PutUint16(buf[2:4], s.clientID)
|
||||
binary.BigEndian.PutUint16(buf[4:6], s.seq)
|
||||
s.seq++
|
||||
copy(buf[6:], body)
|
||||
if _, err := s.udp.Write(buf); err != nil {
|
||||
return err
|
||||
}
|
||||
s.lastUDPSend = s.clock.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) endTransmit() {
|
||||
if s.enc != nil && s.udp != nil && len(s.mic) > 0 {
|
||||
frame := make([]int16, opus.FrameSamples)
|
||||
copy(frame, s.mic)
|
||||
s.mic = nil
|
||||
if pkt, err := s.enc.Encode(frame); err == nil {
|
||||
// A lost datagram ends the session when the receive timer expires.
|
||||
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
|
||||
}
|
||||
} else {
|
||||
s.mic = nil
|
||||
}
|
||||
if s.udp != nil {
|
||||
// A lost datagram ends the session when the receive timer expires.
|
||||
_ = s.sendUDP(udpFlush, nil)
|
||||
s.flushWaiting = true
|
||||
s.flushAt = s.clock.Now()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,6 +778,7 @@ func (s *Station) closeUDP() {
|
||||
s.udp.Close()
|
||||
s.udp = nil
|
||||
}
|
||||
s.udpPeer = nil
|
||||
if s.enc != nil {
|
||||
s.enc.Close()
|
||||
s.enc = nil
|
||||
@@ -507,7 +790,7 @@ func (s *Station) closeUDP() {
|
||||
}
|
||||
|
||||
func (s *Station) transmitting() bool {
|
||||
if s.fatal != "" || s.state.Connection != "Connected" || s.roomBusy {
|
||||
if s.configOpen || s.fatal != "" || s.state.Connection != "Connected" || s.roomBusy {
|
||||
return false
|
||||
}
|
||||
return s.buttonHeld || s.spaceHeld
|
||||
@@ -517,13 +800,15 @@ func (s *Station) publish() {
|
||||
s.noteTransmitLocked()
|
||||
s.state.Callsign = s.cfg.callsign
|
||||
s.state.TalkGroup = s.cfg.talkGroup
|
||||
s.state.NeedsConfig = !s.loaded
|
||||
s.state.ConfigOpen = s.configOpen
|
||||
if s.fatal != "" {
|
||||
s.state.Connection = "Disconnected"
|
||||
s.state.Detail = s.fatal
|
||||
s.state.Talker = ""
|
||||
s.state.Transmitting = false
|
||||
s.state.Protocol = ""
|
||||
} else {
|
||||
s.state.Detail = "Idle"
|
||||
tx := s.transmitting()
|
||||
s.state.Transmitting = tx
|
||||
if tx {
|
||||
@@ -532,12 +817,73 @@ func (s *Station) publish() {
|
||||
} else {
|
||||
s.state.Talker = s.remoteTalker
|
||||
}
|
||||
if s.state.Talker == "" && s.logNotice != "" {
|
||||
s.state.Detail = s.logNotice
|
||||
if s.state.Connection == "Connected" {
|
||||
s.state.Protocol = s.protocol
|
||||
s.retryReason = ""
|
||||
s.state.Detail = "Idle"
|
||||
if s.state.Talker == "" && s.logNotice != "" {
|
||||
s.state.Detail = s.logNotice
|
||||
}
|
||||
} else {
|
||||
s.state.Protocol = ""
|
||||
if s.retryReason != "" {
|
||||
s.state.Detail = s.retryReason
|
||||
} else if s.state.Talker == "" && s.logNotice != "" {
|
||||
s.state.Detail = s.logNotice
|
||||
} else {
|
||||
s.state.Detail = "Idle"
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.configOpen && s.configErr != "" {
|
||||
s.state.Detail = s.configErr
|
||||
} else if s.openBlocked != "" {
|
||||
s.state.Detail = s.openBlocked
|
||||
}
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func unpackIgnored(f frame) error {
|
||||
switch f.typ {
|
||||
case tcpNodeList:
|
||||
_, _, err := parseStringList(f.body)
|
||||
return err
|
||||
case tcpNodeJoined, tcpNodeLeft:
|
||||
_, _, err := parseString(f.body)
|
||||
return err
|
||||
case tcpRequestQSY:
|
||||
_, _, err := parseU32(f.body)
|
||||
return err
|
||||
case tcpStateEvent:
|
||||
_, rest, err := parseString(f.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, rest, err = parseString(rest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err = parseString(rest)
|
||||
return err
|
||||
case tcpSignal:
|
||||
return unpackRecords(f.body, 3)
|
||||
case tcpTxStatus:
|
||||
return unpackRecords(f.body, 2)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func unpackRecords(b []byte, size int) error {
|
||||
n, rest, err := parseU16(b)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rest) != int(n)*size {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+10
-7
@@ -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 {
|
||||
@@ -21,19 +21,22 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
go func() { _ = st.Run(ctx) }()
|
||||
go func() {
|
||||
// Run's error is observed through State, which is the station seam.
|
||||
_ = st.Run(ctx)
|
||||
}()
|
||||
|
||||
got, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connecting" })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" {
|
||||
t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle", got)
|
||||
if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" || got.Protocol != "" {
|
||||
t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle and a blank protocol", got)
|
||||
}
|
||||
|
||||
refl.accept(t)
|
||||
typ, body := refl.readFrame(t)
|
||||
if typ != 5 || len(body) != 4 || body[0] != 0 || body[1] != 2 || body[2] != 0 || body[3] != 0 {
|
||||
if typ != 5 || !versionIs(body, 2, 0) {
|
||||
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body)
|
||||
}
|
||||
|
||||
@@ -91,8 +94,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Detail != "Idle" || got.Talker != "" {
|
||||
t.Fatalf("State = %+v, want Connected Idle", got)
|
||||
if got.Detail != "Idle" || got.Talker != "" || got.Protocol != "2.0" {
|
||||
t.Fatalf("State = %+v, want Connected Idle protocol 2.0", got)
|
||||
}
|
||||
|
||||
// No other talk group: the next TCP message is a heartbeat, not a monitor set.
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Station) finishV3UDP(conn net.Conn, ex *exchange) error {
|
||||
udpConn, peer, err := dialAudio(conn)
|
||||
if err != nil {
|
||||
s.dropAudio()
|
||||
return err
|
||||
}
|
||||
ch := make(chan []byte, 32)
|
||||
errc := make(chan error, 1)
|
||||
go readUDP(udpConn, peer, ch, errc, ex.done)
|
||||
s.mu.Lock()
|
||||
s.udp = udpConn
|
||||
s.udpPeer = peer
|
||||
s.udpWait = true
|
||||
s.txCounter = 1
|
||||
s.rxCounter = 0
|
||||
err = s.sendRegistration()
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
s.dropAudio()
|
||||
udpConn.Close()
|
||||
return err
|
||||
}
|
||||
ex.udpCh = ch
|
||||
ex.udpErr = errc
|
||||
ex.udpReady = true
|
||||
ex.ph = phBeat
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialAudio(conn net.Conn) (*net.UDPConn, *net.UDPAddr, error) {
|
||||
remote, ok := conn.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("tcp peer has no address")
|
||||
}
|
||||
local := &net.UDPAddr{Port: 0}
|
||||
if tcpLocal, ok := conn.LocalAddr().(*net.TCPAddr); ok && tcpLocal.IP != nil {
|
||||
local.IP = tcpLocal.IP
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp", local)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return udpConn, &net.UDPAddr{IP: append(net.IP(nil), remote.IP...), Port: remote.Port}, nil
|
||||
}
|
||||
|
||||
func (s *Station) dropAudio() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.enc != nil {
|
||||
s.enc.Close()
|
||||
s.enc = nil
|
||||
}
|
||||
if s.dec != nil {
|
||||
s.dec.Close()
|
||||
s.dec = nil
|
||||
}
|
||||
}
|
||||
|
||||
func readUDP(conn *net.UDPConn, peer *net.UDPAddr, out chan<- []byte, errc chan<- error, done <-chan struct{}) {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, addr, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
select {
|
||||
case errc <- err:
|
||||
case <-done:
|
||||
}
|
||||
return
|
||||
}
|
||||
if addr == nil || peer == nil || !addr.IP.Equal(peer.IP) || addr.Port != peer.Port {
|
||||
continue
|
||||
}
|
||||
pkt := append([]byte(nil), buf[:n]...)
|
||||
select {
|
||||
case out <- pkt:
|
||||
case <-done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) onUDP(w io.Writer, pkt []byte, ex *exchange, udpRx *time.Time, now time.Time) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.secure {
|
||||
plain, ok := s.openSecure(pkt)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
*udpRx = now.Add(60 * time.Second)
|
||||
if len(plain) < 2 {
|
||||
return nil
|
||||
}
|
||||
typ := binary.BigEndian.Uint16(plain[:2])
|
||||
body := plain[2:]
|
||||
if s.udpWait {
|
||||
if typ != udpHeartbeat {
|
||||
return nil
|
||||
}
|
||||
s.udpWait = false
|
||||
s.mu.Unlock()
|
||||
var tg [4]byte
|
||||
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
|
||||
err := writeFrame(w, tcpSelectTG, tg[:])
|
||||
s.mu.Lock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
s.state.Connection = "Connected"
|
||||
s.up = true
|
||||
s.outage = false
|
||||
s.noteTransmitLocked()
|
||||
s.writeLog(s.clock.Now(), "connected")
|
||||
s.publish()
|
||||
ex.ph = phRun
|
||||
return nil
|
||||
}
|
||||
s.onPlain(typ, body, now)
|
||||
return nil
|
||||
}
|
||||
if !s.acceptV2(pkt) {
|
||||
return nil
|
||||
}
|
||||
*udpRx = now.Add(60 * time.Second)
|
||||
typ := binary.BigEndian.Uint16(pkt[:2])
|
||||
s.onPlain(typ, pkt[6:], now)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) openSecure(pkt []byte) ([]byte, bool) {
|
||||
if len(pkt) < 12 || s.udpKey == nil {
|
||||
return nil, false
|
||||
}
|
||||
counter := binary.BigEndian.Uint32(pkt[:4])
|
||||
if counter < s.rxCounter {
|
||||
return nil, false
|
||||
}
|
||||
tag := pkt[4:12]
|
||||
ct := pkt[12:]
|
||||
plain, err := openDatagram(s.udpKey, s.ivPrefix, 0, counter, pkt[:4], tag, ct)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
s.rxCounter = counter + 1
|
||||
return plain, true
|
||||
}
|
||||
|
||||
func (s *Station) acceptV2(pkt []byte) bool {
|
||||
if len(pkt) < 6 {
|
||||
return false
|
||||
}
|
||||
if binary.BigEndian.Uint16(pkt[2:4]) != s.clientID {
|
||||
return false
|
||||
}
|
||||
seq := binary.BigEndian.Uint16(pkt[4:6])
|
||||
diff := seq - s.rxSeq
|
||||
if diff > 32767 {
|
||||
return false
|
||||
}
|
||||
s.rxSeq = seq + 1
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Station) onPlain(typ uint16, body []byte, now time.Time) {
|
||||
switch typ {
|
||||
case udpAllFlushed:
|
||||
s.flushWaiting = false
|
||||
case udpAudio:
|
||||
if s.dec == nil || s.transmitting() {
|
||||
return
|
||||
}
|
||||
packet, _, err := parseBlob(body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pcm, err := s.dec.Decode(packet)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
s.play = append(s.play, pcm...)
|
||||
s.roomBusy = true
|
||||
s.audioHeard = true
|
||||
s.lastAudio = now
|
||||
s.publish()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) sendRegistration() error {
|
||||
return s.sendSecure(udpHeartbeat, nil, 0, true)
|
||||
}
|
||||
|
||||
func (s *Station) sendUDP(typ uint16, body []byte) error {
|
||||
if s.udp == nil {
|
||||
return errors.New("udp is down")
|
||||
}
|
||||
if s.secure {
|
||||
return s.sendSecure(typ, body, s.txCounter, false)
|
||||
}
|
||||
buf := make([]byte, 6+len(body))
|
||||
binary.BigEndian.PutUint16(buf[0:2], typ)
|
||||
binary.BigEndian.PutUint16(buf[2:4], s.clientID)
|
||||
binary.BigEndian.PutUint16(buf[4:6], s.seq)
|
||||
s.seq++
|
||||
copy(buf[6:], body)
|
||||
if _, err := s.udp.WriteToUDP(buf, s.udpPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
s.lastUDPSend = s.clock.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) sendSecure(typ uint16, body []byte, counter uint32, reg bool) error {
|
||||
plain := appendU16(nil, typ)
|
||||
plain = append(plain, body...)
|
||||
var aad []byte
|
||||
id := s.clientID
|
||||
if reg {
|
||||
aad = registrationAAD(s.clientID)
|
||||
counter = 0
|
||||
} else {
|
||||
aad = counterAAD(counter)
|
||||
}
|
||||
wire, err := sealDatagram(s.udpKey, s.ivPrefix, id, counter, aad, plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.udp.WriteToUDP(wire, s.udpPeer); err != nil {
|
||||
return err
|
||||
}
|
||||
s.lastUDPSend = s.clock.Now()
|
||||
if !reg {
|
||||
s.txCounter = counter + 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (s *Station) onV3(ctx context.Context, wire *framed, f frame, ex *exchange) error {
|
||||
switch f.typ {
|
||||
case tcpCAInfo:
|
||||
if ex.ph != phCA {
|
||||
return nil
|
||||
}
|
||||
return s.onCAInfo(wire, f.body, ex)
|
||||
case tcpCABundle:
|
||||
if ex.ph != phBundle {
|
||||
return nil
|
||||
}
|
||||
return s.onCABundle(wire, f.body, ex)
|
||||
case tcpStartEnc:
|
||||
if ex.ph != phStart {
|
||||
return nil
|
||||
}
|
||||
return s.onStartEncryption(ctx, wire, ex)
|
||||
case tcpCsrReq:
|
||||
return s.sendCSR(wire, ex)
|
||||
case tcpAuthChallenge:
|
||||
return s.answerChallenge(wire, f.body, ex)
|
||||
case tcpAuthOK:
|
||||
ex.authed = true
|
||||
ex.ph = phServer
|
||||
newer, err := csrNewerThanCert(s.cfg.certificateDir)
|
||||
if err != nil {
|
||||
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
|
||||
return errStopped
|
||||
}
|
||||
if newer {
|
||||
if err := s.sendCSR(wire, ex); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case tcpClientCert:
|
||||
return s.saveCert(f.body)
|
||||
case tcpServerInfo:
|
||||
if !ex.authed {
|
||||
return s.retry("Protocol error", nil)
|
||||
}
|
||||
return s.finishV3Server(wire, f.body, ex)
|
||||
case tcpStartUDPEnc:
|
||||
if ex.ph != phUDP {
|
||||
return nil
|
||||
}
|
||||
return s.finishV3UDP(wire.conn, ex)
|
||||
case tcpTalkerStart:
|
||||
s.onTalker(f.body, true)
|
||||
return nil
|
||||
case tcpTalkerStop:
|
||||
s.onTalker(f.body, false)
|
||||
return nil
|
||||
case tcpNodeList, tcpNodeJoined, tcpNodeLeft, tcpRequestQSY, tcpStateEvent, tcpSignal, tcpTxStatus:
|
||||
if err := unpackIgnored(f); err != nil {
|
||||
return s.retry("Protocol error", err)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Station) onCAInfo(w io.Writer, body []byte, ex *exchange) error {
|
||||
_, digest, err := parseCAInfo(body)
|
||||
if err != nil {
|
||||
return s.retry("Protocol error", err)
|
||||
}
|
||||
if err := ensureCertDir(s.cfg.certificateDir); err != nil {
|
||||
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
|
||||
return errStopped
|
||||
}
|
||||
sum, ok, err := caDigest(s.cfg.certificateDir)
|
||||
if err != nil {
|
||||
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
|
||||
return errStopped
|
||||
}
|
||||
if ok {
|
||||
if !bytes.Equal(sum[:], digest) {
|
||||
s.mu.Lock()
|
||||
s.writeLog(s.clock.Now(), "CA digest differs")
|
||||
s.mu.Unlock()
|
||||
}
|
||||
if err := writeFrame(w, tcpStartEncReq, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
ex.ph = phStart
|
||||
return nil
|
||||
}
|
||||
if err := writeFrame(w, tcpCABundleReq, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
ex.ph = phBundle
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCAInfo(b []byte) (uint16, []byte, error) {
|
||||
size, rest, err := parseU16(b)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
digest, rest, err := parseBlob(rest)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
if len(digest) != sha256Len || len(rest) != 0 {
|
||||
return 0, nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
return size, digest, nil
|
||||
}
|
||||
|
||||
const sha256Len = 32
|
||||
|
||||
func (s *Station) onCABundle(w io.Writer, body []byte, ex *exchange) error {
|
||||
caPEM, rest, err := parseString(body)
|
||||
if err != nil {
|
||||
return s.retry("CA signature rejected", err)
|
||||
}
|
||||
sig, rest, err := parseBlob(rest)
|
||||
if err != nil {
|
||||
return s.retry("CA signature rejected", err)
|
||||
}
|
||||
certPEM, rest, err := parseString(rest)
|
||||
if err != nil {
|
||||
return s.retry("CA signature rejected", err)
|
||||
}
|
||||
if len(rest) != 0 {
|
||||
return s.retry("CA signature rejected", nil)
|
||||
}
|
||||
if err := verifyCABundle(caPEM, sig, certPEM); err != nil {
|
||||
return s.retry("CA signature rejected", err)
|
||||
}
|
||||
if err := writeCABundle(s.cfg.certificateDir, caPEM); err != nil {
|
||||
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
|
||||
return errStopped
|
||||
}
|
||||
if err := writeFrame(w, tcpStartEncReq, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
ex.ph = phStart
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) onStartEncryption(ctx context.Context, wire *framed, ex *exchange) error {
|
||||
pair, present, fatal, cause := prepareIdentity(s.cfg.certificateDir, s.cfg.callsign)
|
||||
if fatal != "" {
|
||||
s.endFatalCause(fatal, cause, true)
|
||||
return errStopped
|
||||
}
|
||||
ex.client = pair
|
||||
ex.haveCert = present
|
||||
caPath := filepath.Join(s.cfg.certificateDir, caFileName)
|
||||
caPEM, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
s.endFatalCause("Cannot create "+caPath, err, true)
|
||||
return errStopped
|
||||
}
|
||||
cfg, err := tlsConfig(s.cfg.host, caPEM, pair, present)
|
||||
if err != nil {
|
||||
return s.retry("TLS handshake failed", err)
|
||||
}
|
||||
if err := s.startTLS(ctx, wire, cfg, ex.limit); err != nil {
|
||||
if errors.Is(err, errHostMismatch) {
|
||||
return s.retry("Reflector certificate does not match the host", err)
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return s.retry("TLS handshake failed", err)
|
||||
}
|
||||
ex.ph = phIdent
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) sendCSR(w io.Writer, ex *exchange) error {
|
||||
csrPath := filepath.Join(s.cfg.certificateDir, csrFileName)
|
||||
pem, err := readCSR(s.cfg.certificateDir)
|
||||
if err != nil {
|
||||
s.endFatalCause("Cannot create "+csrPath, err, true)
|
||||
return errStopped
|
||||
}
|
||||
if err := writeFrame(w, tcpCsr, appendString(nil, pem)); err != nil {
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Station) saveCert(body []byte) error {
|
||||
pem, _, err := parseString(body)
|
||||
if err != nil || pem == "" {
|
||||
return s.retry("Protocol error", err)
|
||||
}
|
||||
certPath := filepath.Join(s.cfg.certificateDir, certFileName)
|
||||
if err := writeClientCert(s.cfg.certificateDir, pem); err != nil {
|
||||
s.endFatalCause("Cannot create "+certPath, err, true)
|
||||
return errStopped
|
||||
}
|
||||
return errReconnect
|
||||
}
|
||||
func (s *Station) finishV3Server(wire *framed, body []byte, ex *exchange) error {
|
||||
id, enc, dec, err := s.acceptServer(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if enc == nil {
|
||||
return errStopped
|
||||
}
|
||||
iv := make([]byte, 6)
|
||||
key := make([]byte, 16)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return fmt.Errorf("udp key: %w", err)
|
||||
}
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return fmt.Errorf("udp key: %w", err)
|
||||
}
|
||||
info, err := identityV3(iv, key)
|
||||
if err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return err
|
||||
}
|
||||
if err := writeFrame(wire, tcpNodeInfo, info); err != nil {
|
||||
enc.Close()
|
||||
dec.Close()
|
||||
return err
|
||||
}
|
||||
ex.lastTCP = s.clock.Now()
|
||||
s.mu.Lock()
|
||||
s.clientID = id
|
||||
s.enc = enc
|
||||
s.dec = dec
|
||||
s.ivPrefix = iv
|
||||
s.udpKey = key
|
||||
s.secure = true
|
||||
s.mu.Unlock()
|
||||
ex.ph = phUDP
|
||||
return nil
|
||||
}
|
||||
+54
-8
@@ -1,6 +1,11 @@
|
||||
package station
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"bytes"
|
||||
jsonv2 "encoding/json/v2"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// SoftwareName and SoftwareVersion are the identity sent to the reflector
|
||||
// after login. Edit them here.
|
||||
@@ -9,16 +14,57 @@ const (
|
||||
SoftwareVersion = "1.0.0"
|
||||
)
|
||||
|
||||
type softwareIdentity struct {
|
||||
SW string `json:"sw"`
|
||||
SWVer string `json:"swVer"`
|
||||
ProjVer string `json:"projVer"`
|
||||
MachineArch string `json:"machineArch,omitzero"`
|
||||
}
|
||||
|
||||
func softwareIdentityBody() ([]byte, error) {
|
||||
raw, err := json.Marshal(struct {
|
||||
SW string `json:"sw"`
|
||||
SWVer string `json:"swVer"`
|
||||
}{
|
||||
SW: SoftwareName,
|
||||
SWVer: SoftwareVersion,
|
||||
})
|
||||
raw, err := marshalIdentity(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendString(nil, string(raw)), nil
|
||||
}
|
||||
|
||||
func identityV3(iv, key []byte) ([]byte, error) {
|
||||
raw, err := marshalIdentity(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body := appendBlob(nil, iv)
|
||||
body = appendBlob(body, key)
|
||||
return appendString(body, string(raw)), nil
|
||||
}
|
||||
|
||||
func marshalIdentity(arch bool) ([]byte, error) {
|
||||
info := softwareIdentity{
|
||||
SW: SoftwareName,
|
||||
SWVer: SoftwareVersion,
|
||||
ProjVer: SoftwareVersion,
|
||||
}
|
||||
if arch {
|
||||
if name, ok := MachineArch(); ok {
|
||||
info.MachineArch = name
|
||||
}
|
||||
}
|
||||
return jsonv2.Marshal(info)
|
||||
}
|
||||
|
||||
// MachineArch is the hardware name from uname, when it can be read.
|
||||
func MachineArch() (string, bool) {
|
||||
var u unix.Utsname
|
||||
if err := unix.Uname(&u); err != nil {
|
||||
return "", false
|
||||
}
|
||||
b := u.Machine[:]
|
||||
if i := bytes.IndexByte(b, 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
+83
-20
@@ -24,14 +24,28 @@ type State struct {
|
||||
Detail string `json:"detail"`
|
||||
Callsign string `json:"callsign"`
|
||||
TalkGroup int `json:"talkGroup"`
|
||||
Protocol string `json:"protocol"`
|
||||
Talker string `json:"talker"`
|
||||
Transmitting bool `json:"transmitting"`
|
||||
NeedsConfig bool `json:"needsConfig"`
|
||||
ConfigOpen bool `json:"configOpen"`
|
||||
}
|
||||
|
||||
// Station is the seam tests and the window use.
|
||||
type Station struct {
|
||||
cfg loaded
|
||||
clock Clock
|
||||
cfg loaded
|
||||
clock Clock
|
||||
path string
|
||||
loaded bool
|
||||
configOpen bool
|
||||
configErr string
|
||||
openBlocked string
|
||||
form ConfigForm
|
||||
epoch uint64
|
||||
kick bool
|
||||
conn net.Conn
|
||||
cancelDial context.CancelFunc
|
||||
apply chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
state State
|
||||
@@ -39,13 +53,27 @@ type Station struct {
|
||||
buttonHeld bool
|
||||
spaceHeld bool
|
||||
fatal string
|
||||
retryReason string
|
||||
protocol string
|
||||
remoteTalker string
|
||||
roomBusy bool
|
||||
audioHeard bool
|
||||
lastAudio time.Time
|
||||
flushWaiting bool
|
||||
flushAt time.Time
|
||||
rxSeq uint16
|
||||
secure bool
|
||||
udpWait bool
|
||||
ivPrefix []byte
|
||||
udpKey []byte
|
||||
rxCounter uint32
|
||||
txCounter uint32
|
||||
mic []int16
|
||||
play []int16
|
||||
clientID uint16
|
||||
seq uint16
|
||||
udp *net.UDPConn
|
||||
udpPeer *net.UDPAddr
|
||||
lastUDPSend time.Time
|
||||
enc *opus.Encoder
|
||||
dec *opus.Decoder
|
||||
@@ -61,35 +89,55 @@ type Station struct {
|
||||
talkStart time.Time
|
||||
}
|
||||
|
||||
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
|
||||
// and New returns an error so the caller can exit before opening a window.
|
||||
// New loads the config at opt.ConfigPath. A missing or invalid file does not
|
||||
// stop the station: the Config window opens and the file is left uncreated
|
||||
// until Save. New returns an error when the path cannot be prepared, such as
|
||||
// an unknown home directory.
|
||||
func New(opt Options) (*Station, error) {
|
||||
cfg, err := loadConfig(opt.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clock := opt.Clock
|
||||
if clock == nil {
|
||||
clock = wallClock{}
|
||||
}
|
||||
out, err := loadConfig(opt.ConfigPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := "Idle"
|
||||
if cfg.logNotice != "" {
|
||||
detail = cfg.logNotice
|
||||
if out.detail != "" {
|
||||
detail = out.detail
|
||||
}
|
||||
call, tg := out.cfg.callsign, out.cfg.talkGroup
|
||||
if !out.loaded && out.cfg.callsign == "" {
|
||||
call = out.form.Callsign
|
||||
tg = out.form.TalkGroup
|
||||
}
|
||||
errText := ""
|
||||
if !out.loaded {
|
||||
errText = out.detail
|
||||
}
|
||||
s := &Station{
|
||||
cfg: cfg,
|
||||
clock: clock,
|
||||
wake: make(chan struct{}, 1),
|
||||
logPath: cfg.logPath,
|
||||
logNotice: cfg.logNotice,
|
||||
cfg: out.cfg,
|
||||
clock: clock,
|
||||
path: opt.ConfigPath,
|
||||
loaded: out.loaded,
|
||||
configOpen: !out.loaded,
|
||||
configErr: errText,
|
||||
form: out.form,
|
||||
wake: make(chan struct{}, 1),
|
||||
apply: make(chan struct{}, 1),
|
||||
logPath: out.cfg.logPath,
|
||||
logNotice: out.cfg.logNotice,
|
||||
fatal: out.cfg.certFatal,
|
||||
state: State{
|
||||
Connection: "Disconnected",
|
||||
Detail: detail,
|
||||
Callsign: cfg.callsign,
|
||||
TalkGroup: cfg.talkGroup,
|
||||
Connection: "Disconnected",
|
||||
Detail: detail,
|
||||
Callsign: call,
|
||||
TalkGroup: tg,
|
||||
NeedsConfig: !out.loaded,
|
||||
ConfigOpen: !out.loaded,
|
||||
},
|
||||
}
|
||||
if cfg.enableLogging {
|
||||
if out.loaded && out.cfg.enableLogging {
|
||||
s.openLog()
|
||||
}
|
||||
return s, nil
|
||||
@@ -102,6 +150,18 @@ func (s *Station) State() State {
|
||||
return s.state
|
||||
}
|
||||
|
||||
// Config returns the Config window's form and whether it is open.
|
||||
func (s *Station) Config() ConfigState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return ConfigState{
|
||||
Open: s.configOpen,
|
||||
NeedsConfig: !s.loaded,
|
||||
Error: s.configErr,
|
||||
Form: s.form,
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until pred is true or ctx ends.
|
||||
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
|
||||
s.mu.Lock()
|
||||
@@ -151,6 +211,9 @@ func (s *Station) afterHold(was bool) {
|
||||
if was && !s.transmitting() {
|
||||
s.endTransmit()
|
||||
}
|
||||
if !s.buttonHeld && !s.spaceHeld {
|
||||
s.openBlocked = ""
|
||||
}
|
||||
s.publish()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package station
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var errHostMismatch = errors.New("host mismatch")
|
||||
|
||||
func tlsConfig(host string, caPEM []byte, client tls.Certificate, present bool) (*tls.Config, error) {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("ca bundle")
|
||||
}
|
||||
cfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: pool,
|
||||
InsecureSkipVerify: true,
|
||||
VerifyConnection: func(cs tls.ConnectionState) error {
|
||||
return verifyPeer(host, pool, cs)
|
||||
},
|
||||
}
|
||||
if present {
|
||||
cfg.Certificates = []tls.Certificate{client}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func verifyPeer(host string, pool *x509.CertPool, cs tls.ConnectionState) error {
|
||||
if len(cs.PeerCertificates) == 0 {
|
||||
return errors.New("no peer certificate")
|
||||
}
|
||||
leaf := cs.PeerCertificates[0]
|
||||
opts := x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
if len(cs.PeerCertificates) > 1 {
|
||||
opts.Intermediates = x509.NewCertPool()
|
||||
for _, cert := range cs.PeerCertificates[1:] {
|
||||
opts.Intermediates.AddCert(cert)
|
||||
}
|
||||
}
|
||||
if _, err := leaf.Verify(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
for _, cand := range leaf.IPAddresses {
|
||||
if cand.Equal(ip) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errHostMismatch
|
||||
}
|
||||
if err := leaf.VerifyHostname(host); err != nil {
|
||||
return errHostMismatch
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
# (GeuReflector Satellite/Trunk). 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,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
|
||||
|
||||
+42
-45
@@ -1,77 +1,74 @@
|
||||
# Build and run SvxReflector for the Funknetz local lab.
|
||||
# Provenance: patterned on f4hlv/svxlink-docker (community); builds from upstream sm0svx/svxlink.
|
||||
# Build GeuReflector (IW1GEU) for the Funknetz local lab.
|
||||
# Binary name remains svxreflector; Trunk/Satellite are opt-in via config.
|
||||
# Pin: audric/GeuReflector dbad88e8c3e0f1adb8342db190bb0b3ad00106f4
|
||||
# (master, 2026-07-16; release line v1.3.17).
|
||||
|
||||
# =========================================================
|
||||
# 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 \
|
||||
libmosquitto-dev libhiredis-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ARG GIT_URL=https://github.com/sm0svx/svxlink.git
|
||||
ARG GIT_URL=https://github.com/audric/GeuReflector.git
|
||||
ARG GIT_BRANCH=master
|
||||
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 \
|
||||
-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
|
||||
if [ -n "${GIT_REF}" ]; then \
|
||||
git clone --recursive "${GIT_URL}" GeuReflector; \
|
||||
cd GeuReflector && git checkout "${GIT_REF}"; \
|
||||
else \
|
||||
git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" GeuReflector; \
|
||||
fi
|
||||
|
||||
WORKDIR /build/GeuReflector
|
||||
RUN set -eux; \
|
||||
cmake -S src -B build \
|
||||
-DLOCAL_STATE_DIR=/var \
|
||||
-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 \
|
||||
libmosquitto1 libhiredis0.14 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /build/svxlink/build/*.deb /tmp/svxlink.deb
|
||||
COPY --from=builder /build/GeuReflector/build/bin/svxreflector /usr/bin/svxreflector
|
||||
COPY --from=builder /build/GeuReflector/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"]
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
###################################################################
|
||||
# Local lab SvxReflector for Funknetz (protocol 2.0 / ReflectorV2)
|
||||
# Local lab GeuReflector 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.
|
||||
# GeuReflector 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,52 @@
|
||||
###################################################################
|
||||
# Optional FM-Funknetz link stub (GeuReflector)
|
||||
#
|
||||
# Default: leave ENABLE_FM_LINK=0 in .env — this file is ignored.
|
||||
# When the parent operator provides values:
|
||||
# 1. Uncomment ONE of the blocks below (prefer Satellite for home/NAT labs).
|
||||
# 2. Set ENABLE_FM_LINK=1 in .env
|
||||
# 3. docker compose up -d
|
||||
#
|
||||
# The entrypoint appends this file after the base lab conf, which already
|
||||
# ends in [PASSWORDS]. Satellite keys belong in [GLOBAL]; the section
|
||||
# header below merges with the base [GLOBAL] section.
|
||||
#
|
||||
# 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.
|
||||
# SATELLITE_ID may contain only letters, digits, and dashes.
|
||||
# Do not set LOCAL_PREFIX or a [TRUNK_x] section on a satellite.
|
||||
#
|
||||
# Trunk: equal mesh peer; needs mutual reachability on TCP 5302 and a
|
||||
# matching [TRUNK_x] section name + SECRET on both sides.
|
||||
# Do not enable Satellite and Trunk in the same file.
|
||||
#
|
||||
# Do not commit real secrets.
|
||||
# Docs: docs/research/2026-10-11-geureflector-lab-baseline.md
|
||||
# README: https://github.com/audric/GeuReflector#satellite-reflectors
|
||||
###################################################################
|
||||
|
||||
# --- Satellite (uncomment the section and keys to use) ---
|
||||
# [GLOBAL]
|
||||
# SATELLITE_OF=parent.example.org
|
||||
# SATELLITE_PORT=5303
|
||||
# SATELLITE_ID=lab-yourcall
|
||||
# SATELLITE_SECRET="replace-with-secret-from-operator"
|
||||
# Only these TGs cross to/from the parent (bidirectional). Omit for all TGs.
|
||||
# Syntax: exact "26200", prefix "24*", range "2427-2438", comma-separated.
|
||||
# SATELLITE_FILTER=2621,2629,20
|
||||
|
||||
# --- Trunk peer alternative (uncomment to use; leave Satellite commented) ---
|
||||
# [GLOBAL]
|
||||
# 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 stays off. If enabled later, MQTT_NAME belongs in a [MQTT] section,
|
||||
# not under [TRUNK_x].
|
||||
@@ -1,6 +1,9 @@
|
||||
# Local SvxReflector lab for Funknetz.
|
||||
# Build once, then: docker compose up -d
|
||||
# Local GeuReflector 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,
|
||||
# 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_URL: https://github.com/audric/GeuReflector.git
|
||||
GIT_BRANCH: master
|
||||
image: funknetz-svxreflector:local
|
||||
# Pin (overrides the branch clone). master on 2026-10-11; release line v1.3.17.
|
||||
GIT_REF: dbad88e8c3e0f1adb8342db190bb0b3ad00106f4
|
||||
image: funknetz-geureflector: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
|
||||
|
||||
@@ -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 (GeuReflector 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
|
||||
# (GeuReflector Satellite or Trunk keys) 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 "GeuReflector (svxreflector) is running. Waiting…"
|
||||
|
||||
exit_code=0
|
||||
for pid in $pids; do
|
||||
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/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,
|
||||
"protocol": "v2",
|
||||
"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"
|
||||
Reference in New Issue
Block a user