Speak protocol 3.0 and fall back to 2.0 only when that is the reflector's maximum.

A reflector that has moved to 3.0 will not accept a station that always announces 2.0, and a 2.x reflector still needs one explicit plaintext session.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-07 14:24:00 +02:00
co-authored by Cursor
parent 0930d4b727
commit 3d1a2a1669
24 changed files with 3180 additions and 244 deletions
+18 -2
View File
@@ -29,8 +29,24 @@ The uppercase amateur call sign that names this station on the reflector.
_Avoid_: a lowercase spelling, username, node id
**Auth key**:
The secret the network issues for this station’s callsign, used to prove identity to the reflector.
_Avoid_: certificate, a password sent in the clear
The shared secret the network issues for this station’s callsign. It proves the station on protocol 2.0, and on protocol 3.0 when the reflector has not signed a certificate yet.
_Avoid_: a password sent in the clear, the certificate
**Certificate**:
An X.509 certificate whose common name is this station’s callsign. It proves the station on protocol 3.0.
_Avoid_: auth key, a PEM pasted into the config file
**Certificate directory**:
The directory the station config names. It holds the CA bundle and this station’s key and certificate.
_Avoid_: PEM text inside the config file
**CA bundle**:
The reflector’s certificate authority, kept in the certificate directory. Protocol 3.0 uses it to check the reflector.
_Avoid_: the station certificate
**Protocol**:
The reflector session this station is speaking: 2.0 or 3.0.
_Avoid_: TYPE, a config field the operator sets
**Push-to-talk**:
The operator holding the control that makes this station the talker. Releasing it returns the station to listening.
@@ -0,0 +1,3 @@
# Trust the first CA bundle
Protocol 3.0 checks the reflector with the CA bundle in the certificate directory. When that file is missing, the station stores the bundle after the signature check with the certificate inside the same message. A later digest that differs from the stored file leaves the file in place. Replacing a stored bundle automatically was rejected because the reflector could swap the trust anchor.
@@ -0,0 +1,3 @@
# 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.
+5
View File
@@ -14,6 +14,11 @@ main {
font-size: 14px;
}
#protocol {
min-height: 1.4em;
margin: 0;
}
#detail {
min-height: 1.4em;
font-size: 20px;
+2
View File
@@ -21,6 +21,8 @@ function render(state) {
document.getElementById("connection").textContent = state.connection;
document.getElementById("callsign").textContent = state.callsign;
document.getElementById("talkgroup").textContent = String(state.talkGroup);
const protocol = document.getElementById("protocol");
protocol.textContent = state.protocol ? "Protocol " + state.protocol : "";
document.getElementById("detail").textContent = line(state);
button.classList.toggle("down", !!state.transmitting);
}
+1
View File
@@ -10,6 +10,7 @@
<p id="connection">Disconnected</p>
<p>Callsign <span id="callsign"></span></p>
<p>Talk group <span id="talkgroup"></span></p>
<p id="protocol"></p>
<p id="detail">Idle</p>
<button id="ptt" type="button">Push to talk</button>
</main>
+12
View File
@@ -5,6 +5,10 @@ package opus
#cgo pkg-config: opus
#include <opus.h>
static void reset_decoder(OpusDecoder *dec) {
opus_decoder_ctl(dec, OPUS_RESET_STATE);
}
static int configure_encoder(OpusEncoder *enc) {
int err;
err = opus_encoder_ctl(enc, OPUS_SET_BITRATE(20000));
@@ -114,6 +118,14 @@ func (d *Decoder) Decode(packet []byte) ([]int16, error) {
return pcm, nil
}
// Reset clears decoder state after a gap in the received audio.
func (d *Decoder) Reset() {
if d == nil || d.dec == nil {
return
}
C.reset_decoder(d.dec)
}
// Close releases the decoder.
func (d *Decoder) Close() {
if d == nil || d.dec == nil {
+1 -1
View File
@@ -25,7 +25,7 @@ func main() {
err = wails.Run(&options.App{
Title: "funknetz",
Width: 420,
Height: 280,
Height: 320,
BackgroundColour: &options.RGBA{R: 16, G: 20, B: 24, A: 255},
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
+42 -13
View File
@@ -45,17 +45,13 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
t.Cleanup(cancel)
go func() { _ = st.Run(ctx) }()
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || len(body) != 4 || body[1] != 2 {
t.Fatalf("protocol frame type %d body %v", typ, body)
}
declineTo2(t, refl)
challenge := make([]byte, 20)
for i := range challenge {
challenge[i] = byte(i + 1)
}
refl.writeFrame(t, 10, appendBytes(nil, challenge))
typ, body = refl.readFrame(t)
typ, body := refl.readFrame(t)
if typ != 11 {
t.Fatalf("auth type %d", typ)
}
@@ -88,12 +84,41 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
if utyp != 1 || gotID != id || seq != 0 || len(ubody) != 0 {
t.Fatalf("udp heartbeat type %d id %d seq %d len %d", utyp, gotID, seq, len(ubody))
}
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" }); err != nil {
got, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connected" })
if err != nil {
t.Fatal(err)
}
if got.Protocol != "2.0" {
t.Fatalf("Protocol = %q, want 2.0", got.Protocol)
}
return loggedIn{st: st, clock: clock, refl: refl, cancel: cancel, id: id}
}
func declineTo2(t *testing.T, refl *reflector) {
t.Helper()
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("first version type %d body %v, want 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 version type %d body %v, want 2.0", typ, body)
}
}
func versionWire(major, minor uint16) []byte {
return appendU16(appendU16(nil, major), minor)
}
func versionIs(body []byte, major, minor uint16) bool {
if len(body) != 4 {
return false
}
return binary.BigEndian.Uint16(body[0:2]) == major && binary.BigEndian.Uint16(body[2:4]) == minor
}
func TestQuietSessionSendsHeartbeats(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
@@ -127,12 +152,15 @@ func TestDropRetriesUntilQuit(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
if got := in.st.State(); got.Protocol != "" {
t.Fatalf("Protocol = %q, want blank while disconnected", got.Protocol)
}
in.clock.BlockUntilWaiting(t)
in.clock.Advance(2 * time.Second)
in.refl.accept(t)
typ, body := in.refl.readFrame(t)
if typ != 5 || body[1] != 2 {
t.Fatalf("retry protocol type %d body %v", typ, body)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
}
in.cancel()
in.clock.Advance(2 * time.Second)
@@ -240,7 +268,7 @@ func TestMissingOpusStopsRetries(t *testing.T) {
defer cancel()
go func() { _ = st.Run(ctx) }()
refl.accept(t)
refl.readFrame(t)
declineTo2(t, refl)
challenge := make([]byte, 20)
refl.writeFrame(t, 10, appendBytes(nil, challenge))
refl.readFrame(t)
@@ -508,14 +536,15 @@ func expectAudio(t *testing.T, refl *reflector, dec *opus.Decoder, id, seq uint1
}
func tryFrame(r *reflector) (uint16, []byte, bool) {
_ = r.tcp.SetReadDeadline(time.Now().Add(30 * time.Millisecond))
conn := r.conn()
_ = conn.SetReadDeadline(time.Now().Add(30 * time.Millisecond))
var hdr [4]byte
if _, err := io.ReadFull(r.tcp, hdr[:]); err != nil {
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
return 0, nil, false
}
n := binary.BigEndian.Uint32(hdr[:])
buf := make([]byte, n)
if _, err := io.ReadFull(r.tcp, buf); err != nil {
if _, err := io.ReadFull(conn, buf); err != nil {
return 0, nil, false
}
if len(buf) < 2 {
+279
View File
@@ -0,0 +1,279 @@
package station
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"errors"
"io/fs"
"os"
"path/filepath"
)
const (
caFileName = "ca.pem"
keyFileName = "key.pem"
csrFileName = "csr.pem"
certFileName = "cert.pem"
)
var (
oidBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19}
oidKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 15}
oidExtKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37}
oidClientAuth = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 2}
)
func ensureCertDir(path string) error {
err := os.Mkdir(path, 0o700)
if err != nil {
if !errors.Is(err, fs.ErrExist) {
return err
}
info, statErr := os.Stat(path)
if statErr != nil {
return statErr
}
if !info.IsDir() {
return err
}
return nil
}
return os.Chmod(path, 0o700)
}
func caDigest(dir string) (sum [32]byte, ok bool, err error) {
body, err := os.ReadFile(filepath.Join(dir, caFileName))
if errors.Is(err, fs.ErrNotExist) {
return sum, false, nil
}
if err != nil {
return sum, false, err
}
return sha256.Sum256(body), true, nil
}
func verifyCABundle(caPEM string, sig []byte, certPEM string) error {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return errors.New("signing certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return err
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return errors.New("signing certificate")
}
sum := sha256.Sum256([]byte(caPEM))
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig)
}
func writeCABundle(dir, caPEM string) error {
return writeMode(filepath.Join(dir, caFileName), []byte(caPEM), 0o644)
}
func writeClientCert(dir, certPEM string) error {
return writeMode(filepath.Join(dir, certFileName), []byte(certPEM), 0o644)
}
func csrNewerThanCert(dir string) bool {
csr, err1 := os.Stat(filepath.Join(dir, csrFileName))
cert, err2 := os.Stat(filepath.Join(dir, certFileName))
if err1 != nil || err2 != nil {
return false
}
return csr.ModTime().After(cert.ModTime())
}
func readCSR(dir string) (string, error) {
body, err := os.ReadFile(filepath.Join(dir, csrFileName))
if err != nil {
return "", err
}
return string(body), nil
}
// prepareIdentity checks a saved certificate or creates the key and signing
// request. fatal is a window message when the station must stop.
func prepareIdentity(dir, callsign string) (tls.Certificate, bool, string) {
certPath := filepath.Join(dir, certFileName)
keyPath := filepath.Join(dir, keyFileName)
_, certErr := os.Stat(certPath)
_, keyErr := os.Stat(keyPath)
certExists := certErr == nil
keyExists := keyErr == nil
if certErr != nil && !errors.Is(certErr, fs.ErrNotExist) {
return tls.Certificate{}, false, "Cannot create " + certPath
}
if keyErr != nil && !errors.Is(keyErr, fs.ErrNotExist) {
return tls.Certificate{}, false, "Cannot create " + keyPath
}
if certExists && !keyExists {
return tls.Certificate{}, false, "Certificate key is missing"
}
if certExists && keyExists {
pair, msg := loadPair(certPath, keyPath, callsign)
if msg != "" {
return tls.Certificate{}, false, msg
}
return pair, true, ""
}
if msg := ensureKeyAndCSR(dir, callsign, !keyExists); msg != "" {
return tls.Certificate{}, false, msg
}
return tls.Certificate{}, false, ""
}
func loadPair(certPath, keyPath, callsign string) (tls.Certificate, string) {
cert, err := loadCert(certPath)
if err != nil {
return tls.Certificate{}, "Certificate does not match the key"
}
key, err := loadKey(keyPath)
if err != nil {
return tls.Certificate{}, "Certificate does not match the key"
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok || !pub.Equal(&key.PublicKey) {
return tls.Certificate{}, "Certificate does not match the key"
}
if cert.Subject.CommonName != callsign {
return tls.Certificate{}, "Certificate callsign does not match"
}
return tls.Certificate{
Certificate: [][]byte{cert.Raw},
PrivateKey: key,
Leaf: cert,
}, ""
}
func ensureKeyAndCSR(dir, callsign string, created bool) string {
keyPath := filepath.Join(dir, keyFileName)
var key *rsa.PrivateKey
var err error
if created {
key, err = rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return "Cannot create " + keyPath
}
der := x509.MarshalPKCS1PrivateKey(key)
body := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der})
if err := writeNew(keyPath, body, 0o600); err != nil {
return "Cannot create " + keyPath
}
} else {
key, err = loadKey(keyPath)
if err != nil {
return "Cannot create " + keyPath
}
}
csrPath := filepath.Join(dir, csrFileName)
_, csrErr := os.Stat(csrPath)
if created || errors.Is(csrErr, fs.ErrNotExist) {
if err := writeCSR(csrPath, key, callsign); err != nil {
return "Cannot create " + csrPath
}
}
return ""
}
func writeCSR(path string, key *rsa.PrivateKey, callsign string) error {
bc, err := asn1.Marshal(struct {
CA bool
}{CA: false})
if err != nil {
return err
}
// digitalSignature, keyEncipherment, keyAgreement: bits 0, 2, and 4.
ku, err := asn1.Marshal(asn1.BitString{Bytes: []byte{0xA8}, BitLength: 5})
if err != nil {
return err
}
eku, err := asn1.Marshal([]asn1.ObjectIdentifier{oidClientAuth})
if err != nil {
return err
}
der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
Subject: pkix.Name{CommonName: callsign},
ExtraExtensions: []pkix.Extension{
{Id: oidBasicConstraints, Critical: true, Value: bc},
{Id: oidKeyUsage, Critical: true, Value: ku},
{Id: oidExtKeyUsage, Critical: false, Value: eku},
},
}, key)
if err != nil {
return err
}
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})
return writeMode(path, body, 0o644)
}
func loadCert(path string) (*x509.Certificate, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(body)
if block == nil {
return nil, errors.New("certificate")
}
return x509.ParseCertificate(block.Bytes)
}
func loadKey(path string) (*rsa.PrivateKey, error) {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(body)
if block == nil {
return nil, errors.New("key")
}
switch block.Type {
case "RSA PRIVATE KEY":
return x509.ParsePKCS1PrivateKey(block.Bytes)
case "PRIVATE KEY":
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rk, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("key")
}
return rk, nil
default:
return nil, errors.New("key")
}
}
func writeNew(path string, body []byte, mode os.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
_, werr := f.Write(body)
cerr := f.Close()
if werr != nil {
return werr
}
if cerr != nil {
return cerr
}
return os.Chmod(path, mode)
}
func writeMode(path string, body []byte, mode os.FileMode) error {
if err := os.WriteFile(path, body, mode); err != nil {
return err
}
return os.Chmod(path, mode)
}
+85
View File
@@ -0,0 +1,85 @@
package station
import (
"crypto/aes"
"crypto/cipher"
"crypto/subtle"
"encoding/binary"
"errors"
)
func counterAAD(counter uint32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], counter)
return b[:]
}
func registrationAAD(clientID uint16) []byte {
b := counterAAD(0)
return appendU16(b, clientID)
}
func ivFor(prefix []byte, clientID uint16, counter uint32) []byte {
iv := make([]byte, 12)
copy(iv, prefix)
binary.BigEndian.PutUint16(iv[6:8], clientID)
binary.BigEndian.PutUint32(iv[8:12], counter)
return iv
}
func sealDatagram(key, prefix []byte, clientID uint16, counter uint32, aad, plain []byte) ([]byte, error) {
tag, ct, err := sealTrunc(key, ivFor(prefix, clientID, counter), plain, aad)
if err != nil {
return nil, err
}
wire := make([]byte, 0, len(aad)+len(tag)+len(ct))
wire = append(wire, aad...)
wire = append(wire, tag...)
return append(wire, ct...), nil
}
func openDatagram(key, prefix []byte, clientID uint16, counter uint32, aad, tag, ct []byte) ([]byte, error) {
return openTrunc(key, ivFor(prefix, clientID, counter), tag, ct, aad)
}
// The protocol's tag is 8 bytes. crypto/cipher only builds GCM tags of 12 to 16
// bytes, and an 8-byte tag is the leading 8 bytes of the ordinary 16-byte tag.
func sealTrunc(key, nonce, plain, aad []byte) (tag, ct []byte, err error) {
g, err := gcmFull(key)
if err != nil {
return nil, nil, err
}
out := g.Seal(nil, nonce, plain, aad)
ct = append([]byte(nil), out[:len(plain)]...)
tag = append([]byte(nil), out[len(plain):len(plain)+8]...)
return tag, ct, nil
}
func openTrunc(key, nonce, tag, ct, aad []byte) ([]byte, error) {
if len(tag) != 8 {
return nil, errors.New("tag")
}
g, err := gcmFull(key)
if err != nil {
return nil, err
}
stream := g.Seal(nil, nonce, make([]byte, len(ct)), aad)
plain := make([]byte, len(ct))
subtle.XORBytes(plain, ct, stream[:len(ct)])
check := g.Seal(nil, nonce, plain, aad)
var got, want [8]byte
copy(got[:], tag)
copy(want[:], check[len(plain):len(plain)+8])
if subtle.ConstantTimeCompare(got[:], want[:]) != 1 {
return nil, errors.New("tag")
}
return plain, nil
}
func gcmFull(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
+66 -27
View File
@@ -15,34 +15,38 @@ import (
)
type loaded struct {
host string
port int
callsign string
authKey string
talkGroup int
enableLogging bool
logPath string
logNotice string
host string
port int
callsign string
authKey string
talkGroup int
certificateDir string
certFatal string
enableLogging bool
logPath string
logNotice string
}
type fileShape struct {
Host jsontext.Value `json:"host"`
Port jsontext.Value `json:"port"`
Callsign jsontext.Value `json:"callsign"`
AuthKey jsontext.Value `json:"authKey"`
TalkGroup jsontext.Value `json:"talkGroup"`
EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
Host jsontext.Value `json:"host"`
Port jsontext.Value `json:"port"`
Callsign jsontext.Value `json:"callsign"`
AuthKey jsontext.Value `json:"authKey"`
TalkGroup jsontext.Value `json:"talkGroup"`
CertificateDir jsontext.Value `json:"certificateDir"`
EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
}
type templateFile struct {
Host string `json:"host"`
Port int `json:"port"`
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
Host string `json:"host"`
Port int `json:"port"`
Callsign string `json:"callsign"`
AuthKey string `json:"authKey"`
TalkGroup int `json:"talkGroup"`
CertificateDir string `json:"certificateDir"`
EnableLogging bool `json:"enableLogging"`
LogFile string `json:"logFile"`
}
var callsignPattern = regexp.MustCompile(`^[A-Z0-9][A-Z]{0,2}\d[A-Z0-9]{0,3}[A-Z](?:-[A-Z0-9]{1,3})?$`)
@@ -66,7 +70,7 @@ func writeTemplate(path string) error {
if err != nil {
return err
}
body, err := jsonv2.Marshal(templateFile{LogFile: logFile})
body, err := jsonv2.Marshal(templateFile{CertificateDir: "./funknetz", LogFile: logFile})
if err != nil {
return err
}
@@ -91,15 +95,19 @@ func parseConfig(path string, body []byte) (loaded, error) {
if err := jsonv2.Unmarshal(body, &shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err)
}
if block, notice := blockedLog(shape); block {
certBad := present(shape.CertificateDir) && shape.CertificateDir.Kind() != jsontext.KindString
if block, notice := blockedLog(shape); block || certBad {
cfg, err := parseOperational(shape)
if err != nil {
return loaded{}, err
}
cfg.logNotice = notice
return cfg, nil
if !block {
cfg = applyLog(cfg, shape)
}
return applyCertificate(cfg, shape, path), nil
}
if missingLog(shape) {
if missingCertificate(shape) || missingLog(shape) {
if !operationalKindsOK(shape) {
return parseOperational(shape)
}
@@ -116,7 +124,7 @@ func parseConfig(path string, body []byte) (loaded, error) {
if err != nil {
return loaded{}, err
}
return applyLog(cfg, shape), nil
return applyLog(applyCertificate(cfg, shape, path), shape), nil
}
func parseOperational(shape fileShape) (loaded, error) {
@@ -217,6 +225,10 @@ func missingLog(shape fileShape) bool {
return !present(shape.EnableLogging) || !present(shape.LogFile)
}
func missingCertificate(shape fileShape) bool {
return !present(shape.CertificateDir)
}
func operationalKindsOK(shape fileShape) bool {
if present(shape.Host) && shape.Host.Kind() != jsontext.KindString {
return false
@@ -259,6 +271,7 @@ func withLogDefaults(shape fileShape) (fileShape, error) {
shape.Callsign = rawOr(shape.Callsign, `""`)
shape.AuthKey = rawOr(shape.AuthKey, `""`)
shape.TalkGroup = rawOr(shape.TalkGroup, "0")
shape.CertificateDir = rawOr(shape.CertificateDir, `"./funknetz"`)
shape.EnableLogging = rawOr(shape.EnableLogging, "false")
if !present(shape.LogFile) {
logFile, err := defaultLogPath()
@@ -316,6 +329,32 @@ func rewriteConfig(path string, shape fileShape) error {
return nil
}
func applyCertificate(cfg loaded, shape fileShape, configPath string) loaded {
if !present(shape.CertificateDir) {
cfg.certificateDir = resolveCertDir(configPath, "./funknetz")
return cfg
}
dir, err := stringField("certificateDir", shape.CertificateDir)
if err != nil {
cfg.certFatal = err.Error()
return cfg
}
if strings.TrimSpace(dir) == "" {
cfg.certFatal = "certificateDir is empty"
return cfg
}
cfg.certificateDir = resolveCertDir(configPath, dir)
return cfg
}
func resolveCertDir(configPath, dir string) string {
dir = strings.TrimSpace(dir)
if filepath.IsAbs(dir) {
return filepath.Clean(dir)
}
return filepath.Clean(filepath.Join(filepath.Dir(configPath), dir))
}
func applyLog(cfg loaded, shape fileShape) loaded {
if shape.EnableLogging.Kind() != jsontext.KindTrue {
return cfg
+113 -4
View File
@@ -1,10 +1,14 @@
package station_test
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
jsonv2 "encoding/json/v2"
@@ -54,11 +58,15 @@ func TestMissingConfigIsCreatedAndRejected(t *testing.T) {
if got["logFile"] != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"])
}
if got["certificateDir"] != "./funknetz" {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
}
assertKeyOrder(t, body)
}
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,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
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")
if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err)
}
@@ -240,6 +248,10 @@ func TestMissingLogKeysAreRewritten(t *testing.T) {
if got["logFile"] != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v", got["logFile"])
}
if got["certificateDir"] != "./funknetz" {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
}
assertKeyOrder(t, body)
if st.State().Detail != "Idle" {
t.Fatalf("Detail = %q, want Idle", st.State().Detail)
}
@@ -314,9 +326,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,"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,"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,"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,"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: "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},
@@ -368,6 +380,103 @@ func TestLogSettingsDoNotStopTheStation(t *testing.T) {
})
}
func assertKeyOrder(t *testing.T, body []byte) {
t.Helper()
keys := []string{`"host"`, `"port"`, `"callsign"`, `"authKey"`, `"talkGroup"`, `"certificateDir"`, `"enableLogging"`, `"logFile"`}
last := -1
for _, key := range keys {
i := strings.Index(string(body), key)
if i < 0 || i < last {
t.Fatalf("key %s out of order in %s", key, body)
}
last = i
}
}
func TestMissingCertificateDirIsWrittenAfterTalkGroup(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,\"enableLogging\":false,\"logFile\":\"/tmp/funknetz.log\"}\n")
if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err)
}
if _, err := station.New(station.Options{ConfigPath: path}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got, want := info.Mode().Perm(), os.FileMode(0o640); got != want {
t.Fatalf("mode = %o, want %o", got, want)
}
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var got map[string]any
if err := jsonv2.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["authKey"] != "a b" || got["callsign"] != "DL1ABC" || got["certificateDir"] != "./funknetz" {
t.Fatalf("fields = %#v", got)
}
if strings.Contains(string(body), t.TempDir()) {
t.Fatalf("file = %s, want the stored directory to stay relative", body)
}
assertKeyOrder(t, body)
}
func TestCertificateDirStopsBeforeASocket(t *testing.T) {
tests := []struct {
name string
dir string
want string
}{
{name: "wrong type", dir: "1", want: "certificateDir has the wrong type"},
{name: "empty", dir: `""`, want: "certificateDir is empty"},
{name: "whitespace", dir: `" "`, want: "certificateDir is empty"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
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)
path := filepath.Join(t.TempDir(), "funknetz.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
st, err := station.New(station.Options{ConfigPath: path})
if err != nil {
t.Fatal(err)
}
t.Cleanup(st.Close)
got := st.State()
if got.Detail != tt.want || got.Connection != "Disconnected" || got.Callsign != "DL1ABC" {
t.Fatalf("State = %+v, want detail %q", got, tt.want)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(after) != body {
t.Fatalf("file = %s, want it left untouched", after)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go func() { _ = st.Run(ctx) }()
ln.(*net.TCPListener).SetDeadline(time.Now().Add(200 * time.Millisecond))
if conn, err := ln.Accept(); err == nil {
conn.Close()
t.Fatal("opened a socket")
}
})
}
}
func configJSON(t testing.TB, host string, port int, callsign, authKey string, talkGroup int) string {
t.Helper()
body, err := jsonv2.Marshal(map[string]any{
+22
View File
@@ -2,9 +2,11 @@ package station_test
import (
"context"
"errors"
"net"
"os"
"strconv"
"syscall"
"testing"
"time"
@@ -30,6 +32,9 @@ func TestConnectsToLabReflector(t *testing.T) {
t.Skipf("lab reflector not listening on %s: %v", addr, err)
}
conn.Close()
if !labUDPReachable() {
t.Skipf("lab UDP port %s is not reachable", addr)
}
path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
st, err := station.New(station.Options{ConfigPath: path})
@@ -54,3 +59,20 @@ func TestConnectsToLabReflector(t *testing.T) {
t.Fatalf("talkGroup = %d, want %d", state.TalkGroup, labTalkGroup)
}
}
func labUDPReachable() bool {
peer := &net.UDPAddr{IP: net.ParseIP(labHost), Port: labPort}
conn, err := net.DialUDP("udp", nil, peer)
if err != nil {
return false
}
defer conn.Close()
_, _ = conn.Write([]byte{0})
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
_, err = conn.Read(make([]byte, 1))
if err == nil {
return true
}
var op *net.OpError
return !errors.As(err, &op) || !errors.Is(op.Err, syscall.ECONNREFUSED)
}
+72 -5
View File
@@ -4,26 +4,47 @@ import (
"encoding/binary"
"fmt"
"io"
"regexp"
"strconv"
)
const (
tcpHeartbeat = 1
tcpProtoVer = 5
tcpProtoDown = 6
tcpAuthChallenge = 10
tcpAuthResponse = 11
tcpAuthOK = 12
tcpError = 13
tcpStartEncReq = 14
tcpStartEnc = 15
tcpCsrReq = 16
tcpCsr = 17
tcpClientCert = 18
tcpCAInfo = 19
tcpCABundleReq = 20
tcpCABundle = 21
tcpServerInfo = 100
tcpNodeList = 101
tcpNodeJoined = 102
tcpNodeLeft = 103
tcpTalkerStart = 104
tcpTalkerStop = 105
tcpSelectTG = 106
tcpRequestQSY = 109
tcpStateEvent = 110
tcpNodeInfo = 111
tcpSignal = 112
tcpTxStatus = 113
tcpStartUDPEnc = 114
udpHeartbeat = 1
udpAudio = 101
udpFlush = 102
udpHeartbeat = 1
udpAudio = 101
udpFlush = 102
udpAllFlushed = 103
maxFrame = 32768
maxFrame = 32768
maxPreauth20 = 64
)
type frame struct {
@@ -45,12 +66,16 @@ func writeFrame(w io.Writer, typ uint16, body []byte) error {
}
func readFrame(r io.Reader) (frame, error) {
return readFrameMax(r, maxFrame)
}
func readFrameMax(r io.Reader, max uint32) (frame, error) {
var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return frame{}, err
}
n := binary.BigEndian.Uint32(hdr[:])
if n < 2 || n > maxFrame {
if n < 2 || n > max {
return frame{}, fmt.Errorf("frame length %d", n)
}
buf := make([]byte, n)
@@ -149,6 +174,48 @@ func parseServerInfo(b []byte) (uint16, []string, error) {
return id, codecs, nil
}
func versionWire(major, minor uint16) []byte {
return appendU16(appendU16(nil, major), minor)
}
func parseVersion(b []byte) (uint16, uint16, error) {
major, rest, err := parseU16(b)
if err != nil {
return 0, 0, err
}
minor, rest, err := parseU16(rest)
if err != nil {
return 0, 0, err
}
if len(rest) != 0 {
return 0, 0, io.ErrUnexpectedEOF
}
return major, minor, nil
}
var atMostVersion = regexp.MustCompile(`^Unsupported protocol version \d+\.\d+\. May be at most (\d+)\.(\d+)\.$`)
// fallback20 reports that the reflector's maximum is at least 2.0 and below 3.0.
func fallback20(msg string) bool {
m := atMostVersion.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 !versionBelow(major, minor, 2, 0) && versionBelow(major, minor, 3, 0)
}
func versionBelow(major, minor, maj, min int) bool {
if major != maj {
return major < maj
}
return minor < min
}
func hasOPUS(codecs []string) bool {
for _, c := range codecs {
if c == "OPUS" {
+235
View File
@@ -0,0 +1,235 @@
package station_test
import (
"context"
"encoding/binary"
"testing"
"time"
"gitea.neitzel.de/konrad/funknetz/internal/opus"
"gitea.neitzel.de/konrad/funknetz/station"
)
func TestProtocol2DropsASequenceTooFarBehind(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
enc, err := opus.NewEncoder()
if err != nil {
t.Fatal(err)
}
defer enc.Close()
pkt, err := enc.Encode(make([]int16, opus.FrameSamples))
if err != nil {
t.Fatal(err)
}
in.refl.writeUDP(t, 101, 7, 32768, appendBytes(nil, pkt))
in.refl.writeUDP(t, 101, 7, 4, appendBytes(nil, pkt))
if _, err := in.st.WaitPlayback(t.Context(), opus.FrameSamples); err != nil {
t.Fatal(err)
}
if extra := in.st.TakePlayback(); len(extra) != 0 {
t.Fatalf("played %d extra samples from the old sequence", len(extra))
}
}
func TestProtocol2FlushCompletesOnce(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
in.st.HoldButton()
in.st.ReleaseButton()
typ, id, _, body := in.refl.readUDP(t)
if typ != 102 || id != 7 || len(body) != 0 {
t.Fatalf("flush type %d id %d len %d", typ, id, len(body))
}
in.refl.writeUDP(t, 103, 7, 0, nil)
in.clock.BlockUntilWaiting(t)
in.clock.Advance(3 * time.Second)
if pkt, _, ok := tryUDP(in.refl); ok {
t.Fatalf("sent %d bytes after flush-complete", len(pkt))
}
if got := in.st.State(); got.Connection != "Connected" || got.Protocol != "2.0" {
t.Fatalf("State = %+v", got)
}
}
func TestProtocol2FlushTimesOutWithoutASecondFlush(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
in.st.HoldButton()
in.st.ReleaseButton()
typ, _, _, _ := in.refl.readUDP(t)
if typ != 102 {
t.Fatalf("type %d, want flush", typ)
}
in.clock.BlockUntilWaiting(t)
in.clock.Advance(3 * time.Second)
if pkt, _, ok := tryUDP(in.refl); ok {
t.Fatalf("sent %d bytes when flush-complete did not arrive", len(pkt))
}
}
func TestSilenceFreesPushToTalkAndKeepsTheTalker(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
enc, err := opus.NewEncoder()
if err != nil {
t.Fatal(err)
}
defer enc.Close()
pkt, err := enc.Encode(make([]int16, opus.FrameSamples))
if err != nil {
t.Fatal(err)
}
in.refl.writeUDP(t, 101, 7, 0, appendBytes(nil, pkt))
if _, err := in.st.WaitPlayback(t.Context(), opus.FrameSamples); err != nil {
t.Fatal(err)
}
in.refl.writeFrame(t, 104, appendStr(appendU32(nil, 20), "DB0XYZ"))
if _, err := in.st.Wait(t.Context(), func(s station.State) bool { return s.Talker == "DB0XYZ" }); err != nil {
t.Fatal(err)
}
in.clock.BlockUntilWaiting(t)
in.clock.Advance(3 * time.Second)
in.st.HoldButton()
in.st.WriteMicrophone(make([]int16, opus.FrameSamples))
if _, _, ok := tryUDP(in.refl); ok {
t.Fatal("push-to-talk sent audio at exactly 3 seconds")
}
in.st.ReleaseButton()
in.clock.BlockUntilWaiting(t)
gen := in.clock.Generation()
in.clock.Advance(time.Second)
waitClock(t, in.clock, gen)
if got := in.st.State(); got.Talker != "DB0XYZ" || got.Transmitting {
t.Fatalf("State = %+v, want the talker kept and the button free", got)
}
in.st.HoldButton()
if !in.st.State().Transmitting {
t.Fatal("push-to-talk stayed blocked after the gap")
}
in.st.WriteMicrophone(make([]int16, opus.FrameSamples))
utyp, id, _, _ := in.refl.readUDP(t)
if utyp != 101 || id != 7 {
t.Fatalf("audio type %d id %d", utyp, id)
}
}
func TestUDPLimitRunsBeforeAnyDatagram(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() { _ = st.Run(ctx) }()
refl.accept(t)
declineTo2(t, refl)
for step := 0; step < 6; step++ {
clock.BlockUntilWaiting(t)
clock.Advance(10 * time.Second)
if st.State().Connection == "Disconnected" && step < 5 {
t.Fatalf("dropped on step %d", step)
}
replyTCP(t, refl)
}
if _, err := st.Wait(t.Context(), func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
t.Fatal(err)
}
if got := st.State().Detail; got != "udp receive timeout" {
t.Fatalf("Detail = %q, want udp receive timeout", got)
}
clock.BlockUntilWaiting(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)
}
}
func TestOversizedFrameBeforeAuthRetries(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() { _ = st.Run(ctx) }()
refl.accept(t)
declineTo2(t, refl)
payload := make([]byte, 65)
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], 65)
if _, err := refl.tcp.Write(append(hdr[:], payload...)); err != nil {
t.Fatal(err)
}
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)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry type %d body %v, want 3.0", typ, body)
}
}
func TestApplicationMessageBeforeLoginRetries(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() { _ = st.Run(ctx) }()
refl.accept(t)
declineTo2(t, refl)
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 {
t.Fatal(err)
}
if got.Connection != "Disconnected" || got.Protocol != "" {
t.Fatalf("State = %+v", got)
}
clock.BlockUntilWaiting(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 st.State().Detail != "Protocol error" {
t.Fatalf("Detail = %q, want the reason kept", st.State().Detail)
}
}
func TestIgnoredNoticesDoNotChangeTheStation(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
in.refl.writeFrame(t, 101, appendStr(appendU16(nil, 1), "DB0XYZ"))
in.refl.writeFrame(t, 102, appendStr(nil, "DB0XYZ"))
in.refl.writeFrame(t, 103, appendStr(nil, "DB0XYZ"))
in.refl.writeFrame(t, 109, appendU32(nil, 99))
body := appendStr(nil, "src")
body = appendStr(body, "name")
body = appendStr(body, "msg")
in.refl.writeFrame(t, 110, body)
in.refl.writeFrame(t, 112, append(appendU16(nil, 1), []byte{1, 40, 1}...))
in.refl.writeFrame(t, 113, append(appendU16(nil, 1), []byte{1, 1}...))
in.clock.BlockUntilWaiting(t)
in.clock.Advance(10 * time.Second)
typ, _ := in.refl.readFrame(t)
if typ != 1 {
t.Fatalf("TCP type %d, want a heartbeat", typ)
}
got := in.st.State()
if got.Connection != "Connected" || got.Protocol != "2.0" || got.TalkGroup != 20 || got.Talker != "" {
t.Fatalf("State = %+v", got)
}
}
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
package station_test
import (
"context"
"net"
"os"
"testing"
"time"
"gitea.neitzel.de/konrad/funknetz/station"
)
func TestVersionErrorFallsBackWhenTheMaximumIs2(t *testing.T) {
for _, msg := range []string{
"Unsupported protocol version 3.0. May be at most 2.0.",
"Unsupported protocol version 3.0. May be at most 2.5.",
} {
t.Run(msg, func(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() { _ = st.Run(ctx) }()
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("type %d body %v", typ, body)
}
refl.writeFrame(t, 13, appendStr(nil, msg))
refl.accept(t)
typ, body = refl.readFrame(t)
if typ != 5 || !versionIs(body, 2, 0) {
t.Fatalf("fallback type %d body %v", typ, body)
}
})
}
}
func TestVersionErrorBelow2Stops(t *testing.T) {
for _, msg := range []string{
"Unsupported protocol version 3.0. May be at most 1.9.",
"Unsupported protocol version 3.0. May be at most 0.6.",
} {
t.Run(msg, func(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() { _ = st.Run(ctx) }()
refl.accept(t)
refl.readFrame(t)
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")
}
})
}
}
func TestDowngradeOtherThan2Stops(t *testing.T) {
for _, tc := range []struct {
major, minor uint16
want string
}{
{1, 0, "Unsupported protocol version 1.0"},
{4, 2, "Unsupported protocol version 4.2"},
} {
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))
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() { _ = st.Run(ctx) }()
refl.accept(t)
refl.readFrame(t)
refl.writeFrame(t, 6, versionWire(tc.major, tc.minor))
got, err := st.Wait(ctx, func(s station.State) bool { return s.Detail == tc.want })
if err != nil {
t.Fatal(err)
}
if got.Protocol != "" || got.Connection != "Disconnected" {
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")
}
})
}
}
func TestDowngradeDoesNotCreateTheCertificateDirectory(t *testing.T) {
refl := newReflector(t)
dir := t.TempDir()
path := dir + "/funknetz.json"
body := configWithDir(t, "127.0.0.1", refl.port, "DL1ABC", "key", 20, "./funknetz", "")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
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() { _ = st.Run(ctx) }()
refl.accept(t)
declineTo2(t, refl)
if _, err := os.Stat(dir + "/funknetz"); err == nil {
t.Fatal("created the certificate directory on a 2.0 fallback")
}
}
+63 -7
View File
@@ -1,6 +1,7 @@
package station_test
import (
"crypto/tls"
"encoding/binary"
"encoding/json"
"io"
@@ -133,10 +134,18 @@ type reflector struct {
ln net.Listener
udp *net.UDPConn
tcp net.Conn
tls *tls.Conn
peer *net.UDPAddr
port int
}
func (r *reflector) conn() net.Conn {
if r.tls != nil {
return r.tls
}
return r.tcp
}
func newReflector(t *testing.T) *reflector {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
@@ -166,16 +175,18 @@ func (r *reflector) accept(t *testing.T) {
t.Fatal(err)
}
r.tcp = conn
r.tls = nil
t.Cleanup(func() { conn.Close() })
}
func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
t.Helper()
if err := r.tcp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
conn := r.conn()
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
var hdr [4]byte
if _, err := io.ReadFull(r.tcp, hdr[:]); err != nil {
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
t.Fatal(err)
}
n := binary.BigEndian.Uint32(hdr[:])
@@ -183,7 +194,7 @@ func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
t.Fatalf("frame length %d", n)
}
buf := make([]byte, n)
if _, err := io.ReadFull(r.tcp, buf); err != nil {
if _, err := io.ReadFull(conn, buf); err != nil {
t.Fatal(err)
}
if len(buf) < 2 {
@@ -199,14 +210,29 @@ func (r *reflector) writeFrame(t *testing.T, typ uint16, body []byte) {
copy(payload[2:], body)
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload)))
if err := r.tcp.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
conn := r.conn()
if err := conn.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := r.tcp.Write(append(hdr[:], payload...)); err != nil {
if _, err := conn.Write(append(hdr[:], payload...)); err != nil {
t.Fatal(err)
}
}
func (r *reflector) handshake(t *testing.T, cfg *tls.Config) error {
t.Helper()
r.tls = tls.Server(r.tcp, cfg)
if err := r.tls.SetDeadline(time.Now().Add(2 * time.Second)); err != nil {
return err
}
err := r.tls.Handshake()
_ = r.tls.SetDeadline(time.Time{})
if err != nil {
r.tls = nil
}
return err
}
func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
t.Helper()
if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
@@ -228,6 +254,33 @@ func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
return typ, id, seq, body
}
func (r *reflector) readRawUDP(t *testing.T) []byte {
t.Helper()
if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
buf := make([]byte, 2048)
n, addr, err := r.udp.ReadFromUDP(buf)
if err != nil {
t.Fatal(err)
}
r.peer = addr
return append([]byte(nil), buf[:n]...)
}
func (r *reflector) writeRawUDP(t *testing.T, pkt []byte) {
t.Helper()
if r.peer == nil {
t.Fatal("no udp peer")
}
if err := r.udp.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
t.Fatal(err)
}
if _, err := r.udp.WriteToUDP(pkt, r.peer); err != nil {
t.Fatal(err)
}
}
func (r *reflector) writeUDP(t *testing.T, typ, id, seq uint16, body []byte) {
t.Helper()
if r.peer == nil {
@@ -294,8 +347,11 @@ func requireSoftwareIdentity(t *testing.T, body []byte) {
if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatalf("software identity %q: %v", raw, err)
}
if len(got) != 2 || got["sw"] != station.SoftwareName || got["swVer"] != station.SoftwareVersion {
t.Fatalf("software identity = %#v, want sw %q and swVer %q only", got, station.SoftwareName, station.SoftwareVersion)
if len(got) != 3 || got["sw"] != station.SoftwareName || got["swVer"] != station.SoftwareVersion || got["projVer"] != station.SoftwareVersion {
t.Fatalf("software identity = %#v, want sw, swVer, and projVer %q", got, station.SoftwareName)
}
if _, ok := got["machineArch"]; ok {
t.Fatal("protocol 2.0 node information includes machineArch")
}
}
+874 -170
View File
File diff suppressed because it is too large Load Diff
+12 -6
View File
@@ -27,14 +27,20 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" {
t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle", got)
if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" || got.Protocol != "" {
t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle and a blank protocol", got)
}
refl.accept(t)
typ, body := refl.readFrame(t)
if typ != 5 || len(body) != 4 || body[0] != 0 || body[1] != 2 || body[2] != 0 || body[3] != 0 {
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body)
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)
}
challenge := make([]byte, 20)
@@ -91,8 +97,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.Detail != "Idle" || got.Talker != "" {
t.Fatalf("State = %+v, want Connected Idle", got)
if got.Detail != "Idle" || got.Talker != "" || got.Protocol != "2.0" {
t.Fatalf("State = %+v, want Connected Idle protocol 2.0", got)
}
// No other talk group: the next TCP message is a heartbeat, not a monitor set.
+53 -8
View File
@@ -1,6 +1,11 @@
package station
import jsonv2 "encoding/json/v2"
import (
"bytes"
jsonv2 "encoding/json/v2"
"golang.org/x/sys/unix"
)
// SoftwareName and SoftwareVersion are the identity sent to the reflector
// after login. Edit them here.
@@ -9,16 +14,56 @@ const (
SoftwareVersion = "1.0.0"
)
type nodeSoftware struct {
SW string `json:"sw"`
SWVer string `json:"swVer"`
ProjVer string `json:"projVer"`
MachineArch string `json:"machineArch,omitempty"`
}
func softwareIdentityBody() ([]byte, error) {
raw, err := jsonv2.Marshal(struct {
SW string `json:"sw"`
SWVer string `json:"swVer"`
}{
SW: SoftwareName,
SWVer: SoftwareVersion,
})
raw, err := marshalNode(false)
if err != nil {
return nil, err
}
return appendString(nil, string(raw)), nil
}
func nodeInfoV3(iv, key []byte) ([]byte, error) {
raw, err := marshalNode(true)
if err != nil {
return nil, err
}
body := appendBlob(nil, iv)
body = appendBlob(body, key)
return appendString(body, string(raw)), nil
}
func marshalNode(arch bool) ([]byte, error) {
info := nodeSoftware{
SW: SoftwareName,
SWVer: SoftwareVersion,
ProjVer: SoftwareVersion,
}
if arch {
if name, ok := machineArch(); ok {
info.MachineArch = name
}
}
return jsonv2.Marshal(info)
}
func machineArch() (string, bool) {
var u unix.Utsname
if err := unix.Uname(&u); err != nil {
return "", false
}
b := u.Machine[:]
if i := bytes.IndexByte(b, 0); i >= 0 {
b = b[:i]
}
if len(b) == 0 {
return "", false
}
return string(b), true
}
+19 -1
View File
@@ -24,6 +24,7 @@ type State struct {
Detail string `json:"detail"`
Callsign string `json:"callsign"`
TalkGroup int `json:"talkGroup"`
Protocol string `json:"protocol"`
Talker string `json:"talker"`
Transmitting bool `json:"transmitting"`
}
@@ -39,13 +40,27 @@ type Station struct {
buttonHeld bool
spaceHeld bool
fatal string
retryReason string
protocol string
remoteTalker string
roomBusy bool
audioHeard bool
lastAudio time.Time
flushWaiting bool
flushAt time.Time
rxSeq uint16
secure bool
udpWait bool
ivPrefix []byte
udpKey []byte
rxCounter uint32
txCounter uint32
mic []int16
play []int16
clientID uint16
seq uint16
udp *net.UDPConn
udpPeer *net.UDPAddr
lastUDPSend time.Time
enc *opus.Encoder
dec *opus.Decoder
@@ -73,7 +88,9 @@ func New(opt Options) (*Station, error) {
clock = wallClock{}
}
detail := "Idle"
if cfg.logNotice != "" {
if cfg.certFatal != "" {
detail = cfg.certFatal
} else if cfg.logNotice != "" {
detail = cfg.logNotice
}
s := &Station{
@@ -82,6 +99,7 @@ func New(opt Options) (*Station, error) {
wake: make(chan struct{}, 1),
logPath: cfg.logPath,
logNotice: cfg.logNotice,
fatal: cfg.certFatal,
state: State{
Connection: "Disconnected",
Detail: detail,
+60
View File
@@ -0,0 +1,60 @@
package station
import (
"crypto/tls"
"crypto/x509"
"errors"
"net"
"strings"
)
var errHostMismatch = errors.New("host mismatch")
func tlsConfig(host string, caPEM []byte, client tls.Certificate, present bool) (*tls.Config, error) {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, errors.New("ca bundle")
}
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
RootCAs: pool,
InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error {
return verifyPeer(host, pool, cs)
},
}
if present {
cfg.Certificates = []tls.Certificate{client}
}
return cfg, nil
}
func verifyPeer(host string, pool *x509.CertPool, cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return errors.New("no peer certificate")
}
leaf := cs.PeerCertificates[0]
opts := x509.VerifyOptions{Roots: pool, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
if len(cs.PeerCertificates) > 1 {
opts.Intermediates = x509.NewCertPool()
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
}
if _, err := leaf.Verify(opts); err != nil {
return err
}
host = strings.TrimSuffix(host, ".")
if ip := net.ParseIP(host); ip != nil {
for _, cand := range leaf.IPAddresses {
if cand.Equal(ip) {
return nil
}
}
return errHostMismatch
}
if err := leaf.VerifyHostname(host); err != nil {
return errHostMismatch
}
return nil
}