chore: release v0.25.8

This commit is contained in:
2026-08-17 13:18:32 +02:00
parent 0bab7f05b9
commit 7be6f64596
10 changed files with 661 additions and 83 deletions
+112 -25
View File
@@ -10376,6 +10376,15 @@ func (a *App) TestCloudlogUpload() (string, error) {
// ── QSL Manager (manual upload) ──────────────────────────────────────── // ── QSL Manager (manual upload) ────────────────────────────────────────
// uploadColumnFor maps a service id to its QSO sent-status column. // uploadColumnFor maps a service id to its QSO sent-status column.
// manualUploadPace is the shortest gap between two consecutive single-QSO
// uploads in a bulk "Send to …" run, for the services with no batch endpoint
// (QRZ.com, HRDLog). Selecting twenty-five thousand QSOs in the QSL Manager and
// firing them off as fast as the link allows is exactly the traffic a logbook
// service reads as a robot rather than an operator — Club Log threatens to block
// an IP for it. The gap is free in practice: a round trip to either already
// takes longer than it.
const manualUploadPace = 200 * time.Millisecond
func uploadColumnFor(service string) string { func uploadColumnFor(service string) string {
switch extsvc.Service(service) { switch extsvc.Service(service) {
case extsvc.ServiceQRZ: case extsvc.ServiceQRZ:
@@ -10470,10 +10479,13 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
} }
} }
} }
} else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog { } else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog || svc == extsvc.ServiceEQSL {
statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date" statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date"
if svc == extsvc.ServiceHRDLog { switch svc {
case extsvc.ServiceHRDLog:
statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date" statusCol, dateCol = "hrdlog_qso_upload_status", "hrdlog_qso_upload_date"
case extsvc.ServiceEQSL:
statusCol, dateCol = "eqsl_sent", "eqsl_sent_date"
} }
type item struct { type item struct {
id int64 id int64
@@ -10537,10 +10549,62 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", ")) applog.Printf("extsvc: Club Log batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
} }
} }
} else if svc == extsvc.ServiceEQSL {
// eQSL's ImportADIF.cfm is a file importer — it answers "X out of Y
// records added" — so send a whole chunk per request instead of one
// request per contact. eQSL asks that an upload stay under about a
// thousand records; 100 keeps a single refused record from taking the
// rest of the chunk with it.
const chunk = 100
emit(fmt.Sprintf("eQSL: uploading %d QSO(s) in batches of %d…", len(items), chunk))
for start := 0; start < len(items); start += chunk {
end := start + chunk
if end > len(items) {
end = len(items)
}
batch := items[start:end]
recs := make([]string, len(batch))
batchIDs := make([]int64, len(batch))
for i, it := range batch {
recs[i] = it.rec
batchIDs[i] = it.id
}
res, err := extsvc.UploadEQSLBatch(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, recs)
if err == nil && res.OK {
if merr := a.qso.MarkUploadedBatch(ctx, statusCol, dateCol, date, batchIDs); merr != nil {
applog.Printf("extsvc: eQSL batch mark: %v", merr)
}
uploaded += len(batch)
// eQSL took the file but left records out. It never says WHICH
// — normally they are QSOs it already holds — so quote its own
// count rather than claim a clean run.
if res.Ignored {
emit(fmt.Sprintf("eQSL: %d/%d uploaded — %s", end, len(items), res.Message))
} else {
emit(fmt.Sprintf("eQSL: %d/%d uploaded", end, len(items)))
}
} else {
msg := res.Message
if err != nil {
msg = err.Error()
}
// Name the QSOs in the failing batch, same as Club Log: a
// per-record rejection is otherwise impossible to locate.
who := make([]string, 0, len(batch))
for _, it := range batch {
who = append(who, fmt.Sprintf("%s#%d", it.call, it.id))
}
emit(fmt.Sprintf("eQSL: batch of %d FAILED: %s", len(batch), msg))
applog.Printf("extsvc: eQSL batch FAILED (%s) — QSOs: %s", msg, strings.Join(who, ", "))
}
}
} else { } else {
// HRDLog's NewEntry.aspx inserts only the FIRST record of a multi- // HRDLog's NewEntry.aspx inserts only the FIRST record of a multi-
// record ADIF, so upload ONE record per request. The DB stays cheap: // record ADIF, so upload ONE record per request. The DB stays cheap:
// bulk fetch above + the marks flushed in batches (not one per QSO). // bulk fetch above + the marks flushed in batches (not one per QSO).
// Paced: HRDLog is the one service here with no way to batch, and a
// few thousand requests as fast as the link allows is what a logbook
// reads as a robot.
emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items))) emit(fmt.Sprintf("HRDLog: uploading %d QSO(s) (one request each)…", len(items)))
var doneIDs []int64 var doneIDs []int64
flush := func() { flush := func() {
@@ -10553,6 +10617,9 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
doneIDs = doneIDs[:0] doneIDs = doneIDs[:0]
} }
for i, it := range items { for i, it := range items {
if i > 0 {
time.Sleep(manualUploadPace)
}
res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec) res, err := extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, it.rec)
if err == nil && res.OK { if err == nil && res.OK {
doneIDs = append(doneIDs, it.id) doneIDs = append(doneIDs, it.id)
@@ -10574,34 +10641,26 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
flush() flush()
} }
} else { } else {
// QRZ.com: one record per request (its logbook API has no batch upload). // QRZ.com: one record per request (its logbook API has no batch upload),
for _, id := range ids { // paced for the same reason as HRDLog above.
for i, id := range ids {
if i > 0 {
time.Sleep(manualUploadPace)
}
q, gerr := a.qso.GetByID(ctx, id) q, gerr := a.qso.GetByID(ctx, id)
call := "" call := ""
if gerr == nil { if gerr == nil {
call = q.Callsign call = q.Callsign
} }
force := "" // QRZ rewrites STATION_CALLSIGN to the registered call.
if svc == extsvc.ServiceQRZ { rec, ok := a.buildUploadADIF(id, cfg.QRZ.ForceStationCallsign)
force = cfg.QRZ.ForceStationCallsign
}
rec, ok := a.buildUploadADIF(id, force)
if !ok { if !ok {
emit(call + " — skipped (no record)") emit(call + " — skipped (no record)")
continue continue
} }
var res extsvc.UploadResult // Only QRZ reaches this branch: LoTW, Club Log, HRDLog and eQSL are
var err error // all handled above, and UploadQSOsManual rejects anything else.
switch svc { res, err := extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceQRZ:
res, err = extsvc.UploadQRZ(ctx, nil, cfg.QRZ.APIKey, rec)
case extsvc.ServiceHRDLog:
res, err = extsvc.UploadHRDLog(ctx, nil, cfg.HRDLog.Callsign, cfg.HRDLog.Code, rec)
case extsvc.ServiceEQSL:
res, err = extsvc.UploadEQSL(ctx, nil, cfg.EQSL.Username, cfg.EQSL.Password, cfg.EQSL.QTHNickname, rec)
default:
res, err = extsvc.UploadClublog(ctx, nil, cfg.Clublog, rec)
}
if err == nil && res.OK { if err == nil && res.OK {
a.markExtUploaded(svc, id, "") a.markExtUploaded(svc, id, "")
uploaded++ uploaded++
@@ -17713,16 +17772,26 @@ func (a *App) SendClusterCommand(cmd string) error {
if cmd == "" { if cmd == "" {
return fmt.Errorf("empty command") return fmt.Errorf("empty command")
} }
servers, err := a.listClusterServers() srv, err := a.masterClusterServer()
if err != nil { if err != nil {
return err return err
} }
return a.cluster.SendCommand(srv.ID, cmd)
}
// masterClusterServer returns the master node — the first ENABLED server in
// sort order, which is where commands and spots go.
func (a *App) masterClusterServer() (cluster.ServerConfig, error) {
servers, err := a.listClusterServers()
if err != nil {
return cluster.ServerConfig{}, err
}
for _, s := range servers { for _, s := range servers {
if s.Enabled { if s.Enabled {
return a.cluster.SendCommand(s.ID, cmd) return s, nil
} }
} }
return fmt.Errorf("no enabled cluster server to send to") return cluster.ServerConfig{}, fmt.Errorf("no enabled cluster server to send to")
} }
// SendClusterSpot announces a DX spot on the **master** cluster (first // SendClusterSpot announces a DX spot on the **master** cluster (first
@@ -17744,8 +17813,26 @@ func (a *App) SendClusterSpot(call string, freqKHz float64, comment string) erro
if c := strings.TrimSpace(comment); c != "" { if c := strings.TrimSpace(comment); c != "" {
cmd += " " + c cmd += " " + c
} }
if a.cluster == nil {
return fmt.Errorf("cluster not initialized")
}
srv, err := a.masterClusterServer()
if err != nil {
return err
}
applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd) applog.Printf("cluster: send spot — freqKHz=%v → command %q", freqKHz, cmd)
return a.SendClusterCommand(cmd) if err := a.cluster.SendCommand(srv.ID, cmd); err != nil {
return err
}
// Show it in OUR OWN spot list straight away. Most nodes never broadcast a
// spot back to the station that sent it, so the operator saw nothing appear
// and reported the spot as not sent — several times. It goes through the same
// queue as a spot off the wire, so it gets the same DXCC/POTA enrichment,
// alert evaluation and panadapter mirroring, and the UI de-dupes it against
// the node's echo when there is one.
sp := cluster.NewLocalSpot(srv, a.resolveClusterLogin(srv.LoginOverride), call, freqKHz, comment)
a.enqueueClusterEvent(clusterEvent{spot: &sp})
return nil
} }
// GetClusterStatus returns a snapshot of every active session. Used by // GetClusterStatus returns a snapshot of every active session. Used by
+12 -2
View File
@@ -11,7 +11,12 @@
"Award references can be renumbered in the editor — the number was the one field it would not let you correct.", "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.", "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." "Generic HTTP relay: an https:// board can be accepted with its own self-signed certificate, per board.",
"Club Log: the on-close upload now goes out as one batch — sending hundreds of contacts one at a time got operators blocked.",
"eQSL: uploads go out in batches of 100 too, and QRZ.com and HRDLog — which have no batch upload — are spaced out instead.",
"A spot you send now shows in your own spot list — most nodes never echo it back, so it looked like nothing had gone out.",
"Icom with JTDX in Fake It split: a lost PTT acknowledgement is sent again instead of failing, which made JTDX drop the rig.",
"A cluster whose only greeting is “login:” and which then asks for a password now connects — both prompts were being missed."
], ],
"fr": [ "fr": [
"Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.", "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.",
@@ -22,7 +27,12 @@
"Les références dun diplôme se renumérotent dans l’éditeur : le numéro était le seul champ quil refusait de corriger.", "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.", "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." "Relais HTTP générique : une carte en https:// peut être acceptée avec son certificat auto-signé, carte par carte.",
"Club Log : lenvoi à la fermeture part désormais en un lot — envoyer des centaines de contacts un par un faisait bloquer lopérateur.",
"eQSL : les envois partent aussi par lots de 100, et QRZ.com et HRDLog — qui nont pas denvoi groupé — sont espacés à la place.",
"Un spot que tu envoies apparaît maintenant dans ta liste : la plupart des nœuds ne le renvoient pas, il semblait n’être jamais parti.",
"Icom avec JTDX en split Fake It : un accusé de réception PTT perdu est renvoyé au lieu d’échouer — JTDX lâchait le poste.",
"Un cluster dont tout laccueil est « login: » puis qui réclame un mot de passe se connecte : les deux invites étaient ignorées."
] ]
}, },
{ {
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.25.7'; export const APP_VERSION = '0.25.8';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+27 -1
View File
@@ -1,6 +1,7 @@
package cat package cat
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
@@ -535,11 +536,36 @@ func (b *IcomSerial) SetMode(mode string) error {
return nil return nil
} }
// errIcomAckLost is "the reply never came" — as opposed to a reply that said no
// (NG) or a dead port. Only this one is worth repeating: the command may well
// have been carried out and only its acknowledgement lost. Sentinel rather than
// a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response")
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when the
// acknowledgement is lost.
//
// A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial immediately before every key-down, so the PTT ack queues behind that
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that
// as losing rig control and tore the connection down mid-over, reopening it a
// moment later (an operator's log shows exactly that, twice, a new rigctld client
// within 300 ms of each failure). The same session over TCI never failed, because
// TCI carries no CI-V and needs no Fake It.
//
// Re-sending is safe: asking for a state the rig is already in changes nothing.
func (b *IcomSerial) SetPTT(on bool) error { func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0) state := byte(0)
if on { if on {
state = 1 state = 1
} }
err := b.exec(civ.CmdPTT, civ.SubPTT, state)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
return b.exec(civ.CmdPTT, civ.SubPTT, state) return b.exec(civ.CmdPTT, civ.SubPTT, state)
} }
@@ -619,7 +645,7 @@ func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (
case <-cancel: case <-cancel:
return civ.Decoded{}, fmt.Errorf("icom: interrupted") return civ.Decoded{}, fmt.Errorf("icom: interrupted")
case <-deadline: case <-deadline:
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response") return civ.Decoded{}, errIcomAckLost
} }
} }
} }
+144 -23
View File
@@ -18,6 +18,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"hamlog/internal/applog" "hamlog/internal/applog"
@@ -355,6 +356,18 @@ func (s *session) run() {
// that gets skipped. // that gets skipped.
var idleTick = 30 * time.Second var idleTick = 30 * time.Second
// promptTick is the read deadline used until the login handshake is finished.
// Short, because a node's "login:" / "password:" carries no newline and is only
// visible when the read times out — see the read loop. It costs a few wake-ups
// during the first seconds of a connection and nothing afterwards.
const promptTick = 700 * time.Millisecond
// handshakeWindow bounds how long the fast promptTick applies. A node that has
// a password configured but never asks for one would otherwise keep the loop
// waking every 700 ms for the life of the connection, for a prompt that is never
// coming. Any real login exchange is over in a second or two.
const handshakeWindow = 20 * time.Second
// quietNotice is the silence after which the log says so, once. // quietNotice is the silence after which the log says so, once.
const quietNotice = 10 * time.Minute const quietNotice = 10 * time.Minute
@@ -398,16 +411,37 @@ func (s *session) runOnce() (time.Time, error) {
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider // Login: send on first prompt OR blindly after 1.5s. Many DXSpider
// nodes accept the callsign without re-prompting. // nodes accept the callsign without re-prompting.
loginSent := false //
// Atomic because the blind-login timer below and the read loop both touch
// these. loginSent used to be a plain bool that the timer NEVER SET: the
// callsign went out and nothing recorded it, so the password branch — gated on
// loginSent — was dead code, and the session could only reach "connected" by
// recognising a welcome banner. On a node whose entire greeting is a bare
// "login:" and which then demands a password (f5mzn.org:9000), that left the
// server stuck at "connecting" for ever while telnet logged in by hand fine.
var loginSent, pwdSent atomic.Bool
// CompareAndSwap, not a plain store: the timer and the loop can reach these at
// the same moment, and the callsign must be written exactly once.
sendLogin := func() {
if s.login != "" && loginSent.CompareAndSwap(false, true) {
_, _ = conn.Write([]byte(s.login + "\r\n"))
}
}
// Sent ONCE per connection. A node that re-prompts is refusing the password,
// and answering with the same one again only loops — better to let the
// refusal show in the console than to hide it behind a retry.
sendPassword := func() {
if s.cfg.Password != "" && loginSent.Load() && pwdSent.CompareAndSwap(false, true) {
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
}
}
if s.login != "" { if s.login != "" {
go func() { go func() {
select { select {
case <-s.stopCh: case <-s.stopCh:
return return
case <-time.After(1500 * time.Millisecond): case <-time.After(1500 * time.Millisecond):
if !loginSent { sendLogin()
_, _ = conn.Write([]byte(s.login + "\r\n"))
}
} }
}() }()
} }
@@ -453,7 +487,21 @@ func (s *session) runOnce() (time.Time, error) {
var connectedAt time.Time var connectedAt time.Time
var quiet time.Duration // how long the node has said nothing var quiet time.Duration // how long the node has said nothing
var quietNoticed bool // the long-silence line is said once
var pending string // a line cut in half by a read deadline var pending string // a line cut in half by a read deadline
markConnected := func() {
if s.snapshot().State == StateConnected {
return
}
connectedAt = time.Now()
s.mu.Lock()
s.status.State = StateConnected
s.status.ConnectedAt = connectedAt
s.status.Error = ""
s.mu.Unlock()
s.emitStatus()
fireInitCommands()
}
rd := bufio.NewReader(conn) rd := bufio.NewReader(conn)
for { for {
select { select {
@@ -471,7 +519,21 @@ func (s *session) runOnce() (time.Time, error) {
// Only a REAL error ends the session. A dead peer still gets caught: // Only a REAL error ends the session. A dead peer still gets caught:
// TCP keepalive probes an idle connection and its failure arrives here // TCP keepalive probes an idle connection and its failure arrives here
// as an error, not as a timeout. // as an error, not as a timeout.
_ = conn.SetReadDeadline(time.Now().Add(idleTick)) // SHORT deadline until the handshake is done, the long idle tick after.
//
// A cluster writes its prompts WITHOUT a trailing newline, and ReadString
// only returns on one — so a prompt is never a "line" at all, it is whatever
// is sitting in the buffer when the read deadline expires. At the ordinary
// 30 s tick that made a bare "login:" invisible for half a minute and a
// following "password:" invisible for another, which is long enough for the
// node to give up on us. Only the handshake needs the fast tick; once logged
// in, a long deadline is exactly what we want (see idleTick).
tick := idleTick
if time.Since(linkUpAt) < handshakeWindow &&
(!loginSent.Load() || (s.cfg.Password != "" && !pwdSent.Load())) {
tick = promptTick
}
_ = conn.SetReadDeadline(time.Now().Add(tick))
chunk, err := rd.ReadString('\n') chunk, err := rd.ReadString('\n')
if err != nil { if err != nil {
var ne net.Error var ne net.Error
@@ -480,16 +542,48 @@ func (s *session) runOnce() (time.Time, error) {
// Keep it: a spot line straddling the deadline would otherwise lose // Keep it: a spot line straddling the deadline would otherwise lose
// its first half and arrive as nonsense, or vanish entirely. // its first half and arrive as nonsense, or vanish entirely.
pending += chunk pending += chunk
quiet += idleTick // …but a newline-less PROMPT is not half a line, it is a question,
// and this is the only place it can ever be seen. Answer it, show it
// in the console (an operator watching a stuck server has a right to
// see what the node actually asked), and drop it so it is not glued
// onto the front of the next real line.
if p := strings.TrimSpace(pending); p != "" {
switch {
case !loginSent.Load() && s.login != "" && isLoginPrompt(p):
s.emitLine(p, false)
pending = ""
sendLogin()
if s.cfg.Password == "" {
markConnected()
}
continue
case !pwdSent.Load() && isPasswordPrompt(p):
s.emitLine(p, false)
pending = ""
if s.cfg.Password == "" {
// Nothing to answer with. Shown in the console rather than
// swallowed: an unanswered "password:" sitting there IS the
// explanation for a server that never finishes connecting,
// and it is something the operator can act on.
continue
}
sendPassword()
markConnected()
continue
}
}
quiet += tick
// Said once at the first long silence, so a genuinely mute node is // Said once at the first long silence, so a genuinely mute node is
// visible without a line every tick. // visible without a line every tick.
if quiet == quietNotice { if !quietNoticed && quiet >= quietNotice {
quietNoticed = true
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet) applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
} }
continue continue
} }
return connectedAt, fmt.Errorf("read: %w", err) return connectedAt, fmt.Errorf("read: %w", err)
} }
quietNoticed = false
quiet = 0 quiet = 0
line := pending + chunk line := pending + chunk
pending = "" pending = ""
@@ -519,28 +613,23 @@ func (s *session) runOnce() (time.Time, error) {
} }
} }
// Login on explicit prompt. // Login on explicit prompt — the case where the node DID terminate it with
if !loginSent && s.login != "" && isLoginPrompt(line) { // a newline. The newline-less form is handled on the timeout path above.
_, _ = conn.Write([]byte(s.login + "\r\n")) if !loginSent.Load() && s.login != "" && isLoginPrompt(line) {
loginSent = true s.emitLine(line, false)
sendLogin()
continue continue
} }
// Password on prompt (rare). // Password on prompt.
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) { if !pwdSent.Load() && isPasswordPrompt(line) {
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n")) s.emitLine(line, false)
sendPassword()
continue continue
} }
// Mark connected once we've sent login OR seen a welcome banner. // Mark connected once we've sent login OR seen a welcome banner.
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) { if loginSent.Load() || isWelcome(line) {
connectedAt = time.Now() markConnected()
s.mu.Lock()
s.status.State = StateConnected
s.status.ConnectedAt = connectedAt
s.status.Error = ""
s.mu.Unlock()
s.emitStatus()
fireInitCommands()
} }
// EVERY line goes to the console — spot or not. This is the whole point: // EVERY line goes to the console — spot or not. This is the whole point:
@@ -701,6 +790,38 @@ func parseSpot(line string) (Spot, bool) {
}, true }, true
} }
// NewLocalSpot builds the Spot for a DX announcement WE just sent, so it lands
// in the operator's own list at once.
//
// A node does not necessarily broadcast a spot back to the station that sent it:
// DXSpider suppresses the echo to the originator, and a node-side filter can eat
// it too. So an operator spotted a station, watched their own spot list stay
// empty, and concluded the spot had never gone out — when it had. This is the
// spot the echo would have carried, built from what we sent, deliberately the
// same shape so the UI's call+band de-dupe folds the two into one row on the
// nodes that DO echo.
func NewLocalSpot(srv ServerConfig, spotter, dxCall string, freqKHz float64, comment string) Spot {
freqHz := int64(freqKHz*1000 + 0.5)
now := time.Now()
sp := Spot{
SourceID: srv.ID,
SourceName: srv.Name,
Spotter: strings.ToUpper(strings.TrimSpace(spotter)),
DXCall: strings.ToUpper(strings.TrimSpace(dxCall)),
FreqKHz: freqKHz,
FreqHz: freqHz,
Band: bandFromHz(freqHz),
Comment: strings.TrimSpace(comment),
TimeUTC: now.UTC().Format("1504") + "Z",
ReceivedAt: now,
}
// Raw reads like the node's own broadcast — it is what anything showing the
// source line expects, and it keeps a local spot legible in the log.
sp.Raw = fmt.Sprintf("DX de %s: %9.1f %-12s %-30s %s",
sp.Spotter, sp.FreqKHz, sp.DXCall, sp.Comment, sp.TimeUTC)
return sp
}
func isLoginPrompt(s string) bool { func isLoginPrompt(s string) bool {
low := strings.ToLower(s) low := strings.ToLower(s)
return strings.Contains(low, "login:") || return strings.Contains(low, "login:") ||
+105
View File
@@ -0,0 +1,105 @@
package cluster
import (
"bufio"
"net"
"strconv"
"strings"
"testing"
"time"
)
// A node whose whole greeting is a bare "login:" — no newline, no banner — and
// which then demands a password must still log in.
//
// Reported on f5mzn.org:9000: telnet by hand worked, OpsLog sat at "connecting"
// for ever. Two faults met there. The prompts carry no newline, and the read
// loop only ever looked at complete LINES, so neither prompt was seen at all;
// and the blind 1.5 s login never recorded that it had sent the callsign, which
// left the password branch — gated on that flag — permanently switched off.
//
// The test speaks the node's side literally: "login:" with no newline, then
// "password:" with no newline, then a spot. It asserts both answers arrive and
// that the session reaches Connected without any welcome banner to lean on.
func TestBareLoginAndPasswordPromptsWithoutNewlines(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
host, portStr, _ := net.SplitHostPort(ln.Addr().String())
port, err := strconv.Atoi(portStr)
if err != nil {
t.Fatal(err)
}
type answers struct{ login, pwd string }
got := make(chan answers, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
rd := bufio.NewReader(c)
// No newline, exactly as the node sends it.
if _, err := c.Write([]byte("login: ")); err != nil {
return
}
call, err := rd.ReadString('\n')
if err != nil {
return
}
if _, err := c.Write([]byte("password: ")); err != nil {
return
}
pwd, err := rd.ReadString('\n')
if err != nil {
return
}
got <- answers{strings.TrimSpace(call), strings.TrimSpace(pwd)}
// Something to prove the link is live and parsing again afterwards.
_, _ = c.Write([]byte("DX de F4BPO: 14074.0 OY1CT FT8 1234Z\r\n"))
time.Sleep(time.Second)
}()
spots := make(chan Spot, 4)
s := &session{
cfg: ServerConfig{Name: "pwd node", Host: host, Port: port, Password: "s3cret"},
login: "F4BPO",
onSpot: func(sp Spot) { spots <- sp },
onLine: func(Line) {},
onStatus: func() {},
stopCh: make(chan struct{}),
}
done := make(chan error, 1)
go func() { _, err := s.runOnce(); done <- err }()
defer func() { close(s.stopCh); <-done }()
select {
case a := <-got:
if a.login != "F4BPO" {
t.Errorf("callsign sent = %q, want F4BPO", a.login)
}
if a.pwd != "s3cret" {
t.Errorf("password sent = %q, want s3cret — the prompt carried no newline", a.pwd)
}
case <-time.After(10 * time.Second):
t.Fatal("the node's newline-less prompts were never answered")
}
select {
case sp := <-spots:
if sp.DXCall != "OY1CT" {
t.Errorf("spot from the wrong station: %+v", sp)
}
case <-time.After(5 * time.Second):
t.Fatal("no spot after the login — the stream is not being parsed")
}
// Connected without a welcome banner: the handshake alone must be enough.
if st := s.snapshot().State; st != StateConnected {
t.Errorf("state = %q, want %q — the server would still show as connecting", st, StateConnected)
}
}
+7
View File
@@ -117,6 +117,13 @@ func UploadClublogADIF(ctx context.Context, client *http.Client, cfg ServiceConf
if api == "" { if api == "" {
api = clublogAppAPIKey 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 var buf bytes.Buffer
mw := multipart.NewWriter(&buf) mw := multipart.NewWriter(&buf)
+72
View File
@@ -267,6 +267,78 @@ func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, a
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason) return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
} }
// eqslBatchMax is the largest number of records eQSL asks a single upload to
// carry ("upload only files smaller than about 1000 records at a time", eQSL's
// own ImportADIF interface notes). Callers chunk to this.
const eqslBatchMax = 1000
// UploadEQSLBatch pushes MANY ADIF records to eQSL.cc in ONE request.
//
// ImportADIF.cfm is a file importer, not a per-QSO endpoint: it takes one *or
// more* QSOs and answers "Result: X out of Y records added" — the plural in its
// own reply. So an on-close sweep or a bulk upload is one request, not one per
// contact. No ADIF header is prepended: the single-record path has always posted
// bare <EOR> records and eQSL accepts them (per ADIF, a file starting with '<'
// has no header), and there is no reason to change what is known to work.
//
// A PARTIAL result ("97 out of 100") sets Ignored so the caller can say so.
// eQSL does not identify which records it left out, and in practice they are
// QSOs it already holds — the same duplicate that UploadEQSL reports as success.
func UploadEQSLBatch(ctx context.Context, client *http.Client, user, pswd, qthNick string, records []string) (UploadResult, error) {
user = strings.ToUpper(strings.TrimSpace(user))
if user == "" {
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
}
if strings.TrimSpace(pswd) == "" {
return UploadResult{}, fmt.Errorf("eqsl: password not set")
}
docs := make([]string, 0, len(records))
for _, r := range records {
if strings.TrimSpace(r) == "" {
continue
}
docs = append(docs, eqslRecordWithNickname(strings.TrimRight(r, "\r\n"), qthNick))
}
if len(docs) == 0 {
return UploadResult{}, fmt.Errorf("eqsl: empty adif batch")
}
if len(docs) > eqslBatchMax {
return UploadResult{}, fmt.Errorf("eqsl: batch of %d exceeds the %d-record limit", len(docs), eqslBatchMax)
}
body, err := eqslPost(ctx, client, user, pswd, strings.Join(docs, "\n"))
if err != nil {
return UploadResult{OK: false, Message: body}, err
}
if reason := authErrEQSL(body); reason != "" {
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
}
// The counted result is read FIRST here, unlike the single-record path: a
// batch reply routinely carries both "Result: 97 out of 100 records added"
// and a "Bad record: Duplicate" line for the other three, and matching the
// duplicate first would throw away the count that says the rest went in.
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
added, _ := strconv.Atoi(m[1])
total, _ := strconv.Atoi(m[2])
if added >= 1 {
return UploadResult{OK: true, Message: strings.TrimSpace(m[0]), Ignored: added < total}, nil
}
// "0 out of N" — nothing added. A re-upload of QSOs eQSL already holds
// lands here, and that is not a failure.
if strings.Contains(strings.ToLower(body), "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
}
if strings.Contains(strings.ToLower(body), "duplicate") {
return UploadResult{OK: true, Message: "already in logbook"}, nil
}
reason := eqslReason(body)
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: batch upload failed: %s", reason)
}
// eqslReason trims an eQSL reply to a short human-readable reason: the first // eqslReason trims an eQSL reply to a short human-readable reason: the first
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body // "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
// (capped), else a generic phrase. // (capped), else a generic phrase.
+180 -30
View File
@@ -295,9 +295,10 @@ func (m *Manager) CloseUploadCount() int {
} }
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole // FlushOnClose uploads every QSO due for an on-close push, scanning the whole
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club // logbook (not just this session). Called from the shutdown sequence. QRZ and
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL // the rest go one-by-one (fast HTTP, no batch API); LoTW is signed and uploaded
// batch. Returns the number of QSOs uploaded successfully. // as a single TQSL batch, and Club Log goes through its batch endpoint.
// Returns the number of QSOs uploaded successfully.
func (m *Manager) FlushOnClose() int { func (m *Manager) FlushOnClose() int {
if m.deps.CloseUploadIDs == nil { if m.deps.CloseUploadIDs == nil {
return 0 return 0
@@ -312,41 +313,190 @@ func (m *Manager) FlushOnClose() int {
switch svc { switch svc {
case ServiceLoTW: case ServiceLoTW:
uploaded += m.flushLoTWBatch(ids, cfg.LoTW) uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
case ServiceQRZ:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.QRZ); ok {
uploaded++
}
}
case ServiceClublog: case ServiceClublog:
for _, id := range ids { uploaded += m.flushClublogBatch(ids, cfg.Clublog)
if ok, _ := m.upload(svc, id, cfg.Clublog); ok {
uploaded++
}
}
case ServiceHRDLog:
for _, id := range ids {
if ok, _ := m.upload(svc, id, cfg.HRDLog); ok {
uploaded++
}
}
case ServiceEQSL: case ServiceEQSL:
for _, id := range ids { uploaded += m.flushEQSLBatch(ids, cfg.EQSL)
if ok, _ := m.upload(svc, id, cfg.EQSL); ok { case ServiceQRZ:
uploaded++ uploaded += m.flushOneByOne(svc, ids, cfg.QRZ)
} case ServiceHRDLog:
} uploaded += m.flushOneByOne(svc, ids, cfg.HRDLog)
case ServiceCloudlog: case ServiceCloudlog:
for _, id := range ids { uploaded += m.flushOneByOne(svc, ids, cfg.Cloudlog)
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
uploaded++
}
}
} }
} }
return uploaded return uploaded
} }
// uploadPace is the shortest gap between two consecutive single-QSO uploads in
// an on-close sweep. QRZ, HRDLog and Cloudlog have no batch endpoint — HRDLog's
// NewEntry.aspx keeps only the first record of a multi-record ADIF — so a sweep
// of a freshly imported log is unavoidably one request per contact. It does not
// have to arrive as fast as the link allows, though: that burst is what a
// service reads as a robot, and what got an operator's IP threatened at Club Log
// (see flushClublogBatch). The gap costs nothing in practice, since a round trip
// to any of these already takes longer than it.
const uploadPace = 200 * time.Millisecond
// flushOneByOne uploads ids one request at a time, paced. For the services that
// have no batch API; everything else has its own flush<Service>Batch.
func (m *Manager) flushOneByOne(svc Service, ids []int64, cfg ServiceConfig) int {
uploaded := 0
for i, id := range ids {
if i > 0 {
time.Sleep(uploadPace)
}
if ok, _ := m.upload(svc, id, cfg); ok {
uploaded++
}
}
return uploaded
}
// eqslBatchChunk is how many QSOs go into one ImportADIF.cfm request. eQSL's own
// limit is ten times this (eqslBatchMax); the smaller chunk keeps one refused
// record from taking a thousand others down with it, and keeps the form body
// small enough to be unremarkable.
const eqslBatchChunk = 100
// flushEQSLBatch uploads the on-close eQSL QSOs through ImportADIF.cfm in
// batches instead of one request per contact. Same reasoning as
// flushClublogBatch — eQSL's import endpoint has always taken a whole file, so
// the one-at-a-time loop was making hundreds of requests it never needed to.
func (m *Manager) flushEQSLBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client: UploadEQSLBatch then builds one with a 30 s timeout rather
// than reusing the 20 s budget of a single realtime QSO.
res, err := UploadEQSLBatch(context.Background(), nil, cfg.Username, cfg.Password, cfg.QTHNickname, records)
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: eqsl batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceEQSL, 0, err)
}
} else {
// res.Ignored means eQSL took the file but left records out. Say the
// count out loud: the whole chunk is still marked sent (eQSL never
// says WHICH it dropped, and in practice they are QSOs it already
// had), so the log line is the only trace of the shortfall.
if res.Ignored {
m.logf("extsvc: eqsl batch upload PARTIAL (%d QSOs sent) %s", len(kept), res.Message)
} else {
m.logf("extsvc: eqsl batch upload OK (%d QSOs) %s", len(kept), res.Message)
}
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceEQSL, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceEQSL, id) {
continue
}
// eQSL keeps the QSO's own station call; the account is identified by the
// credentials and the optional QTH nickname — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= eqslBatchChunk {
send()
}
}
send()
return uploaded
}
// clublogBatchChunk is how many QSOs go into one putlogs.php request. Club Log
// dedupes server-side, so chunking is not about correctness — it keeps a single
// malformed record from failing a whole ten-thousand-QSO document, and matches
// what the QSL Manager's bulk upload already uses.
const clublogBatchChunk = 100
// flushClublogBatch uploads the on-close Club Log QSOs through the BATCH
// endpoint (putlogs.php) rather than one realtime.php call each.
//
// It used to walk the ids and call UploadClublog per QSO. On-close upload sweeps
// the WHOLE logbook, so importing an ADIF — or simply switching Club Log on over
// an existing log — turned one app close into hundreds of realtime.php posts.
// That endpoint is reserved for an operator logging contacts as they work them,
// and Club Log blocks the IP of anything that batches through it: an OpsLog user
// was flagged by G7VJR for 185 QSOs in four minutes, which is this loop, not a
// pile-up. Batch upload is the mechanism Club Log provides for exactly this.
func (m *Manager) flushClublogBatch(ids []int64, cfg ServiceConfig) int {
uploaded := 0
var records []string
var kept []int64
send := func() {
if len(records) == 0 {
return
}
// nil client on purpose: UploadClublogADIF then builds one with a 120 s
// timeout. m.deps.Client is the 20 s budget of a single realtime QSO,
// which a hundred-QSO document on a slow link would blow through.
res, err := UploadClublogADIF(context.Background(), nil, cfg, strings.Join(records, "\n"))
if err != nil || !res.OK {
if err == nil {
err = errFromResult(res)
}
m.logf("extsvc: clublog batch upload (%d QSOs) failed: %v", len(kept), err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(ServiceClublog, 0, err)
}
} else {
m.logf("extsvc: clublog batch upload OK (%d QSOs) %s", len(kept), res.Message)
if m.deps.MarkUploaded != nil {
for _, id := range kept {
m.deps.MarkUploaded(ServiceClublog, id, res.LogID)
}
}
uploaded += len(kept)
}
records = records[:0]
kept = kept[:0]
}
for _, id := range ids {
// Skip QSOs not eligible (already sent). The wrong-logbook guard that
// upload() applies per QSO is not repeated here: closeUploadIDs has
// already filtered the sweep down to this logbook's callsign.
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceClublog, id) {
continue
}
// Club Log takes the logbook callsign as its own form field, so the ADIF
// keeps the QSO's own station call (no override) — as in upload().
rec, ok := m.deps.BuildADIF(id, "")
if !ok {
continue
}
records = append(records, rec)
kept = append(kept, id)
if len(records) >= clublogBatchChunk {
send()
}
}
send()
return uploaded
}
// flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then // flushLoTWBatch signs+uploads all queued LoTW QSOs in one TQSL run, then
// stamps each as uploaded on success. // stamps each as uploaded on success.
func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int { func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.25.7" appVersion = "0.25.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.