fix(connections): log what was actually sent, and rename the section

Two answers to "can people debug this when it does not work".

They could not. A custom UDP row logged "sent 18 bytes to 127.0.0.1:12000"
and nothing about the message — which is a template the operator wrote, so
the byte count says nothing about whether the substitution came out as they
meant. The payload is now in the line, with the trigger that fired it, for
custom rows only: an ADIF record or a binary WSJT frame on every QSO would
bury the log rather than fill it.

Worse was the silent case. A template that is a single placeholder the trigger
does not carry — {freq} on a band change — renders empty, sends nothing, and
looked exactly like a row that had never fired. It now says so, throttled per
row, and points at the field list.

And a password in a URL is redacted before it reaches the file. Storing
http://admin:secret@switch/ as typed was a deliberate choice for LAN gear;
writing it into a log that gets sent to whoever is helping is a different
choice, and was never made.

The Settings section is "Connections" rather than "UDP integrations", since it
has not been UDP-only since the URL transport landed. The log prefix stays
"udp:" on purpose — renaming it would orphan every log an operator has already
sent and every note anyone has written against it.
This commit is contained in:
2026-08-15 01:52:36 +02:00
parent 6c2c1f106a
commit ffd6bcf54b
5 changed files with 130 additions and 17 deletions
+45 -1
View File
@@ -1,6 +1,9 @@
package udp
import "testing"
import (
"strings"
"testing"
)
// The escaping rule is the whole difference between a template that works on
// the bench and one that fails on the first portable callsign.
@@ -62,3 +65,44 @@ func TestLineEnding(t *testing.T) {
}
}
}
// These logs get sent to whoever is helping when something breaks. Storing a
// switch password as typed was a deliberate choice for LAN gear; putting it in
// a file that travels is a different one, and was never made.
func TestRedactURLHidesThePassword(t *testing.T) {
for in, want := range map[string]string{
"http://admin:[email protected]/ant?b=20m": "http://admin:***@192.168.1.50/ant?b=20m",
// No credentials, nothing to hide — and the URL must come back untouched,
// because it is the thing being debugged.
"http://192.168.1.50/ant?b=20m": "http://192.168.1.50/ant?b=20m",
// A username with no password is not a secret.
"http://[email protected]/ant": "http://[email protected]/ant",
// Not a URL at all: leave it, the operator needs to see what they typed.
"not a url {band}": "not a url {band}",
} {
if got := redactURL(in); got != want {
t.Errorf("redactURL(%q) = %q, want %q", in, got, want)
}
}
}
// "sent 18 bytes" says nothing about whether the substitution came out right.
// The payload is shown for custom rows and only those: an ADIF record or a
// binary WSJT frame on every QSO would bury the log.
func TestSentPreviewIsForCustomRowsOnly(t *testing.T) {
custom := Config{ServiceType: ServiceCustom, Trigger: TriggerBandChange}
got := sentPreview(custom, []byte("<BAND>20</BAND>\r\n"))
if got != ` on band_change: "<BAND>20</BAND>\r\n"` {
t.Errorf("custom preview = %q", got)
}
// A line ending is a choice the operator made — it has to be visible, and it
// must not break the log line in two.
if strings.ContainsAny(got, "\r\n") {
t.Error("the preview put a real newline in the log line")
}
for _, s := range []ServiceType{ServiceDBUpdated, ServiceWSJTLog, ServiceN1MMRadio} {
if p := sentPreview(Config{ServiceType: s}, []byte("<CALL:5>VK9XX<EOR>")); p != "" {
t.Errorf("%s should not dump its payload, got %q", s, p)
}
}
}