Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:42:34 +07:00
commit f6f102079f
19 changed files with 2222 additions and 0 deletions

592
checker/collect.go Normal file
View file

@ -0,0 +1,592 @@
package checker
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect runs the full SIP probe against a domain.
func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("domain is required")
}
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 5)
if timeoutSecs < 1 {
timeoutSecs = 5
}
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
probeUDP := sdk.GetBoolOption(opts, "probeUDP", true)
probeTCP := sdk.GetBoolOption(opts, "probeTCP", true)
probeTLS := sdk.GetBoolOption(opts, "probeTLS", true)
data := &SIPData{
Domain: domain,
RunAt: time.Now().UTC().Format(time.RFC3339),
SRV: SRVLookup{Errors: map[string]string{}},
}
resolver := net.DefaultResolver
// NAPTR lookup — best-effort, failures become an info issue.
if naptr, err := lookupNAPTR(ctx, domain); err != nil {
data.SRV.Errors["naptr"] = err.Error()
} else {
data.NAPTR = naptr
}
// SRV lookups (per transport). Errors are kept per-prefix; "not
// found" is normalised to nil by lookupSRV.
type srvSet struct {
prefix string
want bool
dst *[]SRVRecord
}
sets := []srvSet{
{"_sip._udp.", probeUDP, &data.SRV.UDP},
{"_sip._tcp.", probeTCP, &data.SRV.TCP},
{"_sips._tcp.", probeTLS, &data.SRV.SIPS},
}
for _, s := range sets {
if !s.want {
continue
}
recs, err := lookupSRV(ctx, resolver, s.prefix, domain)
if err != nil {
data.SRV.Errors[s.prefix] = err.Error()
continue
}
*s.dst = recs
}
// Fallback when no SRV at all: synthesize a single target on each
// enabled transport against the bare domain.
total := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
if total == 0 {
data.SRV.FallbackProbed = true
if probeUDP {
data.SRV.UDP = []SRVRecord{{Target: domain, Port: 5060}}
}
if probeTCP {
data.SRV.TCP = []SRVRecord{{Target: domain, Port: 5060}}
}
if probeTLS {
data.SRV.SIPS = []SRVRecord{{Target: domain, Port: 5061}}
}
}
type transportJob struct {
records []SRVRecord
prefix string
t Transport
}
jobs := []transportJob{
{data.SRV.UDP, "_sip._udp.", TransportUDP},
{data.SRV.TCP, "_sip._tcp.", TransportTCP},
{data.SRV.SIPS, "_sips._tcp.", TransportTLS},
}
var wg sync.WaitGroup
var mu sync.Mutex
for _, job := range jobs {
wg.Add(1)
go func(j transportJob) {
defer wg.Done()
resolveAllInto(ctx, resolver, j.records)
eps := probeSet(ctx, j.prefix, j.t, j.records, perEndpoint)
mu.Lock()
data.Endpoints = append(data.Endpoints, eps...)
mu.Unlock()
}(job)
}
wg.Wait()
computeCoverage(data)
data.Issues = deriveIssues(data, probeUDP, probeTCP, probeTLS)
return data, nil
}
// ─── DNS ──────────────────────────────────────────────────────────────
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
name := prefix + dns.Fqdn(domain)
_, records, err := r.LookupSRV(ctx, "", "", name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return nil, nil
}
return nil, err
}
// RFC 2782 null-target: single "." record with port 0 means
// "service explicitly unavailable".
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
return nil, nil
}
out := make([]SRVRecord, 0, len(records))
for _, r := range records {
out = append(out, SRVRecord{
Target: strings.TrimSuffix(r.Target, "."),
Port: r.Port,
Priority: r.Priority,
Weight: r.Weight,
})
}
return out, nil
}
func lookupNAPTR(ctx context.Context, domain string) ([]NAPTRRecord, error) {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || cfg == nil || len(cfg.Servers) == 0 {
cfg = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: "53"}
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeNAPTR)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = 3 * time.Second
var lastErr error
for _, srv := range cfg.Servers {
addr := net.JoinHostPort(srv, cfg.Port)
in, _, err := c.ExchangeContext(ctx, m, addr)
if err != nil {
lastErr = err
continue
}
if in.Rcode == dns.RcodeNameError {
return nil, nil
}
if in.Rcode != dns.RcodeSuccess {
lastErr = fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
continue
}
var out []NAPTRRecord
for _, rr := range in.Answer {
n, ok := rr.(*dns.NAPTR)
if !ok {
continue
}
if !strings.HasPrefix(strings.ToUpper(n.Service), "SIP+") && !strings.HasPrefix(strings.ToUpper(n.Service), "SIPS+") {
continue
}
out = append(out, NAPTRRecord{
Service: n.Service,
Regexp: n.Regexp,
Replacement: strings.TrimSuffix(n.Replacement, "."),
Flags: n.Flags,
Order: n.Order,
Preference: n.Preference,
})
}
return out, nil
}
return nil, lastErr
}
func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
for i := range records {
ips, err := r.LookupIPAddr(ctx, records[i].Target)
if err != nil {
continue
}
for _, ip := range ips {
if v4 := ip.IP.To4(); v4 != nil {
records[i].IPv4 = append(records[i].IPv4, v4.String())
} else {
records[i].IPv6 = append(records[i].IPv6, ip.IP.String())
}
}
}
}
// ─── Probing ──────────────────────────────────────────────────────────
func probeSet(ctx context.Context, prefix string, t Transport, records []SRVRecord, timeout time.Duration) []EndpointProbe {
var eps []EndpointProbe
for _, rec := range records {
addrs := allAddrs(rec)
if len(addrs) == 0 {
eps = append(eps, EndpointProbe{
Transport: t,
SRVPrefix: prefix,
Target: rec.Target,
Port: rec.Port,
Error: "no A/AAAA records for target",
})
continue
}
for _, a := range addrs {
eps = append(eps, probeEndpoint(ctx, t, prefix, rec, a, timeout))
}
}
return eps
}
type probeAddr struct {
ip string
isV6 bool
}
func allAddrs(r SRVRecord) []probeAddr {
out := make([]probeAddr, 0, len(r.IPv4)+len(r.IPv6))
for _, ip := range r.IPv4 {
out = append(out, probeAddr{ip: ip, isV6: false})
}
for _, ip := range r.IPv6 {
out = append(out, probeAddr{ip: ip, isV6: true})
}
return out
}
func probeEndpoint(ctx context.Context, t Transport, prefix string, rec SRVRecord, a probeAddr, timeout time.Duration) (ep EndpointProbe) {
start := time.Now()
addrPort := net.JoinHostPort(a.ip, strconv.Itoa(int(rec.Port)))
ep = EndpointProbe{
Transport: t,
SRVPrefix: prefix,
Target: rec.Target,
Port: rec.Port,
Address: addrPort,
IsIPv6: a.isV6,
}
defer func() { ep.ElapsedMS = time.Since(start).Milliseconds() }()
ua := "happyDomain-checker-sip/" + Version
switch t {
case TransportUDP:
probeUDP(ctx, &ep, rec.Target, ua, timeout)
case TransportTCP:
probeTCP(ctx, &ep, rec.Target, ua, timeout)
case TransportTLS:
probeTLSConn(ctx, &ep, rec.Target, ua, timeout)
}
return
}
func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "udp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "udp dial: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportUDP, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "udp write: " + err.Error()
return
}
ep.OptionsSent = true
buf := make([]byte, 8192)
n, err := conn.Read(buf)
if err != nil {
ep.Error = "no udp response: " + err.Error()
return
}
resp, err := parseSIPResponse(bytes.NewReader(buf[:n]))
if err != nil {
ep.Error = "bad response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "tcp dial: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportTCP, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "tcp write: " + err.Error()
return
}
ep.OptionsSent = true
resp, err := parseSIPResponse(conn)
if err != nil {
ep.Error = "no tcp response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
raw, err := d.DialContext(ctx, "tcp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "tcp dial: " + err.Error()
return
}
// We deliberately skip cert verification — checker-tls is the
// source of truth for TLS posture. We just want to reach SIP over
// TLS.
cfg := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
ServerName: target,
}
conn := tls.Client(raw, cfg)
if err := conn.HandshakeContext(ctx); err != nil {
_ = raw.Close()
ep.Error = "tls handshake: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
state := conn.ConnectionState()
ep.TLSVersion = tls.VersionName(state.Version)
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportTLS, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "tls write: " + err.Error()
return
}
ep.OptionsSent = true
resp, err := parseSIPResponse(conn)
if err != nil {
ep.Error = "no tls response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func applyResponse(ep *EndpointProbe, resp *sipResponse, sent time.Time) {
ep.OptionsRawCode = resp.StatusCode
ep.OptionsStatus = fmt.Sprintf("%d %s", resp.StatusCode, strings.TrimSpace(resp.StatusPhrase))
ep.OptionsRTTMs = time.Since(sent).Milliseconds()
ep.ServerHeader = resp.Server
ep.UserAgent = resp.UserAgent
ep.AllowMethods = resp.Allow
ep.ContactURI = resp.Contact
}
// ─── Coverage + issues ────────────────────────────────────────────────
func computeCoverage(data *SIPData) {
for _, ep := range data.Endpoints {
if ep.Reachable {
if ep.IsIPv6 {
data.Coverage.HasIPv6 = true
} else {
data.Coverage.HasIPv4 = true
}
}
if !ep.OK() {
continue
}
switch ep.Transport {
case TransportUDP:
data.Coverage.WorkingUDP = true
case TransportTCP:
data.Coverage.WorkingTCP = true
case TransportTLS:
data.Coverage.WorkingTLS = true
}
}
data.Coverage.AnyWorking = data.Coverage.WorkingUDP || data.Coverage.WorkingTCP || data.Coverage.WorkingTLS
}
func deriveIssues(data *SIPData, wantUDP, wantTCP, wantTLS bool) []Issue {
var out []Issue
totalSRV := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
if totalSRV == 0 && data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeNoSRV,
Severity: SeverityCrit,
Message: "No SIP SRV records published for " + data.Domain + ".",
Fix: "Publish `_sip._tcp." + data.Domain + ". SRV 10 10 5060 sip." + data.Domain + ".` (and `_sips._tcp` on 5061 for TLS).",
})
}
// "Only UDP" — the most common real-world failure for modern trunks.
if len(data.SRV.UDP) > 0 && len(data.SRV.TCP) == 0 && len(data.SRV.SIPS) == 0 && !data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeOnlyUDP,
Severity: SeverityWarn,
Message: "Only _sip._udp is published; modern SIP trunks (Twilio, OVH, Orange…) prefer TCP/TLS.",
Fix: "Also publish `_sip._tcp." + data.Domain + ".` and ideally `_sips._tcp." + data.Domain + ".`.",
})
}
// No TLS at all when TCP exists.
if wantTLS && len(data.SRV.SIPS) == 0 && (len(data.SRV.UDP) > 0 || len(data.SRV.TCP) > 0) && !data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeNoTLS,
Severity: SeverityInfo,
Message: "No _sips._tcp SRV record — SIP signalling runs in the clear.",
Fix: "Publish `_sips._tcp." + data.Domain + ".` on port 5061 and terminate TLS on the server.",
})
}
// Per-prefix DNS errors.
for prefix, msg := range data.SRV.Errors {
if prefix == "naptr" {
out = append(out, Issue{
Code: CodeNAPTRServfail,
Severity: SeverityInfo,
Message: "NAPTR lookup for " + data.Domain + " failed: " + msg,
Fix: "This is optional. If you meant to expose a NAPTR, verify your authoritative resolver answers AUTH/NXDOMAIN cleanly.",
})
continue
}
out = append(out, Issue{
Code: CodeSRVServfail,
Severity: SeverityWarn,
Message: "SRV lookup for `" + prefix + data.Domain + "` failed: " + msg,
Fix: "Check zone serial and authoritative NS for this name.",
})
}
// Fallback-probed notice.
if data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeFallbackProbed,
Severity: SeverityInfo,
Message: "No SIP SRV records: probing fell back to " + data.Domain + ":5060 / :5061.",
Fix: "Publish the SRV records expected by SIP clients and trunks.",
})
}
// Per-endpoint findings.
for _, ep := range data.Endpoints {
switch {
case !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target":
out = append(out, Issue{
Code: CodeSRVTargetUnresolved,
Severity: SeverityCrit,
Message: "SRV target `" + ep.Target + "` has no A/AAAA.",
Fix: "Add A/AAAA records for `" + ep.Target + "` or change the SRV target.",
Endpoint: ep.Target,
})
case !ep.Reachable:
code := CodeTCPUnreachable
msg := "TCP port " + strconv.Itoa(int(ep.Port)) + " is closed or filtered on " + ep.Address + "."
fix := "Verify the SIP server is running and the firewall/NAT forwards port " + strconv.Itoa(int(ep.Port)) + "."
switch ep.Transport {
case TransportUDP:
code = CodeUDPUnreachable
msg = "UDP port " + strconv.Itoa(int(ep.Port)) + " refused on " + ep.Address + "."
fix = "Verify the SIP server listens on UDP " + strconv.Itoa(int(ep.Port)) + " and that no stateless firewall drops the reply."
case TransportTLS:
if ep.Error != "" && strings.HasPrefix(ep.Error, "tls handshake") {
code = CodeTLSHandshake
msg = "TLS handshake failed on " + ep.Address + ": " + strings.TrimPrefix(ep.Error, "tls handshake: ")
fix = "Present a valid certificate (chain + SAN including `" + ep.Target + "`) and accept TLS 1.2+."
}
}
out = append(out, Issue{
Code: code,
Severity: SeverityCrit,
Message: msg,
Fix: fix,
Endpoint: ep.Address,
})
case ep.Reachable && !ep.OptionsSent:
out = append(out, Issue{
Code: CodeOptionsNoAnswer,
Severity: SeverityCrit,
Message: ep.Address + " accepted the connection but the probe could not send an OPTIONS: " + ep.Error,
Fix: "Investigate the server's SIP listener.",
Endpoint: ep.Address,
})
case ep.OptionsSent && ep.OptionsRawCode == 0:
out = append(out, Issue{
Code: CodeOptionsNoAnswer,
Severity: SeverityCrit,
Message: ep.Address + " is reachable but silent on SIP OPTIONS.",
Fix: "Enable unauthenticated OPTIONS (`handle_options = yes` in Kamailio, `allowguest = yes` in Asterisk/FreeSWITCH) or add the probe source to the ACL.",
Endpoint: ep.Address,
})
case ep.OptionsRawCode >= 300:
out = append(out, Issue{
Code: CodeOptionsNon2xx,
Severity: SeverityWarn,
Message: ep.Address + " answered " + ep.OptionsStatus + " to OPTIONS.",
Fix: "Check SIP routing / ACL. Some stacks reject unauthenticated OPTIONS with 403/404.",
Endpoint: ep.Address,
})
case ep.OK() && len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"):
out = append(out, Issue{
Code: CodeOptionsNoInvite,
Severity: SeverityWarn,
Message: ep.Address + " answered 2xx but does not advertise INVITE in Allow.",
Fix: "Verify the dialplan / endpoint is allowed to place calls.",
Endpoint: ep.Address,
})
case ep.OK() && len(ep.AllowMethods) == 0:
out = append(out, Issue{
Code: CodeOptionsNoAllow,
Severity: SeverityInfo,
Message: ep.Address + " answered 2xx but did not advertise an Allow header.",
Fix: "Configure the SIP stack to include Allow (benign but helps callers discover capabilities).",
Endpoint: ep.Address,
})
}
}
// Nothing reachable at all.
if len(data.Endpoints) > 0 && !data.Coverage.AnyWorking {
out = append(out, Issue{
Code: CodeAllDown,
Severity: SeverityCrit,
Message: "No SIP endpoint answered OPTIONS on any transport.",
Fix: "Verify the SIP server is running and reachable on the published SRV ports.",
})
}
// IPv6 coverage.
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
out = append(out, Issue{
Code: CodeNoIPv6,
Severity: SeverityInfo,
Message: "No IPv6 endpoint reachable.",
Fix: "Publish AAAA records for the SRV targets.",
})
}
return out
}

