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:
2026-08-17 10:57:29 +02:00
parent dc898ce2af
commit 0bab7f05b9
10 changed files with 193 additions and 30 deletions
+12 -2
View File
@@ -14940,6 +14940,13 @@ type StationDevice struct {
OffURLs []string `json:"off_urls,omitempty"`
OnPat string `json:"on_pattern,omitempty"` // fallback, {relay} substituted
OffPat string `json:"off_pattern,omitempty"`
// InsecureTLS accepts an HTTPS certificate that cannot be verified — which
// is the only kind a relay board on the LAN can present, having signed it
// itself. Off by default, because the other HTTPS case is the opposite one:
// a board reached from outside through a proxy with a real certificate,
// where verification is what stands between an antenna switch and the
// internet.
InsecureTLS bool `json:"insecure_tls,omitempty"`
}
// deviceRelayCount is the relay count for a configured device — fixed by type,
@@ -15000,7 +15007,7 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
// generic board fall through to the WebSwitch driver below: it answered
// the WebSwitch's own address, never sent one configured URL, and
// reported itself offline so every relay button stayed greyed out.
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels)
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels, d.InsecureTLS)
default:
return relaydev.NewWebswitch(d.Host)
}
@@ -15020,7 +15027,10 @@ func deviceKey(d StationDevice) string {
// The labels are part of the wire format here: {value} sends them.
// Renaming a relay re-addresses it, and the cached driver would keep
// commanding the old name.
"|" + strings.Join(d.Labels, "\x1f")
"|" + strings.Join(d.Labels, "\x1f") +
// Ticking the box has to rebuild the driver: the cached one holds the
// verifying client and would go on refusing the certificate.
fmt.Sprintf("|%t", d.InsecureTLS)
}
return k
}
+4 -2
View File
@@ -10,7 +10,8 @@
"WAJA carried Japans civil prefecture numbers instead of the JARLs: 35 of the 47 references are renumbered.",
"Award references can be renumbered in the editor — the number was the one field it would not let you correct.",
"The compass fills the moment Station Control opens, instead of waiting out the rest of a polling interval.",
"Combined amplifiers: the power level (L/M/H) is coupled too, and both amps are commanded at once so the combiner stops beeping."
"Combined amplifiers: the power level (L/M/H) is coupled too, and both amps are commanded at once so the combiner stops beeping.",
"Generic HTTP relay: an https:// board can be accepted with its own self-signed certificate, per board."
],
"fr": [
"Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.",
@@ -20,7 +21,8 @@
"WAJA portait les numéros civils des préfectures japonaises et non ceux de la JARL : 35 des 47 références sont renumérotées.",
"Les références dun diplôme se renumérotent dans l’éditeur : le numéro était le seul champ quil refusait de corriger.",
"La boussole se remplit dès louverture de Station Control, au lieu dattendre la fin dun intervalle dinterrogation.",
"Amplis combinés : le niveau de puissance (L/M/H) est couplé lui aussi, et les deux amplis sont commandés en même temps — fini le bip du combineur."
"Amplis combinés : le niveau de puissance (L/M/H) est couplé lui aussi, et les deux amplis sont commandés en même temps — fini le bip du combineur.",
"Relais HTTP générique : une carte en https:// peut être acceptée avec son certificat auto-signé, carte par carte."
]
},
{
@@ -3,6 +3,7 @@ import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Squar
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -82,7 +83,7 @@ type Device = {
id: string; type: string; name: string; host: string;
user?: string; pass?: string; channels?: number; labels: string[];
// Generic HTTP board only. The per-relay URLs win over the patterns.
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string;
on_urls?: string[]; off_urls?: string[]; on_pattern?: string; off_pattern?: string; insecure_tls?: boolean;
};
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
@@ -787,6 +788,12 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((s) => (s ?? '').includes('{value}'))
&& device.labels.some((l) => !l.trim());
// Any https:// among this board's URLs. A relay box on the LAN signs its own
// certificate, so HTTPS to one cannot be verified — the operator has to say
// whether to accept that, and the question only arises once they type https.
const usesHTTPS = isHTTPGen
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
.some((u) => (u ?? '').trim().toLowerCase().startsWith('https://'));
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
@@ -959,6 +966,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</div>
</div>
<div className="text-[10px] text-muted-foreground">{t('station.patternHint')}</div>
{/* Shown only once an https:// URL is actually in use. 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. */}
{usesHTTPS && (
<label className="flex items-start gap-2 text-xs cursor-pointer">
<Checkbox className="mt-0.5" checked={!!device.insecure_tls}
onCheckedChange={(c) => onChange({ ...device, insecure_tls: !!c })} />
<span>
{t('station.insecureTls')}
<span className="block text-[10px] text-muted-foreground">{t('station.insecureTlsHint')}</span>
</span>
</label>
)}
<div className="space-y-1">
<Label>{t('station.perRelayUrls')}</Label>
<div className="space-y-1">
+2 -2
View File
@@ -157,7 +157,7 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.typeHttpGen': 'HTTP relay (home-made / generic)', 'station.onPattern': 'ON URL pattern', 'station.offPattern': 'OFF URL pattern', 'station.patternHint': 'Optional, http or https. {relay} is the relay number — {relay-1} if the board counts from zero. {value} is that relay\'s label below, so …/relay?on={value} with relay 1 named Ant1 sends …/relay?on=Ant1. Leave both blank if every relay has its own full URL.', 'station.perRelayUrls': 'Per-relay URLs (optional)', 'station.perRelayHint': 'Filled in here, these win over the patterns — for a switch whose channels have nothing in common. {relay} and {value} work here too. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onUrlPh': 'ON URL', 'station.offUrlPh': 'OFF URL',
'station.typeHttpGen': 'HTTP relay (home-made / generic)', 'station.onPattern': 'ON URL pattern', 'station.offPattern': 'OFF URL pattern', 'station.insecureTls': 'Accept a self-signed certificate', 'station.insecureTlsHint': 'A relay board on your own network signs its own certificate, which nothing can verify. Leave this off for a board reached over the internet through a proxy: there the certificate is real, and checking it is what protects the link.', 'station.patternHint': 'Optional, http or https. {relay} is the relay number — {relay-1} if the board counts from zero. {value} is that relay\'s label below, so …/relay?on={value} with relay 1 named Ant1 sends …/relay?on=Ant1. Leave both blank if every relay has its own full URL.', 'station.perRelayUrls': 'Per-relay URLs (optional)', 'station.perRelayHint': 'Filled in here, these win over the patterns — for a switch whose channels have nothing in common. {relay} and {value} work here too. State is remembered, not read back: after a restart every relay is re-commanded once.', 'station.onUrlPh': 'ON URL', 'station.offUrlPh': 'OFF URL',
'station.valueNeedsLabels': 'A URL above uses {value}, which sends the relays label — name every relay you switch that way, or its URL goes out with an empty value.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotateTo': 'Rotate to {az}°', 'station.rotatorNoRead': 'No heading read', 'station.bands': 'Bands', 'station.nudgeUp': 'Up {n} kHz', 'station.nudgeDown': 'Down {n} kHz', 'station.trackOn': 'Tracking on', 'station.trackOff': 'Tracking off', 'station.trackStepTip': 'Re-tune only when the rig has moved this far', 'station.trackModeTip': 'When the antenna is allowed to re-tune', 'station.trackAlways': 'Every frequency change', 'station.trackStep': 'Past a step', 'station.trackBand': 'Band change only', 'station.trackAlwaysTip': 'Follow every frequency change. Best resonance, but the motors run constantly — and on a SteppIR every move blocks transmit while the elements travel.', 'station.trackStepTipMode': 'Re-tune only once the rig has moved further than the step. Follows a QSY, ignores tuning around.', 'station.trackBandTip': 'Re-tune only when the band changes. The motors move a few times a day and are left alone within a band.', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.hostHint': 'LAN IP for local use. To reach the board from OUTSIDE, put a full URL here — e.g. https://relay.yourdomain.com — pointing at a reverse proxy (Nginx Proxy Manager…) that fronts the boards HTTP port.', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
'awards.followHint': 'Awards shown in the Awards tab. Leave the right side empty to show them all.', 'awards.available': 'All awards', 'awards.followed': 'Followed', 'awards.search': 'Filter…', 'awards.addAll': 'Add all', 'awards.clear': 'Clear', 'awards.allTracked': 'All awards are followed.', 'awards.noneFollowed': 'Nothing followed yet — the Awards tab shows all.',
@@ -595,7 +595,7 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.typeHttpGen': 'Relais HTTP (fait main / générique)', 'station.onPattern': 'Modèle dURL ON', 'station.offPattern': 'Modèle dURL OFF', 'station.patternHint': 'Optionnel, http ou https. {relay} est le numéro du relais — {relay-1} si la carte compte à partir de zéro. {value} est le libellé de ce relais ci-dessous : …/relay?on={value} avec le relais 1 nommé Ant1 envoie …/relay?on=Ant1. Laisse les deux vides si chaque relais a sa propre URL complète.', 'station.perRelayUrls': 'URL par relais (optionnel)', 'station.perRelayHint': 'Renseignées ici, elles lemportent sur les modèles — pour un commutateur dont les voies nont rien en commun. {relay} et {value} fonctionnent aussi ici. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onUrlPh': 'URL ON', 'station.offUrlPh': 'URL OFF',
'station.typeHttpGen': 'Relais HTTP (fait main / générique)', 'station.onPattern': 'Modèle dURL ON', 'station.offPattern': 'Modèle dURL OFF', 'station.insecureTls': 'Accepter un certificat auto-signé', 'station.insecureTlsHint': 'Une carte relais sur ton propre réseau signe elle-même son certificat, que rien ne peut vérifier. Laisse décoché pour une carte atteinte par internet à travers un proxy : là le certificat est réel, et le vérifier est ce qui protège la liaison.', 'station.patternHint': 'Optionnel, http ou https. {relay} est le numéro du relais — {relay-1} si la carte compte à partir de zéro. {value} est le libellé de ce relais ci-dessous : …/relay?on={value} avec le relais 1 nommé Ant1 envoie …/relay?on=Ant1. Laisse les deux vides si chaque relais a sa propre URL complète.', 'station.perRelayUrls': 'URL par relais (optionnel)', 'station.perRelayHint': 'Renseignées ici, elles lemportent sur les modèles — pour un commutateur dont les voies nont rien en commun. {relay} et {value} fonctionnent aussi ici. L’état est mémorisé, pas relu : après un redémarrage chaque relais est recommandé une fois.', 'station.onUrlPh': 'URL ON', 'station.offUrlPh': 'URL OFF',
'station.valueNeedsLabels': 'Une URL ci-dessus utilise {value}, qui envoie le libellé du relais — nomme chaque relais commuté ainsi, sinon son URL part avec une valeur vide.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotateTo': 'Tourner vers {az}°', 'station.rotatorNoRead': 'Azimut non lu', 'station.bands': 'Bandes', 'station.nudgeUp': 'Monter de {n} kHz', 'station.nudgeDown': 'Descendre de {n} kHz', 'station.trackOn': 'Suivi actif', 'station.trackOff': 'Suivi inactif', 'station.trackStepTip': 'Ne réaccorder que si le rig a bougé d au moins ça', 'station.trackModeTip': "Quand l'antenne a le droit de se réaccorder", 'station.trackAlways': 'À chaque changement', 'station.trackStep': 'Au-delà d un pas', 'station.trackBand': 'Changement de bande', 'station.trackAlwaysTip': "Suivre chaque changement de fréquence. Résonance idéale, mais les moteurs tournent en permanence — et sur une SteppIR chaque déplacement bloque l'émission le temps du mouvement.", 'station.trackStepTipMode': "Ne réaccorder qu'une fois le rig sorti du pas. Suit un QSY, ignore la recherche autour.", 'station.trackBandTip': "Ne réaccorder qu'au changement de bande. Les moteurs bougent quelques fois par jour et restent tranquilles dans une bande.", 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.hostHint': "IP du LAN en local. Pour joindre la carte depuis L'EXTÉRIEUR, saisis une URL complète ici — ex. https://relais.tondomaine.com — pointant vers un reverse proxy (Nginx Proxy Manager…) qui expose le port HTTP de la carte.", 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
'awards.followHint': 'Diplômes affichés dans longlet Awards. Laisse la colonne de droite vide pour tous les afficher.', 'awards.available': 'Tous les diplômes', 'awards.followed': 'Suivis', 'awards.search': 'Filtrer…', 'awards.addAll': 'Tout ajouter', 'awards.clear': 'Vider', 'awards.allTracked': 'Tous les diplômes sont suivis.', 'awards.noneFollowed': 'Aucun pour linstant — longlet Awards les montre tous.',
+2
View File
@@ -3379,6 +3379,7 @@ export namespace main {
off_urls?: string[];
on_pattern?: string;
off_pattern?: string;
insecure_tls?: boolean;
static createFrom(source: any = {}) {
return new StationDevice(source);
@@ -3398,6 +3399,7 @@ export namespace main {
this.off_urls = source["off_urls"];
this.on_pattern = source["on_pattern"];
this.off_pattern = source["off_pattern"];
this.insecure_tls = source["insecure_tls"];
}
}
export class StationRelay {
+2 -2
View File
@@ -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")
}
+6 -3
View File
@@ -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()
+7 -7
View File
@@ -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 {
+80
View File
@@ -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")
}
}
+57 -11
View File
@@ -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
}