From f2bbdcc3aba1d8e341341aeb9433cdf64ad7bb79 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Fri, 22 May 2026 12:46:40 +0200 Subject: [PATCH] Improve blockpage reliability and deployment --- .dockerignore | 7 + .github/workflows/ci.yml | 42 ++ .github/workflows/docker-build.yaml | 61 ++- .gitignore | 7 + Dockerfile | 55 ++- README.md | 108 ++++- ROADMAP.md | 35 ++ docker-compose.yml | 37 +- go.mod | 3 + main.go | 709 ++++++++++++++++++---------- main_test.go | 175 +++++++ webroot/block.html | 154 +++--- 12 files changed, 1041 insertions(+), 352 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 ROADMAP.md create mode 100644 go.mod create mode 100644 main_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a93aaad --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.github +ssl +*.test +*.out +README.md +ROADMAP.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b51228f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Format check + run: test -z "$(gofmt -l .)" + + - name: Test + run: go test ./... + + - name: Build + run: go build ./... + + docker: + name: Docker build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build image + run: docker build . diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 1761c71..40d90e0 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -1,33 +1,68 @@ -name: Build and Push Docker Image +name: Publish Docker Image on: push: - branches: [ main ] + branches: [main] + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: read + packages: write jobs: - build: + test: + name: Test runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Test + run: go test ./... + + publish: + name: Publish + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout + uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Docker image - uses: docker/build-push-action@v4 + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + + - name: Build and push + uses: docker/build-push-action@v6 with: context: . push: true - tags: ghcr.io/${{ github.repository }}:latest + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 6f72f89..5f2cab7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +# Local build output +/mitm-blockpage +/dynamic_mitm_server + # Dependency directories (remove the comment below to include it) # vendor/ @@ -23,3 +27,6 @@ go.work.sum # env file .env + +# Generated local certificate authority material +/ssl/ diff --git a/Dockerfile b/Dockerfile index 9c1bb4f..502d1a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,34 +1,37 @@ -# Build stage using an official Go image. -FROM golang:1.20 AS builder +FROM golang:1.26-alpine AS builder + +WORKDIR /src + +COPY go.mod ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/mitm-blockpage . + +FROM alpine:3.23.4 + +RUN addgroup -S app \ + && adduser -S -G app app \ + && mkdir -p /app/ssl /app/webroot \ + && chown -R app:app /app WORKDIR /app -# Copy the source code and directories. -COPY . . +COPY --from=builder /out/mitm-blockpage ./mitm-blockpage +COPY --chown=app:app webroot ./webroot -# Ensure that the "ssl" and "webroot" directories exist. -RUN mkdir -p ssl webroot +ENV LISTEN_ADDR=0.0.0.0 \ + LISTEN_PORT=8443 \ + CA_CERT_PATH=/app/ssl/ca_cert.pem \ + CA_KEY_PATH=/app/ssl/ca_key.pem \ + BLOCK_PAGE_PATH=/app/webroot/block.html \ + WEBROOT_DIR=/app/webroot -# If no go.mod file exists, initialize a Go module. -RUN if [ ! -f go.mod ]; then \ - go mod init github.com/yourusername/dynamic_mitm_server; \ - fi && \ - go mod tidy +EXPOSE 8443 -# Build the binary. -RUN CGO_ENABLED=0 go build -o dynamic_mitm_server . +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --no-check-certificate -qO- https://127.0.0.1:8443/healthz >/dev/null || exit 1 -# Final minimal image. -FROM alpine:3.23.4 +USER app -WORKDIR /root/ - -# Copy the binary and the ssl & webroot directories. -COPY --from=builder /app/dynamic_mitm_server . -COPY --from=builder /app/ssl ./ssl -COPY --from=builder /app/webroot ./webroot - -# Expose the port (default 443). -EXPOSE 443 - -ENTRYPOINT ["./dynamic_mitm_server"] +ENTRYPOINT ["./mitm-blockpage"] diff --git a/README.md b/README.md index ef6283d..d95935b 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ -# mitm-blockpage \ No newline at end of file +# mitm-blockpage + +`mitm-blockpage` is a small HTTPS block-page service for network filtering setups that redirect blocked TLS traffic to a local endpoint. + +It generates a local certificate authority, creates per-host leaf certificates from the incoming SNI value, and serves a configurable block page over HTTPS. It also exposes the generated CA certificate so managed clients can install the CA into their trust store. + +## What It Does + +- Serves a self-contained block page for all blocked HTTPS requests. +- Generates and caches per-domain certificates at runtime. +- Creates a local CA automatically on first start. +- Exposes the CA as PEM at `/ca.crt` and DER at `/cert.cer`. +- Provides `/healthz` for container and load balancer checks. +- Supports custom block-page HTML through `BLOCK_PAGE_PATH`. + +## Important Security Notes + +This project creates a local CA private key and uses it to sign certificates dynamically. Treat the generated `ssl/ca_key.pem` as sensitive secret material. + +- Do not commit generated `ssl/` contents. +- Restrict access to the host or volume that stores the CA key. +- Use only in environments where users and administrators understand and approve TLS interception. +- Rotate the CA if the key is exposed. + +## Quick Start With Docker Compose + +```sh +docker compose up --build +``` + +By default Compose exposes the service on host port `443` and stores the generated CA files in a Docker volume named `ca-data`. + +To use another host port: + +```sh +HOST_PORT=8443 docker compose up --build +``` + +Then open: + +- `https://localhost/healthz` +- `https://localhost/ca.crt` +- `https://localhost/cert.cer` + +The certificate endpoints use the generated CA certificate. Install the CA certificate only on devices that should trust this block page. The CA private key remains in the Docker volume and is not exposed by an endpoint. + +## Local Development + +This project builds with Go 1.26 or newer. + +```sh +go test ./... +go run . +``` + +The application defaults to listening on `0.0.0.0:443` when run directly. Use a high port for local development if you do not want to run with elevated privileges: + +```sh +LISTEN_ADDR=127.0.0.1 LISTEN_PORT=8443 go run . +``` + +## Configuration + +| Variable | Default | Description | +| --- | --- | --- | +| `LISTEN_ADDR` | `0.0.0.0` | Address the HTTPS server binds to. | +| `LISTEN_PORT` | `443` | Port the HTTPS server binds to. The Docker image sets this to `8443`. | +| `CA_CERT_PATH` | `ssl/ca_cert.pem` | Path to the local CA certificate in PEM format. | +| `CA_KEY_PATH` | `ssl/ca_key.pem` | Path to the local CA private key. | +| `BLOCK_PAGE_PATH` | `webroot/block.html` | HTML template rendered for blocked requests. | +| `WEBROOT_DIR` | `webroot` | Directory served below `/webroot/` for optional static assets. | +| `SHUTDOWN_TIMEOUT` | `10s` | Graceful shutdown timeout. | + +## Custom Block Page + +The block page is parsed as a Go HTML template. The default template is self-contained and does not depend on external fonts, CSS, or JavaScript. + +Available template fields: + +| Field | Description | +| --- | --- | +| `{{ .RequestedURL }}` | Full requested URL assembled from scheme, host, and path. | +| `{{ .Host }}` | Request host. | +| `{{ .Path }}` | Request path and query string. | + +Example: + +```html +

