Files
Blacktip/certs.go
T
VeillaxandClaude Sonnet 5.5 9448a532e9 Add the Blacktip edge
The edge: per-IP TCP/UDP/HTTP/HTTPS/TLS listeners with port ranges and PROXY
protocol, WireGuard tunnel endpoint, logins in front of web ports (built-in
proxy or Traefik), health checks and failover, and mail egress with DKIM
signing and MTA-STS. Split out of the original single repository; depends on
Reef for the shared types.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:45:16 -06:00

156 lines
4.0 KiB
Go

package blacktip
import (
"context"
"crypto/tls"
"errors"
"path/filepath"
"strings"
"time"
"git.veillax.com/Veillax/reef/model"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert"
)
// Certificates for https ports come from Let's Encrypt via autocert, using the
// TLS-ALPN-01 challenge on the same port 443 listener, so nothing extra has to
// be opened. A wildcard hostname such as *.example.com gets a separate
// certificate for each subdomain on its first visit.
type acmeState struct {
mgr *autocert.Manager
email string
staging bool
tried map[string]time.Time
certs map[string]string
}
// configureACME builds the manager. Caller holds b.mu.
func (b *Blacktip) configureACME(email string, staging bool) {
a := &b.acme
if a.tried == nil {
a.tried, a.certs = map[string]time.Time{}, map[string]string{}
}
if a.mgr != nil && a.email == email && a.staging == staging {
return
}
m := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache(filepath.Join(b.cfg.DataDir, "certs")),
HostPolicy: b.hostPolicy,
Email: email,
}
if staging {
m.Client = &acme.Client{DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory"}
}
a.mgr, a.email, a.staging = m, email, staging
a.tried = map[string]time.Time{}
}
// hostPolicy only lets us request certificates for hostnames we actually serve over https.
func (b *Blacktip) hostPolicy(_ context.Context, host string) error {
b.mu.Lock()
defer b.mu.Unlock()
for _, ms := range b.maps {
if ms.m.Proto == model.ProtoHTTPS && model.HostMatches(ms.m.Hostname, strings.ToLower(host)) {
return nil
}
}
return errors.New("not a hostname served here")
}
func (b *Blacktip) getCert(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if b.cfg.GetCertificate != nil {
return b.cfg.GetCertificate(hello)
}
b.mu.Lock()
mgr := b.acme.mgr
b.mu.Unlock()
if mgr == nil {
return nil, errors.New("certificates are not configured yet")
}
return mgr.GetCertificate(hello)
}
// warmCerts requests certificates ahead of the first visitor so problems show up in the UI.
// Caller holds b.mu.
func (b *Blacktip) warmCerts(ms []model.Mapping) {
for _, m := range ms {
if m.Proto != model.ProtoHTTPS || strings.HasPrefix(m.Hostname, "*") {
continue
}
host := m.Hostname
if b.cfg.GetCertificate != nil {
b.acme.certs[host] = "custom certificate"
continue
}
wait := 12 * time.Hour
if strings.HasPrefix(b.acme.certs[host], "error") {
wait = 3 * time.Minute
}
if t, ok := b.acme.tried[host]; ok && time.Since(t) < wait {
continue
}
b.acme.tried[host] = time.Now()
b.acme.certs[host] = "requesting…"
mgr := b.acme.mgr
go func() {
ctx, cancel := context.WithTimeout(b.ctx, 2*time.Minute)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: host})
done <- err
}()
var err error
select {
case err = <-done:
case <-ctx.Done():
err = ctx.Err()
}
b.mu.Lock()
if err != nil {
b.acme.certs[host] = "error: " + err.Error()
} else {
b.acme.certs[host] = "valid"
}
b.mu.Unlock()
go b.report()
}()
}
}
// certStatus describes the certificate for m. Caller holds b.mu.
func (b *Blacktip) certStatus(m model.Mapping) string {
if m.Proto != model.ProtoHTTPS {
return ""
}
if strings.HasPrefix(m.Hostname, "*") {
if b.tr != nil {
if b.cfg.Traefik.NoACME {
return "self-signed (Traefik default certificate)"
}
if b.cfg.Traefik.ACMEDNS == "" {
return "error: wildcard certificates need -acme-dns <provider>"
}
return "wildcard certificate via DNS challenge"
}
return "issued per subdomain on first visit"
}
if s := b.acme.certs[m.Hostname]; s != "" {
return s
}
return "pending"
}
// acmeFailing reports whether any certificate request failed. Caller holds b.mu.
func (b *Blacktip) acmeFailing() bool {
for _, s := range b.acme.certs {
if strings.HasPrefix(s, "error") {
return true
}
}
return false
}