chore: release v0.25.8
This commit is contained in:
+144
-23
@@ -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:") ||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user