Compare commits
No commits in common. "v0.1.0" and "master" have entirely different histories.
22 changed files with 948 additions and 546 deletions
22
.drone-manifest.yml
Normal file
22
.drone-manifest.yml
Normal file
|
|
@ -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
|
||||||
187
.drone.yml
Normal file
187
.drone.yml
Normal file
|
|
@ -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
|
||||||
|
|
@ -6,9 +6,12 @@ WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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
|
FROM scratch
|
||||||
COPY --from=builder /checker-sip /checker-sip
|
COPY --from=builder /checker-sip /checker-sip
|
||||||
|
USER 65534:65534
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/checker-sip", "-healthcheck"]
|
||||||
ENTRYPOINT ["/checker-sip"]
|
ENTRYPOINT ["/checker-sip"]
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -11,7 +11,7 @@ GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||||
all: $(CHECKER_NAME)
|
all: $(CHECKER_NAME)
|
||||||
|
|
||||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||||
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
|
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||||
|
|
||||||
plugin: $(CHECKER_NAME).so
|
plugin: $(CHECKER_NAME).so
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ docker:
|
||||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test ./...
|
go test -tags standalone ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||||
|
|
|
||||||
15
README.md
15
README.md
|
|
@ -64,9 +64,22 @@ make plugin
|
||||||
published, with a visible info marker in the report.
|
published, with a visible info marker in the report.
|
||||||
4. A/AAAA resolution of every SRV target.
|
4. A/AAAA resolution of every SRV target.
|
||||||
5. TCP connect / UDP send / TLS handshake (with
|
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.
|
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
|
## License
|
||||||
|
|
||||||
Licensed under the **MIT License** (see `LICENSE`).
|
Licensed under the **MIT License** (see `LICENSE`).
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -44,7 +44,7 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
|
||||||
|
|
||||||
resolver := net.DefaultResolver
|
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 {
|
if naptr, err := lookupNAPTR(ctx, domain); err != nil {
|
||||||
data.SRV.Errors["naptr"] = err.Error()
|
data.SRV.Errors["naptr"] = err.Error()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -72,7 +72,9 @@ func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
|
||||||
data.SRV.Errors[s.prefix] = err.Error()
|
data.SRV.Errors[s.prefix] = err.Error()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
*s.dst = recs
|
if recs != nil {
|
||||||
|
*s.dst = recs
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback when no SRV at all: synthesize a single target on each
|
// 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()
|
wg.Wait()
|
||||||
|
|
||||||
computeCoverage(data)
|
|
||||||
data.Issues = deriveIssues(data, probeUDP, probeTCP, probeTLS)
|
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,8 +134,9 @@ func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]S
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// RFC 2782 null-target: single "." record with port 0 means
|
// RFC 2782 null-target: single "." record with port 0 means
|
||||||
// "service explicitly unavailable".
|
// "service explicitly unavailable". The Go resolver normalises to ".",
|
||||||
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
|
// but we also accept "" defensively.
|
||||||
|
if len(records) == 1 && records[0].Port == 0 && (records[0].Target == "." || records[0].Target == "") {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
out := make([]SRVRecord, 0, len(records))
|
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) {
|
func lookupNAPTR(ctx context.Context, domain string) ([]NAPTRRecord, error) {
|
||||||
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
|
||||||
if err != nil || cfg == nil || len(cfg.Servers) == 0 {
|
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"}
|
cfg = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: "53"}
|
||||||
}
|
}
|
||||||
m := new(dns.Msg)
|
m := new(dns.Msg)
|
||||||
m.SetQuestion(dns.Fqdn(domain), dns.TypeNAPTR)
|
m.SetQuestion(dns.Fqdn(domain), dns.TypeNAPTR)
|
||||||
m.RecursionDesired = true
|
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 := 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
|
var lastErr error
|
||||||
for _, srv := range cfg.Servers {
|
for _, srv := range cfg.Servers {
|
||||||
|
qctx, cancel := context.WithTimeout(ctx, perServer)
|
||||||
addr := net.JoinHostPort(srv, cfg.Port)
|
addr := net.JoinHostPort(srv, cfg.Port)
|
||||||
in, _, err := c.ExchangeContext(ctx, m, addr)
|
in, _, err := c.ExchangeContext(qctx, m, addr)
|
||||||
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if in.Rcode == dns.RcodeServerFailure {
|
||||||
|
lastErr = fmt.Errorf("SERVFAIL from %s (possible DNSSEC validation failure)", srv)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if in.Rcode == dns.RcodeNameError {
|
if in.Rcode == dns.RcodeNameError {
|
||||||
return nil, nil
|
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) {
|
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)
|
conn, err := d.DialContext(ctx, "udp", ep.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ep.ReachableErr = err.Error()
|
ep.ReachableErr = err.Error()
|
||||||
|
|
@ -293,33 +315,19 @@ func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
ep.Reachable = true
|
runOptionsExchange(ep, conn, deadline, target, ua, TransportUDP, func(c net.Conn) (*sipResponse, error) {
|
||||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
buf := make([]byte, 8192)
|
||||||
|
n, err := c.Read(buf)
|
||||||
req := buildOptionsRequest(target, ep.Port, TransportUDP, localAddrFor(conn), ua)
|
if err != nil {
|
||||||
sent := time.Now()
|
return nil, err
|
||||||
if _, err := conn.Write([]byte(req)); err != nil {
|
}
|
||||||
ep.Error = "udp write: " + err.Error()
|
return parseSIPResponse(bytes.NewReader(buf[:n]))
|
||||||
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) {
|
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)
|
conn, err := d.DialContext(ctx, "tcp", ep.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ep.ReachableErr = err.Error()
|
ep.ReachableErr = err.Error()
|
||||||
|
|
@ -327,34 +335,22 @@ func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
ep.Reachable = true
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
|
||||||
|
|
||||||
req := buildOptionsRequest(target, ep.Port, TransportTCP, localAddrFor(conn), ua)
|
runOptionsExchange(ep, conn, deadline, target, ua, TransportTCP, func(c net.Conn) (*sipResponse, error) {
|
||||||
sent := time.Now()
|
return parseSIPResponse(c)
|
||||||
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) {
|
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)
|
raw, err := d.DialContext(ctx, "tcp", ep.Address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ep.ReachableErr = err.Error()
|
ep.ReachableErr = err.Error()
|
||||||
ep.Error = "tcp dial: " + err.Error()
|
ep.Error = "tcp dial: " + err.Error()
|
||||||
return
|
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
|
// source of truth for TLS posture. We just want to reach SIP over
|
||||||
// TLS.
|
// TLS.
|
||||||
cfg := &tls.Config{
|
cfg := &tls.Config{
|
||||||
|
|
@ -362,6 +358,9 @@ func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, tim
|
||||||
ServerName: target,
|
ServerName: target,
|
||||||
}
|
}
|
||||||
conn := tls.Client(raw, cfg)
|
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 {
|
if err := conn.HandshakeContext(ctx); err != nil {
|
||||||
_ = raw.Close()
|
_ = raw.Close()
|
||||||
ep.Error = "tls handshake: " + err.Error()
|
ep.Error = "tls handshake: " + err.Error()
|
||||||
|
|
@ -369,24 +368,44 @@ func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, tim
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
ep.Reachable = true
|
|
||||||
state := conn.ConnectionState()
|
state := conn.ConnectionState()
|
||||||
ep.TLSVersion = tls.VersionName(state.Version)
|
ep.TLSVersion = tls.VersionName(state.Version)
|
||||||
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
|
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()
|
sent := time.Now()
|
||||||
if _, err := conn.Write([]byte(req)); err != nil {
|
if _, err := conn.Write([]byte(req)); err != nil {
|
||||||
ep.Error = "tls write: " + err.Error()
|
ep.Error = prefix + " write: " + err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ep.OptionsSent = true
|
ep.OptionsSent = true
|
||||||
|
|
||||||
resp, err := parseSIPResponse(conn)
|
resp, err := readResp(conn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ep.Error = "no tls response: " + err.Error()
|
ep.Error = "no " + prefix + " response: " + err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
applyResponse(ep, resp, sent)
|
applyResponse(ep, resp, sent)
|
||||||
|
|
@ -401,192 +420,3 @@ func applyResponse(ep *EndpointProbe, resp *sipResponse, sent time.Time) {
|
||||||
ep.AllowMethods = resp.Allow
|
ep.AllowMethods = resp.Allow
|
||||||
ep.ContactURI = resp.Contact
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
// time by main / plugin.
|
// time by main / plugin.
|
||||||
var Version = "built-in"
|
var Version = "built-in"
|
||||||
|
|
||||||
func Definition() *sdk.CheckerDefinition {
|
func (p *sipProvider) Definition() *sdk.CheckerDefinition {
|
||||||
return &sdk.CheckerDefinition{
|
return &sdk.CheckerDefinition{
|
||||||
ID: "sip",
|
ID: "sip",
|
||||||
Name: "SIP / VoIP server",
|
Name: "SIP / VoIP server",
|
||||||
|
|
@ -59,7 +59,7 @@ func Definition() *sdk.CheckerDefinition {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Rules: []sdk.CheckRule{Rule()},
|
Rules: Rules(),
|
||||||
Interval: &sdk.CheckIntervalSpec{
|
Interval: &sdk.CheckIntervalSpec{
|
||||||
Min: 5 * time.Minute,
|
Min: 5 * time.Minute,
|
||||||
Max: 7 * 24 * time.Hour,
|
Max: 7 * 24 * time.Hour,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
//go:build standalone
|
||||||
|
|
||||||
package checker
|
package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -10,42 +12,20 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RenderForm exposes the minimal human-facing inputs needed to run a SIP
|
// RenderForm exposes the minimal human-facing inputs needed to run a SIP
|
||||||
// check standalone. Collect resolves NAPTR/SRV itself, so a domain name
|
// check standalone. Collect resolves NAPTR/SRV itself, so the fields
|
||||||
// is the only required field.
|
// come straight from the canonical option documentation in
|
||||||
|
// Definition() (RunOpts then AdminOpts), keeping the two in lock-step.
|
||||||
func (p *sipProvider) RenderForm() []sdk.CheckerOptionField {
|
func (p *sipProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
return []sdk.CheckerOptionField{
|
def := p.Definition()
|
||||||
{
|
fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.AdminOpts))
|
||||||
Id: "domain",
|
fields = append(fields, def.Options.RunOpts...)
|
||||||
Type: "string",
|
fields = append(fields, def.Options.AdminOpts...)
|
||||||
Label: "SIP domain",
|
for i := range fields {
|
||||||
Placeholder: "example.com",
|
if fields[i].Id == "domain" && fields[i].Placeholder == "" {
|
||||||
Required: true,
|
fields[i].Placeholder = "example.com"
|
||||||
},
|
}
|
||||||
{
|
|
||||||
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
|
// ParseForm turns the submitted form into a CheckerOptions. The SIP
|
||||||
|
|
|
||||||
31
checker/issues.go
Normal file
31
checker/issues.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
// computeCoverageView summarises per-transport / per-family reachability
|
||||||
|
// from the raw endpoint probes. Pure raw-data aggregation (counts /
|
||||||
|
// booleans), no severity or judgment is applied here; callers feed the
|
||||||
|
// result back into the report header and (for judgment) into rules.
|
||||||
|
func computeCoverageView(data *SIPData) Coverage {
|
||||||
|
var cov Coverage
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
if ep.Reachable {
|
||||||
|
if ep.IsIPv6 {
|
||||||
|
cov.HasIPv6 = true
|
||||||
|
} else {
|
||||||
|
cov.HasIPv4 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ep.OK() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ep.Transport {
|
||||||
|
case TransportUDP:
|
||||||
|
cov.WorkingUDP = true
|
||||||
|
case TransportTCP:
|
||||||
|
cov.WorkingTCP = true
|
||||||
|
case TransportTLS:
|
||||||
|
cov.WorkingTLS = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cov.AnyWorking = cov.WorkingUDP || cov.WorkingTCP || cov.WorkingTLS
|
||||||
|
return cov
|
||||||
|
}
|
||||||
|
|
@ -15,16 +15,11 @@ func (p *sipProvider) Key() sdk.ObservationKey {
|
||||||
return ObservationKeySIP
|
return ObservationKeySIP
|
||||||
}
|
}
|
||||||
|
|
||||||
// Definition implements sdk.CheckerDefinitionProvider.
|
|
||||||
func (p *sipProvider) Definition() *sdk.CheckerDefinition {
|
|
||||||
return Definition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||||
//
|
//
|
||||||
// It publishes every _sips._tcp SRV target as a tls.endpoint.v1 entry so
|
// It publishes every _sips._tcp SRV target as a tls.endpoint.v1 entry so
|
||||||
// the downstream TLS checker can verify certificate chain, SAN and
|
// 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
|
// SIPS certificates are expected to cover the server hostname (unlike
|
||||||
// XMPP where it's the bare JID domain).
|
// XMPP where it's the bare JID domain).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net"
|
"net"
|
||||||
"sort"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -98,7 +98,7 @@ var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>SIP Report — {{.Domain}}</title>
|
<title>SIP Report, {{.Domain}}</title>
|
||||||
<style>
|
<style>
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
:root {
|
:root {
|
||||||
|
|
@ -188,7 +188,7 @@ th { font-weight: 600; color: #6b7280; }
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="hd">
|
<div class="hd">
|
||||||
<h1>SIP / VoIP — {{.Domain}}</h1>
|
<h1>SIP / VoIP, {{.Domain}}</h1>
|
||||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||||
<div class="chips" style="margin-top:.45rem">
|
<div class="chips" style="margin-top:.45rem">
|
||||||
<span class="chip {{if .WorkingUDP}}ok{{else}}fail{{end}}">{{if .WorkingUDP}}✓{{else}}✗{{end}} UDP</span>
|
<span class="chip {{if .WorkingUDP}}ok{{else}}fail{{end}}">{{if .WorkingUDP}}✓{{else}}✗{{end}} UDP</span>
|
||||||
|
|
@ -225,7 +225,7 @@ th { font-weight: 600; color: #6b7280; }
|
||||||
<td>{{.Preference}}</td>
|
<td>{{.Preference}}</td>
|
||||||
<td><code>{{.Flags}}</code></td>
|
<td><code>{{.Flags}}</code></td>
|
||||||
<td><code>{{.Service}}</code></td>
|
<td><code>{{.Service}}</code></td>
|
||||||
<td>{{if .Replacement}}<code>{{.Replacement}}</code>{{else}}<span class="note">—</span>{{end}}</td>
|
<td>{{if .Replacement}}<code>{{.Replacement}}</code>{{else}}<span class="note">.</span>{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -334,7 +334,7 @@ func (p *sipProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||||
return "", fmt.Errorf("unmarshal sip observation: %w", err)
|
return "", fmt.Errorf("unmarshal sip observation: %w", err)
|
||||||
}
|
}
|
||||||
view := buildReportData(&d, rctx.Related(TLSRelatedKey))
|
view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States())
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||||
return "", fmt.Errorf("render sip report: %w", err)
|
return "", fmt.Errorf("render sip report: %w", err)
|
||||||
|
|
@ -342,72 +342,47 @@ func (p *sipProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildReportData(d *SIPData, related []sdk.RelatedObservation) reportData {
|
func buildReportData(d *SIPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData {
|
||||||
tlsIssues := tlsIssuesFromRelated(related)
|
|
||||||
tlsByAddr := indexTLSByAddress(related)
|
tlsByAddr := indexTLSByAddress(related)
|
||||||
|
|
||||||
allIssues := append([]Issue(nil), d.Issues...)
|
// Coverage is a pure aggregation of the raw endpoint probes: it
|
||||||
allIssues = append(allIssues, tlsIssues...)
|
// powers the header chips and is NOT a judgment.
|
||||||
|
cov := computeCoverageView(d)
|
||||||
|
|
||||||
view := reportData{
|
view := reportData{
|
||||||
Domain: d.Domain,
|
Domain: d.Domain,
|
||||||
RunAt: d.RunAt,
|
RunAt: d.RunAt,
|
||||||
FallbackProbed: d.SRV.FallbackProbed,
|
FallbackProbed: d.SRV.FallbackProbed,
|
||||||
HasIPv4: d.Coverage.HasIPv4,
|
HasIPv4: cov.HasIPv4,
|
||||||
HasIPv6: d.Coverage.HasIPv6,
|
HasIPv6: cov.HasIPv6,
|
||||||
WorkingUDP: d.Coverage.WorkingUDP,
|
WorkingUDP: cov.WorkingUDP,
|
||||||
WorkingTCP: d.Coverage.WorkingTCP,
|
WorkingTCP: cov.WorkingTCP,
|
||||||
WorkingTLS: d.Coverage.WorkingTLS,
|
WorkingTLS: cov.WorkingTLS,
|
||||||
HasIssues: len(allIssues) > 0,
|
|
||||||
HasTLSPosture: len(tlsByAddr) > 0,
|
HasTLSPosture: len(tlsByAddr) > 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
worst := SeverityInfo
|
// Hint/fix section reads ONLY from ctx.States(): Message, Meta["fix"],
|
||||||
for _, is := range allIssues {
|
// Status. When no states are supplied (data-only rendering path), we
|
||||||
if is.Severity == SeverityCrit {
|
// skip the section entirely and show a neutral status based on the
|
||||||
worst = SeverityCrit
|
// raw probe facts.
|
||||||
break
|
view.Fixes, view.StatusLabel, view.StatusClass = buildFixesFromStates(states)
|
||||||
}
|
view.HasIssues = len(view.Fixes) > 0
|
||||||
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.
|
if len(states) == 0 {
|
||||||
sevRank := func(s string) int {
|
// Data-only view: no judgment, no hint block. Status reflects
|
||||||
switch s {
|
// raw reachability only.
|
||||||
case SeverityCrit:
|
view.HasIssues = false
|
||||||
return 0
|
if len(d.Endpoints) == 0 {
|
||||||
case SeverityWarn:
|
view.StatusLabel = "UNKNOWN"
|
||||||
return 1
|
view.StatusClass = "muted"
|
||||||
default:
|
} else if cov.AnyWorking {
|
||||||
return 2
|
view.StatusLabel = "OK"
|
||||||
|
view.StatusClass = "ok"
|
||||||
|
} else {
|
||||||
|
view.StatusLabel = "FAIL"
|
||||||
|
view.StatusClass = "fail"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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...)
|
view.NAPTR = append(view.NAPTR, d.NAPTR...)
|
||||||
|
|
||||||
|
|
@ -485,6 +460,93 @@ func endpointKey(host string, port uint16) string {
|
||||||
return net.JoinHostPort(host, strconv.Itoa(int(port)))
|
return net.JoinHostPort(host, strconv.Itoa(int(port)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildFixesFromStates projects the rule-produced CheckStates onto the
|
||||||
|
// report's hint/fix list. It reads ONLY from sdk.CheckState fields:
|
||||||
|
// Message, Meta["fix"], Status, Code, Subject. No re-derivation from raw
|
||||||
|
// observation happens here.
|
||||||
|
//
|
||||||
|
// Returns the (sorted) fixes plus the overall status label/class. When
|
||||||
|
// states is empty, callers skip the hint section entirely; the neutral
|
||||||
|
// status returned here ("OK") is meant to be overridden by the caller in
|
||||||
|
// that data-only path.
|
||||||
|
func buildFixesFromStates(states []sdk.CheckState) ([]reportFix, string, string) {
|
||||||
|
var fixes []reportFix
|
||||||
|
worst := sdk.StatusOK
|
||||||
|
for _, s := range states {
|
||||||
|
// Only surface states that carry a finding (non-OK, non-Unknown).
|
||||||
|
switch s.Status {
|
||||||
|
case sdk.StatusCrit, sdk.StatusWarn, sdk.StatusInfo, sdk.StatusError:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sev := statusToSeverity(s.Status)
|
||||||
|
fix, _ := s.Meta["fix"].(string)
|
||||||
|
fixes = append(fixes, reportFix{
|
||||||
|
Severity: sev,
|
||||||
|
Code: s.Code,
|
||||||
|
Message: s.Message,
|
||||||
|
Fix: fix,
|
||||||
|
Endpoint: s.Subject,
|
||||||
|
})
|
||||||
|
if statusRank(s.Status) > statusRank(worst) {
|
||||||
|
worst = s.Status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sevRank := func(s string) int {
|
||||||
|
switch s {
|
||||||
|
case SeverityCrit:
|
||||||
|
return 0
|
||||||
|
case SeverityWarn:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(fixes, func(a, b reportFix) int {
|
||||||
|
return sevRank(a.Severity) - sevRank(b.Severity)
|
||||||
|
})
|
||||||
|
|
||||||
|
var label, class string
|
||||||
|
switch {
|
||||||
|
case len(fixes) == 0:
|
||||||
|
label, class = "OK", "ok"
|
||||||
|
case worst == sdk.StatusCrit || worst == sdk.StatusError:
|
||||||
|
label, class = "FAIL", "fail"
|
||||||
|
case worst == sdk.StatusWarn:
|
||||||
|
label, class = "WARN", "warn"
|
||||||
|
default:
|
||||||
|
label, class = "INFO", "muted"
|
||||||
|
}
|
||||||
|
return fixes, label, class
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusToSeverity(s sdk.Status) string {
|
||||||
|
switch s {
|
||||||
|
case sdk.StatusCrit, sdk.StatusError:
|
||||||
|
return SeverityCrit
|
||||||
|
case sdk.StatusWarn:
|
||||||
|
return SeverityWarn
|
||||||
|
case sdk.StatusInfo:
|
||||||
|
return SeverityInfo
|
||||||
|
default:
|
||||||
|
return SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusRank(s sdk.Status) int {
|
||||||
|
switch s {
|
||||||
|
case sdk.StatusCrit, sdk.StatusError:
|
||||||
|
return 3
|
||||||
|
case sdk.StatusWarn:
|
||||||
|
return 2
|
||||||
|
case sdk.StatusInfo:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
|
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
|
||||||
// reportTLSPosture, so the template can match a related observation to
|
// reportTLSPosture, so the template can match a related observation to
|
||||||
// the right endpoint.
|
// the right endpoint.
|
||||||
|
|
|
||||||
224
checker/rule.go
224
checker/rule.go
|
|
@ -3,202 +3,96 @@ package checker
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Rule() sdk.CheckRule {
|
// Rules returns the full list of CheckRules the SIP checker exposes. Each
|
||||||
return &sipRule{}
|
// rule covers a single concern so the UI can show granular pass/fail
|
||||||
|
// instead of a monolithic aggregate. Shared helpers live at the bottom of
|
||||||
|
// this file; per-concern logic is in rules_*.go.
|
||||||
|
func Rules() []sdk.CheckRule {
|
||||||
|
return []sdk.CheckRule{
|
||||||
|
&srvPresenceRule{},
|
||||||
|
&transportDiversityRule{},
|
||||||
|
&srvTargetsResolvableRule{},
|
||||||
|
&endpointReachableRule{},
|
||||||
|
&optionsResponseRule{},
|
||||||
|
&optionsCapabilitiesRule{},
|
||||||
|
&ipv6CoverageRule{},
|
||||||
|
&tlsQualityRule{},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type sipRule struct{}
|
// ─── Shared helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (r *sipRule) Name() string {
|
// loadSIPData fetches the SIP observation. On error, returns a CheckState
|
||||||
return "sip_server"
|
// the caller should emit to short-circuit its rule.
|
||||||
}
|
func loadSIPData(ctx context.Context, obs sdk.ObservationGetter) (*SIPData, *sdk.CheckState) {
|
||||||
|
|
||||||
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
|
var data SIPData
|
||||||
if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil {
|
if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil {
|
||||||
return []sdk.CheckState{{
|
return nil, &sdk.CheckState{
|
||||||
Status: sdk.StatusError,
|
Status: sdk.StatusError,
|
||||||
Message: fmt.Sprintf("failed to load SIP observation: %v", err),
|
Message: fmt.Sprintf("failed to load SIP observation: %v", err),
|
||||||
Code: "sip.observation_error",
|
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)
|
|
||||||
}
|
}
|
||||||
|
return &data, nil
|
||||||
|
}
|
||||||
|
|
||||||
var out []sdk.CheckState
|
func statesFromIssues(issues []Issue) []sdk.CheckState {
|
||||||
out = append(out, zoneState(&data, zoneIssues))
|
out := make([]sdk.CheckState, 0, len(issues))
|
||||||
|
for _, is := range issues {
|
||||||
for _, ep := range data.Endpoints {
|
out = append(out, issueToState(is))
|
||||||
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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// zoneState summarises findings that are not tied to a specific endpoint:
|
func issueToState(is Issue) sdk.CheckState {
|
||||||
// SRV/NAPTR lookup errors, missing transports, overall coverage.
|
st := sdk.CheckState{
|
||||||
func zoneState(data *SIPData, zoneIssues []Issue) sdk.CheckState {
|
Status: severityToStatus(is.Severity),
|
||||||
var transports []string
|
Message: is.Message,
|
||||||
if data.Coverage.WorkingUDP {
|
Code: is.Code,
|
||||||
transports = append(transports, "udp")
|
Subject: is.Endpoint,
|
||||||
}
|
}
|
||||||
if data.Coverage.WorkingTCP {
|
if is.Fix != "" {
|
||||||
transports = append(transports, "tcp")
|
st.Meta = map[string]any{"fix": is.Fix}
|
||||||
}
|
}
|
||||||
if data.Coverage.WorkingTLS {
|
return st
|
||||||
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 passState(code, message string) sdk.CheckState {
|
||||||
func endpointState(ep EndpointProbe, byEndpoint map[string][]Issue) sdk.CheckState {
|
return sdk.CheckState{
|
||||||
subject := string(ep.Transport) + "://" + endpointSubject(ep)
|
Status: sdk.StatusOK,
|
||||||
|
Message: message,
|
||||||
meta := map[string]any{
|
Code: code,
|
||||||
"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
|
func notTestedState(code, message string) sdk.CheckState {
|
||||||
// when no address was reached (e.g. unresolvable SRV target).
|
return sdk.CheckState{
|
||||||
func endpointSubject(ep EndpointProbe) string {
|
Status: sdk.StatusUnknown,
|
||||||
if ep.Address != "" {
|
Message: message,
|
||||||
return ep.Address
|
Code: code,
|
||||||
}
|
}
|
||||||
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 {
|
func severityToStatus(sev string) sdk.Status {
|
||||||
switch worst {
|
switch sev {
|
||||||
case sdk.StatusOK:
|
case SeverityCrit:
|
||||||
return sdk.CheckState{Status: sdk.StatusOK, Subject: subject, Code: okCode, Message: okMsg, Meta: meta}
|
return sdk.StatusCrit
|
||||||
case sdk.StatusInfo:
|
case SeverityWarn:
|
||||||
return sdk.CheckState{Status: sdk.StatusInfo, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
|
return sdk.StatusWarn
|
||||||
case sdk.StatusWarn:
|
case SeverityInfo:
|
||||||
return sdk.CheckState{Status: sdk.StatusWarn, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
|
return sdk.StatusInfo
|
||||||
default:
|
default:
|
||||||
return sdk.CheckState{Status: sdk.StatusCrit, Subject: subject, Code: firstCrit, Message: joinTop(critMsgs, 2), Meta: meta}
|
return sdk.StatusOK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reduceIssues collapses a set of issues into a worst status, first codes
|
// wantTLS returns whether the TLS transport was requested for this run.
|
||||||
// per severity, and separated message lists.
|
// Mirrors the default at Collect time (all three transports probed when
|
||||||
// sdk.Status values are ordered numerically: OK < Info < Warn < Crit.
|
// unset).
|
||||||
func reduceIssues(issues []Issue) (worst sdk.Status, firstCrit, firstWarn string, critMsgs, warnMsgs []string) {
|
func wantTLS(opts sdk.CheckerOptions) bool {
|
||||||
worst = sdk.StatusOK
|
return sdk.GetBoolOption(opts, "probeTLS", true)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
208
checker/rules_endpoint.go
Normal file
208
checker/rules_endpoint.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// endpointReachableRule verifies that every probed endpoint accepts a
|
||||||
|
// connection on its declared transport.
|
||||||
|
type endpointReachableRule struct{}
|
||||||
|
|
||||||
|
func (r *endpointReachableRule) Name() string { return "sip.endpoint_reachable" }
|
||||||
|
func (r *endpointReachableRule) Description() string {
|
||||||
|
return "Verifies that every discovered SIP endpoint accepts a connection on its transport."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if len(data.Endpoints) == 0 {
|
||||||
|
return []sdk.CheckState{notTestedState("sip.endpoint_reachable.skipped", "No endpoint discovered to probe.")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
// Skip "unresolvable target", that's the srvTargetsResolvableRule's concern.
|
||||||
|
if !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ep.Reachable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := CodeTCPUnreachable
|
||||||
|
msg := "TCP port " + strconv.Itoa(int(ep.Port)) + " is closed or filtered on " + ep.Address + "."
|
||||||
|
fix := "Verify the SIP server is running and the firewall/NAT forwards port " + strconv.Itoa(int(ep.Port)) + "."
|
||||||
|
switch ep.Transport {
|
||||||
|
case TransportUDP:
|
||||||
|
code = CodeUDPUnreachable
|
||||||
|
msg = "UDP port " + strconv.Itoa(int(ep.Port)) + " refused on " + ep.Address + "."
|
||||||
|
fix = "Verify the SIP server listens on UDP " + strconv.Itoa(int(ep.Port)) + " and that no stateless firewall drops the reply."
|
||||||
|
case TransportTLS:
|
||||||
|
if ep.Error != "" && strings.HasPrefix(ep.Error, "tls handshake") {
|
||||||
|
code = CodeTLSHandshake
|
||||||
|
msg = "TLS handshake failed on " + ep.Address + ": " + strings.TrimPrefix(ep.Error, "tls handshake: ")
|
||||||
|
fix = "Present a valid certificate (chain + SAN including `" + ep.Target + "`) and accept TLS 1.2+."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: code,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: msg,
|
||||||
|
Fix: fix,
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing reachable at all.
|
||||||
|
cov := computeCoverageView(data)
|
||||||
|
if len(data.Endpoints) > 0 && !cov.AnyWorking {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeAllDown,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "No SIP endpoint answered OPTIONS on any transport.",
|
||||||
|
Fix: "Verify the SIP server is running and reachable on the published SRV ports.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.endpoint_reachable.ok", "All endpoints accepted a connection.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionsResponseRule verifies that every reachable endpoint answers SIP
|
||||||
|
// OPTIONS with a 2xx response.
|
||||||
|
type optionsResponseRule struct{}
|
||||||
|
|
||||||
|
func (r *optionsResponseRule) Name() string { return "sip.options_response" }
|
||||||
|
func (r *optionsResponseRule) Description() string {
|
||||||
|
return "Verifies that every reachable SIP endpoint answers OPTIONS with a 2xx response."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *optionsResponseRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if len(data.Endpoints) == 0 {
|
||||||
|
return []sdk.CheckState{notTestedState("sip.options_response.skipped", "No endpoint discovered to probe.")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
switch {
|
||||||
|
case ep.Reachable && !ep.OptionsSent:
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOptionsNoAnswer,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: ep.Address + " accepted the connection but the probe could not send an OPTIONS: " + ep.Error,
|
||||||
|
Fix: "Investigate the server's SIP listener.",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
case ep.OptionsSent && ep.OptionsRawCode == 0:
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOptionsNoAnswer,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: ep.Address + " is reachable but silent on SIP OPTIONS.",
|
||||||
|
Fix: "Enable unauthenticated OPTIONS (`handle_options = yes` in Kamailio, `allowguest = yes` in Asterisk/FreeSWITCH) or add the probe source to the ACL.",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
case ep.OptionsRawCode >= 300:
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOptionsNon2xx,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: ep.Address + " answered " + ep.OptionsStatus + " to OPTIONS.",
|
||||||
|
Fix: "Check SIP routing / ACL. Some stacks reject unauthenticated OPTIONS with 403/404.",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.options_response.ok", "Every reachable endpoint answered OPTIONS with 2xx.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionsCapabilitiesRule reviews what endpoints advertise in Allow: they
|
||||||
|
// should at least list INVITE. A missing Allow header at all is surfaced
|
||||||
|
// too, as a softer informational finding.
|
||||||
|
type optionsCapabilitiesRule struct{}
|
||||||
|
|
||||||
|
func (r *optionsCapabilitiesRule) Name() string { return "sip.options_capabilities" }
|
||||||
|
func (r *optionsCapabilitiesRule) Description() string {
|
||||||
|
return "Reviews the Allow header advertised in OPTIONS replies (INVITE support, Allow presence)."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *optionsCapabilitiesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
if len(data.Endpoints) == 0 {
|
||||||
|
return []sdk.CheckState{notTestedState("sip.options_capabilities.skipped", "No endpoint discovered to probe.")}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
if !ep.OK() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"):
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOptionsNoInvite,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: ep.Address + " answered 2xx but does not advertise INVITE in Allow.",
|
||||||
|
Fix: "Verify the dialplan / endpoint is allowed to place calls.",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
case len(ep.AllowMethods) == 0:
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOptionsNoAllow,
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: ep.Address + " answered 2xx but did not advertise an Allow header.",
|
||||||
|
Fix: "Configure the SIP stack to include Allow (benign but helps callers discover capabilities).",
|
||||||
|
Endpoint: ep.Address,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.options_capabilities.ok", "Endpoints advertise INVITE in Allow.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipv6CoverageRule verifies that at least one endpoint is reachable over
|
||||||
|
// IPv6 whenever IPv4 is (i.e. we are not silently IPv4-only).
|
||||||
|
type ipv6CoverageRule struct{}
|
||||||
|
|
||||||
|
func (r *ipv6CoverageRule) Name() string { return "sip.ipv6_coverage" }
|
||||||
|
func (r *ipv6CoverageRule) Description() string {
|
||||||
|
return "Verifies at least one SIP endpoint is reachable over IPv6."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
cov := computeCoverageView(data)
|
||||||
|
if cov.HasIPv4 && !cov.HasIPv6 {
|
||||||
|
return statesFromIssues([]Issue{{
|
||||||
|
Code: CodeNoIPv6,
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: "No IPv6 endpoint reachable.",
|
||||||
|
Fix: "Publish AAAA records for the SRV targets.",
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
return []sdk.CheckState{passState("sip.ipv6_coverage.ok", "At least one SIP endpoint is reachable over IPv6.")}
|
||||||
|
}
|
||||||
144
checker/rules_srv.go
Normal file
144
checker/rules_srv.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// srvPresenceRule verifies that SIP SRV records are published for the
|
||||||
|
// domain. It also surfaces NAPTR/SRV lookup errors and the
|
||||||
|
// "fell back to bare domain" notice, because they are all about SRV
|
||||||
|
// discovery posture.
|
||||||
|
type srvPresenceRule struct{}
|
||||||
|
|
||||||
|
func (r *srvPresenceRule) Name() string { return "sip.srv_present" }
|
||||||
|
func (r *srvPresenceRule) Description() string {
|
||||||
|
return "Verifies that _sip._udp / _sip._tcp / _sips._tcp SRV records are published and resolvable."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *srvPresenceRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
totalSRV := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
|
||||||
|
|
||||||
|
if totalSRV == 0 && data.SRV.FallbackProbed {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeNoSRV,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "No SIP SRV records published for " + data.Domain + ".",
|
||||||
|
Fix: "Publish `_sip._tcp." + data.Domain + ". SRV 10 10 5060 sip." + data.Domain + ".` (and `_sips._tcp` on 5061 for TLS).",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for prefix, msg := range data.SRV.Errors {
|
||||||
|
if prefix == "naptr" {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeNAPTRServfail,
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: "NAPTR lookup for " + data.Domain + " failed: " + msg,
|
||||||
|
Fix: "This is optional. If you meant to expose a NAPTR, verify your authoritative resolver answers AUTH/NXDOMAIN cleanly.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeSRVServfail,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "SRV lookup for `" + prefix + data.Domain + "` failed: " + msg,
|
||||||
|
Fix: "Check zone serial and authoritative NS for this name.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.SRV.FallbackProbed {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeFallbackProbed,
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: "No SIP SRV records: probing fell back to " + data.Domain + ":5060 / :5061.",
|
||||||
|
Fix: "Publish the SRV records expected by SIP clients and trunks.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.srv_present.ok", "SIP SRV records are published and resolved cleanly.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// transportDiversityRule flags SIP deployments that publish a single
|
||||||
|
// weak transport (UDP only) or omit the TLS transport entirely.
|
||||||
|
type transportDiversityRule struct{}
|
||||||
|
|
||||||
|
func (r *transportDiversityRule) Name() string { return "sip.transport_diversity" }
|
||||||
|
func (r *transportDiversityRule) Description() string {
|
||||||
|
return "Verifies that modern SIP transports (TCP, and ideally TLS) are published alongside legacy UDP."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *transportDiversityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
|
||||||
|
if len(data.SRV.UDP) > 0 && len(data.SRV.TCP) == 0 && len(data.SRV.SIPS) == 0 && !data.SRV.FallbackProbed {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeOnlyUDP,
|
||||||
|
Severity: SeverityWarn,
|
||||||
|
Message: "Only _sip._udp is published; modern SIP trunks (Twilio, OVH, Orange…) prefer TCP/TLS.",
|
||||||
|
Fix: "Also publish `_sip._tcp." + data.Domain + ".` and ideally `_sips._tcp." + data.Domain + ".`.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantTLS(opts) && len(data.SRV.SIPS) == 0 && (len(data.SRV.UDP) > 0 || len(data.SRV.TCP) > 0) && !data.SRV.FallbackProbed {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeNoTLS,
|
||||||
|
Severity: SeverityInfo,
|
||||||
|
Message: "No _sips._tcp SRV record, SIP signalling runs in the clear.",
|
||||||
|
Fix: "Publish `_sips._tcp." + data.Domain + ".` on port 5061 and terminate TLS on the server.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.transport_diversity.ok", "A modern transport (TCP/TLS) is published.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// srvTargetsResolvableRule flags SRV targets that do not resolve to any
|
||||||
|
// A or AAAA address.
|
||||||
|
type srvTargetsResolvableRule struct{}
|
||||||
|
|
||||||
|
func (r *srvTargetsResolvableRule) Name() string { return "sip.srv_targets_resolvable" }
|
||||||
|
func (r *srvTargetsResolvableRule) Description() string {
|
||||||
|
return "Verifies that every SRV target resolves to at least one A or AAAA address."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *srvTargetsResolvableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
data, errSt := loadSIPData(ctx, obs)
|
||||||
|
if errSt != nil {
|
||||||
|
return []sdk.CheckState{*errSt}
|
||||||
|
}
|
||||||
|
|
||||||
|
var issues []Issue
|
||||||
|
for _, ep := range data.Endpoints {
|
||||||
|
if !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target" {
|
||||||
|
issues = append(issues, Issue{
|
||||||
|
Code: CodeSRVTargetUnresolved,
|
||||||
|
Severity: SeverityCrit,
|
||||||
|
Message: "SRV target `" + ep.Target + "` has no A/AAAA.",
|
||||||
|
Fix: "Add A/AAAA records for `" + ep.Target + "` or change the SRV target.",
|
||||||
|
Endpoint: ep.Target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.srv_targets_resolvable.ok", "All SRV targets resolve to at least one address.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
30
checker/rules_tls.go
Normal file
30
checker/rules_tls.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tlsQualityRule folds findings from a downstream TLS checker (cert
|
||||||
|
// chain, hostname match, expiry, …) onto SIP rule output, so they appear
|
||||||
|
// on the SIP service page without users having to go look at the TLS
|
||||||
|
// checker themselves.
|
||||||
|
type tlsQualityRule struct{}
|
||||||
|
|
||||||
|
func (r *tlsQualityRule) Name() string { return "sip.tls_quality" }
|
||||||
|
func (r *tlsQualityRule) Description() string {
|
||||||
|
return "Folds the downstream TLS checker findings (certificate chain, hostname match, expiry) onto the SIP service."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *tlsQualityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||||
|
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
|
||||||
|
if len(related) == 0 {
|
||||||
|
return []sdk.CheckState{notTestedState("sip.tls_quality.skipped", "No related TLS observation available (no TLS checker downstream, or no probe yet).")}
|
||||||
|
}
|
||||||
|
issues := tlsIssuesFromRelated(related)
|
||||||
|
if len(issues) == 0 {
|
||||||
|
return []sdk.CheckState{passState("sip.tls_quality.ok", "Downstream TLS checker reports no issues on the SIP endpoints.")}
|
||||||
|
}
|
||||||
|
return statesFromIssues(issues)
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
|
@ -39,26 +38,30 @@ func buildOptionsRequest(target string, port uint16, transport Transport, localA
|
||||||
|
|
||||||
branch := "z9hG4bK-" + randHex(8)
|
branch := "z9hG4bK-" + randHex(8)
|
||||||
tag := randHex(6)
|
tag := randHex(6)
|
||||||
callID := randHex(12) + "@happydomain.org"
|
// Use an RFC 2606 reserved TLD so the host part of Call-ID never
|
||||||
|
// resolves to a real domain we don't control.
|
||||||
|
callID := randHex(12) + "@checker-sip.invalid"
|
||||||
|
|
||||||
sipScheme := "sip"
|
sipScheme := "sip"
|
||||||
if transport == TransportTLS {
|
if transport == TransportTLS {
|
||||||
sipScheme = "sips"
|
sipScheme = "sips"
|
||||||
}
|
}
|
||||||
|
|
||||||
requestURI := fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
|
var requestURI string
|
||||||
if transport == TransportTLS {
|
if transport == TransportTLS {
|
||||||
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
|
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
|
||||||
|
} else {
|
||||||
|
requestURI = fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Via uses the remote transport name; local address is a best-effort
|
// Via uses the remote transport name; local address is a best-effort
|
||||||
// hint that servers echo back via ;rport. We don't actually listen
|
// hint that servers echo back via ;rport. We don't actually listen
|
||||||
// on it — this is a one-shot probe.
|
// on it, this is a one-shot probe.
|
||||||
lines := []string{
|
lines := []string{
|
||||||
"OPTIONS " + requestURI + " SIP/2.0",
|
"OPTIONS " + requestURI + " SIP/2.0",
|
||||||
"Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport",
|
"Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport",
|
||||||
"Max-Forwards: 70",
|
"Max-Forwards: 70",
|
||||||
"From: \"happyDomain\" <sip:check@happydomain.org>;tag=" + tag,
|
"From: \"happyDomain\" <sip:check@checker-sip.invalid>;tag=" + tag,
|
||||||
"To: <" + sipScheme + ":ping@" + target + ">",
|
"To: <" + sipScheme + ":ping@" + target + ">",
|
||||||
"Call-ID: " + callID,
|
"Call-ID: " + callID,
|
||||||
"CSeq: 1 OPTIONS",
|
"CSeq: 1 OPTIONS",
|
||||||
|
|
@ -86,14 +89,11 @@ func parseSIPResponse(r io.Reader) (*sipResponse, error) {
|
||||||
return nil, fmt.Errorf("read status line: %w", err)
|
return nil, fmt.Errorf("read status line: %w", err)
|
||||||
}
|
}
|
||||||
statusLine = strings.TrimRight(statusLine, "\r\n")
|
statusLine = strings.TrimRight(statusLine, "\r\n")
|
||||||
if !strings.HasPrefix(statusLine, "SIP/2.0 ") && !strings.HasPrefix(statusLine, "SIP/2.1 ") {
|
if !strings.HasPrefix(statusLine, "SIP/2.0 ") {
|
||||||
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
|
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
|
||||||
}
|
}
|
||||||
_, rest, _ := strings.Cut(statusLine, " ")
|
_, rest, _ := strings.Cut(statusLine, " ")
|
||||||
parts := strings.SplitN(rest, " ", 2)
|
parts := strings.SplitN(rest, " ", 2)
|
||||||
if len(parts) < 1 {
|
|
||||||
return nil, errors.New("malformed status line")
|
|
||||||
}
|
|
||||||
code, convErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
code, convErr := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||||
if convErr != nil {
|
if convErr != nil {
|
||||||
return nil, fmt.Errorf("non-numeric status code %q", parts[0])
|
return nil, fmt.Errorf("non-numeric status code %q", parts[0])
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ package checker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -15,7 +13,7 @@ import (
|
||||||
// convention.
|
// convention.
|
||||||
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
|
||||||
|
|
||||||
// tlsProbeView is our permissive view of a TLS checker's payload — we
|
// tlsProbeView is our permissive view of a TLS checker's payload, we
|
||||||
// read only the fields we need.
|
// read only the fields we need.
|
||||||
type tlsProbeView struct {
|
type tlsProbeView struct {
|
||||||
Host string `json:"host,omitempty"`
|
Host string `json:"host,omitempty"`
|
||||||
|
|
@ -41,7 +39,7 @@ func (v *tlsProbeView) address() string {
|
||||||
return v.Endpoint
|
return v.Endpoint
|
||||||
}
|
}
|
||||||
if v.Host != "" && v.Port != 0 {
|
if v.Host != "" && v.Port != 0 {
|
||||||
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
|
return endpointKey(v.Host, v.Port)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
// OPTIONS ping per RFC 3261) and reports actionable findings.
|
// OPTIONS ping per RFC 3261) and reports actionable findings.
|
||||||
//
|
//
|
||||||
// TLS certificate chain / SAN / expiry / cipher posture is
|
// TLS certificate chain / SAN / expiry / cipher posture is
|
||||||
// intentionally out of scope — the forthcoming checker-tls covers
|
// intentionally out of scope, the forthcoming checker-tls covers
|
||||||
// that. SIPS endpoints are published as "tls" discovery endpoints
|
// that. SIPS endpoints are published as "tls" discovery endpoints
|
||||||
// so checker-tls can probe them; its findings are folded back into
|
// so checker-tls can probe them; its findings are folded back into
|
||||||
// this report via GetRelated("tls_probes"). See
|
// this report via GetRelated("tls_probes"). See
|
||||||
|
|
@ -28,15 +28,15 @@ const (
|
||||||
TransportTLS Transport = "tls" // SIPS, direct TLS on connect
|
TransportTLS Transport = "tls" // SIPS, direct TLS on connect
|
||||||
)
|
)
|
||||||
|
|
||||||
// SIPData is the full observation stored per run.
|
// SIPData is the full observation stored per run. It is a pure record of
|
||||||
|
// what was observed, no severity or pass/fail judgment is encoded here;
|
||||||
|
// those are derived by the rules (see issues.go / rules_*.go).
|
||||||
type SIPData struct {
|
type SIPData struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
RunAt string `json:"run_at"`
|
RunAt string `json:"run_at"`
|
||||||
NAPTR []NAPTRRecord `json:"naptr,omitempty"`
|
NAPTR []NAPTRRecord `json:"naptr,omitempty"`
|
||||||
SRV SRVLookup `json:"srv"`
|
SRV SRVLookup `json:"srv"`
|
||||||
Endpoints []EndpointProbe `json:"endpoints"`
|
Endpoints []EndpointProbe `json:"endpoints"`
|
||||||
Coverage Coverage `json:"coverage"`
|
|
||||||
Issues []Issue `json:"issues"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NAPTRRecord is a subset of a NAPTR record enough to reason about
|
// NAPTRRecord is a subset of a NAPTR record enough to reason about
|
||||||
|
|
@ -107,7 +107,11 @@ func (e EndpointProbe) OK() bool {
|
||||||
return e.Reachable && e.OptionsSent && e.OptionsRawCode >= 200 && e.OptionsRawCode < 300
|
return e.Reachable && e.OptionsSent && e.OptionsRawCode >= 200 && e.OptionsRawCode < 300
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coverage is a roll-up of the per-endpoint results.
|
// Coverage is a roll-up of the per-endpoint results. All fields reflect
|
||||||
|
// what was *reachable* during this run, not what was merely published in
|
||||||
|
// DNS: HasIPv6 is true only if at least one AAAA-resolved endpoint
|
||||||
|
// accepted a connection. A target with AAAA but firewalled off will not
|
||||||
|
// light up HasIPv6.
|
||||||
type Coverage struct {
|
type Coverage struct {
|
||||||
HasIPv4 bool `json:"has_ipv4"`
|
HasIPv4 bool `json:"has_ipv4"`
|
||||||
HasIPv6 bool `json:"has_ipv6"`
|
HasIPv6 bool `json:"has_ipv6"`
|
||||||
|
|
|
||||||
4
go.mod
4
go.mod
|
|
@ -3,8 +3,8 @@ module git.happydns.org/checker-sip
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.happydns.org/checker-sdk-go v1.2.0
|
git.happydns.org/checker-sdk-go v1.5.0
|
||||||
git.happydns.org/checker-tls v0.2.0
|
git.happydns.org/checker-tls v0.6.2
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
8
go.sum
8
go.sum
|
|
@ -1,7 +1,7 @@
|
||||||
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
|
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||||
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||||
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
|
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||||
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
|
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
|
|
|
||||||
6
main.go
6
main.go
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
"git.happydns.org/checker-sdk-go/checker/server"
|
||||||
sip "git.happydns.org/checker-sip/checker"
|
sip "git.happydns.org/checker-sip/checker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -21,8 +21,8 @@ func main() {
|
||||||
|
|
||||||
sip.Version = Version
|
sip.Version = Version
|
||||||
|
|
||||||
server := sdk.NewServer(sip.Provider())
|
srv := server.New(sip.Provider())
|
||||||
if err := server.ListenAndServe(*listenAddr); err != nil {
|
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||||
log.Fatalf("server error: %v", err)
|
log.Fatalf("server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,6 @@ var Version = "custom-build"
|
||||||
// .so file.
|
// .so file.
|
||||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||||
sip.Version = Version
|
sip.Version = Version
|
||||||
return sip.Definition(), sip.Provider(), nil
|
prvd := sip.Provider()
|
||||||
|
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue