diff --git a/changelog.json b/changelog.json index c434d4c..198692f 100644 --- a/changelog.json +++ b/changelog.json @@ -6,6 +6,7 @@ "Worked before: an operator's portable callsigns now count as the same station. Typing RK3DWA finds the RK3DWA/3, /P and /MM contacts too, and the other way round — before, the history only appeared if you typed the exact form. Can be turned off in Settings → General; contest dupe checking is unaffected and stays exact.", "QSO editor: the OpsLog card now appears with the other confirmations. It has its own Sent / Received row in the QSL Info table, next to QSL, LoTW, eQSL and the rest, and the \"QSL received\" tick moved there from Contact's details — with its PSE QSL / TNX indicator, which is unchanged. Sent stays read-only: OpsLog stamps it when the card actually goes out.", "QSL card: the {qso.pse_tnx} stamp now prints \"TNX QSL\" instead of just \"TNX\", to read as the counterpart of \"PSE QSL\".", + "UDP: WSJT-X traffic arriving through a relay is read again. A forwarder such as W&P prepends the origin as text — \"127.0.0.1:2237|\" — in front of the packet it re-broadcasts, which pushed the WSJT-X signature 15 bytes in and made every datagram fail. That header is now skipped, so MSHV / WSJT-X / JTDX behind a relay work on the ordinary \"WSJT-X / JTDX / MSHV\" service: no new setting, nothing to reconfigure.", "UDP: an unreadable packet now says who sent it and what it contained — the sender's address, the size, a text preview and a hex dump — instead of just a magic number. And it stops after five: a port receiving the wrong traffic was writing that line a hundred times a second, filling the log and burying everything else. A closing line points at the two things to check, the sender and the service type.", "Awards: the QRZ.com and Custom confirmation sources actually work now. Both were offered in the award editor but neither was implemented, so ticking one marked no QSO as confirmed — silently. QRZ.com reads the download status (them confirming back, not our upload). Custom reads any QSO field or ADIF tag you name, with an optional list of values that count: point it at APP_OPSLOG_QSL_RCVD for a card received through OpsLog, or at a tag stamped by a club list you imported. Leave the value empty and any non-empty content confirms. A Custom source naming no field confirms nothing.", "Window: closing OpsLog while it is minimised no longer loses its size and position. Windows parks a minimised window at -32000,-32000 with a stub size, and that was what got saved — so the next launch found geometry no screen could hold, discarded it, and reopened at the default place. The stored geometry is now kept instead; it repairs itself the first time you close a window that is on screen." @@ -14,6 +15,7 @@ "Déjà contacté : les indicatifs portables d'un opérateur comptent désormais comme la même station. Taper RK3DWA retrouve aussi les QSO en RK3DWA/3, /P et /MM, et inversement — avant, l'historique n'apparaissait que si tu tapais la forme exacte. Désactivable dans Réglages → Général ; le contrôle de doublon en concours n'est pas touché et reste strict.", "Éditeur de QSO : la carte OpsLog rejoint les autres confirmations. Elle a sa propre ligne Envoyée / Reçue dans le tableau de l'onglet QSL Info, à côté de QSL, LoTW, eQSL et les autres, et la case « QSL reçue » y a été déplacée depuis Détails du contact — avec son indicateur PSE QSL / TNX, inchangé. « Envoyée » reste en lecture seule : OpsLog l'inscrit quand la carte part réellement.", "Carte QSL : le tampon {qso.pse_tnx} imprime désormais « TNX QSL » et non plus « TNX » seul, pour répondre à « PSE QSL ».", + "UDP : le trafic WSJT-X qui arrive via un relais est de nouveau lu. Un réexpéditeur comme W&P place l'origine en texte — « 127.0.0.1:2237| » — devant le paquet qu'il rediffuse, ce qui décalait la signature WSJT-X de 15 octets et faisait échouer chaque datagramme. Cet en-tête est désormais ignoré : MSHV / WSJT-X / JTDX derrière un relais fonctionnent avec le service « WSJT-X / JTDX / MSHV » habituel, sans nouveau réglage ni reconfiguration.", "UDP : un paquet illisible indique désormais qui l'a envoyé et ce qu'il contenait — adresse de l'émetteur, taille, aperçu en texte et vidage hexadécimal — au lieu d'un simple nombre magique. Et il s'arrête après cinq : un port recevant le mauvais trafic écrivait cette ligne cent fois par seconde, saturant le journal et enterrant tout le reste. Une ligne finale rappelle les deux choses à vérifier, l'émetteur et le type de service.", "Diplômes : les sources de confirmation QRZ.com et Custom fonctionnent enfin. Les deux étaient proposées dans l'éditeur de diplômes sans être implémentées : cocher l'une ou l'autre ne confirmait aucun QSO, en silence. QRZ.com lit le statut de téléchargement (leur confirmation en retour, pas notre envoi). Custom lit n'importe quel champ de QSO ou balise ADIF que tu désignes, avec une liste facultative de valeurs qui comptent : pointe-la sur APP_OPSLOG_QSL_RCVD pour une carte reçue via OpsLog, ou sur une balise inscrite à l'import d'une liste de club. Laisse la valeur vide et tout contenu non vide confirme. Une source Custom sans champ ne confirme rien.", "Fenêtre : fermer OpsLog alors qu'il est réduit ne fait plus perdre sa taille et sa position. Windows range une fenêtre réduite en -32000,-32000 avec une taille factice, et c'est cela qui était enregistré — au lancement suivant, OpsLog trouvait une géométrie qu'aucun écran ne peut contenir, la rejetait et rouvrait à l'emplacement par défaut. La géométrie déjà enregistrée est désormais conservée ; cela se répare tout seul à la première fermeture d'une fenêtre visible à l'écran." diff --git a/internal/integrations/udp/forwarder_test.go b/internal/integrations/udp/forwarder_test.go new file mode 100644 index 0000000..56953f9 --- /dev/null +++ b/internal/integrations/udp/forwarder_test.go @@ -0,0 +1,63 @@ +package udp + +import ( + "bytes" + "testing" +) + +// Bytes captured from a real W&P relay in front of MSHV: the origin as text, +// a '|', then the untouched WSJT-X packet. This exact datagram produced +// "bad magic 0x3132372e" — 0x3132372e being ASCII "127.". +var wpDecode = []byte{ + // "127.0.0.1:2237|" + 0x31, 0x32, 0x37, 0x2e, 0x30, 0x2e, 0x30, 0x2e, 0x31, 0x3a, 0x32, 0x32, 0x33, 0x37, 0x7c, + // magic, schema 3, type 2 (Decode), id "MSHV" + 0xad, 0xbc, 0xcb, 0xda, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x00, 0x04, 0x4d, 0x53, 0x48, 0x56, + 0x01, 0x01, 0x4b, 0x31, 0x28, 0x00, 0x00, 0x00, 0x16, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x8a, + 0x00, 0x00, 0x00, 0x03, 0x46, 0x54, 0x38, // "FT8" + 0x00, 0x00, 0x00, 0x0e, 0x54, 0x4e, 0x38, 0x47, 0x44, 0x20, 0x39, 0x41, 0x31, 0x4d, 0x4d, 0x20, 0x37, 0x33, // "TN8GD 9A1MM 73" + 0x00, 0x00, +} + +func TestParseWSJTBehindAForwarder(t *testing.T) { + if _, _, err := ParseWSJT(wpDecode); err != nil { + t.Fatalf("relayed packet still fails: %v", err) + } + // And the header is what was in the way: without it the same bytes parse. + if _, _, err := ParseWSJT(wpDecode[15:]); err != nil { + t.Fatalf("bare packet fails: %v", err) + } +} + +func TestStripForwarderHeader(t *testing.T) { + bare := wpDecode[15:] + + if got := stripForwarderHeader(bare); !bytes.Equal(got, bare) { + t.Error("a packet with no header must be returned untouched") + } + if got := stripForwarderHeader(wpDecode); !bytes.Equal(got, bare) { + t.Error("the text header was not stripped") + } + + // A BINARY prefix is not a forwarder header — refusing it is what stops a + // corrupt packet that merely contains the magic from becoming a QSO. + binPrefix := append([]byte{0x00, 0x01, 0x02}, bare...) + if got := stripForwarderHeader(binPrefix); !bytes.Equal(got, binPrefix) { + t.Error("a non-printable prefix must not be treated as a header") + } + + // Beyond the window, the magic is ignored however printable the prefix is. + far := append(bytes.Repeat([]byte("A"), maxFwdHeader+1), bare...) + if got := stripForwarderHeader(far); !bytes.Equal(got, far) { + t.Error("magic past maxFwdHeader must not be trusted") + } + + // No magic anywhere, and runt packets, must not panic or invent anything. + for _, junk := range [][]byte{[]byte("127.0.0.1:2237|hello"), {}, {0xad}, {0xad, 0xbc, 0xcb}} { + if got := stripForwarderHeader(junk); !bytes.Equal(got, junk) { + t.Errorf("junk %q was altered", junk) + } + } +} diff --git a/internal/integrations/udp/wsjt.go b/internal/integrations/udp/wsjt.go index 6bce9ee..34d66a2 100644 --- a/internal/integrations/udp/wsjt.go +++ b/internal/integrations/udp/wsjt.go @@ -56,12 +56,61 @@ type WSJTEvent struct { IsCQ bool // the decode was a CQ call } +// maxFwdHeader bounds how far into a packet the WSJT-X magic may sit behind a +// forwarder's header. The one seen in the field ("127.0.0.1:2237|") is 15 bytes; +// 64 leaves room for a longer address without ever scanning a real payload. +const maxFwdHeader = 64 + +// stripForwarderHeader removes the origin header a UDP relay prepends. +// +// A relay that re-broadcasts WSJT-X traffic has to say where each datagram came +// from, and it does so as plain text in front of the payload: +// +// "127.0.0.1:2237|" + +// +// The magic then sits 15 bytes in, every packet fails on "bad magic", and an +// operator running MSHV behind such a relay gets nothing at all. There is no +// need for a separate service type: what follows the header IS a WSJT-X packet, +// so the whole parser and everything downstream apply unchanged. +// +// Deliberately narrow. The magic must appear within maxFwdHeader bytes AND +// everything before it must be printable ASCII — a truncated or corrupt packet +// that happens to contain those four bytes somewhere is not resurrected into a +// QSO. Anything else is returned untouched, and still fails as it did. +func stripForwarderHeader(pkt []byte) []byte { + if len(pkt) < 4 { + return pkt + } + if binary.BigEndian.Uint32(pkt) == wsjtMagic { + return pkt // no header — the overwhelmingly common case + } + limit := len(pkt) - 4 + if limit > maxFwdHeader { + limit = maxFwdHeader + } + for i := 1; i <= limit; i++ { + if binary.BigEndian.Uint32(pkt[i:]) != wsjtMagic { + continue + } + for _, b := range pkt[:i] { + if b < 0x20 || b >= 0x7f { + return pkt // not a text header — leave it alone + } + } + return pkt[i:] + } + return pkt +} + // ParseWSJT decodes one UDP packet. Returns ok=false for messages we // don't care about (heartbeat, clears, etc.). func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) { if len(pkt) < 12 { return WSJTEvent{}, false, fmt.Errorf("packet too short") } + // A relay (W&P and friends) puts its own origin header in front — skip it so + // the packet parses exactly as if it had arrived from WSJT-X directly. + pkt = stripForwarderHeader(pkt) r := bytes.NewReader(pkt) var magic, schema, mtype uint32 if err := binary.Read(r, binary.BigEndian, &magic); err != nil {