diff --git a/.drone-manifest.yml b/.drone-manifest.yml deleted file mode 100644 index 2322457..0000000 --- a/.drone-manifest.yml +++ /dev/null @@ -1,22 +0,0 @@ -image: happydomain/checker-sip:{{#if build.tag}}{{trimPrefix "v" build.tag}}{{else}}latest{{/if}} -{{#if build.tags}} -tags: -{{#each build.tags}} - - {{this}} -{{/each}} -{{/if}} -manifests: - - image: happydomain/checker-sip:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-amd64 - platform: - architecture: amd64 - os: linux - - image: happydomain/checker-sip:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm64 - platform: - architecture: arm64 - os: linux - variant: v8 - - image: happydomain/checker-sip:{{#if build.tag}}{{trimPrefix "v" build.tag}}-{{/if}}linux-arm - platform: - architecture: arm - os: linux - variant: v7 diff --git a/.drone.yml b/.drone.yml deleted file mode 100644 index 85f9593..0000000 --- a/.drone.yml +++ /dev/null @@ -1,187 +0,0 @@ ---- -kind: pipeline -type: docker -name: build-amd64 - -platform: - os: linux - arch: amd64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-sip - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-sip - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - ---- -kind: pipeline -type: docker -name: build-arm64 - -platform: - os: linux - arch: arm64 - -steps: - - name: checker build - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_BRANCH}-${DRONE_COMMIT}" - CGO_ENABLED: 0 - when: - event: - exclude: - - tag - - - name: checker build tag - image: golang:1-alpine - commands: - - apk add --no-cache git make - - make - environment: - CHECKER_VERSION: "${DRONE_SEMVER}" - CGO_ENABLED: 0 - when: - event: - - tag - - - name: publish on Docker Hub - image: plugins/docker - settings: - repo: happydomain/checker-sip - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_BRANCH}-${DRONE_COMMIT} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - exclude: - - tag - - - name: publish on Docker Hub (tag) - image: plugins/docker - settings: - repo: happydomain/checker-sip - auto_tag: true - auto_tag_suffix: ${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} - dockerfile: Dockerfile - build_args: - - CHECKER_VERSION=${DRONE_SEMVER} - username: - from_secret: docker_username - password: - from_secret: docker_password - when: - event: - - tag - -trigger: - event: - - cron - - push - - tag - ---- -kind: pipeline -name: docker-manifest - -platform: - os: linux - arch: arm64 - -steps: - - name: publish on Docker Hub - image: plugins/manifest - settings: - auto_tag: true - ignore_missing: true - spec: .drone-manifest.yml - username: - from_secret: docker_username - password: - from_secret: docker_password - -trigger: - branch: - exclude: - - renovate/* - event: - - cron - - push - - tag - -depends_on: - - build-amd64 - - build-arm64 diff --git a/Dockerfile b/Dockerfile index 83cdba4..3425c1b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,12 +6,9 @@ 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 . +RUN CGO_ENABLED=0 go build -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/Makefile b/Makefile index 84b01b2..350582a 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION) all: $(CHECKER_NAME) $(CHECKER_NAME): $(CHECKER_SOURCES) - go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -ldflags "$(GO_LDFLAGS)" -o $@ . plugin: $(CHECKER_NAME).so @@ -22,7 +22,7 @@ docker: docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . test: - go test -tags standalone ./... + go test ./... clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md index bfb86e8..be53698 100644 --- a/README.md +++ b/README.md @@ -64,22 +64,9 @@ make plugin 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). + `InsecureSkipVerify: true` — cert posture is the TLS checker's job). 6. SIP `OPTIONS` request with status, headers and `Allow` parsed. -## Rules - -| Code | Description | Severity | -|------------------------------|---------------------------------------------------------------------------------------------------|---------------------| -| `sip.srv_present` | Verifies that `_sip._udp` / `_sip._tcp` / `_sips._tcp` SRV records are published and resolvable. | CRITICAL | -| `sip.transport_diversity` | Verifies that modern SIP transports (TCP, and ideally TLS) are published alongside legacy UDP. | WARNING | -| `sip.srv_targets_resolvable` | Verifies that every SRV target resolves to at least one A or AAAA address. | CRITICAL | -| `sip.endpoint_reachable` | Verifies that every discovered SIP endpoint accepts a connection on its transport. | CRITICAL | -| `sip.options_response` | Verifies that every reachable SIP endpoint answers OPTIONS with a 2xx response. | CRITICAL | -| `sip.options_capabilities` | Reviews the Allow header advertised in OPTIONS replies (INVITE support, Allow presence). | WARNING | -| `sip.ipv6_coverage` | Verifies at least one SIP endpoint is reachable over IPv6. | INFO | -| `sip.tls_quality` | Folds the downstream TLS checker findings (chain, hostname match, expiry) onto the SIP service. | CRITICAL | - ## License Licensed under the **MIT License** (see `LICENSE`). diff --git a/checker/collect.go b/checker/collect.go index b531f17..9636b97 100644 --- a/checker/collect.go +++ b/checker/collect.go @@ -6,8 +6,8 @@ import ( "crypto/tls" "errors" "fmt" - "log" "net" + "slices" "strconv" "strings" "sync" @@ -44,7 +44,7 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any resolver := net.DefaultResolver - // NAPTR lookup, best-effort, failures become an info issue. + // NAPTR lookup — best-effort, failures become an info issue. if naptr, err := lookupNAPTR(ctx, domain); err != nil { data.SRV.Errors["naptr"] = err.Error() } else { @@ -72,9 +72,7 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any data.SRV.Errors[s.prefix] = err.Error() continue } - if recs != nil { - *s.dst = recs - } + *s.dst = recs } // Fallback when no SRV at all: synthesize a single target on each @@ -118,6 +116,9 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any } wg.Wait() + computeCoverage(data) + data.Issues = deriveIssues(data, probeUDP, probeTCP, probeTLS) + return data, nil } @@ -134,9 +135,8 @@ func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]S 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 == "") { + // "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)) @@ -154,44 +154,23 @@ func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]S 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)) - } - } + c.Timeout = 3 * time.Second 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() + in, _, err := c.ExchangeContext(ctx, m, addr) 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 } @@ -305,8 +284,7 @@ func probeEndpoint(ctx context.Context, t Transport, prefix string, rec SRVRecor } func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) { - deadline := time.Now().Add(timeout) - d := net.Dialer{Deadline: deadline} + d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "udp", ep.Address) if err != nil { ep.ReachableErr = err.Error() @@ -315,19 +293,33 @@ func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout } 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])) - }) + 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) { - deadline := time.Now().Add(timeout) - d := net.Dialer{Deadline: deadline} + d := net.Dialer{Timeout: timeout} conn, err := d.DialContext(ctx, "tcp", ep.Address) if err != nil { ep.ReachableErr = err.Error() @@ -335,22 +327,34 @@ func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout return } defer conn.Close() + ep.Reachable = true + _ = conn.SetDeadline(time.Now().Add(timeout)) - runOptionsExchange(ep, conn, deadline, target, ua, TransportTCP, func(c net.Conn) (*sipResponse, error) { - return parseSIPResponse(c) - }) + 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) { - deadline := time.Now().Add(timeout) - d := net.Dialer{Deadline: deadline} + 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 + // 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{ @@ -358,9 +362,6 @@ func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, tim 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() @@ -368,44 +369,24 @@ func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, tim } defer conn.Close() + ep.Reachable = true 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) - }) -} + _ = conn.SetDeadline(time.Now().Add(timeout)) -// 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) + req := buildOptionsRequest(target, ep.Port, TransportTLS, localAddrFor(conn), ua) sent := time.Now() if _, err := conn.Write([]byte(req)); err != nil { - ep.Error = prefix + " write: " + err.Error() + ep.Error = "tls write: " + err.Error() return } ep.OptionsSent = true - resp, err := readResp(conn) + resp, err := parseSIPResponse(conn) if err != nil { - ep.Error = "no " + prefix + " response: " + err.Error() + ep.Error = "no tls response: " + err.Error() return } applyResponse(ep, resp, sent) @@ -420,3 +401,192 @@ func applyResponse(ep *EndpointProbe, resp *sipResponse, sent time.Time) { 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 index d0f18d2..d9dfc75 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -10,7 +10,7 @@ import ( // time by main / plugin. var Version = "built-in" -func (p *sipProvider) Definition() *sdk.CheckerDefinition { +func Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "sip", Name: "SIP / VoIP server", @@ -59,7 +59,7 @@ func (p *sipProvider) Definition() *sdk.CheckerDefinition { }, }, }, - Rules: Rules(), + Rules: []sdk.CheckRule{Rule()}, Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/interactive.go b/checker/interactive.go index b142045..33199eb 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,5 +1,3 @@ -//go:build standalone - package checker import ( @@ -12,20 +10,42 @@ import ( ) // 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. +// check standalone. Collect resolves NAPTR/SRV itself, so a domain name +// is the only required field. 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 []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, + }, } - return fields } // ParseForm turns the submitted form into a CheckerOptions. The SIP diff --git a/checker/issues.go b/checker/issues.go deleted file mode 100644 index 8b0d92a..0000000 --- a/checker/issues.go +++ /dev/null @@ -1,31 +0,0 @@ -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 index bd82c32..2d54d7a 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -15,11 +15,16 @@ 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 . +// 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). // diff --git a/checker/report.go b/checker/report.go index 94fc42e..32e9eee 100644 --- a/checker/report.go +++ b/checker/report.go @@ -5,7 +5,7 @@ import ( "fmt" "html/template" "net" - "slices" + "sort" "strconv" "strings" "time" @@ -98,7 +98,7 @@ var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{ -SIP Report, {{.Domain}} +SIP Report — {{.Domain}}