Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:42:34 +07:00
commit 612a19c01a
23 changed files with 2402 additions and 0 deletions

422
checker/collect.go Normal file
View file

@ -0,0 +1,422 @@
package checker
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"net"
"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
}
if recs != nil {
*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()
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". The Go resolver normalises to ".",
// but we also accept "" defensively.
if len(records) == 1 && records[0].Port == 0 && (records[0].Target == "." || records[0].Target == "") {
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 {
log.Printf("checker-sip: /etc/resolv.conf unusable (%v), falling back to public resolvers 1.1.1.1/8.8.8.8 for NAPTR lookup of %s", err, domain)
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
// Ask a validating resolver to perform DNSSEC validation and signal
// the result via the AD bit. EDNS0 with DO=1 is required for the
// resolver to honour AD on the response.
m.AuthenticatedData = true
m.SetEdns0(4096, true)
c := new(dns.Client)
// Split the caller's deadline across the configured resolvers so a
// single slow server can't consume the whole context budget. Falls
// back to 3s per server when ctx has no deadline.
perServer := 3 * time.Second
if dl, ok := ctx.Deadline(); ok {
if remaining := time.Until(dl); remaining > 0 {
perServer = remaining / time.Duration(len(cfg.Servers))
}
}
var lastErr error
for _, srv := range cfg.Servers {
qctx, cancel := context.WithTimeout(ctx, perServer)
addr := net.JoinHostPort(srv, cfg.Port)
in, _, err := c.ExchangeContext(qctx, m, addr)
cancel()
if err != nil {
lastErr = err
continue
}
if in.Rcode == dns.RcodeServerFailure {
lastErr = fmt.Errorf("SERVFAIL from %s (possible DNSSEC validation failure)", srv)
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) {
deadline := time.Now().Add(timeout)
d := net.Dialer{Deadline: deadline}
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()
runOptionsExchange(ep, conn, deadline, target, ua, TransportUDP, func(c net.Conn) (*sipResponse, error) {
buf := make([]byte, 8192)
n, err := c.Read(buf)
if err != nil {
return nil, err
}
return parseSIPResponse(bytes.NewReader(buf[:n]))
})
}
func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
d := net.Dialer{Deadline: deadline}
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()
runOptionsExchange(ep, conn, deadline, target, ua, TransportTCP, func(c net.Conn) (*sipResponse, error) {
return parseSIPResponse(c)
})
}
func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
deadline := time.Now().Add(timeout)
d := net.Dialer{Deadline: deadline}
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)
// SetDeadline only fails on a closed/invalid socket; the next handshake
// or I/O call will surface that with a clearer error.
_ = raw.SetDeadline(deadline)
if err := conn.HandshakeContext(ctx); err != nil {
_ = raw.Close()
ep.Error = "tls handshake: " + err.Error()
return
}
defer conn.Close()
state := conn.ConnectionState()
ep.TLSVersion = tls.VersionName(state.Version)
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
runOptionsExchange(ep, conn, deadline, target, ua, TransportTLS, func(c net.Conn) (*sipResponse, error) {
return parseSIPResponse(c)
})
}
// runOptionsExchange performs the post-dial OPTIONS round-trip shared by
// every transport: mark reachable, set the deadline, send the request,
// read the reply via the transport-specific reader, and fold the result
// onto ep. The transport name is used as the prefix for error strings.
func runOptionsExchange(
ep *EndpointProbe,
conn net.Conn,
deadline time.Time,
target, ua string,
t Transport,
readResp func(net.Conn) (*sipResponse, error),
) {
ep.Reachable = true
// SetDeadline only fails on a closed/invalid socket; the next I/O call
// will surface that with a clearer error.
_ = conn.SetDeadline(deadline)
prefix := string(t)
req := buildOptionsRequest(target, ep.Port, t, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = prefix + " write: " + err.Error()
return
}
ep.OptionsSent = true
resp, err := readResp(conn)
if err != nil {
ep.Error = "no " + prefix + " 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
}

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 (p *sipProvider) 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: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
}

56
checker/interactive.go Normal file
View file

@ -0,0 +1,56 @@
//go:build standalone
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 the fields
// come straight from the canonical option documentation in
// Definition() — RunOpts then AdminOpts — keeping the two in lock-step.
func (p *sipProvider) RenderForm() []sdk.CheckerOptionField {
def := p.Definition()
fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.AdminOpts))
fields = append(fields, def.Options.RunOpts...)
fields = append(fields, def.Options.AdminOpts...)
for i := range fields {
if fields[i].Id == "domain" && fields[i].Placeholder == "" {
fields[i].Placeholder = "example.com"
}
}
return fields
}
// 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
}

