package checker import ( "context" "encoding/json" "fmt" "net" "strconv" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" happydns "git.happydns.org/happyDomain/model" ) // unknownSRVPayload mirrors svcs.UnknownSRV for JSON-decoding the service body. // We decode SRV records by hand (instead of importing miekg/dns) so the // checker stays light and its build surface minimal. type unknownSRVPayload struct { Records []struct { Hdr struct { Name string `json:"Name"` } `json:"Hdr"` Priority uint16 `json:"Priority"` Weight uint16 `json:"Weight"` Port uint16 `json:"Port"` Target string `json:"Target"` } `json:"srv"` } func (p *srvProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { svcMsg, ok := sdk.GetOption[happydns.ServiceMessage](opts, "service") if !ok { return nil, fmt.Errorf("service not provided") } if svcMsg.Type != "svcs.UnknownSRV" { return nil, fmt.Errorf("service type is %q, expected svcs.UnknownSRV", svcMsg.Type) } var payload unknownSRVPayload if err := json.Unmarshal(svcMsg.Service, &payload); err != nil { return nil, fmt.Errorf("failed to decode UnknownSRV: %w", err) } if len(payload.Records) == 0 { return nil, fmt.Errorf("service contains no SRV records") } subdomain, _ := opts["subdomain"].(string) domain, _ := opts["domain"].(string) // The service "address" (e.g. _sip._tcp.example.com) — used for reporting. serviceDomain := strings.TrimSuffix(subdomain, ".") if domain != "" { if serviceDomain != "" { serviceDomain += "." + strings.TrimSuffix(domain, ".") } else { serviceDomain = strings.TrimSuffix(domain, ".") } } tcpTimeout := durationOpt(opts, "tcpTimeout", 3000) udpTimeout := durationOpt(opts, "udpTimeout", 2000) data := &SRVData{ServiceDomain: serviceDomain} for _, r := range payload.Records { owner := strings.TrimSuffix(r.Hdr.Name, ".") svc, proto := parseOwner(owner, serviceDomain) rec := SRVRecord{ Service: svc, Proto: proto, Owner: owner, Target: strings.TrimSuffix(r.Target, "."), Port: r.Port, Priority: r.Priority, Weight: r.Weight, } // RFC 2782: "." target means "service decidedly not available". if rec.Target == "" || rec.Target == "." { rec.IsNullTarget = true data.Records = append(data.Records, rec) continue } // CNAME detection (RFC 2782 §"Usage rules": target MUST be a name that // resolves to A/AAAA records directly, not a CNAME). if cname, err := net.DefaultResolver.LookupCNAME(ctx, rec.Target); err == nil { canon := strings.TrimSuffix(cname, ".") if canon != "" && !strings.EqualFold(canon, rec.Target) { rec.IsCNAME = true rec.CNAMEChain = []string{rec.Target, canon} } } ips, err := net.DefaultResolver.LookupIPAddr(ctx, rec.Target) if err != nil { rec.ResolveError = err.Error() data.Records = append(data.Records, rec) continue } for _, ip := range ips { rec.Addresses = append(rec.Addresses, ip.IP.String()) } // Probe each resolved address. for _, addr := range rec.Addresses { hostport := net.JoinHostPort(addr, strconv.Itoa(int(rec.Port))) switch proto { case "udp": rec.Probes = append(rec.Probes, probeUDP(ctx, hostport, udpTimeout)) default: // tcp (and anything else) rec.Probes = append(rec.Probes, probeTCP(ctx, hostport, tcpTimeout)) } } data.Records = append(data.Records, rec) } return data, nil } func parseOwner(owner, serviceDomain string) (svc, proto string) { // Owner of form _service._proto[.domain] s := strings.TrimSuffix(owner, "."+serviceDomain) parts := strings.Split(s, ".") if len(parts) >= 2 && strings.HasPrefix(parts[0], "_") && strings.HasPrefix(parts[1], "_") { return strings.TrimPrefix(parts[0], "_"), strings.TrimPrefix(parts[1], "_") } return "", "tcp" } func durationOpt(opts sdk.CheckerOptions, key string, defMs int) time.Duration { ms := defMs if v, ok := opts[key]; ok { switch n := v.(type) { case float64: ms = int(n) case int: ms = n } } if ms < 100 { ms = 100 } if ms > 60000 { ms = 60000 } return time.Duration(ms) * time.Millisecond } func probeTCP(ctx context.Context, hostport string, timeout time.Duration) ProbeResult { pr := ProbeResult{Address: hostport, Proto: "tcp"} dialer := net.Dialer{Timeout: timeout} start := time.Now() ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() conn, err := dialer.DialContext(ctx, "tcp", hostport) pr.LatencyMs = float64(time.Since(start).Microseconds()) / 1000.0 if err != nil { pr.Error = err.Error() return pr } _ = conn.Close() pr.Connected = true return pr } func probeUDP(ctx context.Context, hostport string, timeout time.Duration) ProbeResult { pr := ProbeResult{Address: hostport, Proto: "udp"} dialer := net.Dialer{Timeout: timeout} ctx2, cancel := context.WithTimeout(ctx, timeout) defer cancel() conn, err := dialer.DialContext(ctx2, "udp", hostport) if err != nil { pr.Error = err.Error() return pr } defer conn.Close() // Send a single zero byte. If the host has nothing listening and returns // ICMP port-unreachable, a subsequent Read will fail with "connection // refused". Silent drops (firewalled) remain indistinguishable from a // working service — report as "reachable (no response)". _ = conn.SetDeadline(time.Now().Add(timeout)) if _, err := conn.Write([]byte{0}); err != nil { pr.Error = err.Error() return pr } buf := make([]byte, 1) _, err = conn.Read(buf) if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { // No ICMP unreachable came back: host probably accepts UDP, // or packets are silently dropped. Treat as "reachable". pr.Connected = true pr.Error = "no UDP response (host may still be reachable)" return pr } if strings.Contains(err.Error(), "refused") { pr.Error = err.Error() return pr } pr.Error = err.Error() return pr } pr.Connected = true return pr }