Let the config choose protocol 2 or 3, and fall back for one session only.

The default stays v2. A reflector that refuses that version and names the other gets one session of it, then the station announces the configured protocol again.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-22 16:28:42 +02:00
co-authored by Cursor
parent 3533b97d00
commit 2c621d4b7f
21 changed files with 361 additions and 82 deletions
+12 -4
View File
@@ -49,7 +49,7 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
challenge := make([]byte, 20)
for i := range challenge {
challenge[i] = byte(i + 1)
@@ -98,6 +98,14 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id, path: path}
}
func expectVersion(t *testing.T, refl *reflector, major, minor uint16) {
t.Helper()
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, major, minor) {
t.Fatalf("version type %d body %v, want %d.%d", typ, body, major, minor)
}
}
func declineTo2(t *testing.T, refl *reflector) {
t.Helper()
typ, body := refl.readFrame(t)
@@ -163,8 +171,8 @@ func TestDropRetriesUntilQuit(t *testing.T) {
in.clock.Advance(2 * time.Second)
in.refl.accept(t)
typ, body := in.refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry protocol type %d body %v, want 2.0", typ, body)
}
in.cancel()
in.clock.Advance(2 * time.Second)
@@ -281,7 +289,7 @@ func TestMissingOpusStopsRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
challenge := make([]byte, 20)
refl.writeFrame(t, 10, appendBytes(nil, challenge))
refl.readFrame(t)
+72 -6
View File
@@ -20,6 +20,7 @@ type loaded struct {
callsign string
authKey string
talkGroup int
protocolMajor uint16
certificateDir string
certFatal string
enableLogging bool
@@ -33,6 +34,7 @@ type fileShape struct {
Callsign jsontext.Value `json:"callsign"`
AuthKey jsontext.Value `json:"authKey"`
TalkGroup jsontext.Value `json:"talkGroup"`
Protocol jsontext.Value `json:"protocol"`
CertificateDir jsontext.Value `json:"certificateDir"`
EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
@@ -44,6 +46,7 @@ type templateFile struct {
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
Protocol string `json:"protocol"`
CertificateDir string `json:"certificateDir"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
@@ -76,13 +79,14 @@ func loadConfig(path string) (configLoad, error) {
return parseConfig(path, body, true)
}
// ConfigForm is the eight fields the Config window edits.
// ConfigForm is the fields the Config window edits.
type ConfigForm struct {
Host string `json:"host"`
Port int `json:"port"`
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
Protocol string `json:"protocol"`
CertificateDir string `json:"certificateDir"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
@@ -101,7 +105,7 @@ func templateForm() (ConfigForm, error) {
if err != nil {
return ConfigForm{}, err
}
return ConfigForm{CertificateDir: "./funknetz", LogFile: logFile}, nil
return ConfigForm{Protocol: "v2", CertificateDir: "./funknetz", LogFile: logFile}, nil
}
func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
@@ -114,7 +118,8 @@ func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
return configLoad{form: form, detail: fmt.Errorf("the file could not be read: %w", err).Error()}, nil
}
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
if block, notice := blockedLog(shape); block || certBad {
protoBad := present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString
if block, notice := blockedLog(shape); block || certBad || protoBad {
cfg, err := parseOperational(shape)
if err != nil {
return refused(shape, err)
@@ -125,7 +130,7 @@ func parseConfig(path string, body []byte, rewrite bool) (configLoad, error) {
}
return finish(shape, applyCertificate(cfg, shape, path))
}
if missingCertificate(shape) || missingLog(shape) {
if missingCertificate(shape) || missingLog(shape) || missingProtocol(shape) {
if !operationalKindsOK(shape) {
_, err := parseOperational(shape)
if err != nil {
@@ -165,7 +170,7 @@ func refused(shape fileShape, cause error) (configLoad, error) {
func formFromShape(shape fileShape) ConfigForm {
fallback, err := templateForm()
if err != nil {
fallback = ConfigForm{CertificateDir: "./funknetz"}
fallback = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
}
return mergeSalvage(shape, fallback)
}
@@ -192,6 +197,11 @@ func mergeSalvage(shape fileShape, fallback ConfigForm) ConfigForm {
fallback.TalkGroup = n
}
}
if s, err := stringField("protocol", shape.Protocol); err == nil && present(shape.Protocol) {
if label, ok := protocolLabel(s); ok {
fallback.Protocol = label
}
}
if s, err := stringField("certificateDir", shape.CertificateDir); err == nil && present(shape.CertificateDir) {
fallback.CertificateDir = s
}
@@ -225,6 +235,10 @@ func parseOperational(shape fileShape) (loaded, error) {
if err != nil {
return loaded{}, err
}
protocolMajor, err := protocolField(shape.Protocol)
if err != nil {
return loaded{}, err
}
host = strings.TrimSpace(host)
if host == "" {
@@ -237,7 +251,45 @@ func parseOperational(shape fileShape) (loaded, error) {
if !callsignPattern.MatchString(call) {
return loaded{}, fmt.Errorf("callsign is invalid")
}
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg}, nil
return loaded{host: host, port: port, callsign: call, authKey: key, talkGroup: tg, protocolMajor: protocolMajor}, nil
}
func protocolField(v jsontext.Value) (uint16, error) {
if !present(v) {
return 2, nil
}
s, err := stringField("protocol", v)
if err != nil {
return 0, err
}
if strings.TrimSpace(s) == "" {
return 2, nil
}
if _, ok := protocolLabel(s); !ok {
return 0, fmt.Errorf("protocol is invalid")
}
if strings.TrimSpace(s) == "v3" {
return 3, nil
}
return 2, nil
}
func protocolLabel(s string) (string, bool) {
switch strings.TrimSpace(s) {
case "v2":
return "v2", true
case "v3":
return "v3", true
default:
return "", false
}
}
func protocolName(major uint16) string {
if major == 3 {
return "v3"
}
return "v2"
}
func stringField(name string, v jsontext.Value) (string, error) {
@@ -306,6 +358,14 @@ func missingCertificate(shape fileShape) bool {
return !present(shape.CertificateDir)
}
func missingProtocol(shape fileShape) bool {
if !present(shape.Protocol) {
return true
}
s, err := stringField("protocol", shape.Protocol)
return err == nil && strings.TrimSpace(s) == ""
}
func operationalKindsOK(shape fileShape) bool {
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
return false
@@ -322,6 +382,9 @@ func operationalKindsOK(shape fileShape) bool {
if present(shape.TalkGroup) && shape.TalkGroup.Kind() != jsontext.KindNumber {
return false
}
if present(shape.Protocol) && shape.Protocol.Kind() != jsontext.KindString {
return false
}
return true
}
@@ -348,6 +411,9 @@ func withLogDefaults(shape fileShape) (fileShape, error) {
shape.Callsign = rawOr(shape.Callsign, `""`)
shape.AuthKey = rawOr(shape.AuthKey, `""`)
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
if missingProtocol(shape) {
shape.Protocol = jsontext.Value(`"v2"`)
}
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
if !present(shape.LogFile) {
+27 -10
View File
@@ -49,6 +49,9 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
if form.EnableLogging {
t.Fatal("EnableLogging = true, want false")
}
if form.Protocol != "v2" {
t.Fatalf("Protocol = %q, want v2", form.Protocol)
}
if form.CertificateDir != "./funknetz" {
t.Fatalf("CertificateDir = %q, want ./funknetz", form.CertificateDir)
}
@@ -59,7 +62,7 @@ func TestMissingConfigOpensWithoutCreating(t *testing.T) {
func TestExistingConfigIsNotRewritten(t *testing.T) {
path := filepath.Join(t.TempDir(), "funknetz.json")
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
original := []byte("{\"host\":\"127.0.0.1\",\"port\":5308,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"/var/funknetz\",\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err)
}
@@ -111,6 +114,8 @@ func TestInvalidConfigNamesTheField(t *testing.T) {
{name: "talk group negative", host: "127.0.0.1", port: 5308, callsign: "DL1ABC", authKey: "key", talkGroup: -3, want: "talkGroup is invalid"},
{name: "talk group type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":"20"}`, want: "talkGroup has the wrong type"},
{name: "bad callsign", host: "127.0.0.1", port: 5308, callsign: "HELLO", authKey: "key", talkGroup: 1, want: "callsign is invalid"},
{name: "protocol type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":2}`, want: "protocol has the wrong type"},
{name: "protocol value", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":1,"protocol":"v1"}`, want: "protocol is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -281,6 +286,9 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
if got["logFile"] != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v", got["logFile"])
}
if got["protocol"] != "v2" {
t.Fatalf("protocol = %#v, want v2", got["protocol"])
}
if got["certificateDir"] != "./funknetz" {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
}
@@ -359,9 +367,9 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
detail string
same bool
}{
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
{name: "relative", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"funknetz.log"}`, detail: "Cannot write funknetz.log", same: true},
{name: "empty", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":" "}`, detail: "Cannot write logFile", same: true},
{name: "tilde", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":"./funknetz","enableLogging":true,"logFile":"~/funknetz.log"}`, detail: "Cannot write ~/funknetz.log", same: true},
{name: "enable type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":"/tmp/funknetz.log"}`, detail: "enableLogging has the wrong type", same: true},
{name: "file type", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":true,"logFile":1}`, detail: "logFile has the wrong type", same: true},
{name: "both types", body: `{"host":"127.0.0.1","port":5308,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"enableLogging":"yes","logFile":1}`, detail: "enableLogging has the wrong type", same: true},
@@ -420,7 +428,7 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
func assertKeyOrder(t *testing.T, body []byte) {
t.Helper()
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"protocol"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
last := -1
for _, key := range keys {
i := strings.Index(string(body), key)
@@ -533,7 +541,7 @@ func TestValidSaveCreatesFileAndConnects(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := fmt.Sprintf("{\"host\":\"127.0.0.1\",\"port\":%d,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":%q}\n", port, logFile)
want := fmt.Sprintf("{\"host\":\"127.0.0.1\",\"port\":%d,\"callsign\":\"DL1ABC\",\"authKey\":\"a b\",\"talkGroup\":20,\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":%q}\n", port, logFile)
if string(body) != want {
t.Fatalf("file = %s, want %s", body, want)
}
@@ -570,7 +578,7 @@ func TestMissingCertificateDirIsWrittenAfterTalkGroup(t *testing.T) {
if err := jsonv2.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" {
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" || got["protocol"] != "v2" {
t.Fatalf("fields = %#v", got)
}
if strings.Contains(string(body), t.TempDir()) {
@@ -597,7 +605,7 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
body := fmt.Sprintf(`{"host":"127.0.0.1","port":%d,"callsign":"DL1ABC","authKey":"key","talkGroup":20,"protocol":"v2","certificateDir":%s,"enableLogging":false,"logFile":"/tmp/funknetz.log"}`, port, tt.dir)
path := filepath.Join(t.TempDir(), "funknetz.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
@@ -635,13 +643,22 @@ func TestCertificateDirStopsBeforeASocket(t *testing.T) {
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
t.Helper()
body, err := jsonv2.Marshal(map[string]any{
return configJSONProtocol(t, host, port, callsign, authKey, talkGroup, "")
}
func configJSONProtocol(t testing.TB, host string, port int, callsign, authKey string, talkGroup int, protocol string) string {
t.Helper()
fields := map[string]any{
"host": host,
"port": port,
"callsign": callsign,
"authKey": authKey,
"talkGroup": talkGroup,
})
}
if protocol != "" {
fields["protocol"] = protocol
}
body, err := jsonv2.Marshal(fields)
if err != nil {
t.Fatal(err)
}
+10 -2
View File
@@ -139,7 +139,8 @@ func (s *Station) applyLogging(prev, next loaded) {
func connectionChanged(prev, next loaded) bool {
return prev.host != next.host || prev.port != next.port || prev.callsign != next.callsign ||
prev.authKey != next.authKey || prev.talkGroup != next.talkGroup || prev.certificateDir != next.certificateDir
prev.authKey != next.authKey || prev.talkGroup != next.talkGroup || prev.protocolMajor != next.protocolMajor ||
prev.certificateDir != next.certificateDir
}
func readForm(path string) (ConfigForm, string, bool) {
@@ -147,7 +148,7 @@ func readForm(path string) (ConfigForm, string, bool) {
if err != nil {
form, ferr := templateForm()
if ferr != nil {
form = ConfigForm{CertificateDir: "./funknetz"}
form = ConfigForm{Protocol: "v2", CertificateDir: "./funknetz"}
}
return form, fmt.Errorf("the file could not be read: %w", err).Error(), false
}
@@ -189,6 +190,7 @@ func validateForm(configPath string, form ConfigForm) (loaded, ConfigForm, error
Callsign: cfg.callsign,
AuthKey: cfg.authKey,
TalkGroup: cfg.talkGroup,
Protocol: protocolName(cfg.protocolMajor),
CertificateDir: strings.TrimSpace(form.CertificateDir),
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
@@ -217,6 +219,10 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
if err != nil {
return fileShape{}, err
}
protocol, err := jsonValue(form.Protocol)
if err != nil {
return fileShape{}, err
}
dir, err := jsonValue(form.CertificateDir)
if err != nil {
return fileShape{}, err
@@ -235,6 +241,7 @@ func shapeFromForm(form ConfigForm) (fileShape, error) {
Callsign: call,
AuthKey: key,
TalkGroup: tg,
Protocol: protocol,
CertificateDir: dir,
EnableLogging: en,
LogFile: logFile,
@@ -256,6 +263,7 @@ func writeForm(path string, form ConfigForm) error {
Callsign: form.Callsign,
AuthKey: form.AuthKey,
TalkGroup: form.TalkGroup,
Protocol: form.Protocol,
CertificateDir: form.CertificateDir,
EnableLogging: form.EnableLogging,
LogFile: form.LogFile,
+3 -2
View File
@@ -118,7 +118,7 @@ func TestSaveRewritesAndReconnects(t *testing.T) {
assertKeyOrder(t, body)
in.refl.accept(t)
declineTo2(t, in.refl)
expectVersion(t, in.refl, 2, 0)
challenge := make([]byte, 20)
for i := range challenge {
challenge[i] = byte(i + 1)
@@ -439,6 +439,7 @@ func TestEachConnectionFieldReconnects(t *testing.T) {
{name: "host", edit: func(f *station.ConfigForm, _ int) { f.Host = "localhost" }},
{name: "callsign", edit: func(f *station.ConfigForm, _ int) { f.Callsign = "DL1ABD" }},
{name: "authKey", edit: func(f *station.ConfigForm, _ int) { f.AuthKey = "other-key" }},
{name: "protocol", edit: func(f *station.ConfigForm, _ int) { f.Protocol = "v3" }},
{name: "certificateDir", edit: func(f *station.ConfigForm, _ int) { f.CertificateDir = "./radios" }},
}
for _, tt := range tests {
@@ -488,7 +489,7 @@ func TestEachConnectionFieldReconnects(t *testing.T) {
}
func fullConfig(host string, port int, callsign, authKey string, talkGroup int, logFile string) string {
return "{\"host\":\"" + host + "\",\"port\":" + strconv.Itoa(port) + ",\"callsign\":\"" + callsign + "\",\"authKey\":\"" + authKey + "\",\"talkGroup\":" + strconv.Itoa(talkGroup) + ",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n"
return "{\"host\":\"" + host + "\",\"port\":" + strconv.Itoa(port) + ",\"callsign\":\"" + callsign + "\",\"authKey\":\"" + authKey + "\",\"talkGroup\":" + strconv.Itoa(talkGroup) + ",\"protocol\":\"v2\",\"certificateDir\":\"./funknetz\",\"enableLogging\":false,\"logFile\":\"" + logFile + "\"}\n"
}
func connStillOpen(conn net.Conn) error {
+31
View File
@@ -194,6 +194,23 @@ func parseVersion(b []byte) (uint16, uint16, error) {
}
var atMostVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. May be at most (\d+)\.(\d+)\.$`)
var atLeastVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. Must be at least (\d+)\.(\d+)\.$`)
// wantsOther reports that the reflector refused the announced major and named the other one.
func wantsOther(announced uint16, msg string) bool {
if announced >= 3 {
return fallback20(msg)
}
return fallback30(msg)
}
// acceptsOther reports that a downgrade names the protocol this station did not announce.
func acceptsOther(announced, gotMajor, gotMinor uint16) bool {
if announced >= 3 {
return gotMajor == 2
}
return gotMajor == 3 && gotMinor == 0
}
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
func fallback20(msg string) bool {
@@ -209,6 +226,20 @@ func fallback20(msg string) bool {
return !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
}
// fallback30 reports that the reflector's minimum is exactly 3.0.
func fallback30(msg string) bool {
m := atLeastVersion.FindStringSubmatch(msg)
if m == nil {
return false
}
major, err1 := strconv.Atoi(m[1])
minor, err2 := strconv.Atoi(m[2])
if err1 != nil || err2 != nil {
return false
}
return major == 3 && minor == 0
}
func versionBelow(major, minor, maj, min int) bool {
if major != maj {
return major < maj
+9 -9
View File
@@ -125,7 +125,7 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
for step := 0; step < 6; step++ {
clock.BlockUntilWaiting(t)
clock.Advance(10 * time.Second)
@@ -144,8 +144,8 @@ func TestUDPLimitRunsBeforeAnyDatagram(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
}
@@ -164,7 +164,7 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
payload := make([]byte, 65)
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 65)
@@ -178,8 +178,8 @@ func TestOversizedFrameBeforeAuthRetries(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
}
@@ -198,7 +198,7 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
_ = st.Run(ctx)
}()
refl.accept(t)
declineTo2(t, refl)
expectVersion(t, refl, 2, 0)
refl.writeFrame(t, 100, serverInfo(1, nil, []string{"OPUS"}))
got, err := st.Wait(t.Context(), func(s station.State) bool { return s.Detail == "Protocol error" })
if err != nil {
@@ -211,8 +211,8 @@ func TestApplicationMessageBeforeLoginRetries(t *testing.T) {
clock.Advance(2 * time.Second)
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v", typ, body)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("retry type %d body %v, want 2.0", typ, body)
}
if st.State().Detail != "Protocol error" {
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
+2 -1
View File
@@ -41,6 +41,7 @@ func configWithDir(t testing.TB, host string, port int, callsign, authKey string
"callsign": callsign,
"authKey": authKey,
"talkGroup": talkGroup,
"protocol": "v3",
"certificateDir": certDir,
"enableLogging": false,
"logFile": logPath,
@@ -509,7 +510,7 @@ func TestCADigestMismatchIsLoggedAndKept(t *testing.T) {
}
body, err := jsonv2.Marshal(map[string]any{
"host": "127.0.0.1", "port": refl.port, "callsign": "DL1ABC", "authKey": "key",
"talkGroup": 20, "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
"talkGroup": 20, "protocol": "v3", "certificateDir": certDir, "enableLogging": true, "logFile": logPath,
})
if err != nil {
t.Fatal(err)
+65 -3
View File
@@ -17,7 +17,7 @@ func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
} {
t.Run(msg, func(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -51,7 +51,7 @@ func TestVersionErrorBelow2Stops(t *testing.T) {
} {
t.Run(msg, func(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -93,7 +93,7 @@ func TestDowngradeOtherThan2Stops(t *testing.T) {
} {
t.Run(tc.want, func(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
path := writeConfigFile(t, configJSONProtocol(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "v3"))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
@@ -150,3 +150,65 @@ func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
t.Fatal("created the certificate directory on a 2.0 fallback")
}
}
func TestConfiguredV2FallsBackTo3ThenReturns(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t)
expectVersion(t, refl, 2, 0)
refl.writeFrame(t, 13, appendStr(nil, "Unsupported protocol version 2.0. Must be at least 3.0."))
refl.accept(t)
expectVersion(t, refl, 3, 0)
refl.tcp.Close()
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
t.Fatal(err)
}
clock.BlockUntilWaiting(t)
clock.Advance(2 * time.Second)
refl.accept(t)
expectVersion(t, refl, 2, 0)
}
func TestV2DoesNotFallBackAbove3(t *testing.T) {
refl := newReflector(t)
path := writeConfigFile(t, configJSON(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20))
clock := newFakeClock()
st, err := station.New(station.Options{ConfigPath: path, Clock: clock})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t)
expectVersion(t, refl, 2, 0)
msg := "Unsupported protocol version 2.0. Must be at least 3.1."
refl.writeFrame(t, 13, appendStr(nil, msg))
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == msg })
if err != nil {
t.Fatal(err)
}
if got.Connection != "Disconnected" || got.Protocol != "" {
t.Fatalf("State = %+v", got)
}
clock.Advance(2 * time.Second)
refl.ln.(*net.TCPListener).SetDeadline(time.Now().Add(150 * time.Millisecond))
if conn, err := refl.ln.Accept(); err == nil {
conn.Close()
t.Fatal("retried")
}
}
+36 -15
View File
@@ -30,11 +30,15 @@ var (
// Run keeps the reflector session until ctx is canceled.
func (s *Station) Run(ctx context.Context) error {
defer s.Close()
major := uint16(3)
var major uint16
holdMajor := false
for {
if ctx.Err() != nil {
return nil
}
if !holdMajor {
major = s.configuredMajor()
}
s.mu.Lock()
if s.fatal != "" || !s.loaded {
s.mu.Unlock()
@@ -61,6 +65,7 @@ func (s *Station) Run(ctx context.Context) error {
stale := s.epoch != epoch
s.mu.Unlock()
if stale {
holdMajor = false
if conn != nil {
// This dial used the config from before Save.
_ = conn.Close()
@@ -72,7 +77,6 @@ func (s *Station) Run(ctx context.Context) error {
if ctx.Err() != nil {
return nil
}
major = 3
s.mu.Lock()
s.noteDownLocked(s.clock.Now(), true)
s.mu.Unlock()
@@ -82,7 +86,7 @@ func (s *Station) Run(ctx context.Context) error {
continue
}
err = s.session(ctx, conn, major, epoch)
err = s.session(ctx, conn, major, epoch, major == s.configuredMajor())
conn.Close()
fallback := errors.Is(err, errFallback)
reconnect := errors.Is(err, errReconnect)
@@ -111,9 +115,10 @@ func (s *Station) Run(ctx context.Context) error {
return nil
}
if fallback {
major = 2
major = otherMajor(major)
holdMajor = true
} else {
major = 3
holdMajor = false
}
continue
}
@@ -124,7 +129,7 @@ func (s *Station) Run(ctx context.Context) error {
}
fatal := s.fatal != ""
s.mu.Unlock()
major = 3
holdMajor = false
if ctx.Err() != nil {
return nil
}
@@ -297,7 +302,23 @@ const (
phChallenge
)
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoch uint64) error {
func (s *Station) configuredMajor() uint16 {
s.mu.Lock()
defer s.mu.Unlock()
if s.cfg.protocolMajor == 3 {
return 3
}
return 2
}
func otherMajor(major uint16) uint16 {
if major == 2 {
return 3
}
return 2
}
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoch uint64, allowFallback bool) error {
s.mu.Lock()
s.conn = conn
stale := s.epoch != epoch
@@ -428,7 +449,7 @@ func (s *Station) session(ctx context.Context, conn net.Conn, major uint16, epoc
if udpRx.Before(ex.limit) {
ex.limit = udpRx
}
err := s.onFrame(ctx, wire, major, tcpf, ex)
err := s.onFrame(ctx, wire, major, tcpf, ex, allowFallback)
if err != nil {
return err
}
@@ -512,14 +533,14 @@ func (s *Station) onClock(now time.Time) {
}
}
func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange) error {
func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange, allowFallback bool) error {
switch f.typ {
case tcpHeartbeat:
return nil
case tcpError:
return s.onReflectorError(f.body, major)
return s.onReflectorError(f.body, major, allowFallback)
case tcpProtoDown:
return s.onDowngrade(f.body, major)
return s.onDowngrade(f.body, major, allowFallback)
}
if !ex.authed && f.typ >= 100 {
return s.retry("Protocol error", nil)
@@ -530,25 +551,25 @@ func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f fra
return s.onV3(ctx, wire, f, ex)
}
func (s *Station) onReflectorError(body []byte, major uint16) error {
func (s *Station) onReflectorError(body []byte, major uint16, allowFallback bool) error {
msg, _, err := parseString(body)
if err != nil {
s.endFatalCause("could not be read", err, true)
return fmt.Errorf("could not be read: %w", err)
}
if major >= 3 && fallback20(msg) {
if allowFallback && wantsOther(major, msg) {
return errFallback
}
s.endFatal(msg, true)
return errStopped
}
func (s *Station) onDowngrade(body []byte, major uint16) error {
func (s *Station) onDowngrade(body []byte, major uint16, allowFallback bool) error {
gotMajor, gotMinor, err := parseVersion(body)
if err != nil {
return err
}
if major >= 3 && gotMajor == 2 {
if allowFallback && acceptsOther(major, gotMajor, gotMinor) {
return errFallback
}
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
+1 -7
View File
@@ -36,14 +36,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("first frame type %d body %v, want protocol 3.0", typ, body)
}
refl.writeFrame(t, 6, versionWire(2, 0))
refl.accept(t)
typ, body = refl.readFrame(t)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("fallback frame type %d body %v, want protocol 2.0", typ, body)
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body)
}
challenge := make([]byte, 20)