Initial commit

This commit is contained in:
nemunaire 2026-04-21 21:50:49 +07:00
commit f27b7397f7
20 changed files with 1471 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-tls
checker-tls.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-tls .
FROM scratch
COPY --from=builder /checker-tls /checker-tls
EXPOSE 8080
ENTRYPOINT ["/checker-tls"]

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.

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
CHECKER_NAME := checker-tls
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 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) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-dummy
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 developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

153
README.md Normal file
View file

@ -0,0 +1,153 @@
# checker-tls
TLS posture checker for happyDomain.
Consumes `DiscoveryEntry` records of type `tls.endpoint.v1` published by
service checkers (xmpp, srv, caldav, carddav, …), performs a real TCP
dial, optional protocol-specific STARTTLS upgrade, and TLS handshake on
each, and exports per-endpoint posture under the observation key
`tls_probes`.
## For producers: the `contract` package
Service checkers that want TLS probing on the endpoints they discover
should depend on `git.happydns.org/checker-tls/contract` and emit
`DiscoveryEntry` records through it. The contract is a tiny
producer↔consumer package; it has no dependency on checker-tls itself
beyond the SDK.
```go
import (
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries is the sdk.DiscoveryPublisher hook on your provider.
func (p *xmppProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d := data.(*XMPPData)
var out []sdk.DiscoveryEntry
for _, srv := range d.Client {
e, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: srv.Target,
Port: srv.Port,
SNI: d.Domain, // only when it differs from Host
STARTTLS: "xmpp-client",
RequireSTARTTLS: true,
})
if err != nil {
return nil, err
}
out = append(out, e)
}
return out, nil
}
```
### `TLSEndpoint` fields
| Field | Meaning |
| ----------------- | ----------------------------------------------------------------------- |
| `Host` | Target hostname (trailing dot tolerated). |
| `Port` | TCP port. |
| `SNI` | TLS SNI; leave empty when equal to `Host`. |
| `STARTTLS` | SRV service name of the upgrade protocol; empty for direct TLS. |
| `RequireSTARTTLS` | `true` when absence of STARTTLS must be reported crit, not opportunistic. |
### Helpers
| Symbol | Role |
| ------------------------ | ------------------------------------------------------------------------------ |
| `contract.Type` | The `DiscoveryEntry.Type` string (`"tls.endpoint.v1"`). |
| `contract.NewEntry(ep)` | Builds a `DiscoveryEntry` with a deterministic `Ref` and marshaled payload. |
| `contract.Ref(ep)` | Exposes the Ref derivation so producers can reference it ahead of time. |
| `contract.ParseEntry(e)` | Decodes a single entry; errors on wrong `Type` or malformed payload. |
| `contract.ParseEntries(s)` | Filters a slice to this contract, decodes each, returns warnings for the malformed. |
The `Ref` is a deterministic 16-hex-digit hash of
`(Host, Port, effective SNI, STARTTLS proto, RequireSTARTTLS)`. It
appears as the key in `tls_probes.probes` and as the value consumers
will see in any future `RelatedObservation.Ref` field.
### Supported STARTTLS protocols
| `TLSEndpoint.STARTTLS` | Upgrade performed |
| ---------------------- | ---------------------------------------------- |
| *(empty)* | Direct TLS on connect |
| `smtp` | ESMTP EHLO + STARTTLS (RFC 3207) |
| `submission` | Same as `smtp` |
| `imap` | IMAP CAPABILITY + STARTTLS (RFC 3501) |
| `pop3` | POP3 CAPA + STLS (RFC 2595) |
| `xmpp-client` | XMPP c2s stream + `<starttls/>` (RFC 6120) |
| `xmpp-server` | XMPP s2s stream + `<starttls/>` (RFC 6120) |
| any other value | Probe reports a `handshake_failed` issue |
The protocol table lives in `checker/starttls.go`; new entries are added
there, not in this repo's consumers.
### Versioning
`DiscoveryEntry.Type` ends in `.v1`. If a future schema adds a field the
consumer can't ignore safely, this package will introduce
`tls.endpoint.v2` alongside a new `TLSEndpoint` type, and old consumers
will silently skip v2 entries (they do not match the expected `Type`).
## Observation payload
Observation data written under `tls_probes`:
```json
{
"probes": {
"<ref>": {
"host": "example.net",
"port": 5222,
"endpoint": "example.net:5222",
"type": "starttls-xmpp-client",
"sni": "example.net",
"tls_version": "TLS1.3",
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"hostname_match": true,
"chain_valid": true,
"not_after": "2026-07-01T00:00:00Z",
"issuer": "Let's Encrypt",
"issues": []
}
},
"collected_at": "2026-04-21T12:34:56Z"
}
```
The map is keyed by `contract.Ref(ep)` — the same value the host exposes
on the lineage side so that a consumer knows which probe corresponds to
which entry it originally published.
The `type` field inside each probe preserves the human-readable
`"tls"` / `"starttls-<proto>"` shape for backward compatibility with
existing downstream parsers.
## Issues reported
- `tcp_unreachable` — dial failed.
- `handshake_failed` — TLS handshake or STARTTLS upgrade failed.
- `starttls_not_offered` — server didn't advertise STARTTLS. Severity is
`crit` when `TLSEndpoint.RequireSTARTTLS` is `true`, `warn` otherwise.
- `chain_invalid` — leaf does not chain to a system-trusted root.
- `hostname_mismatch` — cert SANs don't cover the SNI.
- `expired` / `expiring_soon` — cert expiry posture.
- `weak_tls_version` — negotiated TLS < 1.2.
## Options
| Id | Type | Default | Description |
| ---------------- | ------ | ------- | -------------------------------------------- |
| `probeTimeoutMs` | number | 10000 | Per-endpoint dial + handshake timeout in ms. |
## Running
```bash
# Plugin (loaded by happyDomain at startup)
make plugin
# Standalone HTTP server
make && ./checker-tls -listen :8080
```