31
checker/issues.go Normal file
View file

@ -0,0 +1,31 @@
package checker
// computeCoverageView summarises per-transport / per-family reachability
// from the raw endpoint probes. Pure raw-data aggregation (counts /
// booleans), no severity or judgment is applied here; callers feed the
// result back into the report header and (for judgment) into rules.
func computeCoverageView(data *SIPData) Coverage {
var cov Coverage
for _, ep := range data.Endpoints {
if ep.Reachable {
if ep.IsIPv6 {
cov.HasIPv6 = true
} else {
cov.HasIPv4 = true
}
}
if !ep.OK() {
continue
}
switch ep.Transport {
case TransportUDP:
cov.WorkingUDP = true
case TransportTCP:
cov.WorkingTCP = true
case TransportTLS:
cov.WorkingTLS = true
}
}
cov.AnyWorking = cov.WorkingUDP || cov.WorkingTCP || cov.WorkingTLS
return cov
}

46
checker/provider.go Normal file
View file

@ -0,0 +1,46 @@
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
}
// 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
}

586
checker/report.go Normal file
View file

@ -0,0 +1,586 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"net"
"slices"
"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), rctx.States())
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, states []sdk.CheckState) reportData {
tlsByAddr := indexTLSByAddress(related)
// Coverage is a pure aggregation of the raw endpoint probes: it
// powers the header chips and is NOT a judgment.
cov := computeCoverageView(d)
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
HasIPv4: cov.HasIPv4,
HasIPv6: cov.HasIPv6,
WorkingUDP: cov.WorkingUDP,
WorkingTCP: cov.WorkingTCP,
WorkingTLS: cov.WorkingTLS,
HasTLSPosture: len(tlsByAddr) > 0,
}
// Hint/fix section reads ONLY from ctx.States(): Message, Meta["fix"],
// Status. When no states are supplied (data-only rendering path), we
// skip the section entirely and show a neutral status based on the
// raw probe facts.
view.Fixes, view.StatusLabel, view.StatusClass = buildFixesFromStates(states)
view.HasIssues = len(view.Fixes) > 0
if len(states) == 0 {
// Data-only view: no judgment, no hint block. Status reflects
// raw reachability only.
view.HasIssues = false
if len(d.Endpoints) == 0 {
view.StatusLabel = "UNKNOWN"
view.StatusClass = "muted"
} else if cov.AnyWorking {
view.StatusLabel = "OK"
view.StatusClass = "ok"
} else {
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
}
}
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)))
}
// buildFixesFromStates projects the rule-produced CheckStates onto the
// report's hint/fix list. It reads ONLY from sdk.CheckState fields:
// Message, Meta["fix"], Status, Code, Subject. No re-derivation from raw
// observation happens here.
//
// Returns the (sorted) fixes plus the overall status label/class. When
// states is empty, callers skip the hint section entirely; the neutral
// status returned here ("OK") is meant to be overridden by the caller in
// that data-only path.
func buildFixesFromStates(states []sdk.CheckState) ([]reportFix, string, string) {
var fixes []reportFix
worst := sdk.StatusOK
for _, s := range states {
// Only surface states that carry a finding (non-OK, non-Unknown).
switch s.Status {
case sdk.StatusCrit, sdk.StatusWarn, sdk.StatusInfo, sdk.StatusError:
default:
continue
}
sev := statusToSeverity(s.Status)
fix, _ := s.Meta["fix"].(string)
fixes = append(fixes, reportFix{
Severity: sev,
Code: s.Code,
Message: s.Message,
Fix: fix,
Endpoint: s.Subject,
})
if statusRank(s.Status) > statusRank(worst) {
worst = s.Status
}
}
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
slices.SortStableFunc(fixes, func(a, b reportFix) int {
return sevRank(a.Severity) - sevRank(b.Severity)
})
var label, class string
switch {
case len(fixes) == 0:
label, class = "OK", "ok"
case worst == sdk.StatusCrit || worst == sdk.StatusError:
label, class = "FAIL", "fail"
case worst == sdk.StatusWarn:
label, class = "WARN", "warn"
default:
label, class = "INFO", "muted"
}
return fixes, label, class
}
func statusToSeverity(s sdk.Status) string {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
case sdk.StatusInfo:
return SeverityInfo
default:
return SeverityInfo
}
}
func statusRank(s sdk.Status) int {
switch s {
case sdk.StatusCrit, sdk.StatusError:
return 3
case sdk.StatusWarn:
return 2
case sdk.StatusInfo:
return 1
default:
return 0
}
}
// 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
}

