Serves a page explaining why a domain was blocked instead of leaving a sinkholed client with a dead connection. Binds its own HTTP/HTTPS listeners with self-signed, per-hostname TLS certs generated on the fly, re-evaluates the requesting client against the policy engine per request, and renders an HTML template editable from Settings with a live preview. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TTKpGMQzpfDsvedu1hvSUf
104 lines
3.0 KiB
Go
104 lines
3.0 KiB
Go
package blockpage
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// certCap bounds how many per-hostname certificates are kept in memory. A
|
|
// client that churns through SNI names (deliberately or not) could otherwise
|
|
// grow the cache without limit; when the cap is hit the whole cache is
|
|
// cleared and rebuilt on demand, which just costs a few CPU-cheap regenerations.
|
|
const certCap = 20_000
|
|
|
|
// certStore lazily generates and caches a self-signed TLS certificate for
|
|
// each hostname a client connects with over SNI. Signing each leaf with a
|
|
// name that matches what was requested means the browser's warning is the
|
|
// expected "this certificate is not trusted" one, not also a confusing
|
|
// hostname mismatch.
|
|
type certStore struct {
|
|
mu sync.Mutex
|
|
certs map[string]*tls.Certificate
|
|
}
|
|
|
|
func newCertStore() certStore {
|
|
return certStore{certs: make(map[string]*tls.Certificate)}
|
|
}
|
|
|
|
// get implements tls.Config.GetCertificate.
|
|
func (c *certStore) get(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
name := hello.ServerName
|
|
if name == "" {
|
|
name = "block-page.invalid"
|
|
}
|
|
|
|
c.mu.Lock()
|
|
if cert, ok := c.certs[name]; ok {
|
|
c.mu.Unlock()
|
|
return cert, nil
|
|
}
|
|
c.mu.Unlock()
|
|
|
|
cert, err := generateCert(name)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.mu.Lock()
|
|
if len(c.certs) >= certCap {
|
|
c.certs = make(map[string]*tls.Certificate)
|
|
}
|
|
c.certs[name] = cert
|
|
c.mu.Unlock()
|
|
return cert, nil
|
|
}
|
|
|
|
// generateCert creates a fresh, self-signed ECDSA leaf certificate for name.
|
|
func generateCert(name string) (*tls.Certificate, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate block page certificate key: %w", err)
|
|
}
|
|
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 62))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate block page certificate serial: %w", err)
|
|
}
|
|
|
|
tmpl := &x509.Certificate{
|
|
SerialNumber: serial,
|
|
Subject: pkix.Name{CommonName: name, Organization: []string{"VibeDNS Block Page"}},
|
|
NotBefore: time.Now().Add(-time.Hour),
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
if ip := net.ParseIP(name); ip != nil {
|
|
tmpl.IPAddresses = []net.IP{ip}
|
|
} else {
|
|
tmpl.DNSNames = []string{name}
|
|
}
|
|
|
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create block page certificate: %w", err)
|
|
}
|
|
return &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, nil
|
|
}
|
|
|
|
// parseLeaf parses the leaf certificate's DER bytes, for tests that need to
|
|
// inspect what generateCert produced.
|
|
func parseLeaf(cert *tls.Certificate) (*x509.Certificate, error) {
|
|
return x509.ParseCertificate(cert.Certificate[0])
|
|
}
|