63
checker/collect.go Normal file
View file

@ -0,0 +1,63 @@
package checker
import (
"context"
"fmt"
"log"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/checker-tls/contract"
)
func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
raw, ok := sdk.GetOption[[]sdk.DiscoveryEntry](opts, OptionEndpoints)
if !ok {
return nil, fmt.Errorf("no discovery entries in options: did the host wire AutoFillDiscoveryEntries?")
}
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
if timeoutMs <= 0 {
timeoutMs = DefaultProbeTimeoutMs
}
timeout := time.Duration(timeoutMs) * time.Millisecond
entries, warnings := contract.ParseEntries(raw)
for _, w := range warnings {
log.Printf("checker-tls: discarding malformed entry: %v", w)
}
// An empty entry set is not an error: it is the steady state on any
// target where no producer has published yet, and the first run after
// a fresh publication when the producer hasn't finished its own cycle.
// The rule surfaces this as StatusUnknown rather than StatusError so a
// freshly-enrolled domain doesn't flap red.
if len(entries) == 0 {
return &TLSData{Probes: map[string]TLSProbe{}, CollectedAt: time.Now()}, nil
}
probes := make(map[string]TLSProbe, len(entries))
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProbes)
for _, e := range entries {
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
pr := probe(ctx, e.Endpoint, timeout)
log.Printf("checker-tls: %s %s:%d → tls=%s issues=%d elapsed=%dms err=%q",
pr.Type, pr.Host, pr.Port, pr.TLSVersion, len(pr.Issues), pr.ElapsedMS, pr.Error)
mu.Lock()
probes[e.Ref] = pr
mu.Unlock()
}()
}
wg.Wait()
return &TLSData{
Probes: probes,
CollectedAt: time.Now(),
}, nil
}

52
checker/definition.go Normal file
View file

@ -0,0 +1,52 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version defaults to "built-in"; standalone and plugin builds override it via
// -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// Definition returns the CheckerDefinition for the TLS checker.
func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "tls",
Name: "TLS",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToDomain: true,
},
ObservationKeys: []sdk.ObservationKey{ObservationKeyTLSProbes},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Per-endpoint probe timeout (ms)",
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
Default: float64(DefaultProbeTimeoutMs),
},
},
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionEndpoints,
Label: "Discovery entries",
Description: "Entries published by other checkers for this domain; this checker decodes the tls.endpoint.v1 contract and ignores the rest.",
AutoFill: sdk.AutoFillDiscoveryEntries,
Hide: true,
},
},
},
Rules: []sdk.CheckRule{
Rule(),
},
Interval: &sdk.CheckIntervalSpec{
Min: 6 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
}
}

231
checker/prober.go Normal file
View file

