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.
206 lines
6.9 KiB
Go
206 lines
6.9 KiB
Go
package udp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
)
|
|
|
|
// Custom outbound messages: a trigger, a template, a transport.
|
|
//
|
|
// The hard-coded emitters beside this one each speak a published format at a
|
|
// fixed moment. This is the escape hatch: the operator chooses when it fires and
|
|
// writes what it says. It exists mainly for antenna switches, which are driven
|
|
// by a URL — http://192.168.1.50/antenna?band=20m — and want the band change,
|
|
// but nothing here is specific to them.
|
|
//
|
|
// The presets are NOT reimplemented on top of this. PstRotator and N1MM
|
|
// RadioInfo are documented formats that work; a typo in a template would break
|
|
// them silently, and "it worked before I touched anything" is not a trade worth
|
|
// making for one less code path.
|
|
|
|
// httpTimeout bounds a request. A UDP datagram leaves and is forgotten; an HTTP
|
|
// call to a switch that has been unplugged will otherwise sit there for the
|
|
// operating system's own timeout — and this fires on a band change, which is
|
|
// exactly when nobody wants to wait.
|
|
const httpTimeout = 3 * time.Second
|
|
|
|
// customHTTP is the client for URL rows. One client, reused: a new one per
|
|
// request leaks connections on a switch that keeps them alive.
|
|
var customHTTP = &http.Client{Timeout: httpTimeout}
|
|
|
|
// Render substitutes {field} placeholders in tmpl.
|
|
//
|
|
// escape decides how a value is written, and it is not cosmetic: in a URL every
|
|
// value has to be percent-encoded, or the first portable callsign — F4BPO/P —
|
|
// puts a path separator in the middle of a query string, and a name with a
|
|
// space breaks the request outright. In a UDP payload the opposite holds: the
|
|
// receiver wants the text as typed.
|
|
//
|
|
// A placeholder with no value becomes empty rather than staying as "{freq}".
|
|
// Leaving the braces would send them down the wire, and a receiver would store
|
|
// the literal text as if it meant something.
|
|
func Render(tmpl string, fields map[string]string, escape bool) string {
|
|
var b strings.Builder
|
|
for {
|
|
i := strings.IndexByte(tmpl, '{')
|
|
if i < 0 {
|
|
b.WriteString(tmpl)
|
|
return b.String()
|
|
}
|
|
j := strings.IndexByte(tmpl[i:], '}')
|
|
if j < 0 {
|
|
b.WriteString(tmpl) // an unclosed brace is literal text
|
|
return b.String()
|
|
}
|
|
b.WriteString(tmpl[:i])
|
|
key := strings.ToLower(strings.TrimSpace(tmpl[i+1 : i+j]))
|
|
v := fields[key]
|
|
if escape {
|
|
v = url.QueryEscape(v)
|
|
}
|
|
b.WriteString(v)
|
|
tmpl = tmpl[i+j+1:]
|
|
}
|
|
}
|
|
|
|
// lineEnding turns the stored choice into the bytes appended to a UDP payload.
|
|
// Many receivers read a line at a time and wait for a terminator; a trailing
|
|
// space in a text box is invisible and lost on the first copy-paste, so this is
|
|
// a choice rather than something to type.
|
|
func lineEnding(s string) string {
|
|
switch strings.ToLower(strings.TrimSpace(s)) {
|
|
case "lf":
|
|
return "\n"
|
|
case "crlf":
|
|
return "\r\n"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// EmitTrigger fires every enabled custom row bound to this trigger.
|
|
//
|
|
// Runs the sends in the background: an HTTP request must never sit on the path
|
|
// of the thing that caused it — a band change waiting on an antenna switch is a
|
|
// band change the operator watches not happen.
|
|
func (m *Manager) EmitTrigger(t Trigger, fields map[string]string) {
|
|
var rows []Config
|
|
for _, c := range m.Outbound(ServiceCustom) {
|
|
if c.Trigger == t {
|
|
rows = append(rows, c)
|
|
}
|
|
}
|
|
if len(rows) == 0 {
|
|
return
|
|
}
|
|
for _, c := range rows {
|
|
go m.fireCustom(c, fields)
|
|
}
|
|
}
|
|
|
|
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 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
|
|
// because a firmware update moved its endpoint is a total failure that looks
|
|
// exactly like success from here — the datagram equivalent does not exist, and
|
|
// an operator would have no way to tell.
|
|
func (m *Manager) getURL(c Config, target string) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
|
if err != nil {
|
|
applog.Printf("udp: [%s] bad URL %q: %v", c.Name, redactURL(target), err)
|
|
return
|
|
}
|
|
resp, err := customHTTP.Do(req)
|
|
if err != nil {
|
|
m.noteCustomProblem(c, fmt.Sprintf("%s failed: %v", redactURL(target), err))
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
m.noteCustomProblem(c, fmt.Sprintf("%s answered %s", redactURL(target), resp.Status))
|
|
return
|
|
}
|
|
applog.Printf("udp: [%s] on %s GET %s → %s", c.Name, c.Trigger, redactURL(target), resp.Status)
|
|
}
|
|
|
|
// 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) noteCustomProblem(c Config, msg string) {
|
|
m.httpFailMu.Lock()
|
|
if m.httpFailAt == nil {
|
|
m.httpFailAt = map[int64]time.Time{}
|
|
}
|
|
last := m.httpFailAt[c.ID]
|
|
now := time.Now()
|
|
quiet := now.Sub(last) < 5*time.Minute
|
|
if !quiet {
|
|
m.httpFailAt[c.ID] = now
|
|
}
|
|
m.httpFailMu.Unlock()
|
|
if quiet {
|
|
return
|
|
}
|
|
applog.Printf("udp: [%s] %s", c.Name, msg)
|
|
}
|