Compare commits
1
Commits
3533b97d00
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c621d4b7f |
+5
-5
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+35
-7
@@ -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%;
|
||||
|
||||
Vendored
+35
-8
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Vendored
+6
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -96,6 +96,7 @@ cat >"$GENERATED" <<EOF
|
||||
"callsign": $call_json,
|
||||
"authKey": $key_json,
|
||||
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
||||
"protocol": "v2",
|
||||
"enableLogging": true,
|
||||
"logFile": $log_json
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user