diff --git a/app.go b/app.go index 247ab0a..369277a 100644 --- a/app.go +++ b/app.go @@ -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 } diff --git a/changelog.json b/changelog.json index d540e57..c6b9368 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,8 @@ "WAJA carried Japan’s civil prefecture numbers instead of the JARL’s: 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 d’un diplôme se renumérotent dans l’éditeur : le numéro était le seul champ qu’il refusait de corriger.", "La boussole se remplit dès l’ouverture de Station Control, au lieu d’attendre la fin d’un intervalle d’interrogation.", - "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." ] }, { diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 19c32e9..89cfa7e 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -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([]); useEffect(() => { @@ -959,6 +966,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
{t('station.patternHint')}
+ {/* 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 && ( + + )}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 45ceb70..31d40dc 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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 relay’s 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 board’s 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 d’URL ON', 'station.offPattern': 'Modèle d’URL 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 l’emportent sur les modèles — pour un commutateur dont les voies n’ont 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 d’URL ON', 'station.offPattern': 'Modèle d’URL 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 l’emportent sur les modèles — pour un commutateur dont les voies n’ont 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 l’onglet 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 l’instant — l’onglet Awards les montre tous.', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 5a0c9a3..4085a48 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 { diff --git a/internal/relaydev/denkovi_other.go b/internal/relaydev/denkovi_other.go index ea1b58d..6cb6043 100644 --- a/internal/relaydev/denkovi_other.go +++ b/internal/relaydev/denkovi_other.go @@ -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") } diff --git a/internal/relaydev/httpgen.go b/internal/relaydev/httpgen.go index c77384e..bdcb965 100644 --- a/internal/relaydev/httpgen.go +++ b/internal/relaydev/httpgen.go @@ -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() diff --git a/internal/relaydev/httpgen_test.go b/internal/relaydev/httpgen_test.go index 0fe8306..f9b1efa 100644 --- a/internal/relaydev/httpgen_test.go +++ b/internal/relaydev/httpgen_test.go @@ -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 { diff --git a/internal/relaydev/httpstls_test.go b/internal/relaydev/httpstls_test.go new file mode 100644 index 0000000..5a7e9b8 --- /dev/null +++ b/internal/relaydev/httpstls_test.go @@ -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") + } +} diff --git a/internal/relaydev/relaydev.go b/internal/relaydev/relaydev.go index 11a0ad3..8d4bb71 100644 --- a/internal/relaydev/relaydev.go +++ b/internal/relaydev/relaydev.go @@ -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: 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 }