Initial commit

This commit is contained in:
nemunaire 2026-04-21 22:42:34 +07:00
commit f6f102079f
19 changed files with 2222 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-sip
checker-sip.so

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-sip .
FROM scratch
COPY --from=builder /checker-sip /checker-sip
EXPOSE 8080
ENTRYPOINT ["/checker-sip"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-sip
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker test clean
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

25
NOTICE Normal file
View file

@ -0,0 +1,25 @@
checker-sip
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software from the miekg/dns project
(https://github.com/miekg/dns), licensed under the BSD 3-Clause License:
Copyright (c) 2009 The Go Authors. All rights reserved.
Copyright (c) 2011 Miek Gieben. All rights reserved.
Copyright (c) 2014 CloudFlare. All rights reserved.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

72
README.md Normal file
View file

@ -0,0 +1,72 @@
# checker-sip
SIP / VoIP server checker for [happyDomain](https://www.happydomain.org/).
Probes a domain's SIP deployment end-to-end from its DNS records:
- **RFC 3263 resolution.** NAPTR → SRV (`_sip._udp`, `_sip._tcp`,
`_sips._tcp`) → A/AAAA.
- **Reachability** on every resolved `target:port` over UDP, TCP and TLS.
- **SIP `OPTIONS` ping.** Raw RFC 3261 request; parses status line,
`Server` / `User-Agent`, `Allow` methods, round-trip time.
- **Discovery entries.** Every `_sips._tcp` target is published as a
`tls.endpoint.v1` `DiscoveryEntry` (via
[`checker-tls/contract`](../checker-tls/README.md)) so the TLS checker
can verify chain, SAN, expiry and cipher posture without re-doing the
SRV lookup. TLS issues reported by the TLS checker are folded back
into this report via `GetRelated("tls_probes")`.
Attaches to the `abstract.SIP` service (SRV records for `_sip._udp`,
`_sip._tcp`, `_sips._tcp`). The happyDomain core registers the abstract
service automatically; no extra configuration is required.
## Usage
### Standalone HTTP server
```bash
make
./checker-sip -listen :8080
```
Exposes the standard happyDomain external checker endpoints (`/health`,
`/definition`, `/collect`, `/evaluate`, `/report`).
### Docker
```bash
make docker
docker run -p 8080:8080 happydomain/checker-sip
```
### happyDomain plugin
```bash
make plugin
# produces checker-sip.so, loadable as a Go plugin by happyDomain.
```
## Options
| Scope | Id | Description |
| ----- | ----------- | ---------------------------------------------------------------------- |
| Run | `domain` | SIP domain to test (auto-filled from the service domain). |
| Run | `timeout` | Per-endpoint probe timeout in seconds (default: `5`). |
| Admin | `probeUDP` | Probe `_sip._udp` (default: `true`). Disable if UDP is firewalled. |
| Admin | `probeTCP` | Probe `_sip._tcp` (default: `true`). |
| Admin | `probeTLS` | Probe `_sips._tcp` (default: `true`). |
## Tests performed
1. NAPTR lookup (`SIP+D2U`, `SIP+D2T`, `SIPS+D2T`).
2. SRV lookup for the three transports.
3. Fallback to `<domain>:5060` / `<domain>:5061` when no SRV is
published, with a visible info marker in the report.
4. A/AAAA resolution of every SRV target.
5. TCP connect / UDP send / TLS handshake (with
`InsecureSkipVerify: true` — cert posture is the TLS checker's job).
6. SIP `OPTIONS` request with status, headers and `Allow` parsed.
## License
Licensed under the **MIT License** (see `LICENSE`).

592
checker/collect.go Normal file
View file

@ -0,0 +1,592 @@
package checker
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"slices"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Collect runs the full SIP probe against a domain.
func (p *sipProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
domain, _ := sdk.GetOption[string](opts, "domain")
domain = strings.TrimSuffix(strings.TrimSpace(domain), ".")
if domain == "" {
return nil, fmt.Errorf("domain is required")
}
timeoutSecs := sdk.GetFloatOption(opts, "timeout", 5)
if timeoutSecs < 1 {
timeoutSecs = 5
}
perEndpoint := time.Duration(timeoutSecs * float64(time.Second))
probeUDP := sdk.GetBoolOption(opts, "probeUDP", true)
probeTCP := sdk.GetBoolOption(opts, "probeTCP", true)
probeTLS := sdk.GetBoolOption(opts, "probeTLS", true)
data := &SIPData{
Domain: domain,
RunAt: time.Now().UTC().Format(time.RFC3339),
SRV: SRVLookup{Errors: map[string]string{}},
}
resolver := net.DefaultResolver
// NAPTR lookup — best-effort, failures become an info issue.
if naptr, err := lookupNAPTR(ctx, domain); err != nil {
data.SRV.Errors["naptr"] = err.Error()
} else {
data.NAPTR = naptr
}
// SRV lookups (per transport). Errors are kept per-prefix; "not
// found" is normalised to nil by lookupSRV.
type srvSet struct {
prefix string
want bool
dst *[]SRVRecord
}
sets := []srvSet{
{"_sip._udp.", probeUDP, &data.SRV.UDP},
{"_sip._tcp.", probeTCP, &data.SRV.TCP},
{"_sips._tcp.", probeTLS, &data.SRV.SIPS},
}
for _, s := range sets {
if !s.want {
continue
}
recs, err := lookupSRV(ctx, resolver, s.prefix, domain)
if err != nil {
data.SRV.Errors[s.prefix] = err.Error()
continue
}
*s.dst = recs
}
// Fallback when no SRV at all: synthesize a single target on each
// enabled transport against the bare domain.
total := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
if total == 0 {
data.SRV.FallbackProbed = true
if probeUDP {
data.SRV.UDP = []SRVRecord{{Target: domain, Port: 5060}}
}
if probeTCP {
data.SRV.TCP = []SRVRecord{{Target: domain, Port: 5060}}
}
if probeTLS {
data.SRV.SIPS = []SRVRecord{{Target: domain, Port: 5061}}
}
}
type transportJob struct {
records []SRVRecord
prefix string
t Transport
}
jobs := []transportJob{
{data.SRV.UDP, "_sip._udp.", TransportUDP},
{data.SRV.TCP, "_sip._tcp.", TransportTCP},
{data.SRV.SIPS, "_sips._tcp.", TransportTLS},
}
var wg sync.WaitGroup
var mu sync.Mutex
for _, job := range jobs {
wg.Add(1)
go func(j transportJob) {
defer wg.Done()
resolveAllInto(ctx, resolver, j.records)
eps := probeSet(ctx, j.prefix, j.t, j.records, perEndpoint)
mu.Lock()
data.Endpoints = append(data.Endpoints, eps...)
mu.Unlock()
}(job)
}
wg.Wait()
computeCoverage(data)
data.Issues = deriveIssues(data, probeUDP, probeTCP, probeTLS)
return data, nil
}
// ─── DNS ──────────────────────────────────────────────────────────────
func lookupSRV(ctx context.Context, r *net.Resolver, prefix, domain string) ([]SRVRecord, error) {
name := prefix + dns.Fqdn(domain)
_, records, err := r.LookupSRV(ctx, "", "", name)
if err != nil {
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
return nil, nil
}
return nil, err
}
// RFC 2782 null-target: single "." record with port 0 means
// "service explicitly unavailable".
if len(records) == 1 && (records[0].Target == "." || records[0].Target == "") && records[0].Port == 0 {
return nil, nil
}
out := make([]SRVRecord, 0, len(records))
for _, r := range records {
out = append(out, SRVRecord{
Target: strings.TrimSuffix(r.Target, "."),
Port: r.Port,
Priority: r.Priority,
Weight: r.Weight,
})
}
return out, nil
}
func lookupNAPTR(ctx context.Context, domain string) ([]NAPTRRecord, error) {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil || cfg == nil || len(cfg.Servers) == 0 {
cfg = &dns.ClientConfig{Servers: []string{"1.1.1.1", "8.8.8.8"}, Port: "53"}
}
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeNAPTR)
m.RecursionDesired = true
c := new(dns.Client)
c.Timeout = 3 * time.Second
var lastErr error
for _, srv := range cfg.Servers {
addr := net.JoinHostPort(srv, cfg.Port)
in, _, err := c.ExchangeContext(ctx, m, addr)
if err != nil {
lastErr = err
continue
}
if in.Rcode == dns.RcodeNameError {
return nil, nil
}
if in.Rcode != dns.RcodeSuccess {
lastErr = fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
continue
}
var out []NAPTRRecord
for _, rr := range in.Answer {
n, ok := rr.(*dns.NAPTR)
if !ok {
continue
}
if !strings.HasPrefix(strings.ToUpper(n.Service), "SIP+") && !strings.HasPrefix(strings.ToUpper(n.Service), "SIPS+") {
continue
}
out = append(out, NAPTRRecord{
Service: n.Service,
Regexp: n.Regexp,
Replacement: strings.TrimSuffix(n.Replacement, "."),
Flags: n.Flags,
Order: n.Order,
Preference: n.Preference,
})
}
return out, nil
}
return nil, lastErr
}
func resolveAllInto(ctx context.Context, r *net.Resolver, records []SRVRecord) {
for i := range records {
ips, err := r.LookupIPAddr(ctx, records[i].Target)
if err != nil {
continue
}
for _, ip := range ips {
if v4 := ip.IP.To4(); v4 != nil {
records[i].IPv4 = append(records[i].IPv4, v4.String())
} else {
records[i].IPv6 = append(records[i].IPv6, ip.IP.String())
}
}
}
}
// ─── Probing ──────────────────────────────────────────────────────────
func probeSet(ctx context.Context, prefix string, t Transport, records []SRVRecord, timeout time.Duration) []EndpointProbe {
var eps []EndpointProbe
for _, rec := range records {
addrs := allAddrs(rec)
if len(addrs) == 0 {
eps = append(eps, EndpointProbe{
Transport: t,
SRVPrefix: prefix,
Target: rec.Target,
Port: rec.Port,
Error: "no A/AAAA records for target",
})
continue
}
for _, a := range addrs {
eps = append(eps, probeEndpoint(ctx, t, prefix, rec, a, timeout))
}
}
return eps
}
type probeAddr struct {
ip string
isV6 bool
}
func allAddrs(r SRVRecord) []probeAddr {
out := make([]probeAddr, 0, len(r.IPv4)+len(r.IPv6))
for _, ip := range r.IPv4 {
out = append(out, probeAddr{ip: ip, isV6: false})
}
for _, ip := range r.IPv6 {
out = append(out, probeAddr{ip: ip, isV6: true})
}
return out
}
func probeEndpoint(ctx context.Context, t Transport, prefix string, rec SRVRecord, a probeAddr, timeout time.Duration) (ep EndpointProbe) {
start := time.Now()
addrPort := net.JoinHostPort(a.ip, strconv.Itoa(int(rec.Port)))
ep = EndpointProbe{
Transport: t,
SRVPrefix: prefix,
Target: rec.Target,
Port: rec.Port,
Address: addrPort,
IsIPv6: a.isV6,
}
defer func() { ep.ElapsedMS = time.Since(start).Milliseconds() }()
ua := "happyDomain-checker-sip/" + Version
switch t {
case TransportUDP:
probeUDP(ctx, &ep, rec.Target, ua, timeout)
case TransportTCP:
probeTCP(ctx, &ep, rec.Target, ua, timeout)
case TransportTLS:
probeTLSConn(ctx, &ep, rec.Target, ua, timeout)
}
return
}
func probeUDP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "udp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "udp dial: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportUDP, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "udp write: " + err.Error()
return
}
ep.OptionsSent = true
buf := make([]byte, 8192)
n, err := conn.Read(buf)
if err != nil {
ep.Error = "no udp response: " + err.Error()
return
}
resp, err := parseSIPResponse(bytes.NewReader(buf[:n]))
if err != nil {
ep.Error = "bad response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func probeTCP(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "tcp dial: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportTCP, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "tcp write: " + err.Error()
return
}
ep.OptionsSent = true
resp, err := parseSIPResponse(conn)
if err != nil {
ep.Error = "no tcp response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func probeTLSConn(ctx context.Context, ep *EndpointProbe, target, ua string, timeout time.Duration) {
d := net.Dialer{Timeout: timeout}
raw, err := d.DialContext(ctx, "tcp", ep.Address)
if err != nil {
ep.ReachableErr = err.Error()
ep.Error = "tcp dial: " + err.Error()
return
}
// We deliberately skip cert verification — checker-tls is the
// source of truth for TLS posture. We just want to reach SIP over
// TLS.
cfg := &tls.Config{
InsecureSkipVerify: true, //nolint:gosec
ServerName: target,
}
conn := tls.Client(raw, cfg)
if err := conn.HandshakeContext(ctx); err != nil {
_ = raw.Close()
ep.Error = "tls handshake: " + err.Error()
return
}
defer conn.Close()
ep.Reachable = true
state := conn.ConnectionState()
ep.TLSVersion = tls.VersionName(state.Version)
ep.TLSCipher = tls.CipherSuiteName(state.CipherSuite)
_ = conn.SetDeadline(time.Now().Add(timeout))
req := buildOptionsRequest(target, ep.Port, TransportTLS, localAddrFor(conn), ua)
sent := time.Now()
if _, err := conn.Write([]byte(req)); err != nil {
ep.Error = "tls write: " + err.Error()
return
}
ep.OptionsSent = true
resp, err := parseSIPResponse(conn)
if err != nil {
ep.Error = "no tls response: " + err.Error()
return
}
applyResponse(ep, resp, sent)
}
func applyResponse(ep *EndpointProbe, resp *sipResponse, sent time.Time) {
ep.OptionsRawCode = resp.StatusCode
ep.OptionsStatus = fmt.Sprintf("%d %s", resp.StatusCode, strings.TrimSpace(resp.StatusPhrase))
ep.OptionsRTTMs = time.Since(sent).Milliseconds()
ep.ServerHeader = resp.Server
ep.UserAgent = resp.UserAgent
ep.AllowMethods = resp.Allow
ep.ContactURI = resp.Contact
}
// ─── Coverage + issues ────────────────────────────────────────────────
func computeCoverage(data *SIPData) {
for _, ep := range data.Endpoints {
if ep.Reachable {
if ep.IsIPv6 {
data.Coverage.HasIPv6 = true
} else {
data.Coverage.HasIPv4 = true
}
}
if !ep.OK() {
continue
}
switch ep.Transport {
case TransportUDP:
data.Coverage.WorkingUDP = true
case TransportTCP:
data.Coverage.WorkingTCP = true
case TransportTLS:
data.Coverage.WorkingTLS = true
}
}
data.Coverage.AnyWorking = data.Coverage.WorkingUDP || data.Coverage.WorkingTCP || data.Coverage.WorkingTLS
}
func deriveIssues(data *SIPData, wantUDP, wantTCP, wantTLS bool) []Issue {
var out []Issue
totalSRV := len(data.SRV.UDP) + len(data.SRV.TCP) + len(data.SRV.SIPS)
if totalSRV == 0 && data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeNoSRV,
Severity: SeverityCrit,
Message: "No SIP SRV records published for " + data.Domain + ".",
Fix: "Publish `_sip._tcp." + data.Domain + ". SRV 10 10 5060 sip." + data.Domain + ".` (and `_sips._tcp` on 5061 for TLS).",
})
}
// "Only UDP" — the most common real-world failure for modern trunks.
if len(data.SRV.UDP) > 0 && len(data.SRV.TCP) == 0 && len(data.SRV.SIPS) == 0 && !data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeOnlyUDP,
Severity: SeverityWarn,
Message: "Only _sip._udp is published; modern SIP trunks (Twilio, OVH, Orange…) prefer TCP/TLS.",
Fix: "Also publish `_sip._tcp." + data.Domain + ".` and ideally `_sips._tcp." + data.Domain + ".`.",
})
}
// No TLS at all when TCP exists.
if wantTLS && len(data.SRV.SIPS) == 0 && (len(data.SRV.UDP) > 0 || len(data.SRV.TCP) > 0) && !data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeNoTLS,
Severity: SeverityInfo,
Message: "No _sips._tcp SRV record — SIP signalling runs in the clear.",
Fix: "Publish `_sips._tcp." + data.Domain + ".` on port 5061 and terminate TLS on the server.",
})
}
// Per-prefix DNS errors.
for prefix, msg := range data.SRV.Errors {
if prefix == "naptr" {
out = append(out, Issue{
Code: CodeNAPTRServfail,
Severity: SeverityInfo,
Message: "NAPTR lookup for " + data.Domain + " failed: " + msg,
Fix: "This is optional. If you meant to expose a NAPTR, verify your authoritative resolver answers AUTH/NXDOMAIN cleanly.",
})
continue
}
out = append(out, Issue{
Code: CodeSRVServfail,
Severity: SeverityWarn,
Message: "SRV lookup for `" + prefix + data.Domain + "` failed: " + msg,
Fix: "Check zone serial and authoritative NS for this name.",
})
}
// Fallback-probed notice.
if data.SRV.FallbackProbed {
out = append(out, Issue{
Code: CodeFallbackProbed,
Severity: SeverityInfo,
Message: "No SIP SRV records: probing fell back to " + data.Domain + ":5060 / :5061.",
Fix: "Publish the SRV records expected by SIP clients and trunks.",
})
}
// Per-endpoint findings.
for _, ep := range data.Endpoints {
switch {
case !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target":
out = append(out, Issue{
Code: CodeSRVTargetUnresolved,
Severity: SeverityCrit,
Message: "SRV target `" + ep.Target + "` has no A/AAAA.",
Fix: "Add A/AAAA records for `" + ep.Target + "` or change the SRV target.",
Endpoint: ep.Target,
})
case !ep.Reachable:
code := CodeTCPUnreachable
msg := "TCP port " + strconv.Itoa(int(ep.Port)) + " is closed or filtered on " + ep.Address + "."
fix := "Verify the SIP server is running and the firewall/NAT forwards port " + strconv.Itoa(int(ep.Port)) + "."
switch ep.Transport {
case TransportUDP:
code = CodeUDPUnreachable
msg = "UDP port " + strconv.Itoa(int(ep.Port)) + " refused on " + ep.Address + "."
fix = "Verify the SIP server listens on UDP " + strconv.Itoa(int(ep.Port)) + " and that no stateless firewall drops the reply."
case TransportTLS:
if ep.Error != "" && strings.HasPrefix(ep.Error, "tls handshake") {
code = CodeTLSHandshake
msg = "TLS handshake failed on " + ep.Address + ": " + strings.TrimPrefix(ep.Error, "tls handshake: ")
fix = "Present a valid certificate (chain + SAN including `" + ep.Target + "`) and accept TLS 1.2+."
}
}
out = append(out, Issue{
Code: code,
Severity: SeverityCrit,
Message: msg,
Fix: fix,
Endpoint: ep.Address,
})
case ep.Reachable && !ep.OptionsSent:
out = append(out, Issue{
Code: CodeOptionsNoAnswer,
Severity: SeverityCrit,
Message: ep.Address + " accepted the connection but the probe could not send an OPTIONS: " + ep.Error,
Fix: "Investigate the server's SIP listener.",
Endpoint: ep.Address,
})
case ep.OptionsSent && ep.OptionsRawCode == 0:
out = append(out, Issue{
Code: CodeOptionsNoAnswer,
Severity: SeverityCrit,
Message: ep.Address + " is reachable but silent on SIP OPTIONS.",
Fix: "Enable unauthenticated OPTIONS (`handle_options = yes` in Kamailio, `allowguest = yes` in Asterisk/FreeSWITCH) or add the probe source to the ACL.",
Endpoint: ep.Address,
})
case ep.OptionsRawCode >= 300:
out = append(out, Issue{
Code: CodeOptionsNon2xx,
Severity: SeverityWarn,
Message: ep.Address + " answered " + ep.OptionsStatus + " to OPTIONS.",
Fix: "Check SIP routing / ACL. Some stacks reject unauthenticated OPTIONS with 403/404.",
Endpoint: ep.Address,
})
case ep.OK() && len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"):
out = append(out, Issue{
Code: CodeOptionsNoInvite,
Severity: SeverityWarn,
Message: ep.Address + " answered 2xx but does not advertise INVITE in Allow.",
Fix: "Verify the dialplan / endpoint is allowed to place calls.",
Endpoint: ep.Address,
})
case ep.OK() && len(ep.AllowMethods) == 0:
out = append(out, Issue{
Code: CodeOptionsNoAllow,
Severity: SeverityInfo,
Message: ep.Address + " answered 2xx but did not advertise an Allow header.",
Fix: "Configure the SIP stack to include Allow (benign but helps callers discover capabilities).",
Endpoint: ep.Address,
})
}
}
// Nothing reachable at all.
if len(data.Endpoints) > 0 && !data.Coverage.AnyWorking {
out = append(out, Issue{
Code: CodeAllDown,
Severity: SeverityCrit,
Message: "No SIP endpoint answered OPTIONS on any transport.",
Fix: "Verify the SIP server is running and reachable on the published SRV ports.",
})
}
// IPv6 coverage.
if data.Coverage.HasIPv4 && !data.Coverage.HasIPv6 {
out = append(out, Issue{
Code: CodeNoIPv6,
Severity: SeverityInfo,
Message: "No IPv6 endpoint reachable.",
Fix: "Publish AAAA records for the SRV targets.",
})
}
return out
}