69
checker/definition.go Normal file
View file

@ -0,0 +1,69 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is reported in CheckerDefinition.Version. Overridden at build
// time by main / plugin.
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "sip",
Name: "SIP / VoIP server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.SIP"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeySIP},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "SIP domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 5,
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "probeUDP",
Type: "bool",
Label: "Probe _sip._udp",
Default: true,
Description: "Disable if the checker host cannot send UDP.",
},
{
Id: "probeTCP",
Type: "bool",
Label: "Probe _sip._tcp",
Default: true,
},
{
Id: "probeTLS",
Type: "bool",
Label: "Probe _sips._tcp (TLS)",
Default: true,
},
},
},
Rules: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
}

76
checker/interactive.go Normal file
View file

@ -0,0 +1,76 @@
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the minimal human-facing inputs needed to run a SIP
// check standalone. Collect resolves NAPTR/SRV itself, so a domain name
// is the only required field.
func (p *sipProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "SIP domain",
Placeholder: "example.com",
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 5,
},
{
Id: "probeUDP",
Type: "bool",
Label: "Probe _sip._udp",
Default: true,
},
{
Id: "probeTCP",
Type: "bool",
Label: "Probe _sip._tcp",
Default: true,
},
{
Id: "probeTLS",
Type: "bool",
Label: "Probe _sips._tcp (TLS)",
Default: true,
},
}
}
// ParseForm turns the submitted form into a CheckerOptions. The SIP
// Collect path performs its own DNS lookups, so there is nothing to
// pre-resolve here.
func (p *sipProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
return nil, errors.New("domain is required")
}
opts := sdk.CheckerOptions{
"domain": domain,
"probeUDP": r.FormValue("probeUDP") == "true",
"probeTCP": r.FormValue("probeTCP") == "true",
"probeTLS": r.FormValue("probeTLS") == "true",
}
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout"] = f
}
return opts, nil
}

