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>
156 lines
4.0 KiB
Go
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
|
|
}
|