package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"fmt"
"html/template"
"log"
"math/big"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
)
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 = make(map[string]cachedCert)
cacheMu sync.Mutex
caCert *x509.Certificate
caKey *rsa.PrivateKey
blockPageTemplate *template.Template
)
const defaultBlockPageHTML = `
Access Blocked
Network policy
Access Blocked
This destination is blocked by the network policy currently applied to this connection.
If you believe this is incorrect, contact the network administrator and include the requested URL below.
- Requested URL
- {{ .RequestedURL }}
`
func main() {
cfg := loadConfigFromEnv()
if err := loadCA(cfg.CACertPath, cfg.CAKeyPath); err != nil {
log.Fatalf("error loading CA: %v", err)
}
tmpl, err := loadBlockPage(cfg.BlockPagePath)
if err != nil {
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)
}
}
}
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()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, fmt.Errorf("generating key: %w", err)
}
serialNumber, err := randomSerialNumber()
if err != nil {
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: domain,
},
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,
}
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)
if err != nil {
return tls.Certificate{}, fmt.Errorf("creating certificate: %w", err)
}
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)
}
leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil {
return tls.Certificate{}, fmt.Errorf("parsing generated certificate: %w", err)
}
cacheMu.Lock()
certCache[domain] = cachedCert{cert: tlsCert, expiresAt: leaf.NotAfter}
cacheMu.Unlock()
return tlsCert, nil
}
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 := strings.TrimSpace(hello.ServerName)
if domain == "" {
domain = "localhost"
}
cert, err := generateCertForDomain(domain)
if err != nil {
log.Printf("error generating cert for %s: %v", domain, err)
return nil, err
}
return &cert, nil
}
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)
}
}
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
}
w.Header().Set("Content-Type", "application/x-x509-ca-cert")
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)
}
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)
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()
}