Initial commit

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

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-sip
checker-sip.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-sip .
FROM scratch
COPY --from=builder /checker-sip /checker-sip
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-sip", "-healthcheck"]
ENTRYPOINT ["/checker-sip"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-sip
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

25
NOTICE Normal file
View file

@ -0,0 +1,25 @@
checker-sip
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software from the miekg/dns project
(https://github.com/miekg/dns), licensed under the BSD 3-Clause License:
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright (c) 2011 Miek Gieben. All rights reserved.
Copyright (c) 2014 CloudFlare. All rights reserved.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

72
README.md Normal file
View file

@ -0,0 +1,72 @@
# checker-sip
SIP / VoIP server checker for [happyDomain](https://www.happydomain.org/).
Probes a domain's SIP deployment end-to-end from its DNS records:
- **RFC 3263 resolution.** NAPTR → SRV (`_sip._udp`, `_sip._tcp`,
`_sips._tcp`) → A/AAAA.
- **Reachability** on every resolved `target:port` over UDP, TCP and TLS.
- **SIP `OPTIONS` ping.** Raw RFC 3261 request; parses status line,
`Server` / `User-Agent`, `Allow` methods, round-trip time.
- **Discovery entries.** Every `_sips._tcp` target is published as a
`tls.endpoint.v1` `DiscoveryEntry` (via
[`checker-tls/contract`](../checker-tls/README.md)) so the TLS checker
can verify chain, SAN, expiry and cipher posture without re-doing the
SRV lookup. TLS issues reported by the TLS checker are folded back
into this report via `GetRelated("tls_probes")`.
Attaches to the `abstract.SIP` service (SRV records for `_sip._udp`,
`_sip._tcp`, `_sips._tcp`). The happyDomain core registers the abstract
service automatically; no extra configuration is required.
## Usage
### Standalone HTTP server
```bash
make
./checker-sip -listen :8080
```
Exposes the standard happyDomain external checker endpoints (`/health`,
`/definition`, `/collect`, `/evaluate`, `/report`).
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-sip
```
### happyDomain plugin
```bash
make plugin
# produces checker-sip.so, loadable as a Go plugin by happyDomain.
```
## Options
| Scope | Id | Description |
| ----- | ----------- | ---------------------------------------------------------------------- |
| Run | `domain` | SIP domain to test (auto-filled from the service domain). |
| Run | `timeout` | Per-endpoint probe timeout in seconds (default: `5`). |
| Admin | `probeUDP` | Probe `_sip._udp` (default: `true`). Disable if UDP is firewalled. |
| Admin | `probeTCP` | Probe `_sip._tcp` (default: `true`). |
| Admin | `probeTLS` | Probe `_sips._tcp` (default: `true`). |
## Tests performed
1. NAPTR lookup (`SIP+D2U`, `SIP+D2T`, `SIPS+D2T`).
2. SRV lookup for the three transports.
3. Fallback to `<domain>:5060` / `<domain>:5061` when no SRV is
published, with a visible info marker in the report.
4. A/AAAA resolution of every SRV target.
5. TCP connect / UDP send / TLS handshake (with
`InsecureSkipVerify: true`, cert posture is the TLS checker's job).
6. SIP `OPTIONS` request with status, headers and `Allow` parsed.
## License
Licensed under the **MIT License** (see `LICENSE`).

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"
)

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module git.happydns.org/checker-sip
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-tls v0.6.2
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

18
go.sum Normal file
View file

@ -0,0 +1,18 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
"git.happydns.org/checker-sdk-go/checker/server"
sip "git.happydns.org/checker-sip/checker"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
sip.Version = Version
srv := server.New(sip.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

20
plugin/plugin.go Normal file
View file

@ -0,0 +1,20 @@
// Command plugin is the happyDomain plugin entrypoint for the SIP checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
sdk "git.happydns.org/checker-sdk-go/checker"
sip "git.happydns.org/checker-sip/checker"
)
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
sip.Version = Version
prvd := sip.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}