@ -0,0 +1,231 @@
package checker
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"git.happydns.org/checker-tls/contract"
)
// probeTypeString renders the TLSProbe.Type string from a TLSEndpoint.
// Observation consumers already parse this field in its "tls" /
// "starttls-<proto>" shape; the contract-level split of direct vs.
// STARTTLS is collapsed back here so the wire format of tls_probes
// stays unchanged.
func probeTypeString(ep contract.TLSEndpoint) string {
if ep.STARTTLS == "" {
return "tls"
}
return "starttls-" + ep.STARTTLS
}
// probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the
// given endpoint and returns a populated TLSProbe. It never returns an error:
// transport/handshake failures are recorded on the probe so the caller can
// still surface them in the report.
func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe {
start := time.Now()
host := strings.TrimSuffix(ep.Host, ".")
addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port)))
sni := ep.SNI
if sni == "" {
sni = host
}
p := TLSProbe{
Host: host,
Port: ep.Port,
Endpoint: addr,
Type: probeTypeString(ep),
SNI: sni,
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := &net.Dialer{}
conn, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil {
p.Error = "dial: " + err.Error()
p.Issues = append(p.Issues, Issue{
Code: "tcp_unreachable",
Severity: SeverityCrit,
Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err),
Fix: "Check DNS, firewall, and that the service listens on this port.",
})
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
defer conn.Close()
if deadline, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
tlsConn, err := handshake(conn, ep, sni)
if err != nil {
p.Error = err.Error()
p.Issues = append(p.Issues, classifyHandshakeError(ep, err))
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
defer tlsConn.Close()
state := tlsConn.ConnectionState()
p.TLSVersion = tls.VersionName(state.Version)
p.CipherSuite = tls.CipherSuiteName(state.CipherSuite)
if len(state.PeerCertificates) == 0 {
p.Issues = append(p.Issues, Issue{
Code: "no_peer_cert",
Severity: SeverityCrit,
Message: "Server presented no certificate.",
})
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
leaf := state.PeerCertificates[0]
p.NotAfter = leaf.NotAfter
p.Issuer = leaf.Issuer.CommonName
p.Subject = leaf.Subject.CommonName
p.DNSNames = append(p.DNSNames, leaf.DNSNames...)
hostnameMatch := leaf.VerifyHostname(sni) == nil
p.HostnameMatch = &hostnameMatch
// Chain verification against system roots, using intermediates presented
// by the server. We run this independently from Go's tls.Config
// verification so we can report a dedicated "chain invalid" issue rather
// than failing the whole handshake.
intermediates := x509.NewCertPool()
for _, c := range state.PeerCertificates[1:] {
intermediates.AddCert(c)
}
now := time.Now()
_, verifyErr := leaf.Verify(x509.VerifyOptions{
DNSName: sni,
Intermediates: intermediates,
CurrentTime: now,
})
chainValid := verifyErr == nil
p.ChainValid = &chainValid
if !chainValid {
msg := "Invalid certificate chain"
if verifyErr != nil {
msg = "Invalid certificate chain: " + verifyErr.Error()
}
p.Issues = append(p.Issues, Issue{
Code: "chain_invalid",
Severity: SeverityCrit,
Message: msg,
Fix: "Serve the full intermediate chain and ensure the root is trusted.",
})
}
if !hostnameMatch {
p.Issues = append(p.Issues, Issue{
Code: "hostname_mismatch",
Severity: SeverityCrit,
Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")),
Fix: "Re-issue the certificate with a matching SAN.",
})
}
if leaf.NotAfter.Before(now) {
p.Issues = append(p.Issues, Issue{
Code: "expired",
Severity: SeverityCrit,
Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339),
Fix: "Renew the certificate.",
})
} else if leaf.NotAfter.Sub(now) < 14*24*time.Hour {
p.Issues = append(p.Issues, Issue{
Code: "expiring_soon",
Severity: SeverityWarn,
Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")",
Fix: "Renew before expiry.",
})
}
if state.Version < tls.VersionTLS12 {
p.Issues = append(p.Issues, Issue{
Code: "weak_tls_version",
Severity: SeverityWarn,
Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.",
Fix: "Disable TLS 1.0/1.1 on the server.",
})
}
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
// handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and
// then a TLS handshake. InsecureSkipVerify is true on purpose: we verify
// the chain separately in probe so an invalid chain becomes a structured
// Issue rather than aborting the handshake.
func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) {
cfg := &tls.Config{
ServerName: sni,
InsecureSkipVerify: true,
}
if ep.STARTTLS == "" {
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.Handshake(); err != nil {
return nil, fmt.Errorf("tls-handshake: %w", err)
}
return tlsConn, nil
}
up, ok := starttlsUpgraders[ep.STARTTLS]
if !ok {
return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS)
}
if err := up(conn, sni); err != nil {
return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err)
}
tlsConn := tls.Client(conn, cfg)
if err := tlsConn.Handshake(); err != nil {
return nil, fmt.Errorf("tls-handshake-after-starttls: %w", err)
}
return tlsConn, nil
}
// classifyHandshakeError converts a dial/handshake error into a structured
// Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic
// for some endpoints) from hard failures.
func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue {
msg := err.Error()
if ep.STARTTLS != "" && isStartTLSUnsupported(err) {
sev := SeverityWarn
if ep.RequireSTARTTLS {
sev = SeverityCrit
}
return Issue{
Code: "starttls_not_offered",
Severity: sev,
Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg),
Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.",
}
}
return Issue{
Code: "handshake_failed",
Severity: SeverityCrit,
Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg),
Fix: "Inspect the server's TLS configuration and certificate.",
}
}
var errStartTLSNotOffered = errors.New("starttls not advertised by server")
func isStartTLSUnsupported(err error) bool {
return errors.Is(err, errStartTLSNotOffered)
}

95
checker/prober_test.go Normal file
View file

