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) }