69
checker/definition.go Normal file
View file

@ -0,0 +1,69 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is reported in CheckerDefinition.Version. Overridden at build
// time by main / plugin.
var Version = "built-in"
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "sip",
Name: "SIP / VoIP server",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.SIP"},
},
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeySIP},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "domain",
Type: "string",
Label: "SIP domain",
AutoFill: sdk.AutoFillDomainName,
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 5,
},
},
AdminOpts: []sdk.CheckerOptionDocumentation{
{
Id: "probeUDP",
Type: "bool",
Label: "Probe _sip._udp",
Default: true,
Description: "Disable if the checker host cannot send UDP.",
},
{
Id: "probeTCP",
Type: "bool",
Label: "Probe _sip._tcp",
Default: true,
},
{
Id: "probeTLS",
Type: "bool",
Label: "Probe _sips._tcp (TLS)",
Default: true,
},
},
},
Rules: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Max: 7 * 24 * time.Hour,
Default: 6 * time.Hour,
},
}
}

76
checker/interactive.go Normal file
View file

@ -0,0 +1,76 @@
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the minimal human-facing inputs needed to run a SIP
// check standalone. Collect resolves NAPTR/SRV itself, so a domain name
// is the only required field.
func (p *sipProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "SIP domain",
Placeholder: "example.com",
Required: true,
},
{
Id: "timeout",
Type: "number",
Label: "Per-endpoint timeout (seconds)",
Default: 5,
},
{
Id: "probeUDP",
Type: "bool",
Label: "Probe _sip._udp",
Default: true,
},
{
Id: "probeTCP",
Type: "bool",
Label: "Probe _sip._tcp",
Default: true,
},
{
Id: "probeTLS",
Type: "bool",
Label: "Probe _sips._tcp (TLS)",
Default: true,
},
}
}
// ParseForm turns the submitted form into a CheckerOptions. The SIP
// Collect path performs its own DNS lookups, so there is nothing to
// pre-resolve here.
func (p *sipProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
if domain == "" {
return nil, errors.New("domain is required")
}
opts := sdk.CheckerOptions{
"domain": domain,
"probeUDP": r.FormValue("probeUDP") == "true",
"probeTCP": r.FormValue("probeTCP") == "true",
"probeTLS": r.FormValue("probeTLS") == "true",
}
if v := strings.TrimSpace(r.FormValue("timeout")); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts["timeout"] = f
}
return opts, nil
}

