Compare commits

..
3 Commits
Author SHA1 Message Date
konradandCursor e642b24be0 Separate the window sentence from the wrapped error so a bad certificate file is not reported as a key mismatch.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 15:01:38 +02:00
konradandCursor a405048f23 Write a missing signing request and give up on a handshake that never returns.
A certificate already on disk still has to be renewable, and a reflector that never finishes TLS must not hold the session past the login timers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-07 14:36:28 +02:00
konradandCursor 3d1a2a1669 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>
2026-10-07 14:24:00 +02:00
29 changed files with 3420 additions and 280 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 _Avoid_: a lowercase spelling, username, node id
**Auth key**: **Auth key**:
The secret the network issues for this station’s callsign, used to prove identity to the reflector. 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_: certificate, a password sent in the clear _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**: **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.
@@ -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; font-size: 14px;
} }
#protocol {
min-height: 1.4em;
margin: 0;
}
#detail { #detail {
min-height: 1.4em; min-height: 1.4em;
font-size: 20px; font-size: 20px;
+2
View File
@@ -21,6 +21,8 @@ function render(state) {
document.getElementById("connection").textContent = state.connection; document.getElementById("connection").textContent = state.connection;
document.getElementById("callsign").textContent = state.callsign; document.getElementById("callsign").textContent = state.callsign;
document.getElementById("talkgroup").textContent = String(state.talkGroup); 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); document.getElementById("detail").textContent = line(state);
button.classList.toggle("down", !!state.transmitting); button.classList.toggle("down", !!state.transmitting);
} }
+1
View File
@@ -10,6 +10,7 @@
<p id="connection">Disconnected</p> <p id="connection">Disconnected</p>
<p>Callsign <span id="callsign"></span></p> <p>Callsign <span id="callsign"></span></p>
<p>Talk group <span id="talkgroup"></span></p> <p>Talk group <span id="talkgroup"></span></p>
<p id="protocol"></p>
<p id="detail">Idle</p> <p id="detail">Idle</p>
<button id="ptt" type="button">Push to talk</button> <button id="ptt" type="button">Push to talk</button>
</main> </main>
+63
View File
@@ -0,0 +1,63 @@
// Package gcm64 seals and opens AES-GCM with the 8-byte tag this protocol uses.
package gcm64
import (
"crypto/aes"
"crypto/cipher"
"crypto/subtle"
"encoding/binary"
"errors"
)
// IV is the 12-byte nonce: a 6-byte prefix, the client id, and the counter.
func IV(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
}
// Seal returns an 8-byte tag and the ciphertext. The tag is the leading 8
// bytes of the ordinary 16-byte GCM tag, because crypto/cipher only builds
// tags of 12 to 16 bytes.
func Seal(key, nonce, plain, aad []byte) (tag, ct []byte, err error) {
g, err := full(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
}
// Open checks the 8-byte tag and returns the plaintext.
func Open(key, nonce, tag, ct, aad []byte) ([]byte, error) {
if len(tag) != 8 {
return nil, errors.New("tag")
}
g, err := full(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 full(key []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
+12
View File
@@ -5,6 +5,10 @@ package opus
#cgo pkg-config: opus #cgo pkg-config: opus
#include <opus.h> #include <opus.h>
static void reset_decoder(OpusDecoder *dec) {
opus_decoder_ctl(dec, OPUS_RESET_STATE);
}
static int configure_encoder(OpusEncoder *enc) { static int configure_encoder(OpusEncoder *enc) {
int err; int err;
err = opus_encoder_ctl(enc, OPUS_SET_BITRATE(20000)); err = opus_encoder_ctl(enc, OPUS_SET_BITRATE(20000));
@@ -114,6 +118,14 @@ func (d *Decoder) Decode(packet []byte) ([]int16, error) {
return pcm, nil 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. // Close releases the decoder.
func (d *Decoder) Close() { func (d *Decoder) Close() {
if d == nil || d.dec == nil { if d == nil || d.dec == nil {
+1 -1
View File
@@ -25,7 +25,7 @@ func main() {
err = wails.Run(&options.App{ err = wails.Run(&options.App{
Title: "funknetz", Title: "funknetz",
Width: 420, Width: 420,
Height: 280, Height: 320,
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,
+58 -17
View File
@@ -43,19 +43,18 @@ func loginLog(t *testing.T, callsign, key string, tg int, id uint16, codecs []st
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel) t.Cleanup(cancel)
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t) refl.accept(t)
declineTo2(t, refl)
typ, body := refl.readFrame(t)
if typ != 5 || len(body) != 4 || body[1] != 2 {
t.Fatalf("protocol frame type %d body %v", typ, body)
}
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)
} }
refl.writeFrame(t, 10, appendBytes(nil, challenge)) refl.writeFrame(t, 10, appendBytes(nil, challenge))
typ, body = refl.readFrame(t) typ, body := refl.readFrame(t)
if typ != 11 { if typ != 11 {
t.Fatalf("auth type %d", typ) t.Fatalf("auth type %d", typ)
} }
@@ -88,12 +87,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 { 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)) 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) 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} 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) { func TestQuietSessionSendsHeartbeats(t *testing.T) {
in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"}) in := login(t, "DL1ABC", "key", 20, 7, []string{"OPUS"})
@@ -127,12 +155,15 @@ func TestDropRetriesUntilQuit(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatal(err) 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.BlockUntilWaiting(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 || body[1] != 2 { if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("retry protocol type %d body %v", typ, body) t.Fatalf("retry protocol type %d body %v, want 3.0", typ, body)
} }
in.cancel() in.cancel()
in.clock.Advance(2 * time.Second) in.clock.Advance(2 * time.Second)
@@ -159,7 +190,10 @@ func TestRefusedConnectionRetriesUntilQuit(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil { if _, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Disconnected" }); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -204,7 +238,10 @@ func TestReflectorErrorStopsRetries(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t) refl.accept(t)
refl.readFrame(t) refl.readFrame(t)
refl.writeFrame(t, 13, appendStr(nil, msg)) refl.writeFrame(t, 13, appendStr(nil, msg))
@@ -238,9 +275,12 @@ func TestMissingOpusStopsRetries(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t) refl.accept(t)
refl.readFrame(t) declineTo2(t, refl)
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)
@@ -508,14 +548,15 @@ func expectAudio(t *testing.T, refl *reflector, dec *opus.Decoder, id, seq uint1
} }
func tryFrame(r *reflector) (uint16, []byte, bool) { 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 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 return 0, nil, false
} }
n := binary.BigEndian.Uint32(hdr[:]) n := binary.BigEndian.Uint32(hdr[:])
buf := make([]byte, n) 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 return 0, nil, false
} }
if len(buf) < 2 { if len(buf) < 2 {
+316
View File
@@ -0,0 +1,316 @@
package station
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/pem"
"errors"
"fmt"
"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, error) {
csrPath := filepath.Join(dir, csrFileName)
certPath := filepath.Join(dir, certFileName)
csr, err := os.Stat(csrPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("stat %s: %w", csrPath, err)
}
if err != nil {
return false, nil
}
cert, err := os.Stat(certPath)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return false, fmt.Errorf("stat %s: %w", certPath, err)
}
if err != nil {
return false, nil
}
return csr.ModTime().After(cert.ModTime()), nil
}
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, error) {
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, fmt.Errorf("stat certificate: %w", certErr)
}
if keyErr != nil && !errors.Is(keyErr, fs.ErrNotExist) {
return tls.Certificate{}, false, "Cannot create " + keyPath, fmt.Errorf("stat key: %w", keyErr)
}
if certExists && !keyExists {
return tls.Certificate{}, false, "Certificate key is missing", nil
}
if certExists && keyExists {
pair, msg, err := loadPair(certPath, keyPath, callsign)
if msg != "" {
return tls.Certificate{}, false, msg, err
}
if msg, err := ensureCSR(dir, callsign); msg != "" {
return tls.Certificate{}, false, msg, err
}
return pair, true, "", nil
}
if msg, err := ensureKeyAndCSR(dir, callsign, !keyExists); msg != "" {
return tls.Certificate{}, false, msg, err
}
return tls.Certificate{}, false, "", nil
}
func loadPair(certPath, keyPath, callsign string) (tls.Certificate, string, error) {
cert, err := loadCert(certPath)
if err != nil {
return tls.Certificate{}, "Cannot create " + certPath, fmt.Errorf("read certificate: %w", err)
}
key, err := loadKey(keyPath)
if err != nil {
return tls.Certificate{}, "Cannot create " + keyPath, fmt.Errorf("read key: %w", err)
}
pub, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok || !pub.Equal(&key.PublicKey) {
return tls.Certificate{}, "Certificate does not match the key", nil
}
if cert.Subject.CommonName != callsign {
return tls.Certificate{}, "Certificate callsign does not match", nil
}
return tls.Certificate{
Certificate: [][]byte{cert.Raw},
PrivateKey: key,
Leaf: cert,
}, "", nil
}
func ensureCSR(dir, callsign string) (string, error) {
csrPath := filepath.Join(dir, csrFileName)
_, err := os.Stat(csrPath)
if err == nil {
return "", nil
}
if !errors.Is(err, fs.ErrNotExist) {
return "Cannot create " + csrPath, fmt.Errorf("stat signing request: %w", err)
}
key, err := loadKey(filepath.Join(dir, keyFileName))
if err != nil {
return "Cannot create " + csrPath, fmt.Errorf("read key: %w", err)
}
if err := writeCSR(csrPath, key, callsign); err != nil {
return "Cannot create " + csrPath, err
}
return "", nil
}
func ensureKeyAndCSR(dir, callsign string, created bool) (string, error) {
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, fmt.Errorf("generate key: %w", err)
}
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, err
}
} else {
key, err = loadKey(keyPath)
if err != nil {
return "Cannot create " + keyPath, fmt.Errorf("read key: %w", err)
}
}
csrPath := filepath.Join(dir, csrFileName)
_, csrErr := os.Stat(csrPath)
if csrErr != nil && !errors.Is(csrErr, fs.ErrNotExist) {
return "Cannot create " + csrPath, fmt.Errorf("stat signing request: %w", csrErr)
}
if created || errors.Is(csrErr, fs.ErrNotExist) {
if err := writeCSR(csrPath, key, callsign); err != nil {
return "Cannot create " + csrPath, err
}
}
return "", nil
}
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)
}
+33
View File
@@ -0,0 +1,33 @@
package station
import (
"encoding/binary"
"gitea.neitzel.de/konrad/funknetz/internal/gcm64"
)
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 sealDatagram(key, prefix []byte, clientID uint16, counter uint32, aad, plain []byte) ([]byte, error) {
tag, ct, err := gcm64.Seal(key, gcm64.IV(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 gcm64.Open(key, gcm64.IV(prefix, clientID, counter), tag, ct, aad)
}
+66 -27
View File
@@ -15,34 +15,38 @@ import (
) )
type loaded struct { type loaded struct {
host string host string
port int port int
callsign string callsign string
authKey string authKey string
talkGroup int talkGroup int
enableLogging bool certificateDir string
logPath string certFatal string
logNotice string enableLogging bool
logPath string
logNotice string
} }
type fileShape struct { type fileShape struct {
Host jsontext.Value `json:"host"` Host jsontext.Value `json:"host"`
Port jsontext.Value `json:"port"` Port jsontext.Value `json:"port"`
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"`
EnableLogging jsontext.Value `json:"enableLogging"` CertificateDir jsontext.Value `json:"certificateDir"`
LogFile jsontext.Value `json:"logFile"` EnableLogging jsontext.Value `json:"enableLogging"`
LogFile jsontext.Value `json:"logFile"`
} }
type templateFile struct { type templateFile 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"`
EnableLogging bool `json:"enableLogging"` CertificateDir string `json:"certificateDir"`
LogFile string `json:"logFile"` 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})?$`) 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 { if err != nil {
return err return err
} }
body, err := jsonv2.Marshal(templateFile{LogFile: logFile}) body, err := jsonv2.Marshal(templateFile{CertificateDir: "./funknetz", LogFile: logFile})
if err != nil { if err != nil {
return err return err
} }
@@ -91,15 +95,19 @@ func parseConfig(path string, body []byte) (loaded, error) {
if err := jsonv2.Unmarshal(body, &shape); err != nil { if err := jsonv2.Unmarshal(body, &shape); err != nil {
return loaded{}, fmt.Errorf("the file could not be read: %w", err) 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) cfg, err := parseOperational(shape)
if err != nil { if err != nil {
return loaded{}, err return loaded{}, err
} }
cfg.logNotice = notice 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) { if !operationalKindsOK(shape) {
return parseOperational(shape) return parseOperational(shape)
} }
@@ -116,7 +124,7 @@ func parseConfig(path string, body []byte) (loaded, error) {
if err != nil { if err != nil {
return loaded{}, err return loaded{}, err
} }
return applyLog(cfg, shape), nil return applyLog(applyCertificate(cfg, shape, path), shape), nil
} }
func parseOperational(shape fileShape) (loaded, error) { func parseOperational(shape fileShape) (loaded, error) {
@@ -217,6 +225,10 @@ func missingLog(shape fileShape) bool {
return !present(shape.EnableLogging) || !present(shape.LogFile) return !present(shape.EnableLogging) || !present(shape.LogFile)
} }
func missingCertificate(shape fileShape) bool {
return !present(shape.CertificateDir)
}
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
@@ -259,6 +271,7 @@ 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")
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) {
logFile, err := defaultLogPath() logFile, err := defaultLogPath()
@@ -316,6 +329,32 @@ func rewriteConfig(path string, shape fileShape) error {
return nil 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 { func applyLog(cfg loaded, shape fileShape) loaded {
if shape.EnableLogging.Kind() != jsontext.KindTrue { if shape.EnableLogging.Kind() != jsontext.KindTrue {
return cfg return cfg
+116 -4
View File
@@ -1,10 +1,14 @@
package station_test package station_test
import ( import (
"context"
"fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
jsonv2 "encoding/json/v2" jsonv2 "encoding/json/v2"
@@ -54,11 +58,15 @@ func TestMissingConfigIsCreatedAndRejected(t *testing.T) {
if got["logFile"] != filepath.Join(home, "funknetz.log") { if got["logFile"] != filepath.Join(home, "funknetz.log") {
t.Fatalf("logFile = %#v, want funknetz.log in the home directory", got["logFile"]) 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) { 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,\"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 { if err := os.WriteFile(path, original, 0o640); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -240,6 +248,10 @@ 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["certificateDir"] != "./funknetz" {
t.Fatalf("certificateDir = %#v, want ./funknetz", got["certificateDir"])
}
assertKeyOrder(t, body)
if st.State().Detail != "Idle" { if st.State().Detail != "Idle" {
t.Fatalf("Detail = %q, want Idle", st.State().Detail) t.Fatalf("Detail = %q, want Idle", st.State().Detail)
} }
@@ -314,9 +326,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,"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,"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,"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,"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,"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},
@@ -368,6 +380,106 @@ 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() {
// Run's error is observed through State, which is the station seam.
_ = 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 { 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{ body, err := jsonv2.Marshal(map[string]any{
+26 -1
View File
@@ -2,9 +2,11 @@ package station_test
import ( import (
"context" "context"
"errors"
"net" "net"
"os" "os"
"strconv" "strconv"
"syscall"
"testing" "testing"
"time" "time"
@@ -30,6 +32,9 @@ func TestConnectsToLabReflector(t *testing.T) {
t.Skipf("lab reflector not listening on %s: %v", addr, err) t.Skipf("lab reflector not listening on %s: %v", addr, err)
} }
conn.Close() conn.Close()
if !labUDPReachable() {
t.Skipf("lab UDP port %s is not reachable", addr)
}
path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup)) path := writeConfigFile(t, configJSON(t, labHost, labPort, labCallsign, labAuthKey, labTalkGroup))
st, err := station.New(station.Options{ConfigPath: path}) st, err := station.New(station.Options{ConfigPath: path})
@@ -39,7 +44,10 @@ func TestConnectsToLabReflector(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second)
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
state, err := st.Wait(ctx, func(s station.State) bool { state, err := st.Wait(ctx, func(s station.State) bool {
return s.Connection == "Connected" return s.Connection == "Connected"
@@ -54,3 +62,20 @@ func TestConnectsToLabReflector(t *testing.T) {
t.Fatalf("talkGroup = %d, want %d", state.TalkGroup, labTalkGroup) 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)
}
+9 -1
View File
@@ -93,6 +93,10 @@ func (s *Station) noteDownLocked(now time.Time, dial bool) {
} }
func (s *Station) endFatal(msg string, log bool) { func (s *Station) endFatal(msg string, log bool) {
s.endFatalCause(msg, nil, log)
}
func (s *Station) endFatalCause(msg string, cause error, log bool) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.fatal != "" { if s.fatal != "" {
@@ -107,7 +111,11 @@ func (s *Station) endFatal(msg string, log bool) {
if log { if log {
s.up = false s.up = false
s.outage = true s.outage = true
s.writeLog(now, oneLine(msg)) line := oneLine(msg)
if cause != nil {
line += ": " + oneLine(cause.Error())
}
s.writeLog(now, line)
} }
s.publish() s.publish()
} }
+8 -2
View File
@@ -131,7 +131,10 @@ func TestFirstDialFailureIsLoggedOnce(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
want := "2026-10-03T12:00:00Z disconnected\n" want := "2026-10-03T12:00:00Z disconnected\n"
deadline := time.Now().Add(2 * time.Second) deadline := time.Now().Add(2 * time.Second)
@@ -160,7 +163,10 @@ func TestReflectorErrorsAreOneLogLine(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
refl.accept(t) refl.accept(t)
refl.readFrame(t) refl.readFrame(t)
refl.writeFrame(t, 13, appendStr(nil, "bad\nline")) refl.writeFrame(t, 13, appendStr(nil, "bad\nline"))
+72 -5
View File
@@ -4,26 +4,47 @@ import (
"encoding/binary" "encoding/binary"
"fmt" "fmt"
"io" "io"
"regexp"
"strconv"
) )
const ( const (
tcpHeartbeat = 1 tcpHeartbeat = 1
tcpProtoVer = 5 tcpProtoVer = 5
tcpProtoDown = 6
tcpAuthChallenge = 10 tcpAuthChallenge = 10
tcpAuthResponse = 11 tcpAuthResponse = 11
tcpAuthOK = 12 tcpAuthOK = 12
tcpError = 13 tcpError = 13
tcpStartEncReq = 14
tcpStartEnc = 15
tcpCsrReq = 16
tcpCsr = 17
tcpClientCert = 18
tcpCAInfo = 19
tcpCABundleReq = 20
tcpCABundle = 21
tcpServerInfo = 100 tcpServerInfo = 100
tcpNodeList = 101
tcpNodeJoined = 102
tcpNodeLeft = 103
tcpTalkerStart = 104 tcpTalkerStart = 104
tcpTalkerStop = 105 tcpTalkerStop = 105
tcpSelectTG = 106 tcpSelectTG = 106
tcpRequestQSY = 109
tcpStateEvent = 110
tcpNodeInfo = 111 tcpNodeInfo = 111
tcpSignal = 112
tcpTxStatus = 113
tcpStartUDPEnc = 114
udpHeartbeat = 1 udpHeartbeat = 1
udpAudio = 101 udpAudio = 101
udpFlush = 102 udpFlush = 102
udpAllFlushed = 103
maxFrame = 32768 maxFrame = 32768
maxPreauth20 = 64
) )
type frame struct { type frame struct {
@@ -45,12 +66,16 @@ func writeFrame(w io.Writer, typ uint16, body []byte) error {
} }
func readFrame(r io.Reader) (frame, 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 var hdr [4]byte
if _, err := io.ReadFull(r, hdr[:]); err != nil { if _, err := io.ReadFull(r, hdr[:]); err != nil {
return frame{}, err return frame{}, err
} }
n := binary.BigEndian.Uint32(hdr[:]) n := binary.BigEndian.Uint32(hdr[:])
if n < 2 || n > maxFrame { if n < 2 || n > max {
return frame{}, fmt.Errorf("frame length %d", n) return frame{}, fmt.Errorf("frame length %d", n)
} }
buf := make([]byte, n) buf := make([]byte, n)
@@ -149,6 +174,48 @@ func parseServerInfo(b []byte) (uint16, []string, error) {
return id, codecs, nil 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 { func hasOPUS(codecs []string) bool {
for _, c := range codecs { for _, c := range codecs {
if c == "OPUS" { if c == "OPUS" {
+244
View File
@@ -0,0 +1,244 @@
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() {
// Run's error is observed through State, which is the station seam.
_ = 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() {
// Run's error is observed through State, which is the station seam.
_ = 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() {
// Run's error is observed through State, which is the station seam.
_ = 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
+152
View File
@@ -0,0 +1,152 @@
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() {
// Run's error is observed through State, which is the station seam.
_ = 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() {
// Run's error is observed through State, which is the station seam.
_ = 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() {
// Run's error is observed through State, which is the station seam.
_ = 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() {
// Run's error is observed through State, which is the station seam.
_ = 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")
}
}
+82 -15
View File
@@ -1,6 +1,7 @@
package station_test package station_test
import ( import (
"crypto/tls"
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"io" "io"
@@ -133,21 +134,39 @@ type reflector struct {
ln net.Listener ln net.Listener
udp *net.UDPConn udp *net.UDPConn
tcp net.Conn tcp net.Conn
tls *tls.Conn
peer *net.UDPAddr peer *net.UDPAddr
port int port int
} }
func (r *reflector) conn() net.Conn {
if r.tls != nil {
return r.tls
}
return r.tcp
}
func newReflector(t *testing.T) *reflector { func newReflector(t *testing.T) *reflector {
t.Helper() t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0") var ln net.Listener
if err != nil { var udp *net.UDPConn
t.Fatal(err) var port int
} for range 8 {
port := ln.Addr().(*net.TCPAddr).Port var err error
udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port}) ln, err = net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
t.Fatal(err)
}
port = ln.Addr().(*net.TCPAddr).Port
udp, err = net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
if err == nil {
break
}
ln.Close() ln.Close()
t.Fatal(err) ln = nil
}
if udp == nil {
t.Fatal("udp port stayed in use")
} }
t.Cleanup(func() { t.Cleanup(func() {
if ln != nil { if ln != nil {
@@ -166,16 +185,18 @@ func (r *reflector) accept(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
r.tcp = conn r.tcp = conn
r.tls = nil
t.Cleanup(func() { conn.Close() }) t.Cleanup(func() { conn.Close() })
} }
func (r *reflector) readFrame(t *testing.T) (uint16, []byte) { func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
t.Helper() 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) t.Fatal(err)
} }
var hdr [4]byte var hdr [4]byte
if _, err := io.ReadFull(r.tcp, hdr[:]); err != nil { if _, err := io.ReadFull(conn, hdr[:]); err != nil {
t.Fatal(err) t.Fatal(err)
} }
n := binary.BigEndian.Uint32(hdr[:]) n := binary.BigEndian.Uint32(hdr[:])
@@ -183,7 +204,7 @@ func (r *reflector) readFrame(t *testing.T) (uint16, []byte) {
t.Fatalf("frame length %d", n) t.Fatalf("frame length %d", n)
} }
buf := make([]byte, 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) t.Fatal(err)
} }
if len(buf) < 2 { if len(buf) < 2 {
@@ -199,14 +220,30 @@ func (r *reflector) writeFrame(t *testing.T, typ uint16, body []byte) {
copy(payload[2:], body) copy(payload[2:], body)
var hdr [4]byte var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(payload))) 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) 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) 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()
// Clearing the deadline is best-effort; the handshake error is the result.
_ = 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) { func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
t.Helper() t.Helper()
if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { if err := r.udp.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
@@ -228,6 +265,33 @@ func (r *reflector) readUDP(t *testing.T) (typ, id, seq uint16, body []byte) {
return typ, id, seq, body 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) { func (r *reflector) writeUDP(t *testing.T, typ, id, seq uint16, body []byte) {
t.Helper() t.Helper()
if r.peer == nil { if r.peer == nil {
@@ -294,8 +358,11 @@ func requireSoftwareIdentity(t *testing.T, body []byte) {
if err := json.Unmarshal([]byte(raw), &got); err != nil { if err := json.Unmarshal([]byte(raw), &got); err != nil {
t.Fatalf("software identity %q: %v", raw, err) t.Fatalf("software identity %q: %v", raw, err)
} }
if len(got) != 2 || got["sw"] != station.SoftwareName || got["swVer"] != 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 %q and swVer %q only", got, station.SoftwareName, 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")
} }
} }
+459 -189
View File
@@ -4,11 +4,16 @@ import (
"context" "context"
"crypto/hmac" "crypto/hmac"
"crypto/sha1" "crypto/sha1"
"crypto/tls"
"encoding/binary" "encoding/binary"
"errors" "errors"
"fmt"
"io"
"net" "net"
"strconv" "strconv"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"gitea.neitzel.de/konrad/funknetz/internal/opus" "gitea.neitzel.de/konrad/funknetz/internal/opus"
@@ -16,11 +21,16 @@ import (
const noOpus = "The reflector did not offer OPUS." const noOpus = "The reflector did not offer OPUS."
var errStopped = errors.New("session stopped") var (
errStopped = errors.New("session stopped")
errFallback = errors.New("fallback")
errReconnect = errors.New("reconnect")
)
// 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)
for { for {
if ctx.Err() != nil { if ctx.Err() != nil {
return nil return nil
@@ -41,6 +51,7 @@ 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()
@@ -50,12 +61,30 @@ func (s *Station) Run(ctx context.Context) error {
continue continue
} }
err = s.session(ctx, conn) err = s.session(ctx, conn, major)
conn.Close() conn.Close()
fallback := errors.Is(err, errFallback)
reconnect := errors.Is(err, errReconnect)
s.mu.Lock() s.mu.Lock()
s.closeUDP() s.closeUDP()
s.mic = nil s.mic = nil
s.play = nil s.play = nil
s.audioHeard = false
s.flushWaiting = false
if fallback || reconnect {
s.roomBusy = false
s.remoteTalker = ""
s.mu.Unlock()
if ctx.Err() != nil {
return nil
}
if fallback {
major = 2
} else {
major = 3
}
continue
}
if s.fatal == "" { if s.fatal == "" {
s.noteDownLocked(s.clock.Now(), false) s.noteDownLocked(s.clock.Now(), false)
} else { } else {
@@ -63,14 +92,11 @@ func (s *Station) Run(ctx context.Context) error {
} }
fatal := s.fatal != "" fatal := s.fatal != ""
s.mu.Unlock() s.mu.Unlock()
major = 3
if ctx.Err() != nil { if ctx.Err() != nil {
return nil return nil
} }
if fatal { if fatal || errors.Is(err, errStopped) {
<-ctx.Done()
return nil
}
if err != nil && errors.Is(err, errStopped) {
<-ctx.Done() <-ctx.Done()
return nil return nil
} }
@@ -97,7 +123,147 @@ func (s *Station) wait(ctx context.Context, d time.Duration) bool {
} }
} }
func (s *Station) session(ctx context.Context, conn net.Conn) error { type framed struct {
conn net.Conn
mu sync.Mutex
w io.Writer
frames chan frame
errs chan error
upgrade chan *tls.Config
handshake chan error
max atomic.Uint32
}
// exchange is the state one TCP session's frame handlers share.
type exchange struct {
ph phase
authed bool
client tls.Certificate
haveCert bool
done chan struct{}
udpCh chan []byte
udpErr chan error
udpReady bool
lastTCP time.Time
limit time.Time
}
func newFramed(conn net.Conn, max uint32) *framed {
f := &framed{
conn: conn,
w: conn,
frames: make(chan frame, 8),
errs: make(chan error, 1),
upgrade: make(chan *tls.Config, 1),
handshake: make(chan error, 1),
}
f.max.Store(max)
return f
}
func (f *framed) Write(p []byte) (int, error) {
f.mu.Lock()
w := f.w
f.mu.Unlock()
return w.Write(p)
}
func (f *framed) setMax(n uint32) { f.max.Store(n) }
var errTLSTimeout = errors.New("tls timeout")
func (s *Station) startTLS(ctx context.Context, f *framed, cfg *tls.Config, until time.Time) error {
select {
case f.upgrade <- cfg:
case <-ctx.Done():
return ctx.Err()
}
if err := f.conn.SetReadDeadline(time.Now()); err != nil {
return err
}
wait := until.Sub(s.clock.Now())
if wait < 0 {
wait = 0
}
timer := s.clock.NewTimer(wait)
defer timer.Stop()
select {
case err := <-f.handshake:
return err
case <-ctx.Done():
if err := f.conn.SetDeadline(time.Now()); err != nil {
return err
}
return ctx.Err()
case <-timer.C():
if err := f.conn.SetDeadline(time.Now()); err != nil {
return err
}
select {
case <-f.handshake:
case <-ctx.Done():
}
return errTLSTimeout
}
}
func (f *framed) read(done <-chan struct{}) {
src := io.Reader(f.conn)
for {
fr, err := readFrameMax(src, f.max.Load())
if err != nil {
if ne, ok := errors.AsType[net.Error](err); ok && ne.Timeout() {
select {
case cfg := <-f.upgrade:
if derr := f.conn.SetReadDeadline(time.Time{}); derr != nil {
f.handshake <- derr
return
}
tc := tls.Client(f.conn, cfg)
herr := tc.Handshake()
if herr != nil {
f.handshake <- herr
return
}
f.mu.Lock()
f.w = tc
f.mu.Unlock()
src = tc
f.handshake <- nil
continue
case <-done:
return
}
}
select {
case f.errs <- err:
case <-done:
}
return
}
select {
case f.frames <- fr:
case <-done:
return
}
}
}
type phase int
const (
phCA phase = iota
phBundle
phStart
phIdent
phServer
phUDP
phBeat
phRun
phChallenge
)
func (s *Station) session(ctx context.Context, conn net.Conn, major uint16) error {
done := make(chan struct{}) done := make(chan struct{})
var closeOnce sync.Once var closeOnce sync.Once
closeConn := func() { closeOnce.Do(func() { conn.Close() }) } closeConn := func() { closeOnce.Do(func() { conn.Close() }) }
@@ -110,38 +276,56 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
} }
}() }()
tcpCh := make(chan frame, 8) frameMax := uint32(maxFrame)
tcpErr := make(chan error, 1) ex := &exchange{ph: phCA, done: done, lastTCP: s.clock.Now()}
go readTCP(conn, tcpCh, tcpErr, done) label := "3.0"
if major < 3 {
frameMax = maxPreauth20
ex.ph = phChallenge
label = "2.0"
}
wire := newFramed(conn, frameMax)
go wire.read(done)
s.mu.Lock()
s.protocol = label
s.rxSeq = 0
s.rxCounter = 0
s.txCounter = 1
s.secure = false
s.udpWait = false
s.seq = 0
s.ivPrefix = nil
s.udpKey = nil
s.mu.Unlock()
if err := writeFrame(conn, tcpProtoVer, []byte{0, 2, 0, 0}); err != nil { if err := writeFrame(wire, tcpProtoVer, versionWire(major, 0)); err != nil {
return err return err
} }
lastTCP := s.clock.Now() now := ex.lastTCP
tcpRx := lastTCP.Add(15 * time.Second) tcpRx := now.Add(15 * time.Second)
udpRx := now.Add(60 * time.Second)
var udpCh chan []byte
var udpErr chan error
udpReady := false
var udpRx time.Time
for { for {
s.mu.Lock() s.mu.Lock()
lastUDP := s.lastUDPSend lastUDP := s.lastUDPSend
silence, silenceOK := s.nextSilence()
s.mu.Unlock() s.mu.Unlock()
next := lastTCP.Add(10 * time.Second) next := ex.lastTCP.Add(10 * time.Second)
if tcpRx.Before(next) { if t := tcpRx; t.Before(next) {
next = tcpRx next = t
} }
if udpReady { if t := udpRx; t.Before(next) {
if u := lastUDP.Add(15 * time.Second); u.Before(next) { next = t
next = u }
} if ex.udpReady {
if udpRx.Before(next) { if t := lastUDP.Add(15 * time.Second); t.Before(next) {
next = udpRx next = t
} }
} }
if silenceOK && silence.Before(next) {
next = silence
}
wait := next.Sub(s.clock.Now()) wait := next.Sub(s.clock.Now())
if wait < 0 { if wait < 0 {
wait = 0 wait = 0
@@ -150,9 +334,9 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
var udpRecv <-chan []byte var udpRecv <-chan []byte
var udpFail <-chan error var udpFail <-chan error
if udpCh != nil { if ex.udpCh != nil {
udpRecv = udpCh udpRecv = ex.udpCh
udpFail = udpErr udpFail = ex.udpErr
} }
var reason string var reason string
@@ -162,13 +346,13 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
case <-ctx.Done(): case <-ctx.Done():
timer.Stop() timer.Stop()
return ctx.Err() return ctx.Err()
case err := <-tcpErr: case err := <-wire.errs:
timer.Stop() timer.Stop()
return err return err
case err := <-udpFail: case err := <-udpFail:
timer.Stop() timer.Stop()
return err return err
case tcpf = <-tcpCh: case tcpf = <-wire.frames:
timer.Stop() timer.Stop()
reason = "tcp" reason = "tcp"
case udpp = <-udpRecv: case udpp = <-udpRecv:
@@ -176,7 +360,7 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
reason = "udp" reason = "udp"
case <-timer.C(): case <-timer.C():
select { select {
case tcpf = <-tcpCh: case tcpf = <-wire.frames:
reason = "tcp" reason = "tcp"
default: default:
select { select {
@@ -188,36 +372,47 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
} }
} }
now := s.clock.Now() now = s.clock.Now()
switch reason { switch reason {
case "tcp": case "tcp":
tcpRx = now.Add(15 * time.Second) tcpRx = now.Add(15 * time.Second)
if err := s.onTCP(conn, tcpf, done, &udpCh, &udpErr, &udpReady, &udpRx, &lastTCP); err != nil { ex.limit = tcpRx
if udpRx.Before(ex.limit) {
ex.limit = udpRx
}
err := s.onFrame(ctx, wire, major, tcpf, ex)
if err != nil {
return err return err
} }
case "udp": case "udp":
s.mu.Lock() if err := s.onUDP(wire, udpp, ex, &udpRx, now); err != nil {
if clientIDOf(udpp) == s.clientID { return err
udpRx = now.Add(60 * time.Second)
s.onDatagram(udpp)
} }
s.mu.Unlock()
default: default:
s.mu.Lock()
s.onClock(now)
s.mu.Unlock()
if !now.Before(tcpRx) { if !now.Before(tcpRx) {
return errors.New("tcp receive timeout") return s.retry("tcp receive timeout", nil)
} }
if udpReady && !now.Before(udpRx) { if !now.Before(udpRx) {
return errors.New("udp receive timeout") return s.retry("udp receive timeout", nil)
} }
if !now.Before(lastTCP.Add(10 * time.Second)) { if !now.Before(ex.lastTCP.Add(10 * time.Second)) {
if err := writeFrame(conn, tcpHeartbeat, nil); err != nil { if err := writeFrame(wire, tcpHeartbeat, nil); err != nil {
return err return err
} }
lastTCP = s.clock.Now() ex.lastTCP = s.clock.Now()
} }
s.mu.Lock() s.mu.Lock()
if udpReady && s.udp != nil && !s.clock.Now().Before(s.lastUDPSend.Add(15*time.Second)) { if ex.udpReady && s.udp != nil && !s.clock.Now().Before(s.lastUDPSend.Add(15*time.Second)) {
if err := s.sendUDP(udpHeartbeat, nil); err != nil { var err error
if s.udpWait {
err = s.sendRegistration()
} else {
err = s.sendUDP(udpHeartbeat, nil)
}
if err != nil {
s.mu.Unlock() s.mu.Unlock()
return err return err
} }
@@ -227,127 +422,172 @@ func (s *Station) session(ctx context.Context, conn net.Conn) error {
} }
} }
func readTCP(conn net.Conn, out chan<- frame, errc chan<- error, done <-chan struct{}) { func (s *Station) retry(detail string, cause error) error {
for { s.mu.Lock()
f, err := readFrame(conn) s.retryReason = detail
if err != nil { s.mu.Unlock()
select { text := strings.ToLower(detail)
case errc <- err: if cause == nil {
case <-done: return errors.New(text)
} }
return return fmt.Errorf("%s: %w", text, cause)
}
func (s *Station) nextSilence() (time.Time, bool) {
var next time.Time
ok := false
if s.audioHeard {
next = s.lastAudio.Add(3*time.Second + time.Nanosecond)
ok = true
}
if s.flushWaiting {
at := s.flushAt.Add(3 * time.Second)
if !ok || at.Before(next) {
next = at
ok = true
} }
select { }
case out <- f: return next, ok
case <-done: }
return
func (s *Station) onClock(now time.Time) {
if s.flushWaiting && !now.Before(s.flushAt.Add(3*time.Second)) {
s.flushWaiting = false
}
if s.audioHeard && now.After(s.lastAudio.Add(3*time.Second)) {
if s.dec != nil {
s.dec.Reset()
} }
s.audioHeard = false
s.roomBusy = false
s.publish()
} }
} }
func readUDP(conn *net.UDPConn, out chan<- []byte, errc chan<- error, done <-chan struct{}) { func (s *Station) onFrame(ctx context.Context, wire *framed, major uint16, f frame, ex *exchange) error {
buf := make([]byte, 2048)
for {
n, err := conn.Read(buf)
if err != nil {
select {
case errc <- err:
case <-done:
}
return
}
pkt := append([]byte(nil), buf[:n]...)
select {
case out <- pkt:
case <-done:
return
}
}
}
func (s *Station) onTCP(conn net.Conn, f frame, done <-chan struct{}, udpCh *chan []byte, udpErr *chan error, udpReady *bool, udpRx *time.Time, lastTCP *time.Time) error {
switch f.typ { switch f.typ {
case tcpHeartbeat, tcpAuthOK: case tcpHeartbeat:
return nil
case tcpAuthChallenge:
chal, _, err := parseBlob(f.body)
if err != nil || len(chal) != 20 {
return errors.New("bad challenge")
}
mac := hmac.New(sha1.New, []byte(s.cfg.authKey))
mac.Write(chal)
body := appendString(nil, s.cfg.callsign)
body = appendBlob(body, mac.Sum(nil))
if err := writeFrame(conn, tcpAuthResponse, body); err != nil {
return err
}
*lastTCP = s.clock.Now()
return nil return nil
case tcpError: case tcpError:
msg, _, err := parseString(f.body) return s.onReflectorError(f.body, major)
if err != nil { case tcpProtoDown:
msg = "could not be read" return s.onDowngrade(f.body, major)
} }
s.endFatal(msg, true) if !ex.authed && f.typ >= 100 {
return errStopped return s.retry("Protocol error", nil)
}
if major < 3 {
return s.onV2(wire, f, ex)
}
return s.onV3(ctx, wire, f, ex)
}
func (s *Station) onReflectorError(body []byte, major uint16) 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) {
return errFallback
}
s.endFatal(msg, true)
return errStopped
}
func (s *Station) onDowngrade(body []byte, major uint16) error {
gotMajor, gotMinor, err := parseVersion(body)
if err != nil {
return err
}
if major >= 3 && gotMajor == 2 {
return errFallback
}
s.endFatal("Unsupported protocol version "+strconvU(gotMajor)+"."+strconvU(gotMinor), true)
return errStopped
}
func strconvU(v uint16) string {
return strconv.FormatUint(uint64(v), 10)
}
func (s *Station) onV2(wire *framed, f frame, ex *exchange) error {
switch f.typ {
case tcpAuthOK:
ex.authed = true
ex.ph = phServer
wire.setMax(maxFrame)
return nil
case tcpAuthChallenge:
return s.answerChallenge(wire, f.body, ex)
case tcpServerInfo: case tcpServerInfo:
return s.onServerInfo(conn, f.body, done, udpCh, udpErr, udpReady, udpRx, lastTCP) if ex.ph != phServer {
return s.retry("Protocol error", nil)
}
return s.finishV2(wire, f.body, ex)
case tcpTalkerStart: case tcpTalkerStart:
s.onTalker(f.body, true) s.onTalker(f.body, true)
return nil return nil
case tcpTalkerStop: case tcpTalkerStop:
s.onTalker(f.body, false) s.onTalker(f.body, false)
return nil return nil
case tcpNodeList, tcpNodeJoined, tcpNodeLeft, tcpRequestQSY, tcpStateEvent, tcpSignal, tcpTxStatus:
if err := unpackIgnored(f); err != nil {
return s.retry("Protocol error", err)
}
return nil
default: default:
return nil return nil
} }
} }
func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{}, udpCh *chan []byte, udpErr *chan error, udpReady *bool, udpRx *time.Time, lastTCP *time.Time) error { func (s *Station) answerChallenge(w io.Writer, body []byte, ex *exchange) error {
id, codecs, err := parseServerInfo(body) chal, _, err := parseBlob(body)
if err != nil {
return fmt.Errorf("bad challenge: %w", err)
}
if len(chal) != 20 {
return errors.New("bad challenge")
}
mac := hmac.New(sha1.New, []byte(s.cfg.authKey))
mac.Write(chal)
out := appendString(nil, s.cfg.callsign)
out = appendBlob(out, mac.Sum(nil))
if err := writeFrame(w, tcpAuthResponse, out); err != nil {
return err
}
ex.lastTCP = s.clock.Now()
return nil
}
func (s *Station) finishV2(wire *framed, body []byte, ex *exchange) error {
id, enc, dec, err := s.acceptServer(body)
if err != nil { if err != nil {
return err return err
} }
if !hasOPUS(codecs) { if enc == nil {
s.endFatal(noOpus, true)
return errStopped return errStopped
} }
enc, err := opus.NewEncoder()
if err != nil {
return err
}
dec, err := opus.NewDecoder()
if err != nil {
enc.Close()
return err
}
info, err := softwareIdentityBody() info, err := softwareIdentityBody()
if err != nil { if err != nil {
enc.Close() enc.Close()
dec.Close() dec.Close()
return err return err
} }
if err := writeFrame(conn, tcpNodeInfo, info); err != nil { if err := writeFrame(wire, tcpNodeInfo, info); err != nil {
enc.Close() enc.Close()
dec.Close() dec.Close()
return err return err
} }
var tg [4]byte var tg [4]byte
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup)) binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
if err := writeFrame(conn, tcpSelectTG, tg[:]); err != nil { if err := writeFrame(wire, tcpSelectTG, tg[:]); err != nil {
enc.Close() enc.Close()
dec.Close() dec.Close()
return err return err
} }
*lastTCP = s.clock.Now() ex.lastTCP = s.clock.Now()
udpConn, peer, err := dialAudio(wire.conn)
remote, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok {
enc.Close()
dec.Close()
return errors.New("tcp peer has no address")
}
udpConn, err := net.DialUDP("udp", nil, &net.UDPAddr{IP: remote.IP, Port: remote.Port})
if err != nil { if err != nil {
enc.Close() enc.Close()
dec.Close() dec.Close()
@@ -355,14 +595,16 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
} }
ch := make(chan []byte, 32) ch := make(chan []byte, 32)
errc := make(chan error, 1) errc := make(chan error, 1)
go readUDP(udpConn, ch, errc, done) go readUDP(udpConn, peer, ch, errc, ex.done)
s.mu.Lock() s.mu.Lock()
s.clientID = id s.clientID = id
s.udpPeer = peer
s.seq = 0 s.seq = 0
s.rxSeq = 0
s.udp = udpConn s.udp = udpConn
s.enc = enc s.enc = enc
s.dec = dec s.dec = dec
s.secure = false
err = s.sendUDP(udpHeartbeat, nil) err = s.sendUDP(udpHeartbeat, nil)
if err != nil { if err != nil {
s.mu.Unlock() s.mu.Unlock()
@@ -378,14 +620,33 @@ func (s *Station) onServerInfo(conn net.Conn, body []byte, done <-chan struct{},
s.writeLog(s.clock.Now(), "connected") s.writeLog(s.clock.Now(), "connected")
s.publish() s.publish()
s.mu.Unlock() s.mu.Unlock()
ex.udpCh = ch
*udpCh = ch ex.udpErr = errc
*udpErr = errc ex.udpReady = true
*udpReady = true
*udpRx = s.clock.Now().Add(60 * time.Second)
return nil return nil
} }
func (s *Station) acceptServer(body []byte) (uint16, *opus.Encoder, *opus.Decoder, error) {
id, codecs, err := parseServerInfo(body)
if err != nil {
return 0, nil, nil, err
}
if !hasOPUS(codecs) {
s.endFatal(noOpus, true)
return 0, nil, nil, errStopped
}
enc, err := opus.NewEncoder()
if err != nil {
return 0, nil, nil, err
}
dec, err := opus.NewDecoder()
if err != nil {
enc.Close()
return 0, nil, nil, err
}
return id, enc, dec, nil
}
func (s *Station) stop(msg string) { s.endFatal(msg, false) } func (s *Station) stop(msg string) { s.endFatal(msg, false) }
// Fail stops retries and shows message until the operator quits. // Fail stops retries and shows message until the operator quits.
@@ -425,69 +686,21 @@ func (s *Station) onTalker(body []byte, start bool) {
s.publish() s.publish()
} }
func (s *Station) onDatagram(pkt []byte) {
if len(pkt) < 6 {
return
}
typ := binary.BigEndian.Uint16(pkt[:2])
if typ != udpAudio || s.dec == nil {
return
}
if s.transmitting() {
return
}
packet, _, err := parseBlob(pkt[6:])
if err != nil {
return
}
pcm, err := s.dec.Decode(packet)
if err != nil {
return
}
s.play = append(s.play, pcm...)
s.roomBusy = true
s.publish()
}
func clientIDOf(pkt []byte) uint16 {
if len(pkt) < 6 {
return ^uint16(0)
}
return binary.BigEndian.Uint16(pkt[2:4])
}
func (s *Station) sendUDP(typ uint16, body []byte) error {
if s.udp == nil {
return errors.New("udp is down")
}
buf := make([]byte, 6+len(body))
binary.BigEndian.PutUint16(buf[0:2], typ)
binary.BigEndian.PutUint16(buf[2:4], s.clientID)
binary.BigEndian.PutUint16(buf[4:6], s.seq)
s.seq++
copy(buf[6:], body)
if _, err := s.udp.Write(buf); err != nil {
return err
}
s.lastUDPSend = s.clock.Now()
return nil
}
func (s *Station) endTransmit() { func (s *Station) endTransmit() {
if s.enc != nil && s.udp != nil && len(s.mic) > 0 { if s.enc != nil && s.udp != nil && len(s.mic) > 0 {
frame := make([]int16, opus.FrameSamples) frame := make([]int16, opus.FrameSamples)
copy(frame, s.mic) copy(frame, s.mic)
s.mic = nil s.mic = nil
if pkt, err := s.enc.Encode(frame); err == nil { if pkt, err := s.enc.Encode(frame); err == nil {
// A lost datagram ends the session when the receive timer expires.
_ = s.sendUDP(udpAudio, appendBlob(nil, pkt)) _ = s.sendUDP(udpAudio, appendBlob(nil, pkt))
} }
} else { } else {
s.mic = nil s.mic = nil
} }
if s.udp != nil { if s.udp != nil {
// A lost datagram ends the session when the receive timer expires.
_ = s.sendUDP(udpFlush, nil) _ = s.sendUDP(udpFlush, nil)
s.flushWaiting = true
s.flushAt = s.clock.Now()
} }
} }
@@ -496,6 +709,7 @@ func (s *Station) closeUDP() {
s.udp.Close() s.udp.Close()
s.udp = nil s.udp = nil
} }
s.udpPeer = nil
if s.enc != nil { if s.enc != nil {
s.enc.Close() s.enc.Close()
s.enc = nil s.enc = nil
@@ -522,8 +736,8 @@ func (s *Station) publish() {
s.state.Detail = s.fatal s.state.Detail = s.fatal
s.state.Talker = "" s.state.Talker = ""
s.state.Transmitting = false s.state.Transmitting = false
s.state.Protocol = ""
} else { } else {
s.state.Detail = "Idle"
tx := s.transmitting() tx := s.transmitting()
s.state.Transmitting = tx s.state.Transmitting = tx
if tx { if tx {
@@ -532,8 +746,22 @@ func (s *Station) publish() {
} else { } else {
s.state.Talker = s.remoteTalker s.state.Talker = s.remoteTalker
} }
if s.state.Talker == "" && s.logNotice != "" { if s.state.Connection == "Connected" {
s.state.Detail = s.logNotice s.state.Protocol = s.protocol
s.retryReason = ""
s.state.Detail = "Idle"
if s.state.Talker == "" && s.logNotice != "" {
s.state.Detail = s.logNotice
}
} else {
s.state.Protocol = ""
if s.retryReason != "" {
s.state.Detail = s.retryReason
} else if s.state.Talker == "" && s.logNotice != "" {
s.state.Detail = s.logNotice
} else {
s.state.Detail = "Idle"
}
} }
} }
select { select {
@@ -541,3 +769,45 @@ func (s *Station) publish() {
default: default:
} }
} }
func unpackIgnored(f frame) error {
switch f.typ {
case tcpNodeList:
_, _, err := parseStringList(f.body)
return err
case tcpNodeJoined, tcpNodeLeft:
_, _, err := parseString(f.body)
return err
case tcpRequestQSY:
_, _, err := parseU32(f.body)
return err
case tcpStateEvent:
_, rest, err := parseString(f.body)
if err != nil {
return err
}
_, rest, err = parseString(rest)
if err != nil {
return err
}
_, _, err = parseString(rest)
return err
case tcpSignal:
return unpackRecords(f.body, 3)
case tcpTxStatus:
return unpackRecords(f.body, 2)
default:
return nil
}
}
func unpackRecords(b []byte, size int) error {
n, rest, err := parseU16(b)
if err != nil {
return err
}
if len(rest) != int(n)*size {
return io.ErrUnexpectedEOF
}
return nil
}
+16 -7
View File
@@ -21,20 +21,29 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
} }
ctx, cancel := context.WithCancel(t.Context()) ctx, cancel := context.WithCancel(t.Context())
defer cancel() defer cancel()
go func() { _ = st.Run(ctx) }() go func() {
// Run's error is observed through State, which is the station seam.
_ = st.Run(ctx)
}()
got, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connecting" }) got, err := st.Wait(ctx, func(s station.State) bool { return s.Connection == "Connecting" })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" { if got.Callsign != "DL1ABC" || got.TalkGroup != 20 || got.Detail != "Idle" || got.Protocol != "" {
t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle", got) t.Fatalf("State = %+v, want DL1ABC talk group 20 Idle and a blank protocol", got)
} }
refl.accept(t) refl.accept(t)
typ, body := refl.readFrame(t) typ, body := refl.readFrame(t)
if typ != 5 || len(body) != 4 || body[0] != 0 || body[1] != 2 || body[2] != 0 || body[3] != 0 { if typ != 5 || !versionIs(body, 3, 0) {
t.Fatalf("first frame type %d body %v, want protocol 2.0", typ, body) 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) challenge := make([]byte, 20)
@@ -91,8 +100,8 @@ func TestLoginAnnouncesProtocolAndJoinsTheTalkGroup(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Detail != "Idle" || got.Talker != "" { if got.Detail != "Idle" || got.Talker != "" || got.Protocol != "2.0" {
t.Fatalf("State = %+v, want Connected Idle", got) 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. // No other talk group: the next TCP message is a heartbeat, not a monitor set.
+246
View File
@@ -0,0 +1,246 @@
package station
import (
"encoding/binary"
"errors"
"io"
"net"
"time"
)
func (s *Station) finishV3UDP(conn net.Conn, ex *exchange) error {
udpConn, peer, err := dialAudio(conn)
if err != nil {
s.dropAudio()
return err
}
ch := make(chan []byte, 32)
errc := make(chan error, 1)
go readUDP(udpConn, peer, ch, errc, ex.done)
s.mu.Lock()
s.udp = udpConn
s.udpPeer = peer
s.udpWait = true
s.txCounter = 1
s.rxCounter = 0
err = s.sendRegistration()
s.mu.Unlock()
if err != nil {
s.dropAudio()
udpConn.Close()
return err
}
ex.udpCh = ch
ex.udpErr = errc
ex.udpReady = true
ex.ph = phBeat
return nil
}
func dialAudio(conn net.Conn) (*net.UDPConn, *net.UDPAddr, error) {
remote, ok := conn.RemoteAddr().(*net.TCPAddr)
if !ok {
return nil, nil, errors.New("tcp peer has no address")
}
local := &net.UDPAddr{Port: 0}
if tcpLocal, ok := conn.LocalAddr().(*net.TCPAddr); ok && tcpLocal.IP != nil {
local.IP = tcpLocal.IP
}
udpConn, err := net.ListenUDP("udp", local)
if err != nil {
return nil, nil, err
}
return udpConn, &net.UDPAddr{IP: append(net.IP(nil), remote.IP...), Port: remote.Port}, nil
}
func (s *Station) dropAudio() {
s.mu.Lock()
defer s.mu.Unlock()
if s.enc != nil {
s.enc.Close()
s.enc = nil
}
if s.dec != nil {
s.dec.Close()
s.dec = nil
}
}
func readUDP(conn *net.UDPConn, peer *net.UDPAddr, out chan<- []byte, errc chan<- error, done <-chan struct{}) {
buf := make([]byte, 2048)
for {
n, addr, err := conn.ReadFromUDP(buf)
if err != nil {
select {
case errc <- err:
case <-done:
}
return
}
if addr == nil || peer == nil || !addr.IP.Equal(peer.IP) || addr.Port != peer.Port {
continue
}
pkt := append([]byte(nil), buf[:n]...)
select {
case out <- pkt:
case <-done:
return
}
}
}
func (s *Station) onUDP(w io.Writer, pkt []byte, ex *exchange, udpRx *time.Time, now time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.secure {
plain, ok := s.openSecure(pkt)
if !ok {
return nil
}
*udpRx = now.Add(60 * time.Second)
if len(plain) < 2 {
return nil
}
typ := binary.BigEndian.Uint16(plain[:2])
body := plain[2:]
if s.udpWait {
if typ != udpHeartbeat {
return nil
}
s.udpWait = false
s.mu.Unlock()
var tg [4]byte
binary.BigEndian.PutUint32(tg[:], uint32(s.cfg.talkGroup))
err := writeFrame(w, tcpSelectTG, tg[:])
s.mu.Lock()
if err != nil {
return err
}
ex.lastTCP = s.clock.Now()
s.state.Connection = "Connected"
s.up = true
s.outage = false
s.noteTransmitLocked()
s.writeLog(s.clock.Now(), "connected")
s.publish()
ex.ph = phRun
return nil
}
s.onPlain(typ, body, now)
return nil
}
if !s.acceptV2(pkt) {
return nil
}
*udpRx = now.Add(60 * time.Second)
typ := binary.BigEndian.Uint16(pkt[:2])
s.onPlain(typ, pkt[6:], now)
return nil
}
func (s *Station) openSecure(pkt []byte) ([]byte, bool) {
if len(pkt) < 12 || s.udpKey == nil {
return nil, false
}
counter := binary.BigEndian.Uint32(pkt[:4])
if counter < s.rxCounter {
return nil, false
}
tag := pkt[4:12]
ct := pkt[12:]
plain, err := openDatagram(s.udpKey, s.ivPrefix, 0, counter, pkt[:4], tag, ct)
if err != nil {
return nil, false
}
s.rxCounter = counter + 1
return plain, true
}
func (s *Station) acceptV2(pkt []byte) bool {
if len(pkt) < 6 {
return false
}
if binary.BigEndian.Uint16(pkt[2:4]) != s.clientID {
return false
}
seq := binary.BigEndian.Uint16(pkt[4:6])
diff := seq - s.rxSeq
if diff > 32767 {
return false
}
s.rxSeq = seq + 1
return true
}
func (s *Station) onPlain(typ uint16, body []byte, now time.Time) {
switch typ {
case udpAllFlushed:
s.flushWaiting = false
case udpAudio:
if s.dec == nil || s.transmitting() {
return
}
packet, _, err := parseBlob(body)
if err != nil {
return
}
pcm, err := s.dec.Decode(packet)
if err != nil {
return
}
s.play = append(s.play, pcm...)
s.roomBusy = true
s.audioHeard = true
s.lastAudio = now
s.publish()
}
}
func (s *Station) sendRegistration() error {
return s.sendSecure(udpHeartbeat, nil, 0, true)
}
func (s *Station) sendUDP(typ uint16, body []byte) error {
if s.udp == nil {
return errors.New("udp is down")
}
if s.secure {
return s.sendSecure(typ, body, s.txCounter, false)
}
buf := make([]byte, 6+len(body))
binary.BigEndian.PutUint16(buf[0:2], typ)
binary.BigEndian.PutUint16(buf[2:4], s.clientID)
binary.BigEndian.PutUint16(buf[4:6], s.seq)
s.seq++
copy(buf[6:], body)
if _, err := s.udp.WriteToUDP(buf, s.udpPeer); err != nil {
return err
}
s.lastUDPSend = s.clock.Now()
return nil
}
func (s *Station) sendSecure(typ uint16, body []byte, counter uint32, reg bool) error {
plain := appendU16(nil, typ)
plain = append(plain, body...)
var aad []byte
id := s.clientID
if reg {
aad = registrationAAD(s.clientID)
counter = 0
} else {
aad = counterAAD(counter)
}
wire, err := sealDatagram(s.udpKey, s.ivPrefix, id, counter, aad, plain)
if err != nil {
return err
}
if _, err := s.udp.WriteToUDP(wire, s.udpPeer); err != nil {
return err
}
s.lastUDPSend = s.clock.Now()
if !reg {
s.txCounter = counter + 1
}
return nil
}
+259
View File
@@ -0,0 +1,259 @@
package station
import (
"bytes"
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
func (s *Station) onV3(ctx context.Context, wire *framed, f frame, ex *exchange) error {
switch f.typ {
case tcpCAInfo:
if ex.ph != phCA {
return nil
}
return s.onCAInfo(wire, f.body, ex)
case tcpCABundle:
if ex.ph != phBundle {
return nil
}
return s.onCABundle(wire, f.body, ex)
case tcpStartEnc:
if ex.ph != phStart {
return nil
}
return s.onStartEncryption(ctx, wire, ex)
case tcpCsrReq:
return s.sendCSR(wire, ex)
case tcpAuthChallenge:
return s.answerChallenge(wire, f.body, ex)
case tcpAuthOK:
ex.authed = true
ex.ph = phServer
newer, err := csrNewerThanCert(s.cfg.certificateDir)
if err != nil {
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
return errStopped
}
if newer {
if err := s.sendCSR(wire, ex); err != nil {
return err
}
}
return nil
case tcpClientCert:
return s.saveCert(f.body)
case tcpServerInfo:
if !ex.authed {
return s.retry("Protocol error", nil)
}
return s.finishV3Server(wire, f.body, ex)
case tcpStartUDPEnc:
if ex.ph != phUDP {
return nil
}
return s.finishV3UDP(wire.conn, ex)
case tcpTalkerStart:
s.onTalker(f.body, true)
return nil
case tcpTalkerStop:
s.onTalker(f.body, false)
return nil
case tcpNodeList, tcpNodeJoined, tcpNodeLeft, tcpRequestQSY, tcpStateEvent, tcpSignal, tcpTxStatus:
if err := unpackIgnored(f); err != nil {
return s.retry("Protocol error", err)
}
return nil
default:
return nil
}
}
func (s *Station) onCAInfo(w io.Writer, body []byte, ex *exchange) error {
_, digest, err := parseCAInfo(body)
if err != nil {
return s.retry("Protocol error", err)
}
if err := ensureCertDir(s.cfg.certificateDir); err != nil {
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
return errStopped
}
sum, ok, err := caDigest(s.cfg.certificateDir)
if err != nil {
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
return errStopped
}
if ok {
if !bytes.Equal(sum[:], digest) {
s.mu.Lock()
s.writeLog(s.clock.Now(), "CA digest differs")
s.mu.Unlock()
}
if err := writeFrame(w, tcpStartEncReq, nil); err != nil {
return err
}
ex.lastTCP = s.clock.Now()
ex.ph = phStart
return nil
}
if err := writeFrame(w, tcpCABundleReq, nil); err != nil {
return err
}
ex.lastTCP = s.clock.Now()
ex.ph = phBundle
return nil
}
func parseCAInfo(b []byte) (uint16, []byte, error) {
size, rest, err := parseU16(b)
if err != nil {
return 0, nil, err
}
digest, rest, err := parseBlob(rest)
if err != nil {
return 0, nil, err
}
if len(digest) != sha256Len || len(rest) != 0 {
return 0, nil, io.ErrUnexpectedEOF
}
return size, digest, nil
}
const sha256Len = 32
func (s *Station) onCABundle(w io.Writer, body []byte, ex *exchange) error {
caPEM, rest, err := parseString(body)
if err != nil {
return s.retry("CA signature rejected", err)
}
sig, rest, err := parseBlob(rest)
if err != nil {
return s.retry("CA signature rejected", err)
}
certPEM, rest, err := parseString(rest)
if err != nil {
return s.retry("CA signature rejected", err)
}
if len(rest) != 0 {
return s.retry("CA signature rejected", nil)
}
if err := verifyCABundle(caPEM, sig, certPEM); err != nil {
return s.retry("CA signature rejected", err)
}
if err := writeCABundle(s.cfg.certificateDir, caPEM); err != nil {
s.endFatalCause("Cannot create "+s.cfg.certificateDir, err, true)
return errStopped
}
if err := writeFrame(w, tcpStartEncReq, nil); err != nil {
return err
}
ex.lastTCP = s.clock.Now()
ex.ph = phStart
return nil
}
func (s *Station) onStartEncryption(ctx context.Context, wire *framed, ex *exchange) error {
pair, present, fatal, cause := prepareIdentity(s.cfg.certificateDir, s.cfg.callsign)
if fatal != "" {
s.endFatalCause(fatal, cause, true)
return errStopped
}
ex.client = pair
ex.haveCert = present
caPath := filepath.Join(s.cfg.certificateDir, caFileName)
caPEM, err := os.ReadFile(caPath)
if err != nil {
s.endFatalCause("Cannot create "+caPath, err, true)
return errStopped
}
cfg, err := tlsConfig(s.cfg.host, caPEM, pair, present)
if err != nil {
return s.retry("TLS handshake failed", err)
}
if err := s.startTLS(ctx, wire, cfg, ex.limit); err != nil {
if errors.Is(err, errHostMismatch) {
return s.retry("Reflector certificate does not match the host", err)
}
if ctx.Err() != nil {
return ctx.Err()
}
return s.retry("TLS handshake failed", err)
}
ex.ph = phIdent
return nil
}
func (s *Station) sendCSR(w io.Writer, ex *exchange) error {
csrPath := filepath.Join(s.cfg.certificateDir, csrFileName)
pem, err := readCSR(s.cfg.certificateDir)
if err != nil {
s.endFatalCause("Cannot create "+csrPath, err, true)
return errStopped
}
if err := writeFrame(w, tcpCsr, appendString(nil, pem)); err != nil {
return err
}
ex.lastTCP = s.clock.Now()
return nil
}
func (s *Station) saveCert(body []byte) error {
pem, _, err := parseString(body)
if err != nil || pem == "" {
return s.retry("Protocol error", err)
}
certPath := filepath.Join(s.cfg.certificateDir, certFileName)
if err := writeClientCert(s.cfg.certificateDir, pem); err != nil {
s.endFatalCause("Cannot create "+certPath, err, true)
return errStopped
}
return errReconnect
}
func (s *Station) finishV3Server(wire *framed, body []byte, ex *exchange) error {
id, enc, dec, err := s.acceptServer(body)
if err != nil {
return err
}
if enc == nil {
return errStopped
}
iv := make([]byte, 6)
key := make([]byte, 16)
if _, err := rand.Read(iv); err != nil {
enc.Close()
dec.Close()
return fmt.Errorf("udp key: %w", err)
}
if _, err := rand.Read(key); err != nil {
enc.Close()
dec.Close()
return fmt.Errorf("udp key: %w", err)
}
info, err := identityV3(iv, key)
if err != nil {
enc.Close()
dec.Close()
return err
}
if err := writeFrame(wire, tcpNodeInfo, info); err != nil {
enc.Close()
dec.Close()
return err
}
ex.lastTCP = s.clock.Now()
s.mu.Lock()
s.clientID = id
s.enc = enc
s.dec = dec
s.ivPrefix = iv
s.udpKey = key
s.secure = true
s.mu.Unlock()
ex.ph = phUDP
return nil
}
+54 -8
View File
@@ -1,6 +1,11 @@
package station 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 // SoftwareName and SoftwareVersion are the identity sent to the reflector
// after login. Edit them here. // after login. Edit them here.
@@ -9,16 +14,57 @@ const (
SoftwareVersion = "1.0.0" SoftwareVersion = "1.0.0"
) )
type softwareIdentity struct {
SW string `json:"sw"`
SWVer string `json:"swVer"`
ProjVer string `json:"projVer"`
MachineArch string `json:"machineArch,omitzero"`
}
func softwareIdentityBody() ([]byte, error) { func softwareIdentityBody() ([]byte, error) {
raw, err := jsonv2.Marshal(struct { raw, err := marshalIdentity(false)
SW string `json:"sw"`
SWVer string `json:"swVer"`
}{
SW: SoftwareName,
SWVer: SoftwareVersion,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return appendString(nil, string(raw)), nil return appendString(nil, string(raw)), nil
} }
func identityV3(iv, key []byte) ([]byte, error) {
raw, err := marshalIdentity(true)
if err != nil {
return nil, err
}
body := appendBlob(nil, iv)
body = appendBlob(body, key)
return appendString(body, string(raw)), nil
}
func marshalIdentity(arch bool) ([]byte, error) {
info := softwareIdentity{
SW: SoftwareName,
SWVer: SoftwareVersion,
ProjVer: SoftwareVersion,
}
if arch {
if name, ok := MachineArch(); ok {
info.MachineArch = name
}
}
return jsonv2.Marshal(info)
}
// MachineArch is the hardware name from uname, when it can be read.
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"` Detail string `json:"detail"`
Callsign string `json:"callsign"` Callsign string `json:"callsign"`
TalkGroup int `json:"talkGroup"` TalkGroup int `json:"talkGroup"`
Protocol string `json:"protocol"`
Talker string `json:"talker"` Talker string `json:"talker"`
Transmitting bool `json:"transmitting"` Transmitting bool `json:"transmitting"`
} }
@@ -39,13 +40,27 @@ type Station struct {
buttonHeld bool buttonHeld bool
spaceHeld bool spaceHeld bool
fatal string fatal string
retryReason string
protocol string
remoteTalker string remoteTalker string
roomBusy bool 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 mic []int16
play []int16 play []int16
clientID uint16 clientID uint16
seq uint16 seq uint16
udp *net.UDPConn udp *net.UDPConn
udpPeer *net.UDPAddr
lastUDPSend time.Time lastUDPSend time.Time
enc *opus.Encoder enc *opus.Encoder
dec *opus.Decoder dec *opus.Decoder
@@ -73,7 +88,9 @@ func New(opt Options) (*Station, error) {
clock = wallClock{} clock = wallClock{}
} }
detail := "Idle" detail := "Idle"
if cfg.logNotice != "" { if cfg.certFatal != "" {
detail = cfg.certFatal
} else if cfg.logNotice != "" {
detail = cfg.logNotice detail = cfg.logNotice
} }
s := &Station{ s := &Station{
@@ -82,6 +99,7 @@ func New(opt Options) (*Station, error) {
wake: make(chan struct{}, 1), wake: make(chan struct{}, 1),
logPath: cfg.logPath, logPath: cfg.logPath,
logNotice: cfg.logNotice, logNotice: cfg.logNotice,
fatal: cfg.certFatal,
state: State{ state: State{
Connection: "Disconnected", Connection: "Disconnected",
Detail: detail, 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
}