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 -7
View File
@@ -107,18 +107,35 @@ func (m *Manager) fireCustom(c Config, fields map[string]string) {
if c.Transport == TransportURL {
target := Render(c.URL, fields, true)
if strings.TrimSpace(target) == "" {
m.noteEmptyRender(c, c.URL)
return
}
m.getURL(c, target)
return
}
payload := Render(c.Template, fields, false) + lineEnding(c.LineEnd)
if payload == "" {
if strings.TrimSpace(payload) == "" {
m.noteEmptyRender(c, c.Template)
return
}
m.sendTo(c, []byte(payload))
}
// noteEmptyRender reports a template that produced nothing.
//
// Silence here was the worst of the lot: a row whose template 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. The
// operator has no way to tell a typo from a trigger that is not reaching them.
//
// Throttled with the same per-row timer as a failing URL: a template that is
// wrong stays wrong on every band change.
func (m *Manager) noteEmptyRender(c Config, tmpl string) {
m.noteCustomProblem(c, fmt.Sprintf(
"on %s the message came out empty — %q filled in nothing. Check the field names against the list under the box.",
c.Trigger, tmpl))
}
// getURL performs the request and reports what came back.
//
// The status code is logged, not just transport errors. A switch answering 404
@@ -130,25 +147,46 @@ func (m *Manager) getURL(c Config, target string) {
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
applog.Printf("udp: [%s] bad URL %q: %v", c.Name, target, err)
applog.Printf("udp: [%s] bad URL %q: %v", c.Name, redactURL(target), err)
return
}
resp, err := customHTTP.Do(req)
if err != nil {
m.noteCustomHTTP(c, fmt.Sprintf("%s failed: %v", target, err))
m.noteCustomProblem(c, fmt.Sprintf("%s failed: %v", redactURL(target), err))
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
m.noteCustomHTTP(c, fmt.Sprintf("%s answered %s", target, resp.Status))
m.noteCustomProblem(c, fmt.Sprintf("%s answered %s", redactURL(target), resp.Status))
return
}
applog.Printf("udp: [%s] GET %s → %s", c.Name, target, resp.Status)
applog.Printf("udp: [%s] on %s GET %s → %s", c.Name, c.Trigger, redactURL(target), resp.Status)
}
// noteCustomHTTP logs a failing URL row, throttled per row: a switch that is
// redactURL hides the password in a URL before it reaches the log.
//
// The URL is what makes this feature debuggable, so it is logged in full — but
// an operator who put http://admin:secret@switch/ in the box is going to send
// that log to somebody when something goes wrong. Storing the password as typed
// was a deliberate choice for LAN gear; writing it into a file that travels is
// not the same choice, and was never made.
func redactURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.User == nil {
return raw
}
if _, hasPass := u.User.Password(); !hasPass {
return raw
}
u.User = url.UserPassword(u.User.Username(), "***")
// Qt-style escaping of the placeholder would give %2A%2A%2A; put it back so
// the line stays readable.
return strings.ReplaceAll(u.String(), "%2A%2A%2A", "***")
}
// noteCustomProblem logs a failing URL row, throttled per row: a switch that is
// off answers the same way on every band change, and this fires on every one.
func (m *Manager) noteCustomHTTP(c Config, msg string) {
func (m *Manager) noteCustomProblem(c Config, msg string) {
m.httpFailMu.Lock()
if m.httpFailAt == nil {
m.httpFailAt = map[int64]time.Time{}