commit f6f102079f765ff2e8aea20a0f3bafd07578372c 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..3425c1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-sip . + +FROM scratch +COPY --from=builder /checker-sip /checker-sip +EXPOSE 8080 +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..350582a --- /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 -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 ./... + +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..be53698 --- /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..9636b97 --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,592 @@ +package checker + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/miekg/dns" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Collect runs the full SIP probe against a domain. +func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + domain, _ := sdk.GetOption[string](opts, "domain") + domain = strings.TrimSuffix(strings.TrimSpace(domain), ".") + if domain == "" { + return nil, fmt.Errorf("domain is required") + } + + timeoutSecs := sdk.GetFloatOption(opts, "timeout", 5) + if timeoutSecs < 1 { + timeoutSecs = 5 + } + perEndpoint := time.Duration(timeoutSecs * float64(time.Second)) + + probeUDP := sdk.GetBoolOption(opts, "probeUDP", true) + probeTCP := sdk.GetBoolOption(opts, "probeTCP", true) + probeTLS := sdk.GetBoolOption(opts, "probeTLS", true) + + data := &SIPData{ + Domain: domain, + RunAt: time.Now().UTC().Format(time.RFC3339), + SRV: SRVLookup{Errors: map[string]string{}}, + } + + resolver := net.DefaultResolver + + // NAPTR lookup — best-effort, failures become an info issue. + if naptr, err := lookupNAPTR(ctx, domain); err != nil { + data.SRV.Errors["naptr"] = err.Error() + } else { + data.NAPTR = naptr + } + + // SRV lookups (per transport). Errors are kept per-prefix; "not + // found" is normalised to nil by lookupSRV. + type srvSet struct { + prefix string + want bool + dst *[]SRVRecord + } + sets := []srvSet{ + {"_sip._udp.", probeUDP, &data.SRV.UDP}, + {"_sip._tcp.", probeTCP, &data.SRV.TCP}, + {"_sips._tcp.", probeTLS, &data.SRV.SIPS}, + } + for _, s := range sets { + if !s.want { + continue + } + recs, err := lookupSRV(ctx, resolver, s.prefix, domain) + if err != nil { + data.SRV.Errors[s.prefix] = err.Error() + continue + } + *s.dst = recs + } + + // Fallback when no SRV at all: synthesize a single target on each + // enabled transport against the bare domain. + total := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS) + if total == 0 { + data.SRV.FallbackProbed = true + if probeUDP { + data.SRV.UDP = []SRVRecord{{Target: domain, Port: 5060}} + } + if probeTCP { + data.SRV.TCP = []SRVRecord{{Target: domain, Port: 5060}} + } + if probeTLS { + data.SRV.SIPS = []SRVRecord{{Target: domain, Port: 5061}} + } + } + + type transportJob struct { + records []SRVRecord + prefix string + t Transport + } + jobs := []transportJob{ + {data.SRV.UDP, "_sip._udp.", TransportUDP}, + {data.SRV.TCP, "_sip._tcp.", TransportTCP}, + {data.SRV.SIPS, "_sips._tcp.", TransportTLS}, + } + var wg sync.WaitGroup + var mu sync.Mutex + for _, job := range jobs { + wg.Add(1) + go func(j transportJob) { + defer wg.Done() + resolveAllInto(ctx, resolver, j.records) + eps := probeSet(ctx, j.prefix, j.t, j.records, perEndpoint) + mu.Lock() + data.Endpoints = append(data.Endpoints, eps...) + mu.Unlock() + }(job) + } + wg.Wait() + + computeCoverage(data) + data.Issues = deriveIssues(data, probeUDP, probeTCP, probeTLS) + + return data, nil +} + +// ─── DNS ────────────────────────────────────────────────────────────── + +func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) { + name := prefix + dns.Fqdn(domain) + _, records, err := r.LookupSRV(ctx, "", "", name) + if err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return nil, nil + } + return nil, err + } + // RFC 2782 null-target: single "." record with port 0 means + // "service explicitly unavailable". + if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 { + return nil, nil + } + out := make([]SRVRecord, 0, len(records)) + for _, r := range records { + out = append(out, SRVRecord{ + Target: strings.TrimSuffix(r.Target, "."), + Port: r.Port, + Priority: r.Priority, + Weight: r.Weight, + }) + } + return out, nil +} + +func lookupNAPTR(ctx context.Context, domain string) ([]NAPTRRecord, error) { + cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf") + if err != nil || cfg == nil || len(cfg.Servers) == 0 { + cfg = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: "53"} + } + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(domain), dns.TypeNAPTR) + m.RecursionDesired = true + + c := new(dns.Client) + c.Timeout = 3 * time.Second + + var lastErr error + for _, srv := range cfg.Servers { + addr := net.JoinHostPort(srv, cfg.Port) + in, _, err := c.ExchangeContext(ctx, m, addr) + if err != nil { + lastErr = err + continue + } + if in.Rcode == dns.RcodeNameError { + return nil, nil + } + if in.Rcode != dns.RcodeSuccess { + lastErr = fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode]) + continue + } + var out []NAPTRRecord + for _, rr := range in.Answer { + n, ok := rr.(*dns.NAPTR) + if !ok { + continue + } + if !strings.HasPrefix(strings.ToUpper(n.Service), "SIP+") && !strings.HasPrefix(strings.ToUpper(n.Service), "SIPS+") { + continue + } + out = append(out, NAPTRRecord{ + Service: n.Service, + Regexp: n.Regexp, + Replacement: strings.TrimSuffix(n.Replacement, "."), + Flags: n.Flags, + Order: n.Order, + Preference: n.Preference, + }) + } + return out, nil + } + return nil, lastErr +} + +func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) { + for i := range records { + ips, err := r.LookupIPAddr(ctx, records[i].Target) + if err != nil { + continue + } + for _, ip := range ips { + if v4 := ip.IP.To4(); v4 != nil { + records[i].IPv4 = append(records[i].IPv4, v4.String()) + } else { + records[i].IPv6 = append(records[i].IPv6, ip.IP.String()) + } + } + } +} + +// ─── Probing ────────────────────────────────────────────────────────── + +func probeSet(ctx context.Context, prefix string, t Transport, records []SRVRecord, timeout time.Duration) []EndpointProbe { + var eps []EndpointProbe + for _, rec := range records { + addrs := allAddrs(rec) + if len(addrs) == 0 { + eps = append(eps, EndpointProbe{ + Transport: t, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + Error: "no A/AAAA records for target", + }) + continue + } + for _, a := range addrs { + eps = append(eps, probeEndpoint(ctx, t, prefix, rec, a, timeout)) + } + } + return eps +} + +type probeAddr struct { + ip string + isV6 bool +} + +func allAddrs(r SRVRecord) []probeAddr { + out := make([]probeAddr, 0, len(r.IPv4)+len(r.IPv6)) + for _, ip := range r.IPv4 { + out = append(out, probeAddr{ip: ip, isV6: false}) + } + for _, ip := range r.IPv6 { + out = append(out, probeAddr{ip: ip, isV6: true}) + } + return out +} + +func probeEndpoint(ctx context.Context, t Transport, prefix string, rec SRVRecord, a probeAddr, timeout time.Duration) (ep EndpointProbe) { + start := time.Now() + addrPort := net.JoinHostPort(a.ip, strconv.Itoa(int(rec.Port))) + ep = EndpointProbe{ + Transport: t, + SRVPrefix: prefix, + Target: rec.Target, + Port: rec.Port, + Address: addrPort, + IsIPv6: a.isV6, + } + defer func() { ep.ElapsedMS = time.Since(start).Milliseconds() }() + + ua := "happyDomain-checker-sip/" + Version + + switch t { + case TransportUDP: + probeUDP(ctx, &ep, rec.Target, ua, timeout) + case TransportTCP: + probeTCP(ctx, &ep, rec.Target, ua, timeout) + case TransportTLS: + probeTLSConn(ctx, &ep, rec.Target, ua, timeout) + } + + return +} + +func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) { + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "udp", ep.Address) + if err != nil { + ep.ReachableErr = err.Error() + ep.Error = "udp dial: " + err.Error() + return + } + defer conn.Close() + + ep.Reachable = true + _ = conn.SetDeadline(time.Now().Add(timeout)) + + req := buildOptionsRequest(target, ep.Port, TransportUDP, localAddrFor(conn), ua) + sent := time.Now() + if _, err := conn.Write([]byte(req)); err != nil { + ep.Error = "udp write: " + err.Error() + return + } + ep.OptionsSent = true + + buf := make([]byte, 8192) + n, err := conn.Read(buf) + if err != nil { + ep.Error = "no udp response: " + err.Error() + return + } + resp, err := parseSIPResponse(bytes.NewReader(buf[:n])) + if err != nil { + ep.Error = "bad response: " + err.Error() + return + } + applyResponse(ep, resp, sent) +} + +func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) { + d := net.Dialer{Timeout: timeout} + conn, err := d.DialContext(ctx, "tcp", ep.Address) + if err != nil { + ep.ReachableErr = err.Error() + ep.Error = "tcp dial: " + err.Error() + return + } + defer conn.Close() + ep.Reachable = true + _ = conn.SetDeadline(time.Now().Add(timeout)) + + req := buildOptionsRequest(target, ep.Port, TransportTCP, localAddrFor(conn), ua) + sent := time.Now() + if _, err := conn.Write([]byte(req)); err != nil { + ep.Error = "tcp write: " + err.Error() + return + } + ep.OptionsSent = true + + resp, err := parseSIPResponse(conn) + if err != nil { + ep.Error = "no tcp response: " + err.Error() + return + } + applyResponse(ep, resp, sent) +} + +func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) { + d := net.Dialer{Timeout: timeout} + raw, err := d.DialContext(ctx, "tcp", ep.Address) + if err != nil { + ep.ReachableErr = err.Error() + ep.Error = "tcp dial: " + err.Error() + return + } + // We deliberately skip cert verification — checker-tls is the + // source of truth for TLS posture. We just want to reach SIP over + // TLS. + cfg := &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + ServerName: target, + } + conn := tls.Client(raw, cfg) + if err := conn.HandshakeContext(ctx); err != nil { + _ = raw.Close() + ep.Error = "tls handshake: " + err.Error() + return + } + defer conn.Close() + + ep.Reachable = true + state := conn.ConnectionState() + ep.TLSVersion = tls.VersionName(state.Version) + ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite) + + _ = conn.SetDeadline(time.Now().Add(timeout)) + + req := buildOptionsRequest(target, ep.Port, TransportTLS, localAddrFor(conn), ua) + sent := time.Now() + if _, err := conn.Write([]byte(req)); err != nil { + ep.Error = "tls write: " + err.Error() + return + } + ep.OptionsSent = true + + resp, err := parseSIPResponse(conn) + if err != nil { + ep.Error = "no tls response: " + err.Error() + return + } + applyResponse(ep, resp, sent) +} + +func applyResponse(ep *EndpointProbe, resp *sipResponse, sent time.Time) { + ep.OptionsRawCode = resp.StatusCode + ep.OptionsStatus = fmt.Sprintf("%d %s", resp.StatusCode, strings.TrimSpace(resp.StatusPhrase)) + ep.OptionsRTTMs = time.Since(sent).Milliseconds() + ep.ServerHeader = resp.Server + ep.UserAgent = resp.UserAgent + ep.AllowMethods = resp.Allow + ep.ContactURI = resp.Contact +} + +// ─── Coverage + issues ──────────────────────────────────────────────── + +func computeCoverage(data *SIPData) { + for _, ep := range data.Endpoints { + if ep.Reachable { + if ep.IsIPv6 { + data.Coverage.HasIPv6 = true + } else { + data.Coverage.HasIPv4 = true + } + } + if !ep.OK() { + continue + } + switch ep.Transport { + case TransportUDP: + data.Coverage.WorkingUDP = true + case TransportTCP: + data.Coverage.WorkingTCP = true + case TransportTLS: + data.Coverage.WorkingTLS = true + } + } + data.Coverage.AnyWorking = data.Coverage.WorkingUDP || data.Coverage.WorkingTCP || data.Coverage.WorkingTLS +} + +func deriveIssues(data *SIPData, wantUDP, wantTCP, wantTLS bool) []Issue { + var out []Issue + + totalSRV := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS) + + if totalSRV == 0 && data.SRV.FallbackProbed { + out = append(out, Issue{ + Code: CodeNoSRV, + Severity: SeverityCrit, + Message: "No SIP SRV records published for " + data.Domain + ".", + Fix: "Publish `_sip._tcp." + data.Domain + ". SRV 10 10 5060 sip." + data.Domain + ".` (and `_sips._tcp` on 5061 for TLS).", + }) + } + + // "Only UDP" — the most common real-world failure for modern trunks. + if len(data.SRV.UDP) > 0 && len(data.SRV.TCP) == 0 && len(data.SRV.SIPS) == 0 && !data.SRV.FallbackProbed { + out = append(out, Issue{ + Code: CodeOnlyUDP, + Severity: SeverityWarn, + Message: "Only _sip._udp is published; modern SIP trunks (Twilio, OVH, Orange…) prefer TCP/TLS.", + Fix: "Also publish `_sip._tcp." + data.Domain + ".` and ideally `_sips._tcp." + data.Domain + ".`.", + }) + } + + // No TLS at all when TCP exists. + if wantTLS && len(data.SRV.SIPS) == 0 && (len(data.SRV.UDP) > 0 || len(data.SRV.TCP) > 0) && !data.SRV.FallbackProbed { + out = append(out, Issue{ + Code: CodeNoTLS, + Severity: SeverityInfo, + Message: "No _sips._tcp SRV record — SIP signalling runs in the clear.", + Fix: "Publish `_sips._tcp." + data.Domain + ".` on port 5061 and terminate TLS on the server.", + }) + } + + // Per-prefix DNS errors. + for prefix, msg := range data.SRV.Errors { + if prefix == "naptr" { + out = append(out, Issue{ + Code: CodeNAPTRServfail, + Severity: SeverityInfo, + Message: "NAPTR lookup for " + data.Domain + " failed: " + msg, + Fix: "This is optional. If you meant to expose a NAPTR, verify your authoritative resolver answers AUTH/NXDOMAIN cleanly.", + }) + continue + } + out = append(out, Issue{ + Code: CodeSRVServfail, + Severity: SeverityWarn, + Message: "SRV lookup for `" + prefix + data.Domain + "` failed: " + msg, + Fix: "Check zone serial and authoritative NS for this name.", + }) + } + + // Fallback-probed notice. + if data.SRV.FallbackProbed { + out = append(out, Issue{ + Code: CodeFallbackProbed, + Severity: SeverityInfo, + Message: "No SIP SRV records: probing fell back to " + data.Domain + ":5060 / :5061.", + Fix: "Publish the SRV records expected by SIP clients and trunks.", + }) + } + + // Per-endpoint findings. + for _, ep := range data.Endpoints { + switch { + case !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target": + out = append(out, Issue{ + Code: CodeSRVTargetUnresolved, + Severity: SeverityCrit, + Message: "SRV target `" + ep.Target + "` has no A/AAAA.", + Fix: "Add A/AAAA records for `" + ep.Target + "` or change the SRV target.", + Endpoint: ep.Target, + }) + case !ep.Reachable: + code := CodeTCPUnreachable + msg := "TCP port " + strconv.Itoa(int(ep.Port)) + " is closed or filtered on " + ep.Address + "." + fix := "Verify the SIP server is running and the firewall/NAT forwards port " + strconv.Itoa(int(ep.Port)) + "." + switch ep.Transport { + case TransportUDP: + code = CodeUDPUnreachable + msg = "UDP port " + strconv.Itoa(int(ep.Port)) + " refused on " + ep.Address + "." + fix = "Verify the SIP server listens on UDP " + strconv.Itoa(int(ep.Port)) + " and that no stateless firewall drops the reply." + case TransportTLS: + if ep.Error != "" && strings.HasPrefix(ep.Error, "tls handshake") { + code = CodeTLSHandshake + msg = "TLS handshake failed on " + ep.Address + ": " + strings.TrimPrefix(ep.Error, "tls handshake: ") + fix = "Present a valid certificate (chain + SAN including `" + ep.Target + "`) and accept TLS 1.2+." + } + } + out = append(out, Issue{ + Code: code, + Severity: SeverityCrit, + Message: msg, + Fix: fix, + Endpoint: ep.Address, + }) + case ep.Reachable && !ep.OptionsSent: + out = append(out, Issue{ + Code: CodeOptionsNoAnswer, + Severity: SeverityCrit, + Message: ep.Address + " accepted the connection but the probe could not send an OPTIONS: " + ep.Error, + Fix: "Investigate the server's SIP listener.", + Endpoint: ep.Address, + }) + case ep.OptionsSent && ep.OptionsRawCode == 0: + out = append(out, Issue{ + Code: CodeOptionsNoAnswer, + Severity: SeverityCrit, + Message: ep.Address + " is reachable but silent on SIP OPTIONS.", + Fix: "Enable unauthenticated OPTIONS (`handle_options = yes` in Kamailio, `allowguest = yes` in Asterisk/FreeSWITCH) or add the probe source to the ACL.", + Endpoint: ep.Address, + }) + case ep.OptionsRawCode >= 300: + out = append(out, Issue{ + Code: CodeOptionsNon2xx, + Severity: SeverityWarn, + Message: ep.Address + " answered " + ep.OptionsStatus + " to OPTIONS.", + Fix: "Check SIP routing / ACL. Some stacks reject unauthenticated OPTIONS with 403/404.", + Endpoint: ep.Address, + }) + case ep.OK() && len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"): + out = append(out, Issue{ + Code: CodeOptionsNoInvite, + Severity: SeverityWarn, + Message: ep.Address + " answered 2xx but does not advertise INVITE in Allow.", + Fix: "Verify the dialplan / endpoint is allowed to place calls.", + Endpoint: ep.Address, + }) + case ep.OK() && len(ep.AllowMethods) == 0: + out = append(out, Issue{ + Code: CodeOptionsNoAllow, + Severity: SeverityInfo, + Message: ep.Address + " answered 2xx but did not advertise an Allow header.", + Fix: "Configure the SIP stack to include Allow (benign but helps callers discover capabilities).", + Endpoint: ep.Address, + }) + } + } + + // Nothing reachable at all. + if len(data.Endpoints) > 0 && !data.Coverage.AnyWorking { + out = append(out, Issue{ + Code: CodeAllDown, + Severity: SeverityCrit, + Message: "No SIP endpoint answered OPTIONS on any transport.", + Fix: "Verify the SIP server is running and reachable on the published SRV ports.", + }) + } + + // IPv6 coverage. + if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 { + out = append(out, Issue{ + Code: CodeNoIPv6, + Severity: SeverityInfo, + Message: "No IPv6 endpoint reachable.", + Fix: "Publish AAAA records for the SRV targets.", + }) + } + + return out +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..d9dfc75 --- /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 Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "sip", + Name: "SIP / VoIP server", + Version: Version, + Availability: sdk.CheckerAvailability{ + ApplyToService: true, + LimitToServices: []string{"abstract.SIP"}, + }, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeySIP}, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "domain", + Type: "string", + Label: "SIP domain", + AutoFill: sdk.AutoFillDomainName, + Required: true, + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 5, + }, + }, + AdminOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "probeUDP", + Type: "bool", + Label: "Probe _sip._udp", + Default: true, + Description: "Disable if the checker host cannot send UDP.", + }, + { + Id: "probeTCP", + Type: "bool", + Label: "Probe _sip._tcp", + Default: true, + }, + { + Id: "probeTLS", + Type: "bool", + Label: "Probe _sips._tcp (TLS)", + Default: true, + }, + }, + }, + Rules: []sdk.CheckRule{Rule()}, + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Max: 7 * 24 * time.Hour, + Default: 6 * time.Hour, + }, + } +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..33199eb --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,76 @@ +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes the minimal human-facing inputs needed to run a SIP +// check standalone. Collect resolves NAPTR/SRV itself, so a domain name +// is the only required field. +func (p *sipProvider) RenderForm() []sdk.CheckerOptionField { + return []sdk.CheckerOptionField{ + { + Id: "domain", + Type: "string", + Label: "SIP domain", + Placeholder: "example.com", + Required: true, + }, + { + Id: "timeout", + Type: "number", + Label: "Per-endpoint timeout (seconds)", + Default: 5, + }, + { + Id: "probeUDP", + Type: "bool", + Label: "Probe _sip._udp", + Default: true, + }, + { + Id: "probeTCP", + Type: "bool", + Label: "Probe _sip._tcp", + Default: true, + }, + { + Id: "probeTLS", + Type: "bool", + Label: "Probe _sips._tcp (TLS)", + Default: true, + }, + } +} + +// ParseForm turns the submitted form into a CheckerOptions. The SIP +// Collect path performs its own DNS lookups, so there is nothing to +// pre-resolve here. +func (p *sipProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + domain := strings.TrimSpace(r.FormValue("domain")) + if domain == "" { + return nil, errors.New("domain is required") + } + + opts := sdk.CheckerOptions{ + "domain": domain, + "probeUDP": r.FormValue("probeUDP") == "true", + "probeTCP": r.FormValue("probeTCP") == "true", + "probeTLS": r.FormValue("probeTLS") == "true", + } + + if v := strings.TrimSpace(r.FormValue("timeout")); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return nil, errors.New("timeout must be a number") + } + opts["timeout"] = f + } + + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..2d54d7a --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,51 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +func Provider() sdk.ObservationProvider { + return &sipProvider{} +} + +type sipProvider struct{} + +func (p *sipProvider) Key() sdk.ObservationKey { + return ObservationKeySIP +} + +// Definition implements sdk.CheckerDefinitionProvider. +func (p *sipProvider) Definition() *sdk.CheckerDefinition { + return Definition() +} + +// DiscoverEntries implements sdk.DiscoveryPublisher. +// +// It publishes every _sips._tcp SRV target as a tls.endpoint.v1 entry so +// the downstream TLS checker can verify certificate chain, SAN and +// expiry without re-doing the SRV lookup. SNI is set to the SRV target — +// SIPS certificates are expected to cover the server hostname (unlike +// XMPP where it's the bare JID domain). +// +// _sip._udp and _sip._tcp are plaintext with no historical STARTTLS +// convention, so nothing is emitted for them. +func (p *sipProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + d, ok := data.(*SIPData) + if !ok || d == nil { + return nil, nil + } + var out []sdk.DiscoveryEntry + for _, r := range d.SRV.SIPS { + e, err := tlsct.NewEntry(tlsct.TLSEndpoint{ + Host: r.Target, + Port: r.Port, + SNI: r.Target, + }) + if err != nil { + return nil, err + } + out = append(out, e) + } + return out, nil +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..32e9eee --- /dev/null +++ b/checker/report.go @@ -0,0 +1,524 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "net" + "sort" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// ─── View models ──────────────────────────────────────────────────── + +type reportFix struct { + Severity string + Code string + Message string + Fix string + Endpoint string +} + +type reportTLSPosture struct { + CheckedAt time.Time + ChainValid *bool + HostnameMatch *bool + NotAfter time.Time + TLSVersion string + Issues []reportFix +} + +type reportEndpoint struct { + Transport string + TransportTag string // "SIP/UDP", "SIP/TCP", "SIPS/TLS" + SRVPrefix string + Target string + Port uint16 + Address string + IsIPv6 bool + Reachable bool + ReachableErr string + TLSVersion string + TLSCipher string + OptionsSent bool + OptionsStatus string + OptionsRTTMs int64 + ServerHeader string + UserAgent string + AllowMethods []string + ContactURI string + ElapsedMS int64 + Error string + OK bool + StatusLabel string + StatusClass string + + TLSPosture *reportTLSPosture +} + +type reportSRVEntry struct { + Prefix string + Target string + Port uint16 + Priority uint16 + Weight uint16 + IPv4 []string + IPv6 []string +} + +type reportData struct { + Domain string + RunAt string + StatusLabel string + StatusClass string + HasIssues bool + Fixes []reportFix + NAPTR []NAPTRRecord + SRV []reportSRVEntry + FallbackProbed bool + Endpoints []reportEndpoint + HasIPv4 bool + HasIPv6 bool + WorkingUDP bool + WorkingTCP bool + WorkingTLS bool + HasTLSPosture bool +} + +// ─── Template ─────────────────────────────────────────────────────── + +var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{ + "deref": func(b *bool) bool { return b != nil && *b }, +}).Parse(` + + + + +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)) + var buf strings.Builder + if err := reportTpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("render sip report: %w", err) + } + return buf.String(), nil +} + +func buildReportData(d *SIPData, related []sdk.RelatedObservation) reportData { + tlsIssues := tlsIssuesFromRelated(related) + tlsByAddr := indexTLSByAddress(related) + + allIssues := append([]Issue(nil), d.Issues...) + allIssues = append(allIssues, tlsIssues...) + + view := reportData{ + Domain: d.Domain, + RunAt: d.RunAt, + FallbackProbed: d.SRV.FallbackProbed, + HasIPv4: d.Coverage.HasIPv4, + HasIPv6: d.Coverage.HasIPv6, + WorkingUDP: d.Coverage.WorkingUDP, + WorkingTCP: d.Coverage.WorkingTCP, + WorkingTLS: d.Coverage.WorkingTLS, + HasIssues: len(allIssues) > 0, + HasTLSPosture: len(tlsByAddr) > 0, + } + + worst := SeverityInfo + for _, is := range allIssues { + if is.Severity == SeverityCrit { + worst = SeverityCrit + break + } + if is.Severity == SeverityWarn { + worst = SeverityWarn + } + } + switch { + case len(allIssues) == 0: + view.StatusLabel = "OK" + view.StatusClass = "ok" + case worst == SeverityCrit: + view.StatusLabel = "FAIL" + view.StatusClass = "fail" + case worst == SeverityWarn: + view.StatusLabel = "WARN" + view.StatusClass = "warn" + default: + view.StatusLabel = "INFO" + view.StatusClass = "muted" + } + + // Sort fixes crit → warn → info. + sevRank := func(s string) int { + switch s { + case SeverityCrit: + return 0 + case SeverityWarn: + return 1 + default: + return 2 + } + } + sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) + for _, is := range allIssues { + view.Fixes = append(view.Fixes, reportFix{ + Severity: is.Severity, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + Endpoint: is.Endpoint, + }) + } + + view.NAPTR = append(view.NAPTR, d.NAPTR...) + + addSRV := func(prefix string, records []SRVRecord) { + for _, r := range records { + view.SRV = append(view.SRV, reportSRVEntry{ + Prefix: prefix, Target: r.Target, Port: r.Port, + Priority: r.Priority, Weight: r.Weight, + IPv4: r.IPv4, IPv6: r.IPv6, + }) + } + } + addSRV("_sip._udp", d.SRV.UDP) + addSRV("_sip._tcp", d.SRV.TCP) + addSRV("_sips._tcp", d.SRV.SIPS) + + for _, ep := range d.Endpoints { + re := reportEndpoint{ + Transport: string(ep.Transport), + TransportTag: transportTag(ep.Transport), + SRVPrefix: ep.SRVPrefix, + Target: ep.Target, + Port: ep.Port, + Address: ep.Address, + IsIPv6: ep.IsIPv6, + Reachable: ep.Reachable, + ReachableErr: ep.ReachableErr, + TLSVersion: ep.TLSVersion, + TLSCipher: ep.TLSCipher, + OptionsSent: ep.OptionsSent, + OptionsStatus: ep.OptionsStatus, + OptionsRTTMs: ep.OptionsRTTMs, + ServerHeader: ep.ServerHeader, + UserAgent: ep.UserAgent, + AllowMethods: ep.AllowMethods, + ContactURI: ep.ContactURI, + ElapsedMS: ep.ElapsedMS, + Error: ep.Error, + OK: ep.OK(), + } + if re.OK { + re.StatusLabel = "OK" + re.StatusClass = "ok" + } else if ep.Reachable { + re.StatusLabel = "partial" + re.StatusClass = "warn" + } else { + re.StatusLabel = "unreachable" + re.StatusClass = "fail" + } + if meta, hit := tlsByAddr[ep.Address]; hit { + re.TLSPosture = meta + } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { + re.TLSPosture = meta + } + view.Endpoints = append(view.Endpoints, re) + } + + return view +} + +func transportTag(t Transport) string { + switch t { + case TransportUDP: + return "SIP/UDP" + case TransportTCP: + return "SIP/TCP" + case TransportTLS: + return "SIPS/TLS" + } + return string(t) +} + +func endpointKey(host string, port uint16) string { + return net.JoinHostPort(host, strconv.Itoa(int(port))) +} + +// indexTLSByAddress returns a map keyed by "host:port" pointing at a +// reportTLSPosture, so the template can match a related observation to +// the right endpoint. +func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { + out := map[string]*reportTLSPosture{} + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if addr == "" { + continue + } + posture := &reportTLSPosture{ + CheckedAt: r.CollectedAt, + ChainValid: v.ChainValid, + HostnameMatch: v.HostnameMatch, + NotAfter: v.NotAfter, + TLSVersion: v.TLSVersion, + } + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { + continue + } + posture.Issues = append(posture.Issues, reportFix{ + Severity: sev, + Code: is.Code, + Message: is.Message, + Fix: is.Fix, + }) + } + out[addr] = posture + } + return out +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..24fcdfe --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,204 @@ +package checker + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +func Rule() sdk.CheckRule { + return &sipRule{} +} + +type sipRule struct{} + +func (r *sipRule) Name() string { + return "sip_server" +} + +func (r *sipRule) Description() string { + return "Checks DNS resolution, reachability and OPTIONS response of a SIP/VoIP server" +} + +func (r *sipRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + var data SIPData + if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to load SIP observation: %v", err), + Code: "sip.observation_error", + }} + } + + issues := append([]Issue(nil), data.Issues...) + related, _ := obs.GetRelated(ctx, TLSRelatedKey) + issues = append(issues, tlsIssuesFromRelated(related)...) + + byEndpoint := map[string][]Issue{} + var zoneIssues []Issue + for _, is := range issues { + if is.Endpoint == "" { + zoneIssues = append(zoneIssues, is) + continue + } + byEndpoint[is.Endpoint] = append(byEndpoint[is.Endpoint], is) + } + + var out []sdk.CheckState + out = append(out, zoneState(&data, zoneIssues)) + + for _, ep := range data.Endpoints { + out = append(out, endpointState(ep, byEndpoint)) + } + + if len(out) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Message: "no SIP endpoint to evaluate", + Code: "sip.no_endpoint", + }} + } + return out +} + +// zoneState summarises findings that are not tied to a specific endpoint: +// SRV/NAPTR lookup errors, missing transports, overall coverage. +func zoneState(data *SIPData, zoneIssues []Issue) sdk.CheckState { + var transports []string + if data.Coverage.WorkingUDP { + transports = append(transports, "udp") + } + if data.Coverage.WorkingTCP { + transports = append(transports, "tcp") + } + if data.Coverage.WorkingTLS { + transports = append(transports, "tls") + } + + meta := map[string]any{ + "working_udp": data.Coverage.WorkingUDP, + "working_tcp": data.Coverage.WorkingTCP, + "working_tls": data.Coverage.WorkingTLS, + "has_ipv4": data.Coverage.HasIPv4, + "has_ipv6": data.Coverage.HasIPv6, + "endpoints": len(data.Endpoints), + "issue_count": len(data.Issues), + } + + worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(zoneIssues) + + okMsg := fmt.Sprintf("SIP operational (%s, %d endpoints)", strings.Join(transports, "+"), len(data.Endpoints)) + return buildCheckState(worst, data.Domain, "sip.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta) +} + +// endpointState produces one CheckState per probed endpoint. +func endpointState(ep EndpointProbe, byEndpoint map[string][]Issue) sdk.CheckState { + subject := string(ep.Transport) + "://" + endpointSubject(ep) + + meta := map[string]any{ + "transport": string(ep.Transport), + "target": ep.Target, + "port": ep.Port, + "address": ep.Address, + "is_ipv6": ep.IsIPv6, + "reachable": ep.Reachable, + } + if ep.TLSVersion != "" { + meta["tls_version"] = ep.TLSVersion + } + if ep.OptionsRawCode != 0 { + meta["options_status"] = ep.OptionsStatus + meta["options_rtt_ms"] = ep.OptionsRTTMs + } + + // Match endpoint issues by either the address or the SRV target + // (unresolvable-target issues key on ep.Target). + var epIssues []Issue + epIssues = append(epIssues, byEndpoint[ep.Address]...) + if ep.Target != "" && ep.Target != ep.Address { + epIssues = append(epIssues, byEndpoint[ep.Target]...) + } + + worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(epIssues) + + okMsg := "OPTIONS " + ep.OptionsStatus + if okMsg == "OPTIONS " { + okMsg = "reachable" + } + return buildCheckState(worst, subject, "sip.endpoint.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta) +} + +// endpointSubject prefers the resolved address; falls back to target:port +// when no address was reached (e.g. unresolvable SRV target). +func endpointSubject(ep EndpointProbe) string { + if ep.Address != "" { + return ep.Address + } + if ep.Target != "" { + return net.JoinHostPort(ep.Target, strconv.Itoa(int(ep.Port))) + } + return strconv.Itoa(int(ep.Port)) +} + +func buildCheckState(worst sdk.Status, subject, okCode, okMsg, firstCrit, firstWarn string, critMsgs, warnMsgs []string, meta map[string]any) sdk.CheckState { + switch worst { + case sdk.StatusOK: + return sdk.CheckState{Status: sdk.StatusOK, Subject: subject, Code: okCode, Message: okMsg, Meta: meta} + case sdk.StatusInfo: + return sdk.CheckState{Status: sdk.StatusInfo, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta} + case sdk.StatusWarn: + return sdk.CheckState{Status: sdk.StatusWarn, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta} + default: + return sdk.CheckState{Status: sdk.StatusCrit, Subject: subject, Code: firstCrit, Message: joinTop(critMsgs, 2), Meta: meta} + } +} + +// reduceIssues collapses a set of issues into a worst status, first codes +// per severity, and separated message lists. +// sdk.Status values are ordered numerically: OK < Info < Warn < Crit. +func reduceIssues(issues []Issue) (worst sdk.Status, firstCrit, firstWarn string, critMsgs, warnMsgs []string) { + worst = sdk.StatusOK + for _, is := range issues { + switch is.Severity { + case SeverityCrit: + if worst < sdk.StatusCrit { + worst = sdk.StatusCrit + } + if firstCrit == "" { + firstCrit = is.Code + } + critMsgs = append(critMsgs, is.Message) + case SeverityWarn: + if worst < sdk.StatusWarn { + worst = sdk.StatusWarn + } + if firstWarn == "" { + firstWarn = is.Code + } + warnMsgs = append(warnMsgs, is.Message) + case SeverityInfo: + if worst < sdk.StatusInfo { + worst = sdk.StatusInfo + } + if firstWarn == "" { + firstWarn = is.Code + } + warnMsgs = append(warnMsgs, is.Message) + } + } + return +} + +func joinTop(msgs []string, n int) string { + if len(msgs) == 0 { + return "" + } + if len(msgs) <= n { + return strings.Join(msgs, "; ") + } + return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n) +} diff --git a/checker/sip_probe.go b/checker/sip_probe.go new file mode 100644 index 0000000..8ed48aa --- /dev/null +++ b/checker/sip_probe.go @@ -0,0 +1,171 @@ +package checker + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" +) + +// sipResponse is the minimal parsed form of a SIP response line + headers +// we need to power the rule and the report. +type sipResponse struct { + StatusCode int + StatusPhrase string + Server string + UserAgent string // some stacks use this instead of Server + Contact string + Allow []string +} + +// buildOptionsRequest returns a ready-to-send SIP OPTIONS message for +// the given target / transport pair. +// +// The message is deliberately minimal and RFC 3261 §11-conforming: just +// enough for any SIP stack to recognise it as an OPTIONS ping. +func buildOptionsRequest(target string, port uint16, transport Transport, localAddr string, userAgent string) string { + tUpper := "UDP" + switch transport { + case TransportTCP: + tUpper = "TCP" + case TransportTLS: + tUpper = "TLS" + } + + branch := "z9hG4bK-" + randHex(8) + tag := randHex(6) + callID := randHex(12) + "@happydomain.org" + + sipScheme := "sip" + if transport == TransportTLS { + sipScheme = "sips" + } + + requestURI := fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper)) + if transport == TransportTLS { + requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port) + } + + // Via uses the remote transport name; local address is a best-effort + // hint that servers echo back via ;rport. We don't actually listen + // on it — this is a one-shot probe. + lines := []string{ + "OPTIONS " + requestURI + " SIP/2.0", + "Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport", + "Max-Forwards: 70", + "From: \"happyDomain\" ;tag=" + tag, + "To: <" + sipScheme + ":ping@" + target + ">", + "Call-ID: " + callID, + "CSeq: 1 OPTIONS", + "User-Agent: " + userAgent, + "Accept: application/sdp", + "Content-Length: 0", + } + return strings.Join(lines, "\r\n") + "\r\n\r\n" +} + +func randHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// parseSIPResponse reads a SIP response from r and extracts the fields +// we care about. It tolerates bodies (reads Content-Length bytes) and +// truncates defensively so a chatty server can't OOM us. +func parseSIPResponse(r io.Reader) (*sipResponse, error) { + br := bufio.NewReaderSize(io.LimitReader(r, 16*1024), 8*1024) + + statusLine, err := br.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("read status line: %w", err) + } + statusLine = strings.TrimRight(statusLine, "\r\n") + if !strings.HasPrefix(statusLine, "SIP/2.0 ") && !strings.HasPrefix(statusLine, "SIP/2.1 ") { + return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80)) + } + _, rest, _ := strings.Cut(statusLine, " ") + parts := strings.SplitN(rest, " ", 2) + if len(parts) < 1 { + return nil, errors.New("malformed status line") + } + code, convErr := strconv.Atoi(strings.TrimSpace(parts[0])) + if convErr != nil { + return nil, fmt.Errorf("non-numeric status code %q", parts[0]) + } + phrase := "" + if len(parts) == 2 { + phrase = strings.TrimSpace(parts[1]) + } + + resp := &sipResponse{StatusCode: code, StatusPhrase: phrase} + + for { + line, err := br.ReadString('\n') + if err != nil && err != io.EOF { + return resp, fmt.Errorf("read header: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + // Fold continuation lines per RFC 3261 §7.3.1: a header line + // starting with whitespace continues the previous one. We don't + // need perfect fidelity, so just skip continuations. + if line[0] == ' ' || line[0] == '\t' { + continue + } + colon := strings.IndexByte(line, ':') + if colon < 0 { + continue + } + name := strings.ToLower(strings.TrimSpace(line[:colon])) + value := strings.TrimSpace(line[colon+1:]) + + switch name { + case "server", "s": + resp.Server = value + case "user-agent": + resp.UserAgent = value + case "contact", "m": + if resp.Contact == "" { + resp.Contact = value + } + case "allow": + // SIP allows multiple Allow headers *or* comma-separated; + // handle both. + for m := range strings.SplitSeq(value, ",") { + m = strings.TrimSpace(strings.ToUpper(m)) + if m != "" { + resp.Allow = append(resp.Allow, m) + } + } + } + if err == io.EOF { + break + } + } + + return resp, nil +} + +func trunc(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} + +// localAddrFor returns a best-effort "host:port" describing the local +// side of conn, or "0.0.0.0:0" if conn is nil (UDP probe before dial). +func localAddrFor(conn net.Conn) string { + if conn == nil { + return "0.0.0.0:0" + } + return conn.LocalAddr().String() +} diff --git a/checker/tls_related.go b/checker/tls_related.go new file mode 100644 index 0000000..d3bd243 --- /dev/null +++ b/checker/tls_related.go @@ -0,0 +1,132 @@ +package checker + +import ( + "encoding/json" + "net" + "strconv" + "strings" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// TLSRelatedKey is the observation key the downstream TLS checker +// publishes. Same value as the XMPP checker uses, by cross-checker +// convention. +const TLSRelatedKey sdk.ObservationKey = "tls_probes" + +// tlsProbeView is our permissive view of a TLS checker's payload — we +// read only the fields we need. +type tlsProbeView struct { + Host string `json:"host,omitempty"` + Port uint16 `json:"port,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + TLSVersion string `json:"tls_version,omitempty"` + CipherSuite string `json:"cipher_suite,omitempty"` + HostnameMatch *bool `json:"hostname_match,omitempty"` + ChainValid *bool `json:"chain_valid,omitempty"` + NotAfter time.Time `json:"not_after,omitempty"` + Issues []struct { + Code string `json:"code"` + Severity string `json:"severity"` + Message string `json:"message,omitempty"` + Fix string `json:"fix,omitempty"` + } `json:"issues,omitempty"` +} + +// address returns "host:port" used as our matching key against SIP +// endpoints. Falls back to Endpoint when host/port are unset. +func (v *tlsProbeView) address() string { + if v.Endpoint != "" { + return v.Endpoint + } + if v.Host != "" && v.Port != 0 { + return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port))) + } + return "" +} + +func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView { + var v tlsProbeView + if err := json.Unmarshal(r.Data, &v); err != nil { + return nil + } + return &v +} + +// tlsIssuesFromRelated converts downstream TLS observations into Issue +// entries for the SIP aggregation. Structured issues from the TLS +// checker are forwarded with a "sip.tls." prefix so origin is obvious; +// flag-only payloads are summarised into one synthesised issue. +func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue { + var out []Issue + for _, r := range related { + v := parseTLSRelated(r) + if v == nil { + continue + } + addr := v.address() + if len(v.Issues) > 0 { + for _, is := range v.Issues { + sev := strings.ToLower(is.Severity) + switch sev { + case SeverityCrit, SeverityWarn, SeverityInfo: + default: + continue + } + code := is.Code + if code == "" { + code = "tls.unknown" + } + out = append(out, Issue{ + Code: "sip.tls." + code, + Severity: sev, + Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message), + Fix: is.Fix, + Endpoint: addr, + }) + } + continue + } + // Flag-only payload: synthesise a summary issue. + sev := v.worstSeverity() + if sev == "" { + continue + } + msg := "TLS issue reported on " + addr + switch { + case v.ChainValid != nil && !*v.ChainValid: + msg = "Invalid certificate chain on " + addr + case v.HostnameMatch != nil && !*v.HostnameMatch: + msg = "Certificate does not cover the SIP host on " + addr + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0: + msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour: + msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")" + } + out = append(out, Issue{ + Code: "sip.tls.probe", + Severity: sev, + Message: msg, + Fix: "See the TLS checker report for details.", + Endpoint: addr, + }) + } + return out +} + +func (v *tlsProbeView) worstSeverity() string { + if v.ChainValid != nil && !*v.ChainValid { + return SeverityCrit + } + if v.HostnameMatch != nil && !*v.HostnameMatch { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 { + return SeverityCrit + } + if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour { + return SeverityWarn + } + return "" +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..f384a65 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,159 @@ +// Package checker implements the SIP / VoIP server checker for +// happyDomain. +// +// It probes a domain's SIP deployment end-to-end (NAPTR + SRV +// resolution per RFC 3263, reachability on UDP / TCP / TLS, SIP +// OPTIONS ping per RFC 3261) and reports actionable findings. +// +// TLS certificate chain / SAN / expiry / cipher posture is +// intentionally out of scope — the forthcoming checker-tls covers +// that. SIPS endpoints are published as "tls" discovery endpoints +// so checker-tls can probe them; its findings are folded back into +// this report via GetRelated("tls_probes"). See +// happydomain3/docs/checker-discovery-endpoint.md. +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +const ObservationKeySIP sdk.ObservationKey = "sip" + +// Transport identifies one of the three SIP transports we probe. +type Transport string + +const ( + TransportUDP Transport = "udp" + TransportTCP Transport = "tcp" + TransportTLS Transport = "tls" // SIPS, direct TLS on connect +) + +// SIPData is the full observation stored per run. +type SIPData struct { + Domain string `json:"domain"` + RunAt string `json:"run_at"` + NAPTR []NAPTRRecord `json:"naptr,omitempty"` + SRV SRVLookup `json:"srv"` + Endpoints []EndpointProbe `json:"endpoints"` + Coverage Coverage `json:"coverage"` + Issues []Issue `json:"issues"` +} + +// NAPTRRecord is a subset of a NAPTR record enough to reason about +// SIP service resolution. +type NAPTRRecord struct { + Service string `json:"service"` // e.g. "SIP+D2T" + Regexp string `json:"regexp,omitempty"` + Replacement string `json:"replacement,omitempty"` + Flags string `json:"flags,omitempty"` + Order uint16 `json:"order"` + Preference uint16 `json:"preference"` +} + +// SRVLookup groups the SRV records found per transport plus per-prefix +// lookup errors and a fallback marker when no SRV was published. +type SRVLookup struct { + UDP []SRVRecord `json:"udp,omitempty"` + TCP []SRVRecord `json:"tcp,omitempty"` + SIPS []SRVRecord `json:"sips,omitempty"` + // Errors per-set, keyed by SRV prefix ("_sip._udp.", …). + Errors map[string]string `json:"errors,omitempty"` + // FallbackProbed is true when no SRV was published and we probed + // the bare domain on 5060 / 5061. + FallbackProbed bool `json:"fallback_probed,omitempty"` +} + +// SRVRecord captures one SRV plus the addresses it resolves to. +type SRVRecord struct { + Target string `json:"target"` + Port uint16 `json:"port"` + Priority uint16 `json:"priority"` + Weight uint16 `json:"weight"` + IPv4 []string `json:"ipv4,omitempty"` + IPv6 []string `json:"ipv6,omitempty"` +} + +// EndpointProbe is the result of probing one (transport, target, address). +type EndpointProbe struct { + Transport Transport `json:"transport"` + SRVPrefix string `json:"srv_prefix"` + Target string `json:"target"` + Port uint16 `json:"port"` + Address string `json:"address"` + IsIPv6 bool `json:"is_ipv6,omitempty"` + + Reachable bool `json:"reachable"` + ReachableErr string `json:"reachable_err,omitempty"` + + TLSVersion string `json:"tls_version,omitempty"` + TLSCipher string `json:"tls_cipher,omitempty"` + + OptionsSent bool `json:"options_sent,omitempty"` + OptionsStatus string `json:"options_status,omitempty"` // e.g. "200 OK" + OptionsRawCode int `json:"options_raw_code,omitempty"` + OptionsRTTMs int64 `json:"options_rtt_ms,omitempty"` + ServerHeader string `json:"server_header,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + AllowMethods []string `json:"allow_methods,omitempty"` + ContactURI string `json:"contact_uri,omitempty"` + + ElapsedMS int64 `json:"elapsed_ms"` + Error string `json:"error,omitempty"` +} + +// OK reports whether this probe counts as a working SIP endpoint +// (reachable + 2xx answer to OPTIONS). +func (e EndpointProbe) OK() bool { + return e.Reachable && e.OptionsSent && e.OptionsRawCode >= 200 && e.OptionsRawCode < 300 +} + +// Coverage is a roll-up of the per-endpoint results. +type Coverage struct { + HasIPv4 bool `json:"has_ipv4"` + HasIPv6 bool `json:"has_ipv6"` + WorkingUDP bool `json:"working_udp"` + WorkingTCP bool `json:"working_tcp"` + WorkingTLS bool `json:"working_tls"` + AnyWorking bool `json:"any_working"` +} + +// Issue is a structured finding. The rule reduces issues to a worst +// severity; the report renders them as an actionable fix list. +type Issue struct { + Code string `json:"code"` + Severity string `json:"severity"` // "info" | "warn" | "crit" + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Endpoint string `json:"endpoint,omitempty"` +} + +// Severities. Match checker-xmpp conventions for cross-checker +// consistency. +const ( + SeverityInfo = "info" + SeverityWarn = "warn" + SeverityCrit = "crit" +) + +// Issue codes. Keep short, stable, prefixed with "sip." so downstream +// consumers can filter. +const ( + CodeNoSRV = "sip.no_srv" + CodeOnlyUDP = "sip.srv.only_udp" + CodeNoTLS = "sip.srv.no_tls" + CodeSRVServfail = "sip.srv.servfail" + CodeSRVTargetUnresolved = "sip.srv.target_unresolvable" + CodeNAPTRServfail = "sip.naptr.servfail" + + CodeTCPUnreachable = "sip.tcp.unreachable" + CodeUDPUnreachable = "sip.udp.unreachable" + CodeTLSHandshake = "sip.tls.handshake_failed" + CodeOptionsNoAnswer = "sip.options.no_response" + CodeOptionsNon2xx = "sip.options.non_2xx" + CodeOptionsNoAllow = "sip.options.no_allow" + CodeOptionsNoInvite = "sip.options.no_invite" + + CodeFallbackProbed = "sip.fallback_probed" + CodeNoIPv6 = "sip.no_ipv6" + CodeAllDown = "sip.all_endpoints_down" +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a69dd79 --- /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.2.0 + git.happydns.org/checker-tls v0.2.0 + 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..da182fc --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8= +git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM= +git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8= +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..1d764ac --- /dev/null +++ b/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + + sdk "git.happydns.org/checker-sdk-go/checker" + 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 + + server := sdk.NewServer(sip.Provider()) + if err := server.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..3d4cac4 --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,19 @@ +// 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 + return sip.Definition(), sip.Provider(), nil +}