Files
OpsLog/internal/relaydev/httpgen.go
T
rouggy 0bab7f05b9 feat(relays): accept a self-signed certificate on a generic HTTP board
HTTPS to a relay board could not work. Nearly every board that offers it signs
its own certificate — there is no authority anywhere that could have signed it —
so the request failed verification before it left.

A checkbox, per board, off by default. Not a blanket switch, because the other
HTTPS case is real and opposite: a board reached from outside through a proxy
with a genuine certificate, where verification is the only thing standing
between an antenna switch and the internet. Same setting, two boards, different
answers.

Off by default is only safe if the failure explains itself, so a certificate
error now names the box to tick. Go's own "x509: certificate signed by unknown
authority" is accurate and tells an operator nothing about what to do next.

Shown only once an https:// URL is actually in the board's configuration. A
board on plain HTTP has no certificate to argue about, and an option that cannot
matter yet is one more thing to wonder about.

The flag joins the driver cache key: ticking it has to rebuild the driver, or
the cached one would go on refusing the certificate with the verifying client it
already holds.

The boards that take a bare host — WebSwitch, KMTronic — keep verification. An
https:// typed there is the proxy case by construction, since they default to
plain HTTP on the LAN.

Three tests against a real self-signed TLS server: accepted with the box,
refused with a message naming it without the box, and one board's setting not
leaking into another's.
2026-08-17 10:57:29 +02:00

228 lines
8.0 KiB
Go