@ -0,0 +1,95 @@
package checker
import (
"context"
"net"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"git.happydns.org/checker-tls/contract"
)
func TestProbe_DirectTLS_OK(t *testing.T) {
srv := httptest.NewTLSServer(nil)
defer srv.Close()
u, _ := url.Parse(srv.URL)
host, portStr, _ := net.SplitHostPort(u.Host)
port, _ := strconv.ParseUint(portStr, 10, 16)
probe := probe(context.Background(), contract.TLSEndpoint{
Host: host,
Port: uint16(port),
SNI: host,
}, 5*time.Second)
if probe.Error != "" {
t.Fatalf("unexpected error: %s", probe.Error)
}
if probe.TLSVersion == "" {
t.Errorf("expected TLSVersion, got empty")
}
if probe.CipherSuite == "" {
t.Errorf("expected CipherSuite, got empty")
}
if probe.ChainValid == nil || *probe.ChainValid {
t.Errorf("httptest self-signed chain should NOT be valid (chain_valid=%v)", probe.ChainValid)
}
if probe.HostnameMatch == nil {
t.Errorf("expected HostnameMatch to be populated")
}
if probe.NotAfter.IsZero() {
t.Errorf("expected NotAfter populated")
}
}
func TestProbe_TCPUnreachable(t *testing.T) {
// Grab a free port then immediately close it so we know nothing listens.
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := l.Addr().(*net.TCPAddr)
_ = l.Close()
probe := probe(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: uint16(addr.Port),
}, 1*time.Second)
if probe.Error == "" {
t.Errorf("expected an error for unreachable port")
}
if len(probe.Issues) == 0 || probe.Issues[0].Code != "tcp_unreachable" {
t.Errorf("expected tcp_unreachable issue, got %+v", probe.Issues)
}
}
func TestProbe_UnsupportedStartTLSProto(t *testing.T) {
// Listen so the dial succeeds, but the type maps to an unknown proto.
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go func() {
c, err := l.Accept()
if err == nil {
c.Close()
}
}()
addr := l.Addr().(*net.TCPAddr)
probe := probe(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: uint16(addr.Port),
STARTTLS: "totallyfake",
}, 2*time.Second)
if probe.Error == "" {
t.Errorf("expected handshake error for unsupported starttls protocol")
}
}

14
checker/provider.go Normal file
View file

@ -0,0 +1,14 @@
package checker
import sdk "git.happydns.org/checker-sdk-go/checker"
// Provider returns a new TLS observation provider.
func Provider() sdk.ObservationProvider {
return &tlsProvider{}
}
type tlsProvider struct{}
func (p *tlsProvider) Key() sdk.ObservationKey {
return ObservationKeyTLSProbes
}

137
checker/rule.go Normal file
View file

@ -0,0 +1,137 @@
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the rule that aggregates per-endpoint TLS probe outcomes into
// a single status for this checker run.
func Rule() sdk.CheckRule {
return &tlsRule{}
}
type tlsRule struct{}
func (r *tlsRule) Name() string { return "tls_posture" }
func (r *tlsRule) Description() string {
return "Summarises TLS handshake, certificate validity, hostname match and expiry across all probed endpoints"
}
func (r *tlsRule) ValidateOptions(opts sdk.CheckerOptions) error {
return nil
}
func (r *tlsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) sdk.CheckState {
var data TLSData
if err := obs.Get(ctx, ObservationKeyTLSProbes, &data); err != nil {
return sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("Failed to read tls_probes: %v", err),
Code: "tls_observation_error",
}
}
var (
total = len(data.Probes)
okCount int
warnCount int
critCount int
firstCrit string
firstWarn string
)
// Steady state when no producer has published entries for this target
// yet (or when the last producer run cleared them). Report Unknown so
// we don't flap red during the eventual-consistency window between a
// fresh enrollment and the first producer cycle.
if total == 0 {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: "No TLS endpoints have been discovered for this target yet",
Code: "tls_no_endpoints",
}
}
for _, p := range data.Probes {
worst, critMsg, warnMsg := summarize(p.Issues)
switch worst {
case SeverityCrit:
critCount++
if firstCrit == "" {
firstCrit = fmt.Sprintf("%s (%s)", p.Endpoint, critMsg)
}
case SeverityWarn:
warnCount++
if firstWarn == "" {
firstWarn = fmt.Sprintf("%s (%s)", p.Endpoint, warnMsg)
}
default:
okCount++
}
}
meta := map[string]any{
"probes": total,
"ok": okCount,
"warn": warnCount,
"crit": critCount,
}
switch {
case critCount > 0:
return sdk.CheckState{
Status: sdk.StatusCrit,
Message: fmt.Sprintf("%d/%d TLS endpoint(s) have critical issues: %s", critCount, total, firstCrit),
Code: "tls_critical",
Meta: meta,
}
case warnCount > 0:
return sdk.CheckState{
Status: sdk.StatusWarn,
Message: fmt.Sprintf("%d/%d TLS endpoint(s) have warnings: %s", warnCount, total, firstWarn),
Code: "tls_warning",
Meta: meta,
}
default:
return sdk.CheckState{
Status: sdk.StatusOK,
Message: fmt.Sprintf("%d TLS endpoint(s) OK", total),
Code: "tls_ok",
Meta: meta,
}
}
}
// summarize walks the issues once and returns (worst severity, first
// critical message, first warning message). Picking the messages during the
// same pass avoids a second iteration in the caller.
func summarize(issues []Issue) (worst, firstCrit, firstWarn string) {
for _, is := range issues {
msg := is.Message
if msg == "" {
msg = is.Code
}
switch is.Severity {
case SeverityCrit:
worst = SeverityCrit
if firstCrit == "" {
firstCrit = msg
}
case SeverityWarn:
if worst == "" || worst == SeverityInfo {
worst = SeverityWarn
}
if firstWarn == "" {
firstWarn = msg
}
case SeverityInfo:
if worst == "" {
worst = SeverityInfo
}
}
}
return
}

