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>
This commit is contained in:
1 parent
995ef9a6aa
commit
9448a532e9
17 files changed
+3492
-1
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user