Files
l-ui/internal/agent/v4beacon.go
T
Muse 12aff835ed feat(agent): periodic IPv4 beacon proves public v4 to panel (item ③)
Agent posts an empty beacon over a tcp4-forced connection every
5 minutes (plus once at startup); panel files the observed source
as SeenIPv4 only when it is a public IPv4 literal, never touching
the reported telemetry address, so 1:1 NAT nodes stop falling back
to their private address in share links.
2026-10-24 09:43:56 +00:00

59 lines
2.1 KiB
Go

package agent
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"time"
)
// Beacon cadence is deliberately slow: the proven address barely changes, so the heartbeat's 60 s would be pure noise; the first beacon fires at startup.
const defaultV4BeaconInterval = 5 * time.Minute
// v4OnlyDialContext forces every dial onto tcp4: on 1:1 NAT the local v4 is private, so only a panel-observed v4 arrival proves the public address (a v6-only panel just fails the dial).
func v4OnlyDialContext(dial func(ctx context.Context, network, address string) (net.Conn, error)) func(ctx context.Context, network, address string) (net.Conn, error) {
return func(ctx context.Context, _ string, address string) (net.Conn, error) {
return dial(ctx, "tcp4", address)
}
}
// newV4BeaconClient mirrors the agent's normal client (same TLS trust, no proxy — a proxy's egress would prove nothing about this host) except its dialer is pinned to tcp4.
func newV4BeaconClient() *http.Client {
return &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
DialContext: v4OnlyDialContext((&net.Dialer{Timeout: 10 * time.Second}).DialContext),
},
}
}
// sendV4Beacon posts one empty beacon over a v4-only connection; the panel's observed source is the whole signal, since any self-reported address is worthless behind 1:1 NAT. Any failure is the caller's to ignore — the heartbeat keeps working over v6 meanwhile.
func (a *Agent) sendV4Beacon() error {
url := fmt.Sprintf("%s/panel/api/agent/v4-beacon", a.config.MasterURL)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader([]byte(`{}`)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+a.config.Token)
client := a.v4httpClient
if client == nil {
client = newV4BeaconClient()
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("v4 beacon failed with status %d", resp.StatusCode)
}
return nil
}