281
checker/starttls.go Normal file
View file

@ -0,0 +1,281 @@
package checker
import (
"bufio"
"encoding/xml"
"fmt"
"io"
"net"
"strings"
)
// starttlsUpgrader performs the plaintext portion of a STARTTLS upgrade on
// conn, leaving conn ready for tls.Client(conn, …).Handshake(). On success
// the returned function returns nil; on failure it returns a descriptive
// error (wrap errStartTLSNotOffered when the server advertises no STARTTLS).
type starttlsUpgrader func(conn net.Conn, sni string) error
var starttlsUpgraders = map[string]starttlsUpgrader{
"smtp": starttlsSMTP,
"submission": starttlsSMTP,
"imap": starttlsIMAP,
"pop3": starttlsPOP3,
"xmpp-client": starttlsXMPPClient,
"xmpp-server": starttlsXMPPServer,
}
// starttlsSMTP implements ESMTP EHLO + STARTTLS (RFC 3207).
func starttlsSMTP(conn net.Conn, sni string) error {
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
if err := readSMTPGreeting(rw.Reader); err != nil {
return fmt.Errorf("read greeting: %w", err)
}
if _, err := rw.WriteString("EHLO checker.happydomain.org\r\n"); err != nil {
return fmt.Errorf("write ehlo: %w", err)
}
if err := rw.Flush(); err != nil {
return fmt.Errorf("flush ehlo: %w", err)
}
lines, err := readSMTPResponse(rw.Reader)
if err != nil {
return fmt.Errorf("read ehlo: %w", err)
}
if !hasSTARTTLSExt(lines) {
return fmt.Errorf("%w: EHLO did not advertise STARTTLS", errStartTLSNotOffered)
}
if _, err := rw.WriteString("STARTTLS\r\n"); err != nil {
return fmt.Errorf("write starttls: %w", err)
}
if err := rw.Flush(); err != nil {
return fmt.Errorf("flush starttls: %w", err)
}
resp, err := readSMTPResponse(rw.Reader)
if err != nil {
return fmt.Errorf("read starttls: %w", err)
}
if len(resp) == 0 || !strings.HasPrefix(resp[0], "220") {
return fmt.Errorf("server refused STARTTLS: %s", strings.Join(resp, " / "))
}
return nil
}
func readSMTPGreeting(r *bufio.Reader) error {
_, err := readSMTPResponse(r)
return err
}
// readSMTPResponse reads one multi-line SMTP response (lines with "NNN-" are
// continuation, "NNN " terminates).
func readSMTPResponse(r *bufio.Reader) ([]string, error) {
var out []string
for {
line, err := r.ReadString('\n')
if err != nil {
return out, err
}
line = strings.TrimRight(line, "\r\n")
out = append(out, line)
if len(line) < 4 || line[3] == ' ' {
return out, nil
}
}
}
func hasSTARTTLSExt(lines []string) bool {
for _, l := range lines {
if len(l) < 4 {
continue
}
rest := strings.ToUpper(strings.TrimSpace(l[4:]))
if rest == "STARTTLS" || strings.HasPrefix(rest, "STARTTLS ") {
return true
}
}
return false
}
// starttlsIMAP implements RFC 3501 STARTTLS.
func starttlsIMAP(conn net.Conn, sni string) error {
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
if _, err := rw.ReadString('\n'); err != nil {
return fmt.Errorf("read greeting: %w", err)
}
if _, err := rw.WriteString("A001 CAPABILITY\r\n"); err != nil {
return fmt.Errorf("write CAPABILITY: %w", err)
}
if err := rw.Flush(); err != nil {
return err
}
supportsSTARTTLS := false
for {
line, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read CAPABILITY: %w", err)
}
if strings.Contains(strings.ToUpper(line), "STARTTLS") {
supportsSTARTTLS = true
}
if strings.HasPrefix(line, "A001 ") {
break
}
}
if !supportsSTARTTLS {
return fmt.Errorf("%w: IMAP CAPABILITY did not advertise STARTTLS", errStartTLSNotOffered)
}
if _, err := rw.WriteString("A002 STARTTLS\r\n"); err != nil {
return err
}
if err := rw.Flush(); err != nil {
return err
}
for {
line, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read STARTTLS response: %w", err)
}
if strings.HasPrefix(line, "A002 OK") {
return nil
}
if strings.HasPrefix(line, "A002 ") {
return fmt.Errorf("server refused STARTTLS: %s", strings.TrimSpace(line))
}
}
}
// starttlsPOP3 implements RFC 2595 STLS.
func starttlsPOP3(conn net.Conn, sni string) error {
rw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
greeting, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read greeting: %w", err)
}
if !strings.HasPrefix(greeting, "+OK") {
return fmt.Errorf("unexpected POP3 greeting: %s", strings.TrimSpace(greeting))
}
if _, err := rw.WriteString("CAPA\r\n"); err != nil {
return err
}
if err := rw.Flush(); err != nil {
return err
}
first, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read CAPA: %w", err)
}
supportsSTLS := false
if strings.HasPrefix(first, "+OK") {
for {
line, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read CAPA body: %w", err)
}
line = strings.TrimRight(line, "\r\n")
if line == "." {
break
}
if strings.EqualFold(line, "STLS") {
supportsSTLS = true
}
}
}
if !supportsSTLS {
return fmt.Errorf("%w: POP3 CAPA did not advertise STLS", errStartTLSNotOffered)
}
if _, err := rw.WriteString("STLS\r\n"); err != nil {
return err
}
if err := rw.Flush(); err != nil {
return err
}
resp, err := rw.ReadString('\n')
if err != nil {
return fmt.Errorf("read STLS response: %w", err)
}
if !strings.HasPrefix(resp, "+OK") {
return fmt.Errorf("server refused STLS: %s", strings.TrimSpace(resp))
}
return nil
}
// starttlsXMPPClient implements RFC 6120 STARTTLS for c2s streams.
func starttlsXMPPClient(conn net.Conn, sni string) error {
return starttlsXMPP(conn, sni, "jabber:client")
}
// starttlsXMPPServer implements RFC 6120 STARTTLS for s2s streams.
func starttlsXMPPServer(conn net.Conn, sni string) error {
return starttlsXMPP(conn, sni, "jabber:server")
}
func starttlsXMPP(conn net.Conn, sni, ns string) error {
header := fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, ns, sni)
if _, err := io.WriteString(conn, header); err != nil {
return fmt.Errorf("write stream header: %w", err)
}
dec := xml.NewDecoder(conn)
// Read the inbound <stream:stream> opening and its <stream:features>.
hasStartTLS := false
for {
tok, err := dec.Token()
if err != nil {
return fmt.Errorf("read stream features: %w", err)
}
if se, ok := tok.(xml.StartElement); ok {
if se.Name.Local == "features" {
// Scan features children.
for {
t2, err := dec.Token()
if err != nil {
return fmt.Errorf("read features body: %w", err)
}
switch ee := t2.(type) {
case xml.StartElement:
if ee.Name.Local == "starttls" {
hasStartTLS = true
}
_ = dec.Skip()
case xml.EndElement:
if ee.Name.Local == "features" {
goto doneFeatures
}
}
}
}
}
}
doneFeatures:
if !hasStartTLS {
return fmt.Errorf("%w: XMPP features did not advertise starttls", errStartTLSNotOffered)
}
if _, err := io.WriteString(conn, `<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`); err != nil {
return fmt.Errorf("write starttls: %w", err)
}
for {
tok, err := dec.Token()
if err != nil {
return fmt.Errorf("read proceed: %w", err)
}
if se, ok := tok.(xml.StartElement); ok {
switch se.Name.Local {
case "proceed":
return nil
case "failure":
return fmt.Errorf("server refused STARTTLS (<failure/>)")
}
}
}
}

