diff --git a/.drone-manifest.yml b/.drone-manifest.yml new file mode 100644 index 0000000..2322457 --- /dev/null +++ b/.drone-manifest.yml @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..85f9593 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,187 @@ +--- +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 3425c1b..83cdba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,9 +6,12 @@ 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 . +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/Makefile b/Makefile index 350582a..84b01b2 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 -ldflags "$(GO_LDFLAGS)" -o $@ . + go build -tags standalone -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 ./... + go test -tags standalone ./... clean: rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/README.md b/README.md index be53698..bfb86e8 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,22 @@ 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 9636b97..b531f17 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,7 +72,9 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any data.SRV.Errors[s.prefix] = err.Error() continue } - *s.dst = recs + if recs != nil { + *s.dst = recs + } } // Fallback when no SRV at all: synthesize a single target on each @@ -116,9 +118,6 @@ 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 } @@ -135,8 +134,9 @@ 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". - if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 { + // "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)) @@ -154,23 +154,44 @@ 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) - c.Timeout = 3 * time.Second + + // 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(ctx, m, addr) + 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 } @@ -284,7 +305,8 @@ func probeEndpoint(ctx context.Context, t Transport, prefix string, rec SRVRecor } func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) { - d := net.Dialer{Timeout: timeout} + 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() @@ -293,33 +315,19 @@ func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout } 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) + 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) { - d := net.Dialer{Timeout: timeout} + 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() @@ -327,34 +335,22 @@ func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout 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) + 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) { - d := net.Dialer{Timeout: timeout} + 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 + // 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{ @@ -362,6 +358,9 @@ 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() @@ -369,24 +368,44 @@ 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) - _ = conn.SetDeadline(time.Now().Add(timeout)) + runOptionsExchange(ep, conn, deadline, target, ua, TransportTLS, func(c net.Conn) (*sipResponse, error) { + return parseSIPResponse(c) + }) +} - req := buildOptionsRequest(target, ep.Port, TransportTLS, localAddrFor(conn), ua) +// 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 = "tls write: " + err.Error() + ep.Error = prefix + " write: " + err.Error() return } ep.OptionsSent = true - resp, err := parseSIPResponse(conn) + resp, err := readResp(conn) if err != nil { - ep.Error = "no tls response: " + err.Error() + ep.Error = "no " + prefix + " response: " + err.Error() return } applyResponse(ep, resp, sent) @@ -401,192 +420,3 @@ 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 d9dfc75..d0f18d2 100644 --- a/checker/definition.go +++ b/checker/definition.go @@ -10,7 +10,7 @@ import ( // time by main / plugin. var Version = "built-in" -func Definition() *sdk.CheckerDefinition { +func (p *sipProvider) Definition() *sdk.CheckerDefinition { return &sdk.CheckerDefinition{ ID: "sip", Name: "SIP / VoIP server", @@ -59,7 +59,7 @@ func Definition() *sdk.CheckerDefinition { }, }, }, - Rules: []sdk.CheckRule{Rule()}, + Rules: Rules(), Interval: &sdk.CheckIntervalSpec{ Min: 5 * time.Minute, Max: 7 * 24 * time.Hour, diff --git a/checker/interactive.go b/checker/interactive.go index 33199eb..b142045 100644 --- a/checker/interactive.go +++ b/checker/interactive.go @@ -1,3 +1,5 @@ +//go:build standalone + package checker import ( @@ -10,42 +12,20 @@ import ( ) // 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. +// 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 { - 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, - }, + 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 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 index 2d54d7a..bd82c32 100644 --- a/checker/provider.go +++ b/checker/provider.go @@ -15,16 +15,11 @@ 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 32e9eee..94fc42e 100644 --- a/checker/report.go +++ b/checker/report.go @@ -5,7 +5,7 @@ import ( "fmt" "html/template" "net" - "sort" + "slices" "strconv" "strings" "time" @@ -98,7 +98,7 @@ var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{ -SIP Report — {{.Domain}} +SIP Report, {{.Domain}}