This commit is contained in:
iceBear67
2026-07-26 20:44:49 +08:00
parent ae79082481
commit 7eb35f82b1
29 changed files with 2000 additions and 1463 deletions
-67
View File
@@ -3,7 +3,6 @@ package core
import (
"context"
"log/slog"
"net/netip"
"sync"
"testing"
"time"
@@ -152,72 +151,6 @@ func TestLogBufferRedactsOnExport(t *testing.T) {
}
}
func TestLanScannerConcurrentAccess(t *testing.T) {
s := NewLanScanner(slog.New(slog.DiscardHandler))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
s.Start(ctx)
var wg sync.WaitGroup
// Feed announcements the way the read loops do.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ctx.Err() == nil; i++ {
src := netip.AddrPortFrom(netip.MustParseAddr("192.168.1.50"), uint16(40000+i%3))
s.handle(src, "[MOTD]§aTest §bServer[/MOTD][AD]25565[/AD]")
s.handle(src, "malformed packet")
}
}()
// Read like the GUI does.
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
for _, srv := range s.Servers() {
_ = srv.Motd
_ = srv.Addr
}
_ = s.Err()
}
}()
}
// Reconfigure while it runs.
wg.Add(1)
go func() {
defer wg.Done()
for ctx.Err() == nil {
s.SetSelfEntries([]LanEntry{{Motd: "Test Server", Port: 25565}})
time.Sleep(time.Millisecond)
s.SetSelfEntries(nil)
}
}()
wg.Wait()
servers := s.Servers()
if len(servers) == 0 {
t.Fatal("expected the synthetic announcements to be recorded")
}
for _, srv := range servers {
if srv.Port != 25565 {
t.Errorf("unexpected port %d", srv.Port)
}
// Colour codes must be stripped.
if indexOf(srv.Motd, "§") >= 0 {
t.Errorf("colour codes survived: %q", srv.Motd)
}
if srv.Motd != "Test Server" {
t.Errorf("motd = %q, want %q", srv.Motd, "Test Server")
}
}
}
func TestPeerMonitorSnapshotIsIsolated(t *testing.T) {
m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{})
-539
View File
@@ -1,539 +0,0 @@
package core
import (
"context"
"errors"
"log/slog"
"net"
"net/netip"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Minecraft's LAN discovery protocol: servers multicast the ASCII payload
// "[MOTD]<motd>[/MOTD][AD]<port>[/AD]" to these groups roughly every 1.5s.
// core/lan.go sends them; this file listens for them.
const (
lanScanGroupV4 = "224.0.2.60:4445"
lanScanGroupV6 = "[ff75:230::60]:4445"
)
const (
// lanScanExpiry drops a server that stopped broadcasting.
lanScanExpiry = 30 * time.Second
// lanScanStale is how long a server may go unheard before the next packet
// from it is treated as a real change worth waking the UI for. Without it
// the GUI would redraw on every duplicate broadcast.
lanScanStale = 10 * time.Second
// lanScanSweep is the expiry tick interval.
lanScanSweep = 5 * time.Second
// lanScanBuf is the per-read buffer size; LAN announcements are tiny.
lanScanBuf = 2048
// lanScanMotdRunes caps a stored MOTD so a hostile peer cannot bloat the UI.
lanScanMotdRunes = 120
)
// LanServer is one Minecraft server seen broadcasting on the local network.
type LanServer struct {
Motd string // MOTD with Minecraft section-sign colour codes stripped
RawMotd string // as received
Port int //
Source netip.AddrPort // who sent the packet
Addr netip.Addr // Source.Addr(), the address to actually connect to
FirstSeen time.Time
LastSeen time.Time
Count int // packets seen
IsSelf bool // matches one of the entries tslink is advertising
}
// lanScanKey deduplicates by sender address and advertised port. The sender's
// ephemeral source port is deliberately excluded: it changes per socket.
type lanScanKey struct {
addr netip.Addr
port int
}
// LanScanner watches for Minecraft LAN broadcasts on every multicast-capable
// interface and keeps a deduplicated, self-expiring view of what it heard.
//
// All methods are safe for concurrent use; the GUI calls [LanScanner.Servers]
// from its frame loop while the read goroutines are writing.
type LanScanner struct {
logger *slog.Logger
mu sync.RWMutex
servers map[lanScanKey]*LanServer
self []LanEntry
lastErr string
subs map[int]chan struct{}
nextSub int
started bool
// live counts read loops still running. A VPN or virtual adapter going
// down kills its socket's loop; when the last one dies the scanner is
// deaf, and Err() has to say so instead of continuing to report health.
live int
}
// NewLanScanner returns a scanner that has not started listening yet. A nil
// logger falls back to slog.Default.
func NewLanScanner(logger *slog.Logger) *LanScanner {
if logger == nil {
logger = slog.Default()
}
return &LanScanner{
logger: logger.With(slog.String("from", "lanscan")),
servers: make(map[lanScanKey]*LanServer),
subs: make(map[int]chan struct{}),
}
}
// SetSelfEntries tells the scanner which advertisements are our own, so the UI
// can distinguish "the tunnel is working" from "someone else is hosting". It
// may be called after Start and re-evaluates already-known servers.
func (s *LanScanner) SetSelfEntries(entries []LanEntry) {
cp := make([]LanEntry, len(entries))
copy(cp, entries)
s.mu.Lock()
s.self = cp
changed := false
for _, srv := range s.servers {
self := matchesSelf(cp, srv.RawMotd, srv.Port)
if self != srv.IsSelf {
srv.IsSelf = self
changed = true
}
}
if changed {
s.notifyLocked()
}
s.mu.Unlock()
}
// Start begins listening; it returns immediately and stops when ctx is done.
// Calling it twice is a no-op.
func (s *LanScanner) Start(ctx context.Context) {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.mu.Unlock()
conns := s.listen()
if len(conns) == 0 {
s.mu.Lock()
s.lastErr = "no multicast listener could be created"
// Clear the guard so a caller that notices Err() can retry once the
// network stack is up. Binding can fail simply because Start ran
// before the interfaces existed, and a permanently dead scanner is a
// worse outcome than a redundant retry.
s.started = false
s.mu.Unlock()
s.logger.Warn("lan scan disabled, all multicast binds failed")
return
}
s.logger.With(slog.Int("sockets", len(conns))).Debug("lan scan listening")
// One closer goroutine unblocks every read at once on cancellation.
go func() {
<-ctx.Done()
for _, c := range conns {
_ = c.Close()
}
}()
s.mu.Lock()
s.live = len(conns)
s.mu.Unlock()
var wg sync.WaitGroup
for _, c := range conns {
wg.Add(1)
go func(c *net.UDPConn) {
defer wg.Done()
defer s.readerExited(ctx)
s.readLoop(ctx, c)
}(c)
}
go s.sweepLoop(ctx)
go func() {
wg.Wait()
s.logger.Debug("lan scan stopped")
}()
}
// listen joins the IPv4 group on every up, multicast-capable interface plus a
// nil-interface fallback, then does the same for IPv6. Per-interface failures
// are expected (containers, down VPN adapters) and only logged at debug level.
func (s *LanScanner) listen() []*net.UDPConn {
var conns []*net.UDPConn
v4, err := net.ResolveUDPAddr("udp4", lanScanGroupV4)
if err != nil {
s.logger.With(slog.String("error", err.Error())).Error("failed to resolve ipv4 multicast group")
}
v6, err := net.ResolveUDPAddr("udp6", lanScanGroupV6)
if err != nil {
s.logger.With(slog.String("error", err.Error())).Debug("failed to resolve ipv6 multicast group")
}
ifaces, err := net.Interfaces()
if err != nil {
s.logger.With(slog.String("error", err.Error())).Warn("failed to enumerate interfaces, falling back to default")
ifaces = nil
}
for i := range ifaces {
ifi := ifaces[i]
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 {
continue
}
if v4 != nil {
if c, err := net.ListenMulticastUDP("udp4", &ifi, v4); err == nil {
conns = append(conns, c)
} else {
s.logger.With(
slog.String("iface", ifi.Name),
slog.String("error", err.Error()),
).Debug("ipv4 multicast join failed")
}
}
if v6 != nil {
if c, err := net.ListenMulticastUDP("udp6", &ifi, v6); err == nil {
conns = append(conns, c)
} else {
s.logger.With(
slog.String("iface", ifi.Name),
slog.String("error", err.Error()),
).Debug("ipv6 multicast join failed")
}
}
}
// Fallback: let the OS pick the interface. On some hosts this is the only
// socket that ever receives anything.
if v4 != nil {
if c, err := net.ListenMulticastUDP("udp4", nil, v4); err == nil {
conns = append(conns, c)
} else {
s.logger.With(slog.String("error", err.Error())).Debug("default ipv4 multicast join failed")
}
}
if v6 != nil {
if c, err := net.ListenMulticastUDP("udp6", nil, v6); err == nil {
conns = append(conns, c)
} else {
s.logger.With(slog.String("error", err.Error())).Debug("default ipv6 multicast join failed")
}
}
for _, c := range conns {
_ = c.SetReadBuffer(64 * 1024)
}
return conns
}
// readLoop drains one socket until ctx is done or the socket is closed. A
// malformed packet is logged at debug level and never terminates the loop.
func (s *LanScanner) readLoop(ctx context.Context, c *net.UDPConn) {
buf := make([]byte, lanScanBuf)
for {
if ctx.Err() != nil {
return
}
// A deadline guarantees the loop notices cancellation even if the
// closer goroutine has not run yet.
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
n, src, err := c.ReadFromUDP(buf)
if err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() != nil {
return
}
var nerr net.Error
if errors.As(err, &nerr) && nerr.Timeout() {
continue
}
if errors.Is(err, net.ErrClosed) {
return
}
// Anything else (ENETDOWN from an adapter disappearing, for
// instance) means this socket is finished. Release it here rather
// than leaving the fd until the process exits; the ctx closer
// goroutine would otherwise be the only thing that ever closes it.
s.logger.With(slog.String("error", err.Error())).Debug("lan scan read failed")
_ = c.Close()
return
}
if n <= 0 || src == nil {
continue
}
ap, ok := netip.AddrFromSlice(src.IP)
if !ok {
continue
}
s.handle(netip.AddrPortFrom(ap.Unmap(), uint16(src.Port)), string(buf[:n]))
}
}
// readerExited records that one read loop finished. Once every socket is gone
// while the scanner is still meant to be running, Err() must report it — the
// UI otherwise shows a healthy "listening" chip over a scanner that will never
// hear another packet.
func (s *LanScanner) readerExited(ctx context.Context) {
s.mu.Lock()
if s.live > 0 {
s.live--
}
dead := s.live == 0 && ctx.Err() == nil
if dead {
s.lastErr = "all multicast listeners stopped, restart to rescan"
s.started = false
s.notifyLocked()
}
s.mu.Unlock()
if dead {
s.logger.Warn("lan scan has no live listeners left")
}
}
// sweepLoop expires servers that stopped broadcasting.
func (s *LanScanner) sweepLoop(ctx context.Context) {
t := time.NewTicker(lanScanSweep)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
s.expire(time.Now())
}
}
}
func (s *LanScanner) expire(now time.Time) {
s.mu.Lock()
changed := false
for k, srv := range s.servers {
if now.Sub(srv.LastSeen) > lanScanExpiry {
delete(s.servers, k)
changed = true
s.logger.With(
slog.String("addr", srv.Addr.String()),
slog.Int("port", srv.Port),
).Debug("lan server expired")
}
}
if changed {
s.notifyLocked()
}
s.mu.Unlock()
}
// handle records one parsed announcement.
func (s *LanScanner) handle(src netip.AddrPort, payload string) {
rawMotd, port, ok := parseLanAnnouncement(payload)
if !ok {
s.logger.With(
slog.String("src", src.String()),
slog.Int("len", len(payload)),
).Debug("ignoring malformed lan announcement")
return
}
now := time.Now()
key := lanScanKey{addr: src.Addr(), port: port}
s.mu.Lock()
defer s.mu.Unlock()
self := matchesSelf(s.self, rawMotd, port)
if srv, ok := s.servers[key]; ok {
// A repeat. Only wake the UI when something it renders actually moved.
changed := srv.IsSelf != self || srv.RawMotd != rawMotd ||
now.Sub(srv.LastSeen) > lanScanStale
srv.LastSeen = now
srv.Count++
srv.RawMotd = rawMotd
srv.Motd = cleanLanMotd(rawMotd)
srv.IsSelf = self
srv.Source = src
if changed {
s.notifyLocked()
}
return
}
s.servers[key] = &LanServer{
Motd: cleanLanMotd(rawMotd),
RawMotd: rawMotd,
Port: port,
Source: src,
Addr: src.Addr(),
FirstSeen: now,
LastSeen: now,
Count: 1,
IsSelf: self,
}
s.logger.With(
slog.String("addr", src.Addr().String()),
slog.Int("port", port),
slog.Bool("self", self),
).Debug("new lan server")
s.notifyLocked()
}
// Servers returns the currently-known servers, freshest first, safe to call
// from the UI. The result is a copy: LanServer holds no reference types, so
// the caller may read it without holding any lock.
func (s *LanScanner) Servers() []LanServer {
s.mu.RLock()
out := make([]LanServer, 0, len(s.servers))
for _, srv := range s.servers {
out = append(out, *srv)
}
s.mu.RUnlock()
// Deterministic ordering keeps the GUI from jittering between refreshes:
// our own advertisements sink to the bottom, then freshest first.
sort.SliceStable(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.IsSelf != b.IsSelf {
return !a.IsSelf
}
if !a.LastSeen.Equal(b.LastSeen) {
return a.LastSeen.After(b.LastSeen)
}
if a.Port != b.Port {
return a.Port < b.Port
}
return a.Source.String() < b.Source.String()
})
return out
}
// Err returns the last listener error, if the scanner could not bind at all.
// It is empty while the scanner is healthy.
func (s *LanScanner) Err() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.lastErr
}
// Subscribe returns a channel that receives a value whenever the server set
// meaningfully changes, plus a function that cancels the subscription. The
// channel is buffered and coalescing: a slow reader sees one wakeup, not a
// backlog of duplicate broadcasts.
func (s *LanScanner) Subscribe() (<-chan struct{}, func()) {
ch := make(chan struct{}, 1)
s.mu.Lock()
id := s.nextSub
s.nextSub++
s.subs[id] = ch
s.mu.Unlock()
var once sync.Once
cancel := func() {
once.Do(func() {
s.mu.Lock()
delete(s.subs, id)
s.mu.Unlock()
})
}
return ch, cancel
}
// notifyLocked wakes every subscriber. The caller must hold s.mu.
func (s *LanScanner) notifyLocked() {
for _, ch := range s.subs {
select {
case ch <- struct{}{}:
default: // subscriber has a pending wakeup already
}
}
}
// ---------------------------------------------------------------------------
// parsing
// ---------------------------------------------------------------------------
// parseLanAnnouncement extracts the MOTD and port from a Minecraft LAN
// broadcast. It is strict: anything not shaped exactly like
// "[MOTD]…[/MOTD][AD]<1..65535>[/AD]" is rejected.
func parseLanAnnouncement(payload string) (motd string, port int, ok bool) {
motd, ok = between(payload, "[MOTD]", "[/MOTD]")
if !ok {
return "", 0, false
}
ad, ok := between(payload, "[AD]", "[/AD]")
if !ok {
return "", 0, false
}
port, err := strconv.Atoi(strings.TrimSpace(ad))
if err != nil || !validPort(port) {
return "", 0, false
}
return motd, port, true
}
// between returns the text enclosed by the first open tag and the first close
// tag that follows it.
func between(s, openTag, closeTag string) (string, bool) {
i := strings.Index(s, openTag)
if i < 0 {
return "", false
}
rest := s[i+len(openTag):]
j := strings.Index(rest, closeTag)
if j < 0 {
return "", false
}
return rest[:j], true
}
// cleanLanMotd strips Minecraft section-sign colour codes, trims whitespace and
// caps the result so an oversized announcement cannot distort the UI.
func cleanLanMotd(raw string) string {
var b strings.Builder
b.Grow(len(raw))
skip := false
for _, r := range raw {
if skip {
// Drop the single formatting character following the section sign.
skip = false
continue
}
if r == '§' {
skip = true
continue
}
b.WriteRune(r)
}
out := strings.TrimSpace(b.String())
n := 0
for i := range out {
n++
if n > lanScanMotdRunes {
return out[:i]
}
}
return out
}
// matchesSelf reports whether an announcement corresponds to one of our own
// advertised entries. Comparison uses the raw MOTD, which is exactly what
// core/lan.go puts on the wire.
func matchesSelf(self []LanEntry, rawMotd string, port int) bool {
for _, e := range self {
if e.Port == port && e.Motd == rawMotd {
return true
}
}
return false
}
+9
View File
@@ -154,6 +154,7 @@ type PeerMonitor struct {
refreshStatus chan struct{}
refreshPing chan struct{}
refreshLinks chan struct{}
mu sync.RWMutex
raw *ipnstate.Status // last good status, nil until the first poll lands
@@ -180,6 +181,7 @@ func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *s
opt: opt.withDefaults(),
refreshStatus: make(chan struct{}, 1),
refreshPing: make(chan struct{}, 1),
refreshLinks: make(chan struct{}, 1),
hist: make(map[string][]PeerSample),
last: make(map[string]pingOutcome),
links: make(map[netip.Addr][]string),
@@ -196,9 +198,14 @@ func (m *PeerMonitor) Start(ctx context.Context) {
}
// RefreshNow triggers an immediate status+ping cycle without blocking the caller.
//
// Link resolution is kicked too. It normally runs every linkResolveInterval,
// but the GUI now lists only linked peers, so a user staring at an empty page
// after a DNS hiccup has no other way to ask for a retry.
func (m *PeerMonitor) RefreshNow() {
kick(m.refreshStatus)
kick(m.refreshPing)
kick(m.refreshLinks)
}
// kick delivers a coalescing wakeup: a pending signal is enough.
@@ -534,6 +541,8 @@ func (m *PeerMonitor) linkLoop(ctx context.Context) {
return
case <-ticker.C:
m.resolveLinks(ctx)
case <-m.refreshLinks:
m.resolveLinks(ctx)
}
}
}
-7
View File
@@ -96,7 +96,6 @@ type State struct {
Config *Config
Server *tsnet.Server
Peers *PeerMonitor
Lan *LanScanner
}
// Ready reports whether the service finished booting.
@@ -414,13 +413,8 @@ func (s *Supervisor) boot(ctx context.Context) error {
peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{})
peers.Start(ctx)
lan := NewLanScanner(s.logger.With("from", "lan_scan"))
lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect))
lan.Start(ctx)
s.update(func(st *State) {
st.Peers = peers
st.Lan = lan
})
s.stepDone(StepKeyMonitors, nil)
@@ -457,7 +451,6 @@ func (s *Supervisor) teardown() {
srv := s.state.Server
s.state.Server = nil
s.state.Peers = nil
s.state.Lan = nil
s.mu.Unlock()
if srv != nil {