65
checker/types.go Normal file
View file

@ -0,0 +1,65 @@
// Package checker implements a TLS checker for happyDomain. See README for
// the payload shape and consumer contract.
package checker
import "time"
// ObservationKeyTLSProbes is the observation key this checker writes.
const ObservationKeyTLSProbes = "tls_probes"
// Option ids on CheckerOptions.
const (
OptionEndpoints = "endpoints"
OptionProbeTimeoutMs = "probeTimeoutMs"
)
// Defaults shared between the definition's Default field and the runtime
// fallback when probeTimeoutMs is unset or invalid.
const (
DefaultProbeTimeoutMs = 10000
// MaxConcurrentProbes caps parallel probes per collect run to avoid
// exhausting file descriptors on domains with many endpoints.
MaxConcurrentProbes = 32
)
// Severity values used in Issue.Severity (lowercase, ascii).
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
)
// TLSData is the full collected payload written under ObservationKeyTLSProbes.
type TLSData struct {
Probes map[string]TLSProbe `json:"probes"`
CollectedAt time.Time `json:"collected_at"`
}
// TLSProbe captures the outcome of probing a single endpoint. Field names
// mirror what consumers already parse (checker-xmpp's tlsProbeView).
type TLSProbe struct {
Host string `json:"host"`
Port uint16 `json:"port"`
Endpoint string `json:"endpoint"`
Type string `json:"type"`
SNI string `json:"sni,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"`
Issuer string `json:"issuer,omitempty"`
Subject string `json:"subject,omitempty"`
DNSNames []string `json:"dns_names,omitempty"`
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
Error string `json:"error,omitempty"`
Issues []Issue `json:"issues,omitempty"`
}
// Issue is a single TLS finding surfaced to the consumer.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
}

143
contract/contract.go Normal file
View file

