Initial commit
This commit is contained in:
commit
9cae9b693c
19 changed files with 2311 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-sip
|
||||||
|
checker-sip.so
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal 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
21
LICENSE
Normal 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
28
Makefile
Normal 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
25
NOTICE
Normal 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
|
||||||
100
README.md
Normal file
100
README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## Why a raw SIP OPTIONS request rather than `emiago/sipgo`?
|
||||||
|
|
||||||
|
`emiago/sipgo` is a full SIP user-agent stack. Its lifecycle expects a
|
||||||
|
stable bidirectional transport with a local listener, which is overkill
|
||||||
|
for a one-shot outbound OPTIONS probe — and problematic in a containerised
|
||||||
|
checker that may be behind NAT. This checker sends a minimal RFC 3261
|
||||||
|
OPTIONS message (~50 lines of text handling in `checker/sip_probe.go`)
|
||||||
|
over `net.Conn` / `*tls.Conn`. The same wire format works for UDP, TCP
|
||||||
|
and TLS. Swap in `sipgo` later if richer interactions (REGISTER, media,
|
||||||
|
dialogs) are ever needed.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Common failure scenarios addressed directly in the report
|
||||||
|
|
||||||
|
- Missing SRV records (zone not publishing any `_sip.*` SRV).
|
||||||
|
- Only `_sip._udp` published — modern trunks (Twilio, OVH, Orange)
|
||||||
|
require TCP/TLS.
|
||||||
|
- SRV target that doesn't resolve.
|
||||||
|
- Port closed / firewalled on the SRV target.
|
||||||
|
- Server drops `OPTIONS` silently (common with default Asterisk /
|
||||||
|
Kamailio configs).
|
||||||
|
- `OPTIONS` answered with a non-2xx status (ACL, routing, overload).
|
||||||
|
- TLS issues surfaced through the TLS checker (expired cert, SAN
|
||||||
|
mismatch, chain invalid) rendered per-endpoint.
|
||||||
|
|
||||||
|
The HTML report's header banner ranks these top-down with concrete fix
|
||||||
|
hints so the user can go straight to the change they need to make.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the **MIT License** (see `LICENSE`). Third-party
|
||||||
|
attributions for `checker-sdk-go` (Apache-2.0) are recorded in `NOTICE`.
|
||||||
572
checker/collect.go
Normal file
572
checker/collect.go
Normal file
|
|
@ -0,0 +1,572 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"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}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAllInto(ctx, resolver, data.SRV.UDP)
|
||||||
|
resolveAllInto(ctx, resolver, data.SRV.TCP)
|
||||||
|
resolveAllInto(ctx, resolver, data.SRV.SIPS)
|
||||||
|
|
||||||
|
probeSet(ctx, data, "_sip._udp.", TransportUDP, data.SRV.UDP, perEndpoint)
|
||||||
|
probeSet(ctx, data, "_sip._tcp.", TransportTCP, data.SRV.TCP, perEndpoint)
|
||||||
|
probeSet(ctx, data, "_sips._tcp.", TransportTLS, data.SRV.SIPS, perEndpoint)
|
||||||
|
|
||||||
|
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, data *SIPData, prefix string, t Transport, records []SRVRecord, timeout time.Duration) {
|
||||||
|
for _, rec := range records {
|
||||||
|
addrs := allAddrs(rec)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
data.Endpoints = append(data.Endpoints, EndpointProbe{
|
||||||
|
Transport: t,
|
||||||
|
SRVPrefix: prefix,
|
||||||
|
Target: rec.Target,
|
||||||
|
Port: rec.Port,
|
||||||
|
Error: "no A/AAAA records for target",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, a := range addrs {
|
||||||
|
data.Endpoints = append(data.Endpoints, probeEndpoint(ctx, t, prefix, rec, a, timeout))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
69
checker/definition.go
Normal 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
76
checker/interactive.go
Normal 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
51
checker/provider.go
Normal 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
|
||||||
|
}
|
||||||
538
checker/report.go
Normal file
538
checker/report.go
Normal file
|
|
@ -0,0 +1,538 @@
|
||||||
|
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 reportNAPTR struct {
|
||||||
|
Service string
|
||||||
|
Order uint16
|
||||||
|
Preference uint16
|
||||||
|
Flags string
|
||||||
|
Regexp string
|
||||||
|
Replacement string
|
||||||
|
}
|
||||||
|
|
||||||
|
type reportData struct {
|
||||||
|
Domain string
|
||||||
|
RunAt string
|
||||||
|
StatusLabel string
|
||||||
|
StatusClass string
|
||||||
|
HasIssues bool
|
||||||
|
Fixes []reportFix
|
||||||
|
NAPTR []reportNAPTR
|
||||||
|
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}}✓{{else}}✗{{end}} UDP</span>
|
||||||
|
<span class="chip {{if .WorkingTCP}}ok{{else}}fail{{end}}">{{if .WorkingTCP}}✓{{else}}✗{{end}} TCP</span>
|
||||||
|
<span class="chip {{if .WorkingTLS}}ok{{else}}fail{{end}}">{{if .WorkingTLS}}✓{{else}}✗{{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}} · {{.Endpoint}}{{end}}</div>
|
||||||
|
<div class="msg">{{.Message}}</div>
|
||||||
|
{{if .Fix}}<div class="how">→ {{.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}} · {{.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">✓ connected</span>{{else}}<span class="check-fail">✗ {{if .ReachableErr}}{{.ReachableErr}}{{else}}unreachable{{end}}</span>{{end}}
|
||||||
|
</dd>
|
||||||
|
{{if or .TLSVersion .TLSCipher}}
|
||||||
|
<dt>TLS</dt><dd>{{.TLSVersion}}{{if and .TLSVersion .TLSCipher}} · {{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}} · {{.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">✓ chain valid</span>{{else}}<span class="check-fail">✗ chain invalid</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .TLSPosture.HostnameMatch}}
|
||||||
|
· {{if deref .TLSPosture.HostnameMatch}}<span class="check-ok">✓ hostname match</span>{{else}}<span class="check-fail">✗ hostname mismatch</span>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if not .TLSPosture.NotAfter.IsZero}}
|
||||||
|
· 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">→ {{.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, n := range d.NAPTR {
|
||||||
|
view.NAPTR = append(view.NAPTR, reportNAPTR{
|
||||||
|
Service: n.Service, Order: n.Order, Preference: n.Preference,
|
||||||
|
Flags: n.Flags, Regexp: n.Regexp, Replacement: n.Replacement,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
256
checker/rule.go
Normal file
256
checker/rule.go
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
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)
|
||||||
|
|
||||||
|
subject := data.Domain
|
||||||
|
switch worst {
|
||||||
|
case sdk.StatusOK:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: subject,
|
||||||
|
Code: "sip.ok",
|
||||||
|
Message: fmt.Sprintf("SIP operational (%s, %d endpoints)", strings.Join(transports, "+"), len(data.Endpoints)),
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
switch worst {
|
||||||
|
case sdk.StatusOK:
|
||||||
|
msg := "OPTIONS " + ep.OptionsStatus
|
||||||
|
if msg == "OPTIONS " {
|
||||||
|
msg = "reachable"
|
||||||
|
}
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Subject: subject,
|
||||||
|
Code: "sip.endpoint.ok",
|
||||||
|
Message: msg,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// reduceIssues collapses a set of issues into a worst status, first codes
|
||||||
|
// per severity, and separated message lists.
|
||||||
|
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
171
checker/sip_probe.go
Normal 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", sipScheme, target, port)
|
||||||
|
if transport == TransportUDP || transport == TransportTCP {
|
||||||
|
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
|
||||||
|
// 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.Split(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()
|
||||||
|
}
|
||||||
147
checker/tls_related.go
Normal file
147
checker/tls_related.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
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 {
|
||||||
|
worst := ""
|
||||||
|
for _, is := range v.Issues {
|
||||||
|
switch strings.ToLower(is.Severity) {
|
||||||
|
case SeverityCrit:
|
||||||
|
return SeverityCrit
|
||||||
|
case SeverityWarn:
|
||||||
|
if worst != SeverityCrit {
|
||||||
|
worst = SeverityWarn
|
||||||
|
}
|
||||||
|
case SeverityInfo:
|
||||||
|
if worst == "" {
|
||||||
|
worst = SeverityInfo
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 worst
|
||||||
|
}
|
||||||
159
checker/types.go
Normal file
159
checker/types.go
Normal 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
17
go.mod
Normal 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
18
go.sum
Normal 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
28
main.go
Normal 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
19
plugin/plugin.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue