fix: a test button that cannot fail is worse than no button

TestClublog checked that three fields were non-empty and returned "Ready — CALL
via EMAIL". Nothing was ever sent to Club Log, so a wrong password produced the
identical green message. It now signs in, through getadif.php because that is
the one authenticated endpoint that cannot change anything: a test must never
put a record into someone's log. A future start year keeps it from downloading
130 000 QSOs to prove a password, and a rejected login answers 403 before any
body arrives.

LoTW is two credentials doing two jobs and the button reported only the first.
Uploads go through TQSL signed by the certificate — the website password is
never involved — so a wrong one breaks nothing until the day confirmations are
downloaded, by which time nobody connects the two events. Both are now checked
and, more importantly, reported separately.

Also: the detector still refused 12 and 10 m while the PSK Reporter feed was
already subscribed to them, so those decodes were fetched and thrown away. The
rule was never "HF is out", it is "is an opening here an event" — 20 m being
open is the normal state of the band, 10 m opening is not.
This commit is contained in:
2026-08-11 13:16:09 +02:00
parent 9c33feecfa
commit 0550ecdac3
4 changed files with 108 additions and 11 deletions
+6 -2
View File
@@ -8,7 +8,9 @@
"Shared CAT is steadier and now diagnoses itself. It survives a rig answering \"busy\" just after transmit instead of dropping the link, stops repeating a PTT state the client never changed, and logs a plain explanation when another program has taken its port or when a client is set to a rig model instead of Hamlib NET rigctl. It also answers the lock-mode and stop-morse commands some clients send around every transmit, instead of refusing them.",
"Web publishing now offers every field a QSO carries, awards included — 123 instead of 23 — from a searchable dropdown, with the chosen columns listed above it in publication order. Choose carefully: the page is public and the list includes addresses and e-mail.",
"Band-opening detection no longer ignores long paths. It capped them at 2400 km on the assumption that anything further was not a single hop; multi-hop sporadic E is ordinary on 6 m, so the openings most worth hearing about were the ones being discarded. Direction still decides — a second hop leaves the sector the first one entered.",
"Band-opening detection now has the data it needs. Switching on \"Watch for band openings\" (Settings DX Cluster) subscribes to the PSK Reporter feed — every station on the air reporting what it decodes, rather than the handful of VHF spots a cluster carries — and adds the two RBN nodes if they are missing. Pick the bands to watch: 12, 10, 6, 4 and 2 m. An MQTT chip in the status bar, beside the rig and amplifier, shows the feed is alive."
"Band-opening detection now has the data it needs. Switching on \"Watch for band openings\" (Settings DX Cluster) subscribes to the PSK Reporter feed — every station on the air reporting what it decodes, rather than the handful of VHF spots a cluster carries — and adds the two RBN nodes if they are missing. Pick the bands to watch: 12, 10, 6, 4 and 2 m. An MQTT chip in the status bar, beside the rig and amplifier, shows the feed is alive.",
"Opening detection now covers 12 and 10 m, not just 6, 4 and 2 m. The feed was already subscribed to them while the detector still threw them away, so a busy 10 m could never be announced.",
"Test connection now actually tests. Club Log checked that the three fields were not empty and reported success without contacting anyone, so a wrong password looked exactly like a right one; it now signs in for real, through the read-only endpoint so a test can never add a record. LoTW reports its two credentials separately: TQSL signs uploads and never uses the website password, so a wrong one used to break nothing until the day confirmations were downloaded."
],
"fr": [
"Décodes digitaux : le carré locator d une station pouvait être enregistré comme son indicatif quand le texte du message sortait de l ordinaire. Un grid à la place de l indicatif est maintenant refusé.",
@@ -16,7 +18,9 @@
"Le CAT partagé est plus solide et se diagnostique tout seul. Il survit à un rig qui répond « occupé » juste après une émission au lieu de lâcher le lien, cesse de répéter un état PTT que le client n a pas changé, et écrit une explication claire quand un autre programme lui a pris son port ou qu un client est réglé sur un modèle de rig au lieu de Hamlib NET rigctl. Il répond aussi aux commandes de verrouillage et d arrêt du morse que certains logiciels envoient à chaque émission, au lieu de les refuser.",
"La publication web propose désormais tous les champs d un QSO, awards compris — 123 au lieu de 23 — depuis une liste déroulante cherchable, les colonnes choisies étant listées au-dessus dans l ordre de publication. À choisir avec soin : la page est publique et la liste contient adresses et e-mails.",
"La détection d ouverture n ignore plus les longues distances. Elle plafonnait à 2400 km en supposant qu au-delà ce n était plus un saut simple ; l Es à sauts multiples est ordinaire sur 6 m, donc les ouvertures les plus intéressantes étaient précisément celles qu on jetait. C est toujours la direction qui tranche — un second saut repart dans le secteur où le premier est arrivé.",
"La détection d ouverture dispose enfin des données qu il lui faut. Activer « Surveiller les ouvertures de bande » (Paramètres Cluster DX) souscrit au flux PSK Reporter — toutes les stations en l air qui rapportent ce qu elles décodent, au lieu des quelques spots VHF que porte un cluster — et ajoute les deux nœuds RBN s ils manquent. Les bandes surveillées se choisissent : 12, 10, 6, 4 et 2 m. Une pastille MQTT dans la barre d état, à côté du rig et de l ampli, montre que le flux est vivant."
"La détection d ouverture dispose enfin des données qu il lui faut. Activer « Surveiller les ouvertures de bande » (Paramètres Cluster DX) souscrit au flux PSK Reporter — toutes les stations en l air qui rapportent ce qu elles décodent, au lieu des quelques spots VHF que porte un cluster — et ajoute les deux nœuds RBN s ils manquent. Les bandes surveillées se choisissent : 12, 10, 6, 4 et 2 m. Une pastille MQTT dans la barre d état, à côté du rig et de l ampli, montre que le flux est vivant.",
"La détection d ouverture couvre désormais le 12 et le 10 m, plus seulement le 6, 4 et 2 m. Le flux y était déjà abonné alors que le détecteur les jetait encore, donc un 10 m très actif ne pouvait jamais être annoncé.",
"Le bouton Tester la connexion teste vraiment. Club Log vérifiait que les trois champs n étaient pas vides et annonçait la réussite sans contacter personne : un mauvais mot de passe ressemblait exactement à un bon. Il s authentifie maintenant pour de vrai, via le point d accès en lecture seule pour qu un test ne puisse jamais ajouter un enregistrement. LoTW annonce ses deux identifiants séparément : TQSL signe les envois et n utilise jamais le mot de passe du site, donc un mauvais ne cassait rien jusqu au jour du téléchargement des confirmations."
]
},
{
+10 -3
View File
@@ -66,9 +66,16 @@ func DefaultConfig() Config {
}
}
// Bands watched. HF is deliberately absent: an "opening" on 20 m is the normal
// state of the band and announcing it would be noise.
var watched = map[string]bool{"6m": true, "4m": true, "2m": true}
// Bands watched.
//
// 10 and 12 m are in, and they are HF. The line is not "HF versus VHF" but
// "is an opening here an event": 20 m being open is the normal state of the
// band and saying so is noise, while 10 and 12 m spend most of a solar cycle
// shut and open sharply when they go — which is exactly what an operator wants
// interrupting them for. They also carry the Es that reaches 6 m, often first.
//
// Anything below 12 m stays out for the reason above.
var watched = map[string]bool{"12m": true, "10m": true, "6m": true, "4m": true, "2m": true}
// Watched reports whether a band is one the detector looks at.
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
+67 -4
View File
@@ -27,6 +27,11 @@ const clublogBatchURL = "https://clublog.org/putlogs.php"
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// clublogDownloadURL is Club Log's ADIF export. Used ONLY to verify credentials
// (see TestClublog): it is the one authenticated endpoint that cannot change
// anything in the operator's log, which is what a test button must never do.
const clublogDownloadURL = "https://clublog.org/getadif.php"
// clublogAppAPIKey is OpsLog's Club Log *application* API key. Club Log
// requires an api parameter that identifies the client software (not the
// user) — the same way Log4OM embeds its own key — so we ship it baked in
@@ -204,17 +209,75 @@ func stripHTMLBrief(s string) string {
// TestClublog validates the configured credentials by attempting a no-op
// style check. Club Log has no dedicated status endpoint, so we report the
// fields look complete; a real failure surfaces on the first upload.
// TestClublog checks the credentials against Club Log, not against themselves.
//
// It used to verify that the three fields were non-empty and then report
// "Ready — <call> via <email>". Nothing was sent anywhere, so a wrong password
// produced exactly the same green message as a right one. That is worse than
// having no button: it is confidence the test never earned, and it cost an
// operator the one moment they were actually looking for the problem.
//
// The download endpoint is used because it is READ-ONLY: testing a password
// must not put a record into someone's log. Club Log answers a rejected login
// with 403 before sending any body, so the answer arrives immediately; on
// success the body is a log, and we read a few bytes and hang up rather than
// pull it down to prove a point.
func TestClublog(ctx context.Context, cfg ServiceConfig) (string, error) {
_ = ctx
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case strings.TrimSpace(cfg.Email) == "":
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case strings.TrimSpace(cfg.Callsign) == "":
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
return fmt.Sprintf("Ready — %s via %s", strings.ToUpper(strings.TrimSpace(cfg.Callsign)), strings.TrimSpace(cfg.Email)), nil
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("call", call)
// A future start date: the credentials are what is being checked, not the
// log, and an operator with 130 000 QSOs should not download them to find
// out whether a password is right.
form.Set("startyear", "2099")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogDownloadURL, strings.NewReader(form.Encode()))
if err != nil {
return "", fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
return "", fmt.Errorf("clublog: could not reach Club Log: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
msg := strings.TrimSpace(string(body))
switch resp.StatusCode {
case http.StatusOK:
return fmt.Sprintf("Ready — %s via %s (Club Log accepted the login)", call, email), nil
case http.StatusUnauthorized, http.StatusForbidden:
if msg == "" {
msg = "wrong e-mail, password or logbook callsign"
}
return "", fmt.Errorf("Club Log rejected the login: %s", msg)
default:
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return "", fmt.Errorf("clublog: http %d %s", resp.StatusCode, msg)
}
}
// clublogPost performs the form POST and maps the HTTP status to a result.
+25 -2
View File
@@ -277,10 +277,33 @@ func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) {
if err != nil {
return "", fmt.Errorf("lotw: can't read station locations: %w", err)
}
found := ""
for _, l := range locs {
if strings.EqualFold(l.Name, loc) {
return fmt.Sprintf("Ready — TQSL found, location %q (%s)", l.Name, l.Call), nil
found = l.Call
break
}
}
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
if found == "" {
return "", fmt.Errorf("lotw: station location %q not found in TQSL", loc)
}
// LoTW is TWO credentials doing two jobs, and the button used to report only
// the first. Uploading goes through TQSL and is signed by the certificate —
// the website password is never involved, so a wrong one breaks nothing until
// the day confirmations are downloaded and nobody connects the two events.
//
// So the download login is tested separately, and said separately. A future
// "since" date makes LoTW return an empty report rather than the whole
// account: the credentials are what is being checked, not the log.
up := fmt.Sprintf("Ready — TQSL found, location %q (%s)", loc, found)
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
return up + ". Download login not set — confirmations cannot be fetched.", nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := DownloadLoTWConfirmations(ctx, nil, cfg, "2099-01-01", ""); err != nil {
return "", fmt.Errorf("%s — but the DOWNLOAD login failed: %w", up, err)
}
return up + ". Download login accepted.", nil
}