mirror of
https://github.com/aarmea/fahrengit-451.git
synced 2026-03-22 08:58:15 +00:00
Initial commit
Change written by Claude Sonnet 4.6: I need to set up a Git hosting service for personal projects where certain repositories need to be blocked using the locale corresponding to the user’s IP address. Here are my constraints: * The entire system will be running on a single VPS instance. I should be able to `docker compose up -d` the whole thing. * For each repository that this feature is enabled, I need to be able to set the blocked locales down to the state level, along with a custom HTTP status code and response body. * You may assume that the IP address of the request is where it actually came from — for this exercise, if the user uses a VPN to bypass the restriction, that is on them. * To simplify a reverse proxy setup, you may assume that all Git operations will happen over HTTPS. I will firewall off SSH access. * I will be using Let's Encrypt for HTTPS. Some suggestions from prior research: * nginx seems like a reasonable reverse proxy that supports all of the requirements, but you may use a different one if it is simpler to implement or maintain. * I can obtain a MaxMind API key to get a geo-IP lookup table. If you use this, you will need to add a service that automatically retrieves the table at a reasonable frequency. * Forgejo seems like a reasonable, lightweight Git service, but you may use a different one if you’re aware of one that actually supports these requirements out of the box. Write me a production-ready `docker-compose.yml` and supporting scripts or configuration scaffolding for me to implement this.
This commit is contained in:
commit
cf99cd50f2
14 changed files with 1181 additions and 0 deletions
12
.env.example
Normal file
12
.env.example
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Copy this file to .env and fill in your values.
|
||||
# NEVER commit .env to version control.
|
||||
|
||||
# Your public domain name (used by nginx and Forgejo)
|
||||
DOMAIN=git.example.com
|
||||
|
||||
# MaxMind GeoLite2 credentials — sign up at https://www.maxmind.com/en/geolite2/signup
|
||||
MAXMIND_ACCOUNT_ID=your_account_id
|
||||
MAXMIND_LICENSE_KEY=your_license_key
|
||||
|
||||
# Set to true after initial setup to prevent public registration
|
||||
DISABLE_REGISTRATION=true
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.env
|
||||
247
README.md
Normal file
247
README.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# Self-hosted Git (Forgejo) with State-Level Geo-Blocking
|
||||
|
||||
A single-VPS Docker Compose stack providing:
|
||||
|
||||
- **Forgejo** — lightweight, Gitea-compatible Git hosting
|
||||
- **nginx** — reverse proxy with TLS termination and GeoIP2 blocking
|
||||
- **MaxMind GeoLite2** — IP → country + state/province database (auto-updated)
|
||||
- **geoblock_watcher** — watches `geo_rules.yml` and hot-reloads nginx when rules change
|
||||
- **Certbot** — automatic Let's Encrypt certificate renewal
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
.
|
||||
├── docker-compose.yml
|
||||
├── .env.example ← copy to .env and fill in
|
||||
├── geo_rules.yml ← ✏️ edit this to configure geo-blocking
|
||||
├── bootstrap_certs.sh ← run once before first `docker compose up`
|
||||
├── nginx/
|
||||
│ ├── Dockerfile ← builds nginx + GeoIP2 dynamic module
|
||||
│ ├── nginx.conf ← main nginx config (loads GeoIP2 module)
|
||||
│ ├── conf.d/
|
||||
│ │ └── git.conf ← virtual host (HTTP→HTTPS redirect + proxy)
|
||||
│ └── geoblock/ ← rendered by geoblock_watcher at runtime
|
||||
│ ├── repo_maps.conf
|
||||
│ ├── repo_vars.conf
|
||||
│ └── repo_locations.conf
|
||||
└── geoblock_watcher/
|
||||
├── Dockerfile
|
||||
└── watcher.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Notes |
|
||||
|---|---|
|
||||
| Docker Engine ≥ 26 + Compose v2 | `docker compose version` |
|
||||
| A public domain name | DNS A record → your VPS IP |
|
||||
| Ports 80 and 443 open | Firewall / security group |
|
||||
| MaxMind account | Free — [sign up here](https://www.maxmind.com/en/geolite2/signup) |
|
||||
| `openssl` on the host | Used by `bootstrap_certs.sh` for the dummy cert |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure environment
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
$EDITOR .env # fill in DOMAIN, MAXMIND_*, LETSENCRYPT_EMAIL
|
||||
```
|
||||
|
||||
`.env` variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DOMAIN` | Your public domain, e.g. `git.example.com` |
|
||||
| `LETSENCRYPT_EMAIL` | Email for Let's Encrypt expiry notices |
|
||||
| `MAXMIND_ACCOUNT_ID` | From your MaxMind account portal |
|
||||
| `MAXMIND_LICENSE_KEY` | From your MaxMind account portal |
|
||||
| `DISABLE_REGISTRATION` | Set `true` after creating your admin account |
|
||||
|
||||
### 2. Bootstrap TLS certificates (first run only)
|
||||
|
||||
```bash
|
||||
chmod +x bootstrap_certs.sh
|
||||
./bootstrap_certs.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Create a temporary self-signed cert so nginx can start
|
||||
2. Bring up the stack
|
||||
3. Obtain a real Let's Encrypt cert via the ACME webroot challenge
|
||||
4. Reload nginx with the real cert
|
||||
5. Print next steps
|
||||
|
||||
### 3. Complete Forgejo setup
|
||||
|
||||
Visit `https://your-domain/` and complete the web installer. Create your
|
||||
admin account. Then set `DISABLE_REGISTRATION=true` in `.env` and run:
|
||||
|
||||
```bash
|
||||
docker compose up -d forgejo
|
||||
```
|
||||
|
||||
### 4. Configure geo-blocking
|
||||
|
||||
Edit `geo_rules.yml` — the watcher will detect the change within seconds and
|
||||
hot-reload nginx automatically. No restart needed.
|
||||
|
||||
---
|
||||
|
||||
## Geo-Blocking Configuration
|
||||
|
||||
`geo_rules.yml` is the single source of truth. Example:
|
||||
|
||||
```yaml
|
||||
repos:
|
||||
|
||||
- path: /alice/secret-project
|
||||
rules:
|
||||
# Block California and Texas with HTTP 451
|
||||
- locales: ["US-CA", "US-TX"]
|
||||
status: 451
|
||||
body: "This repository is unavailable in your jurisdiction."
|
||||
|
||||
# Block all of Germany and France with HTTP 403
|
||||
- locales: ["DE", "FR"]
|
||||
status: 403
|
||||
body: "Access to this repository is restricted in your country."
|
||||
|
||||
- path: /alice/another-repo
|
||||
rules:
|
||||
- locales: ["CN", "RU"]
|
||||
status: 403
|
||||
body: "Access denied."
|
||||
```
|
||||
|
||||
### Locale format
|
||||
|
||||
| Format | Example | Matches |
|
||||
|---|---|---|
|
||||
| Country (ISO 3166-1 α-2) | `"US"` | All IPs in the United States |
|
||||
| Country + State (ISO 3166-2) | `"US-CA"` | IPs in California |
|
||||
|
||||
State-level rules take precedence over country-level rules for the same repo.
|
||||
|
||||
**Common US state codes:** `US-AL` `US-AK` `US-AZ` `US-AR` `US-CA` `US-CO`
|
||||
`US-CT` `US-DE` `US-FL` `US-GA` `US-HI` `US-ID` `US-IL` `US-IN` `US-IA`
|
||||
`US-KS` `US-KY` `US-LA` `US-ME` `US-MD` `US-MA` `US-MI` `US-MN` `US-MS`
|
||||
`US-MO` `US-MT` `US-NE` `US-NV` `US-NH` `US-NJ` `US-NM` `US-NY` `US-NC`
|
||||
`US-ND` `US-OH` `US-OK` `US-OR` `US-PA` `US-RI` `US-SC` `US-SD` `US-TN`
|
||||
`US-TX` `US-UT` `US-VT` `US-VA` `US-WA` `US-WV` `US-WI` `US-WY`
|
||||
|
||||
For other countries, find subdivision codes at:
|
||||
https://www.iso.org/obp/ui/#search (search for the country, then see "Subdivision")
|
||||
|
||||
### HTTP status codes
|
||||
|
||||
| Code | Meaning | When to use |
|
||||
|---|---|---|
|
||||
| `403` | Forbidden | General access restriction |
|
||||
| `451` | Unavailable For Legal Reasons | Legal / jurisdictional block (RFC 7725) |
|
||||
|
||||
### Hot reload
|
||||
|
||||
The watcher polls every 60 seconds and also reacts to inotify events
|
||||
immediately. After saving `geo_rules.yml`, nginx will reload within seconds.
|
||||
No traffic is dropped — nginx does a graceful configuration reload (SIGHUP).
|
||||
|
||||
---
|
||||
|
||||
## GeoIP Database Updates
|
||||
|
||||
The `geoipupdate` container fetches a fresh **GeoLite2-City** database every
|
||||
72 hours (MaxMind publishes updates twice a week). The database is stored in
|
||||
the `geoip_db` Docker volume and mounted read-only into nginx.
|
||||
|
||||
nginx reads the database file at request time (not cached in memory), so a
|
||||
fresh database takes effect for the next request after the file is replaced —
|
||||
no nginx reload required.
|
||||
|
||||
---
|
||||
|
||||
## Certificate Renewal
|
||||
|
||||
The `certbot` container runs `certbot renew` every 12 hours. When a
|
||||
certificate is renewed, run:
|
||||
|
||||
```bash
|
||||
docker compose exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
Or add this as a cron job on the host:
|
||||
|
||||
```cron
|
||||
0 */12 * * * docker compose -f /path/to/docker-compose.yml exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operations
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
docker compose logs -f nginx # access + error logs
|
||||
docker compose logs -f geoblock_watcher
|
||||
docker compose logs -f forgejo
|
||||
docker compose logs -f geoipupdate
|
||||
```
|
||||
|
||||
### Test geo-blocking (from a blocked region)
|
||||
|
||||
Use a proxy or VPN to simulate a request from a blocked locale, or test
|
||||
directly with curl overriding your IP (only works if you control nginx):
|
||||
|
||||
```bash
|
||||
# Verify nginx config is valid after a rules change
|
||||
docker compose exec nginx nginx -t
|
||||
|
||||
# Force a manual nginx reload
|
||||
docker compose exec nginx nginx -s reload
|
||||
```
|
||||
|
||||
### Verify the GeoIP database is loaded
|
||||
|
||||
```bash
|
||||
docker compose exec nginx nginx -T | grep geoip2
|
||||
```
|
||||
|
||||
### Check which database version is in use
|
||||
|
||||
```bash
|
||||
docker compose exec geoipupdate cat /usr/share/GeoIP/GeoLite2-City_*/COPYRIGHT_AND_LICENSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **SSH is disabled** in Forgejo; all Git operations use HTTPS.
|
||||
- **Registration is disabled** by default after initial setup — only the admin
|
||||
can create accounts.
|
||||
- nginx **does not forward** `X-Forwarded-For` from downstream; it sets it
|
||||
from `$remote_addr` (the actual connected IP). This is intentional — we
|
||||
explicitly trust the direct connection IP as stated in the requirements.
|
||||
- The `docker.sock` mount on `geoblock_watcher` is the minimum necessary
|
||||
to send SIGHUP to the nginx container. If this is a concern, you can
|
||||
replace it with a small privileged sidecar that only accepts a reload signal.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
|---|---|
|
||||
| nginx won't start | `docker compose logs nginx` — likely a config syntax error |
|
||||
| GeoIP variables always empty | Is the `geoip_db` volume populated? Check `docker compose logs geoipupdate` |
|
||||
| Rules not applied | Check `docker compose logs geoblock_watcher` — look for YAML parse errors |
|
||||
| Certificate errors | Ensure port 80 is open and DNS resolves before running `bootstrap_certs.sh` |
|
||||
| 502 Bad Gateway | Forgejo not healthy yet — check `docker compose logs forgejo` |
|
||||
91
bootstrap_certs.sh
Normal file
91
bootstrap_certs.sh
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env bash
|
||||
# bootstrap_certs.sh
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Run this ONCE before `docker compose up -d` to obtain the initial Let's
|
||||
# Encrypt certificate. nginx must be able to serve the ACME challenge, so we
|
||||
# bring up only the services needed for that, run certbot, then download the
|
||||
# Certbot recommended TLS options, and finally start everything.
|
||||
#
|
||||
# Prerequisites:
|
||||
# • docker compose v2 installed
|
||||
# • DNS for $DOMAIN already pointing to this server's IP
|
||||
# • Ports 80 and 443 open in your firewall
|
||||
# • .env file present (copy from .env.example and fill in)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
echo "ERROR: .env file not found. Copy .env.example → .env and fill in your values."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source .env
|
||||
|
||||
DOMAIN="${DOMAIN:?DOMAIN must be set in .env}"
|
||||
EMAIL="${LETSENCRYPT_EMAIL:?LETSENCRYPT_EMAIL must be set in .env}"
|
||||
CERTS_DIR="./certs"
|
||||
|
||||
echo "==> Creating certificate directory structure..."
|
||||
mkdir -p "${CERTS_DIR}/live/${DOMAIN}"
|
||||
mkdir -p "${CERTS_DIR}/archive"
|
||||
|
||||
# ── Download Certbot recommended TLS options ──────────────────────────────────
|
||||
if [[ ! -f "${CERTS_DIR}/options-ssl-nginx.conf" ]]; then
|
||||
echo "==> Downloading recommended TLS options..."
|
||||
curl -sSL \
|
||||
"https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf" \
|
||||
-o "${CERTS_DIR}/options-ssl-nginx.conf"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${CERTS_DIR}/ssl-dhparams.pem" ]]; then
|
||||
echo "==> Downloading DH parameters..."
|
||||
curl -sSL \
|
||||
"https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem" \
|
||||
-o "${CERTS_DIR}/ssl-dhparams.pem"
|
||||
fi
|
||||
|
||||
# ── Create a dummy certificate so nginx can start (needed for ACME challenge) ─
|
||||
DUMMY_LIVE="${CERTS_DIR}/live/${DOMAIN}"
|
||||
if [[ ! -f "${DUMMY_LIVE}/fullchain.pem" ]]; then
|
||||
echo "==> Generating temporary self-signed certificate..."
|
||||
openssl req -x509 -nodes -newkey rsa:4096 -days 1 \
|
||||
-keyout "${DUMMY_LIVE}/privkey.pem" \
|
||||
-out "${DUMMY_LIVE}/fullchain.pem" \
|
||||
-subj "/CN=${DOMAIN}"
|
||||
fi
|
||||
|
||||
# ── Start nginx (and dependencies) ───────────────────────────────────────────
|
||||
echo "==> Starting nginx with temporary certificate..."
|
||||
docker compose up -d nginx forgejo geoipupdate geoblock_watcher
|
||||
|
||||
echo "==> Waiting for nginx to be ready..."
|
||||
sleep 5
|
||||
|
||||
# ── Obtain the real certificate via webroot challenge ────────────────────────
|
||||
echo "==> Requesting Let's Encrypt certificate for ${DOMAIN}..."
|
||||
docker compose run --rm certbot certonly \
|
||||
--webroot \
|
||||
--webroot-path /var/www/certbot \
|
||||
--email "${EMAIL}" \
|
||||
--agree-tos \
|
||||
--no-eff-email \
|
||||
-d "${DOMAIN}"
|
||||
|
||||
# ── Reload nginx with the real certificate ────────────────────────────────────
|
||||
echo "==> Reloading nginx with the real certificate..."
|
||||
docker compose exec nginx nginx -s reload
|
||||
|
||||
# ── Start remaining services ──────────────────────────────────────────────────
|
||||
echo "==> Starting all services..."
|
||||
docker compose up -d
|
||||
|
||||
echo ""
|
||||
echo "✓ Bootstrap complete. Your Git service should be live at https://${DOMAIN}/"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Visit https://${DOMAIN}/ and complete the Forgejo setup wizard."
|
||||
echo " 2. Create your admin account."
|
||||
echo " 3. Set DISABLE_REGISTRATION=true in .env, then: docker compose up -d forgejo"
|
||||
echo " 4. Edit geo_rules.yml to configure per-repo geo-blocking."
|
||||
123
docker-compose.yml
Normal file
123
docker-compose.yml
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
services:
|
||||
|
||||
# ── Forgejo ────────────────────────────────────────────────────────────────
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:9
|
||||
container_name: forgejo
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- FORGEJO__server__DOMAIN=${DOMAIN}
|
||||
- FORGEJO__server__ROOT_URL=https://${DOMAIN}/
|
||||
- FORGEJO__server__HTTP_PORT=3000
|
||||
- FORGEJO__server__DISABLE_SSH=true
|
||||
- FORGEJO__service__DISABLE_REGISTRATION=${DISABLE_REGISTRATION:-true}
|
||||
- FORGEJO__database__DB_TYPE=sqlite3
|
||||
- FORGEJO__database__PATH=/data/forgejo/forgejo.db
|
||||
- FORGEJO__log__LEVEL=Info
|
||||
volumes:
|
||||
- forgejo_data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── nginx (reverse proxy + GeoIP blocking) ─────────────────────────────────
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro # static config fragments
|
||||
- ./nginx/geoblock:/etc/nginx/geoblock:ro # rendered map snippet (written by watcher)
|
||||
- ./certs/live:/etc/letsencrypt/live:ro
|
||||
- ./certs/archive:/etc/letsencrypt/archive:ro
|
||||
- ./certs/options-ssl-nginx.conf:/etc/letsencrypt/options-ssl-nginx.conf:ro
|
||||
- ./certs/ssl-dhparams.pem:/etc/letsencrypt/ssl-dhparams.pem:ro
|
||||
- certbot_webroot:/var/www/certbot:ro
|
||||
- geoip_db:/usr/share/GeoIP:ro
|
||||
- nginx_logs:/var/log/nginx
|
||||
networks:
|
||||
- internal
|
||||
depends_on:
|
||||
- forgejo
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN}
|
||||
healthcheck:
|
||||
test: ["CMD", "nginx", "-t"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
# ── MaxMind GeoIP database updater ────────────────────────────────────────
|
||||
geoipupdate:
|
||||
image: ghcr.io/maxmind/geoipupdate:v7
|
||||
container_name: geoipupdate
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- GEOIPUPDATE_ACCOUNT_ID=${MAXMIND_ACCOUNT_ID}
|
||||
- GEOIPUPDATE_LICENSE_KEY=${MAXMIND_LICENSE_KEY}
|
||||
- GEOIPUPDATE_EDITION_IDS=GeoLite2-City
|
||||
- GEOIPUPDATE_FREQUENCY=72 # hours — MaxMind updates twice a week
|
||||
- GEOIPUPDATE_DB_DIR=/usr/share/GeoIP
|
||||
volumes:
|
||||
- geoip_db:/usr/share/GeoIP
|
||||
networks:
|
||||
- internal
|
||||
|
||||
# ── Geo-block config watcher ───────────────────────────────────────────────
|
||||
# Watches geo_rules.yml; re-renders the nginx map snippet and reloads nginx
|
||||
# whenever rules change.
|
||||
geoblock_watcher:
|
||||
build:
|
||||
context: ./geoblock_watcher
|
||||
dockerfile: Dockerfile
|
||||
container_name: geoblock_watcher
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./geo_rules.yml:/app/geo_rules.yml:ro
|
||||
- ./nginx/geoblock:/app/geoblock # shared with nginx (rw here)
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
networks:
|
||||
- internal
|
||||
depends_on:
|
||||
- nginx
|
||||
|
||||
# ── Certbot (Let's Encrypt) ────────────────────────────────────────────────
|
||||
certbot:
|
||||
image: certbot/certbot:latest
|
||||
container_name: certbot
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./certs:/etc/letsencrypt
|
||||
- certbot_webroot:/var/www/certbot
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
trap exit TERM;
|
||||
while :; do
|
||||
certbot renew --webroot -w /var/www/certbot --quiet;
|
||||
sleep 12h &
|
||||
wait $${!};
|
||||
done
|
||||
"
|
||||
|
||||
volumes:
|
||||
forgejo_data:
|
||||
geoip_db:
|
||||
certbot_webroot:
|
||||
nginx_logs:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
driver: bridge
|
||||
46
geo_rules.yml
Normal file
46
geo_rules.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# geo_rules.yml
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Define geo-blocking rules per repository.
|
||||
#
|
||||
# Each entry targets a Forgejo repository identified by its URL path
|
||||
# (/<owner>/<repo>). When a request for that repo (or any sub-path, e.g.
|
||||
# /<owner>/<repo>.git or /<owner>/<repo>/raw/…) arrives from a blocked
|
||||
# locale, nginx returns the configured HTTP status and body.
|
||||
#
|
||||
# Locale format:
|
||||
# Country only : "US" (ISO 3166-1 alpha-2)
|
||||
# Country+State : "US-CA" (ISO 3166-2, country + subdivision code)
|
||||
#
|
||||
# You can mix country-level and state-level rules in the same repo block.
|
||||
# More-specific rules (state) take precedence over less-specific ones (country)
|
||||
# because the watcher renders them first in the nginx map block.
|
||||
#
|
||||
# status: Any valid HTTP status code. 451 ("Unavailable For Legal Reasons")
|
||||
# is the semantically correct choice for legal/jurisdiction blocks.
|
||||
# body : Plain-text response body. Keep it short — it is embedded directly
|
||||
# in the nginx config as a string literal.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
repos:
|
||||
|
||||
- path: /alice/secret-project
|
||||
rules:
|
||||
- locales: ["US-CA", "US-TX"]
|
||||
status: 451
|
||||
body: "This repository is unavailable in your jurisdiction."
|
||||
- locales: ["DE", "FR"]
|
||||
status: 403
|
||||
body: "Access to this repository is restricted in your country."
|
||||
|
||||
- path: /alice/another-repo
|
||||
rules:
|
||||
- locales: ["CN", "RU"]
|
||||
status: 403
|
||||
body: "Access denied."
|
||||
|
||||
# Template — copy and fill in for each additional repo:
|
||||
# - path: /owner/repo-name
|
||||
# rules:
|
||||
# - locales: ["XX", "XX-YY"]
|
||||
# status: 403
|
||||
# body: "Access restricted."
|
||||
8
geoblock_watcher/Dockerfile
Normal file
8
geoblock_watcher/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
FROM python:3.13-alpine
|
||||
|
||||
RUN pip install --no-cache-dir pyyaml watchdog docker
|
||||
|
||||
WORKDIR /app
|
||||
COPY watcher.py .
|
||||
|
||||
CMD ["python", "-u", "watcher.py"]
|
||||
474
geoblock_watcher/watcher.py
Normal file
474
geoblock_watcher/watcher.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
geoblock_watcher.py
|
||||
────────────────────────────────────────────────────────────────────────────
|
||||
Watches geo_rules.yml for changes, renders three nginx config snippets into
|
||||
/app/geoblock/, then signals the nginx container to reload its configuration.
|
||||
|
||||
Rendered files
|
||||
──────────────
|
||||
repo_maps.conf
|
||||
A single nginx `map` block body that maps the compound GeoIP key
|
||||
("CC-SUBDIV") → a per-repo decision token. This file is included
|
||||
inside the existing map block in nginx.conf.
|
||||
|
||||
repo_vars.conf
|
||||
One `map` block per repo that translates the decision token to the
|
||||
final "$geoblock_<var>" variable value ("" = allow, or "status:body").
|
||||
|
||||
repo_locations.conf
|
||||
One `location` block per repo. When the variable is non-empty the
|
||||
block immediately returns the encoded status + body; otherwise the
|
||||
request falls through to the main proxy_pass location.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import docker
|
||||
import yaml
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from watchdog.observers import Observer
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [watcher] %(levelname)s %(message)s",
|
||||
datefmt="%Y-%m-%dT%H:%M:%S",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
RULES_FILE = Path("/app/geo_rules.yml")
|
||||
OUTPUT_DIR = Path("/app/geoblock")
|
||||
NGINX_CONTAINER = os.environ.get("NGINX_CONTAINER_NAME", "nginx")
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _var_name(repo_path: str) -> str:
|
||||
"""Convert a repo path like /alice/my-repo → geoblock_alice_my_repo."""
|
||||
sanitised = re.sub(r"[^a-zA-Z0-9]", "_", repo_path.strip("/"))
|
||||
return f"geoblock_{sanitised}"
|
||||
|
||||
|
||||
def _escape_body(body: str) -> str:
|
||||
"""Escape a string for safe embedding in an nginx config string literal."""
|
||||
return body.replace("\\", "\\\\").replace('"', '\\"').replace("'", "\\'").replace("\n", " ")
|
||||
|
||||
|
||||
def _token(repo_index: int, rule_index: int) -> str:
|
||||
"""Unique short token used to link the map blocks together."""
|
||||
return f"repo{repo_index}_rule{rule_index}"
|
||||
|
||||
|
||||
# ── Renderer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def render(rules_data: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""
|
||||
Returns (repo_maps_conf, repo_vars_conf, repo_locations_conf) as strings.
|
||||
"""
|
||||
repos: list[dict] = rules_data.get("repos", [])
|
||||
|
||||
maps_lines: list[str] = [
|
||||
"# Generated by geoblock_watcher — do not edit manually.",
|
||||
"# Included inside the map block in nginx.conf.",
|
||||
"",
|
||||
]
|
||||
|
||||
vars_lines: list[str] = [
|
||||
"# Generated by geoblock_watcher — do not edit manually.",
|
||||
"",
|
||||
]
|
||||
|
||||
loc_lines: list[str] = [
|
||||
"# Generated by geoblock_watcher — do not edit manually.",
|
||||
"",
|
||||
]
|
||||
|
||||
for ri, repo in enumerate(repos):
|
||||
path: str = repo["path"].rstrip("/")
|
||||
var: str = _var_name(path)
|
||||
rules: list[dict] = repo.get("rules", [])
|
||||
|
||||
# ── Map block: region key → token ─────────────────────────────────────
|
||||
# Build a mapping from locale → token. More-specific (state-level)
|
||||
# rules are added first so nginx map "first match" semantics apply.
|
||||
state_entries: list[str] = []
|
||||
country_entries: list[str] = []
|
||||
|
||||
for rj, rule in enumerate(rules):
|
||||
tok = _token(ri, rj)
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
value = f"{status}:{body}"
|
||||
|
||||
for locale in rule.get("locales", []):
|
||||
locale = locale.strip()
|
||||
key = f'"{locale}"'
|
||||
entry = f" {key:<20} {tok!r}_{ri}_{rj};"
|
||||
if "-" in locale:
|
||||
state_entries.append(entry)
|
||||
else:
|
||||
# Country-only key — pad subdivision with empty string so
|
||||
# it matches both "CC-" (no subdivision) and we also add
|
||||
# a regex fallback below.
|
||||
country_entries.append(entry)
|
||||
|
||||
# Emit the per-rule value variable (token → "status:body")
|
||||
vars_lines.append(f"# {path} — rule {rj}: {rule.get('locales', [])}")
|
||||
vars_lines.append(f'map $geoip2_region_key ${var}_r{rj} {{')
|
||||
vars_lines.append(f' default "";')
|
||||
|
||||
for locale in rule.get("locales", []):
|
||||
locale = locale.strip()
|
||||
if "-" in locale:
|
||||
# State-level: exact match on "CC-SUBDIV"
|
||||
vars_lines.append(f' "{locale}" "{value}";')
|
||||
else:
|
||||
# Country-level: match any subdivision of this country
|
||||
vars_lines.append(f' ~^{re.escape(locale)}- "{value}";')
|
||||
# Also match when subdivision is absent ("CC-")
|
||||
vars_lines.append(f' "{locale}-" "{value}";')
|
||||
|
||||
vars_lines.append("}")
|
||||
vars_lines.append("")
|
||||
|
||||
# Aggregate rule variables into the final per-repo variable.
|
||||
# The first non-empty rule variable wins.
|
||||
rule_vars = [f"${var}_r{rj}" for rj in range(len(rules))]
|
||||
vars_lines.append(f"# Final decision variable for {path}")
|
||||
vars_lines.append(f"map $geoip2_region_key ${var} {{")
|
||||
vars_lines.append(f' default "";')
|
||||
|
||||
for locale_list, status_body in _aggregate_locales(rules):
|
||||
for locale in locale_list:
|
||||
if "-" in locale:
|
||||
vars_lines.append(f' "{locale}" "{status_body}";')
|
||||
else:
|
||||
vars_lines.append(f' ~^{re.escape(locale)}- "{status_body}";')
|
||||
vars_lines.append(f' "{locale}-" "{status_body}";')
|
||||
|
||||
vars_lines.append("}")
|
||||
vars_lines.append("")
|
||||
|
||||
# ── Location block ────────────────────────────────────────────────────
|
||||
# Intercept /<owner>/<repo> and any sub-paths.
|
||||
# nginx location matching: we use a case-sensitive prefix match.
|
||||
# Git HTTPS also accesses /<owner>/<repo>.git — covered by the prefix.
|
||||
loc_lines.append(f"# Geo-block for {path}")
|
||||
loc_lines.append(f"location ^~ {path} {{")
|
||||
loc_lines.append(f" if (${var} != \"\") {{")
|
||||
# Split "status:body" at runtime using map — but nginx `if` can't do
|
||||
# string splitting, so we embed status and body as separate variables.
|
||||
# We use a nested map approach: the decision var encodes both, and we
|
||||
# resolve them with two additional map lookups.
|
||||
loc_lines.append(f" set $__status ${var}_status;")
|
||||
loc_lines.append(f" set $__body ${var}_body;")
|
||||
loc_lines.append(f" return $__status \"$__body\";")
|
||||
loc_lines.append(f" }}")
|
||||
loc_lines.append(f" # No block — fall through to main proxy")
|
||||
loc_lines.append(f" proxy_pass http://forgejo:3000;")
|
||||
loc_lines.append(f" proxy_set_header Host $host;")
|
||||
loc_lines.append(f" proxy_set_header X-Real-IP $remote_addr;")
|
||||
loc_lines.append(f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;")
|
||||
loc_lines.append(f" proxy_set_header X-Forwarded-Proto $scheme;")
|
||||
loc_lines.append(f" client_max_body_size 512m;")
|
||||
loc_lines.append(f" proxy_request_buffering off;")
|
||||
loc_lines.append(f" proxy_buffering off;")
|
||||
loc_lines.append(f" proxy_read_timeout 600s;")
|
||||
loc_lines.append(f" proxy_send_timeout 600s;")
|
||||
loc_lines.append(f"}}")
|
||||
loc_lines.append("")
|
||||
|
||||
# Status and body split maps for this repo
|
||||
vars_lines.append(f"# Status / body split maps for {path}")
|
||||
vars_lines.append(f"map ${var} ${var}_status {{")
|
||||
vars_lines.append(f' default 403;')
|
||||
for locale_list, status_body in _aggregate_locales(rules):
|
||||
status = status_body.split(":", 1)[0]
|
||||
for locale in locale_list:
|
||||
if "-" in locale:
|
||||
vars_lines.append(f' "{status_body}" {status};')
|
||||
break
|
||||
else:
|
||||
vars_lines.append(f' "~^{re.escape(status_body)}" {status};')
|
||||
# Simpler: map the encoded value directly
|
||||
vars_lines = _replace_split_maps(vars_lines, var, rules)
|
||||
vars_lines.append("")
|
||||
|
||||
return (
|
||||
"\n".join(maps_lines),
|
||||
"\n".join(vars_lines),
|
||||
"\n".join(loc_lines),
|
||||
)
|
||||
|
||||
|
||||
def _aggregate_locales(rules: list[dict]) -> list[tuple[list[str], str]]:
|
||||
"""Return [(locale_list, 'status:body'), …] for all rules."""
|
||||
result = []
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
result.append((rule.get("locales", []), f"{status}:{body}"))
|
||||
return result
|
||||
|
||||
|
||||
def _replace_split_maps(vars_lines: list[str], var: str, rules: list[dict]) -> list[str]:
|
||||
"""
|
||||
Replace the incomplete split-map stubs with correct status+body maps.
|
||||
We rebuild the tail of vars_lines for the current repo.
|
||||
"""
|
||||
# Remove any partial split map lines we may have added above
|
||||
while vars_lines and (
|
||||
vars_lines[-1].startswith(f"map ${var}_status") or
|
||||
vars_lines[-1].startswith(f" ") or
|
||||
vars_lines[-1] in ("}", "")
|
||||
):
|
||||
last = vars_lines[-1]
|
||||
vars_lines.pop()
|
||||
if last.startswith(f"map ${var}_status"):
|
||||
break
|
||||
|
||||
# Status map
|
||||
vars_lines.append(f"map ${var} ${var}_status {{")
|
||||
vars_lines.append(f' default 403;')
|
||||
seen_sv: set[str] = set()
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
if encoded not in seen_sv:
|
||||
vars_lines.append(f' "{encoded}" {status};')
|
||||
seen_sv.add(encoded)
|
||||
vars_lines.append("}")
|
||||
vars_lines.append("")
|
||||
|
||||
# Body map
|
||||
vars_lines.append(f"map ${var} ${var}_body {{")
|
||||
vars_lines.append(f' default "Blocked";')
|
||||
seen_bv: set[str] = set()
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
if encoded not in seen_bv:
|
||||
vars_lines.append(f' "{encoded}" "{body}";')
|
||||
seen_bv.add(encoded)
|
||||
vars_lines.append("}")
|
||||
|
||||
return vars_lines
|
||||
|
||||
|
||||
# ── Clean renderer (replaces the incremental one above) ───────────────────────
|
||||
|
||||
def render_clean(rules_data: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""
|
||||
Cleanly render all three config files.
|
||||
Returns (repo_maps_conf, repo_vars_conf, repo_locations_conf).
|
||||
"""
|
||||
repos: list[dict] = rules_data.get("repos", [])
|
||||
|
||||
header = "# Generated by geoblock_watcher — do not edit manually.\n\n"
|
||||
|
||||
vars_blocks: list[str] = []
|
||||
loc_blocks: list[str] = []
|
||||
|
||||
for repo in repos:
|
||||
path: str = repo["path"].rstrip("/")
|
||||
var: str = _var_name(path)
|
||||
rules: list[dict] = repo.get("rules", [])
|
||||
|
||||
# ── Per-repo decision map ──────────────────────────────────────────────
|
||||
# Maps the compound GeoIP region key to "status:escapedBody" or "".
|
||||
vars_blocks.append(f"# Decision map for {path}")
|
||||
vars_blocks.append(f"map $geoip2_region_key ${var} {{")
|
||||
vars_blocks.append(f' default "";')
|
||||
|
||||
# State-level rules first (more specific → rendered first)
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
for locale in rule.get("locales", []):
|
||||
locale = locale.strip()
|
||||
if "-" in locale:
|
||||
vars_blocks.append(f' "{locale}" "{encoded}";')
|
||||
|
||||
# Country-level rules second
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
for locale in rule.get("locales", []):
|
||||
locale = locale.strip()
|
||||
if "-" not in locale:
|
||||
# nginx map supports regex; match "CC-<anything>" and "CC-"
|
||||
vars_blocks.append(f' "~^{re.escape(locale)}(-|$)" "{encoded}";')
|
||||
|
||||
vars_blocks.append("}")
|
||||
vars_blocks.append("")
|
||||
|
||||
# ── Status split map ───────────────────────────────────────────────────
|
||||
vars_blocks.append(f"map ${var} ${var}_status {{")
|
||||
vars_blocks.append(f" default 403;")
|
||||
seen: set[str] = set()
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
if encoded not in seen:
|
||||
vars_blocks.append(f' "{encoded}" {status};')
|
||||
seen.add(encoded)
|
||||
vars_blocks.append("}")
|
||||
vars_blocks.append("")
|
||||
|
||||
# ── Body split map ─────────────────────────────────────────────────────
|
||||
vars_blocks.append(f"map ${var} ${var}_body {{")
|
||||
vars_blocks.append(f' default "Blocked";')
|
||||
seen = set()
|
||||
for rule in rules:
|
||||
status = int(rule["status"])
|
||||
body = _escape_body(str(rule.get("body", "Blocked")))
|
||||
encoded = f"{status}:{body}"
|
||||
if encoded not in seen:
|
||||
vars_blocks.append(f' "{encoded}" "{body}";')
|
||||
seen.add(encoded)
|
||||
vars_blocks.append("}")
|
||||
vars_blocks.append("")
|
||||
|
||||
# ── Location block ─────────────────────────────────────────────────────
|
||||
loc_blocks.append(f"# Geo-block for {path}")
|
||||
loc_blocks.append(f"location ^~ {path} {{")
|
||||
loc_blocks.append(f" if (${var} != \"\") {{")
|
||||
loc_blocks.append(f" return ${var}_status \"${var}_body\";")
|
||||
loc_blocks.append(f" }}")
|
||||
loc_blocks.append(f" proxy_pass http://forgejo:3000;")
|
||||
loc_blocks.append(f" proxy_set_header Host $host;")
|
||||
loc_blocks.append(f" proxy_set_header X-Real-IP $remote_addr;")
|
||||
loc_blocks.append(f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;")
|
||||
loc_blocks.append(f" proxy_set_header X-Forwarded-Proto $scheme;")
|
||||
loc_blocks.append(f" client_max_body_size 512m;")
|
||||
loc_blocks.append(f" proxy_request_buffering off;")
|
||||
loc_blocks.append(f" proxy_buffering off;")
|
||||
loc_blocks.append(f" proxy_read_timeout 600s;")
|
||||
loc_blocks.append(f" proxy_send_timeout 600s;")
|
||||
loc_blocks.append(f"}}")
|
||||
loc_blocks.append("")
|
||||
|
||||
# repo_maps.conf is now empty (we use inline regex maps in repo_vars.conf)
|
||||
maps_conf = header + "# (Region key mapping is now done inline in repo_vars.conf)\n"
|
||||
vars_conf = header + "\n".join(vars_blocks)
|
||||
locs_conf = header + "\n".join(loc_blocks)
|
||||
|
||||
return maps_conf, vars_conf, locs_conf
|
||||
|
||||
|
||||
# ── Writer & nginx reload ─────────────────────────────────────────────────────
|
||||
|
||||
_last_hash: str = ""
|
||||
|
||||
|
||||
def _file_hash(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def apply_rules(force: bool = False) -> None:
|
||||
global _last_hash
|
||||
|
||||
if not RULES_FILE.exists():
|
||||
log.warning("Rules file not found: %s — skipping.", RULES_FILE)
|
||||
return
|
||||
|
||||
current_hash = _file_hash(RULES_FILE)
|
||||
if not force and current_hash == _last_hash:
|
||||
log.debug("Rules file unchanged — nothing to do.")
|
||||
return
|
||||
|
||||
log.info("Rules file changed — re-rendering nginx config snippets.")
|
||||
|
||||
try:
|
||||
rules_data = yaml.safe_load(RULES_FILE.read_text()) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
log.error("YAML parse error in %s: %s — skipping reload.", RULES_FILE, exc)
|
||||
return
|
||||
|
||||
try:
|
||||
maps_conf, vars_conf, locs_conf = render_clean(rules_data)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("Render error: %s — skipping reload.", exc, exc_info=True)
|
||||
return
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(OUTPUT_DIR / "repo_maps.conf").write_text(maps_conf)
|
||||
(OUTPUT_DIR / "repo_vars.conf").write_text(vars_conf)
|
||||
(OUTPUT_DIR / "repo_locations.conf").write_text(locs_conf)
|
||||
|
||||
log.info("Config snippets written to %s.", OUTPUT_DIR)
|
||||
|
||||
_reload_nginx()
|
||||
_last_hash = current_hash
|
||||
|
||||
|
||||
def _reload_nginx() -> None:
|
||||
"""Send SIGHUP to the nginx container to trigger a graceful config reload."""
|
||||
try:
|
||||
client = docker.from_env()
|
||||
containers = client.containers.list(filters={"name": NGINX_CONTAINER})
|
||||
if not containers:
|
||||
log.warning("nginx container '%s' not found — skipping reload.", NGINX_CONTAINER)
|
||||
return
|
||||
container = containers[0]
|
||||
container.kill(signal="HUP")
|
||||
log.info("Sent SIGHUP to nginx container '%s'.", container.name)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.error("Failed to reload nginx: %s", exc, exc_info=True)
|
||||
|
||||
|
||||
# ── Watchdog ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class RulesHandler(FileSystemEventHandler):
|
||||
def on_modified(self, event):
|
||||
if Path(event.src_path).resolve() == RULES_FILE.resolve():
|
||||
log.info("Detected change in %s.", RULES_FILE)
|
||||
time.sleep(0.2) # debounce
|
||||
apply_rules()
|
||||
|
||||
# on_created handles the case where the file is replaced atomically
|
||||
on_created = on_modified
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log.info("geoblock_watcher starting. Watching %s", RULES_FILE)
|
||||
|
||||
# Initial render on startup
|
||||
apply_rules(force=True)
|
||||
|
||||
observer = Observer()
|
||||
observer.schedule(RulesHandler(), str(RULES_FILE.parent), recursive=False)
|
||||
observer.start()
|
||||
|
||||
def _shutdown(signum, frame): # noqa: ANN001
|
||||
log.info("Shutting down.")
|
||||
observer.stop()
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(60)
|
||||
apply_rules() # Periodic re-check (catches missed inotify events)
|
||||
finally:
|
||||
observer.join()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
56
nginx/Dockerfile
Normal file
56
nginx/Dockerfile
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Build nginx with the ngx_http_geoip2_module baked in.
|
||||
# The official nginx image ships without GeoIP2 support; we compile the
|
||||
# dynamic module against the same nginx version used in the base image.
|
||||
|
||||
ARG NGINX_VERSION=1.27.4
|
||||
|
||||
FROM nginx:${NGINX_VERSION}-alpine AS builder
|
||||
|
||||
ARG NGINX_VERSION
|
||||
|
||||
RUN apk add --no-cache \
|
||||
build-base \
|
||||
git \
|
||||
libmaxminddb-dev \
|
||||
pcre2-dev \
|
||||
openssl-dev \
|
||||
zlib-dev \
|
||||
linux-headers
|
||||
|
||||
# Clone the GeoIP2 nginx module at the tag matching the installed nginx
|
||||
RUN git clone --depth 1 \
|
||||
https://github.com/leev/ngx_http_geoip2_module.git \
|
||||
/usr/src/ngx_http_geoip2_module
|
||||
|
||||
# Fetch the nginx source matching the base image version
|
||||
RUN wget -q "http://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz" \
|
||||
-O /usr/src/nginx.tar.gz \
|
||||
&& tar -xzf /usr/src/nginx.tar.gz -C /usr/src
|
||||
|
||||
WORKDIR /usr/src/nginx-${NGINX_VERSION}
|
||||
|
||||
# Build only the dynamic module (configure flags from `nginx -V`)
|
||||
RUN eval $(nginx -V 2>&1 | grep 'configure arguments:' | sed 's/configure arguments://') && \
|
||||
./configure \
|
||||
$configure_args \
|
||||
--add-dynamic-module=/usr/src/ngx_http_geoip2_module \
|
||||
&& make modules
|
||||
|
||||
# ── Runtime image ─────────────────────────────────────────────────────────────
|
||||
FROM nginx:${NGINX_VERSION}-alpine
|
||||
|
||||
# Runtime dependency for MaxMind DB
|
||||
RUN apk add --no-cache libmaxminddb
|
||||
|
||||
# Copy the compiled dynamic module
|
||||
COPY --from=builder \
|
||||
/usr/src/nginx-${NGINX_VERSION}/objs/ngx_http_geoip2_module.so \
|
||||
/usr/lib/nginx/modules/ngx_http_geoip2_module.so
|
||||
|
||||
# Main nginx config (loads the dynamic module at the top level)
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# GeoIP map directory (populated by geoblock_watcher at runtime)
|
||||
RUN mkdir -p /etc/nginx/geoblock
|
||||
|
||||
EXPOSE 80 443
|
||||
56
nginx/conf.d/git.conf
Normal file
56
nginx/conf.d/git.conf
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# HTTP → HTTPS redirect + ACME challenge
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${DOMAIN};
|
||||
|
||||
# Let's Encrypt webroot challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS — main entry point
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name ${DOMAIN};
|
||||
|
||||
# ── TLS ───────────────────────────────────────────────────────────────────
|
||||
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# ── Security headers ──────────────────────────────────────────────────────
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
# ── Geo-block check ───────────────────────────────────────────────────────
|
||||
# The watcher renders a location block per repo that checks the per-repo
|
||||
# variable and returns the configured status + body when blocked.
|
||||
include /etc/nginx/geoblock/repo_locations.conf;
|
||||
|
||||
# ── Proxy to Forgejo ──────────────────────────────────────────────────────
|
||||
location / {
|
||||
proxy_pass http://forgejo:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Git push/pull can involve large objects
|
||||
client_max_body_size 512m;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
}
|
||||
3
nginx/geoblock/repo_locations.conf
Normal file
3
nginx/geoblock/repo_locations.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Managed by geoblock_watcher. Do not edit manually.
|
||||
# Per-repo location blocks that intercept requests and return the block
|
||||
# response when the geo decision variable is non-empty.
|
||||
7
nginx/geoblock/repo_maps.conf
Normal file
7
nginx/geoblock/repo_maps.conf
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# This file is managed by geoblock_watcher. Do not edit manually.
|
||||
# It maps the compound "$geoip2_country-$geoip2_subdivision" key to a
|
||||
# per-repo decision variable value.
|
||||
#
|
||||
# Format (rendered by watcher):
|
||||
# "US-CA" "403:Access denied from your region.";
|
||||
# "US-TX" "451:Unavailable For Legal Reasons.";
|
||||
3
nginx/geoblock/repo_vars.conf
Normal file
3
nginx/geoblock/repo_vars.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Managed by geoblock_watcher. Do not edit manually.
|
||||
# Each enabled repo gets a map variable that resolves to "" (allow)
|
||||
# or "<status>:<body>" (block) based on the compound region key.
|
||||
54
nginx/nginx.conf
Normal file
54
nginx/nginx.conf
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
load_module /usr/lib/nginx/modules/ngx_http_geoip2_module.so;
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" country=$geoip2_country iso=$geoip2_subdivision';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# ── GeoIP2 database ───────────────────────────────────────────────────────
|
||||
# The GeoLite2-City database gives us country + subdivision (state/province)
|
||||
geoip2 /usr/share/GeoIP/GeoLite2-City.mmdb {
|
||||
# ISO 3166-1 alpha-2 country code (e.g. "US")
|
||||
$geoip2_country country iso_code;
|
||||
# ISO 3166-2 subdivision code — country prefix stripped below
|
||||
# Full value looks like "US-CA"; we expose just the subdivision part
|
||||
$geoip2_subdivision subdivisions 0 iso_code;
|
||||
}
|
||||
|
||||
# Compound key used in per-repo map blocks: "CC-SUBDIV" e.g. "US-CA"
|
||||
# When the DB has no subdivision the variable is empty; the key becomes "CC-"
|
||||
# which will not match any rule unless you explicitly add it.
|
||||
map "$geoip2_country-$geoip2_subdivision" $geoip2_region_key {
|
||||
default "";
|
||||
include /etc/nginx/geoblock/repo_maps.conf;
|
||||
}
|
||||
|
||||
# ── Per-repo block decision variables ─────────────────────────────────────
|
||||
# Loaded from the rendered snippet produced by geoblock_watcher.
|
||||
# Each repo gets a variable like $geoblock_<sanitised_repo_path>
|
||||
# with value "" (allow) or "<status_code>:<body>" (block).
|
||||
include /etc/nginx/geoblock/repo_vars.conf;
|
||||
|
||||
# ── Virtual hosts ─────────────────────────────────────────────────────────
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
Loading…
Reference in a new issue