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

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))
}
}