Improve blockpage reliability and deployment

This commit is contained in:
Christian Krakau-Louis
2026-05-22 12:46:40 +02:00
parent 085abe990b
commit f2bbdcc3ab
12 changed files with 1041 additions and 352 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.github
ssl
*.test
*.out
README.md
ROADMAP.md
+42
View File
@@ -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 .
+48 -13
View File
@@ -1,33 +1,68 @@
name: Build and Push Docker Image name: Publish Docker Image
on: on:
push: push:
branches: [ main ] branches: [main]
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs: jobs:
build: test:
name: Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout repository - name: Checkout
uses: actions/checkout@v3 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Docker metadata
uses: docker/build-push-action@v4 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: with:
context: . context: .
push: true push: true
tags: ghcr.io/${{ github.repository }}:latest platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+7
View File
@@ -14,6 +14,10 @@
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# Local build output
/mitm-blockpage
/dynamic_mitm_server
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
@@ -23,3 +27,6 @@ go.work.sum
# env file # env file
.env .env
# Generated local certificate authority material
/ssl/
+29 -26
View File
@@ -1,34 +1,37 @@
# Build stage using an official Go image. FROM golang:1.26-alpine AS builder
FROM golang:1.20 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 WORKDIR /app
# Copy the source code and directories. COPY --from=builder /out/mitm-blockpage ./mitm-blockpage
COPY . . COPY --chown=app:app webroot ./webroot
# Ensure that the "ssl" and "webroot" directories exist. ENV LISTEN_ADDR=0.0.0.0 \
RUN mkdir -p ssl webroot 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. EXPOSE 8443
RUN if [ ! -f go.mod ]; then \
go mod init github.com/yourusername/dynamic_mitm_server; \
fi && \
go mod tidy
# Build the binary. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
RUN CGO_ENABLED=0 go build -o dynamic_mitm_server . CMD wget --no-check-certificate -qO- https://127.0.0.1:8443/healthz >/dev/null || exit 1
# Final minimal image. USER app
FROM alpine:3.23.4
WORKDIR /root/ ENTRYPOINT ["./mitm-blockpage"]
# 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"]
+106
View File
@@ -1 +1,107 @@
# mitm-blockpage # 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
<h1>Access blocked</h1>
<p>{{ .RequestedURL }} is blocked by policy.</p>
```
## 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.
+35
View File
@@ -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.
+20 -17
View File
@@ -1,23 +1,26 @@
version: "3.8"
services: services:
dynamic-dns: mitm-blockpage:
build: . build: .
container_name: dynamic_dns_server container_name: mitm-blockpage
ports: ports:
- "${LISTEN_PORT:-443}:443" - "${HOST_PORT:-443}:8443"
environment: environment:
# Listening parameters. LISTEN_ADDR: 0.0.0.0
- LISTEN_ADDR=0.0.0.0 LISTEN_PORT: 8443
- LISTEN_PORT=443 CA_CERT_PATH: /app/ssl/ca_cert.pem
# CA certificate and key paths (inside the container). CA_KEY_PATH: /app/ssl/ca_key.pem
- CA_CERT_PATH=ssl/ca_cert.pem BLOCK_PAGE_PATH: /app/webroot/block.html
- CA_KEY_PATH=ssl/ca_key.pem WEBROOT_DIR: /app/webroot
# Block page file path.
- BLOCK_PAGE_PATH=webroot/block.html
volumes: volumes:
# Mount the directory containing your CA files. - ca-data:/app/ssl
- ./ssl:/root/ssl - ./webroot:/app/webroot:ro
# Mount your custom webroot (containing block.html, CSS, images, etc.) healthcheck:
- ./webroot:/root/webroot 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 restart: unless-stopped
volumes:
ca-data:
+3
View File
@@ -0,0 +1,3 @@
module github.com/christianlouis/mitm-blockpage
go 1.26
+467 -242
View File
@@ -1,218 +1,448 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
"io/ioutil" "html/template"
"log" "log"
"math/big" "math/big"
"net"
"net/http" "net/http"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"syscall"
"time" "time"
) )
// CachedCert holds a generated certificate and its expiration time. const (
type CachedCert struct { 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 cert tls.Certificate
expiresAt time.Time expiresAt time.Time
} }
type blockPageData struct {
RequestedURL string
Host string
Path string
}
var ( var (
// certCache maps a domain to its generated certificate. certCache = make(map[string]cachedCert)
certCache = make(map[string]CachedCert)
cacheMu sync.Mutex cacheMu sync.Mutex
// Global CA certificate and key.
caCert *x509.Certificate caCert *x509.Certificate
caKey *rsa.PrivateKey caKey *rsa.PrivateKey
// Block page HTML content. blockPageTemplate *template.Template
blockPageHTML string
) )
// defaultBlockPageHTML is used if no file is found.
const defaultBlockPageHTML = `<!doctype html> const defaultBlockPageHTML = `<!doctype html>
<html> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Access Blocked</title> <title>Access Blocked</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
body { font-family: sans-serif; text-align: center; padding: 50px; background: #f7f7f7; } :root {
h1 { font-size: 48px; color: #e74c3c; } color-scheme: light dark;
p { font-size: 20px; } --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> </style>
</head> </head>
<body> <body>
<h1>Access Blocked</h1> <main>
<p>Hey, this site is blocked by your network policy.</p> <div class="status">Network policy</div>
<p>If you think this is an error, please contact your network administrator.</p> <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> </body>
</html>` </html>`
// generateAndSaveCA generates a new self-signed CA and writes the certificate and key to disk. func main() {
func generateAndSaveCA(certPath, keyPath string) error { cfg := loadConfigFromEnv()
// 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)
}
// Create a certificate template for a CA. if err := loadCA(cfg.CACertPath, cfg.CAKeyPath); err != nil {
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) log.Fatalf("error loading CA: %v", err)
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")
} }
data, err := ioutil.ReadFile(path)
tmpl, err := loadBlockPage(cfg.BlockPagePath)
if err != nil { if err != nil {
log.Printf("Could not load block page from %s: %v", path, err) log.Fatalf("error loading block page: %v", err)
return defaultBlockPageHTML }
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 loadConfigFromEnv() config {
func generateCertForDomain(domain string) (tls.Certificate, error) { cfg := config{
// Check cache first. ListenAddr: envOrDefault("LISTEN_ADDR", defaultListenAddr),
cacheMu.Lock() ListenPort: envOrDefault("LISTEN_PORT", defaultListenPort),
if cached, ok := certCache[domain]; ok { CACertPath: envOrDefault("CA_CERT_PATH", defaultCACertPath),
// If the certificate expires in more than 1 minute, return it. CAKeyPath: envOrDefault("CA_KEY_PATH", defaultCAKeyPath),
if time.Now().Add(1 * time.Minute).Before(cached.expiresAt) { BlockPagePath: envOrDefault("BLOCK_PAGE_PATH", defaultBlockPagePath),
cacheMu.Unlock() WebrootDir: envOrDefault("WEBROOT_DIR", defaultWebrootDir),
return cached.cert, nil 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() cacheMu.Unlock()
// Generate a new RSA key.
key, err := rsa.GenerateKey(rand.Reader, 2048) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
return tls.Certificate{}, fmt.Errorf("generating key: %w", err) return tls.Certificate{}, fmt.Errorf("generating key: %w", err)
} }
// Create a certificate template. serialNumber, err := randomSerialNumber()
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil { if err != nil {
return tls.Certificate{}, fmt.Errorf("generating serial number: %w", err) return tls.Certificate{}, err
} }
template := x509.Certificate{ template := x509.Certificate{
SerialNumber: serialNumber, SerialNumber: serialNumber,
Subject: pkix.Name{ Subject: pkix.Name{
CommonName: domain, CommonName: domain,
}, },
NotBefore: time.Now().Add(-1 * time.Minute), NotBefore: time.Now().Add(-time.Minute),
NotAfter: time.Now().Add(30 * 24 * time.Hour), // valid for 30 days NotAfter: time.Now().Add(30 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true, 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) 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) return tls.Certificate{}, fmt.Errorf("creating certificate: %w", err)
} }
// PEM encode certificate and key. certPEM, keyPEM := encodeCertificateAndKey(derBytes, key)
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil { if err != nil {
return tls.Certificate{}, fmt.Errorf("loading TLS key pair: %w", err) 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]) leaf, err := x509.ParseCertificate(tlsCert.Certificate[0])
if err != nil { if err != nil {
return tls.Certificate{}, fmt.Errorf("parsing generated certificate: %w", err) return tls.Certificate{}, fmt.Errorf("parsing generated certificate: %w", err)
} }
expiry := leaf.NotAfter
// Cache the certificate.
cacheMu.Lock() cacheMu.Lock()
certCache[domain] = CachedCert{cert: tlsCert, expiresAt: expiry} certCache[domain] = cachedCert{cert: tlsCert, expiresAt: leaf.NotAfter}
cacheMu.Unlock() cacheMu.Unlock()
return tlsCert, nil 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) { func getCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
domain := hello.ServerName domain := strings.TrimSpace(hello.ServerName)
if domain == "" { if domain == "" {
domain = "localhost" domain = "localhost"
} }
log.Printf("SNI request for domain: %s", domain)
cert, err := generateCertForDomain(domain) cert, err := generateCertForDomain(domain)
if err != nil { 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 nil, err
} }
return &cert, nil return &cert, nil
} }
// caHandler serves the CA certificate so that it can be added to a browser trust store. func caPEMHandler(caPath string) http.HandlerFunc {
func caHandler(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
caPath := os.Getenv("CA_CERT_PATH") if r.Method != http.MethodGet && r.Method != http.MethodHead {
if caPath == "" { http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
caPath = filepath.Join("ssl", "ca_cert.pem") 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) { 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 { if caCert == nil {
http.Error(w, "CA not loaded", http.StatusInternalServerError) http.Error(w, "CA not loaded", http.StatusInternalServerError)
return return
} }
// The DER-encoded certificate is available as caCert.Raw.
w.Header().Set("Content-Type", "application/x-x509-ca-cert") 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 healthHandler(w http.ResponseWriter, r *http.Request) {
func blockHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead {
log.Printf("Serving block page for %s", r.URL.String()) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
w.Header().Set("Content-Type", "text/html") 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.WriteHeader(http.StatusOK)
w.Write([]byte(blockPageHTML)) if r.Method != http.MethodHead {
} _, _ = w.Write([]byte("ok\n"))
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)
} }
} }
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()
}
+175
View File
@@ -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)
}
}
+101 -53
View File
@@ -1,74 +1,122 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Access Blocked</title> <title>Access Blocked</title>
<!-- Materialize CSS --> <meta name="viewport" content="width=device-width, initial-scale=1">
<link
href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"
rel="stylesheet"
/>
<!-- Material Icons -->
<link
href="https://fonts.googleapis.com/icon?family=Material+Icons"
rel="stylesheet"
/>
<style> <style>
: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 { body {
display: flex; margin: 0;
min-height: 100vh; min-height: 100vh;
flex-direction: column; display: grid;
background: #f5f5f5; place-items: center;
padding: 24px;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
} }
main { main {
flex: 1 0 auto; 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);
} }
.container {
margin-top: 5rem; .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;
} }
.card-panel {
padding: 2rem; .status::before {
text-align: center; content: "";
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
} }
.blocked-icon {
font-size: 4rem; h1 {
color: #e53935; margin: 0 0 12px;
font-size: clamp(2rem, 6vw, 3.4rem);
line-height: 1.05;
letter-spacing: 0;
} }
.blocked-title {
font-size: 2.5rem; p {
margin: 1rem 0; margin: 0 0 16px;
color: var(--muted);
font-size: 1.05rem;
line-height: 1.6;
} }
.blocked-url {
font-size: 1.2rem; dl {
color: #555; margin: 26px 0 0;
word-break: break-all; 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> </style>
</head> </head>
<body> <body>
<main> <main>
<div class="container"> <div class="status">Network policy</div>
<div class="card-panel z-depth-3"> <h1>Access Blocked</h1>
<i class="material-icons blocked-icon">block</i> <p>This destination is blocked by the network policy currently applied to this connection.</p>
<h2 class="blocked-title">Access Blocked</h2> <p>If you believe this is incorrect, contact the network administrator and include the requested URL below.</p>
<p>This site has been blocked by your network policy.</p> <dl>
<p>If you believe this is an error, please contact your network administrator.</p> <dt>Requested URL</dt>
<h5>Requested URL:</h5> <dd>{{ .RequestedURL }}</dd>
<p id="requestedUrl" class="blocked-url"></p> </dl>
</div>
</div>
</main> </main>
<!-- Materialize JS (optional for any interactive components) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
<script>
// On page load, insert the current URL into the block page.
document.addEventListener("DOMContentLoaded", function () {
var urlElement = document.getElementById("requestedUrl");
if (urlElement) {
urlElement.textContent = window.location.href;
}
});
</script>
</body> </body>
</html> </html>