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
+144 -23
View File
@@ -18,6 +18,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"hamlog/internal/applog"
@@ -355,6 +356,18 @@ func (s *session) run() {
// that gets skipped.
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.
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
// 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 != "" {
go func() {
select {
case <-s.stopCh:
return
case <-time.After(1500 * time.Millisecond):
if !loginSent {
_, _ = conn.Write([]byte(s.login + "\r\n"))
}
sendLogin()
}
}()
}
@@ -453,7 +487,21 @@ func (s *session) runOnce() (time.Time, error) {
var connectedAt time.Time
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
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)
for {
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:
// TCP keepalive probes an idle connection and its failure arrives here
// 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')
if err != nil {
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
// its first half and arrive as nonsense, or vanish entirely.
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
// 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)
}
continue
}
return connectedAt, fmt.Errorf("read: %w", err)
}
quietNoticed = false
quiet = 0
line := pending + chunk
pending = ""
@@ -519,28 +613,23 @@ func (s *session) runOnce() (time.Time, error) {
}
}
// Login on explicit prompt.
if !loginSent && s.login != "" && isLoginPrompt(line) {
_, _ = conn.Write([]byte(s.login + "\r\n"))
loginSent = true
// Login on explicit prompt — the case where the node DID terminate it with
// a newline. The newline-less form is handled on the timeout path above.
if !loginSent.Load() && s.login != "" && isLoginPrompt(line) {
s.emitLine(line, false)
sendLogin()
continue
}
// Password on prompt (rare).
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
// Password on prompt.
if !pwdSent.Load() && isPasswordPrompt(line) {
s.emitLine(line, false)
sendPassword()
continue
}
// Mark connected once we've sent login OR seen a welcome banner.
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
connectedAt = time.Now()
s.mu.Lock()
s.status.State = StateConnected
s.status.ConnectedAt = connectedAt
s.status.Error = ""
s.mu.Unlock()
s.emitStatus()
fireInitCommands()
if loginSent.Load() || isWelcome(line) {
markConnected()
}
// 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
}
// 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 {
low := strings.ToLower(s)
return strings.Contains(low, "login:") ||