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.
This commit is contained in:
@@ -20,8 +20,8 @@ func NewDenkovi(serial string, count int) Device {
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Close() error { return nil }
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Close() error { return nil }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ type httpGen struct {
|
||||
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
|
||||
@@ -64,7 +67,7 @@ type httpGen struct {
|
||||
// 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) Device {
|
||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string, insecure bool) Device {
|
||||
if count <= 0 {
|
||||
count = len(onURLs)
|
||||
}
|
||||
@@ -74,7 +77,7 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
||||
return &httpGen{
|
||||
onURLs: onURLs, offURLs: offURLs,
|
||||
onPat: onPat, offPat: offPat, labels: labels,
|
||||
user: user, pass: pass, count: count,
|
||||
user: user, pass: pass, count: count, insecure: insecure,
|
||||
state: make([]bool, count),
|
||||
}
|
||||
}
|
||||
@@ -196,7 +199,7 @@ func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
|
||||
}
|
||||
u := h.urlFor(relay, on)
|
||||
u = withScheme(u)
|
||||
if _, err := get(ctx, u, h.user, h.pass); err != nil {
|
||||
if _, err := get(ctx, u, h.user, h.pass, h.insecure); err != nil {
|
||||
return err
|
||||
}
|
||||
h.mu.Lock()
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
|
||||
|
||||
d := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/relay?n={relay}&state=on",
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil)
|
||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil, false)
|
||||
if err := d.Set(context.Background(), 2, true); err != nil {
|
||||
t.Fatalf("Set on: %v", err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
||||
d := NewHTTPGeneric(
|
||||
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
||||
[]string{srv.URL + "/FF0100", "", ""},
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil)
|
||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil, false)
|
||||
|
||||
_ = d.Set(context.Background(), 1, true) // its own URL
|
||||
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
||||
@@ -82,7 +82,7 @@ func TestHTTPGenericValueIsTheRelayLabel(t *testing.T) {
|
||||
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
|
||||
nil,
|
||||
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
|
||||
"", "", 3, []string{"Ant1", "Beam 20m", ""})
|
||||
"", "", 3, []string{"Ant1", "Beam 20m", ""}, false)
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 2, false)
|
||||
mu.Lock()
|
||||
@@ -107,7 +107,7 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
d := NewHTTPGeneric(nil, nil,
|
||||
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil)
|
||||
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil, false)
|
||||
_ = d.Set(context.Background(), 1, true)
|
||||
_ = d.Set(context.Background(), 4, false)
|
||||
mu.Lock()
|
||||
@@ -123,7 +123,7 @@ func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||
// movement. It must be refused, and the message must say the label is what is
|
||||
// missing.
|
||||
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
|
||||
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""})
|
||||
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""}, false)
|
||||
err := d.Set(context.Background(), 1, true)
|
||||
if err == nil || !strings.Contains(err.Error(), "label") {
|
||||
t.Errorf("err = %v, want it to name the missing label", err)
|
||||
@@ -149,7 +149,7 @@ func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
|
||||
// A switch with the ON URLs filled and OFF left empty latches. The error has to
|
||||
// name the direction, or the operator cannot tell which half is missing.
|
||||
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil)
|
||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil, false)
|
||||
err := d.Set(context.Background(), 1, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
||||
t.Errorf("err = %v, want it to name the OFF direction", err)
|
||||
@@ -160,7 +160,7 @@ func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
defer srv.Close()
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil, false)
|
||||
_ = d.Set(context.Background(), 2, true)
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A relay board on the LAN signs its own certificate — there is no authority
|
||||
// anywhere that could have signed it. httptest.NewTLSServer presents exactly
|
||||
// that: a certificate from an unknown issuer, which is what the hardware does.
|
||||
func selfSignedRelay(t *testing.T) (*httptest.Server, func() []string) {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var got []string
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
got = append(got, r.URL.Path)
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), got...)
|
||||
}
|
||||
}
|
||||
|
||||
// With the box ticked, the board answers.
|
||||
func TestHTTPSRelayWithASelfSignedCertificate(t *testing.T) {
|
||||
srv, seen := selfSignedRelay(t)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, true)
|
||||
if err := d.Set(context.Background(), 1, true); err != nil {
|
||||
t.Fatalf("Set over HTTPS: %v", err)
|
||||
}
|
||||
if paths := seen(); len(paths) != 1 || paths[0] != "/on/1" {
|
||||
t.Errorf("the board was asked for %v, want /on/1", paths)
|
||||
}
|
||||
}
|
||||
|
||||
// Without it, the request is refused — and the refusal has to name the box.
|
||||
//
|
||||
// Go's own message, "x509: certificate signed by unknown authority", is
|
||||
// accurate and tells an operator nothing about what to do next. This is the
|
||||
// difference between a dead end and an instruction, and it is the whole reason
|
||||
// the default can safely stay OFF.
|
||||
func TestARefusedCertificateNamesTheSetting(t *testing.T) {
|
||||
srv, seen := selfSignedRelay(t)
|
||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 2, nil, false)
|
||||
err := d.Set(context.Background(), 1, true)
|
||||
if err == nil {
|
||||
t.Fatal("an unverifiable certificate was accepted with the box unticked")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "self-signed") {
|
||||
t.Errorf("the refusal reads %q — it does not say which setting to change", err)
|
||||
}
|
||||
if len(seen()) != 0 {
|
||||
t.Error("the request reached the board despite the certificate being refused")
|
||||
}
|
||||
}
|
||||
|
||||
// The box belongs to ONE board. An operator with a self-signed switch on the
|
||||
// LAN and a second board reached through a proper HTTPS proxy must keep real
|
||||
// verification on the second — that link crosses the internet, and it commands
|
||||
// an antenna.
|
||||
func TestAcceptingOneBoardsCertificateDoesNotAffectAnother(t *testing.T) {
|
||||
srv, _ := selfSignedRelay(t)
|
||||
lan := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, true)
|
||||
if err := lan.Set(context.Background(), 1, true); err != nil {
|
||||
t.Fatalf("the LAN board: %v", err)
|
||||
}
|
||||
strict := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", "", "", "", 1, nil, false)
|
||||
if err := strict.Set(context.Background(), 1, true); err == nil {
|
||||
t.Error("the second board accepted the certificate too — the setting is not per board")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,10 @@ package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -28,8 +31,8 @@ import (
|
||||
|
||||
// Device is one relay board.
|
||||
type Device interface {
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||
// Close releases any OS handle the driver holds (serial port, FTDI handle).
|
||||
// Network boards hold nothing and no-op. MUST be called when a cached driver is
|
||||
@@ -40,8 +43,47 @@ type Device interface {
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||
|
||||
// insecureClient talks to a board presenting a certificate nothing can verify.
|
||||
//
|
||||
// Which is nearly every board that offers HTTPS at all: a relay box on the LAN
|
||||
// signs its own certificate, and there is no authority anywhere that could have
|
||||
// signed it. Refusing that means refusing HTTPS on the hardware, which is not a
|
||||
// security decision, only an outcome.
|
||||
//
|
||||
// So it is offered, per board, and OFF by default — 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. One box, on the board that needs it.
|
||||
//
|
||||
// Built once. A Transport per request would open a fresh TLS connection every
|
||||
// time and never reuse one.
|
||||
var insecureClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // the operator ticked the box for this board
|
||||
},
|
||||
}
|
||||
|
||||
// certError says which box to tick when TLS is what failed.
|
||||
//
|
||||
// Go's own message — "x509: certificate signed by unknown authority" — is
|
||||
// accurate and tells an operator nothing about what to do next. Naming the
|
||||
// setting turns a dead end into an instruction.
|
||||
func certError(err error) error {
|
||||
var unknown x509.UnknownAuthorityError
|
||||
var host x509.HostnameError
|
||||
var verify *tls.CertificateVerificationError
|
||||
if errors.As(err, &unknown) || errors.As(err, &host) || errors.As(err, &verify) {
|
||||
return fmt.Errorf("%w — the board's HTTPS certificate cannot be verified; "+
|
||||
"tick \"Accept a self-signed certificate\" for this board if it is on your own network", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// get issues a GET with optional basic auth and returns the body on 2xx.
|
||||
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
//
|
||||
// insecure skips certificate verification, for a board that signs its own.
|
||||
func get(ctx context.Context, url, user, pass string, insecure bool) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -49,9 +91,13 @@ func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
client := httpClient()
|
||||
if insecure {
|
||||
client = insecureClient
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, certError(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
@@ -87,8 +133,8 @@ type webswitch struct {
|
||||
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > w.count {
|
||||
@@ -98,7 +144,7 @@ func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if on {
|
||||
action = "on"
|
||||
}
|
||||
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "")
|
||||
_, err := get(ctx, fmt.Sprintf("%s/relaycontrol/%s/%d", relayBase(w.host), action, relay), "", "", false)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -109,7 +155,7 @@ func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||
sel.WriteString(strconv.Itoa(i))
|
||||
sel.WriteByte('$')
|
||||
}
|
||||
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "")
|
||||
body, err := get(ctx, fmt.Sprintf("%s/relaystate/get2/%s", relayBase(w.host), sel.String()), "", "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -156,7 +202,7 @@ func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||
state = "01"
|
||||
}
|
||||
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
||||
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass)
|
||||
_, err := get(ctx, fmt.Sprintf("%s/FF%02d%s", relayBase(k.host), relay, state), k.user, k.pass, false)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -170,7 +216,7 @@ type kmStatus struct {
|
||||
}
|
||||
|
||||
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass)
|
||||
body, err := get(ctx, fmt.Sprintf("%s/status.xml", relayBase(k.host)), k.user, k.pass, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user