c0d9aa7518
Codeberg is being retired as the project's public site, so every reference now points at GitHub. That includes the Go module path (codeberg.org/mix/selfpost → github.com/mixeme/selfpost): leaving an import path on a host that is going away would break `go get` and `go install`, so this is not only a docs change. Touches go.mod, test/e2e/go.mod, all imports, Makefile MODULE, the -ldflags version stamp in build/Dockerfile and docs/development.md, the licence headers in the SVG/HTML assets, and README (no more primary/mirror pair). Comments no longer cite the archived specification. "spec 7.6.1", "spec 5.1" and friends pointed into docs/archive/specification-v1.0.md, which is marked as not a source of truth; each is now a reference to the live document that owns the subject — architecture.md (with section), product.md, security.md or the README. The review only asked for the 7.x refs (code-review.md § 4), but 4/5/6/ 8/9 had the same defect, so they went too. Comments only, no behaviour change. Also closes the remaining review items: architecture.md gained a Code layers section with the layer diagram (A2), and TestParseDelivery gained the exotic mail.log cases (§ 3). Fixes a bug that last test found: the delivery-line pattern matched status= greedily, taking the *last* occurrence on the line. Postfix appends the remote server's reply verbatim, so a rejection whose reply quoted "status=sent" was filed as a delivered message in the send log. It now takes the first status= after the recipient, which is the real field. R7 (CONTRIBUTING.md) moved to roadmap 2.x — one developer, no external PR flow, so the file would have no audience yet. R1 (compose image tag) and the git tag stay in roadmap § v1.x as the release-commit steps. gofmt/go vet clean on both modules; go test ./... green except the three known Windows-only failures (file perms, backslash paths, renaming an open file). Not exercised on the dev server — no Docker locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
2.4 KiB
Go
84 lines
2.4 KiB
Go
package health
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// certWarnDays is how close to expiry the certificate has to be before the
|
|
// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
|
|
// comfortably past the point where automatic renewal should have happened.
|
|
const certWarnDays = 14
|
|
|
|
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
|
// (README § Environment variables: TLS_CERT_FILE). The panel only reads it —
|
|
// the file is supplied by the reverse proxy through a read-only mount.
|
|
type Certificate struct {
|
|
Path string
|
|
Subject string
|
|
NotAfter time.Time
|
|
DaysLeft int
|
|
Status Status
|
|
Detail string
|
|
}
|
|
|
|
// CheckCertificate parses the leaf certificate at path and reports how much
|
|
// validity is left. A missing or unparsable file is an error status rather than
|
|
// an error return: the status page reports it in place, like every other check.
|
|
func CheckCertificate(path string) Certificate {
|
|
c := Certificate{Path: path}
|
|
if path == "" {
|
|
c.Status = StatusUnknown
|
|
c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
|
|
return c
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
c.Status = StatusError
|
|
c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
|
|
return c
|
|
}
|
|
leaf, err := parseLeaf(data)
|
|
if err != nil {
|
|
c.Status = StatusError
|
|
c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
|
|
return c
|
|
}
|
|
|
|
c.Subject = leaf.Subject.CommonName
|
|
c.NotAfter = leaf.NotAfter
|
|
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
|
|
switch {
|
|
case !time.Now().Before(leaf.NotAfter):
|
|
c.Status = StatusError
|
|
c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
|
|
case c.DaysLeft < certWarnDays:
|
|
c.Status = StatusWarn
|
|
c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
|
|
default:
|
|
c.Status = StatusOK
|
|
c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
|
|
// the one whose validity clients see.
|
|
func parseLeaf(data []byte) (*x509.Certificate, error) {
|
|
for rest := data; len(rest) > 0; {
|
|
var block *pem.Block
|
|
block, rest = pem.Decode(rest)
|
|
if block == nil {
|
|
break
|
|
}
|
|
if block.Type != "CERTIFICATE" {
|
|
continue
|
|
}
|
|
return x509.ParseCertificate(block.Bytes)
|
|
}
|
|
return nil, fmt.Errorf("no CERTIFICATE block found")
|
|
}
|