51
checker/provider.go Normal file
View file

@ -0,0 +1,51 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
func Provider() sdk.ObservationProvider {
return &sipProvider{}
}
type sipProvider struct{}
func (p *sipProvider) Key() sdk.ObservationKey {
return ObservationKeySIP
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *sipProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// It publishes every _sips._tcp SRV target as a tls.endpoint.v1 entry so
// the downstream TLS checker can verify certificate chain, SAN and
// expiry without re-doing the SRV lookup. SNI is set to the SRV target —
// SIPS certificates are expected to cover the server hostname (unlike
// XMPP where it's the bare JID domain).
//
// _sip._udp and _sip._tcp are plaintext with no historical STARTTLS
// convention, so nothing is emitted for them.
func (p *sipProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*SIPData)
if !ok || d == nil {
return nil, nil
}
var out []sdk.DiscoveryEntry
for _, r := range d.SRV.SIPS {
e, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: r.Target,
Port: r.Port,
SNI: r.Target,
})
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}

524
checker/report.go Normal file
View file

@ -0,0 +1,524 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"net"
"sort"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ─── View models ────────────────────────────────────────────────────
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
TLSVersion string
Issues []reportFix
}
type reportEndpoint struct {
Transport string
TransportTag string // "SIP/UDP", "SIP/TCP", "SIPS/TLS"
SRVPrefix string
Target string
Port uint16
Address string
IsIPv6 bool
Reachable bool
ReachableErr string
TLSVersion string
TLSCipher string
OptionsSent bool
OptionsStatus string
OptionsRTTMs int64
ServerHeader string
UserAgent string
AllowMethods []string
ContactURI string
ElapsedMS int64
Error string
OK bool
StatusLabel string
StatusClass string
TLSPosture *reportTLSPosture
}
type reportSRVEntry struct {
Prefix string
Target string
Port uint16
Priority uint16
Weight uint16
IPv4 []string
IPv6 []string
}
type reportData struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
NAPTR []NAPTRRecord
SRV []reportSRVEntry
FallbackProbed bool
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
WorkingUDP bool
WorkingTCP bool
WorkingTLS bool
HasTLSPosture bool
}
// ─── Template ───────────────────────────────────────────────────────
var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{
"deref": func(b *bool) bool { return b != nil && *b },
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIP Report {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.section details { box-shadow: none; border: 1px solid #e5e7eb; margin-bottom: .4rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶"; font-size: .65rem; color: #9ca3af;
transition: transform .15s; flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.badge.ok { background: #d1fae5; color: #065f46; }
.badge.warn { background: #fef3c7; color: #92400e; }
.badge.fail { background: #fee2e2; color: #991b1b; }
.badge.muted{ background: #e5e7eb; color: #374151; }
.chips { display: flex; gap: .35rem; flex-wrap: wrap; margin: .4rem 0; }
.chip {
display: inline-flex; align-items: center; gap: .35rem;
padding: .15rem .55rem; border-radius: 6px;
font-size: .75rem; font-weight: 600;
background: #f3f4f6; color: #374151;
}
.chip.ok { background: #d1fae5; color: #065f46; }
.chip.fail { background: #fee2e2; color: #991b1b; }
.fix {
border-left: 3px solid #e5e7eb;
padding: .5rem .75rem;
margin-bottom: .4rem;
background: #fafafa;
border-radius: 0 6px 6px 0;
}
.fix.crit { border-left-color: #dc2626; background: #fef2f2; }
.fix.warn { border-left-color: #d97706; background: #fffbeb; }
.fix.info { border-left-color: #2563eb; background: #eff6ff; }
.fix .code { font-size: .7rem; color: #6b7280; font-family: ui-monospace, monospace; }
.fix .msg { font-weight: 600; margin: .1rem 0; }
.fix .how { color: #374151; font-size: .85rem; }
.conn-head { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: .2rem .75rem; margin: 0; font-size: .85rem; }
dt { color: #6b7280; }
dd { margin: 0; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; font-weight: 700; }
.check-fail { color: #dc2626; font-weight: 700; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .75rem; text-align: center; margin-top: 1rem; }
.meth { display: inline-block; font-size: .72rem; padding: .1rem .45rem; background: #eef2ff; color: #4338ca; border-radius: 4px; margin: .1rem .15rem 0 0; font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<div class="hd">
<h1>SIP / VoIP {{.Domain}}</h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="chips" style="margin-top:.45rem">
<span class="chip {{if .WorkingUDP}}ok{{else}}fail{{end}}">{{if .WorkingUDP}}&#10003;{{else}}&#10007;{{end}} UDP</span>
<span class="chip {{if .WorkingTCP}}ok{{else}}fail{{end}}">{{if .WorkingTCP}}&#10003;{{else}}&#10007;{{end}} TCP</span>
<span class="chip {{if .WorkingTLS}}ok{{else}}fail{{end}}">{{if .WorkingTLS}}&#10003;{{else}}&#10007;{{end}} TLS</span>
<span class="chip {{if .HasIPv4}}ok{{end}}">IPv4</span>
<span class="chip {{if .HasIPv6}}ok{{end}}">IPv6</span>
</div>
{{if .FallbackProbed}}<div class="note">No SIP SRV records were published. Probed the bare domain on default ports.</div>{{end}}
{{if .RunAt}}<div class="note">Checked {{.RunAt}}</div>{{end}}
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} &middot; {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .NAPTR}}
<div class="section">
<h2>NAPTR ({{len .NAPTR}})</h2>
<table>
<tr><th>Order</th><th>Pref</th><th>Flags</th><th>Service</th><th>Replacement</th></tr>
{{range .NAPTR}}
<tr>
<td>{{.Order}}</td>
<td>{{.Preference}}</td>
<td><code>{{.Flags}}</code></td>
<td><code>{{.Service}}</code></td>
<td>{{if .Replacement}}<code>{{.Replacement}}</code>{{else}}<span class="note"></span>{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .SRV}}
<div class="section">
<h2>SRV records ({{len .SRV}})</h2>
<table>
<tr><th>Prefix</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>A / AAAA</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}} / {{.Weight}}</td>
<td>
{{range .IPv4}}<code>{{.}}</code> {{end}}
{{range .IPv6}}<code>{{.}}</code> {{end}}
</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .Endpoints}}
<div class="section">
<h2>Endpoint probes ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if not .OK}} open{{end}}>
<summary>
<span class="conn-head">{{.TransportTag}} &middot; {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl>
<dt>Target</dt><dd><code>{{.Target}}:{{.Port}}</code>{{if .SRVPrefix}} <span class="note">({{.SRVPrefix}})</span>{{end}}</dd>
<dt>Reachable</dt>
<dd>
{{if .Reachable}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; {{if .ReachableErr}}{{.ReachableErr}}{{else}}unreachable{{end}}</span>{{end}}
</dd>
{{if or .TLSVersion .TLSCipher}}
<dt>TLS</dt><dd>{{.TLSVersion}}{{if and .TLSVersion .TLSCipher}} &middot; {{end}}{{.TLSCipher}}</dd>
{{end}}
{{if .OptionsSent}}
<dt>OPTIONS</dt>
<dd>
{{if .OptionsStatus}}<span class="badge {{if .OK}}ok{{else}}warn{{end}}">{{.OptionsStatus}}</span>{{else}}<span class="badge fail">no reply</span>{{end}}
{{if .OptionsRTTMs}} &middot; {{.OptionsRTTMs}} ms{{end}}
</dd>
{{end}}
{{if .ServerHeader}}<dt>Server</dt><dd><code>{{.ServerHeader}}</code></dd>{{end}}
{{if .UserAgent}}<dt>User-Agent</dt><dd><code>{{.UserAgent}}</code></dd>{{end}}
{{if .ContactURI}}<dt>Contact</dt><dd><code>{{.ContactURI}}</code></dd>{{end}}
{{if .AllowMethods}}
<dt>Allow</dt>
<dd>{{range .AllowMethods}}<span class="meth">{{.}}</span>{{end}}</dd>
{{end}}
{{if .TLSPosture}}
<dt>TLS posture</dt>
<dd>
{{if .TLSPosture.ChainValid}}
{{if deref .TLSPosture.ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .TLSPosture.HostnameMatch}}
&middot; {{if deref .TLSPosture.HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .TLSPosture.NotAfter.IsZero}}
&middot; expires <code>{{.TLSPosture.NotAfter.Format "2006-01-02"}}</code>
{{end}}
{{if not .TLSPosture.CheckedAt.IsZero}}
<div class="note">TLS checked {{.TLSPosture.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
{{end}}
{{range .TLSPosture.Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}TLS posture above comes from the TLS checker on the same endpoints.{{else}}Run the TLS checker on this domain to see chain / SAN / expiry per SIPS endpoint.{{end}}</p>
</body>
</html>`))
// ─── Rendering ──────────────────────────────────────────────────────
// GetHTMLReport implements sdk.CheckerHTMLReporter. Related TLS
// observations (tls_probes) are folded in so cert posture surfaces on
// the SIP page directly.
func (p *sipProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d SIPData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal sip observation: %w", err)
}
view := buildReportData(&d, rctx.Related(TLSRelatedKey))
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render sip report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *SIPData, related []sdk.RelatedObservation) reportData {
tlsIssues := tlsIssuesFromRelated(related)
tlsByAddr := indexTLSByAddress(related)
allIssues := append([]Issue(nil), d.Issues...)
allIssues = append(allIssues, tlsIssues...)
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
WorkingUDP: d.Coverage.WorkingUDP,
WorkingTCP: d.Coverage.WorkingTCP,
WorkingTLS: d.Coverage.WorkingTLS,
HasIssues: len(allIssues) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
worst := SeverityInfo
for _, is := range allIssues {
if is.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if is.Severity == SeverityWarn {
worst = SeverityWarn
}
}
switch {
case len(allIssues) == 0:
view.StatusLabel = "OK"
view.StatusClass = "ok"
case worst == SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case worst == SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "muted"
}
// Sort fixes crit → warn → info.
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
for _, is := range allIssues {
view.Fixes = append(view.Fixes, reportFix{
Severity: is.Severity,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
Endpoint: is.Endpoint,
})
}
view.NAPTR = append(view.NAPTR, d.NAPTR...)
addSRV := func(prefix string, records []SRVRecord) {
for _, r := range records {
view.SRV = append(view.SRV, reportSRVEntry{
Prefix: prefix, Target: r.Target, Port: r.Port,
Priority: r.Priority, Weight: r.Weight,
IPv4: r.IPv4, IPv6: r.IPv6,
})
}
}
addSRV("_sip._udp", d.SRV.UDP)
addSRV("_sip._tcp", d.SRV.TCP)
addSRV("_sips._tcp", d.SRV.SIPS)
for _, ep := range d.Endpoints {
re := reportEndpoint{
Transport: string(ep.Transport),
TransportTag: transportTag(ep.Transport),
SRVPrefix: ep.SRVPrefix,
Target: ep.Target,
Port: ep.Port,
Address: ep.Address,
IsIPv6: ep.IsIPv6,
Reachable: ep.Reachable,
ReachableErr: ep.ReachableErr,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
OptionsSent: ep.OptionsSent,
OptionsStatus: ep.OptionsStatus,
OptionsRTTMs: ep.OptionsRTTMs,
ServerHeader: ep.ServerHeader,
UserAgent: ep.UserAgent,
AllowMethods: ep.AllowMethods,
ContactURI: ep.ContactURI,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
OK: ep.OK(),
}
if re.OK {
re.StatusLabel = "OK"
re.StatusClass = "ok"
} else if ep.Reachable {
re.StatusLabel = "partial"
re.StatusClass = "warn"
} else {
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
re.TLSPosture = meta
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
func transportTag(t Transport) string {
switch t {
case TransportUDP:
return "SIP/UDP"
case TransportTCP:
return "SIP/TCP"
case TransportTLS:
return "SIPS/TLS"
}
return string(t)
}
func endpointKey(host string, port uint16) string {
return net.JoinHostPort(host, strconv.Itoa(int(port)))
}
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
// reportTLSPosture, so the template can match a related observation to
// the right endpoint.
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
out := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if addr == "" {
continue
}
posture := &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
TLSVersion: v.TLSVersion,
}
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
continue
}
posture.Issues = append(posture.Issues, reportFix{
Severity: sev,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
})
}
out[addr] = posture
}
return out
}

204
checker/rule.go Normal file
View file

@ -0,0 +1,204 @@
package checker
import (
"context"
"fmt"
"net"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Rule() sdk.CheckRule {
return &sipRule{}
}
type sipRule struct{}
func (r *sipRule) Name() string {
return "sip_server"
}
func (r *sipRule) Description() string {
return "Checks DNS resolution, reachability and OPTIONS response of a SIP/VoIP server"
}
func (r *sipRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data SIPData
if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SIP observation: %v", err),
Code: "sip.observation_error",
}}
}
issues := append([]Issue(nil), data.Issues...)
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
issues = append(issues, tlsIssuesFromRelated(related)...)
byEndpoint := map[string][]Issue{}
var zoneIssues []Issue
for _, is := range issues {
if is.Endpoint == "" {
zoneIssues = append(zoneIssues, is)
continue
}
byEndpoint[is.Endpoint] = append(byEndpoint[is.Endpoint], is)
}
var out []sdk.CheckState
out = append(out, zoneState(&data, zoneIssues))
for _, ep := range data.Endpoints {
out = append(out, endpointState(ep, byEndpoint))
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Message: "no SIP endpoint to evaluate",
Code: "sip.no_endpoint",
}}
}
return out
}
// zoneState summarises findings that are not tied to a specific endpoint:
// SRV/NAPTR lookup errors, missing transports, overall coverage.
func zoneState(data *SIPData, zoneIssues []Issue) sdk.CheckState {
var transports []string
if data.Coverage.WorkingUDP {
transports = append(transports, "udp")
}
if data.Coverage.WorkingTCP {
transports = append(transports, "tcp")
}
if data.Coverage.WorkingTLS {
transports = append(transports, "tls")
}
meta := map[string]any{
"working_udp": data.Coverage.WorkingUDP,
"working_tcp": data.Coverage.WorkingTCP,
"working_tls": data.Coverage.WorkingTLS,
"has_ipv4": data.Coverage.HasIPv4,
"has_ipv6": data.Coverage.HasIPv6,
"endpoints": len(data.Endpoints),
"issue_count": len(data.Issues),
}
worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(zoneIssues)
okMsg := fmt.Sprintf("SIP operational (%s, %d endpoints)", strings.Join(transports, "+"), len(data.Endpoints))
return buildCheckState(worst, data.Domain, "sip.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta)
}
// endpointState produces one CheckState per probed endpoint.
func endpointState(ep EndpointProbe, byEndpoint map[string][]Issue) sdk.CheckState {
subject := string(ep.Transport) + "://" + endpointSubject(ep)
meta := map[string]any{
"transport": string(ep.Transport),
"target": ep.Target,
"port": ep.Port,
"address": ep.Address,
"is_ipv6": ep.IsIPv6,
"reachable": ep.Reachable,
}
if ep.TLSVersion != "" {
meta["tls_version"] = ep.TLSVersion
}
if ep.OptionsRawCode != 0 {
meta["options_status"] = ep.OptionsStatus
meta["options_rtt_ms"] = ep.OptionsRTTMs
}
// Match endpoint issues by either the address or the SRV target
// (unresolvable-target issues key on ep.Target).
var epIssues []Issue
epIssues = append(epIssues, byEndpoint[ep.Address]...)
if ep.Target != "" && ep.Target != ep.Address {
epIssues = append(epIssues, byEndpoint[ep.Target]...)
}
worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(epIssues)
okMsg := "OPTIONS " + ep.OptionsStatus
if okMsg == "OPTIONS " {
okMsg = "reachable"
}
return buildCheckState(worst, subject, "sip.endpoint.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta)
}
// endpointSubject prefers the resolved address; falls back to target:port
// when no address was reached (e.g. unresolvable SRV target).
func endpointSubject(ep EndpointProbe) string {
if ep.Address != "" {
return ep.Address
}
if ep.Target != "" {
return net.JoinHostPort(ep.Target, strconv.Itoa(int(ep.Port)))
}
return strconv.Itoa(int(ep.Port))
}
func buildCheckState(worst sdk.Status, subject, okCode, okMsg, firstCrit, firstWarn string, critMsgs, warnMsgs []string, meta map[string]any) sdk.CheckState {
switch worst {
case sdk.StatusOK:
return sdk.CheckState{Status: sdk.StatusOK, Subject: subject, Code: okCode, Message: okMsg, Meta: meta}
case sdk.StatusInfo:
return sdk.CheckState{Status: sdk.StatusInfo, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
case sdk.StatusWarn:
return sdk.CheckState{Status: sdk.StatusWarn, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
default:
return sdk.CheckState{Status: sdk.StatusCrit, Subject: subject, Code: firstCrit, Message: joinTop(critMsgs, 2), Meta: meta}
}
}
// reduceIssues collapses a set of issues into a worst status, first codes
// per severity, and separated message lists.
// sdk.Status values are ordered numerically: OK < Info < Warn < Crit.
func reduceIssues(issues []Issue) (worst sdk.Status, firstCrit, firstWarn string, critMsgs, warnMsgs []string) {
worst = sdk.StatusOK
for _, is := range issues {
switch is.Severity {
case SeverityCrit:
if worst < sdk.StatusCrit {
worst = sdk.StatusCrit
}
if firstCrit == "" {
firstCrit = is.Code
}
critMsgs = append(critMsgs, is.Message)
case SeverityWarn:
if worst < sdk.StatusWarn {
worst = sdk.StatusWarn
}
if firstWarn == "" {
firstWarn = is.Code
}
warnMsgs = append(warnMsgs, is.Message)
case SeverityInfo:
if worst < sdk.StatusInfo {
worst = sdk.StatusInfo
}
if firstWarn == "" {
firstWarn = is.Code
}
warnMsgs = append(warnMsgs, is.Message)
}
}
return
}
func joinTop(msgs []string, n int) string {
if len(msgs) == 0 {
return ""
}
if len(msgs) <= n {
return strings.Join(msgs, "; ")
}
return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n)
}

171
checker/sip_probe.go Normal file
View file

@ -0,0 +1,171 @@
package checker
import (
"bufio"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
)
// sipResponse is the minimal parsed form of a SIP response line + headers
// we need to power the rule and the report.
type sipResponse struct {
StatusCode int
StatusPhrase string
Server string
UserAgent string // some stacks use this instead of Server
Contact string
Allow []string
}
// buildOptionsRequest returns a ready-to-send SIP OPTIONS message for
// the given target / transport pair.
//
// The message is deliberately minimal and RFC 3261 §11-conforming: just
// enough for any SIP stack to recognise it as an OPTIONS ping.
func buildOptionsRequest(target string, port uint16, transport Transport, localAddr string, userAgent string) string {
tUpper := "UDP"
switch transport {
case TransportTCP:
tUpper = "TCP"
case TransportTLS:
tUpper = "TLS"
}
branch := "z9hG4bK-" + randHex(8)
tag := randHex(6)
callID := randHex(12) + "@happydomain.org"
sipScheme := "sip"
if transport == TransportTLS {
sipScheme = "sips"
}
requestURI := fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
if transport == TransportTLS {
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
}
// Via uses the remote transport name; local address is a best-effort
// hint that servers echo back via ;rport. We don't actually listen
// on it — this is a one-shot probe.
lines := []string{
"OPTIONS " + requestURI + " SIP/2.0",
"Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport",
"Max-Forwards: 70",
"From: \"happyDomain\" <sip:check@happydomain.org>;tag=" + tag,
"To: <" + sipScheme + ":ping@" + target + ">",
"Call-ID: " + callID,
"CSeq: 1 OPTIONS",
"User-Agent: " + userAgent,
"Accept: application/sdp",
"Content-Length: 0",
}
return strings.Join(lines, "\r\n") + "\r\n\r\n"
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// parseSIPResponse reads a SIP response from r and extracts the fields
// we care about. It tolerates bodies (reads Content-Length bytes) and
// truncates defensively so a chatty server can't OOM us.
func parseSIPResponse(r io.Reader) (*sipResponse, error) {
br := bufio.NewReaderSize(io.LimitReader(r, 16*1024), 8*1024)
statusLine, err := br.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read status line: %w", err)
}
statusLine = strings.TrimRight(statusLine, "\r\n")
if !strings.HasPrefix(statusLine, "SIP/2.0 ") && !strings.HasPrefix(statusLine, "SIP/2.1 ") {
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
}
_, rest, _ := strings.Cut(statusLine, " ")
parts := strings.SplitN(rest, " ", 2)
if len(parts) < 1 {
return nil, errors.New("malformed status line")
}
code, convErr := strconv.Atoi(strings.TrimSpace(parts[0]))
if convErr != nil {
return nil, fmt.Errorf("non-numeric status code %q", parts[0])
}
phrase := ""
if len(parts) == 2 {
phrase = strings.TrimSpace(parts[1])
}
resp := &sipResponse{StatusCode: code, StatusPhrase: phrase}
for {
line, err := br.ReadString('\n')
if err != nil && err != io.EOF {
return resp, fmt.Errorf("read header: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
// Fold continuation lines per RFC 3261 §7.3.1: a header line
// starting with whitespace continues the previous one. We don't
// need perfect fidelity, so just skip continuations.
if line[0] == ' ' || line[0] == '\t' {
continue
}
colon := strings.IndexByte(line, ':')
if colon < 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(line[:colon]))
value := strings.TrimSpace(line[colon+1:])
switch name {
case "server", "s":
resp.Server = value
case "user-agent":
resp.UserAgent = value
case "contact", "m":
if resp.Contact == "" {
resp.Contact = value
}
case "allow":
// SIP allows multiple Allow headers *or* comma-separated;
// handle both.
for m := range strings.SplitSeq(value, ",") {
m = strings.TrimSpace(strings.ToUpper(m))
if m != "" {
resp.Allow = append(resp.Allow, m)
}
}
}
if err == io.EOF {
break
}
}
return resp, nil
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// localAddrFor returns a best-effort "host:port" describing the local
// side of conn, or "0.0.0.0:0" if conn is nil (UDP probe before dial).
func localAddrFor(conn net.Conn) string {
if conn == nil {
return "0.0.0.0:0"
}
return conn.LocalAddr().String()
}

132
checker/tls_related.go Normal file
View file

@ -0,0 +1,132 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key the downstream TLS checker
// publishes. Same value as the XMPP checker uses, by cross-checker
// convention.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is our permissive view of a TLS checker's payload — we
// read only the fields we need.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Issues []struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
// address returns "host:port" used as our matching key against SIP
// endpoints. Falls back to Endpoint when host/port are unset.
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
// tlsIssuesFromRelated converts downstream TLS observations into Issue
// entries for the SIP aggregation. Structured issues from the TLS
// checker are forwarded with a "sip.tls." prefix so origin is obvious;
// flag-only payloads are summarised into one synthesised issue.
func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
var out []Issue
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if len(v.Issues) > 0 {
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
switch sev {
case SeverityCrit, SeverityWarn, SeverityInfo:
default:
continue
}
code := is.Code
if code == "" {
code = "tls.unknown"
}
out = append(out, Issue{
Code: "sip.tls." + code,
Severity: sev,
Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message),
Fix: is.Fix,
Endpoint: addr,
})
}
continue
}
// Flag-only payload: synthesise a summary issue.
sev := v.worstSeverity()
if sev == "" {
continue
}
msg := "TLS issue reported on " + addr
switch {
case v.ChainValid != nil && !*v.ChainValid:
msg = "Invalid certificate chain on " + addr
case v.HostnameMatch != nil && !*v.HostnameMatch:
msg = "Certificate does not cover the SIP host on " + addr
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
}
out = append(out, Issue{
Code: "sip.tls.probe",
Severity: sev,
Message: msg,
Fix: "See the TLS checker report for details.",
Endpoint: addr,
})
}
return out
}
func (v *tlsProbeView) worstSeverity() string {
if v.ChainValid != nil && !*v.ChainValid {
return SeverityCrit
}
if v.HostnameMatch != nil && !*v.HostnameMatch {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
return SeverityWarn
}
return ""
}

159
checker/types.go Normal file
View file

@ -0,0 +1,159 @@
// Package checker implements the SIP / VoIP server checker for
// happyDomain.
//
// It probes a domain's SIP deployment end-to-end (NAPTR + SRV
// resolution per RFC 3263, reachability on UDP / TCP / TLS, SIP
// OPTIONS ping per RFC 3261) and reports actionable findings.
//
// TLS certificate chain / SAN / expiry / cipher posture is
// intentionally out of scope — the forthcoming checker-tls covers
// that. SIPS endpoints are published as "tls" discovery endpoints
// so checker-tls can probe them; its findings are folded back into
// this report via GetRelated("tls_probes"). See
// happydomain3/docs/checker-discovery-endpoint.md.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
const ObservationKeySIP sdk.ObservationKey = "sip"
// Transport identifies one of the three SIP transports we probe.
type Transport string
const (
TransportUDP Transport = "udp"
TransportTCP Transport = "tcp"
TransportTLS Transport = "tls" // SIPS, direct TLS on connect
)
// SIPData is the full observation stored per run.
type SIPData struct {
Domain string `json:"domain"`
RunAt string `json:"run_at"`
NAPTR []NAPTRRecord `json:"naptr,omitempty"`
SRV SRVLookup `json:"srv"`
Endpoints []EndpointProbe `json:"endpoints"`
Coverage Coverage `json:"coverage"`
Issues []Issue `json:"issues"`
}
// NAPTRRecord is a subset of a NAPTR record enough to reason about
// SIP service resolution.
type NAPTRRecord struct {
Service string `json:"service"` // e.g. "SIP+D2T"
Regexp string `json:"regexp,omitempty"`
Replacement string `json:"replacement,omitempty"`
Flags string `json:"flags,omitempty"`
Order uint16 `json:"order"`
Preference uint16 `json:"preference"`
}
// SRVLookup groups the SRV records found per transport plus per-prefix
// lookup errors and a fallback marker when no SRV was published.
type SRVLookup struct {
UDP []SRVRecord `json:"udp,omitempty"`
TCP []SRVRecord `json:"tcp,omitempty"`
SIPS []SRVRecord `json:"sips,omitempty"`
// Errors per-set, keyed by SRV prefix ("_sip._udp.", …).
Errors map[string]string `json:"errors,omitempty"`
// FallbackProbed is true when no SRV was published and we probed
// the bare domain on 5060 / 5061.
FallbackProbed bool `json:"fallback_probed,omitempty"`
}
// SRVRecord captures one SRV plus the addresses it resolves to.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
}
// EndpointProbe is the result of probing one (transport, target, address).
type EndpointProbe struct {
Transport Transport `json:"transport"`
SRVPrefix string `json:"srv_prefix"`
Target string `json:"target"`
Port uint16 `json:"port"`
Address string `json:"address"`
IsIPv6 bool `json:"is_ipv6,omitempty"`
Reachable bool `json:"reachable"`
ReachableErr string `json:"reachable_err,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
OptionsSent bool `json:"options_sent,omitempty"`
OptionsStatus string `json:"options_status,omitempty"` // e.g. "200 OK"
OptionsRawCode int `json:"options_raw_code,omitempty"`
OptionsRTTMs int64 `json:"options_rtt_ms,omitempty"`
ServerHeader string `json:"server_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
AllowMethods []string `json:"allow_methods,omitempty"`
ContactURI string `json:"contact_uri,omitempty"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// OK reports whether this probe counts as a working SIP endpoint
// (reachable + 2xx answer to OPTIONS).
func (e EndpointProbe) OK() bool {
return e.Reachable && e.OptionsSent && e.OptionsRawCode >= 200 && e.OptionsRawCode < 300
}
// Coverage is a roll-up of the per-endpoint results.
type Coverage struct {
HasIPv4 bool `json:"has_ipv4"`
HasIPv6 bool `json:"has_ipv6"`
WorkingUDP bool `json:"working_udp"`
WorkingTCP bool `json:"working_tcp"`
WorkingTLS bool `json:"working_tls"`
AnyWorking bool `json:"any_working"`
}
// Issue is a structured finding. The rule reduces issues to a worst
// severity; the report renders them as an actionable fix list.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"` // "info" | "warn" | "crit"
Message string `json:"message"`
Fix string `json:"fix,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
// Severities. Match checker-xmpp conventions for cross-checker
// consistency.
const (
SeverityInfo = "info"
SeverityWarn = "warn"
SeverityCrit = "crit"
)
// Issue codes. Keep short, stable, prefixed with "sip." so downstream
// consumers can filter.
const (
CodeNoSRV = "sip.no_srv"
CodeOnlyUDP = "sip.srv.only_udp"
CodeNoTLS = "sip.srv.no_tls"
CodeSRVServfail = "sip.srv.servfail"
CodeSRVTargetUnresolved = "sip.srv.target_unresolvable"
CodeNAPTRServfail = "sip.naptr.servfail"
CodeTCPUnreachable = "sip.tcp.unreachable"
CodeUDPUnreachable = "sip.udp.unreachable"
CodeTLSHandshake = "sip.tls.handshake_failed"
CodeOptionsNoAnswer = "sip.options.no_response"
CodeOptionsNon2xx = "sip.options.non_2xx"
CodeOptionsNoAllow = "sip.options.no_allow"
CodeOptionsNoInvite = "sip.options.no_invite"
CodeFallbackProbed = "sip.fallback_probed"
CodeNoIPv6 = "sip.no_ipv6"
CodeAllDown = "sip.all_endpoints_down"
)