Improve blockpage reliability and deployment
This commit is contained in:
@@ -1,218 +1,448 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"html/template"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CachedCert holds a generated certificate and its expiration time.
|
||||
type CachedCert struct {
|
||||
const (
|
||||
defaultListenAddr = "0.0.0.0"
|
||||
defaultListenPort = "443"
|
||||
defaultCACertPath = "ssl/ca_cert.pem"
|
||||
defaultCAKeyPath = "ssl/ca_key.pem"
|
||||
defaultBlockPagePath = "webroot/block.html"
|
||||
defaultWebrootDir = "webroot"
|
||||
)
|
||||
|
||||
type config struct {
|
||||
ListenAddr string
|
||||
ListenPort string
|
||||
CACertPath string
|
||||
CAKeyPath string
|
||||
BlockPagePath string
|
||||
WebrootDir string
|
||||
ShutdownTimeout time.Duration
|
||||
}
|
||||
|
||||
type cachedCert struct {
|
||||
cert tls.Certificate
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type blockPageData struct {
|
||||
RequestedURL string
|
||||
Host string
|
||||
Path string
|
||||
}
|
||||
|
||||
var (
|
||||
// certCache maps a domain to its generated certificate.
|
||||
certCache = make(map[string]CachedCert)
|
||||
certCache = make(map[string]cachedCert)
|
||||
cacheMu sync.Mutex
|
||||
|
||||
// Global CA certificate and key.
|
||||
caCert *x509.Certificate
|
||||
caKey *rsa.PrivateKey
|
||||
|
||||
// Block page HTML content.
|
||||
blockPageHTML string
|
||||
blockPageTemplate *template.Template
|
||||
)
|
||||
|
||||
// defaultBlockPageHTML is used if no file is found.
|
||||
const defaultBlockPageHTML = `<!doctype html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Access Blocked</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f7f7f7; }
|
||||
h1 { font-size: 48px; color: #e74c3c; }
|
||||
p { font-size: 20px; }
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f4f7fb;
|
||||
--panel: #ffffff;
|
||||
--text: #1f2937;
|
||||
--muted: #5f6b7a;
|
||||
--border: #d7dee8;
|
||||
--accent: #c2410c;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #101827;
|
||||
--panel: #162033;
|
||||
--text: #f8fafc;
|
||||
--muted: #cbd5e1;
|
||||
--border: #334155;
|
||||
--accent: #fb923c;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
main {
|
||||
width: min(100%, 680px);
|
||||
padding: 32px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 18px 45px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 18px;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.status::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: clamp(2rem, 6vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
p {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
dl {
|
||||
margin: 26px 0 0;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
dt {
|
||||
margin-bottom: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Access Blocked</h1>
|
||||
<p>Hey, this site is blocked by your network policy.</p>
|
||||
<p>If you think this is an error, please contact your network administrator.</p>
|
||||
<main>
|
||||
<div class="status">Network policy</div>
|
||||
<h1>Access Blocked</h1>
|
||||
<p>This destination is blocked by the network policy currently applied to this connection.</p>
|
||||
<p>If you believe this is incorrect, contact the network administrator and include the requested URL below.</p>
|
||||
<dl>
|
||||
<dt>Requested URL</dt>
|
||||
<dd>{{ .RequestedURL }}</dd>
|
||||
</dl>
|
||||
</main>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// generateAndSaveCA generates a new self-signed CA and writes the certificate and key to disk.
|
||||
func generateAndSaveCA(certPath, keyPath string) error {
|
||||
// Generate a new RSA key for the CA (use a larger key for a CA)
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate CA key: %w", err)
|
||||
}
|
||||
func main() {
|
||||
cfg := loadConfigFromEnv()
|
||||
|
||||
// Create a certificate template for a CA.
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"Dynamic MITM CA"},
|
||||
CommonName: "Dynamic MITM CA",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour), // valid for 10 years
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
// Self-sign the certificate.
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Write the certificate to file.
|
||||
certOut, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create CA cert file: %w", err)
|
||||
}
|
||||
defer certOut.Close()
|
||||
if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
||||
return fmt.Errorf("failed to write CA cert: %w", err)
|
||||
}
|
||||
|
||||
// Write the key to file.
|
||||
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create CA key file: %w", err)
|
||||
}
|
||||
defer keyOut.Close()
|
||||
if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil {
|
||||
return fmt.Errorf("failed to write CA key: %w", err)
|
||||
}
|
||||
|
||||
// Assign the global variables.
|
||||
caCert, err = x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse generated CA certificate: %w", err)
|
||||
}
|
||||
caKey = key
|
||||
|
||||
log.Printf("New CA generated and saved to %s and %s", certPath, keyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCA loads the CA certificate and key from the specified files,
|
||||
// or generates them if they do not exist.
|
||||
func loadCA(caCertPath, caKeyPath string) error {
|
||||
// Try to read the CA certificate.
|
||||
caCertPEM, err := ioutil.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// CA certificate not found: generate a new one.
|
||||
log.Printf("CA certificate not found at %s. Generating a new CA...", caCertPath)
|
||||
return generateAndSaveCA(caCertPath, caKeyPath)
|
||||
}
|
||||
return fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(caCertPEM)
|
||||
if block == nil {
|
||||
return fmt.Errorf("failed to decode CA certificate PEM")
|
||||
}
|
||||
caCert, err = x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
// Try to read the CA key.
|
||||
caKeyPEM, err := ioutil.ReadFile(caKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// If key not found but cert exists, that's an error.
|
||||
return fmt.Errorf("CA key not found at %s", caKeyPath)
|
||||
}
|
||||
return fmt.Errorf("failed to read CA key: %w", err)
|
||||
}
|
||||
block, _ = pem.Decode(caKeyPEM)
|
||||
if block == nil {
|
||||
return fmt.Errorf("failed to decode CA key PEM")
|
||||
}
|
||||
caKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA key: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// loadBlockPage loads the block page HTML from a file.
|
||||
func loadBlockPage() string {
|
||||
path := os.Getenv("BLOCK_PAGE_PATH")
|
||||
if path == "" {
|
||||
path = filepath.Join("webroot", "block.html")
|
||||
if err := loadCA(cfg.CACertPath, cfg.CAKeyPath); err != nil {
|
||||
log.Fatalf("error loading CA: %v", err)
|
||||
}
|
||||
data, err := ioutil.ReadFile(path)
|
||||
|
||||
tmpl, err := loadBlockPage(cfg.BlockPagePath)
|
||||
if err != nil {
|
||||
log.Printf("Could not load block page from %s: %v", path, err)
|
||||
return defaultBlockPageHTML
|
||||
log.Fatalf("error loading block page: %v", err)
|
||||
}
|
||||
blockPageTemplate = tmpl
|
||||
|
||||
server := newServer(cfg)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
log.Printf("starting HTTPS block page server on %s", server.Addr)
|
||||
errCh <- server.ListenAndServeTLS("", "")
|
||||
}()
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-stop:
|
||||
log.Printf("received %s, shutting down", sig)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Fatalf("server shutdown failed: %v", err)
|
||||
}
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// generateCertForDomain creates (and caches) a new certificate for the given domain.
|
||||
func generateCertForDomain(domain string) (tls.Certificate, error) {
|
||||
// Check cache first.
|
||||
cacheMu.Lock()
|
||||
if cached, ok := certCache[domain]; ok {
|
||||
// If the certificate expires in more than 1 minute, return it.
|
||||
if time.Now().Add(1 * time.Minute).Before(cached.expiresAt) {
|
||||
cacheMu.Unlock()
|
||||
return cached.cert, nil
|
||||
func loadConfigFromEnv() config {
|
||||
cfg := config{
|
||||
ListenAddr: envOrDefault("LISTEN_ADDR", defaultListenAddr),
|
||||
ListenPort: envOrDefault("LISTEN_PORT", defaultListenPort),
|
||||
CACertPath: envOrDefault("CA_CERT_PATH", defaultCACertPath),
|
||||
CAKeyPath: envOrDefault("CA_KEY_PATH", defaultCAKeyPath),
|
||||
BlockPagePath: envOrDefault("BLOCK_PAGE_PATH", defaultBlockPagePath),
|
||||
WebrootDir: envOrDefault("WEBROOT_DIR", defaultWebrootDir),
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
if timeout := os.Getenv("SHUTDOWN_TIMEOUT"); timeout != "" {
|
||||
if parsed, err := time.ParseDuration(timeout); err == nil && parsed > 0 {
|
||||
cfg.ShutdownTimeout = parsed
|
||||
} else {
|
||||
log.Printf("invalid SHUTDOWN_TIMEOUT %q, using %s", timeout, cfg.ShutdownTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func envOrDefault(key, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func newServer(cfg config) *http.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", healthHandler)
|
||||
mux.HandleFunc("/ca.crt", caPEMHandler(cfg.CACertPath))
|
||||
mux.HandleFunc("/cert.cer", caDERHandler)
|
||||
mux.Handle("/webroot/", http.StripPrefix("/webroot/", http.FileServer(http.Dir(cfg.WebrootDir))))
|
||||
mux.HandleFunc("/", blockHandler)
|
||||
|
||||
return &http.Server{
|
||||
Addr: net.JoinHostPort(cfg.ListenAddr, cfg.ListenPort),
|
||||
Handler: mux,
|
||||
TLSConfig: newTLSConfig(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func newTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: getCertificate,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
func generateAndSaveCA(certPath, keyPath string) error {
|
||||
if err := ensureParentDir(certPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureParentDir(keyPath, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serialNumber, err := randomSerialNumber()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"MITM Blockpage"},
|
||||
CommonName: "MITM Blockpage Local CA",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create CA certificate: %w", err)
|
||||
}
|
||||
|
||||
if err := writePEMFile(certPath, 0644, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil {
|
||||
return fmt.Errorf("failed to write CA cert: %w", err)
|
||||
}
|
||||
if err := writePEMFile(keyPath, 0600, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}); err != nil {
|
||||
return fmt.Errorf("failed to write CA key: %w", err)
|
||||
}
|
||||
|
||||
caCert, err = x509.ParseCertificate(derBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse generated CA certificate: %w", err)
|
||||
}
|
||||
caKey = key
|
||||
|
||||
log.Printf("new CA generated and saved to %s and %s", certPath, keyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePEMFile(path string, perm os.FileMode, block *pem.Block) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := pem.Encode(file, block); err != nil {
|
||||
return err
|
||||
}
|
||||
return file.Chmod(perm)
|
||||
}
|
||||
|
||||
func loadCA(caCertPath, caKeyPath string) error {
|
||||
caCertPEM, err := os.ReadFile(caCertPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("CA certificate not found at %s, generating a new CA", caCertPath)
|
||||
return generateAndSaveCA(caCertPath, caKeyPath)
|
||||
}
|
||||
return fmt.Errorf("failed to read CA cert: %w", err)
|
||||
}
|
||||
|
||||
certBlock, err := decodeSinglePEMBlock(caCertPEM, "CERTIFICATE")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode CA certificate PEM: %w", err)
|
||||
}
|
||||
caCert, err = x509.ParseCertificate(certBlock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
caKeyPEM, err := os.ReadFile(caKeyPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("CA key not found at %s", caKeyPath)
|
||||
}
|
||||
return fmt.Errorf("failed to read CA key: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, err := decodeSinglePEMBlock(caKeyPEM, "RSA PRIVATE KEY")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode CA key PEM: %w", err)
|
||||
}
|
||||
caKey, err = x509.ParsePKCS1PrivateKey(keyBlock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse CA key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeSinglePEMBlock(data []byte, expectedType string) ([]byte, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, errors.New("no PEM block found")
|
||||
}
|
||||
if block.Type != expectedType {
|
||||
return nil, fmt.Errorf("unexpected PEM type %q", block.Type)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
func ensureParentDir(path string, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, perm); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadBlockPage(path string) (*template.Template, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
log.Printf("could not load block page from %s: %v", path, err)
|
||||
} else {
|
||||
log.Printf("block page not found at %s, using built-in fallback", path)
|
||||
}
|
||||
data = []byte(defaultBlockPageHTML)
|
||||
}
|
||||
return template.New("block-page").Parse(string(data))
|
||||
}
|
||||
|
||||
func generateCertForDomain(domain string) (tls.Certificate, error) {
|
||||
cacheMu.Lock()
|
||||
if cached, ok := certCache[domain]; ok && time.Now().Add(time.Minute).Before(cached.expiresAt) {
|
||||
cacheMu.Unlock()
|
||||
return cached.cert, nil
|
||||
}
|
||||
cacheMu.Unlock()
|
||||
|
||||
// Generate a new RSA key.
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generating key: %w", err)
|
||||
}
|
||||
|
||||
// Create a certificate template.
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
serialNumber, err := randomSerialNumber()
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("generating serial number: %w", err)
|
||||
return tls.Certificate{}, err
|
||||
}
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: domain,
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Minute),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour), // valid for 30 days
|
||||
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
template.IPAddresses = []net.IP{ip}
|
||||
} else {
|
||||
template.DNSNames = []string{domain}
|
||||
}
|
||||
|
||||
derBytes, err := x509.CreateCertificate(rand.Reader, &template, caCert, &key.PublicKey, caKey)
|
||||
@@ -220,139 +450,134 @@ func generateCertForDomain(domain string) (tls.Certificate, error) {
|
||||
return tls.Certificate{}, fmt.Errorf("creating certificate: %w", err)
|
||||
}
|
||||
|
||||
// PEM encode certificate and key.
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
|
||||
certPEM, keyPEM := encodeCertificateAndKey(derBytes, key)
|
||||
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("loading TLS key pair: %w", err)
|
||||
}
|
||||
|
||||
// Parse the certificate to get its expiration.
|
||||
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
|
||||
if err != nil {
|
||||
return tls.Certificate{}, fmt.Errorf("parsing generated certificate: %w", err)
|
||||
}
|
||||
expiry := leaf.NotAfter
|
||||
|
||||
// Cache the certificate.
|
||||
cacheMu.Lock()
|
||||
certCache[domain] = CachedCert{cert: tlsCert, expiresAt: expiry}
|
||||
certCache[domain] = cachedCert{cert: tlsCert, expiresAt: leaf.NotAfter}
|
||||
cacheMu.Unlock()
|
||||
|
||||
return tlsCert, nil
|
||||
}
|
||||
|
||||
// getCertificate is the TLS callback that provides a certificate based on SNI.
|
||||
func encodeCertificateAndKey(derBytes []byte, key *rsa.PrivateKey) ([]byte, []byte) {
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func randomSerialNumber() (*big.Int, error) {
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate serial number: %w", err)
|
||||
}
|
||||
return serialNumber, nil
|
||||
}
|
||||
|
||||
func getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
domain := hello.ServerName
|
||||
domain := strings.TrimSpace(hello.ServerName)
|
||||
if domain == "" {
|
||||
domain = "localhost"
|
||||
}
|
||||
log.Printf("SNI request for domain: %s", domain)
|
||||
|
||||
cert, err := generateCertForDomain(domain)
|
||||
if err != nil {
|
||||
log.Printf("Error generating cert for %s: %v", domain, err)
|
||||
log.Printf("error generating cert for %s: %v", domain, err)
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
}
|
||||
|
||||
// caHandler serves the CA certificate so that it can be added to a browser trust store.
|
||||
func caHandler(w http.ResponseWriter, r *http.Request) {
|
||||
caPath := os.Getenv("CA_CERT_PATH")
|
||||
if caPath == "" {
|
||||
caPath = filepath.Join("ssl", "ca_cert.pem")
|
||||
func caPEMHandler(caPath string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
caData, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
http.Error(w, "CA certificate not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="mitm-blockpage-ca.crt"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(caData)
|
||||
}
|
||||
caData, err := ioutil.ReadFile(caPath)
|
||||
if err != nil {
|
||||
http.Error(w, "CA certificate not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(caData)
|
||||
}
|
||||
|
||||
// caDERHandler serves the CA certificate in DER format.
|
||||
func caDERHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if caCert == nil {
|
||||
http.Error(w, "CA not loaded", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// The DER-encoded certificate is available as caCert.Raw.
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
|
||||
w.Write(caCert.Raw)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="mitm-blockpage-ca.cer"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method == http.MethodHead {
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(caCert.Raw)
|
||||
}
|
||||
|
||||
// blockHandler serves the block page.
|
||||
func blockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Serving block page for %s", r.URL.String())
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
if caCert == nil || caKey == nil || blockPageTemplate == nil {
|
||||
http.Error(w, "not ready", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(blockPageHTML))
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Read configuration from environment variables (with defaults).
|
||||
listenAddr := os.Getenv("LISTEN_ADDR")
|
||||
if listenAddr == "" {
|
||||
listenAddr = "0.0.0.0"
|
||||
}
|
||||
listenPort := os.Getenv("LISTEN_PORT")
|
||||
if listenPort == "" {
|
||||
listenPort = "443"
|
||||
}
|
||||
caCertPath := os.Getenv("CA_CERT_PATH")
|
||||
if caCertPath == "" {
|
||||
caCertPath = filepath.Join("ssl", "ca_cert.pem")
|
||||
}
|
||||
caKeyPath := os.Getenv("CA_KEY_PATH")
|
||||
if caKeyPath == "" {
|
||||
caKeyPath = filepath.Join("ssl", "ca_key.pem")
|
||||
}
|
||||
blockPagePath := os.Getenv("BLOCK_PAGE_PATH")
|
||||
if blockPagePath == "" {
|
||||
blockPagePath = filepath.Join("webroot", "block.html")
|
||||
}
|
||||
|
||||
// Load the CA certificate and key.
|
||||
if err := loadCA(caCertPath, caKeyPath); err != nil {
|
||||
log.Fatalf("Error loading CA: %v", err)
|
||||
}
|
||||
|
||||
// Load the block page HTML.
|
||||
blockPageHTML = loadBlockPage()
|
||||
|
||||
// Create a new ServeMux.
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Route to serve the CA certificate.
|
||||
mux.HandleFunc("/ca.crt", caHandler)
|
||||
// Serve static files from the webroot subdirectory.
|
||||
mux.Handle("/webroot/", http.StripPrefix("/webroot/", http.FileServer(http.Dir("webroot"))))
|
||||
// All other requests show the block page.
|
||||
mux.HandleFunc("/", blockHandler)
|
||||
// Route to serve the CA certificate in DER format.
|
||||
mux.HandleFunc("/cert.cer", caDERHandler)
|
||||
|
||||
// Create a TLS configuration with our dynamic certificate callback.
|
||||
tlsConfig := &tls.Config{
|
||||
GetCertificate: getCertificate,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
// Create the HTTP server.
|
||||
serverAddr := fmt.Sprintf("%s:%s", listenAddr, listenPort)
|
||||
server := &http.Server{
|
||||
Addr: serverAddr,
|
||||
Handler: mux,
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
log.Printf("Starting HTTPS server on %s with dynamic certificate generation...", serverAddr)
|
||||
// Pass empty strings for cert and key because GetCertificate provides them.
|
||||
if err := server.ListenAndServeTLS("", ""); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
if r.Method != http.MethodHead {
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func blockHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
data := blockPageData{
|
||||
RequestedURL: requestedURL(r),
|
||||
Host: r.Host,
|
||||
Path: r.URL.RequestURI(),
|
||||
}
|
||||
|
||||
if err := blockPageTemplate.Execute(w, data); err != nil {
|
||||
log.Printf("error rendering block page for %s: %v", r.Host, err)
|
||||
}
|
||||
}
|
||||
|
||||
func requestedURL(r *http.Request) string {
|
||||
scheme := "https"
|
||||
if r.TLS == nil {
|
||||
scheme = "http"
|
||||
}
|
||||
host := r.Host
|
||||
if host == "" {
|
||||
host = r.URL.Host
|
||||
}
|
||||
return scheme + "://" + host + r.URL.RequestURI()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user