51
checker/provider.go Normal file
View file

@ -0,0 +1,51 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
func Provider() sdk.ObservationProvider {
return &sipProvider{}
}
type sipProvider struct{}
func (p *sipProvider) Key() sdk.ObservationKey {
return ObservationKeySIP
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *sipProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// It publishes every _sips._tcp SRV target as a tls.endpoint.v1 entry so
// the downstream TLS checker can verify certificate chain, SAN and
// expiry without re-doing the SRV lookup. SNI is set to the SRV target —
// SIPS certificates are expected to cover the server hostname (unlike
// XMPP where it's the bare JID domain).
//
// _sip._udp and _sip._tcp are plaintext with no historical STARTTLS
// convention, so nothing is emitted for them.
func (p *sipProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*SIPData)
if !ok || d == nil {
return nil, nil
}
var out []sdk.DiscoveryEntry
for _, r := range d.SRV.SIPS {
e, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: r.Target,
Port: r.Port,
SNI: r.Target,
})
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}

524
checker/report.go Normal file
View file

@ -0,0 +1,524 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"net"
"sort"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ─── View models ────────────────────────────────────────────────────
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
}
type reportTLSPosture struct {
CheckedAt time.Time
ChainValid *bool
HostnameMatch *bool
NotAfter time.Time
TLSVersion string
Issues []reportFix
}
type reportEndpoint struct {
Transport string
TransportTag string // "SIP/UDP", "SIP/TCP", "SIPS/TLS"
SRVPrefix string
Target string
Port uint16
Address string
IsIPv6 bool
Reachable bool
ReachableErr string
TLSVersion string
TLSCipher string
OptionsSent bool
OptionsStatus string
OptionsRTTMs int64
ServerHeader string
UserAgent string
AllowMethods []string
ContactURI string
ElapsedMS int64
Error string
OK bool
StatusLabel string
StatusClass string
TLSPosture *reportTLSPosture
}
type reportSRVEntry struct {
Prefix string
Target string
Port uint16
Priority uint16
Weight uint16
IPv4 []string
IPv6 []string
}
type reportData struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
Fixes []reportFix
NAPTR []NAPTRRecord
SRV []reportSRVEntry
FallbackProbed bool
Endpoints []reportEndpoint
HasIPv4 bool
HasIPv6 bool
WorkingUDP bool
WorkingTCP bool
WorkingTLS bool
HasTLSPosture bool
}
// ─── Template ───────────────────────────────────────────────────────
var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{
"deref": func(b *bool) bool { return b != nil && *b },
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SIP Report {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.section details { box-shadow: none; border: 1px solid #e5e7eb; margin-bottom: .4rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before {
content: "▶"; font-size: .65rem; color: #9ca3af;
transition: transform .15s; flex-shrink: 0;
}
details[open] > summary::before { transform: rotate(90deg); }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.badge.ok { background: #d1fae5; color: #065f46; }
.badge.warn { background: #fef3c7; color: #92400e; }
.badge.fail { background: #fee2e2; color: #991b1b; }
.badge.muted{ background: #e5e7eb; color: #374151; }
.chips { display: flex; gap: .35rem; flex-wrap: wrap; margin: .4rem 0; }
.chip {
display: inline-flex; align-items: center; gap: .35rem;
padding: .15rem .55rem; border-radius: 6px;
font-size: .75rem; font-weight: 600;
background: #f3f4f6; color: #374151;
}
.chip.ok { background: #d1fae5; color: #065f46; }
.chip.fail { background: #fee2e2; color: #991b1b; }
.fix {
border-left: 3px solid #e5e7eb;
padding: .5rem .75rem;
margin-bottom: .4rem;
background: #fafafa;
border-radius: 0 6px 6px 0;
}
.fix.crit { border-left-color: #dc2626; background: #fef2f2; }
.fix.warn { border-left-color: #d97706; background: #fffbeb; }
.fix.info { border-left-color: #2563eb; background: #eff6ff; }
.fix .code { font-size: .7rem; color: #6b7280; font-family: ui-monospace, monospace; }
.fix .msg { font-weight: 600; margin: .1rem 0; }
.fix .how { color: #374151; font-size: .85rem; }
.conn-head { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: .2rem .75rem; margin: 0; font-size: .85rem; }
dt { color: #6b7280; }
dd { margin: 0; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.check-ok { color: #059669; font-weight: 700; }
.check-fail { color: #dc2626; font-weight: 700; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .75rem; text-align: center; margin-top: 1rem; }
.meth { display: inline-block; font-size: .72rem; padding: .1rem .45rem; background: #eef2ff; color: #4338ca; border-radius: 4px; margin: .1rem .15rem 0 0; font-family: ui-monospace, monospace; }
</style>
</head>
<body>
<div class="hd">
<h1>SIP / VoIP {{.Domain}}</h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="chips" style="margin-top:.45rem">
<span class="chip {{if .WorkingUDP}}ok{{else}}fail{{end}}">{{if .WorkingUDP}}&#10003;{{else}}&#10007;{{end}} UDP</span>
<span class="chip {{if .WorkingTCP}}ok{{else}}fail{{end}}">{{if .WorkingTCP}}&#10003;{{else}}&#10007;{{end}} TCP</span>
<span class="chip {{if .WorkingTLS}}ok{{else}}fail{{end}}">{{if .WorkingTLS}}&#10003;{{else}}&#10007;{{end}} TLS</span>
<span class="chip {{if .HasIPv4}}ok{{end}}">IPv4</span>
<span class="chip {{if .HasIPv6}}ok{{end}}">IPv6</span>
</div>
{{if .FallbackProbed}}<div class="note">No SIP SRV records were published. Probed the bare domain on default ports.</div>{{end}}
{{if .RunAt}}<div class="note">Checked {{.RunAt}}</div>{{end}}
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .Fixes}}
<div class="fix {{.Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} &middot; {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .NAPTR}}
<div class="section">
<h2>NAPTR ({{len .NAPTR}})</h2>
<table>
<tr><th>Order</th><th>Pref</th><th>Flags</th><th>Service</th><th>Replacement</th></tr>
{{range .NAPTR}}
<tr>
<td>{{.Order}}</td>
<td>{{.Preference}}</td>
<td><code>{{.Flags}}</code></td>
<td><code>{{.Service}}</code></td>
<td>{{if .Replacement}}<code>{{.Replacement}}</code>{{else}}<span class="note"></span>{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .SRV}}
<div class="section">
<h2>SRV records ({{len .SRV}})</h2>
<table>
<tr><th>Prefix</th><th>Target</th><th>Port</th><th>Prio/Weight</th><th>A / AAAA</th></tr>
{{range .SRV}}
<tr>
<td><code>{{.Prefix}}</code></td>
<td><code>{{.Target}}</code></td>
<td>{{.Port}}</td>
<td>{{.Priority}} / {{.Weight}}</td>
<td>
{{range .IPv4}}<code>{{.}}</code> {{end}}
{{range .IPv6}}<code>{{.}}</code> {{end}}
</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .Endpoints}}
<div class="section">
<h2>Endpoint probes ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if not .OK}} open{{end}}>
<summary>
<span class="conn-head">{{.TransportTag}} &middot; {{.Address}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl>
<dt>Target</dt><dd><code>{{.Target}}:{{.Port}}</code>{{if .SRVPrefix}} <span class="note">({{.SRVPrefix}})</span>{{end}}</dd>
<dt>Reachable</dt>
<dd>
{{if .Reachable}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; {{if .ReachableErr}}{{.ReachableErr}}{{else}}unreachable{{end}}</span>{{end}}
</dd>
{{if or .TLSVersion .TLSCipher}}
<dt>TLS</dt><dd>{{.TLSVersion}}{{if and .TLSVersion .TLSCipher}} &middot; {{end}}{{.TLSCipher}}</dd>
{{end}}
{{if .OptionsSent}}
<dt>OPTIONS</dt>
<dd>
{{if .OptionsStatus}}<span class="badge {{if .OK}}ok{{else}}warn{{end}}">{{.OptionsStatus}}</span>{{else}}<span class="badge fail">no reply</span>{{end}}
{{if .OptionsRTTMs}} &middot; {{.OptionsRTTMs}} ms{{end}}
</dd>
{{end}}
{{if .ServerHeader}}<dt>Server</dt><dd><code>{{.ServerHeader}}</code></dd>{{end}}
{{if .UserAgent}}<dt>User-Agent</dt><dd><code>{{.UserAgent}}</code></dd>{{end}}
{{if .ContactURI}}<dt>Contact</dt><dd><code>{{.ContactURI}}</code></dd>{{end}}
{{if .AllowMethods}}
<dt>Allow</dt>
<dd>{{range .AllowMethods}}<span class="meth">{{.}}</span>{{end}}</dd>
{{end}}
{{if .TLSPosture}}
<dt>TLS posture</dt>
<dd>
{{if .TLSPosture.ChainValid}}
{{if deref .TLSPosture.ChainValid}}<span class="check-ok">&#10003; chain valid</span>{{else}}<span class="check-fail">&#10007; chain invalid</span>{{end}}
{{end}}
{{if .TLSPosture.HostnameMatch}}
&middot; {{if deref .TLSPosture.HostnameMatch}}<span class="check-ok">&#10003; hostname match</span>{{else}}<span class="check-fail">&#10007; hostname mismatch</span>{{end}}
{{end}}
{{if not .TLSPosture.NotAfter.IsZero}}
&middot; expires <code>{{.TLSPosture.NotAfter.Format "2006-01-02"}}</code>
{{end}}
{{if not .TLSPosture.CheckedAt.IsZero}}
<div class="note">TLS checked {{.TLSPosture.CheckedAt.Format "2006-01-02 15:04 MST"}}</div>
{{end}}
{{range .TLSPosture.Issues}}
<div class="fix {{.Severity}}" style="margin-top:.3rem">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</dd>
{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">{{if .HasTLSPosture}}TLS posture above comes from the TLS checker on the same endpoints.{{else}}Run the TLS checker on this domain to see chain / SAN / expiry per SIPS endpoint.{{end}}</p>
</body>
</html>`))
// ─── Rendering ──────────────────────────────────────────────────────
// GetHTMLReport implements sdk.CheckerHTMLReporter. Related TLS
// observations (tls_probes) are folded in so cert posture surfaces on
// the SIP page directly.
func (p *sipProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d SIPData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal sip observation: %w", err)
}
view := buildReportData(&d, rctx.Related(TLSRelatedKey))
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render sip report: %w", err)
}
return buf.String(), nil
}
func buildReportData(d *SIPData, related []sdk.RelatedObservation) reportData {
tlsIssues := tlsIssuesFromRelated(related)
tlsByAddr := indexTLSByAddress(related)
allIssues := append([]Issue(nil), d.Issues...)
allIssues = append(allIssues, tlsIssues...)
view := reportData{
Domain: d.Domain,
RunAt: d.RunAt,
FallbackProbed: d.SRV.FallbackProbed,
HasIPv4: d.Coverage.HasIPv4,
HasIPv6: d.Coverage.HasIPv6,
WorkingUDP: d.Coverage.WorkingUDP,
WorkingTCP: d.Coverage.WorkingTCP,
WorkingTLS: d.Coverage.WorkingTLS,
HasIssues: len(allIssues) > 0,
HasTLSPosture: len(tlsByAddr) > 0,
}
worst := SeverityInfo
for _, is := range allIssues {
if is.Severity == SeverityCrit {
worst = SeverityCrit
break
}
if is.Severity == SeverityWarn {
worst = SeverityWarn
}
}
switch {
case len(allIssues) == 0:
view.StatusLabel = "OK"
view.StatusClass = "ok"
case worst == SeverityCrit:
view.StatusLabel = "FAIL"
view.StatusClass = "fail"
case worst == SeverityWarn:
view.StatusLabel = "WARN"
view.StatusClass = "warn"
default:
view.StatusLabel = "INFO"
view.StatusClass = "muted"
}
// Sort fixes crit → warn → info.
sevRank := func(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
default:
return 2
}
}
sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) })
for _, is := range allIssues {
view.Fixes = append(view.Fixes, reportFix{
Severity: is.Severity,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
Endpoint: is.Endpoint,
})
}
view.NAPTR = append(view.NAPTR, d.NAPTR...)
addSRV := func(prefix string, records []SRVRecord) {
for _, r := range records {
view.SRV = append(view.SRV, reportSRVEntry{
Prefix: prefix, Target: r.Target, Port: r.Port,
Priority: r.Priority, Weight: r.Weight,
IPv4: r.IPv4, IPv6: r.IPv6,
})
}
}
addSRV("_sip._udp", d.SRV.UDP)
addSRV("_sip._tcp", d.SRV.TCP)
addSRV("_sips._tcp", d.SRV.SIPS)
for _, ep := range d.Endpoints {
re := reportEndpoint{
Transport: string(ep.Transport),
TransportTag: transportTag(ep.Transport),
SRVPrefix: ep.SRVPrefix,
Target: ep.Target,
Port: ep.Port,
Address: ep.Address,
IsIPv6: ep.IsIPv6,
Reachable: ep.Reachable,
ReachableErr: ep.ReachableErr,
TLSVersion: ep.TLSVersion,
TLSCipher: ep.TLSCipher,
OptionsSent: ep.OptionsSent,
OptionsStatus: ep.OptionsStatus,
OptionsRTTMs: ep.OptionsRTTMs,
ServerHeader: ep.ServerHeader,
UserAgent: ep.UserAgent,
AllowMethods: ep.AllowMethods,
ContactURI: ep.ContactURI,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
OK: ep.OK(),
}
if re.OK {
re.StatusLabel = "OK"
re.StatusClass = "ok"
} else if ep.Reachable {
re.StatusLabel = "partial"
re.StatusClass = "warn"
} else {
re.StatusLabel = "unreachable"
re.StatusClass = "fail"
}
if meta, hit := tlsByAddr[ep.Address]; hit {
re.TLSPosture = meta
} else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit {
re.TLSPosture = meta
}
view.Endpoints = append(view.Endpoints, re)
}
return view
}
func transportTag(t Transport) string {
switch t {
case TransportUDP:
return "SIP/UDP"
case TransportTCP:
return "SIP/TCP"
case TransportTLS:
return "SIPS/TLS"
}
return string(t)
}
func endpointKey(host string, port uint16) string {
return net.JoinHostPort(host, strconv.Itoa(int(port)))
}
// indexTLSByAddress returns a map keyed by "host:port" pointing at a
// reportTLSPosture, so the template can match a related observation to
// the right endpoint.
func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture {
out := map[string]*reportTLSPosture{}
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if addr == "" {
continue
}
posture := &reportTLSPosture{
CheckedAt: r.CollectedAt,
ChainValid: v.ChainValid,
HostnameMatch: v.HostnameMatch,
NotAfter: v.NotAfter,
TLSVersion: v.TLSVersion,
}
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo {
continue
}
posture.Issues = append(posture.Issues, reportFix{
Severity: sev,
Code: is.Code,
Message: is.Message,
Fix: is.Fix,
})
}
out[addr] = posture
}
return out
}

204
checker/rule.go Normal file
View file

@ -0,0 +1,204 @@
package checker
import (
"context"
"fmt"
"net"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func Rule() sdk.CheckRule {
return &sipRule{}
}
type sipRule struct{}
func (r *sipRule) Name() string {
return "sip_server"
}
func (r *sipRule) Description() string {
return "Checks DNS resolution, reachability and OPTIONS response of a SIP/VoIP server"
}
func (r *sipRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data SIPData
if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SIP observation: %v", err),
Code: "sip.observation_error",
}}
}
issues := append([]Issue(nil), data.Issues...)
related, _ := obs.GetRelated(ctx, TLSRelatedKey)
issues = append(issues, tlsIssuesFromRelated(related)...)
byEndpoint := map[string][]Issue{}
var zoneIssues []Issue
for _, is := range issues {
if is.Endpoint == "" {
zoneIssues = append(zoneIssues, is)
continue
}
byEndpoint[is.Endpoint] = append(byEndpoint[is.Endpoint], is)
}
var out []sdk.CheckState
out = append(out, zoneState(&data, zoneIssues))
for _, ep := range data.Endpoints {
out = append(out, endpointState(ep, byEndpoint))
}
if len(out) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Message: "no SIP endpoint to evaluate",
Code: "sip.no_endpoint",
}}
}
return out
}
// zoneState summarises findings that are not tied to a specific endpoint:
// SRV/NAPTR lookup errors, missing transports, overall coverage.
func zoneState(data *SIPData, zoneIssues []Issue) sdk.CheckState {
var transports []string
if data.Coverage.WorkingUDP {
transports = append(transports, "udp")
}
if data.Coverage.WorkingTCP {
transports = append(transports, "tcp")
}
if data.Coverage.WorkingTLS {
transports = append(transports, "tls")
}
meta := map[string]any{
"working_udp": data.Coverage.WorkingUDP,
"working_tcp": data.Coverage.WorkingTCP,
"working_tls": data.Coverage.WorkingTLS,
"has_ipv4": data.Coverage.HasIPv4,
"has_ipv6": data.Coverage.HasIPv6,
"endpoints": len(data.Endpoints),
"issue_count": len(data.Issues),
}
worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(zoneIssues)
okMsg := fmt.Sprintf("SIP operational (%s, %d endpoints)", strings.Join(transports, "+"), len(data.Endpoints))
return buildCheckState(worst, data.Domain, "sip.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta)
}
// endpointState produces one CheckState per probed endpoint.
func endpointState(ep EndpointProbe, byEndpoint map[string][]Issue) sdk.CheckState {
subject := string(ep.Transport) + "://" + endpointSubject(ep)
meta := map[string]any{
"transport": string(ep.Transport),
"target": ep.Target,
"port": ep.Port,
"address": ep.Address,
"is_ipv6": ep.IsIPv6,
"reachable": ep.Reachable,
}
if ep.TLSVersion != "" {
meta["tls_version"] = ep.TLSVersion
}
if ep.OptionsRawCode != 0 {
meta["options_status"] = ep.OptionsStatus
meta["options_rtt_ms"] = ep.OptionsRTTMs
}
// Match endpoint issues by either the address or the SRV target
// (unresolvable-target issues key on ep.Target).
var epIssues []Issue
epIssues = append(epIssues, byEndpoint[ep.Address]...)
if ep.Target != "" && ep.Target != ep.Address {
epIssues = append(epIssues, byEndpoint[ep.Target]...)
}
worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(epIssues)
okMsg := "OPTIONS " + ep.OptionsStatus
if okMsg == "OPTIONS " {
okMsg = "reachable"
}
return buildCheckState(worst, subject, "sip.endpoint.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta)
}
// endpointSubject prefers the resolved address; falls back to target:port
// when no address was reached (e.g. unresolvable SRV target).
func endpointSubject(ep EndpointProbe) string {
if ep.Address != "" {
return ep.Address
}
if ep.Target != "" {
return net.JoinHostPort(ep.Target, strconv.Itoa(int(ep.Port)))
}
return strconv.Itoa(int(ep.Port))
}
func buildCheckState(worst sdk.Status, subject, okCode, okMsg, firstCrit, firstWarn string, critMsgs, warnMsgs []string, meta map[string]any) sdk.CheckState {
switch worst {
case sdk.StatusOK:
return sdk.CheckState{Status: sdk.StatusOK, Subject: subject, Code: okCode, Message: okMsg, Meta: meta}
case sdk.StatusInfo:
return sdk.CheckState{Status: sdk.StatusInfo, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
case sdk.StatusWarn:
return sdk.CheckState{Status: sdk.StatusWarn, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta}
default:
return sdk.CheckState{Status: sdk.StatusCrit, Subject: subject, Code: firstCrit, Message: joinTop(critMsgs, 2), Meta: meta}
}
}
// reduceIssues collapses a set of issues into a worst status, first codes
// per severity, and separated message lists.
// sdk.Status values are ordered numerically: OK < Info < Warn < Crit.
func reduceIssues(issues []Issue) (worst sdk.Status, firstCrit, firstWarn string, critMsgs, warnMsgs []string) {
worst = sdk.StatusOK
for _, is := range issues {
switch is.Severity {
case SeverityCrit:
if worst < sdk.StatusCrit {
worst = sdk.StatusCrit
}
if firstCrit == "" {
firstCrit = is.Code
}
critMsgs = append(critMsgs, is.Message)
case SeverityWarn:
if worst < sdk.StatusWarn {
worst = sdk.StatusWarn
}
if firstWarn == "" {
firstWarn = is.Code
}
warnMsgs = append(warnMsgs, is.Message)
case SeverityInfo:
if worst < sdk.StatusInfo {
worst = sdk.StatusInfo
}
if firstWarn == "" {
firstWarn = is.Code
}
warnMsgs = append(warnMsgs, is.Message)
}
}
return
}
func joinTop(msgs []string, n int) string {
if len(msgs) == 0 {
return ""
}
if len(msgs) <= n {
return strings.Join(msgs, "; ")
}
return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n)
}

171
checker/sip_probe.go Normal file
View file

@ -0,0 +1,171 @@
package checker
import (
"bufio"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
)
// sipResponse is the minimal parsed form of a SIP response line + headers
// we need to power the rule and the report.
type sipResponse struct {
StatusCode int
StatusPhrase string
Server string
UserAgent string // some stacks use this instead of Server
Contact string
Allow []string
}
// buildOptionsRequest returns a ready-to-send SIP OPTIONS message for
// the given target / transport pair.
//
// The message is deliberately minimal and RFC 3261 §11-conforming: just
// enough for any SIP stack to recognise it as an OPTIONS ping.
func buildOptionsRequest(target string, port uint16, transport Transport, localAddr string, userAgent string) string {
tUpper := "UDP"
switch transport {
case TransportTCP:
tUpper = "TCP"
case TransportTLS:
tUpper = "TLS"
}
branch := "z9hG4bK-" + randHex(8)
tag := randHex(6)
callID := randHex(12) + "@happydomain.org"
sipScheme := "sip"
if transport == TransportTLS {
sipScheme = "sips"
}
requestURI := fmt.Sprintf("%s:%s:%d;transport=%s", sipScheme, target, port, strings.ToLower(tUpper))
if transport == TransportTLS {
requestURI = fmt.Sprintf("%s:%s:%d", sipScheme, target, port)
}
// Via uses the remote transport name; local address is a best-effort
// hint that servers echo back via ;rport. We don't actually listen
// on it — this is a one-shot probe.
lines := []string{
"OPTIONS " + requestURI + " SIP/2.0",
"Via: SIP/2.0/" + tUpper + " " + localAddr + ";branch=" + branch + ";rport",
"Max-Forwards: 70",
"From: \"happyDomain\" <sip:check@happydomain.org>;tag=" + tag,
"To: <" + sipScheme + ":ping@" + target + ">",
"Call-ID: " + callID,
"CSeq: 1 OPTIONS",
"User-Agent: " + userAgent,
"Accept: application/sdp",
"Content-Length: 0",
}
return strings.Join(lines, "\r\n") + "\r\n\r\n"
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// parseSIPResponse reads a SIP response from r and extracts the fields
// we care about. It tolerates bodies (reads Content-Length bytes) and
// truncates defensively so a chatty server can't OOM us.
func parseSIPResponse(r io.Reader) (*sipResponse, error) {
br := bufio.NewReaderSize(io.LimitReader(r, 16*1024), 8*1024)
statusLine, err := br.ReadString('\n')
if err != nil {
return nil, fmt.Errorf("read status line: %w", err)
}
statusLine = strings.TrimRight(statusLine, "\r\n")
if !strings.HasPrefix(statusLine, "SIP/2.0 ") && !strings.HasPrefix(statusLine, "SIP/2.1 ") {
return nil, fmt.Errorf("not a SIP response: %q", trunc(statusLine, 80))
}
_, rest, _ := strings.Cut(statusLine, " ")
parts := strings.SplitN(rest, " ", 2)
if len(parts) < 1 {
return nil, errors.New("malformed status line")
}
code, convErr := strconv.Atoi(strings.TrimSpace(parts[0]))
if convErr != nil {
return nil, fmt.Errorf("non-numeric status code %q", parts[0])
}
phrase := ""
if len(parts) == 2 {
phrase = strings.TrimSpace(parts[1])
}
resp := &sipResponse{StatusCode: code, StatusPhrase: phrase}
for {
line, err := br.ReadString('\n')
if err != nil && err != io.EOF {
return resp, fmt.Errorf("read header: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
// Fold continuation lines per RFC 3261 §7.3.1: a header line
// starting with whitespace continues the previous one. We don't
// need perfect fidelity, so just skip continuations.
if line[0] == ' ' || line[0] == '\t' {
continue
}
colon := strings.IndexByte(line, ':')
if colon < 0 {
continue
}
name := strings.ToLower(strings.TrimSpace(line[:colon]))
value := strings.TrimSpace(line[colon+1:])
switch name {
case "server", "s":
resp.Server = value
case "user-agent":
resp.UserAgent = value
case "contact", "m":
if resp.Contact == "" {
resp.Contact = value
}
case "allow":
// SIP allows multiple Allow headers *or* comma-separated;
// handle both.
for m := range strings.SplitSeq(value, ",") {
m = strings.TrimSpace(strings.ToUpper(m))
if m != "" {
resp.Allow = append(resp.Allow, m)
}
}
}
if err == io.EOF {
break
}
}
return resp, nil
}
func trunc(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
// localAddrFor returns a best-effort "host:port" describing the local
// side of conn, or "0.0.0.0:0" if conn is nil (UDP probe before dial).
func localAddrFor(conn net.Conn) string {
if conn == nil {
return "0.0.0.0:0"
}
return conn.LocalAddr().String()
}

132
checker/tls_related.go Normal file
View file

@ -0,0 +1,132 @@
package checker
import (
"encoding/json"
"net"
"strconv"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// TLSRelatedKey is the observation key the downstream TLS checker
// publishes. Same value as the XMPP checker uses, by cross-checker
// convention.
const TLSRelatedKey sdk.ObservationKey = "tls_probes"
// tlsProbeView is our permissive view of a TLS checker's payload — we
// read only the fields we need.
type tlsProbeView struct {
Host string `json:"host,omitempty"`
Port uint16 `json:"port,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
CipherSuite string `json:"cipher_suite,omitempty"`
HostnameMatch *bool `json:"hostname_match,omitempty"`
ChainValid *bool `json:"chain_valid,omitempty"`
NotAfter time.Time `json:"not_after,omitempty"`
Issues []struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
} `json:"issues,omitempty"`
}
// address returns "host:port" used as our matching key against SIP
// endpoints. Falls back to Endpoint when host/port are unset.
func (v *tlsProbeView) address() string {
if v.Endpoint != "" {
return v.Endpoint
}
if v.Host != "" && v.Port != 0 {
return net.JoinHostPort(v.Host, strconv.Itoa(int(v.Port)))
}
return ""
}
func parseTLSRelated(r sdk.RelatedObservation) *tlsProbeView {
var v tlsProbeView
if err := json.Unmarshal(r.Data, &v); err != nil {
return nil
}
return &v
}
// tlsIssuesFromRelated converts downstream TLS observations into Issue
// entries for the SIP aggregation. Structured issues from the TLS
// checker are forwarded with a "sip.tls." prefix so origin is obvious;
// flag-only payloads are summarised into one synthesised issue.
func tlsIssuesFromRelated(related []sdk.RelatedObservation) []Issue {
var out []Issue
for _, r := range related {
v := parseTLSRelated(r)
if v == nil {
continue
}
addr := v.address()
if len(v.Issues) > 0 {
for _, is := range v.Issues {
sev := strings.ToLower(is.Severity)
switch sev {
case SeverityCrit, SeverityWarn, SeverityInfo:
default:
continue
}
code := is.Code
if code == "" {
code = "tls.unknown"
}
out = append(out, Issue{
Code: "sip.tls." + code,
Severity: sev,
Message: strings.TrimSpace("TLS on " + addr + ": " + is.Message),
Fix: is.Fix,
Endpoint: addr,
})
}
continue
}
// Flag-only payload: synthesise a summary issue.
sev := v.worstSeverity()
if sev == "" {
continue
}
msg := "TLS issue reported on " + addr
switch {
case v.ChainValid != nil && !*v.ChainValid:
msg = "Invalid certificate chain on " + addr
case v.HostnameMatch != nil && !*v.HostnameMatch:
msg = "Certificate does not cover the SIP host on " + addr
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0:
msg = "Certificate expired on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
case !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour:
msg = "Certificate expiring soon on " + addr + " (" + v.NotAfter.Format(time.RFC3339) + ")"
}
out = append(out, Issue{
Code: "sip.tls.probe",
Severity: sev,
Message: msg,
Fix: "See the TLS checker report for details.",
Endpoint: addr,
})
}
return out
}
func (v *tlsProbeView) worstSeverity() string {
if v.ChainValid != nil && !*v.ChainValid {
return SeverityCrit
}
if v.HostnameMatch != nil && !*v.HostnameMatch {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 0 {
return SeverityCrit
}
if !v.NotAfter.IsZero() && time.Until(v.NotAfter) < 14*24*time.Hour {
return SeverityWarn
}
return ""
}

159
checker/types.go Normal file
View file

@ -0,0 +1,159 @@
// Package checker implements the SIP / VoIP server checker for
// happyDomain.
//
// It probes a domain's SIP deployment end-to-end (NAPTR + SRV
// resolution per RFC 3263, reachability on UDP / TCP / TLS, SIP
// OPTIONS ping per RFC 3261) and reports actionable findings.
//
// TLS certificate chain / SAN / expiry / cipher posture is
// intentionally out of scope — the forthcoming checker-tls covers
// that. SIPS endpoints are published as "tls" discovery endpoints
// so checker-tls can probe them; its findings are folded back into
// this report via GetRelated("tls_probes"). See
// happydomain3/docs/checker-discovery-endpoint.md.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
const ObservationKeySIP sdk.ObservationKey = "sip"
// Transport identifies one of the three SIP transports we probe.
type Transport string
const (
TransportUDP Transport = "udp"
TransportTCP Transport = "tcp"
TransportTLS Transport = "tls" // SIPS, direct TLS on connect
)
// SIPData is the full observation stored per run.
type SIPData struct {
Domain string `json:"domain"`
RunAt string `json:"run_at"`
NAPTR []NAPTRRecord `json:"naptr,omitempty"`
SRV SRVLookup `json:"srv"`
Endpoints []EndpointProbe `json:"endpoints"`
Coverage Coverage `json:"coverage"`
Issues []Issue `json:"issues"`
}
// NAPTRRecord is a subset of a NAPTR record enough to reason about
// SIP service resolution.
type NAPTRRecord struct {
Service string `json:"service"` // e.g. "SIP+D2T"
Regexp string `json:"regexp,omitempty"`
Replacement string `json:"replacement,omitempty"`
Flags string `json:"flags,omitempty"`
Order uint16 `json:"order"`
Preference uint16 `json:"preference"`
}
// SRVLookup groups the SRV records found per transport plus per-prefix
// lookup errors and a fallback marker when no SRV was published.
type SRVLookup struct {
UDP []SRVRecord `json:"udp,omitempty"`
TCP []SRVRecord `json:"tcp,omitempty"`
SIPS []SRVRecord `json:"sips,omitempty"`
// Errors per-set, keyed by SRV prefix ("_sip._udp.", …).
Errors map[string]string `json:"errors,omitempty"`
// FallbackProbed is true when no SRV was published and we probed
// the bare domain on 5060 / 5061.
FallbackProbed bool `json:"fallback_probed,omitempty"`
}
// SRVRecord captures one SRV plus the addresses it resolves to.
type SRVRecord struct {
Target string `json:"target"`
Port uint16 `json:"port"`
Priority uint16 `json:"priority"`
Weight uint16 `json:"weight"`
IPv4 []string `json:"ipv4,omitempty"`
IPv6 []string `json:"ipv6,omitempty"`
}
// EndpointProbe is the result of probing one (transport, target, address).
type EndpointProbe struct {
Transport Transport `json:"transport"`
SRVPrefix string `json:"srv_prefix"`
Target string `json:"target"`
Port uint16 `json:"port"`
Address string `json:"address"`
IsIPv6 bool `json:"is_ipv6,omitempty"`
Reachable bool `json:"reachable"`
ReachableErr string `json:"reachable_err,omitempty"`
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
OptionsSent bool `json:"options_sent,omitempty"`
OptionsStatus string `json:"options_status,omitempty"` // e.g. "200 OK"
OptionsRawCode int `json:"options_raw_code,omitempty"`
OptionsRTTMs int64 `json:"options_rtt_ms,omitempty"`
ServerHeader string `json:"server_header,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
AllowMethods []string `json:"allow_methods,omitempty"`
ContactURI string `json:"contact_uri,omitempty"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// OK reports whether this probe counts as a working SIP endpoint
// (reachable + 2xx answer to OPTIONS).
func (e EndpointProbe) OK() bool {
return e.Reachable && e.OptionsSent && e.OptionsRawCode >= 200 && e.OptionsRawCode < 300
}
// Coverage is a roll-up of the per-endpoint results.
type Coverage struct {
HasIPv4 bool `json:"has_ipv4"`
HasIPv6 bool `json:"has_ipv6"`
WorkingUDP bool `json:"working_udp"`
WorkingTCP bool `json:"working_tcp"`
WorkingTLS bool `json:"working_tls"`
AnyWorking bool `json:"any_working"`
}
// Issue is a structured finding. The rule reduces issues to a worst
// severity; the report renders them as an actionable fix list.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"` // "info" | "warn" | "crit"
Message string `json:"message"`
Fix string `json:"fix,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
}
// Severities. Match checker-xmpp conventions for cross-checker
// consistency.
const (
SeverityInfo = "info"
SeverityWarn = "warn"
SeverityCrit = "crit"
)
// Issue codes. Keep short, stable, prefixed with "sip." so downstream
// consumers can filter.
const (
CodeNoSRV = "sip.no_srv"
CodeOnlyUDP = "sip.srv.only_udp"
CodeNoTLS = "sip.srv.no_tls"
CodeSRVServfail = "sip.srv.servfail"
CodeSRVTargetUnresolved = "sip.srv.target_unresolvable"
CodeNAPTRServfail = "sip.naptr.servfail"
CodeTCPUnreachable = "sip.tcp.unreachable"
CodeUDPUnreachable = "sip.udp.unreachable"
CodeTLSHandshake = "sip.tls.handshake_failed"
CodeOptionsNoAnswer = "sip.options.no_response"
CodeOptionsNon2xx = "sip.options.non_2xx"
CodeOptionsNoAllow = "sip.options.no_allow"
CodeOptionsNoInvite = "sip.options.no_invite"
CodeFallbackProbed = "sip.fallback_probed"
CodeNoIPv6 = "sip.no_ipv6"
CodeAllDown = "sip.all_endpoints_down"
)

17
go.mod Normal file
View file

@ -0,0 +1,17 @@
module git.happydns.org/checker-sip
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.2.0
git.happydns.org/checker-tls v0.2.0
github.com/miekg/dns v1.1.72
)
require (
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)

18
go.sum Normal file
View file

@ -0,0 +1,18 @@
git.happydns.org/checker-sdk-go v1.2.0 h1:v4MpKAz0W3PwP+bxx3pya8w893sVH5xTD1of1cc0TV8=
git.happydns.org/checker-sdk-go v1.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.2.0 h1:2dYpcePBylUc3le76fFlLbxraiLpGESmOhx4NfD7REM=
git.happydns.org/checker-tls v0.2.0/go.mod h1:0ZSG0CTP007SHBPE7qInESVIOcW+xgucHUhHgj6MeZ8=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=

28
main.go Normal file
View file

@ -0,0 +1,28 @@
package main
import (
"flag"
"log"
sdk "git.happydns.org/checker-sdk-go/checker"
sip "git.happydns.org/checker-sip/checker"
)
// Version is the standalone binary's version. It defaults to "custom-build"
// and is meant to be overridden by the CI at link time:
//
// go build -ldflags "-X main.Version=1.2.3" .
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
sip.Version = Version
server := sdk.NewServer(sip.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

19
plugin/plugin.go Normal file
View file

@ -0,0 +1,19 @@
// Command plugin is the happyDomain plugin entrypoint for the SIP checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
sdk "git.happydns.org/checker-sdk-go/checker"
sip "git.happydns.org/checker-sip/checker"
)
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the
// .so file.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
sip.Version = Version
return sip.Definition(), sip.Provider(), nil
}