Initial commit
This commit is contained in:
commit
f27b7397f7
20 changed files with 1471 additions and 0 deletions
143
contract/contract.go
Normal file
143
contract/contract.go
Normal 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
106
contract/contract_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue