Compare commits
4
Commits
e642b24be0
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c621d4b7f | ||
|
|
3533b97d00 | ||
|
|
d21e7051d7 | ||
|
|
775d1b5c82 |
+11
-3
@@ -17,7 +17,7 @@ The conference that forwards audio among stations that have joined the same talk
|
|||||||
_Avoid_: server, bridge
|
_Avoid_: server, bridge
|
||||||
|
|
||||||
**Talk group**:
|
**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
|
_Avoid_: channel, room
|
||||||
|
|
||||||
**Talker**:
|
**Talker**:
|
||||||
@@ -45,9 +45,17 @@ The reflector’s certificate authority, kept in the certificate directory. Prot
|
|||||||
_Avoid_: the station certificate
|
_Avoid_: the station certificate
|
||||||
|
|
||||||
**Protocol**:
|
**Protocol**:
|
||||||
The reflector session this station is speaking: 2.0 or 3.0.
|
The reflector session this station speaks: 2.0 or 3.0. The config chooses v2 or v3; v2 is the default.
|
||||||
_Avoid_: TYPE, a config field the operator sets
|
_Avoid_: TYPE
|
||||||
|
|
||||||
**Push-to-talk**:
|
**Push-to-talk**:
|
||||||
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
|
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
|
_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
|
||||||
|
|||||||
@@ -5,17 +5,22 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
|
||||||
"gitea.neitzel.de/konrad/funknetz/audio"
|
"gitea.neitzel.de/konrad/funknetz/audio"
|
||||||
"gitea.neitzel.de/konrad/funknetz/station"
|
"gitea.neitzel.de/konrad/funknetz/station"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App is the window's view of the station.
|
// App is the window's view of the station.
|
||||||
type App struct {
|
type App struct {
|
||||||
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
station *station.Station
|
station *station.Station
|
||||||
|
quit func(context.Context)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) startup(ctx context.Context) {
|
func (a *App) startup(ctx context.Context) {
|
||||||
|
a.ctx = ctx
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
a.cancel = cancel
|
a.cancel = cancel
|
||||||
if err := audio.Start(runCtx, a.station.WriteMicrophone, a.station.TakePlayback); err != nil {
|
if err := audio.Start(runCtx, a.station.WriteMicrophone, a.station.TakePlayback); err != nil {
|
||||||
@@ -46,6 +51,29 @@ func (a *App) HoldSpace() { a.station.HoldSpace() }
|
|||||||
// ReleaseSpace reports that Space was released.
|
// ReleaseSpace reports that Space was released.
|
||||||
func (a *App) ReleaseSpace() { a.station.ReleaseSpace() }
|
func (a *App) ReleaseSpace() { a.station.ReleaseSpace() }
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func openStation(args []string) (*station.Station, error) {
|
func openStation(args []string) (*station.Station, error) {
|
||||||
path, err := resolveConfigPath(args)
|
path, err := resolveConfigPath(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,8 +82,9 @@ func openStation(args []string) (*station.Station, error) {
|
|||||||
return station.New(station.Options{ConfigPath: path})
|
return station.New(station.Options{ConfigPath: path})
|
||||||
}
|
}
|
||||||
|
|
||||||
// openStationReported opens the station from args. A flag or config failure is
|
// openStationReported opens the station from args. A flag or path failure is
|
||||||
// one line on stderr and the returned error. The window is not opened.
|
// 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) {
|
func openStationReported(args []string, stderr io.Writer) (*station.Station, error) {
|
||||||
st, err := openStation(args)
|
st, err := openStation(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+80
-53
@@ -4,12 +4,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"encoding/json/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveConfigPathDefaultIsHome(t *testing.T) {
|
func TestResolveConfigPathDefaultIsHome(t *testing.T) {
|
||||||
@@ -86,20 +85,15 @@ func TestOpenStationUsesResolvedPath(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "custom.json")
|
path := filepath.Join(dir, "custom.json")
|
||||||
|
|
||||||
_, err := openStation([]string{"-c", path})
|
st, err := openStation([]string{"-c", path})
|
||||||
if err == nil {
|
|
||||||
t.Fatal("openStation() error = nil, want rejection after creating the file")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "created") {
|
|
||||||
t.Fatalf("openStation() error = %q, want it to say the file was created", err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
|
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||||
t.Fatalf("mode = %o, want %o", got, want)
|
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.
|
// Same rules as the home file: a second open with a filled config works.
|
||||||
@@ -116,7 +110,7 @@ func TestOpenStationUsesResolvedPath(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
st, err := openStation([]string{"--config", path})
|
st, err = openStation([]string{"--config", path})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -136,52 +130,35 @@ func TestConfigEqualsFlagSelectsTheFile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreatedFileViaFlagMatchesHomeTemplate(t *testing.T) {
|
func TestMissingFileOpensWithoutStderr(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "lab.json")
|
path := filepath.Join(t.TempDir(), "lab.json")
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
st, err := openStationReported([]string{"-c", path}, &stderr)
|
st, err := openStationReported([]string{"-c", path}, &stderr)
|
||||||
if st != nil {
|
|
||||||
t.Fatal("station = non-nil, want nil before the window")
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("error = nil, want the file created")
|
|
||||||
}
|
|
||||||
if got, want := err.Error(), "created "+path; got != want {
|
|
||||||
t.Fatalf("error = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := stderr.String(), err.Error()+"\n"; got != want {
|
|
||||||
t.Fatalf("stderr = %q, want %q", got, want)
|
|
||||||
}
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
|
if st == nil {
|
||||||
t.Fatalf("mode = %o, want %o", got, want)
|
t.Fatal("station = nil, want a station so the window can open")
|
||||||
}
|
}
|
||||||
body, err := os.ReadFile(path)
|
if stderr.Len() != 0 {
|
||||||
if err != nil {
|
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
var got map[string]any
|
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||||
if err := json.Unmarshal(body, &got); err != nil {
|
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||||
t.Fatalf("created file is not JSON: %v\n%s", err, body)
|
|
||||||
}
|
}
|
||||||
if got["host"] != "" || got["callsign"] != "" || got["authKey"] != "" {
|
if !st.State().NeedsConfig || !st.State().ConfigOpen {
|
||||||
t.Fatalf("string fields = %#v, want empty host, callsign, and authKey", got)
|
t.Fatalf("State = %+v, want the Config window open", st.State())
|
||||||
}
|
}
|
||||||
if got["port"] != float64(0) || got["talkGroup"] != float64(0) {
|
form := st.Config().Form
|
||||||
t.Fatalf("numeric fields = %#v, want port and talkGroup at zero", got)
|
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)
|
||||||
if got["enableLogging"] != false {
|
|
||||||
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
|
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
if form.LogFile != filepath.Join(home, "funknetz.log") {
|
||||||
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"])
|
t.Fatalf("LogFile = %q, want funknetz.log in the home directory", form.LogFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +248,7 @@ func TestExtraWordsDoNotChangeTheSelectedFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestSelectedFileIsLeftByteForByte(t *testing.T) {
|
func TestSelectedFileIsLeftByteForByte(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "lab.json")
|
path := filepath.Join(t.TempDir(), "lab.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\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -292,7 +269,7 @@ func TestSelectedFileIsLeftByteForByte(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) {
|
func TestEmptyHostOnSelectedFileOpensConfig(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "lab.json")
|
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")
|
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 {
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
@@ -300,14 +277,64 @@ func TestEmptyHostOnSelectedFileNamesHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
st, err := openStationReported([]string{"-c", path}, &stderr)
|
st, err := openStationReported([]string{"-c", path}, &stderr)
|
||||||
if st != nil {
|
if err != nil {
|
||||||
t.Fatal("station = non-nil, want nil")
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err == nil || !strings.Contains(err.Error(), "host") {
|
if st == nil || !st.State().ConfigOpen || !strings.Contains(st.State().Detail, "host") {
|
||||||
t.Fatalf("error = %v, want it to name host", err)
|
t.Fatalf("State = %+v, want the Config window open naming host", st.State())
|
||||||
}
|
}
|
||||||
if stderr.String() != err.Error()+"\n" {
|
if stderr.Len() != 0 {
|
||||||
t.Fatalf("stderr = %q, want the error line", stderr.String())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 default file is `~/.funknetz.json`, created mode 0600 when missing. Application Support would match other Mac apps and would be a worse place to open in an editor. That default path is part of the station's contract; moving it later strands existing files. `-c` / `--config` may select another file; create, rewrite, and validate rules are the same—only the path changes.
|
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.
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
Superseded by [ADR 0006](0006-configured-protocol.md).
|
||||||
|
|
||||||
# Announce protocol 3.0, fall back only on an explicit 2.x maximum
|
# 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.
|
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.
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
**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.
|
**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.
|
**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.
|
**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.
|
**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**.
|
This note is **factual** except sections labeled **Inference**.
|
||||||
|
|
||||||
|
|||||||
+15
-13
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
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.
|
Funknetz must be proven against a local reflector before it touches the live network. This document is that lab: how to run the reflector, what it will accept from a station, and what the wire protocol actually does—so a headset client can be tested without FM-Funknetz credentials.
|
||||||
|
|
||||||
The lab runs **[jayReflector](https://github.com/dj1jay/jayReflector)** (DJ1JAY / FM-Funknetz), a Trunk/Satellite-capable fork of SvxReflector. The process is still named `svxreflector`. You do **not** need the `svxlink` node binary, LocalRx/LocalTx, or EchoLink to exercise the headset client.
|
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 DJ1JAY provides parameters.
|
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
|
## Running a local reflector
|
||||||
|
|
||||||
Lab compose lives in [`svxlink-docker/`](../svxlink-docker/). It builds jayReflector, mounts a minimal `svxreflector.conf`, publishes TCP **and** UDP on the listen port, and runs only `svxreflector`.
|
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
|
```sh
|
||||||
cd svxlink-docker
|
cd svxlink-docker
|
||||||
@@ -18,7 +18,7 @@ docker compose up -d --build
|
|||||||
|
|
||||||
| Item | Lab value |
|
| Item | Lab value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Binary | jayReflector → `/usr/bin/svxreflector` |
|
| Binary | GeuReflector → `/usr/bin/svxreflector` |
|
||||||
| Listen port | **5300** TCP **and** UDP (local clients; FM-Funknetz production client port is often 5308) |
|
| Listen port | **5300** TCP **and** UDP (local clients; FM-Funknetz production client port is often 5308) |
|
||||||
| Config | `config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` |
|
| Config | `config/svxreflector.conf` → `/etc/svxlink/svxreflector.conf` |
|
||||||
| Auth (protocol 2.0) | `[USERS]` → `[PASSWORDS]`; station `authKey` matches that secret |
|
| Auth (protocol 2.0) | `[USERS]` → `[PASSWORDS]`; station `authKey` matches that secret |
|
||||||
@@ -45,6 +45,7 @@ Manual lab JSON (values match `.env` / the mounted conf; secrets are local-test
|
|||||||
"callsign": "DL0FN-1",
|
"callsign": "DL0FN-1",
|
||||||
"authKey": "local-lab-secret-change-me",
|
"authKey": "local-lab-secret-change-me",
|
||||||
"talkGroup": 9,
|
"talkGroup": 9,
|
||||||
|
"protocol": "v2",
|
||||||
"enableLogging": false,
|
"enableLogging": false,
|
||||||
"logFile": "/tmp/funknetz.log"
|
"logFile": "/tmp/funknetz.log"
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ A second station can use callsign `DL0FN-2` with the same auth key. Do not reuse
|
|||||||
|
|
||||||
Default: `ENABLE_FM_LINK=0` — local island only. No network uplink.
|
Default: `ENABLE_FM_LINK=0` — local island only. No network uplink.
|
||||||
|
|
||||||
When DJ1JAY provides parameters:
|
When the parent operator provides parameters:
|
||||||
|
|
||||||
1. Edit [`config/svxreflector.link.conf`](../svxlink-docker/config/svxreflector.link.conf) (prefer **Satellite** for home labs).
|
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`.
|
2. Set `ENABLE_FM_LINK=1` in `.env`.
|
||||||
@@ -69,21 +70,22 @@ The entrypoint appends the link file after the base conf. Local Funknetz setting
|
|||||||
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).
|
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
|
```text
|
||||||
Funknetz ──:5300──► jayReflector (lab) ──outbound :5303──► FM-Funknetz parent
|
Funknetz ──:5300──► GeuReflector (lab) ──outbound :5303──► FM-Funknetz parent
|
||||||
```
|
```
|
||||||
|
|
||||||
Fill from DJ1JAY (jayReflector README shape):
|
Fill under `[GLOBAL]` in the link stub (GeuReflector README shape). `SATELLITE_ID` may contain only letters, digits, and dashes:
|
||||||
|
|
||||||
- `SATELLITE_OF=parent.example.org:5303`
|
- `SATELLITE_OF=parent.example.org`
|
||||||
|
- `SATELLITE_PORT=5303`
|
||||||
- `SATELLITE_ID=…`
|
- `SATELLITE_ID=…`
|
||||||
- `SATELLITE_SECRET=…`
|
- `SATELLITE_SECRET=…`
|
||||||
- optional `SATELLITE_FILTER=…` for “only some TGs”
|
- optional `SATELLITE_FILTER=…` for “only some TGs”
|
||||||
|
|
||||||
### Trunk (only if agreed)
|
### Trunk (only if agreed)
|
||||||
|
|
||||||
Equal mesh peer: mutual TCP **5302**, matching `[TRUNK_x]` section name and `SECRET`, plus filters (`ALLOW_TGS` / `TG_MAP`). That **does** require the peer to reach your trunk port — not the default lab layout. Prefer Satellite unless DJ1JAY asks for Trunk.
|
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-07-jayreflector-fm-funknetz.md](../../docs/research/2026-10-07-jayreflector-fm-funknetz.md).
|
Research baseline: [docs/research/2026-10-11-geureflector-lab-baseline.md](../../docs/research/2026-10-11-geureflector-lab-baseline.md).
|
||||||
|
|
||||||
## What the reflector admits
|
## What the reflector admits
|
||||||
|
|
||||||
@@ -103,7 +105,7 @@ For the lab and for production as FM-Funknetz documents it, the station speaks *
|
|||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
The byte-level specification is [rfc.md](./rfc.md). It describes the protocol as implemented by SvxLink. The contract in that source is the message types in SvxLink / jayReflector (`ReflectorMsg.h` and the client logics). Control rides **TCP** as length-prefixed frames (4-byte big-endian length, then payload). Audio and UDP keepalives ride **UDP** to the same host and port. Message types are numeric. Protocol **2.0** authenticates with the auth-key HMAC and sends plaintext UDP with a header of type, client id, and sequence. Protocol **3.0** uses X.509/TLS and encrypts UDP (AES-128-GCM). Funknetz targets 2.0.
|
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:
|
Typical protocol 2.0 login:
|
||||||
|
|
||||||
@@ -155,7 +157,7 @@ The codec name is negotiated in `MsgServerInfo`, not per audio packet. When Opus
|
|||||||
| Concept | What it is in this lab |
|
| Concept | What it is in this lab |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Stock `sm0svx` peering | **Unsupported** — not what we build. |
|
| Stock `sm0svx` peering | **Unsupported** — not what we build. |
|
||||||
| jayReflector **Satellite** | Lab dials parent `:5303`; optional `SATELLITE_FILTER` for some TGs. Outbound only. |
|
| GeuReflector **Satellite** | Lab dials parent `:5303`; optional `SATELLITE_FILTER` for some TGs. Outbound only. |
|
||||||
| jayReflector **Trunk** | Mesh peer on `:5302` with filters / `TG_MAP`. Needs mutual reachability. |
|
| 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. |
|
| `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. |
|
| Same reflector, same TG, two stations | Normal conference — local test without any uplink. |
|
||||||
|
|||||||
Vendored
+163
-3
@@ -1,19 +1,52 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: #101418;
|
background: #101418;
|
||||||
color: #f2f4f6;
|
color: #f2f4f6;
|
||||||
font: 16px/1.4 system-ui, sans-serif;
|
font: 16px/1.4 system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
padding: 24px;
|
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 {
|
#connection {
|
||||||
margin: 0 0 16px;
|
margin: 0;
|
||||||
font-size: 14px;
|
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 {
|
#protocol {
|
||||||
min-height: 1.4em;
|
min-height: 1.4em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -24,7 +57,7 @@ main {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
#ptt {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
@@ -36,6 +69,133 @@ button {
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.down {
|
#ptt.down {
|
||||||
background: #9b2226;
|
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
+189
-1
@@ -1,4 +1,13 @@
|
|||||||
const button = document.getElementById("ptt");
|
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() {
|
function api() {
|
||||||
if (!window.go || !window.go.main || !window.go.main.App) {
|
if (!window.go || !window.go.main || !window.go.main.App) {
|
||||||
@@ -17,6 +26,132 @@ function line(state) {
|
|||||||
return "Idle";
|
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) {
|
function render(state) {
|
||||||
document.getElementById("connection").textContent = state.connection;
|
document.getElementById("connection").textContent = state.connection;
|
||||||
document.getElementById("callsign").textContent = state.callsign;
|
document.getElementById("callsign").textContent = state.callsign;
|
||||||
@@ -25,6 +160,12 @@ function render(state) {
|
|||||||
protocol.textContent = state.protocol ? "Protocol " + state.protocol : "";
|
protocol.textContent = state.protocol ? "Protocol " + state.protocol : "";
|
||||||
document.getElementById("detail").textContent = line(state);
|
document.getElementById("detail").textContent = line(state);
|
||||||
button.classList.toggle("down", !!state.transmitting);
|
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() {
|
function refresh() {
|
||||||
@@ -35,7 +176,47 @@ function refresh() {
|
|||||||
app.State().then(render).catch(function () {});
|
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) {
|
button.addEventListener("pointerdown", function (event) {
|
||||||
|
if (button.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
button.setPointerCapture(event.pointerId);
|
button.setPointerCapture(event.pointerId);
|
||||||
const app = api();
|
const app = api();
|
||||||
@@ -57,11 +238,18 @@ button.addEventListener("pointercancel", releaseButton);
|
|||||||
let spaceHeld = false;
|
let spaceHeld = false;
|
||||||
|
|
||||||
window.addEventListener("keydown", function (event) {
|
window.addEventListener("keydown", function (event) {
|
||||||
|
if (event.code === "Escape") {
|
||||||
|
if (!config.hidden) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelConfig();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.code !== "Space") {
|
if (event.code !== "Space") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.repeat || spaceHeld) {
|
if (!config.hidden || button.disabled || event.repeat || spaceHeld) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
spaceHeld = true;
|
spaceHeld = true;
|
||||||
|
|||||||
Vendored
+32
@@ -7,13 +7,45 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
|
<div class="status">
|
||||||
<p id="connection">Disconnected</p>
|
<p id="connection">Disconnected</p>
|
||||||
|
<button id="gear" type="button" aria-label="Config">⚙</button>
|
||||||
|
</div>
|
||||||
<p>Callsign <span id="callsign"></span></p>
|
<p>Callsign <span id="callsign"></span></p>
|
||||||
<p>Talk group <span id="talkgroup"></span></p>
|
<p>Talk group <span id="talkgroup"></span></p>
|
||||||
<p id="protocol"></p>
|
<p id="protocol"></p>
|
||||||
<p id="detail">Idle</p>
|
<p id="detail">Idle</p>
|
||||||
<button id="ptt" type="button">Push to talk</button>
|
<button id="ptt" type="button">Push to talk</button>
|
||||||
</main>
|
</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>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ func main() {
|
|||||||
err = wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
Title: "funknetz",
|
Title: "funknetz",
|
||||||
Width: 420,
|
Width: 420,
|
||||||
Height: 320,
|
Height: 480,
|
||||||
|
MinWidth: 420,
|
||||||
|
MinHeight: 480,
|
||||||
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
|
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
|
||||||
AssetServer: &assetserver.Options{Assets: assets},
|
AssetServer: &assetserver.Options{Assets: assets},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type loggedIn struct {
|
|||||||
refl *reflector
|
refl *reflector
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
id uint16
|
id uint16
|
||||||
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn {
|
func login(t *testing.T, callsign, key string, tg int, id uint16, codecs []string) loggedIn {
|
||||||
@@ -48,7 +49,7 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
for i := range challenge {
|
for i := range challenge {
|
||||||
challenge[i] = byte(i + 1)
|
challenge[i] = byte(i + 1)
|
||||||
@@ -94,7 +95,15 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
|
|||||||
if got.Protocol != "2.0" {
|
if got.Protocol != "2.0" {
|
||||||
t.Fatalf("Protocol = %q, want 2.0", got.Protocol)
|
t.Fatalf("Protocol = %q, want 2.0", got.Protocol)
|
||||||
}
|
}
|
||||||
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id}
|
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) {
|
func declineTo2(t *testing.T, refl *reflector) {
|
||||||
@@ -162,8 +171,8 @@ func TestDropRetriesUntilQuit(t *testing.T) {
|
|||||||
in.clock.Advance(2 * time.Second)
|
in.clock.Advance(2 * time.Second)
|
||||||
in.refl.accept(t)
|
in.refl.accept(t)
|
||||||
typ, body := in.refl.readFrame(t)
|
typ, body := in.refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry protocol type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
in.cancel()
|
in.cancel()
|
||||||
in.clock.Advance(2 * time.Second)
|
in.clock.Advance(2 * time.Second)
|
||||||
@@ -280,7 +289,7 @@ func TestMissingOpusStopsRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
||||||
refl.readFrame(t)
|
refl.readFrame(t)
|
||||||
|
|||||||
+187
-40
@@ -20,6 +20,7 @@ type loaded struct {
|
|||||||
callsign string
|
callsign string
|
||||||
authKey string
|
authKey string
|
||||||
talkGroup int
|
talkGroup int
|
||||||
|
protocolMajor uint16
|
||||||
certificateDir string
|
certificateDir string
|
||||||
certFatal string
|
certFatal string
|
||||||
enableLogging bool
|
enableLogging bool
|
||||||
@@ -33,6 +34,7 @@ type fileShape struct {
|
|||||||
Callsign jsontext.Value `json:"callsign"`
|
Callsign jsontext.Value `json:"callsign"`
|
||||||
AuthKey jsontext.Value `json:"authKey"`
|
AuthKey jsontext.Value `json:"authKey"`
|
||||||
TalkGroup jsontext.Value `json:"talkGroup"`
|
TalkGroup jsontext.Value `json:"talkGroup"`
|
||||||
|
Protocol jsontext.Value `json:"protocol"`
|
||||||
CertificateDir jsontext.Value `json:"certificateDir"`
|
CertificateDir jsontext.Value `json:"certificateDir"`
|
||||||
EnableLogging jsontext.Value `json:"enableLogging"`
|
EnableLogging jsontext.Value `json:"enableLogging"`
|
||||||
LogFile jsontext.Value `json:"logFile"`
|
LogFile jsontext.Value `json:"logFile"`
|
||||||
@@ -44,6 +46,7 @@ type templateFile struct {
|
|||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
AuthKey string `json:"authKey"`
|
AuthKey string `json:"authKey"`
|
||||||
TalkGroup int `json:"talkGroup"`
|
TalkGroup int `json:"talkGroup"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
CertificateDir string `json:"certificateDir"`
|
CertificateDir string `json:"certificateDir"`
|
||||||
EnableLogging bool `json:"enableLogging"`
|
EnableLogging bool `json:"enableLogging"`
|
||||||
LogFile string `json:"logFile"`
|
LogFile string `json:"logFile"`
|
||||||
@@ -51,80 +54,164 @@ type templateFile struct {
|
|||||||
|
|
||||||
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
|
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)
|
body, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
form, ferr := templateForm()
|
||||||
|
if ferr != nil {
|
||||||
|
return configLoad{}, ferr
|
||||||
|
}
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
if err := writeTemplate(path); err != nil {
|
return configLoad{form: form}, nil
|
||||||
return loaded{}, err
|
|
||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("created %s", path)
|
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
|
||||||
}
|
}
|
||||||
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
return parseConfig(path, body, true)
|
||||||
}
|
|
||||||
return parseConfig(path, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeTemplate(path string) error {
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
logFile, err := defaultLogPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return ConfigForm{}, err
|
||||||
}
|
}
|
||||||
body, err := jsonv2.Marshal(templateFile{CertificateDir: "./funknetz", LogFile: logFile})
|
return ConfigForm{Protocol: "v2", CertificateDir: "./funknetz", LogFile: logFile}, nil
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseConfig(path string, body []byte) (loaded, error) {
|
func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
|
||||||
var shape fileShape
|
var shape fileShape
|
||||||
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
if err := jsonv2.Unmarshal(body, &shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
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
|
||||||
}
|
}
|
||||||
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
|
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
|
||||||
if block, notice := blockedLog(shape); block || certBad {
|
protoBad := present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString
|
||||||
|
if block, notice := blockedLog(shape); block || certBad || protoBad {
|
||||||
cfg, err := parseOperational(shape)
|
cfg, err := parseOperational(shape)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return refused(shape, err)
|
||||||
}
|
}
|
||||||
cfg.logNotice = notice
|
cfg.logNotice = notice
|
||||||
if !block {
|
if !block {
|
||||||
cfg = applyLog(cfg, shape)
|
cfg = applyLog(cfg, shape)
|
||||||
}
|
}
|
||||||
return applyCertificate(cfg, shape, path), nil
|
return finish(shape, applyCertificate(cfg, shape, path))
|
||||||
}
|
}
|
||||||
if missingCertificate(shape) || missingLog(shape) {
|
if missingCertificate(shape) || missingLog(shape) || missingProtocol(shape) {
|
||||||
if !operationalKindsOK(shape) {
|
if !operationalKindsOK(shape) {
|
||||||
return parseOperational(shape)
|
_, err := parseOperational(shape)
|
||||||
|
if err != nil {
|
||||||
|
return refused(shape, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
shape, err = withLogDefaults(shape)
|
shape, err = withLogDefaults(shape)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return configLoad{}, err
|
||||||
}
|
}
|
||||||
|
if rewrite {
|
||||||
if err := rewriteConfig(path, shape); err != nil {
|
if err := rewriteConfig(path, shape); err != nil {
|
||||||
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
|
return configLoad{}, fmt.Errorf("the file could not be read: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg, err := parseOperational(shape)
|
cfg, err := parseOperational(shape)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return refused(shape, err)
|
||||||
}
|
}
|
||||||
return applyLog(applyCertificate(cfg, shape, path), 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) {
|
func parseOperational(shape fileShape) (loaded, error) {
|
||||||
@@ -148,6 +235,10 @@ func parseOperational(shape fileShape) (loaded, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return loaded{}, err
|
||||||
}
|
}
|
||||||
|
protocolMajor, err := protocolField(shape.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return loaded{}, err
|
||||||
|
}
|
||||||
|
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
@@ -160,7 +251,45 @@ func parseOperational(shape fileShape) (loaded, error) {
|
|||||||
if !callsignPattern.MatchString(call) {
|
if !callsignPattern.MatchString(call) {
|
||||||
return loaded{}, fmt.Errorf("callsign is invalid")
|
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) {
|
func stringField(name string, v jsontext.Value) (string, error) {
|
||||||
@@ -229,6 +358,14 @@ func missingCertificate(shape fileShape) bool {
|
|||||||
return !present(shape.CertificateDir)
|
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 {
|
func operationalKindsOK(shape fileShape) bool {
|
||||||
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
||||||
return false
|
return false
|
||||||
@@ -245,6 +382,9 @@ func operationalKindsOK(shape fileShape) bool {
|
|||||||
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +411,9 @@ func withLogDefaults(shape fileShape) (fileShape, error) {
|
|||||||
shape.Callsign = rawOr(shape.Callsign, `""`)
|
shape.Callsign = rawOr(shape.Callsign, `""`)
|
||||||
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
||||||
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
||||||
|
if missingProtocol(shape) {
|
||||||
|
shape.Protocol = jsontext.Value(`"v2"`)
|
||||||
|
}
|
||||||
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
|
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
|
||||||
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
||||||
if !present(shape.LogFile) {
|
if !present(shape.LogFile) {
|
||||||
@@ -304,13 +447,17 @@ func rewriteConfig(path string, shape fileShape) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
body = append(body, '\n')
|
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")
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".funknetz-*.json")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
tmpName := tmp.Name()
|
tmpName := tmp.Name()
|
||||||
_, werr := tmp.Write(body)
|
_, werr := tmp.Write(body)
|
||||||
cerr := tmp.Chmod(info.Mode().Perm())
|
cerr := tmp.Chmod(mode)
|
||||||
clerr := tmp.Close()
|
clerr := tmp.Close()
|
||||||
if werr != nil || cerr != nil || clerr != nil {
|
if werr != nil || cerr != nil || clerr != nil {
|
||||||
os.Remove(tmpName)
|
os.Remove(tmpName)
|
||||||
|
|||||||
+226
-56
@@ -15,58 +15,54 @@ import (
|
|||||||
"gitea.neitzel.de/konrad/funknetz/station"
|
"gitea.neitzel.de/konrad/funknetz/station"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMissingConfigIsCreatedAndRejected(t *testing.T) {
|
func TestMissingConfigOpensWithoutCreating(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), ".funknetz.json")
|
path := filepath.Join(t.TempDir(), ".funknetz.json")
|
||||||
|
|
||||||
_, err := station.New(station.Options{ConfigPath: path})
|
st, 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, want := info.Mode().Perm(), os.FileMode(0o600); got != want {
|
t.Cleanup(st.Close)
|
||||||
t.Fatalf("mode = %o, want %o", got, want)
|
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||||
|
t.Fatalf("stat = %v, want the file absent", statErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := os.ReadFile(path)
|
got := st.State()
|
||||||
if err != nil {
|
if !got.NeedsConfig || !got.ConfigOpen || got.Connection != "Disconnected" {
|
||||||
t.Fatal(err)
|
t.Fatalf("State = %+v, want Disconnected with the Config window open", got)
|
||||||
}
|
}
|
||||||
var got map[string]any
|
cfg := st.Config()
|
||||||
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
if !cfg.Open || !cfg.NeedsConfig || cfg.Error != "" {
|
||||||
t.Fatalf("created file is not JSON: %v\n%s", err, body)
|
t.Fatalf("Config = %+v, want an open needs-config form and no error", cfg)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got["enableLogging"] != false {
|
form := cfg.Form
|
||||||
t.Fatalf("enableLogging = %#v, want false", got["enableLogging"])
|
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") {
|
if form.Port != 0 || form.TalkGroup != 0 {
|
||||||
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"])
|
t.Fatalf("numeric fields = %+v, want port and talkGroup at zero", form)
|
||||||
}
|
}
|
||||||
if got["certificateDir"] != "./funknetz" {
|
if form.EnableLogging {
|
||||||
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
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)
|
||||||
}
|
}
|
||||||
assertKeyOrder(t, body)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"/var/funknetz\",\"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 {
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -118,6 +114,8 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
|
|||||||
{name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, 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: "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", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -133,12 +131,17 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err = station.New(station.Options{ConfigPath: path})
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("New() error = nil, want a field rejection")
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err.Error() != tt.want {
|
t.Cleanup(st.Close)
|
||||||
t.Fatalf("New() error = %q, want %q", err.Error(), tt.want)
|
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)
|
after, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -164,20 +167,55 @@ 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) {
|
func TestBrokenConfigSaysTheFileCouldNotBeRead(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := station.New(station.Options{ConfigPath: path})
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("New() error = nil, want a read failure")
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "the file could not be read") {
|
t.Cleanup(st.Close)
|
||||||
t.Fatalf("New() error = %q, want it to say the file could not be read", err.Error())
|
got := st.Config()
|
||||||
|
if !got.Open || !got.NeedsConfig {
|
||||||
|
t.Fatalf("Config = %+v, want the Config window open", got)
|
||||||
}
|
}
|
||||||
if strings.Contains(err.Error(), "callsign") {
|
if !strings.Contains(got.Error, "the file could not be read") {
|
||||||
t.Fatalf("New() error = %q, want no field name", err.Error())
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +286,9 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
|
|||||||
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||||
t.Fatalf("logFile = %#v", got["logFile"])
|
t.Fatalf("logFile = %#v", got["logFile"])
|
||||||
}
|
}
|
||||||
|
if got["protocol"] != "v2" {
|
||||||
|
t.Fatalf("protocol = %#v, want v2", got["protocol"])
|
||||||
|
}
|
||||||
if got["certificateDir"] != "./funknetz" {
|
if got["certificateDir"] != "./funknetz" {
|
||||||
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
||||||
}
|
}
|
||||||
@@ -326,9 +367,9 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
|||||||
detail string
|
detail string
|
||||||
same bool
|
same bool
|
||||||
}{
|
}{
|
||||||
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","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,"certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", 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,"certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", 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: "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: "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},
|
{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},
|
||||||
@@ -360,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")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`)
|
body := []byte(`{"host":"","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes"}`)
|
||||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
_, err := station.New(station.Options{ConfigPath: path})
|
st, err := station.New(station.Options{ConfigPath: path})
|
||||||
if err == nil || err.Error() != "host is empty" {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v, want host is empty", err)
|
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)
|
after, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -382,7 +428,7 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
|||||||
|
|
||||||
func assertKeyOrder(t *testing.T, body []byte) {
|
func assertKeyOrder(t *testing.T, body []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
|
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"protocol"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
|
||||||
last := -1
|
last := -1
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
i := strings.Index(string(body), key)
|
i := strings.Index(string(body), key)
|
||||||
@@ -393,6 +439,121 @@ func assertKeyOrder(t *testing.T, body []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
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,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||||
@@ -417,7 +578,7 @@ func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
|
|||||||
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" {
|
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" || got["protocol"] != "v2" {
|
||||||
t.Fatalf("fields = %#v", got)
|
t.Fatalf("fields = %#v", got)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(body), t.TempDir()) {
|
if strings.Contains(string(body), t.TempDir()) {
|
||||||
@@ -444,7 +605,7 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
port := ln.Addr().(*net.TCPAddr).Port
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
|
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")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -482,13 +643,22 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
|
|||||||
|
|
||||||
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
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,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"authKey": authKey,
|
"authKey": authKey,
|
||||||
"talkGroup": talkGroup,
|
"talkGroup": talkGroup,
|
||||||
})
|
}
|
||||||
|
if protocol != "" {
|
||||||
|
fields["protocol"] = protocol
|
||||||
|
}
|
||||||
|
body, err := jsonv2.Marshal(fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -194,6 +194,23 @@ func parseVersion(b []byte) (uint16, uint16, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var atMostVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. May be at most (\d+)\.(\d+)\.$`)
|
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.
|
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
|
||||||
func fallback20(msg string) bool {
|
func fallback20(msg string) bool {
|
||||||
@@ -209,6 +226,20 @@ func fallback20(msg string) bool {
|
|||||||
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
|
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 {
|
func versionBelow(major, minor, maj, min int) bool {
|
||||||
if major != maj {
|
if major != maj {
|
||||||
return major < maj
|
return major < maj
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
for step := 0; step < 6; step++ {
|
for step := 0; step < 6; step++ {
|
||||||
clock.BlockUntilWaiting(t)
|
clock.BlockUntilWaiting(t)
|
||||||
clock.Advance(10 * time.Second)
|
clock.Advance(10 * time.Second)
|
||||||
@@ -144,8 +144,8 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
payload := make([]byte, 65)
|
payload := make([]byte, 65)
|
||||||
var hdr [4]byte
|
var hdr [4]byte
|
||||||
binary.BigEndian.PutUint32(hdr[:], 65)
|
binary.BigEndian.PutUint32(hdr[:], 65)
|
||||||
@@ -178,8 +178,8 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
|
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" })
|
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,8 +211,8 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
if st.State().Detail != "Protocol error" {
|
if st.State().Detail != "Protocol error" {
|
||||||
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
|
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func configWithDir(t testing.TB, host string, port int, callsign, authKey string
|
|||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"authKey": authKey,
|
"authKey": authKey,
|
||||||
"talkGroup": talkGroup,
|
"talkGroup": talkGroup,
|
||||||
|
"protocol": "v3",
|
||||||
"certificateDir": certDir,
|
"certificateDir": certDir,
|
||||||
"enableLogging": false,
|
"enableLogging": false,
|
||||||
"logFile": logPath,
|
"logFile": logPath,
|
||||||
@@ -509,7 +510,7 @@ func TestCADigestMismatchIsLoggedAndKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
"host": "127.0.0.1", "port": refl.port, "callsign": "DL1ABC", "authKey": "key",
|
"host": "127.0.0.1", "port": refl.port, "callsign": "DL1ABC", "authKey": "key",
|
||||||
"talkGroup": 20, "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
|
"talkGroup": 20, "protocol": "v3", "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(msg, func(t *testing.T) {
|
t.Run(msg, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,7 +51,7 @@ func TestVersionErrorBelow2Stops(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(msg, func(t *testing.T) {
|
t.Run(msg, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,7 +93,7 @@ func TestDowngradeOtherThan2Stops(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.want, func(t *testing.T) {
|
t.Run(tc.want, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,3 +150,65 @@ func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
|
|||||||
t.Fatal("created the certificate directory on a 2.0 fallback")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+99
-23
@@ -30,28 +30,53 @@ var (
|
|||||||
// Run keeps the reflector session until ctx is canceled.
|
// Run keeps the reflector session until ctx is canceled.
|
||||||
func (s *Station) Run(ctx context.Context) error {
|
func (s *Station) Run(ctx context.Context) error {
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
major := uint16(3)
|
var major uint16
|
||||||
|
holdMajor := false
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
s.mu.Lock()
|
if !holdMajor {
|
||||||
if s.fatal != "" {
|
major = s.configuredMajor()
|
||||||
s.mu.Unlock()
|
|
||||||
<-ctx.Done()
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
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.state.Connection = "Connecting"
|
||||||
s.publish()
|
s.publish()
|
||||||
host, port := s.cfg.host, s.cfg.port
|
host, port := s.cfg.host, s.cfg.port
|
||||||
|
epoch := s.epoch
|
||||||
|
dialCtx, dialCancel := context.WithCancel(ctx)
|
||||||
|
s.cancelDial = dialCancel
|
||||||
s.mu.Unlock()
|
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 err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
major = 3
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.noteDownLocked(s.clock.Now(), true)
|
s.noteDownLocked(s.clock.Now(), true)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -61,17 +86,28 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.session(ctx, conn, major)
|
err = s.session(ctx, conn, major, epoch, major == s.configuredMajor())
|
||||||
conn.Close()
|
conn.Close()
|
||||||
fallback := errors.Is(err, errFallback)
|
fallback := errors.Is(err, errFallback)
|
||||||
reconnect := errors.Is(err, errReconnect)
|
reconnect := errors.Is(err, errReconnect)
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.closeUDP()
|
s.closeUDP()
|
||||||
|
if s.conn == conn {
|
||||||
|
s.conn = nil
|
||||||
|
}
|
||||||
s.mic = nil
|
s.mic = nil
|
||||||
s.play = nil
|
s.play = nil
|
||||||
s.audioHeard = false
|
s.audioHeard = false
|
||||||
s.flushWaiting = false
|
s.flushWaiting = false
|
||||||
if fallback || reconnect {
|
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.roomBusy = false
|
||||||
s.remoteTalker = ""
|
s.remoteTalker = ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -79,9 +115,10 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if fallback {
|
if fallback {
|
||||||
major = 2
|
major = otherMajor(major)
|
||||||
|
holdMajor = true
|
||||||
} else {
|
} else {
|
||||||
major = 3
|
holdMajor = false
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -92,7 +129,7 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
fatal := s.fatal != ""
|
fatal := s.fatal != ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
major = 3
|
holdMajor = false
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -120,6 +157,8 @@ func (s *Station) wait(ctx context.Context, d time.Duration) bool {
|
|||||||
return false
|
return false
|
||||||
case <-timer.C():
|
case <-timer.C():
|
||||||
return true
|
return true
|
||||||
|
case <-s.apply:
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +302,37 @@ const (
|
|||||||
phChallenge
|
phChallenge
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16) error {
|
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{})
|
done := make(chan struct{})
|
||||||
var closeOnce sync.Once
|
var closeOnce sync.Once
|
||||||
closeConn := func() { closeOnce.Do(func() { conn.Close() }) }
|
closeConn := func() { closeOnce.Do(func() { conn.Close() }) }
|
||||||
@@ -380,7 +449,7 @@ func (s *Station) session(ctx context.Context, conn net.Conn, major uint16) erro
|
|||||||
if udpRx.Before(ex.limit) {
|
if udpRx.Before(ex.limit) {
|
||||||
ex.limit = udpRx
|
ex.limit = udpRx
|
||||||
}
|
}
|
||||||
err := s.onFrame(ctx, wire, major, tcpf, ex)
|
err := s.onFrame(ctx, wire, major, tcpf, ex, allowFallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -464,14 +533,14 @@ func (s *Station) onClock(now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange) error {
|
func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange, allowFallback bool) error {
|
||||||
switch f.typ {
|
switch f.typ {
|
||||||
case tcpHeartbeat:
|
case tcpHeartbeat:
|
||||||
return nil
|
return nil
|
||||||
case tcpError:
|
case tcpError:
|
||||||
return s.onReflectorError(f.body, major)
|
return s.onReflectorError(f.body, major, allowFallback)
|
||||||
case tcpProtoDown:
|
case tcpProtoDown:
|
||||||
return s.onDowngrade(f.body, major)
|
return s.onDowngrade(f.body, major, allowFallback)
|
||||||
}
|
}
|
||||||
if !ex.authed && f.typ >= 100 {
|
if !ex.authed && f.typ >= 100 {
|
||||||
return s.retry("Protocol error", nil)
|
return s.retry("Protocol error", nil)
|
||||||
@@ -482,25 +551,25 @@ func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f fra
|
|||||||
return s.onV3(ctx, wire, f, ex)
|
return s.onV3(ctx, wire, f, ex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) onReflectorError(body []byte, major uint16) error {
|
func (s *Station) onReflectorError(body []byte, major uint16, allowFallback bool) error {
|
||||||
msg, _, err := parseString(body)
|
msg, _, err := parseString(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.endFatalCause("could not be read", err, true)
|
s.endFatalCause("could not be read", err, true)
|
||||||
return fmt.Errorf("could not be read: %w", err)
|
return fmt.Errorf("could not be read: %w", err)
|
||||||
}
|
}
|
||||||
if major >= 3 && fallback20(msg) {
|
if allowFallback && wantsOther(major, msg) {
|
||||||
return errFallback
|
return errFallback
|
||||||
}
|
}
|
||||||
s.endFatal(msg, true)
|
s.endFatal(msg, true)
|
||||||
return errStopped
|
return errStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) onDowngrade(body []byte, major uint16) error {
|
func (s *Station) onDowngrade(body []byte, major uint16, allowFallback bool) error {
|
||||||
gotMajor, gotMinor, err := parseVersion(body)
|
gotMajor, gotMinor, err := parseVersion(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if major >= 3 && gotMajor == 2 {
|
if allowFallback && acceptsOther(major, gotMajor, gotMinor) {
|
||||||
return errFallback
|
return errFallback
|
||||||
}
|
}
|
||||||
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
|
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
|
||||||
@@ -721,7 +790,7 @@ func (s *Station) closeUDP() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) transmitting() bool {
|
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 false
|
||||||
}
|
}
|
||||||
return s.buttonHeld || s.spaceHeld
|
return s.buttonHeld || s.spaceHeld
|
||||||
@@ -731,6 +800,8 @@ func (s *Station) publish() {
|
|||||||
s.noteTransmitLocked()
|
s.noteTransmitLocked()
|
||||||
s.state.Callsign = s.cfg.callsign
|
s.state.Callsign = s.cfg.callsign
|
||||||
s.state.TalkGroup = s.cfg.talkGroup
|
s.state.TalkGroup = s.cfg.talkGroup
|
||||||
|
s.state.NeedsConfig = !s.loaded
|
||||||
|
s.state.ConfigOpen = s.configOpen
|
||||||
if s.fatal != "" {
|
if s.fatal != "" {
|
||||||
s.state.Connection = "Disconnected"
|
s.state.Connection = "Disconnected"
|
||||||
s.state.Detail = s.fatal
|
s.state.Detail = s.fatal
|
||||||
@@ -764,6 +835,11 @@ func (s *Station) publish() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if s.configOpen && s.configErr != "" {
|
||||||
|
s.state.Detail = s.configErr
|
||||||
|
} else if s.openBlocked != "" {
|
||||||
|
s.state.Detail = s.openBlocked
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case s.wake <- struct{}{}:
|
case s.wake <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -36,14 +36,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
|||||||
|
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
|
||||||
t.Fatalf("first frame type %d body %v, want protocol 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) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("fallback frame type %d body %v, want protocol 2.0", typ, body)
|
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
|
|||||||
+62
-17
@@ -27,12 +27,25 @@ type State struct {
|
|||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Talker string `json:"talker"`
|
Talker string `json:"talker"`
|
||||||
Transmitting bool `json:"transmitting"`
|
Transmitting bool `json:"transmitting"`
|
||||||
|
NeedsConfig bool `json:"needsConfig"`
|
||||||
|
ConfigOpen bool `json:"configOpen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Station is the seam tests and the window use.
|
// Station is the seam tests and the window use.
|
||||||
type Station struct {
|
type Station struct {
|
||||||
cfg loaded
|
cfg loaded
|
||||||
clock Clock
|
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
|
mu sync.Mutex
|
||||||
state State
|
state State
|
||||||
@@ -76,38 +89,55 @@ type Station struct {
|
|||||||
talkStart time.Time
|
talkStart time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
|
// New loads the config at opt.ConfigPath. A missing or invalid file does not
|
||||||
// and New returns an error so the caller can exit before opening a window.
|
// 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) {
|
func New(opt Options) (*Station, error) {
|
||||||
cfg, err := loadConfig(opt.ConfigPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
clock := opt.Clock
|
clock := opt.Clock
|
||||||
if clock == nil {
|
if clock == nil {
|
||||||
clock = wallClock{}
|
clock = wallClock{}
|
||||||
}
|
}
|
||||||
|
out, err := loadConfig(opt.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
detail := "Idle"
|
detail := "Idle"
|
||||||
if cfg.certFatal != "" {
|
if out.detail != "" {
|
||||||
detail = cfg.certFatal
|
detail = out.detail
|
||||||
} else if cfg.logNotice != "" {
|
}
|
||||||
detail = cfg.logNotice
|
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{
|
s := &Station{
|
||||||
cfg: cfg,
|
cfg: out.cfg,
|
||||||
clock: clock,
|
clock: clock,
|
||||||
|
path: opt.ConfigPath,
|
||||||
|
loaded: out.loaded,
|
||||||
|
configOpen: !out.loaded,
|
||||||
|
configErr: errText,
|
||||||
|
form: out.form,
|
||||||
wake: make(chan struct{}, 1),
|
wake: make(chan struct{}, 1),
|
||||||
logPath: cfg.logPath,
|
apply: make(chan struct{}, 1),
|
||||||
logNotice: cfg.logNotice,
|
logPath: out.cfg.logPath,
|
||||||
fatal: cfg.certFatal,
|
logNotice: out.cfg.logNotice,
|
||||||
|
fatal: out.cfg.certFatal,
|
||||||
state: State{
|
state: State{
|
||||||
Connection: "Disconnected",
|
Connection: "Disconnected",
|
||||||
Detail: detail,
|
Detail: detail,
|
||||||
Callsign: cfg.callsign,
|
Callsign: call,
|
||||||
TalkGroup: cfg.talkGroup,
|
TalkGroup: tg,
|
||||||
|
NeedsConfig: !out.loaded,
|
||||||
|
ConfigOpen: !out.loaded,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if cfg.enableLogging {
|
if out.loaded && out.cfg.enableLogging {
|
||||||
s.openLog()
|
s.openLog()
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -120,6 +150,18 @@ func (s *Station) State() State {
|
|||||||
return s.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.
|
// Wait blocks until pred is true or ctx ends.
|
||||||
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
|
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -169,6 +211,9 @@ func (s *Station) afterHold(was bool) {
|
|||||||
if was && !s.transmitting() {
|
if was && !s.transmitting() {
|
||||||
s.endTransmit()
|
s.endTransmit()
|
||||||
}
|
}
|
||||||
|
if !s.buttonHeld && !s.spaceHeld {
|
||||||
|
s.openBlocked = ""
|
||||||
|
}
|
||||||
s.publish()
|
s.publish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ TZ=Europe/Berlin
|
|||||||
LISTEN_PORT=5300
|
LISTEN_PORT=5300
|
||||||
|
|
||||||
# 0 = local lab only (default). 1 = append config/svxreflector.link.conf
|
# 0 = local lab only (default). 1 = append config/svxreflector.link.conf
|
||||||
# (Satellite/Trunk from DJ1JAY). Satellite dials out — no public DNS needed.
|
# (GeuReflector Satellite/Trunk). Satellite dials out — no public DNS needed.
|
||||||
ENABLE_FM_LINK=0
|
ENABLE_FM_LINK=0
|
||||||
|
|
||||||
SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf
|
SVXREFLECTOR_CONF=/etc/svxlink/svxreflector.conf
|
||||||
|
|||||||
+13
-11
@@ -1,6 +1,7 @@
|
|||||||
# Build jayReflector (DJ1JAY / FM-Funknetz) for the Funknetz local lab.
|
# Build GeuReflector (IW1GEU) for the Funknetz local lab.
|
||||||
# Binary name remains svxreflector; Trunk/Satellite are opt-in via config.
|
# Binary name remains svxreflector; Trunk/Satellite are opt-in via config.
|
||||||
# Upstream pattern: dj1jay/jayReflector Dockerfile (clone + cmake, not stock sm0svx).
|
# Pin: audric/GeuReflector dbad88e8c3e0f1adb8342db190bb0b3ad00106f4
|
||||||
|
# (master, 2026-07-16; release line v1.3.17).
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
# STAGE 1 — BUILD
|
# STAGE 1 — BUILD
|
||||||
@@ -13,27 +14,27 @@ RUN apt-get update && apt-get -y install --no-install-recommends \
|
|||||||
build-essential cmake \
|
build-essential cmake \
|
||||||
libsigc++-2.0-dev libssl-dev libjsoncpp-dev libpopt-dev \
|
libsigc++-2.0-dev libssl-dev libjsoncpp-dev libpopt-dev \
|
||||||
libopus-dev libgsm1-dev \
|
libopus-dev libgsm1-dev \
|
||||||
|
libmosquitto-dev libhiredis-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
ARG GIT_URL=https://github.com/dj1jay/jayReflector.git
|
ARG GIT_URL=https://github.com/audric/GeuReflector.git
|
||||||
ARG GIT_BRANCH=main
|
ARG GIT_BRANCH=master
|
||||||
ARG GIT_REF=
|
ARG GIT_REF=
|
||||||
ARG NUM_CORES=4
|
ARG NUM_CORES=4
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
if [ -n "${GIT_REF}" ]; then \
|
if [ -n "${GIT_REF}" ]; then \
|
||||||
git clone --recursive "${GIT_URL}" jayReflector; \
|
git clone --recursive "${GIT_URL}" GeuReflector; \
|
||||||
cd jayReflector && git checkout "${GIT_REF}"; \
|
cd GeuReflector && git checkout "${GIT_REF}"; \
|
||||||
else \
|
else \
|
||||||
git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" jayReflector; \
|
git clone --recursive --depth 1 --branch "${GIT_BRANCH}" "${GIT_URL}" GeuReflector; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
WORKDIR /build/jayReflector
|
WORKDIR /build/GeuReflector
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
cmake -S src -B build \
|
cmake -S src -B build \
|
||||||
-DLOCAL_STATE_DIR=/var \
|
-DLOCAL_STATE_DIR=/var \
|
||||||
-DUSE_QT=OFF \
|
|
||||||
-DUSE_ALSA=OFF \
|
-DUSE_ALSA=OFF \
|
||||||
-DCMAKE_BUILD_TYPE=Release; \
|
-DCMAKE_BUILD_TYPE=Release; \
|
||||||
cmake --build build --target svxreflector -j"${NUM_CORES}"
|
cmake --build build --target svxreflector -j"${NUM_CORES}"
|
||||||
@@ -48,10 +49,11 @@ RUN apt-get update && apt-get -y install --no-install-recommends \
|
|||||||
ca-certificates openssl \
|
ca-certificates openssl \
|
||||||
libsigc++-2.0-0v5 libssl3 libjsoncpp25 libpopt0 \
|
libsigc++-2.0-0v5 libssl3 libjsoncpp25 libpopt0 \
|
||||||
libopus0 libgsm1 \
|
libopus0 libgsm1 \
|
||||||
|
libmosquitto1 libhiredis0.14 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /build/jayReflector/build/bin/svxreflector /usr/bin/svxreflector
|
COPY --from=builder /build/GeuReflector/build/bin/svxreflector /usr/bin/svxreflector
|
||||||
COPY --from=builder /build/jayReflector/build/lib/lib*.so* /usr/lib/
|
COPY --from=builder /build/GeuReflector/build/lib/lib*.so* /usr/lib/
|
||||||
|
|
||||||
RUN mkdir -p /etc/svxlink /etc/svxlink/pki /var/log/svxlink /var/lib/svxlink && \
|
RUN mkdir -p /etc/svxlink /etc/svxlink/pki /var/log/svxlink /var/lib/svxlink && \
|
||||||
ldconfig && \
|
ldconfig && \
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
###################################################################
|
###################################################################
|
||||||
# Local lab jayReflector for Funknetz (protocol 2.0 / ReflectorV2)
|
# Local lab GeuReflector for Funknetz (protocol 2.0 / ReflectorV2)
|
||||||
# Secrets here are placeholders — do not reuse production keys.
|
# Secrets here are placeholders — do not reuse production keys.
|
||||||
#
|
#
|
||||||
# Standalone by default: local clients only. FM-Funknetz Satellite/Trunk
|
# Standalone by default: local clients only. FM-Funknetz Satellite/Trunk
|
||||||
@@ -17,8 +17,8 @@ CERT_PKI_DIR=/etc/svxlink/pki/
|
|||||||
# Optional status JSON (publish 8080 in compose if you enable this):
|
# Optional status JSON (publish 8080 in compose if you enable this):
|
||||||
# HTTP_SRV_PORT=8080
|
# HTTP_SRV_PORT=8080
|
||||||
|
|
||||||
# jayReflector / modern SvxReflector bootstraps a local PKI even for
|
# GeuReflector bootstraps a local PKI even for protocol 2.0 AUTH_KEY
|
||||||
# protocol 2.0 AUTH_KEY clients. COMMON_NAME is required on first start.
|
# clients. COMMON_NAME is required on first start.
|
||||||
[SERVER_CERT]
|
[SERVER_CERT]
|
||||||
COMMON_NAME=funknetz-lab.local
|
COMMON_NAME=funknetz-lab.local
|
||||||
SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1
|
SUBJECT_ALT_NAME=DNS:funknetz-lab.local,DNS:localhost,IP:127.0.0.1
|
||||||
|
|||||||
@@ -1,33 +1,43 @@
|
|||||||
###################################################################
|
###################################################################
|
||||||
# Optional FM-Funknetz link stub (jayReflector)
|
# Optional FM-Funknetz link stub (GeuReflector)
|
||||||
#
|
#
|
||||||
# Default: leave ENABLE_FM_LINK=0 in .env — this file is ignored.
|
# Default: leave ENABLE_FM_LINK=0 in .env — this file is ignored.
|
||||||
# When DJ1JAY provides values:
|
# When the parent operator provides values:
|
||||||
# 1. Fill ONE of the blocks below (prefer Satellite for home/NAT labs).
|
# 1. Uncomment ONE of the blocks below (prefer Satellite for home/NAT labs).
|
||||||
# 2. Set ENABLE_FM_LINK=1 in .env
|
# 2. Set ENABLE_FM_LINK=1 in .env
|
||||||
# 3. docker compose up -d
|
# 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
|
# Satellite (recommended): this reflector dials OUT to the parent on
|
||||||
# TCP 5303. No public DNS, no inbound peer port, no port-forward required
|
# TCP 5303. No public DNS, no inbound peer port, no port-forward required
|
||||||
# for the link. Local Funknetz clients still use host :5300 only.
|
# 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
|
# Trunk: equal mesh peer; needs mutual reachability on TCP 5302 and a
|
||||||
# matching [TRUNK_x] section name + SECRET on both sides.
|
# matching [TRUNK_x] section name + SECRET on both sides.
|
||||||
|
# Do not enable Satellite and Trunk in the same file.
|
||||||
#
|
#
|
||||||
# Do not commit real secrets. Prefer a local override or keep secrets
|
# Do not commit real secrets.
|
||||||
# only in your private .env workflow if you copy this file.
|
# Docs: docs/research/2026-10-11-geureflector-lab-baseline.md
|
||||||
# Docs: docs/research/2026-10-07-jayreflector-fm-funknetz.md
|
# README: https://github.com/audric/GeuReflector#satellite-reflectors
|
||||||
# README: https://github.com/dj1jay/jayReflector
|
|
||||||
###################################################################
|
###################################################################
|
||||||
|
|
||||||
# --- Satellite (fill from DJ1JAY; uncomment to use) ---
|
# --- Satellite (uncomment the section and keys to use) ---
|
||||||
# SATELLITE_OF=parent.example.org:5303
|
# [GLOBAL]
|
||||||
|
# SATELLITE_OF=parent.example.org
|
||||||
|
# SATELLITE_PORT=5303
|
||||||
# SATELLITE_ID=lab-yourcall
|
# SATELLITE_ID=lab-yourcall
|
||||||
# SATELLITE_SECRET="replace-with-secret-from-dj1jay"
|
# SATELLITE_SECRET="replace-with-secret-from-operator"
|
||||||
# Only these TGs cross to/from the parent (bidirectional). Omit for all TGs.
|
# 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
|
# SATELLITE_FILTER=2621,2629,20
|
||||||
|
|
||||||
# --- Trunk peer alternative (fill from DJ1JAY; usually leave commented) ---
|
# --- Trunk peer alternative (uncomment to use; leave Satellite commented) ---
|
||||||
|
# [GLOBAL]
|
||||||
# LOCAL_PREFIX=9
|
# LOCAL_PREFIX=9
|
||||||
# TRUNK_LISTEN_PORT=5302
|
# TRUNK_LISTEN_PORT=5302
|
||||||
#
|
#
|
||||||
@@ -38,4 +48,5 @@
|
|||||||
# REMOTE_PREFIX=262
|
# REMOTE_PREFIX=262
|
||||||
# ALLOW_TGS=2621,2629,20
|
# ALLOW_TGS=2621,2629,20
|
||||||
# # TG_MAP=1:2624100
|
# # TG_MAP=1:2624100
|
||||||
# MQTT_NAME=Lab-YourCall
|
# MQTT stays off. If enabled later, MQTT_NAME belongs in a [MQTT] section,
|
||||||
|
# not under [TRUNK_x].
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Local jayReflector lab for Funknetz.
|
# Local GeuReflector lab for Funknetz.
|
||||||
# Build once, then: docker compose up -d --build
|
# Build once, then: docker compose up -d --build
|
||||||
# Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*).
|
# Connect the app with host/port/callsign/authKey from .env (see FUNKNETZ_*).
|
||||||
#
|
#
|
||||||
# FM-Funknetz link (optional): fill config/svxreflector.link.conf from DJ1JAY,
|
# FM-Funknetz link (optional): fill config/svxreflector.link.conf,
|
||||||
# set ENABLE_FM_LINK=1, recreate. Satellite mode is outbound-only (no DNS/inbound).
|
# set ENABLE_FM_LINK=1, recreate. Satellite mode is outbound-only (no DNS/inbound).
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -11,11 +11,11 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
GIT_URL: https://github.com/dj1jay/jayReflector.git
|
GIT_URL: https://github.com/audric/GeuReflector.git
|
||||||
GIT_BRANCH: main
|
GIT_BRANCH: master
|
||||||
# Optional pin (overrides branch clone when set), e.g.:
|
# Pin (overrides the branch clone). master on 2026-10-11; release line v1.3.17.
|
||||||
# GIT_REF: 50997ed21a37c7c05c132eca5ebb07bab4b80fc7
|
GIT_REF: dbad88e8c3e0f1adb8342db190bb0b3ad00106f4
|
||||||
image: funknetz-jayreflector:local
|
image: funknetz-geureflector:local
|
||||||
container_name: funknetz-svxreflector
|
container_name: funknetz-svxreflector
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ stop_all() {
|
|||||||
|
|
||||||
trap 'stop_all; exit 0' INT TERM
|
trap 'stop_all; exit 0' INT TERM
|
||||||
|
|
||||||
command -v svxreflector >/dev/null 2>&1 || die "svxreflector binary not found (jayReflector build)"
|
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)"
|
[ -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
|
# Local-only by default. When ENABLE_FM_LINK=1, append the link stub
|
||||||
# (Satellite or Trunk keys from DJ1JAY) after the base lab conf.
|
# (GeuReflector Satellite or Trunk keys) after the base lab conf.
|
||||||
if [ "$ENABLE_FM_LINK" = "1" ]; then
|
if [ "$ENABLE_FM_LINK" = "1" ]; then
|
||||||
[ -f "$SVXREFLECTOR_LINK_CONF" ] || die "ENABLE_FM_LINK=1 but link config missing: $SVXREFLECTOR_LINK_CONF"
|
[ -f "$SVXREFLECTOR_LINK_CONF" ] || die "ENABLE_FM_LINK=1 but link config missing: $SVXREFLECTOR_LINK_CONF"
|
||||||
{
|
{
|
||||||
@@ -82,7 +82,7 @@ mkdir -p /etc/svxlink/pki /var/lib/svxlink/pki
|
|||||||
|
|
||||||
start_bg "svxreflector" svxreflector --config "$conf_path" $SVXREFLECTOR_ARGS
|
start_bg "svxreflector" svxreflector --config "$conf_path" $SVXREFLECTOR_ARGS
|
||||||
|
|
||||||
log "jayReflector (svxreflector) is running. Waiting…"
|
log "GeuReflector (svxreflector) is running. Waiting…"
|
||||||
|
|
||||||
exit_code=0
|
exit_code=0
|
||||||
for pid in $pids; do
|
for pid in $pids; do
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ cat >"$GENERATED" <<EOF
|
|||||||
"callsign": $call_json,
|
"callsign": $call_json,
|
||||||
"authKey": $key_json,
|
"authKey": $key_json,
|
||||||
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
||||||
|
"protocol": "v2",
|
||||||
"enableLogging": true,
|
"enableLogging": true,
|
||||||
"logFile": $log_json
|
"logFile": $log_json
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user