98
checker/rule.go Normal file
View file

@ -0,0 +1,98 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the full list of CheckRules the SIP checker exposes. Each
// rule covers a single concern so the UI can show granular pass/fail
// instead of a monolithic aggregate. Shared helpers live at the bottom of
// this file; per-concern logic is in rules_*.go.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&srvPresenceRule{},
&transportDiversityRule{},
&srvTargetsResolvableRule{},
&endpointReachableRule{},
&optionsResponseRule{},
&optionsCapabilitiesRule{},
&ipv6CoverageRule{},
&tlsQualityRule{},
}
}
// ─── Shared helpers ──────────────────────────────────────────────────
// loadSIPData fetches the SIP observation. On error, returns a CheckState
// the caller should emit to short-circuit its rule.
func loadSIPData(ctx context.Context, obs sdk.ObservationGetter) (*SIPData, *sdk.CheckState) {
var data SIPData
if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SIP observation: %v", err),
Code: "sip.observation_error",
}
}
return &data, nil
}
func statesFromIssues(issues []Issue) []sdk.CheckState {
out := make([]sdk.CheckState, 0, len(issues))
for _, is := range issues {
out = append(out, issueToState(is))
}
return out
}
func issueToState(is Issue) sdk.CheckState {
st := sdk.CheckState{
Status: severityToStatus(is.Severity),
Message: is.Message,
Code: is.Code,
Subject: is.Endpoint,
}
if is.Fix != "" {
st.Meta = map[string]any{"fix": is.Fix}
}
return st
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Message: message,
Code: code,
}
}
func notTestedState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: code,
}
}
func severityToStatus(sev string) sdk.Status {
switch sev {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
// wantTLS returns whether the TLS transport was requested for this run.
// Mirrors the default at Collect time (all three transports probed when
// unset).
func wantTLS(opts sdk.CheckerOptions) bool {
return sdk.GetBoolOption(opts, "probeTLS", true)
}

208
checker/rules_endpoint.go Normal file
View file

@ -0,0 +1,208 @@
package checker
import (
"context"
"slices"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// endpointReachableRule verifies that every probed endpoint accepts a
// connection on its declared transport.
type endpointReachableRule struct{}
func (r *endpointReachableRule) Name() string { return "sip.endpoint_reachable" }
func (r *endpointReachableRule) Description() string {
return "Verifies that every discovered SIP endpoint accepts a connection on its transport."
}
func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{notTestedState("sip.endpoint_reachable.skipped", "No endpoint discovered to probe.")}
}
var issues []Issue
for _, ep := range data.Endpoints {
// Skip "unresolvable target", that's the srvTargetsResolvableRule's concern.
if !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target" {
continue
}
if ep.Reachable {
continue
}
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+."
}
}
issues = append(issues, Issue{
Code: code,
Severity: SeverityCrit,
Message: msg,
Fix: fix,
Endpoint: ep.Address,
})
}
// Nothing reachable at all.
cov := computeCoverageView(data)
if len(data.Endpoints) > 0 && !cov.AnyWorking {
issues = append(issues, 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.",
})
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.endpoint_reachable.ok", "All endpoints accepted a connection.")}
}
return statesFromIssues(issues)
}
// optionsResponseRule verifies that every reachable endpoint answers SIP
// OPTIONS with a 2xx response.
type optionsResponseRule struct{}
func (r *optionsResponseRule) Name() string { return "sip.options_response" }
func (r *optionsResponseRule) Description() string {
return "Verifies that every reachable SIP endpoint answers OPTIONS with a 2xx response."
}
func (r *optionsResponseRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{notTestedState("sip.options_response.skipped", "No endpoint discovered to probe.")}
}
var issues []Issue
for _, ep := range data.Endpoints {
switch {
case ep.Reachable && !ep.OptionsSent:
issues = append(issues, 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:
issues = append(issues, 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:
issues = append(issues, 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,
})
}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.options_response.ok", "Every reachable endpoint answered OPTIONS with 2xx.")}
}
return statesFromIssues(issues)
}
// optionsCapabilitiesRule reviews what endpoints advertise in Allow: they
// should at least list INVITE. A missing Allow header at all is surfaced
// too, as a softer informational finding.
type optionsCapabilitiesRule struct{}
func (r *optionsCapabilitiesRule) Name() string { return "sip.options_capabilities" }
func (r *optionsCapabilitiesRule) Description() string {
return "Reviews the Allow header advertised in OPTIONS replies (INVITE support, Allow presence)."
}
func (r *optionsCapabilitiesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{notTestedState("sip.options_capabilities.skipped", "No endpoint discovered to probe.")}
}
var issues []Issue
for _, ep := range data.Endpoints {
if !ep.OK() {
continue
}
switch {
case len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"):
issues = append(issues, 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 len(ep.AllowMethods) == 0:
issues = append(issues, 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,
})
}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.options_capabilities.ok", "Endpoints advertise INVITE in Allow.")}
}
return statesFromIssues(issues)
}
// ipv6CoverageRule verifies that at least one endpoint is reachable over
// IPv6 whenever IPv4 is (i.e. we are not silently IPv4-only).
type ipv6CoverageRule struct{}
func (r *ipv6CoverageRule) Name() string { return "sip.ipv6_coverage" }
func (r *ipv6CoverageRule) Description() string {
return "Verifies at least one SIP endpoint is reachable over IPv6."
}
func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
cov := computeCoverageView(data)
if cov.HasIPv4 && !cov.HasIPv6 {
return statesFromIssues([]Issue{{
Code: CodeNoIPv6,
Severity: SeverityInfo,
Message: "No IPv6 endpoint reachable.",
Fix: "Publish AAAA records for the SRV targets.",
}})
}
return []sdk.CheckState{passState("sip.ipv6_coverage.ok", "At least one SIP endpoint is reachable over IPv6.")}
}

