diff --git a/CONTEXT.md b/CONTEXT.md index 0cfb67d..d455d68 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/config_path_test.go b/config_path_test.go index b070163..e990dc8 100644 --- a/config_path_test.go +++ b/config_path_test.go @@ -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) } diff --git a/docs/adr/0005-protocol-3-with-explicit-2-fallback.md b/docs/adr/0005-protocol-3-with-explicit-2-fallback.md index 9efdebf..0b448f0 100644 --- a/docs/adr/0005-protocol-3-with-explicit-2-fallback.md +++ b/docs/adr/0005-protocol-3-with-explicit-2-fallback.md @@ -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. diff --git a/docs/adr/0006-configured-protocol.md b/docs/adr/0006-configured-protocol.md new file mode 100644 index 0000000..61299ea --- /dev/null +++ b/docs/adr/0006-configured-protocol.md @@ -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. diff --git a/docs/svxlink.md b/docs/svxlink.md index 98e54a9..f95cca3 100644 --- a/docs/svxlink.md +++ b/docs/svxlink.md @@ -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" } diff --git a/frontend/dist/app.css b/frontend/dist/app.css index 9ac30ba..27e9791 100644 --- a/frontend/dist/app.css +++ b/frontend/dist/app.css @@ -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%; diff --git a/frontend/dist/app.js b/frontend/dist/app.js index ace9c92..6f8f3a2 100644 --- a/frontend/dist/app.js +++ b/frontend/dist/app.js @@ -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(); } diff --git a/frontend/dist/index.html b/frontend/dist/index.html index a2e12c4..1b00080 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -31,6 +31,12 @@ + diff --git a/main.go b/main.go index c73d366..dfcba57 100644 --- a/main.go +++ b/main.go @@ -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, diff --git a/station/behavior_test.go b/station/behavior_test.go index ea23318..5106ef5 100644 --- a/station/behavior_test.go +++ b/station/behavior_test.go @@ -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) diff --git a/station/config.go b/station/config.go index fb61260..5b72490 100644 --- a/station/config.go +++ b/station/config.go @@ -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) { diff --git a/station/config_test.go b/station/config_test.go index ec87e0e..7eb1086 100644 --- a/station/config_test.go +++ b/station/config_test.go @@ -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) } diff --git a/station/config_window.go b/station/config_window.go index af18803..98ae635 100644 --- a/station/config_window.go +++ b/station/config_window.go @@ -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, diff --git a/station/config_window_test.go b/station/config_window_test.go index 394d0c2..c6b70b3 100644 --- a/station/config_window_test.go +++ b/station/config_window_test.go @@ -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 { diff --git a/station/protocol.go b/station/protocol.go index b93ddc7..41027b6 100644 --- a/station/protocol.go +++ b/station/protocol.go @@ -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 diff --git a/station/protocol2_test.go b/station/protocol2_test.go index 75e70ca..f093b23 100644 --- a/station/protocol2_test.go +++ b/station/protocol2_test.go @@ -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) diff --git a/station/protocol3_test.go b/station/protocol3_test.go index 154e463..937bc40 100644 --- a/station/protocol3_test.go +++ b/station/protocol3_test.go @@ -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) diff --git a/station/protocol_select_test.go b/station/protocol_select_test.go index eccefd4..3126950 100644 --- a/station/protocol_select_test.go +++ b/station/protocol_select_test.go @@ -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") + } +} diff --git a/station/session.go b/station/session.go index 77d1d9b..9ce74b7 100644 --- a/station/session.go +++ b/station/session.go @@ -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) diff --git a/station/session_test.go b/station/session_test.go index 7d428f0..c7162fd 100644 --- a/station/session_test.go +++ b/station/session_test.go @@ -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) diff --git a/teststart.sh b/teststart.sh index 271577f..cf97bb9 100755 --- a/teststart.sh +++ b/teststart.sh @@ -96,6 +96,7 @@ cat >"$GENERATED" <