package relaydev
import (
"context"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
)
// A generic HTTP relay board: one URL to switch a relay on, one to switch it off.
//
// This is for the home-made switch — an ESP8266 with a web page, a Shelly, a
// Sonoff running third-party firmware, any of the boxes that answer a GET and
// have no protocol worth naming. The named drivers beside it exist because
// their boards need something specific; this one exists because most of them
// need nothing at all.
//
// TWO WAYS TO CONFIGURE IT, and the difference matters:
//
// - one URL pair with {relay} in it, used for every relay:
// http://192.168.1.9/relay?n={relay}&state=on
// - one pair per relay, when the box has no pattern to speak of:
// relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
//
// The second is the reason this driver exists. A hand-made switch often has
// URLs with nothing in common between channels, and a template with {relay}
// cannot express that.
//
// TWO SUBSTITUTIONS are available in either form:
//
// {relay} the relay number, 1-based. {relay-1} for a board that counts its
// channels from zero — otherwise the whole pattern has to be given
// up for eight hand-typed URLs over one missing offset.
// {value} that relay's LABEL, the name given to it in Relay labels. A switch
// addressed by antenna name rather than by channel number
// (…/relay?on=Ant1) is then one pattern instead of eight URLs, and
// renaming the antenna re-addresses it — the name the operator reads
// on the button and the name on the wire cannot drift apart because
// they are the same string.
//
// The label is percent-encoded, so a name with a space or an accent goes out as
// a valid URL rather than a request the board rejects without saying why.
//
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
// or answer with a web page nobody can parse reliably. Status therefore returns
// what we last commanded — see the method for what that costs.
type httpGen struct {
onURLs []string // index 0 = relay 1; "" falls back to the pattern
offURLs []string
onPat string // pattern with {relay}, used when the per-relay URL is empty
offPat string
labels []string // index 0 = relay 1; what {value} resolves to
user string
pass string
count int
// insecure accepts a certificate nothing can verify — the self-signed one a
// relay board on the LAN presents. Per board, and the operator's choice.
insecure bool
mu sync.Mutex
state []bool
}
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
// relay 1) and may be short or hold empty entries; onPat/offPat are the
// fallback patterns; labels are the relay names {value} substitutes.
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string, insecure bool) Device {
if count <= 0 {
count = len(onURLs)
}
if count <= 0 {
count = 1
}
return &httpGen{
onURLs: onURLs, offURLs: offURLs,
onPat: onPat, offPat: offPat, labels: labels,
user: user, pass: pass, count: count, insecure: insecure,
state: make([]bool, count),
}
}
func (h *httpGen) Count() int { return h.count }
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
// patFor returns the pattern for a direction, trimmed.
func (h *httpGen) patFor(on bool) string {
if on {
return strings.TrimSpace(h.onPat)
}
return strings.TrimSpace(h.offPat)
}
// entryFor returns what was typed in the per-relay box for a direction.
func (h *httpGen) entryFor(relay int, on bool) string {
list := h.offURLs
if on {
list = h.onURLs
}
if i := relay - 1; i >= 0 && i < len(list) {
return strings.TrimSpace(list[i])
}
return ""
}
// labelFor returns the relay's name, as typed in Relay labels.
func (h *httpGen) labelFor(relay int) string {
if i := relay - 1; i >= 0 && i < len(h.labels) {
return strings.TrimSpace(h.labels[i])
}
return ""
}
// urlFor builds the request for one relay in one direction: the per-relay URL
// if there is one, the pattern otherwise, with both substitutions applied.
func (h *httpGen) urlFor(relay int, on bool) string {
u := h.entryFor(relay, on)
if u == "" {
u = h.patFor(on)
}
if u == "" {
return ""
}
return expand(u, relay, h.labelFor(relay))
}
// escapeValue percent-encodes a relay label for use anywhere in a URL.
//
// url.QueryEscape alone is wrong: it writes a space as "+", which is a space
// only in a query string and a literal plus sign in a path. Encoding it as %20
// instead is correct in both, and {value} may land in either.
func escapeValue(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
// withScheme supplies http:// when none was typed, and leaves https:// alone.
//
// The same rule the named boards get from relayBase, and it has to be here too:
// this driver takes whole URLs rather than a host, and a line typed as
// "192.168.1.9/Set0/1" would otherwise fail with "unsupported protocol scheme"
// — an error about a scheme, for a field where nobody knew one was expected.
// An https:// board (a reverse proxy fronting the shack, most often) is passed
// through untouched and needs no other handling: it is the same HTTP client.
func withScheme(u string) string {
if u == "" {
return ""
}
if l := strings.ToLower(u); strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
return u
}
return "http://" + u
}
// relayToken matches {relay} and its offset forms, {relay-1} / {relay+2}.
var relayToken = regexp.MustCompile(`\{relay([+-]\d+)?\}`)
// expand substitutes {value} with the relay's label and {relay} with its
// number, honouring an offset. A board that numbers its channels from zero is
// written {relay-1}; without that the whole pattern has to be abandoned for
// four hand-typed URLs.
func expand(s string, relay int, label string) string {
s = strings.ReplaceAll(s, "{value}", escapeValue(label))
return relayToken.ReplaceAllStringFunc(s, func(m string) string {
n := relay
if i := strings.IndexAny(m, "+-"); i >= 0 {
if off, err := strconv.Atoi(m[i : len(m)-1]); err == nil {
n += off
}
}
return strconv.Itoa(n)
})
}
func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > h.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
}
// Naming the direction matters: an operator who filled the ON URLs and left
// OFF empty gets a switch that latches, and "no URL configured" alone would
// not say which half is missing.
dir := "OFF"
if on {
dir = "ON"
}
tmpl := h.entryFor(relay, on)
if tmpl == "" {
tmpl = h.patFor(on)
}
if tmpl == "" {
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
}
// {value} with no label would send "?on=" — an empty parameter to an antenna
// switch, which most boards answer with a cheerful 200 and no movement. Say
// what is missing instead of firing it.
if strings.Contains(tmpl, "{value}") && h.labelFor(relay) == "" {
return fmt.Errorf("the %s URL for relay %d uses {value}, but relay %d has no label to put there", dir, relay, relay)
}
u := h.urlFor(relay, on)
u = withScheme(u)
if _, err := get(ctx, u, h.user, h.pass, h.insecure); err != nil {
return err
}
h.mu.Lock()
if i := relay - 1; i < len(h.state) {
h.state[i] = on
}
h.mu.Unlock()
return nil
}
// Status returns what was last commanded, not what the board is doing.
//
// These boxes rarely have a status endpoint worth trusting, so there is nothing
// to read. The cost is real and worth stating: after OpsLog restarts, every
// relay reads as off until something switches it, so the automatic control
// re-commands each one once. That is a harmless extra command on a board with
// no memory of its own — and far better than guessing a state and leaving an
// antenna disconnected because we assumed it was already selected.
func (h *httpGen) Status(ctx context.Context) ([]bool, error) {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]bool, h.count)
copy(out, h.state)
return out, nil
}