commit 96854b566a52522b870c961419269b0f088f6ed1 Author: Pierre-Olivier Mercier Date: Tue Apr 21 22:42:34 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef4242 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-sip +checker-sip.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..83cdba4 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..84b01b2 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..a5a6816 --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..b4da216 --- /dev/null +++ b/README.md @@ -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 `:5060` / `: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`). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..b531f17 --- /dev/null +++ b/checker/collect.go @@ -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 +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..d0f18d2 --- /dev/null +++ b/checker/definition.go @@ -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, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..b142045 --- /dev/null +++ b/checker/interactive.go @@ -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 +} diff --git a/checker/issues.go b/checker/issues.go new file mode 100644 index 0000000..8b0d92a --- /dev/null +++ b/checker/issues.go @@ -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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..bd82c32 --- /dev/null +++ b/checker/provider.go @@ -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 +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..94fc42e --- /dev/null +++ b/checker/report.go @@ -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(` + + + + +SIP Report, {{.Domain}} + + + + +
+

SIP / VoIP, {{.Domain}}

+ {{.StatusLabel}} +
+ {{if .WorkingUDP}}✓{{else}}✗{{end}} UDP + {{if .WorkingTCP}}✓{{else}}✗{{end}} TCP + {{if .WorkingTLS}}✓{{else}}✗{{end}} TLS + IPv4 + IPv6 +
+ {{if .FallbackProbed}}
No SIP SRV records were published. Probed the bare domain on default ports.
{{end}} + {{if .RunAt}}
Checked {{.RunAt}}
{{end}} +
+ +{{if .HasIssues}} +
+

What to fix

+ {{range .Fixes}} +
+
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+{{end}} + +{{if .NAPTR}} +
+

NAPTR ({{len .NAPTR}})

+ + + {{range .NAPTR}} + + + + + + + + {{end}} +
OrderPrefFlagsServiceReplacement
{{.Order}}{{.Preference}}{{.Flags}}{{.Service}}{{if .Replacement}}{{.Replacement}}{{else}}.{{end}}
+
+{{end}} + +{{if .SRV}} +
+

SRV records ({{len .SRV}})

+ + + {{range .SRV}} + + + + + + + + {{end}} +
PrefixTargetPortPrio/WeightA / AAAA
{{.Prefix}}{{.Target}}{{.Port}}{{.Priority}} / {{.Weight}} + {{range .IPv4}}{{.}} {{end}} + {{range .IPv6}}{{.}} {{end}} +
+
+{{end}} + +{{if .Endpoints}} +
+

Endpoint probes ({{len .Endpoints}})

+ {{range .Endpoints}} + + + {{.TransportTag}} · {{.Address}} + {{.StatusLabel}} + +
+
+
Target
{{.Target}}:{{.Port}}{{if .SRVPrefix}} ({{.SRVPrefix}}){{end}}
+
Reachable
+
+ {{if .Reachable}}✓ connected{{else}}✗ {{if .ReachableErr}}{{.ReachableErr}}{{else}}unreachable{{end}}{{end}} +
+ {{if or .TLSVersion .TLSCipher}} +
TLS
{{.TLSVersion}}{{if and .TLSVersion .TLSCipher}} · {{end}}{{.TLSCipher}}
+ {{end}} + {{if .OptionsSent}} +
OPTIONS
+
+ {{if .OptionsStatus}}{{.OptionsStatus}}{{else}}no reply{{end}} + {{if .OptionsRTTMs}} · {{.OptionsRTTMs}} ms{{end}} +
+ {{end}} + {{if .ServerHeader}}
Server
{{.ServerHeader}}
{{end}} + {{if .UserAgent}}
User-Agent
{{.UserAgent}}
{{end}} + {{if .ContactURI}}
Contact
{{.ContactURI}}
{{end}} + {{if .AllowMethods}} +
Allow
+
{{range .AllowMethods}}{{.}}{{end}}
+ {{end}} + {{if .TLSPosture}} +
TLS posture
+
+ {{if .TLSPosture.ChainValid}} + {{if deref .TLSPosture.ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}} + {{end}} + {{if .TLSPosture.HostnameMatch}} + · {{if deref .TLSPosture.HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}} + {{end}} + {{if not .TLSPosture.NotAfter.IsZero}} + · expires {{.TLSPosture.NotAfter.Format "2006-01-02"}} + {{end}} + {{if not .TLSPosture.CheckedAt.IsZero}} +
TLS checked {{.TLSPosture.CheckedAt.Format "2006-01-02 15:04 MST"}}
+ {{end}} + {{range .TLSPosture.Issues}} +
+
{{.Code}}
+
{{.Message}}
+ {{if .Fix}}
→ {{.Fix}}
{{end}} +
+ {{end}} +
+ {{end}} +
Duration
{{.ElapsedMS}} ms
+ {{if .Error}}
Error
{{.Error}}
{{end}} +
+
+ + {{end}} +
+{{end}} + + + + +`)) + +// ─── 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 +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..7fe3dc2 --- /dev/null +++ b/checker/rule.go @@ -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) +} diff --git a/checker/rules_endpoint.go b/checker/rules_endpoint.go new file mode 100644 index 0000000..d56f137 --- /dev/null +++ b/checker/rules_endpoint.go @@ -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.")} +} diff --git a/checker/rules_srv.go b/checker/rules_srv.go new file mode 100644 index 0000000..2a747cb --- /dev/null +++ b/checker/rules_srv.go @@ -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) +} diff --git a/checker/rules_tls.go b/checker/rules_tls.go new file mode 100644 index 0000000..8df5a61 --- /dev/null +++ b/checker/rules_tls.go @@ -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) +} diff --git a/checker/sip_probe.go b/checker/sip_probe.go new file mode 100644 index 0000000..23820f1 --- /dev/null +++ b/checker/sip_probe.go @@ -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\" ;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() +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..744b99b --- /dev/null +++ b/checker/tls_related.go @@ -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 "" +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..308adf4 --- /dev/null +++ b/checker/types.go @@ -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" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7dcd65b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d4ff867 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..f47e66c --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..8f77b40 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}