diff --git a/changelog.json b/changelog.json index 833aab4..4fc1040 100644 --- a/changelog.json +++ b/changelog.json @@ -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." ] }, { diff --git a/internal/bandopen/bandopen.go b/internal/bandopen/bandopen.go index 76ae44c..05665fb 100644 --- a/internal/bandopen/bandopen.go +++ b/internal/bandopen/bandopen.go @@ -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))] } diff --git a/internal/extsvc/clublog.go b/internal/extsvc/clublog.go index 3ee2763..c0719a2 100644 --- a/internal/extsvc/clublog.go +++ b/internal/extsvc/clublog.go @@ -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 — via ". 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. diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index e503e82..e4a1910 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -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 }