Files
OpsLog/internal/extsvc/clublog.go
T
rouggy 628d1e8490 fix(clublog): use OpsLog's own application API key
The embedded key was registered to XV9Q, not to OpsLog. Not a cosmetic
detail: Club Log identifies the client software by that key, so every
OpsLog upload in the world was attributed to that callsign. Its owner
received the abuse warning OpsLog earned when the on-close sweep was still
posting hundreds of QSOs through the realtime endpoint - and a revocation
aimed at them would have cut Club Log uploads for every user of this
program at once.

G7VJR issued a key for "OpsLog" on request. Same mechanism, same UX: the
key identifies the software, the operator still supplies their own e-mail
and password, so it authorises nothing on its own.

Club Log asks that it not be published in source code. The source is on a
private remote and only the built exe is released, but it remains
recoverable from that binary by anyone who looks - as it is for every
logger that embeds one. It is an identifier that can be attributed, not a
secret.
2026-08-18 10:10:33 +02:00

360 lines
14 KiB
Go

package extsvc
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// clublogRealtimeURL is Club Log's real-time single-QSO upload endpoint, used
// when a QSO is logged. Bulk/manual uploads go to clublogBatchURL instead.
const clublogRealtimeURL = "https://clublog.org/realtime.php"
// clublogBatchURL is Club Log's batch ADIF endpoint: it accepts a whole ADIF
// file in one multipart request and dedupes server-side, so a manual upload of
// N QSOs is one HTTP request instead of N realtime.php calls.
const clublogBatchURL = "https://clublog.org/putlogs.php"
// clublogUserAgent identifies OpsLog to Club Log. Go's default
// "Go-http-client/1.1" User-Agent is blocked by Club Log's web front end (nginx
// returns 403 Forbidden before the request reaches the app), so every request
// must send a real, app-identifying User-Agent.
const clublogUserAgent = "OpsLog/1.0 (+https://github.com/GregTroar/OpsLog)"
// looksLikeHTML reports a body that is a web page rather than an answer. Club
// Log serves its normal site for refusals and blocks, so this is what separates
// "here is what went wrong" from 4 KB of markup an operator cannot act on.
func looksLikeHTML(s string) bool {
l := strings.ToLower(strings.TrimSpace(s))
return strings.HasPrefix(l, "<!doctype html") || strings.HasPrefix(l, "<html")
}
// 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 own Club Log *application* API key, issued to
// "OpsLog" by G7VJR on 2026-08-18.
//
// Club Log requires an api parameter identifying the client SOFTWARE, not the
// user — the same way Log4OM embeds its own — so it ships baked in rather than
// asking every operator to request one.
//
// It replaces a key that was registered to XV9Q, not to OpsLog. That was not a
// cosmetic detail: every OpsLog upload in the world was attributed to that
// callsign, its owner received the abuse warnings OpsLog earned, and a
// revocation aimed at them would have cut Club Log uploads for every user of
// this program at once.
//
// Club Log asks that the key not be published in source code. The source lives
// on a private remote and only the built exe is released — but the key is still
// recoverable from that binary by anyone who looks, as it is for every logger
// that embeds one. Treat it as an identifier that can be attributed, never as a
// secret: it authorises nothing on its own, since every request also carries the
// operator's own e-mail and password.
const clublogAppAPIKey = "8df47807a412c586787c9401c96c10c135d6e580"
// UploadClublog pushes one ADIF record to Club Log in real time. The user
// supplies the account email + password and the logbook callsign; the
// application API key is embedded (clublogAppAPIKey), so users never need
// one — same UX as Log4OM.
//
// Form params:
//
// email, password, callsign, adif, clientident, api
//
// Club Log replies with HTTP 200 on success; on failure it returns a 4xx/5xx
// status with a plain-text reason in the body.
func UploadClublog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifRecord) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif record")
}
form := url.Values{}
form.Set("email", email)
form.Set("password", cfg.Password)
form.Set("callsign", call)
form.Set("adif", adifRecord)
form.Set("clientident", "OpsLog")
// Club Log requires the application API key. Use OpsLog's embedded key;
// a per-user override (cfg.APIKey) wins if one is ever configured.
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
form.Set("api", api)
res, err := clublogPost(ctx, client, clublogRealtimeURL, form)
if err != nil {
return UploadResult{}, err
}
return res, nil
}
// UploadClublogADIF pushes a whole ADIF document (header + many records) to
// Club Log's batch endpoint (putlogs.php) in a single multipart request. Use
// this for manual/bulk uploads instead of calling UploadClublog per QSO. Club
// Log dedupes server-side, so re-uploading QSOs it already holds is harmless.
//
// Multipart form fields: email, password, callsign, api, clientident, and the
// ADIF as a "file" upload. Returns HTTP 200 on success with a summary body.
func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConfig, adifDoc string) (UploadResult, error) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return UploadResult{}, fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return UploadResult{}, fmt.Errorf("clublog: password not set")
case call == "":
return UploadResult{}, fmt.Errorf("clublog: logbook callsign not set")
case strings.TrimSpace(adifDoc) == "":
return UploadResult{}, fmt.Errorf("clublog: empty adif document")
}
api := strings.TrimSpace(cfg.APIKey)
if api == "" {
api = clublogAppAPIKey
}
// putlogs.php reads the upload as an ADIF *file*, so it needs a header.
// Callers that already build a full document (the QSL Manager) pass one;
// callers that only have <EOR>-terminated records (the on-close flush) do
// not, and a headerless file is rejected. Same rule as the LoTW writer.
if !strings.Contains(strings.ToUpper(adifDoc), "<EOH>") {
adifDoc = "OpsLog Club Log upload\n<PROGRAMID:6>OpsLog <EOH>\n" + adifDoc
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("email", email)
_ = mw.WriteField("password", cfg.Password)
_ = mw.WriteField("callsign", call)
_ = mw.WriteField("api", api)
_ = mw.WriteField("clientident", "OpsLog")
fw, err := mw.CreateFormFile("file", "opslog.adi")
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build form: %w", err)
}
if _, err := io.WriteString(fw, adifDoc); err != nil {
return UploadResult{}, fmt.Errorf("clublog: write adif: %w", err)
}
if err := mw.Close(); err != nil {
return UploadResult{}, fmt.Errorf("clublog: finalise form: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, clublogBatchURL, &buf)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: 120 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
if resp.StatusCode == http.StatusOK {
return UploadResult{OK: true, Message: msg}, nil
}
diag := clublogFailureDiag(resp, msg)
return UploadResult{OK: false, Message: diag}, fmt.Errorf("clublog: batch upload failed: %s", diag)
}
// clublogFailureDiag turns a non-200 response into a readable one-liner that
// names WHO blocked the request — Club Log's app vs. an intermediary (Cloudflare,
// Sucuri, a corporate proxy, an antivirus TLS shim). A bare "403 Forbidden nginx"
// page means the request never reached the app; the fingerprint headers below
// tell the operator whether it's Club Log's own WAF or something on their side.
func clublogFailureDiag(resp *http.Response, body string) string {
server := strings.TrimSpace(resp.Header.Get("Server"))
// Notable intermediary fingerprints — presence points at the culprit.
fp := []string{}
for _, h := range []string{"CF-RAY", "CF-Mitigated", "X-Sucuri-ID", "X-Sucuri-Block", "X-Squid-Error", "Via", "X-Cache", "Retry-After"} {
if v := strings.TrimSpace(resp.Header.Get(h)); v != "" {
fp = append(fp, h+"="+v)
}
}
// Collapse the HTML error page to something short.
summary := stripHTMLBrief(body)
if summary == "" {
summary = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
out := fmt.Sprintf("HTTP %d — %s", resp.StatusCode, summary)
if server != "" {
out += " [server=" + server + "]"
}
if len(fp) > 0 {
out += " [" + strings.Join(fp, " ") + "]"
}
return out
}
// stripHTMLBrief removes tags and collapses whitespace, returning the first ~160
// chars of visible text — enough to read "403 Forbidden" without the markup.
func stripHTMLBrief(s string) string {
var b strings.Builder
depth := 0
for _, r := range s {
switch r {
case '<':
depth++
case '>':
if depth > 0 {
depth--
}
default:
if depth == 0 {
b.WriteRune(r)
}
}
}
out := strings.Join(strings.Fields(b.String()), " ")
if len(out) > 160 {
out = out[:160] + "…"
}
return out
}
// 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) {
email := strings.TrimSpace(cfg.Email)
call := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
switch {
case email == "":
return "", fmt.Errorf("clublog: account email not set")
case cfg.Password == "":
return "", fmt.Errorf("clublog: password not set")
case call == "":
return "", fmt.Errorf("clublog: logbook callsign not set")
}
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)
// No date filter. A "startyear=2099" was tried to keep the reply small, but
// Club Log answers an unrecognised request with the SAME 403 it uses for a
// refused login — so an unverified parameter would have made every correct
// password look wrong, which is the failure this whole change exists to end.
// The reply is capped at 4 KB and the body closed immediately instead; the
// status code arrives before any of it.
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:
// Club Log refuses with its ordinary web page, not an error string, so the
// body is 4 KB of markup that says nothing to an operator. It goes to the
// log — that is where a real diagnosis happens — and the message says the
// one thing there is to do about it.
LogSink("clublog: login refused (http %d), body: %s", resp.StatusCode, msg)
return "", fmt.Errorf("Club Log refused the login — check the account e-mail, " +
"the password and the logbook callsign. They are the Club Log website's own credentials")
default:
LogSink("clublog: unexpected http %d, body: %s", resp.StatusCode, msg)
if looksLikeHTML(msg) {
return "", fmt.Errorf("clublog: Club Log answered with a web page (http %d) instead of a log — see the log file", resp.StatusCode)
}
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.
func clublogPost(ctx context.Context, client *http.Client, endpoint string, form url.Values) (UploadResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: build request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", clublogUserAgent)
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
resp, err := client.Do(req)
if err != nil {
return UploadResult{}, fmt.Errorf("clublog: request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
msg := strings.TrimSpace(string(body))
switch {
case resp.StatusCode == http.StatusOK:
return UploadResult{OK: true, Message: msg}, nil
case isClublogDuplicate(resp.StatusCode, msg):
// Club Log rejects an exact duplicate; treat as already-logged.
return UploadResult{OK: true, Message: "already in logbook"}, nil
default:
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
return UploadResult{OK: false, Message: msg}, fmt.Errorf("clublog: upload failed: %s", msg)
}
}
// isClublogDuplicate recognises Club Log's "already have this QSO" rejection
// so repeated uploads stay idempotent.
func isClublogDuplicate(status int, msg string) bool {
m := strings.ToLower(msg)
return strings.Contains(m, "duplicate") || strings.Contains(m, "already")
}