Initial commit
This commit is contained in:
commit
ccc5b0cd98
26 changed files with 1806 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
checker-tls
|
||||||
|
checker-tls.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-tls .
|
||||||
|
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=builder /checker-tls /checker-tls
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/checker-tls"]
|
||||||
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.
|
||||||
25
Makefile
Normal file
25
Makefile
Normal 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
26
NOTICE
Normal 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
|
||||||
155
README.md
Normal file
155
README.md
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
# 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",
|
||||||
|
"issuer_dn": "CN=R3,O=Let's Encrypt,C=US",
|
||||||
|
"issuer_aki": "142EB317B75856CBAE500940E61FAF9D8B14C2C6",
|
||||||
|
"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
63
checker/collect.go
Normal 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
52
checker/definition.go
Normal 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 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
128
checker/interactive.go
Normal file
128
checker/interactive.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
|
"git.happydns.org/checker-tls/contract"
|
||||||
|
)
|
||||||
|
|
||||||
|
// starttlsChoices returns the STARTTLS protocols supported by starttlsUpgraders,
|
||||||
|
// sorted, with an empty first entry meaning "speak TLS immediately".
|
||||||
|
func starttlsChoices() []string {
|
||||||
|
protos := make([]string, 0, len(starttlsUpgraders)+1)
|
||||||
|
protos = append(protos, "")
|
||||||
|
for k := range starttlsUpgraders {
|
||||||
|
protos = append(protos, k)
|
||||||
|
}
|
||||||
|
sort.Strings(protos)
|
||||||
|
return protos
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderForm satisfies sdk.CheckerInteractive. The fields mirror the inputs
|
||||||
|
// a producer checker would put into a contract.TLSEndpoint; a human fills
|
||||||
|
// them in directly when running the checker standalone.
|
||||||
|
func (p *tlsProvider) RenderForm() []sdk.CheckerOptionField {
|
||||||
|
return []sdk.CheckerOptionField{
|
||||||
|
{
|
||||||
|
Id: "host",
|
||||||
|
Type: "string",
|
||||||
|
Label: "Host",
|
||||||
|
Description: "Hostname or IP to probe.",
|
||||||
|
Placeholder: "example.com",
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "port",
|
||||||
|
Type: "uint",
|
||||||
|
Label: "Port",
|
||||||
|
Description: "TCP port of the TLS (or STARTTLS) endpoint.",
|
||||||
|
Default: float64(443),
|
||||||
|
Required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "sni",
|
||||||
|
Type: "string",
|
||||||
|
Label: "SNI",
|
||||||
|
Description: "Server name for the TLS handshake. Leave empty to reuse Host.",
|
||||||
|
Placeholder: "(defaults to Host)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "starttls",
|
||||||
|
Type: "string",
|
||||||
|
Label: "STARTTLS protocol",
|
||||||
|
Description: "Plaintext protocol to upgrade before the handshake. Leave empty for direct TLS.",
|
||||||
|
Choices: starttlsChoices(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "require",
|
||||||
|
Type: "bool",
|
||||||
|
Label: "Require STARTTLS",
|
||||||
|
Description: "When checked, a server that does not advertise STARTTLS is reported critical instead of a warning.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: OptionProbeTimeoutMs,
|
||||||
|
Type: "uint",
|
||||||
|
Label: "Probe timeout (ms)",
|
||||||
|
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on the endpoint.",
|
||||||
|
Default: float64(DefaultProbeTimeoutMs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseForm satisfies sdk.CheckerInteractive. It turns the human inputs into
|
||||||
|
// a single contract.TLSEndpoint, wraps it in a DiscoveryEntry, and returns
|
||||||
|
// CheckerOptions shaped as if a happyDomain host had auto-filled
|
||||||
|
// OptionEndpoints via AutoFillDiscoveryEntries.
|
||||||
|
func (p *tlsProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||||
|
host := strings.TrimSpace(r.FormValue("host"))
|
||||||
|
if host == "" {
|
||||||
|
return nil, errors.New("host is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
portStr := strings.TrimSpace(r.FormValue("port"))
|
||||||
|
if portStr == "" {
|
||||||
|
return nil, errors.New("port is required")
|
||||||
|
}
|
||||||
|
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||||
|
if err != nil || port64 == 0 {
|
||||||
|
return nil, fmt.Errorf("invalid port %q: must be 1-65535", portStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
starttls := strings.TrimSpace(r.FormValue("starttls"))
|
||||||
|
if starttls != "" {
|
||||||
|
if _, ok := starttlsUpgraders[starttls]; !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported STARTTLS protocol %q", starttls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ep := contract.TLSEndpoint{
|
||||||
|
Host: host,
|
||||||
|
Port: uint16(port64),
|
||||||
|
SNI: strings.TrimSpace(r.FormValue("sni")),
|
||||||
|
STARTTLS: starttls,
|
||||||
|
RequireSTARTTLS: r.FormValue("require") == "true",
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := contract.NewEntry(ep)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build discovery entry: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := sdk.CheckerOptions{
|
||||||
|
OptionEndpoints: []sdk.DiscoveryEntry{entry},
|
||||||
|
}
|
||||||
|
|
||||||
|
if v := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
opts[OptionProbeTimeoutMs] = float64(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return opts, nil
|
||||||
|
}
|
||||||
236
checker/prober.go
Normal file
236
checker/prober.go
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/hex"
|
||||||
|
"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.IssuerDN = leaf.Issuer.String()
|
||||||
|
if len(leaf.AuthorityKeyId) > 0 {
|
||||||
|
p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId))
|
||||||
|
}
|
||||||
|
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
95
checker/prober_test.go
Normal 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
14
checker/provider.go
Normal 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
|
||||||
|
}
|
||||||
147
checker/rule.go
Normal file
147
checker/rule.go
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
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",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 len(data.Probes) == 0 {
|
||||||
|
return []sdk.CheckState{{
|
||||||
|
Status: sdk.StatusUnknown,
|
||||||
|
Message: "No TLS endpoints have been discovered for this target yet",
|
||||||
|
Code: "tls_no_endpoints",
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
refs := make([]string, 0, len(data.Probes))
|
||||||
|
for ref := range data.Probes {
|
||||||
|
refs = append(refs, ref)
|
||||||
|
}
|
||||||
|
sort.Strings(refs)
|
||||||
|
|
||||||
|
out := make([]sdk.CheckState, 0, len(refs))
|
||||||
|
for _, ref := range refs {
|
||||||
|
p := data.Probes[ref]
|
||||||
|
out = append(out, evaluateProbe(p))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateProbe distills a single TLSProbe into a CheckState. Subject is the
|
||||||
|
// probed endpoint so the host can correlate states across runs and surface
|
||||||
|
// them per-target in the UI. Message describes the finding only -- the UI
|
||||||
|
// renders Subject separately.
|
||||||
|
func evaluateProbe(p TLSProbe) sdk.CheckState {
|
||||||
|
subject := fmt.Sprintf("%s://%s", p.Type, p.Endpoint)
|
||||||
|
meta := map[string]any{
|
||||||
|
"type": p.Type,
|
||||||
|
"host": p.Host,
|
||||||
|
"port": p.Port,
|
||||||
|
"sni": p.SNI,
|
||||||
|
"issues": len(p.Issues),
|
||||||
|
}
|
||||||
|
if p.TLSVersion != "" {
|
||||||
|
meta["tls_version"] = p.TLSVersion
|
||||||
|
}
|
||||||
|
if !p.NotAfter.IsZero() {
|
||||||
|
meta["not_after"] = p.NotAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
worst, critMsg, warnMsg := summarize(p.Issues)
|
||||||
|
switch worst {
|
||||||
|
case SeverityCrit:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusCrit,
|
||||||
|
Message: critMsg,
|
||||||
|
Code: "tls_critical",
|
||||||
|
Subject: subject,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
case SeverityWarn:
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusWarn,
|
||||||
|
Message: warnMsg,
|
||||||
|
Code: "tls_warning",
|
||||||
|
Subject: subject,
|
||||||
|
Meta: meta,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
msg := "TLS endpoint OK"
|
||||||
|
if p.TLSVersion != "" {
|
||||||
|
msg = fmt.Sprintf("TLS endpoint OK (%s)", p.TLSVersion)
|
||||||
|
}
|
||||||
|
return sdk.CheckState{
|
||||||
|
Status: sdk.StatusOK,
|
||||||
|
Message: msg,
|
||||||
|
Code: "tls_ok",
|
||||||
|
Subject: subject,
|
||||||
|
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
|
||||||
|
}
|
||||||
15
checker/starttls.go
Normal file
15
checker/starttls.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import "net"
|
||||||
|
|
||||||
|
// 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{}
|
||||||
|
|
||||||
|
func registerStartTLS(protocol string, upgrader starttlsUpgrader) {
|
||||||
|
starttlsUpgraders[protocol] = upgrader
|
||||||
|
}
|
||||||
64
checker/starttls_imap.go
Normal file
64
checker/starttls_imap.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerStartTLS("imap", starttlsIMAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
checker/starttls_ldap.go
Normal file
142
checker/starttls_ldap.go
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerStartTLS("ldap", starttlsLDAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// starttlsLDAP implements RFC 2830 StartTLS.
|
||||||
|
//
|
||||||
|
// It sends a single ExtendedRequest with OID 1.3.6.1.4.1.1466.20037 on
|
||||||
|
// messageID=1 and reads back the ExtendedResponse. On resultCode 0 the
|
||||||
|
// connection is ready for the TLS handshake. On resultCode 2
|
||||||
|
// (protocolError) or 53 (unwillingToPerform) we wrap errStartTLSNotOffered
|
||||||
|
// -- the server is reachable but cannot upgrade -- so the caller can surface
|
||||||
|
// that as a missing-STARTTLS issue rather than a handshake failure.
|
||||||
|
func starttlsLDAP(conn net.Conn, sni string) error {
|
||||||
|
// Fixed LDAPMessage:
|
||||||
|
// SEQUENCE {
|
||||||
|
// INTEGER messageID = 1,
|
||||||
|
// [APPLICATION 23] SEQUENCE {
|
||||||
|
// [0] OCTET STRING "1.3.6.1.4.1.1466.20037"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
request := []byte{
|
||||||
|
0x30, 0x1d,
|
||||||
|
0x02, 0x01, 0x01,
|
||||||
|
0x77, 0x18,
|
||||||
|
0x80, 0x16,
|
||||||
|
'1', '.', '3', '.', '6', '.', '1', '.', '4', '.', '1', '.',
|
||||||
|
'1', '4', '6', '6', '.', '2', '0', '0', '3', '7',
|
||||||
|
}
|
||||||
|
if _, err := conn.Write(request); err != nil {
|
||||||
|
return fmt.Errorf("write StartTLS request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
|
||||||
|
tag, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
if tag != 0x30 {
|
||||||
|
return fmt.Errorf("unexpected LDAP response tag 0x%02x", tag)
|
||||||
|
}
|
||||||
|
length, err := readBERLength(r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read response length: %w", err)
|
||||||
|
}
|
||||||
|
if length <= 0 || length > 4096 {
|
||||||
|
return fmt.Errorf("unreasonable LDAP response length %d", length)
|
||||||
|
}
|
||||||
|
body := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageID INTEGER -- skip.
|
||||||
|
pos := 0
|
||||||
|
if pos >= len(body) || body[pos] != 0x02 {
|
||||||
|
return fmt.Errorf("expected INTEGER messageID")
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
if pos >= len(body) {
|
||||||
|
return fmt.Errorf("truncated messageID length")
|
||||||
|
}
|
||||||
|
msgIDLen := int(body[pos])
|
||||||
|
pos++
|
||||||
|
pos += msgIDLen
|
||||||
|
if pos >= len(body) {
|
||||||
|
return fmt.Errorf("truncated LDAP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// [APPLICATION 24] constructed = 0x78.
|
||||||
|
if body[pos] != 0x78 {
|
||||||
|
return fmt.Errorf("expected ExtendedResponse tag, got 0x%02x", body[pos])
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
// Skip extendedResp length (possibly multi-byte).
|
||||||
|
if pos >= len(body) {
|
||||||
|
return fmt.Errorf("truncated ExtendedResponse length")
|
||||||
|
}
|
||||||
|
if body[pos] < 0x80 {
|
||||||
|
pos++
|
||||||
|
} else {
|
||||||
|
n := int(body[pos] & 0x7f)
|
||||||
|
pos += 1 + n
|
||||||
|
}
|
||||||
|
|
||||||
|
// resultCode ENUMERATED (tag 0x0a).
|
||||||
|
if pos+2 > len(body) || body[pos] != 0x0a {
|
||||||
|
return fmt.Errorf("expected resultCode ENUMERATED")
|
||||||
|
}
|
||||||
|
rcLen := int(body[pos+1])
|
||||||
|
if rcLen < 1 || pos+2+rcLen > len(body) {
|
||||||
|
return fmt.Errorf("invalid resultCode length %d", rcLen)
|
||||||
|
}
|
||||||
|
rc := 0
|
||||||
|
for i := 0; i < rcLen; i++ {
|
||||||
|
rc = (rc << 8) | int(body[pos+2+i])
|
||||||
|
}
|
||||||
|
switch rc {
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
case 2, 53:
|
||||||
|
return fmt.Errorf("%w: LDAP StartTLS refused (resultCode=%d)", errStartTLSNotOffered, rc)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("server refused StartTLS (LDAP resultCode=%d)", rc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBERLength reads a definite-form BER length (short or long form).
|
||||||
|
func readBERLength(r *bufio.Reader) (int, error) {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if b < 0x80 {
|
||||||
|
return int(b), nil
|
||||||
|
}
|
||||||
|
n := int(b & 0x7f)
|
||||||
|
if n == 0 {
|
||||||
|
return 0, fmt.Errorf("indefinite-form length not supported")
|
||||||
|
}
|
||||||
|
if n > 4 {
|
||||||
|
return 0, fmt.Errorf("length octet count %d too large", n)
|
||||||
|
}
|
||||||
|
length := 0
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
bb, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
length = (length << 8) | int(bb)
|
||||||
|
}
|
||||||
|
return length, nil
|
||||||
|
}
|
||||||
70
checker/starttls_pop3.go
Normal file
70
checker/starttls_pop3.go
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerStartTLS("pop3", starttlsPOP3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
86
checker/starttls_smtp.go
Normal file
86
checker/starttls_smtp.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerStartTLS("smtp", starttlsSMTP)
|
||||||
|
registerStartTLS("submission", starttlsSMTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
86
checker/starttls_xmpp.go
Normal file
86
checker/starttls_xmpp.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerStartTLS("xmpp-client", starttlsXMPPClient)
|
||||||
|
registerStartTLS("xmpp-server", starttlsXMPPServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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/>)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
checker/types.go
Normal file
73
checker/types.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// 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"`
|
||||||
|
// IssuerDN is the leaf's issuer as an RFC 2253 DN string, suitable for
|
||||||
|
// matching the CCADB CAA Identifiers CSV "Subject" column when the AKI
|
||||||
|
// lookup misses.
|
||||||
|
IssuerDN string `json:"issuer_dn,omitempty"`
|
||||||
|
// IssuerAKI is the uppercase hex of the leaf's Authority Key Identifier
|
||||||
|
// extension (i.e. the issuer cert's SKI). This is the primary lookup key
|
||||||
|
// into the CCADB CAA Identifiers CSV ("Subject Key Identifier (Hex)").
|
||||||
|
IssuerAKI string `json:"issuer_aki,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
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
module git.happydns.org/checker-tls
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require git.happydns.org/checker-sdk-go v1.2.0
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
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=
|
||||||
23
main.go
Normal file
23
main.go
Normal 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
13
plugin/plugin.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue