package checker import ( "context" "crypto/tls" "fmt" "net" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) type probeConfig struct { mode string username string password string sharedSecret string realm string probePeer string testChannelBind bool timeout time.Duration } // Collect gathers raw STUN/TURN observations (SRV discovery, dial // outcome, STUN Binding result, TURN Allocate results, relay echo). // It performs NO judgement: severity, pass/fail, warning thresholds // and fix suggestions are left to the CheckRule layer. func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { zone, _ := opts["zone"].(string) uri, _ := opts["serverURI"].(string) mode, _ := opts["mode"].(string) if mode == "" { mode = "auto" } username, _ := opts["username"].(string) password, _ := opts["credential"].(string) sharedSecret, _ := opts["sharedSecret"].(string) realm, _ := opts["realm"].(string) transportsRaw, _ := opts["transports"].(string) probePeer, _ := opts["probePeer"].(string) if probePeer == "" { probePeer = "1.1.1.1:53" } // Refuse to relay traffic toward private/loopback/link-local // destinations: a malicious caller could otherwise abuse the target // TURN server to port-scan the operator's internal network through us. if isPrivateAddr(probePeer) { return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer) } timeoutSec := sdk.GetIntOption(opts, "timeout", 5) if timeoutSec <= 0 { timeoutSec = 5 } cfg := probeConfig{ mode: mode, username: username, password: password, sharedSecret: sharedSecret, realm: realm, probePeer: probePeer, testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false), timeout: time.Duration(timeoutSec) * time.Second, } transports := parseTransports(transportsRaw) warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200)) critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000)) data := &StunTurnData{ Zone: zone, Mode: mode, RequestedURI: uri, ProbePeer: probePeer, WarningRTTMs: warnRTT, CriticalRTT: critRTT, HasCreds: sharedSecret != "" || (username != "" && password != ""), CollectedAt: time.Now().UTC(), } endpoints, err := discoverEndpoints(ctx, zone, uri, transports) if err != nil { data.GlobalError = err.Error() return data, nil } for _, ep := range endpoints { probe := EndpointProbe{Endpoint: ep} collectEndpoint(ctx, &probe, cfg) data.Endpoints = append(data.Endpoints, probe) } return data, nil } // collectEndpoint runs every network probe we know how to run against // a single endpoint and records raw results on probe. It never assigns // severity. func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) { ep := probe.Endpoint // Best-effort DNS lookup for IPv6 coverage rule. if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil { for _, ip := range ips { probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String()) } } dialStart := time.Now() dc, err := dial(ctx, ep, cfg.timeout) dialDur := time.Since(dialStart) if err != nil { probe.Dial = DialResult{ OK: false, DurationMs: dialDur.Milliseconds(), Error: err.Error(), } return } defer dc.Close() probe.Dial = DialResult{ OK: true, DurationMs: dialDur.Milliseconds(), RemoteAddr: dc.remoteAddr.String(), } if dc.tlsState != nil { probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version) probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite) probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState) } if dc.dtlsState != nil { probe.Dial.DTLSHandshake = true } // STUN Binding probe, always attempted. bind := runSTUNBinding(dc, cfg.timeout) probe.STUNBinding.Attempted = true if bind.Err != nil { probe.STUNBinding.OK = false probe.STUNBinding.Error = bind.Err.Error() } else { probe.STUNBinding.OK = true probe.STUNBinding.RTTMs = bind.RTT.Milliseconds() if bind.ReflexiveAddr != nil { probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String() } probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped } // TURN-only probes short-circuit when mode=stun or the scheme is // stun:/stuns:. if cfg.mode == "stun" || !ep.IsTURN { return } // Unauthenticated TURN Allocate (open-relay probe). noAuth := runTURNAllocate(dc, nil, cfg.timeout) probe.TURNNoAuth.Attempted = true probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds() probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge if noAuth.RelayConn != nil { probe.TURNNoAuth.OK = true if noAuth.RelayAddr != nil { probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String() } _ = noAuth.RelayConn.Close() if noAuth.Client != nil { noAuth.Client.Close() } } else if noAuth.Err != nil && !noAuth.UnauthChallenge { probe.TURNNoAuth.Error = noAuth.Err.Error() } // Authenticated TURN Allocate, if credentials are provided. creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm) if creds == nil { return } dc2, err := dial(ctx, ep, cfg.timeout) if err != nil { probe.TURNAuth.Attempted = true probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err) return } defer dc2.Close() auth := runTURNAllocate(dc2, creds, cfg.timeout) probe.TURNAuth.Attempted = true probe.TURNAuth.DurationMs = auth.Duration.Milliseconds() probe.TURNAuth.ErrorCode = auth.AuthErrorCode probe.TURNAuth.ErrorReason = auth.AuthErrorReason if auth.Err != nil { probe.TURNAuth.OK = false probe.TURNAuth.Error = auth.Err.Error() return } probe.TURNAuth.OK = true if auth.RelayAddr != nil { probe.TURNAuth.RelayAddr = auth.RelayAddr.String() } probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay defer func() { auth.RelayConn.Close() if auth.Client != nil { auth.Client.Close() } }() // Relay echo. probe.RelayEcho.Attempted = true probe.RelayEcho.PeerAddr = cfg.probePeer if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil { probe.RelayEcho.OK = false probe.RelayEcho.Error = err.Error() } else { probe.RelayEcho.OK = true } if cfg.testChannelBind { probe.ChannelBindRun = true } } func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials { if sharedSecret != "" { return restAPICredentials(sharedSecret, username, realm, time.Hour) } if username != "" && password != "" { return &turnCredentials{Username: username, Password: password, Realm: realm} } return nil }