144
checker/rules_srv.go Normal file
View file

@ -0,0 +1,144 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// srvPresenceRule verifies that SIP SRV records are published for the
// domain. It also surfaces NAPTR/SRV lookup errors and the
// "fell back to bare domain" notice, because they are all about SRV
// discovery posture.
type srvPresenceRule struct{}
func (r *srvPresenceRule) Name() string { return "sip.srv_present" }
func (r *srvPresenceRule) Description() string {
return "Verifies that _sip._udp / _sip._tcp / _sips._tcp SRV records are published and resolvable."
}
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var issues []Issue
totalSRV := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
if totalSRV == 0 && data.SRV.FallbackProbed {
issues = append(issues, 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).",
})
}
for prefix, msg := range data.SRV.Errors {
if prefix == "naptr" {
issues = append(issues, 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
}
issues = append(issues, Issue{
Code: CodeSRVServfail,
Severity: SeverityWarn,
Message: "SRV lookup for `" + prefix + data.Domain + "` failed: " + msg,
Fix: "Check zone serial and authoritative NS for this name.",
})
}
if data.SRV.FallbackProbed {
issues = append(issues, 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.",
})
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.srv_present.ok", "SIP SRV records are published and resolved cleanly.")}
}
return statesFromIssues(issues)
}
// transportDiversityRule flags SIP deployments that publish a single
// weak transport (UDP only) or omit the TLS transport entirely.
type transportDiversityRule struct{}
func (r *transportDiversityRule) Name() string { return "sip.transport_diversity" }
func (r *transportDiversityRule) Description() string {
return "Verifies that modern SIP transports (TCP, and ideally TLS) are published alongside legacy UDP."
}
func (r *transportDiversityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var issues []Issue
if len(data.SRV.UDP) > 0 && len(data.SRV.TCP) == 0 && len(data.SRV.SIPS) == 0 && !data.SRV.FallbackProbed {
issues = append(issues, 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 + ".`.",
})
}
if wantTLS(opts) && len(data.SRV.SIPS) == 0 && (len(data.SRV.UDP) > 0 || len(data.SRV.TCP) > 0) && !data.SRV.FallbackProbed {
issues = append(issues, 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.",
})
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.transport_diversity.ok", "A modern transport (TCP/TLS) is published.")}
}
return statesFromIssues(issues)
}
// srvTargetsResolvableRule flags SRV targets that do not resolve to any
// A or AAAA address.
type srvTargetsResolvableRule struct{}
func (r *srvTargetsResolvableRule) Name() string { return "sip.srv_targets_resolvable" }
func (r *srvTargetsResolvableRule) Description() string {
return "Verifies that every SRV target resolves to at least one A or AAAA address."
}
func (r *srvTargetsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSIPData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var issues []Issue
for _, ep := range data.Endpoints {
if !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target" {
issues = append(issues, 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,
})
}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.srv_targets_resolvable.ok", "All SRV targets resolve to at least one address.")}
}
return statesFromIssues(issues)
}