Access blocked

+

{{ .RequestedURL }} is blocked by policy.

+``` + +## Endpoints + +| Endpoint | Description | +| --- | --- | +| `/` | Block page fallback for all requests. | +| `/healthz` | Returns `ok` after the CA and block page are loaded. | +| `/ca.crt` | CA certificate in PEM format. | +| `/cert.cer` | CA certificate in DER format, useful for Windows import flows. | +| `/webroot/*` | Optional static files from `WEBROOT_DIR`. | + +## How It Fits Into a Network + +This service does not decide what to block. A firewall, DNS filter, proxy, or policy engine should redirect blocked destinations to this service. `mitm-blockpage` is only responsible for presenting a trusted HTTPS response once traffic arrives. + +See [ROADMAP.md](ROADMAP.md) for planned improvements and open decisions. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..a505acb --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,35 @@ +# Roadmap + +This roadmap focuses on turning `mitm-blockpage` into a reliable, documented component for managed network environments. + +## Current Baseline + +- Runtime certificate generation with an automatically generated local CA. +- Self-contained HTML block page. +- Docker and Compose deployment path. +- Health check endpoint. +- GitHub Actions for CI and container publishing. +- Unit tests for certificate generation, CA loading, config, and handlers. + +## Near Term + +- Add release artifacts for Linux amd64 and arm64. +- Add release notes and documented image tag policy. +- Add example integrations for common redirect patterns, such as DNS sinkhole, firewall NAT, and reverse proxy setups. +- Add a documented CA rotation procedure. +- Add structured JSON logs for request and certificate generation events. + +## Medium Term + +- Support externally managed CA material mounted from a secret store. +- Add Prometheus metrics for requests, certificate cache hits, and certificate generation failures. +- Add configurable certificate lifetime and cache eviction. +- Add admin-facing diagnostics that report active configuration without exposing secret material. +- Add end-to-end tests that validate TLS behavior with a generated CA. + +## Open Decisions + +- Whether the project should remain a standalone block-page service or grow a policy API. +- Whether custom block pages should support only Go templates or also static placeholder replacement. +- Whether the default deployment target should optimize for Docker Compose, Kubernetes, or bare-metal appliance installs. +- Whether per-domain certificate keys should stay ephemeral or optionally be persisted. diff --git a/docker-compose.yml b/docker-compose.yml index 2d4c43e..54dcc90 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,23 +1,26 @@ -version: "3.8" - services: - dynamic-dns: + mitm-blockpage: build: . - container_name: dynamic_dns_server + container_name: mitm-blockpage ports: - - "${LISTEN_PORT:-443}:443" + - "${HOST_PORT:-443}:8443" environment: - # Listening parameters. - - LISTEN_ADDR=0.0.0.0 - - LISTEN_PORT=443 - # CA certificate and key paths (inside the container). - - CA_CERT_PATH=ssl/ca_cert.pem - - CA_KEY_PATH=ssl/ca_key.pem - # Block page file path. - - BLOCK_PAGE_PATH=webroot/block.html + LISTEN_ADDR: 0.0.0.0 + LISTEN_PORT: 8443 + CA_CERT_PATH: /app/ssl/ca_cert.pem + CA_KEY_PATH: /app/ssl/ca_key.pem + BLOCK_PAGE_PATH: /app/webroot/block.html + WEBROOT_DIR: /app/webroot volumes: - # Mount the directory containing your CA files. - - ./ssl:/root/ssl - # Mount your custom webroot (containing block.html, CSS, images, etc.) - - ./webroot:/root/webroot + - ca-data:/app/ssl + - ./webroot:/app/webroot:ro + healthcheck: + test: ["CMD-SHELL", "wget --no-check-certificate -qO- https://127.0.0.1:8443/healthz >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s restart: unless-stopped + +volumes: + ca-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ff8a52d --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/christianlouis/mitm-blockpage + +go 1.26 diff --git a/main.go b/main.go index 46d888b..4e0d75b 100644 --- a/main.go +++ b/main.go @@ -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 = ` - + Access Blocked -

Access Blocked

-

Hey, this site is blocked by your network policy.

-

If you think this is an error, please contact your network administrator.

+
+
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 }}
+
+
` -// 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() +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..408d534 --- /dev/null +++ b/main_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "html/template" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func resetGlobals(t *testing.T) { + t.Helper() + certCache = make(map[string]cachedCert) + caCert = nil + caKey = nil + blockPageTemplate = nil +} + +func TestLoadConfigFromEnv(t *testing.T) { + t.Setenv("LISTEN_ADDR", "127.0.0.1") + t.Setenv("LISTEN_PORT", "8443") + t.Setenv("CA_CERT_PATH", "test/ca.pem") + t.Setenv("CA_KEY_PATH", "test/key.pem") + t.Setenv("BLOCK_PAGE_PATH", "test/block.html") + t.Setenv("WEBROOT_DIR", "test/webroot") + t.Setenv("SHUTDOWN_TIMEOUT", "3s") + + cfg := loadConfigFromEnv() + if cfg.ListenAddr != "127.0.0.1" || cfg.ListenPort != "8443" { + t.Fatalf("unexpected listen config: %#v", cfg) + } + if cfg.CACertPath != "test/ca.pem" || cfg.CAKeyPath != "test/key.pem" { + t.Fatalf("unexpected CA paths: %#v", cfg) + } + if cfg.BlockPagePath != "test/block.html" || cfg.WebrootDir != "test/webroot" { + t.Fatalf("unexpected content paths: %#v", cfg) + } + if cfg.ShutdownTimeout != 3*time.Second { + t.Fatalf("unexpected shutdown timeout: %s", cfg.ShutdownTimeout) + } +} + +func TestGenerateAndLoadCA(t *testing.T) { + resetGlobals(t) + + dir := t.TempDir() + certPath := filepath.Join(dir, "ssl", "ca_cert.pem") + keyPath := filepath.Join(dir, "ssl", "private", "ca_key.pem") + + if err := loadCA(certPath, keyPath); err != nil { + t.Fatalf("loadCA should generate missing CA: %v", err) + } + if caCert == nil || caKey == nil { + t.Fatal("expected CA certificate and key to be loaded") + } + if _, err := os.Stat(certPath); err != nil { + t.Fatalf("expected CA certificate file: %v", err) + } + + keyInfo, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("expected CA key file: %v", err) + } + if got := keyInfo.Mode().Perm(); got != 0600 { + t.Fatalf("expected CA key permissions 0600, got %o", got) + } + + resetGlobals(t) + if err := loadCA(certPath, keyPath); err != nil { + t.Fatalf("loadCA should load existing CA: %v", err) + } + if caCert == nil || caKey == nil { + t.Fatal("expected existing CA certificate and key to load") + } +} + +func TestGenerateCertForDomainCachesCertificate(t *testing.T) { + resetGlobals(t) + loadTestCA(t) + + first, err := generateCertForDomain("blocked.example") + if err != nil { + t.Fatalf("generate first cert: %v", err) + } + second, err := generateCertForDomain("blocked.example") + if err != nil { + t.Fatalf("generate cached cert: %v", err) + } + + if !bytes.Equal(first.Certificate[0], second.Certificate[0]) { + t.Fatal("expected cached certificate to be reused") + } + + leaf, err := x509.ParseCertificate(first.Certificate[0]) + if err != nil { + t.Fatalf("parse generated leaf: %v", err) + } + if len(leaf.DNSNames) != 1 || leaf.DNSNames[0] != "blocked.example" { + t.Fatalf("unexpected DNS SANs: %#v", leaf.DNSNames) + } +} + +func TestGenerateCertForIPAddress(t *testing.T) { + resetGlobals(t) + loadTestCA(t) + + cert, err := generateCertForDomain("192.0.2.10") + if err != nil { + t.Fatalf("generate IP certificate: %v", err) + } + + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("parse generated leaf: %v", err) + } + if len(leaf.IPAddresses) != 1 || !leaf.IPAddresses[0].Equal(net.ParseIP("192.0.2.10")) { + t.Fatalf("unexpected IP SANs: %#v", leaf.IPAddresses) + } +} + +func TestBlockHandlerRendersRequestedURL(t *testing.T) { + resetGlobals(t) + blockPageTemplate = template.Must(template.New("test").Parse(`blocked: {{ .RequestedURL }}`)) + + req := httptest.NewRequest(http.MethodGet, "https://blocked.example/admin?next=%2F", nil) + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + + blockHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "https://blocked.example/admin?next=%2F") { + t.Fatalf("expected requested URL in body, got %q", rec.Body.String()) + } + if got := rec.Header().Get("Cache-Control"); got != "no-store" { + t.Fatalf("expected no-store cache header, got %q", got) + } +} + +func TestHealthHandler(t *testing.T) { + resetGlobals(t) + rec := httptest.NewRecorder() + healthHandler(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 before init, got %d", rec.Code) + } + + loadTestCA(t) + blockPageTemplate = template.Must(template.New("test").Parse("ok")) + rec = httptest.NewRecorder() + healthHandler(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 after init, got %d", rec.Code) + } + if strings.TrimSpace(rec.Body.String()) != "ok" { + t.Fatalf("unexpected health body: %q", rec.Body.String()) + } +} + +func loadTestCA(t *testing.T) { + t.Helper() + dir := t.TempDir() + if err := loadCA(filepath.Join(dir, "ca.pem"), filepath.Join(dir, "ca.key")); err != nil { + t.Fatalf("load test CA: %v", err) + } +} diff --git a/webroot/block.html b/webroot/block.html index ca6144a..7340e68 100644 --- a/webroot/block.html +++ b/webroot/block.html @@ -1,74 +1,122 @@ - + - - + Access Blocked - - - - +
-
-
- block -

Access Blocked

-

This site has been blocked by your network policy.

-

If you believe this is an error, please contact your network administrator.

-
Requested URL:
-

-
-
+
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 }}
+
- - -