Compare commits

..
1 Commits
Author SHA1 Message Date
konradandCursor 2c621d4b7f Let the config choose protocol 2 or 3, and fall back for one session only.
The default stays v2. A reflector that refuses that version and names the other gets one session of it, then the station announces the configured protocol again.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 16:28:42 +02:00
21 changed files with 361 additions and 82 deletions
+5 -5
View File
@@ -45,17 +45,17 @@ The reflector’s certificate authority, kept in the certificate directory. Prot
_Avoid_: the station certificate
**Protocol**:
The reflector session this station is speaking: 2.0 or 3.0.
_Avoid_: TYPE, a config field the operator sets
The reflector session this station speaks: 2.0 or 3.0. The config chooses v2 or v3; v2 is the default.
_Avoid_: TYPE
**Push-to-talk**:
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
_Avoid_: voice-operated transmit, a mute toggle
**Config**:
The JSON file that names how this station joins a reflector (host, port, callsign, auth key, talk group, certificate directory, logging). Default path `~/.funknetz.json`; `-c` may select another file.
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 is a modal over the station UI, not a separate OS window.
_Avoid_: settings window, preferences dialog, a raw JSON editor as the primary UI
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
+2 -2
View File
@@ -248,7 +248,7 @@ func TestExtraWordsDoNotChangeTheSelectedFile(t *testing.T) {
func TestSelectedFileIsLeftByteForByte(t *testing.T) {
path := filepath.Join(t.TempDir(), "lab.json")
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"./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\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err)
}
@@ -308,7 +308,7 @@ func TestCancelWithoutALoadedConfigQuits(t *testing.T) {
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,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
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)
}
@@ -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
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.
+3
View File
@@ -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
View File
@@ -45,6 +45,7 @@ Manual lab JSON (values match `.env` / the mounted conf; secrets are local-test
"callsign": "DL0FN-1",
"authKey": "local-lab-secret-change-me",
"talkGroup": 9,
"protocol": "v2",
"enableLogging": false,
"logFile": "/tmp/funknetz.log"
}
+35 -7
View File
@@ -1,12 +1,25 @@
html,
body {
height: 100%;
}
body {
margin: 0;
overflow: hidden;
background: #101418;
color: #f2f4f6;
font: 16px/1.4 system-ui, sans-serif;
}
main {
box-sizing: border-box;
height: 100%;
padding: 24px;
overflow: auto;
}
body.config-open main {
display: none;
}
.status {
@@ -66,14 +79,28 @@ main {
}
#config {
position: fixed;
top: 64px;
right: 0;
bottom: 0;
left: 0;
box-sizing: border-box;
height: 100%;
padding: 16px;
background: rgba(16, 20, 24, 0.45);
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] {
@@ -106,7 +133,8 @@ main {
font-size: 14px;
}
#config input {
#config input,
#config select {
box-sizing: border-box;
display: block;
width: 100%;
+35 -8
View File
@@ -5,7 +5,6 @@ const configError = document.getElementById("config-error");
const authKey = document.getElementById("authKey");
const authToggle = document.getElementById("auth-toggle");
const configHeight = 680;
let savedSize = null;
let configLoading = false;
let sizeToken = 0;
@@ -31,18 +30,39 @@ function runtime() {
return window.runtime || null;
}
function growWindow() {
function fitConfigWindow() {
const rt = runtime();
if (!rt || !rt.WindowGetSize || !rt.WindowSetSize) {
return;
}
const token = ++sizeToken;
rt.WindowGetSize().then(function (size) {
requestAnimationFrame(function () {
if (token !== sizeToken || config.hidden) {
return;
}
savedSize = size;
rt.WindowSetSize(Math.max(size.w, 420), Math.max(size.h, configHeight));
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);
}
});
});
});
}
@@ -60,6 +80,9 @@ function restoreWindow() {
function showError(message) {
configError.hidden = !message;
configError.textContent = message || "";
if (!config.hidden) {
fitConfigWindow();
}
}
function fillForm(form) {
@@ -68,6 +91,7 @@ function fillForm(form) {
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 || "";
@@ -82,6 +106,7 @@ function readForm() {
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
@@ -111,9 +136,9 @@ function showConfig() {
return;
}
fillForm(cfg.form || {});
showError(cfg.error);
config.hidden = false;
growWindow();
document.body.classList.add("config-open");
showError(cfg.error);
}).catch(function () {}).then(function () {
configLoading = false;
});
@@ -121,7 +146,9 @@ function showConfig() {
function hideConfig() {
config.hidden = true;
showError("");
document.body.classList.remove("config-open");
configError.hidden = true;
configError.textContent = "";
restoreWindow();
}
+6
View File
@@ -31,6 +31,12 @@
</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>
+3 -1
View File
@@ -25,7 +25,9 @@ func main() {
err = wails.Run(&options.App{
Title: "funknetz",
Width: 420,
Height: 320,
Height: 480,
MinWidth: 420,
MinHeight: 480,
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
+12 -4
View File
@@ -49,7 +49,7 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
challenge := make([]byte, 20)
for i := range challenge {
challenge[i] = byte(i + 1)
@@ -98,6 +98,14 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id, path: path}
}
func expectVersion(t *testing.T, refl *reflector, major, minor uint16) {
t.Helper()
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, major, minor) {
t.Fatalf("version type %d body %v, want %d.%d", typ, body, major, minor)
}
}
func declineTo2(t *testing.T, refl *reflector) {
t.Helper()
typ, body := refl.readFrame(t)
@@ -163,8 +171,8 @@ func TestDropRetriesUntilQuit(t *testing.T) {
in.clock.Advance(2 * time.Second)
in.refl.accept(t)
typ, body := in.refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry protocol type %d body %v, want 2.0", typ, body)
}
in.cancel()
in.clock.Advance(2 * time.Second)
@@ -281,7 +289,7 @@ func TestMissingOpusStopsRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
challenge := make([]byte, 20)
refl.writeFrame(t, 10, appendBytes(nil, challenge))
refl.readFrame(t)
+72 -6
View File
@@ -20,6 +20,7 @@ type loaded struct {
callsign string
authKey string
talkGroup int
protocolMajor uint16
certificateDir string
certFatal string
enableLogging bool
@@ -33,6 +34,7 @@ type fileShape struct {
Callsign jsontext.Value `json:"callsign"`
AuthKey jsontext.Value `json:"authKey"`
TalkGroup jsontext.Value `json:"talkGroup"`
Protocol jsontext.Value `json:"protocol"`
CertificateDir jsontext.Value `json:"certificateDir"`
EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
@@ -44,6 +46,7 @@ type templateFile struct {
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"`
@@ -76,13 +79,14 @@ func loadConfig(path string) (configLoad, error) {
return parseConfig(path, body, true)
}
// ConfigForm is the eight fields the Config window edits.
// 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"`
@@ -101,7 +105,7 @@ func templateForm() (ConfigForm, error) {
if err != nil {
return ConfigForm{}, err
}
return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil
return ConfigForm{Protocol: "v2", CertificateDir: "./funknetz", LogFile: logFile}, nil
}
func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
@@ -114,7 +118,8 @@ func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
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
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)
if err != nil {
return refused(shape, err)
@@ -125,7 +130,7 @@ func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
}
return finish(shape, applyCertificate(cfg, shape, path))
}
if missingCertificate(shape) || missingLog(shape) {
if missingCertificate(shape) || missingLog(shape) || missingProtocol(shape) {
if !operationalKindsOK(shape) {
_, err := parseOperational(shape)
if err != nil {
@@ -165,7 +170,7 @@ func refused(shape fileShape, cause error) (configLoad, error) {
func formFromShape(shape fileShape) ConfigForm {
fallback, err := templateForm()
if err != nil {
fallback = ConfigForm{CertificateDir: "./funknetz"}
fallback = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
}
return mergeSalvage(shape, fallback)
}
@@ -192,6 +197,11 @@ func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
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
}
@@ -225,6 +235,10 @@ func parseOperational(shape fileShape) (loaded, error) {
if err != nil {
return loaded{}, err
}
protocolMajor, err := protocolField(shape.Protocol)
if err != nil {
return loaded{}, err
}
host = strings.TrimSpace(host)
if host == "" {
@@ -237,7 +251,45 @@ func parseOperational(shape fileShape) (loaded, error) {
if !callsignPattern.MatchString(call) {
return loaded{}, fmt.Errorf("callsign is invalid")
}
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg}, nil
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg, protocolMajor: protocolMajor}, nil
}
func protocolField(v jsontext.Value) (uint16, error) {
if !present(v) {
return 2, nil
}
s, err := stringField("protocol", v)
if err != nil {
return 0, err
}
if strings.TrimSpace(s) == "" {
return 2, nil
}
if _, ok := protocolLabel(s); !ok {
return 0, fmt.Errorf("protocol is invalid")
}
if strings.TrimSpace(s) == "v3" {
return 3, nil
}
return 2, nil
}
func protocolLabel(s string) (string, bool) {
switch strings.TrimSpace(s) {
case "v2":
return "v2", true
case "v3":
return "v3", true
default:
return "", false
}
}
func protocolName(major uint16) string {
if major == 3 {
return "v3"
}
return "v2"
}
func stringField(name string, v jsontext.Value) (string, error) {
@@ -306,6 +358,14 @@ func missingCertificate(shape fileShape) bool {
return !present(shape.CertificateDir)
}
func missingProtocol(shape fileShape) bool {
if !present(shape.Protocol) {
return true
}
s, err := stringField("protocol", shape.Protocol)
return err == nil && strings.TrimSpace(s) == ""
}
func operationalKindsOK(shape fileShape) bool {
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
return false
@@ -322,6 +382,9 @@ func operationalKindsOK(shape fileShape) bool {
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
return false
}
if present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString {
return false
}
return true
}
@@ -348,6 +411,9 @@ func withLogDefaults(shape fileShape) (fileShape, error) {
shape.Callsign = rawOr(shape.Callsign, `""`)
shape.AuthKey = rawOr(shape.AuthKey, `""`)
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
if missingProtocol(shape) {
shape.Protocol = jsontext.Value(`"v2"`)
}
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
if !present(shape.LogFile) {
+27 -10
View File
@@ -49,6 +49,9 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
if form.EnableLogging {
t.Fatal("EnableLogging = true, want false")
}
if form.Protocol != "v2" {
t.Fatalf("Protocol = %q, want v2", form.Protocol)
}
if form.CertificateDir != "./funknetz" {
t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir)
}
@@ -59,7 +62,7 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
func TestExistingConfigIsNotRewritten(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json")
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"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 {
t.Fatal(err)
}
@@ -111,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 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: "protocol type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":2}`, want: "protocol has the wrong type"},
{name: "protocol value", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":"v1"}`, want: "protocol is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -281,6 +286,9 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
if got["logFile"] != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v", got["logFile"])
}
if got["protocol"] != "v2" {
t.Fatalf("protocol = %#v, want v2", got["protocol"])
}
if got["certificateDir"] != "./funknetz" {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
}
@@ -359,9 +367,9 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
detail string
same bool
}{
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"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: "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: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
@@ -420,7 +428,7 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
func assertKeyOrder(t *testing.T, body []byte) {
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
for _, key := range keys {
i := strings.Index(string(body), key)
@@ -533,7 +541,7 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("{\"host\":\"127.0.0.1\",\"port\":%d,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":%q}\n", port, logFile)
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)
}
@@ -570,7 +578,7 @@ func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
if err := jsonv2.Unmarshal(body, &got); err != nil {
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)
}
if strings.Contains(string(body), t.TempDir()) {
@@ -597,7 +605,7 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"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")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
@@ -635,13 +643,22 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
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,
"port": port,
"callsign": callsign,
"authKey": authKey,
"talkGroup": talkGroup,
})
}
if protocol != "" {
fields["protocol"] = protocol
}
body, err := jsonv2.Marshal(fields)
if err != nil {
t.Fatal(err)
}
+10 -2
View File
@@ -139,7 +139,8 @@ func (s *Station) applyLogging(prev, next loaded) {
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.certificateDir != next.certificateDir
prev.authKey != next.authKey || prev.talkGroup != next.talkGroup || prev.protocolMajor != next.protocolMajor ||
prev.certificateDir != next.certificateDir
}
func readForm(path string) (ConfigForm, string, bool) {
@@ -147,7 +148,7 @@ func readForm(path string) (ConfigForm, string, bool) {
if err != nil {
form, ferr := templateForm()
if ferr != nil {
form = ConfigForm{CertificateDir: "./funknetz"}
form = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
}
return form, fmt.Errorf("the file could not be read: %w", err).Error(), false
}
@@ -189,6 +190,7 @@ func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error
Callsign: cfg.callsign,
AuthKey: cfg.authKey,
TalkGroup: cfg.talkGroup,
Protocol: protocolName(cfg.protocolMajor),
CertificateDir: strings.TrimSpace(form.CertificateDir),
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
@@ -217,6 +219,10 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
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
@@ -235,6 +241,7 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
Callsign: call,
AuthKey: key,
TalkGroup: tg,
Protocol: protocol,
CertificateDir: dir,
EnableLogging: en,
LogFile: logFile,
@@ -256,6 +263,7 @@ func writeForm(path string, form ConfigForm) error {
Callsign: form.Callsign,
AuthKey: form.AuthKey,
TalkGroup: form.TalkGroup,
Protocol: form.Protocol,
CertificateDir: form.CertificateDir,
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
+3 -2
View File
@@ -118,7 +118,7 @@ func TestSaveRewritesAndReconnects(t *testing.T) {
assertKeyOrder(t, body)
in.refl.accept(t)
declineTo2(t, in.refl)
expectVersion(t, in.refl, 2, 0)
challenge := make([]byte, 20)
for i := range challenge {
challenge[i] = byte(i + 1)
@@ -439,6 +439,7 @@ func TestEachConnectionFieldReconnects(t *testing.T) {
{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 {
@@ -488,7 +489,7 @@ func TestEachConnectionFieldReconnects(t *testing.T) {
}
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) + ",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n"
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 {
+31
View File
@@ -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 atLeastVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. Must be at least (\d+)\.(\d+)\.$`)
// wantsOther reports that the reflector refused the announced major and named the other one.
func wantsOther(announced uint16, msg string) bool {
if announced >= 3 {
return fallback20(msg)
}
return fallback30(msg)
}
// acceptsOther reports that a downgrade names the protocol this station did not announce.
func acceptsOther(announced, gotMajor, gotMinor uint16) bool {
if announced >= 3 {
return gotMajor == 2
}
return gotMajor == 3 && gotMinor == 0
}
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
func fallback20(msg string) bool {
@@ -209,6 +226,20 @@ func fallback20(msg string) bool {
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
}
// fallback30 reports that the reflector's minimum is exactly 3.0.
func fallback30(msg string) bool {
m := atLeastVersion.FindStringSubmatch(msg)
if m == nil {
return false
}
major, err1 := strconv.Atoi(m[1])
minor, err2 := strconv.Atoi(m[2])
if err1 != nil || err2 != nil {
return false
}
return major == 3 && minor == 0
}
func versionBelow(major, minor, maj, min int) bool {
if major != maj {
return major < maj
+9 -9
View File
@@ -125,7 +125,7 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
for step := 0; step < 6; step++ {
clock.BlockUntilWaiting(t)
clock.Advance(10 * time.Second)
@@ -144,8 +144,8 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
}
@@ -164,7 +164,7 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
payload := make([]byte, 65)
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 65)
@@ -178,8 +178,8 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
}
@@ -198,7 +198,7 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
if err != nil {
@@ -211,8 +211,8 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
if st.State().Detail != "Protocol error" {
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
+2 -1
View File
@@ -41,6 +41,7 @@ func configWithDir(t testing.TB, host string, port int, callsign, authKey string
"callsign": callsign,
"authKey": authKey,
"talkGroup": talkGroup,
"protocol": "v3",
"certificateDir": certDir,
"enableLogging": false,
"logFile": logPath,
@@ -509,7 +510,7 @@ func TestCADigestMismatchIsLoggedAndKept(t *testing.T) {
}
body, err := jsonv2.Marshal(map[string]any{
"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 {
t.Fatal(err)
+65 -3
View File
@@ -17,7 +17,7 @@ func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
} {
t.Run(msg, func(t *testing.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()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -51,7 +51,7 @@ func TestVersionErrorBelow2Stops(t *testing.T) {
} {
t.Run(msg, func(t *testing.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()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -93,7 +93,7 @@ func TestDowngradeOtherThan2Stops(t *testing.T) {
} {
t.Run(tc.want, func(t *testing.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()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -150,3 +150,65 @@ func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
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")
}
}
+36 -15
View File
@@ -30,11 +30,15 @@ var (
// Run keeps the reflector session until ctx is canceled.
func (s *Station) Run(ctx context.Context) error {
defer s.Close()
major := uint16(3)
var major uint16
holdMajor := false
for {
if ctx.Err() != nil {
return nil
}
if !holdMajor {
major = s.configuredMajor()
}
s.mu.Lock()
if s.fatal != "" || !s.loaded {
s.mu.Unlock()
@@ -61,6 +65,7 @@ func (s *Station) Run(ctx context.Context) error {
stale := s.epoch != epoch
s.mu.Unlock()
if stale {
holdMajor = false
if conn != nil {
// This dial used the config from before Save.
_ = conn.Close()
@@ -72,7 +77,6 @@ func (s *Station) Run(ctx context.Context) error {
if ctx.Err() != nil {
return nil
}
major = 3
s.mu.Lock()
s.noteDownLocked(s.clock.Now(), true)
s.mu.Unlock()
@@ -82,7 +86,7 @@ func (s *Station) Run(ctx context.Context) error {
continue
}
err = s.session(ctx, conn, major, epoch)
err = s.session(ctx, conn, major, epoch, major == s.configuredMajor())
conn.Close()
fallback := errors.Is(err, errFallback)
reconnect := errors.Is(err, errReconnect)
@@ -111,9 +115,10 @@ func (s *Station) Run(ctx context.Context) error {
return nil
}
if fallback {
major = 2
major = otherMajor(major)
holdMajor = true
} else {
major = 3
holdMajor = false
}
continue
}
@@ -124,7 +129,7 @@ func (s *Station) Run(ctx context.Context) error {
}
fatal := s.fatal != ""
s.mu.Unlock()
major = 3
holdMajor = false
if ctx.Err() != nil {
return nil
}
@@ -297,7 +302,23 @@ const (
phChallenge
)
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoch uint64) 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
@@ -428,7 +449,7 @@ func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoc
if udpRx.Before(ex.limit) {
ex.limit = udpRx
}
err := s.onFrame(ctx, wire, major, tcpf, ex)
err := s.onFrame(ctx, wire, major, tcpf, ex, allowFallback)
if err != nil {
return err
}
@@ -512,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 {
case tcpHeartbeat:
return nil
case tcpError:
return s.onReflectorError(f.body, major)
return s.onReflectorError(f.body, major, allowFallback)
case tcpProtoDown:
return s.onDowngrade(f.body, major)
return s.onDowngrade(f.body, major, allowFallback)
}
if !ex.authed && f.typ >= 100 {
return s.retry("Protocol error", nil)
@@ -530,25 +551,25 @@ func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f fra
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)
if err != nil {
s.endFatalCause("could not be read", err, true)
return fmt.Errorf("could not be read: %w", err)
}
if major >= 3 && fallback20(msg) {
if allowFallback && wantsOther(major, msg) {
return errFallback
}
s.endFatal(msg, true)
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)
if err != nil {
return err
}
if major >= 3 && gotMajor == 2 {
if allowFallback && acceptsOther(major, gotMajor, gotMinor) {
return errFallback
}
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
+1 -7
View File
@@ -36,14 +36,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
refl.accept(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) {
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)
+1
View File
@@ -96,6 +96,7 @@ cat >"$GENERATED" <<EOF
"callsign": $call_json,
"authKey": $key_json,
"talkGroup": $FUNKNETZ_TALK_GROUP,
"protocol": "v2",
"enableLogging": true,
"logFile": $log_json
}