30
checker/rules_tls.go Normal file
View file

@ -0,0 +1,30 @@
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// tlsQualityRule folds findings from a downstream TLS checker (cert
// chain, hostname match, expiry, …) onto SIP rule output, so they appear
// on the SIP service page without users having to go look at the TLS
// checker themselves.
type tlsQualityRule struct{}
func (r *tlsQualityRule) Name() string { return "sip.tls_quality" }
func (r *tlsQualityRule) Description() string {
return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the SIP service."
}
func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
if len(related) == 0 {
return []sdk.CheckState{notTestedState("sip.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")}
}
issues := tlsIssuesFromRelated(related)
if len(issues) == 0 {
return []sdk.CheckState{passState("sip.tls_quality.ok", "Downstream TLS checker reports no issues on the SIP endpoints.")}
}
return statesFromIssues(issues)
}

171
checker/sip_probe.go Normal file
View file

@ -0,0 +1,171 @@
package checker
import (
"bufio"
"crypto/rand"
"encoding/hex"
"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)
// Use an RFC 2606 reserved TLD so the host part of Call-ID never
// resolves to a real domain we don't control.
callID := randHex(12) + "@checker-sip.invalid"
sipScheme := "sip"
if transport == TransportTLS {
sipScheme = "sips"
}
var requestURI string
if transport == TransportTLS {
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
} else {
requestURI = fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
}
// 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@checker-sip.invalid>;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 ") {
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
}
_, rest, _ := strings.Cut(statusLine, " ")
parts := strings.SplitN(rest, " ", 2)
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()
}

130
checker/tls_related.go Normal file
View file

@ -0,0 +1,130 @@
package checker
import (
"encoding/json"
"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 endpointKey(v.Host, 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 ""
}

163
checker/types.go Normal file
View file

@ -0,0 +1,163 @@
// 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. It is a pure record of
// what was observed, no severity or pass/fail judgment is encoded here;
// those are derived by the rules (see issues.go / rules_*.go).
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"`
}
// 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. All fields reflect
// what was *reachable* during this run, not what was merely published in
// DNS: HasIPv6 is true only if at least one AAAA-resolved endpoint
// accepted a connection. A target with AAAA but firewalled off will not
// light up HasIPv6.
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"
)