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
|
_Avoid_: the station certificate
|
||||||
|
|
||||||
**Protocol**:
|
**Protocol**:
|
||||||
The reflector session this station is speaking: 2.0 or 3.0.
|
The reflector session this station speaks: 2.0 or 3.0. The config chooses v2 or v3; v2 is the default.
|
||||||
_Avoid_: TYPE, a config field the operator sets
|
_Avoid_: TYPE
|
||||||
|
|
||||||
**Push-to-talk**:
|
**Push-to-talk**:
|
||||||
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
|
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
|
||||||
_Avoid_: voice-operated transmit, a mute toggle
|
_Avoid_: voice-operated transmit, a mute toggle
|
||||||
|
|
||||||
**Config**:
|
**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
|
_Avoid_: settings file, preferences, treating PEMs as config fields
|
||||||
|
|
||||||
**Config window**:
|
**Config window**:
|
||||||
The in-app form that edits the config. It is a modal over the station UI, not a separate OS window.
|
The in-app form that edits the config. It fills the station window, which is resized to the form.
|
||||||
_Avoid_: settings window, preferences dialog, a raw JSON editor as the primary UI
|
_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) {
|
func TestSelectedFileIsLeftByteForByte(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "lab.json")
|
path := filepath.Join(t.TempDir(), "lab.json")
|
||||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"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 {
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -308,7 +308,7 @@ func TestCancelWithoutALoadedConfigQuits(t *testing.T) {
|
|||||||
|
|
||||||
func TestCancelAfterLoadKeepsTheSession(t *testing.T) {
|
func TestCancelAfterLoadKeepsTheSession(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "lab.json")
|
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 {
|
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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
|
# Announce protocol 3.0, fall back only on an explicit 2.x maximum
|
||||||
|
|
||||||
Every TCP connection announces protocol 3.0. The station opens one 2.0 session only when the reflector sends a downgrade, or an unsupported-version error, whose maximum is at least 2.0 and below 3.0. The connection after that 2.0 session ends announces 3.0 again. A maximum whose major is below 2 stops the station with the reflector’s message. A reflector `MsgError` stops the station until the operator quits. A bad CA signature, a TLS or hostname failure, a heartbeat timeout, and a dropped connection retry on 3.0, and the window keeps the reason between attempts. The Protocol line shows 2.0 or 3.0 while the session is up, so a plaintext session is visible. Staying on 3.0 after every failure was rejected because a reflector that still expects 2.0 would never accept the station. Falling back on any handshake failure was rejected because a TLS or CA problem would then send plaintext audio.
|
Every TCP connection announces protocol 3.0. The station opens one 2.0 session only when the reflector sends a downgrade, or an unsupported-version error, whose maximum is at least 2.0 and below 3.0. The connection after that 2.0 session ends announces 3.0 again. A maximum whose major is below 2 stops the station with the reflector’s message. A reflector `MsgError` stops the station until the operator quits. A bad CA signature, a TLS or hostname failure, a heartbeat timeout, and a dropped connection retry on 3.0, and the window keeps the reason between attempts. The Protocol line shows 2.0 or 3.0 while the session is up, so a plaintext session is visible. Staying on 3.0 after every failure was rejected because a reflector that still expects 2.0 would never accept the station. Falling back on any handshake failure was rejected because a TLS or CA problem would then send plaintext audio.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# The config chooses the protocol; the other is one fallback session
|
||||||
|
|
||||||
|
The station announces the protocol named in the config. The default is v2. It opens one session of the other protocol only when the reflector refuses the configured version and names the other as acceptable: a downgrade or an “at most 2.x” error when v3 was announced, and a downgrade to 3.0 or a “must be at least 3.0” error when v2 was announced. The connection after that session announces the configured protocol again. A TLS, CA, or hostname failure retries the configured protocol and does not switch, so a certificate problem does not become a plaintext session. Always announcing 3.0 was rejected because the operator has to be able to stay on v2.
|
||||||
@@ -45,6 +45,7 @@ Manual lab JSON (values match `.env` / the mounted conf; secrets are local-test
|
|||||||
"callsign": "DL0FN-1",
|
"callsign": "DL0FN-1",
|
||||||
"authKey": "local-lab-secret-change-me",
|
"authKey": "local-lab-secret-change-me",
|
||||||
"talkGroup": 9,
|
"talkGroup": 9,
|
||||||
|
"protocol": "v2",
|
||||||
"enableLogging": false,
|
"enableLogging": false,
|
||||||
"logFile": "/tmp/funknetz.log"
|
"logFile": "/tmp/funknetz.log"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+35
-7
@@ -1,12 +1,25 @@
|
|||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: #101418;
|
background: #101418;
|
||||||
color: #f2f4f6;
|
color: #f2f4f6;
|
||||||
font: 16px/1.4 system-ui, sans-serif;
|
font: 16px/1.4 system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.config-open main {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
@@ -66,14 +79,28 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#config {
|
#config {
|
||||||
position: fixed;
|
|
||||||
top: 64px;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
height: 100%;
|
||||||
padding: 16px;
|
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] {
|
#config[hidden] {
|
||||||
@@ -106,7 +133,8 @@ main {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#config input {
|
#config input,
|
||||||
|
#config select {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Vendored
+35
-8
@@ -5,7 +5,6 @@ const configError = document.getElementById("config-error");
|
|||||||
const authKey = document.getElementById("authKey");
|
const authKey = document.getElementById("authKey");
|
||||||
const authToggle = document.getElementById("auth-toggle");
|
const authToggle = document.getElementById("auth-toggle");
|
||||||
|
|
||||||
const configHeight = 680;
|
|
||||||
let savedSize = null;
|
let savedSize = null;
|
||||||
let configLoading = false;
|
let configLoading = false;
|
||||||
let sizeToken = 0;
|
let sizeToken = 0;
|
||||||
@@ -31,18 +30,39 @@ function runtime() {
|
|||||||
return window.runtime || null;
|
return window.runtime || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function growWindow() {
|
function fitConfigWindow() {
|
||||||
const rt = runtime();
|
const rt = runtime();
|
||||||
if (!rt || !rt.WindowGetSize || !rt.WindowSetSize) {
|
if (!rt || !rt.WindowGetSize || !rt.WindowSetSize) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const token = ++sizeToken;
|
const token = ++sizeToken;
|
||||||
rt.WindowGetSize().then(function (size) {
|
requestAnimationFrame(function () {
|
||||||
if (token !== sizeToken || config.hidden) {
|
if (token !== sizeToken || config.hidden) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
savedSize = size;
|
rt.WindowGetSize().then(function (size) {
|
||||||
rt.WindowSetSize(Math.max(size.w, 420), Math.max(size.h, configHeight));
|
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) {
|
function showError(message) {
|
||||||
configError.hidden = !message;
|
configError.hidden = !message;
|
||||||
configError.textContent = message || "";
|
configError.textContent = message || "";
|
||||||
|
if (!config.hidden) {
|
||||||
|
fitConfigWindow();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillForm(form) {
|
function fillForm(form) {
|
||||||
@@ -68,6 +91,7 @@ function fillForm(form) {
|
|||||||
document.getElementById("callsign-field").value = form.callsign || "";
|
document.getElementById("callsign-field").value = form.callsign || "";
|
||||||
authKey.value = form.authKey || "";
|
authKey.value = form.authKey || "";
|
||||||
document.getElementById("talkGroup").value = String(form.talkGroup || 0);
|
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("certificateDir").value = form.certificateDir || "";
|
||||||
document.getElementById("enableLogging").checked = !!form.enableLogging;
|
document.getElementById("enableLogging").checked = !!form.enableLogging;
|
||||||
document.getElementById("logFile").value = form.logFile || "";
|
document.getElementById("logFile").value = form.logFile || "";
|
||||||
@@ -82,6 +106,7 @@ function readForm() {
|
|||||||
callsign: document.getElementById("callsign-field").value,
|
callsign: document.getElementById("callsign-field").value,
|
||||||
authKey: authKey.value,
|
authKey: authKey.value,
|
||||||
talkGroup: numberValue("talkGroup"),
|
talkGroup: numberValue("talkGroup"),
|
||||||
|
protocol: document.getElementById("protocol-field").value,
|
||||||
certificateDir: document.getElementById("certificateDir").value,
|
certificateDir: document.getElementById("certificateDir").value,
|
||||||
enableLogging: document.getElementById("enableLogging").checked,
|
enableLogging: document.getElementById("enableLogging").checked,
|
||||||
logFile: document.getElementById("logFile").value
|
logFile: document.getElementById("logFile").value
|
||||||
@@ -111,9 +136,9 @@ function showConfig() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
fillForm(cfg.form || {});
|
fillForm(cfg.form || {});
|
||||||
showError(cfg.error);
|
|
||||||
config.hidden = false;
|
config.hidden = false;
|
||||||
growWindow();
|
document.body.classList.add("config-open");
|
||||||
|
showError(cfg.error);
|
||||||
}).catch(function () {}).then(function () {
|
}).catch(function () {}).then(function () {
|
||||||
configLoading = false;
|
configLoading = false;
|
||||||
});
|
});
|
||||||
@@ -121,7 +146,9 @@ function showConfig() {
|
|||||||
|
|
||||||
function hideConfig() {
|
function hideConfig() {
|
||||||
config.hidden = true;
|
config.hidden = true;
|
||||||
showError("");
|
document.body.classList.remove("config-open");
|
||||||
|
configError.hidden = true;
|
||||||
|
configError.textContent = "";
|
||||||
restoreWindow();
|
restoreWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+6
@@ -31,6 +31,12 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Talk group <input id="talkGroup" name="talkGroup" inputmode="numeric" autocomplete="off"></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>Certificate directory <input id="certificateDir" name="certificateDir" autocomplete="off"></label>
|
||||||
<label class="check">Enable logging <input id="enableLogging" name="enableLogging" type="checkbox"></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>
|
<label>Log file <input id="logFile" name="logFile" autocomplete="off"></label>
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ func main() {
|
|||||||
err = wails.Run(&options.App{
|
err = wails.Run(&options.App{
|
||||||
Title: "funknetz",
|
Title: "funknetz",
|
||||||
Width: 420,
|
Width: 420,
|
||||||
Height: 320,
|
Height: 480,
|
||||||
|
MinWidth: 420,
|
||||||
|
MinHeight: 480,
|
||||||
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
|
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
|
||||||
AssetServer: &assetserver.Options{Assets: assets},
|
AssetServer: &assetserver.Options{Assets: assets},
|
||||||
OnStartup: app.startup,
|
OnStartup: app.startup,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
for i := range challenge {
|
for i := range challenge {
|
||||||
challenge[i] = byte(i + 1)
|
challenge[i] = byte(i + 1)
|
||||||
@@ -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}
|
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id, path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func expectVersion(t *testing.T, refl *reflector, major, minor uint16) {
|
||||||
|
t.Helper()
|
||||||
|
typ, body := refl.readFrame(t)
|
||||||
|
if typ != 5 || !versionIs(body, major, minor) {
|
||||||
|
t.Fatalf("version type %d body %v, want %d.%d", typ, body, major, minor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func declineTo2(t *testing.T, refl *reflector) {
|
func declineTo2(t *testing.T, refl *reflector) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
@@ -163,8 +171,8 @@ func TestDropRetriesUntilQuit(t *testing.T) {
|
|||||||
in.clock.Advance(2 * time.Second)
|
in.clock.Advance(2 * time.Second)
|
||||||
in.refl.accept(t)
|
in.refl.accept(t)
|
||||||
typ, body := in.refl.readFrame(t)
|
typ, body := in.refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry protocol type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
in.cancel()
|
in.cancel()
|
||||||
in.clock.Advance(2 * time.Second)
|
in.clock.Advance(2 * time.Second)
|
||||||
@@ -281,7 +289,7 @@ func TestMissingOpusStopsRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
refl.writeFrame(t, 10, appendBytes(nil, challenge))
|
||||||
refl.readFrame(t)
|
refl.readFrame(t)
|
||||||
|
|||||||
+72
-6
@@ -20,6 +20,7 @@ type loaded struct {
|
|||||||
callsign string
|
callsign string
|
||||||
authKey string
|
authKey string
|
||||||
talkGroup int
|
talkGroup int
|
||||||
|
protocolMajor uint16
|
||||||
certificateDir string
|
certificateDir string
|
||||||
certFatal string
|
certFatal string
|
||||||
enableLogging bool
|
enableLogging bool
|
||||||
@@ -33,6 +34,7 @@ type fileShape struct {
|
|||||||
Callsign jsontext.Value `json:"callsign"`
|
Callsign jsontext.Value `json:"callsign"`
|
||||||
AuthKey jsontext.Value `json:"authKey"`
|
AuthKey jsontext.Value `json:"authKey"`
|
||||||
TalkGroup jsontext.Value `json:"talkGroup"`
|
TalkGroup jsontext.Value `json:"talkGroup"`
|
||||||
|
Protocol jsontext.Value `json:"protocol"`
|
||||||
CertificateDir jsontext.Value `json:"certificateDir"`
|
CertificateDir jsontext.Value `json:"certificateDir"`
|
||||||
EnableLogging jsontext.Value `json:"enableLogging"`
|
EnableLogging jsontext.Value `json:"enableLogging"`
|
||||||
LogFile jsontext.Value `json:"logFile"`
|
LogFile jsontext.Value `json:"logFile"`
|
||||||
@@ -44,6 +46,7 @@ type templateFile struct {
|
|||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
AuthKey string `json:"authKey"`
|
AuthKey string `json:"authKey"`
|
||||||
TalkGroup int `json:"talkGroup"`
|
TalkGroup int `json:"talkGroup"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
CertificateDir string `json:"certificateDir"`
|
CertificateDir string `json:"certificateDir"`
|
||||||
EnableLogging bool `json:"enableLogging"`
|
EnableLogging bool `json:"enableLogging"`
|
||||||
LogFile string `json:"logFile"`
|
LogFile string `json:"logFile"`
|
||||||
@@ -76,13 +79,14 @@ func loadConfig(path string) (configLoad, error) {
|
|||||||
return parseConfig(path, body, true)
|
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 {
|
type ConfigForm struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
AuthKey string `json:"authKey"`
|
AuthKey string `json:"authKey"`
|
||||||
TalkGroup int `json:"talkGroup"`
|
TalkGroup int `json:"talkGroup"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
CertificateDir string `json:"certificateDir"`
|
CertificateDir string `json:"certificateDir"`
|
||||||
EnableLogging bool `json:"enableLogging"`
|
EnableLogging bool `json:"enableLogging"`
|
||||||
LogFile string `json:"logFile"`
|
LogFile string `json:"logFile"`
|
||||||
@@ -101,7 +105,7 @@ func templateForm() (ConfigForm, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ConfigForm{}, err
|
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) {
|
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
|
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
|
||||||
}
|
}
|
||||||
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
|
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
|
||||||
if block, notice := blockedLog(shape); block || certBad {
|
protoBad := present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString
|
||||||
|
if block, notice := blockedLog(shape); block || certBad || protoBad {
|
||||||
cfg, err := parseOperational(shape)
|
cfg, err := parseOperational(shape)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return refused(shape, err)
|
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))
|
return finish(shape, applyCertificate(cfg, shape, path))
|
||||||
}
|
}
|
||||||
if missingCertificate(shape) || missingLog(shape) {
|
if missingCertificate(shape) || missingLog(shape) || missingProtocol(shape) {
|
||||||
if !operationalKindsOK(shape) {
|
if !operationalKindsOK(shape) {
|
||||||
_, err := parseOperational(shape)
|
_, err := parseOperational(shape)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -165,7 +170,7 @@ func refused(shape fileShape, cause error) (configLoad, error) {
|
|||||||
func formFromShape(shape fileShape) ConfigForm {
|
func formFromShape(shape fileShape) ConfigForm {
|
||||||
fallback, err := templateForm()
|
fallback, err := templateForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fallback = ConfigForm{CertificateDir: "./funknetz"}
|
fallback = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
|
||||||
}
|
}
|
||||||
return mergeSalvage(shape, fallback)
|
return mergeSalvage(shape, fallback)
|
||||||
}
|
}
|
||||||
@@ -192,6 +197,11 @@ func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
|
|||||||
fallback.TalkGroup = n
|
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) {
|
if s, err := stringField("certificateDir", shape.CertificateDir); err == nil && present(shape.CertificateDir) {
|
||||||
fallback.CertificateDir = s
|
fallback.CertificateDir = s
|
||||||
}
|
}
|
||||||
@@ -225,6 +235,10 @@ func parseOperational(shape fileShape) (loaded, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return loaded{}, err
|
return loaded{}, err
|
||||||
}
|
}
|
||||||
|
protocolMajor, err := protocolField(shape.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return loaded{}, err
|
||||||
|
}
|
||||||
|
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
if host == "" {
|
if host == "" {
|
||||||
@@ -237,7 +251,45 @@ func parseOperational(shape fileShape) (loaded, error) {
|
|||||||
if !callsignPattern.MatchString(call) {
|
if !callsignPattern.MatchString(call) {
|
||||||
return loaded{}, fmt.Errorf("callsign is invalid")
|
return loaded{}, fmt.Errorf("callsign is invalid")
|
||||||
}
|
}
|
||||||
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg}, nil
|
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg, protocolMajor: protocolMajor}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolField(v jsontext.Value) (uint16, error) {
|
||||||
|
if !present(v) {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
s, err := stringField("protocol", v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
if _, ok := protocolLabel(s); !ok {
|
||||||
|
return 0, fmt.Errorf("protocol is invalid")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "v3" {
|
||||||
|
return 3, nil
|
||||||
|
}
|
||||||
|
return 2, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolLabel(s string) (string, bool) {
|
||||||
|
switch strings.TrimSpace(s) {
|
||||||
|
case "v2":
|
||||||
|
return "v2", true
|
||||||
|
case "v3":
|
||||||
|
return "v3", true
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func protocolName(major uint16) string {
|
||||||
|
if major == 3 {
|
||||||
|
return "v3"
|
||||||
|
}
|
||||||
|
return "v2"
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringField(name string, v jsontext.Value) (string, error) {
|
func stringField(name string, v jsontext.Value) (string, error) {
|
||||||
@@ -306,6 +358,14 @@ func missingCertificate(shape fileShape) bool {
|
|||||||
return !present(shape.CertificateDir)
|
return !present(shape.CertificateDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func missingProtocol(shape fileShape) bool {
|
||||||
|
if !present(shape.Protocol) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
s, err := stringField("protocol", shape.Protocol)
|
||||||
|
return err == nil && strings.TrimSpace(s) == ""
|
||||||
|
}
|
||||||
|
|
||||||
func operationalKindsOK(shape fileShape) bool {
|
func operationalKindsOK(shape fileShape) bool {
|
||||||
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
|
||||||
return false
|
return false
|
||||||
@@ -322,6 +382,9 @@ func operationalKindsOK(shape fileShape) bool {
|
|||||||
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +411,9 @@ func withLogDefaults(shape fileShape) (fileShape, error) {
|
|||||||
shape.Callsign = rawOr(shape.Callsign, `""`)
|
shape.Callsign = rawOr(shape.Callsign, `""`)
|
||||||
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
shape.AuthKey = rawOr(shape.AuthKey, `""`)
|
||||||
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
|
||||||
|
if missingProtocol(shape) {
|
||||||
|
shape.Protocol = jsontext.Value(`"v2"`)
|
||||||
|
}
|
||||||
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
|
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
|
||||||
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
|
||||||
if !present(shape.LogFile) {
|
if !present(shape.LogFile) {
|
||||||
|
|||||||
+27
-10
@@ -49,6 +49,9 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
|
|||||||
if form.EnableLogging {
|
if form.EnableLogging {
|
||||||
t.Fatal("EnableLogging = true, want false")
|
t.Fatal("EnableLogging = true, want false")
|
||||||
}
|
}
|
||||||
|
if form.Protocol != "v2" {
|
||||||
|
t.Fatalf("Protocol = %q, want v2", form.Protocol)
|
||||||
|
}
|
||||||
if form.CertificateDir != "./funknetz" {
|
if form.CertificateDir != "./funknetz" {
|
||||||
t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir)
|
t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir)
|
||||||
}
|
}
|
||||||
@@ -59,7 +62,7 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
|
|||||||
|
|
||||||
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
func TestExistingConfigIsNotRewritten(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
|
||||||
if err := os.WriteFile(path, original, 0o640); err != nil {
|
if err := os.WriteFile(path, original, 0o640); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -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 negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"},
|
||||||
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
|
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
|
||||||
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
|
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
|
||||||
|
{name: "protocol type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":2}`, want: "protocol has the wrong type"},
|
||||||
|
{name: "protocol value", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":"v1"}`, want: "protocol is invalid"},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
@@ -281,6 +286,9 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
|
|||||||
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
if got["logFile"] != filepath.Join(home, "funknetz.log") {
|
||||||
t.Fatalf("logFile = %#v", got["logFile"])
|
t.Fatalf("logFile = %#v", got["logFile"])
|
||||||
}
|
}
|
||||||
|
if got["protocol"] != "v2" {
|
||||||
|
t.Fatalf("protocol = %#v, want v2", got["protocol"])
|
||||||
|
}
|
||||||
if got["certificateDir"] != "./funknetz" {
|
if got["certificateDir"] != "./funknetz" {
|
||||||
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
|
||||||
}
|
}
|
||||||
@@ -359,9 +367,9 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
|||||||
detail string
|
detail string
|
||||||
same bool
|
same bool
|
||||||
}{
|
}{
|
||||||
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
|
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
|
||||||
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
|
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
|
||||||
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
|
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
|
||||||
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
|
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
|
||||||
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
|
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
|
||||||
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
|
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
|
||||||
@@ -420,7 +428,7 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
|
|||||||
|
|
||||||
func assertKeyOrder(t *testing.T, body []byte) {
|
func assertKeyOrder(t *testing.T, body []byte) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
|
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"protocol"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
|
||||||
last := -1
|
last := -1
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
i := strings.Index(string(body), key)
|
i := strings.Index(string(body), key)
|
||||||
@@ -533,7 +541,7 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if string(body) != want {
|
||||||
t.Fatalf("file = %s, want %s", 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 {
|
if err := jsonv2.Unmarshal(body, &got); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" {
|
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" || got["protocol"] != "v2" {
|
||||||
t.Fatalf("fields = %#v", got)
|
t.Fatalf("fields = %#v", got)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(body), t.TempDir()) {
|
if strings.Contains(string(body), t.TempDir()) {
|
||||||
@@ -597,7 +605,7 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
port := ln.Addr().(*net.TCPAddr).Port
|
port := ln.Addr().(*net.TCPAddr).Port
|
||||||
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
|
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
|
||||||
path := filepath.Join(t.TempDir(), "funknetz.json")
|
path := filepath.Join(t.TempDir(), "funknetz.json")
|
||||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -635,13 +643,22 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
|
|||||||
|
|
||||||
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
return configJSONProtocol(t, host, port, callsign, authKey, talkGroup, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configJSONProtocol(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, protocol string) string {
|
||||||
|
t.Helper()
|
||||||
|
fields := map[string]any{
|
||||||
"host": host,
|
"host": host,
|
||||||
"port": port,
|
"port": port,
|
||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"authKey": authKey,
|
"authKey": authKey,
|
||||||
"talkGroup": talkGroup,
|
"talkGroup": talkGroup,
|
||||||
})
|
}
|
||||||
|
if protocol != "" {
|
||||||
|
fields["protocol"] = protocol
|
||||||
|
}
|
||||||
|
body, err := jsonv2.Marshal(fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,8 @@ func (s *Station) applyLogging(prev, next loaded) {
|
|||||||
|
|
||||||
func connectionChanged(prev, next loaded) bool {
|
func connectionChanged(prev, next loaded) bool {
|
||||||
return prev.host != next.host || prev.port != next.port || prev.callsign != next.callsign ||
|
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) {
|
func readForm(path string) (ConfigForm, string, bool) {
|
||||||
@@ -147,7 +148,7 @@ func readForm(path string) (ConfigForm, string, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
form, ferr := templateForm()
|
form, ferr := templateForm()
|
||||||
if ferr != nil {
|
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
|
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,
|
Callsign: cfg.callsign,
|
||||||
AuthKey: cfg.authKey,
|
AuthKey: cfg.authKey,
|
||||||
TalkGroup: cfg.talkGroup,
|
TalkGroup: cfg.talkGroup,
|
||||||
|
Protocol: protocolName(cfg.protocolMajor),
|
||||||
CertificateDir: strings.TrimSpace(form.CertificateDir),
|
CertificateDir: strings.TrimSpace(form.CertificateDir),
|
||||||
EnableLogging: form.EnableLogging,
|
EnableLogging: form.EnableLogging,
|
||||||
LogFile: form.LogFile,
|
LogFile: form.LogFile,
|
||||||
@@ -217,6 +219,10 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fileShape{}, err
|
return fileShape{}, err
|
||||||
}
|
}
|
||||||
|
protocol, err := jsonValue(form.Protocol)
|
||||||
|
if err != nil {
|
||||||
|
return fileShape{}, err
|
||||||
|
}
|
||||||
dir, err := jsonValue(form.CertificateDir)
|
dir, err := jsonValue(form.CertificateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fileShape{}, err
|
return fileShape{}, err
|
||||||
@@ -235,6 +241,7 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
|
|||||||
Callsign: call,
|
Callsign: call,
|
||||||
AuthKey: key,
|
AuthKey: key,
|
||||||
TalkGroup: tg,
|
TalkGroup: tg,
|
||||||
|
Protocol: protocol,
|
||||||
CertificateDir: dir,
|
CertificateDir: dir,
|
||||||
EnableLogging: en,
|
EnableLogging: en,
|
||||||
LogFile: logFile,
|
LogFile: logFile,
|
||||||
@@ -256,6 +263,7 @@ func writeForm(path string, form ConfigForm) error {
|
|||||||
Callsign: form.Callsign,
|
Callsign: form.Callsign,
|
||||||
AuthKey: form.AuthKey,
|
AuthKey: form.AuthKey,
|
||||||
TalkGroup: form.TalkGroup,
|
TalkGroup: form.TalkGroup,
|
||||||
|
Protocol: form.Protocol,
|
||||||
CertificateDir: form.CertificateDir,
|
CertificateDir: form.CertificateDir,
|
||||||
EnableLogging: form.EnableLogging,
|
EnableLogging: form.EnableLogging,
|
||||||
LogFile: form.LogFile,
|
LogFile: form.LogFile,
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ func TestSaveRewritesAndReconnects(t *testing.T) {
|
|||||||
assertKeyOrder(t, body)
|
assertKeyOrder(t, body)
|
||||||
|
|
||||||
in.refl.accept(t)
|
in.refl.accept(t)
|
||||||
declineTo2(t, in.refl)
|
expectVersion(t, in.refl, 2, 0)
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
for i := range challenge {
|
for i := range challenge {
|
||||||
challenge[i] = byte(i + 1)
|
challenge[i] = byte(i + 1)
|
||||||
@@ -439,6 +439,7 @@ func TestEachConnectionFieldReconnects(t *testing.T) {
|
|||||||
{name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }},
|
{name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }},
|
||||||
{name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }},
|
{name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }},
|
||||||
{name: "authKey", edit: func(f *station.ConfigForm, _ int) { f.AuthKey = "other-key" }},
|
{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" }},
|
{name: "certificateDir", edit: func(f *station.ConfigForm, _ int) { f.CertificateDir = "./radios" }},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
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 {
|
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 {
|
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 atMostVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. May be at most (\d+)\.(\d+)\.$`)
|
||||||
|
var atLeastVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. Must be at least (\d+)\.(\d+)\.$`)
|
||||||
|
|
||||||
|
// wantsOther reports that the reflector refused the announced major and named the other one.
|
||||||
|
func wantsOther(announced uint16, msg string) bool {
|
||||||
|
if announced >= 3 {
|
||||||
|
return fallback20(msg)
|
||||||
|
}
|
||||||
|
return fallback30(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acceptsOther reports that a downgrade names the protocol this station did not announce.
|
||||||
|
func acceptsOther(announced, gotMajor, gotMinor uint16) bool {
|
||||||
|
if announced >= 3 {
|
||||||
|
return gotMajor == 2
|
||||||
|
}
|
||||||
|
return gotMajor == 3 && gotMinor == 0
|
||||||
|
}
|
||||||
|
|
||||||
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
|
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
|
||||||
func fallback20(msg string) bool {
|
func fallback20(msg string) bool {
|
||||||
@@ -209,6 +226,20 @@ func fallback20(msg string) bool {
|
|||||||
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
|
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fallback30 reports that the reflector's minimum is exactly 3.0.
|
||||||
|
func fallback30(msg string) bool {
|
||||||
|
m := atLeastVersion.FindStringSubmatch(msg)
|
||||||
|
if m == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
major, err1 := strconv.Atoi(m[1])
|
||||||
|
minor, err2 := strconv.Atoi(m[2])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return major == 3 && minor == 0
|
||||||
|
}
|
||||||
|
|
||||||
func versionBelow(major, minor, maj, min int) bool {
|
func versionBelow(major, minor, maj, min int) bool {
|
||||||
if major != maj {
|
if major != maj {
|
||||||
return major < maj
|
return major < maj
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
for step := 0; step < 6; step++ {
|
for step := 0; step < 6; step++ {
|
||||||
clock.BlockUntilWaiting(t)
|
clock.BlockUntilWaiting(t)
|
||||||
clock.Advance(10 * time.Second)
|
clock.Advance(10 * time.Second)
|
||||||
@@ -144,8 +144,8 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
payload := make([]byte, 65)
|
payload := make([]byte, 65)
|
||||||
var hdr [4]byte
|
var hdr [4]byte
|
||||||
binary.BigEndian.PutUint32(hdr[:], 65)
|
binary.BigEndian.PutUint32(hdr[:], 65)
|
||||||
@@ -178,8 +178,8 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
|
|||||||
_ = st.Run(ctx)
|
_ = st.Run(ctx)
|
||||||
}()
|
}()
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
declineTo2(t, refl)
|
expectVersion(t, refl, 2, 0)
|
||||||
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
|
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
|
||||||
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
|
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -211,8 +211,8 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
|
|||||||
clock.Advance(2 * time.Second)
|
clock.Advance(2 * time.Second)
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("retry type %d body %v", typ, body)
|
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
|
||||||
}
|
}
|
||||||
if st.State().Detail != "Protocol error" {
|
if st.State().Detail != "Protocol error" {
|
||||||
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
|
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ func configWithDir(t testing.TB, host string, port int, callsign, authKey string
|
|||||||
"callsign": callsign,
|
"callsign": callsign,
|
||||||
"authKey": authKey,
|
"authKey": authKey,
|
||||||
"talkGroup": talkGroup,
|
"talkGroup": talkGroup,
|
||||||
|
"protocol": "v3",
|
||||||
"certificateDir": certDir,
|
"certificateDir": certDir,
|
||||||
"enableLogging": false,
|
"enableLogging": false,
|
||||||
"logFile": logPath,
|
"logFile": logPath,
|
||||||
@@ -509,7 +510,7 @@ func TestCADigestMismatchIsLoggedAndKept(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, err := jsonv2.Marshal(map[string]any{
|
body, err := jsonv2.Marshal(map[string]any{
|
||||||
"host": "127.0.0.1", "port": refl.port, "callsign": "DL1ABC", "authKey": "key",
|
"host": "127.0.0.1", "port": refl.port, "callsign": "DL1ABC", "authKey": "key",
|
||||||
"talkGroup": 20, "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
|
"talkGroup": 20, "protocol": "v3", "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(msg, func(t *testing.T) {
|
t.Run(msg, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,7 +51,7 @@ func TestVersionErrorBelow2Stops(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(msg, func(t *testing.T) {
|
t.Run(msg, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,7 +93,7 @@ func TestDowngradeOtherThan2Stops(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(tc.want, func(t *testing.T) {
|
t.Run(tc.want, func(t *testing.T) {
|
||||||
refl := newReflector(t)
|
refl := newReflector(t)
|
||||||
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
|
||||||
clock := newFakeClock()
|
clock := newFakeClock()
|
||||||
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -150,3 +150,65 @@ func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
|
|||||||
t.Fatal("created the certificate directory on a 2.0 fallback")
|
t.Fatal("created the certificate directory on a 2.0 fallback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfiguredV2FallsBackTo3ThenReturns(t *testing.T) {
|
||||||
|
refl := newReflector(t)
|
||||||
|
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
||||||
|
clock := newFakeClock()
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
// Run's error is observed through State, which is the station seam.
|
||||||
|
_ = st.Run(ctx)
|
||||||
|
}()
|
||||||
|
refl.accept(t)
|
||||||
|
expectVersion(t, refl, 2, 0)
|
||||||
|
refl.writeFrame(t, 13, appendStr(nil, "Unsupported protocol version 2.0. Must be at least 3.0."))
|
||||||
|
refl.accept(t)
|
||||||
|
expectVersion(t, refl, 3, 0)
|
||||||
|
refl.tcp.Close()
|
||||||
|
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clock.BlockUntilWaiting(t)
|
||||||
|
clock.Advance(2 * time.Second)
|
||||||
|
refl.accept(t)
|
||||||
|
expectVersion(t, refl, 2, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2DoesNotFallBackAbove3(t *testing.T) {
|
||||||
|
refl := newReflector(t)
|
||||||
|
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
|
||||||
|
clock := newFakeClock()
|
||||||
|
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
// Run's error is observed through State, which is the station seam.
|
||||||
|
_ = st.Run(ctx)
|
||||||
|
}()
|
||||||
|
refl.accept(t)
|
||||||
|
expectVersion(t, refl, 2, 0)
|
||||||
|
msg := "Unsupported protocol version 2.0. Must be at least 3.1."
|
||||||
|
refl.writeFrame(t, 13, appendStr(nil, msg))
|
||||||
|
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == msg })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Connection != "Disconnected" || got.Protocol != "" {
|
||||||
|
t.Fatalf("State = %+v", got)
|
||||||
|
}
|
||||||
|
clock.Advance(2 * time.Second)
|
||||||
|
refl.ln.(*net.TCPListener).SetDeadline(time.Now().Add(150 * time.Millisecond))
|
||||||
|
if conn, err := refl.ln.Accept(); err == nil {
|
||||||
|
conn.Close()
|
||||||
|
t.Fatal("retried")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+36
-15
@@ -30,11 +30,15 @@ var (
|
|||||||
// Run keeps the reflector session until ctx is canceled.
|
// Run keeps the reflector session until ctx is canceled.
|
||||||
func (s *Station) Run(ctx context.Context) error {
|
func (s *Station) Run(ctx context.Context) error {
|
||||||
defer s.Close()
|
defer s.Close()
|
||||||
major := uint16(3)
|
var major uint16
|
||||||
|
holdMajor := false
|
||||||
for {
|
for {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if !holdMajor {
|
||||||
|
major = s.configuredMajor()
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
if s.fatal != "" || !s.loaded {
|
if s.fatal != "" || !s.loaded {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -61,6 +65,7 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
stale := s.epoch != epoch
|
stale := s.epoch != epoch
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if stale {
|
if stale {
|
||||||
|
holdMajor = false
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
// This dial used the config from before Save.
|
// This dial used the config from before Save.
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
@@ -72,7 +77,6 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
major = 3
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.noteDownLocked(s.clock.Now(), true)
|
s.noteDownLocked(s.clock.Now(), true)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -82,7 +86,7 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.session(ctx, conn, major, epoch)
|
err = s.session(ctx, conn, major, epoch, major == s.configuredMajor())
|
||||||
conn.Close()
|
conn.Close()
|
||||||
fallback := errors.Is(err, errFallback)
|
fallback := errors.Is(err, errFallback)
|
||||||
reconnect := errors.Is(err, errReconnect)
|
reconnect := errors.Is(err, errReconnect)
|
||||||
@@ -111,9 +115,10 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if fallback {
|
if fallback {
|
||||||
major = 2
|
major = otherMajor(major)
|
||||||
|
holdMajor = true
|
||||||
} else {
|
} else {
|
||||||
major = 3
|
holdMajor = false
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,7 @@ func (s *Station) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
fatal := s.fatal != ""
|
fatal := s.fatal != ""
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
major = 3
|
holdMajor = false
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -297,7 +302,23 @@ const (
|
|||||||
phChallenge
|
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.mu.Lock()
|
||||||
s.conn = conn
|
s.conn = conn
|
||||||
stale := s.epoch != epoch
|
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) {
|
if udpRx.Before(ex.limit) {
|
||||||
ex.limit = udpRx
|
ex.limit = udpRx
|
||||||
}
|
}
|
||||||
err := s.onFrame(ctx, wire, major, tcpf, ex)
|
err := s.onFrame(ctx, wire, major, tcpf, ex, allowFallback)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -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 {
|
switch f.typ {
|
||||||
case tcpHeartbeat:
|
case tcpHeartbeat:
|
||||||
return nil
|
return nil
|
||||||
case tcpError:
|
case tcpError:
|
||||||
return s.onReflectorError(f.body, major)
|
return s.onReflectorError(f.body, major, allowFallback)
|
||||||
case tcpProtoDown:
|
case tcpProtoDown:
|
||||||
return s.onDowngrade(f.body, major)
|
return s.onDowngrade(f.body, major, allowFallback)
|
||||||
}
|
}
|
||||||
if !ex.authed && f.typ >= 100 {
|
if !ex.authed && f.typ >= 100 {
|
||||||
return s.retry("Protocol error", nil)
|
return s.retry("Protocol error", nil)
|
||||||
@@ -530,25 +551,25 @@ func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f fra
|
|||||||
return s.onV3(ctx, wire, f, ex)
|
return s.onV3(ctx, wire, f, ex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) onReflectorError(body []byte, major uint16) error {
|
func (s *Station) onReflectorError(body []byte, major uint16, allowFallback bool) error {
|
||||||
msg, _, err := parseString(body)
|
msg, _, err := parseString(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.endFatalCause("could not be read", err, true)
|
s.endFatalCause("could not be read", err, true)
|
||||||
return fmt.Errorf("could not be read: %w", err)
|
return fmt.Errorf("could not be read: %w", err)
|
||||||
}
|
}
|
||||||
if major >= 3 && fallback20(msg) {
|
if allowFallback && wantsOther(major, msg) {
|
||||||
return errFallback
|
return errFallback
|
||||||
}
|
}
|
||||||
s.endFatal(msg, true)
|
s.endFatal(msg, true)
|
||||||
return errStopped
|
return errStopped
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Station) onDowngrade(body []byte, major uint16) error {
|
func (s *Station) onDowngrade(body []byte, major uint16, allowFallback bool) error {
|
||||||
gotMajor, gotMinor, err := parseVersion(body)
|
gotMajor, gotMinor, err := parseVersion(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if major >= 3 && gotMajor == 2 {
|
if allowFallback && acceptsOther(major, gotMajor, gotMinor) {
|
||||||
return errFallback
|
return errFallback
|
||||||
}
|
}
|
||||||
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
|
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
|
||||||
|
|||||||
@@ -36,14 +36,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
|
|||||||
|
|
||||||
refl.accept(t)
|
refl.accept(t)
|
||||||
typ, body := refl.readFrame(t)
|
typ, body := refl.readFrame(t)
|
||||||
if typ != 5 || !versionIs(body, 3, 0) {
|
|
||||||
t.Fatalf("first frame type %d body %v, want protocol 3.0", typ, body)
|
|
||||||
}
|
|
||||||
refl.writeFrame(t, 6, versionWire(2, 0))
|
|
||||||
refl.accept(t)
|
|
||||||
typ, body = refl.readFrame(t)
|
|
||||||
if typ != 5 || !versionIs(body, 2, 0) {
|
if typ != 5 || !versionIs(body, 2, 0) {
|
||||||
t.Fatalf("fallback frame type %d body %v, want protocol 2.0", typ, body)
|
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
challenge := make([]byte, 20)
|
challenge := make([]byte, 20)
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ cat >"$GENERATED" <<EOF
|
|||||||
"callsign": $call_json,
|
"callsign": $call_json,
|
||||||
"authKey": $key_json,
|
"authKey": $key_json,
|
||||||
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
"talkGroup": $FUNKNETZ_TALK_GROUP,
|
||||||
|
"protocol": "v2",
|
||||||
"enableLogging": true,
|
"enableLogging": true,
|
||||||
"logFile": $log_json
|
"logFile": $log_json
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user