@ -0,0 +1,143 @@
// Package contract defines the DiscoveryEntry schema that checker-tls
// consumes. Producer checkers (checker-xmpp, checker-srv, checker-sip, …)
// import this package to describe the TLS endpoints they want probed; the
// SDK remains agnostic of TLS specifics because the payload is marshaled
// here and carried as opaque bytes inside sdk.DiscoveryEntry.
//
// Stability: this package is the source of truth for the on-wire payload
// under Type. Breaking changes must bump the version suffix on Type
// (tls.endpoint.v1 → v2) and ship a new Go type; consumers that only
// understand the previous version will then ignore the new entries
// automatically.
package contract
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Type is the value placed in sdk.DiscoveryEntry.Type for this contract.
// Consumers match entries by comparing against this exact string.
const Type = "tls.endpoint.v1"
// TLSEndpoint describes a single (host, port) that checker-tls should probe,
// optionally preceded by a protocol-specific STARTTLS upgrade.
type TLSEndpoint struct {
// Host is the target hostname. Consumers dial it directly; trailing
// dots are tolerated.
Host string `json:"host"`
// Port is the TCP port number.
Port uint16 `json:"port"`
// SNI is the server name to use in the TLS handshake. Leave empty when
// equal to Host; consumers substitute Host in that case. Set
// explicitly when the upstream service is fronted by a name that does
// not match Host (CDN, multi-tenant host, XMPP domain vs. SRV target).
SNI string `json:"sni,omitempty"`
// STARTTLS names the plaintext protocol that must be upgraded before
// the TLS handshake. Empty means "speak TLS immediately after TCP
// connect". Non-empty values follow the SRV service-name convention
// (RFC 6335): "smtp", "submission", "imap", "pop3", "xmpp-client",
// "xmpp-server", "ldap", "nntp", "ftp", "sieve", "postgres", …
//
// Unknown values are surfaced by checker-tls as a handshake_failed
// issue; they do not prevent other endpoints from being probed.
STARTTLS string `json:"starttls,omitempty"`
// RequireSTARTTLS is meaningful only when STARTTLS is non-empty. It
// distinguishes a mandatory upgrade (server absence = crit) from an
// opportunistic one (server absence = warn). Producers set this from
// protocol knowledge: XMPP mandates STARTTLS, SMTP 25 is opportunistic,
// SMTP 587/submission mandates it, etc.
RequireSTARTTLS bool `json:"require,omitempty"`
}
// NewEntry wraps ep in an sdk.DiscoveryEntry with Type, a deterministic Ref
// derived from ep, and a marshaled Payload. The returned entry can be
// returned as-is from a DiscoveryPublisher implementation.
func NewEntry(ep TLSEndpoint) (sdk.DiscoveryEntry, error) {
payload, err := json.Marshal(ep)
if err != nil {
return sdk.DiscoveryEntry{}, fmt.Errorf("contract: marshal TLSEndpoint: %w", err)
}
return sdk.DiscoveryEntry{
Type: Type,
Ref: Ref(ep),
Payload: payload,
}, nil
}
// Ref computes a stable identifier for ep. The output is deterministic:
// two endpoints with the same Host, Port, effective SNI, STARTTLS protocol,
// and RequireSTARTTLS flag yield the same Ref. It is meant to double as a
// human-readable key in probe result maps.
//
// Format (length capped by hashing the long form):
//
// sha1(host|port|sni|starttls|require)[:16]
//
// A 16-hex-digit (8-byte) prefix of SHA-1 is sufficient for this use case:
// we only need stability against the single (producer, target) space,
// where collisions are vanishingly unlikely.
func Ref(ep TLSEndpoint) string {
sni := ep.SNI
if sni == "" {
sni = ep.Host
}
req := "0"
if ep.RequireSTARTTLS {
req = "1"
}
canonical := fmt.Sprintf("%s|%d|%s|%s|%s", ep.Host, ep.Port, sni, ep.STARTTLS, req)
sum := sha1.Sum([]byte(canonical))
return hex.EncodeToString(sum[:8])
}
// ParseEntry decodes e into a TLSEndpoint. Returns an error when e.Type is
// not Type or when e.Payload fails to unmarshal.
func ParseEntry(e sdk.DiscoveryEntry) (TLSEndpoint, error) {
if e.Type != Type {
return TLSEndpoint{}, fmt.Errorf("contract: entry type %q does not match %q", e.Type, Type)
}
var ep TLSEndpoint
if err := json.Unmarshal(e.Payload, &ep); err != nil {
return TLSEndpoint{}, fmt.Errorf("contract: unmarshal TLSEndpoint: %w", err)
}
return ep, nil
}
// Entry pairs a decoded TLSEndpoint with the Ref that appeared on the wire.
// The wire Ref is preserved verbatim rather than recomputed so consumers
// always match the value observed downstream (in RelatedObservation /
// probe map keys), even if a future version of this package changes Ref's
// derivation.
type Entry struct {
Ref string
Endpoint TLSEndpoint
}
// ParseEntries filters entries to those of Type and decodes each payload.
// Entries of other types are ignored silently — they belong to other
// contracts. Entries of this type whose Payload fails to unmarshal are
// skipped and returned as warnings so a single malformed payload cannot
// starve the checker of the rest of its workload.
func ParseEntries(entries []sdk.DiscoveryEntry) (out []Entry, warnings []error) {
for _, e := range entries {
if e.Type != Type {
continue
}
ep, err := ParseEntry(e)
if err != nil {
warnings = append(warnings, fmt.Errorf("ref=%q: %w", e.Ref, err))
continue
}
out = append(out, Entry{Ref: e.Ref, Endpoint: ep})
}
return out, warnings
}

106
contract/contract_test.go Normal file
View file

@ -0,0 +1,106 @@
package contract
import (
"encoding/json"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
)
func TestNewEntry_RoundTrip(t *testing.T) {
in := TLSEndpoint{
Host: "jabber.example.net",
Port: 5222,
SNI: "example.net",
STARTTLS: "xmpp-client",
RequireSTARTTLS: true,
}
e, err := NewEntry(in)
if err != nil {
t.Fatalf("NewEntry: %v", err)
}
if e.Type != Type {
t.Errorf("Type = %q, want %q", e.Type, Type)
}
if e.Ref == "" {
t.Error("Ref is empty")
}
out, err := ParseEntry(e)
if err != nil {
t.Fatalf("ParseEntry: %v", err)
}
if out != in {
t.Errorf("round-trip mismatch:\n got %+v\nwant %+v", out, in)
}
}
func TestRef_StableAcrossCalls(t *testing.T) {
ep1 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"}
ep2 := TLSEndpoint{Host: "a", Port: 443, STARTTLS: "smtp"}
if Ref(ep1) != Ref(ep2) {
t.Error("Ref is not deterministic")
}
}
func TestRef_SNIDefaultsToHost(t *testing.T) {
a := TLSEndpoint{Host: "example.net", Port: 443}
b := TLSEndpoint{Host: "example.net", Port: 443, SNI: "example.net"}
if Ref(a) != Ref(b) {
t.Errorf("Ref differs when SNI is omitted vs. explicit but equal: %s vs %s", Ref(a), Ref(b))
}
}
func TestRef_FieldsAffectOutput(t *testing.T) {
base := TLSEndpoint{Host: "example.net", Port: 443}
cases := []TLSEndpoint{
{Host: "other.net", Port: 443},
{Host: "example.net", Port: 8443},
{Host: "example.net", Port: 443, SNI: "alt.example.net"},
{Host: "example.net", Port: 443, STARTTLS: "smtp"},
{Host: "example.net", Port: 443, STARTTLS: "smtp", RequireSTARTTLS: true},
}
baseRef := Ref(base)
for i, c := range cases {
if Ref(c) == baseRef {
t.Errorf("case %d: expected Ref to change when a significant field changes, got %q", i, baseRef)
}
}
}
func TestParseEntry_WrongType(t *testing.T) {
e := sdk.DiscoveryEntry{Type: "something.else.v1", Ref: "x", Payload: json.RawMessage(`{}`)}
if _, err := ParseEntry(e); err == nil {
t.Error("expected error on wrong type, got nil")
}
}
func TestParseEntry_BadPayload(t *testing.T) {
e := sdk.DiscoveryEntry{Type: Type, Ref: "x", Payload: json.RawMessage(`not json`)}
if _, err := ParseEntry(e); err == nil {
t.Error("expected error on malformed payload, got nil")
}
}
func TestParseEntries_FiltersAndAccumulatesWarnings(t *testing.T) {
good, err := NewEntry(TLSEndpoint{Host: "a", Port: 443})
if err != nil {
t.Fatal(err)
}
bad := sdk.DiscoveryEntry{Type: Type, Ref: "bad", Payload: json.RawMessage(`not json`)}
foreign := sdk.DiscoveryEntry{Type: "other.v1", Ref: "f", Payload: json.RawMessage(`{}`)}
entries, warnings := ParseEntries([]sdk.DiscoveryEntry{good, bad, foreign})
if len(entries) != 1 {
t.Errorf("entries = %d, want 1", len(entries))
}
if entries[0].Endpoint.Host != "a" {
t.Errorf("decoded host = %q, want %q", entries[0].Endpoint.Host, "a")
}
if entries[0].Ref != good.Ref {
t.Errorf("preserved ref = %q, want %q", entries[0].Ref, good.Ref)
}
if len(warnings) != 1 {
t.Errorf("warnings = %d, want 1 (one malformed payload of the correct type)", len(warnings))
}
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module git.happydns.org/checker-tls
go 1.25.0
require git.happydns.org/checker-sdk-go v1.1.0

2
go.sum Normal file
View file

@ -0,0 +1,2 @@
git.happydns.org/checker-sdk-go v1.1.0 h1:xgR39X1Mh+v481BHTDYHtGYFL1qRwldTsehazwSc67Y=
git.happydns.org/checker-sdk-go v1.1.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=

23
main.go Normal file
View file

@ -0,0 +1,23 @@
package main
import (
"flag"
"log"
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
tls.Version = Version
server := sdk.NewServer(tls.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

13
plugin/plugin.go Normal file
View file

@ -0,0 +1,13 @@
package main
import (
sdk "git.happydns.org/checker-sdk-go/checker"
tls "git.happydns.org/checker-tls/checker"
)
var Version = "custom-build"
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
tls.Version = Version
return tls.Definition(), tls.Provider(), nil
}