build(deploy): front the origin with cloudflare and drop certbot
7 files changed, +88 −36
- INSTALL.md modified +8 −6
- README.md modified +1 −1
- deploy/cloudflare.conf added +24 −0
- deploy/compose.yaml modified +1 −1
- deploy/domains.sh modified +46 −23
- deploy/gitpocket-domains.service modified +1 −1
- deploy/nginx.conf modified +7 −4
INSTALL.md modified
| @@ -21,7 +21,7 @@ The compose file runs the server as the `git` account with a read only root file | ||
| 21 | 21 | ``` |
| 22 | 22 | git clone https://gitpocket.io/~gitpocket/gitpocket /opt/gitpocket |
| 23 | 23 | cd /opt/gitpocket |
| 24 | -printf 'GITPOCKET_HOST=git.example.org\nGITPOCKET_UID=%s\nGITPOCKET_GID=%s\n' $(id -u git) $(id -g git) > deploy/.env | |
| 24 | +printf 'GITPOCKET_HOST=git.example.org\nGITPOCKET_SSH_HOST=ssh.git.example.org\nGITPOCKET_UID=%s\nGITPOCKET_GID=%s\n' $(id -u git) $(id -g git) > deploy/.env | |
| 25 | 25 | docker compose -f deploy/compose.yaml up -d --build |
| 26 | 26 | ``` |
| 27 | 27 | |
| @@ -53,18 +53,20 @@ The unit sets `GOMEMLIMIT` to 192 MiB, caps the server and every git it starts a | ||
| 53 | 53 | |
| 54 | 54 | ## Reverse proxy |
| 55 | 55 | |
| 56 | -`deploy/nginx.conf` is a complete site. Set `server_name` and the certificate paths, then install it. | |
| 56 | +`deploy/nginx.conf` is a complete site that expects to sit behind the Cloudflare proxy, so the address of the host never appears in DNS and every request that does not arrive from a Cloudflare network is dropped by the included snippet before it reaches the server. | |
| 57 | 57 | |
| 58 | 58 | ``` |
| 59 | +mkdir -p /etc/gitpocket /etc/nginx/snippets | |
| 60 | +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 3650 -subj /CN=origin -keyout /etc/gitpocket/origin.key -out /etc/gitpocket/origin.pem | |
| 61 | +cp deploy/cloudflare.conf /etc/nginx/snippets/gitpocket-cloudflare.conf | |
| 59 | 62 | cp deploy/nginx.conf /etc/nginx/sites-available/gitpocket |
| 60 | 63 | ln -s ../sites-available/gitpocket /etc/nginx/sites-enabled/ |
| 61 | -certbot certonly --nginx -d git.example.org | |
| 62 | 64 | nginx -t && systemctl reload nginx |
| 63 | 65 | ``` |
| 64 | 66 | |
| 65 | -Pages need a wildcard DNS record and certificate for `*.git.example.org` along with a second server block that proxies those names to the same port, which `deploy/nginx.conf` already includes, and without it user sites are simply unreachable. | |
| 67 | +In the Cloudflare zone the records for `git.example.org` and `*.git.example.org` are proxied with the SSL mode set to Full, which lets the edge hold the public certificates while the origin keeps a long lived self signed pair that never needs renewing. A Cloudflare origin certificate can replace that pair when the zone runs in Full (strict) mode instead. SSH cannot pass through the proxy, so publish an unproxied `ssh.git.example.org` record and start the server with `-ssh-host ssh.git.example.org` to show that name in clone URLs while accepting that this one record reveals the address. | |
| 66 | 68 | |
| 67 | -Custom domains need the host to issue certificates since the server itself never terminates TLS, so install the script below with its timer to read the verified domains every five minutes and write them into `/etc/nginx/conf.d/gitpocket-domains.conf`. The script runs certbot over a webroot challenge for each new domain and reloads nginx only when that file has actually changed. | |
| 69 | +Custom domains reach the origin through Cloudflare for SaaS, where each verified domain becomes a custom hostname whose certificate Cloudflare issues once the owner points a CNAME at their subdomain. The script below runs every five minutes, writes the verified domains into `/etc/nginx/conf.d/gitpocket-domains.conf` and keeps the custom hostnames of the zone in step with them, reading `CF_TOKEN` and `CF_ZONE` from `/etc/gitpocket/cloudflare`, where the token needs the SSL and Certificates edit permission and `CF_SKIP` may list domains that are already proxied by a zone of your own. | |
| 68 | 70 | |
| 69 | 71 | ``` |
| 70 | 72 | install -m 755 deploy/domains.sh /usr/local/sbin/gitpocket-domains |
| @@ -72,7 +74,7 @@ cp deploy/gitpocket-domains.service deploy/gitpocket-domains.timer /etc/systemd/ | ||
| 72 | 74 | systemctl enable --now gitpocket-domains.timer |
| 73 | 75 | ``` |
| 74 | 76 | |
| 75 | -Whatever proxy you use has to forward `Host` and `X-Forwarded-Proto`, set `X-Forwarded-For` to the client address, turn request buffering off and allow large bodies, otherwise bigger HTTPS pushes are cut short. The server takes the last address in `X-Forwarded-For`, so a client cannot pick its own rate limit bucket. | |
| 77 | +Whatever proxy you use has to forward `Host` and `X-Forwarded-Proto`, set `X-Forwarded-For` to the client address as recovered from `CF-Connecting-IP`, turn request buffering off and allow large bodies, otherwise bigger HTTPS pushes are cut short. The server takes the last address in `X-Forwarded-For`, so a client cannot pick its own rate limit bucket. | |
| 76 | 78 | |
| 77 | 79 | ## SSH |
| 78 | 80 | |
README.md modified
| @@ -60,7 +60,7 @@ Pushing again with the same topic updates that pull request and records a new re | ||
| 60 | 60 | |
| 61 | 61 | A repository named `pages` is served at `https://you.git.example.org/` from its `pages` branch when one exists and from the default branch otherwise, and any other repository with a `pages` branch appears beneath it at `/name/`. Private repositories publish only their `pages` branch so the site is public while the source on every other branch stays private, which matches how GitHub treats Pages built from a private repository. Paths without an extension also try `.html` and directories serve their `index.html`, while a missing file serves the site's own `404.html` with a 404 status when the site provides one. Symlinks and submodules are never followed and files over 32 MB are refused, accounts younger than a day have no site at all and admins can turn any site off from the admin page. |
| 62 | 62 | |
| 63 | -A site can also answer on a domain of your own once you claim it under settings, publish the TXT record `_gitpocket-pages.<domain>` with the token shown there and point the domain at the server through a CNAME to your subdomain or matching A and AAAA records. Verification checks both records and a daily recheck drops any domain whose TXT record has disappeared, while `deploy/domains.sh` on the host issues the certificate and writes the nginx server block for every verified domain. | |
| 63 | +A site can also answer on a domain of your own once you claim it under settings, publish the TXT record `_gitpocket-pages.<domain>` with the token shown there and point the domain at your subdomain through a CNAME, so the address of the server is never handed out. Verification checks the TXT record and a daily recheck drops any domain whose record has disappeared, while `deploy/domains.sh` on the host registers every verified domain as a Cloudflare custom hostname and writes its nginx server block. | |
| 64 | 64 | |
| 65 | 65 | The pages origin never sets or reads cookies and the main site only uses `__Host-` cookies, so a script running on a user site can neither read a session from the main host nor plant a cookie that the main host would later accept. |
| 66 | 66 | |
deploy/cloudflare.conf added
| @@ -0,0 +1,24 @@ | ||
| 1 | +set_real_ip_from 173.245.48.0/20; | |
| 2 | +set_real_ip_from 103.21.244.0/22; | |
| 3 | +set_real_ip_from 103.22.200.0/22; | |
| 4 | +set_real_ip_from 103.31.4.0/22; | |
| 5 | +set_real_ip_from 141.101.64.0/18; | |
| 6 | +set_real_ip_from 108.162.192.0/18; | |
| 7 | +set_real_ip_from 190.93.240.0/20; | |
| 8 | +set_real_ip_from 188.114.96.0/20; | |
| 9 | +set_real_ip_from 197.234.240.0/22; | |
| 10 | +set_real_ip_from 198.41.128.0/17; | |
| 11 | +set_real_ip_from 162.158.0.0/15; | |
| 12 | +set_real_ip_from 104.16.0.0/13; | |
| 13 | +set_real_ip_from 104.24.0.0/14; | |
| 14 | +set_real_ip_from 172.64.0.0/13; | |
| 15 | +set_real_ip_from 131.0.72.0/22; | |
| 16 | +set_real_ip_from 2400:cb00::/32; | |
| 17 | +set_real_ip_from 2606:4700::/32; | |
| 18 | +set_real_ip_from 2803:f800::/32; | |
| 19 | +set_real_ip_from 2405:b500::/32; | |
| 20 | +set_real_ip_from 2405:8100::/32; | |
| 21 | +set_real_ip_from 2a06:98c0::/29; | |
| 22 | +set_real_ip_from 2c0f:f248::/32; | |
| 23 | +real_ip_header CF-Connecting-IP; | |
| 24 | +if ($remote_addr = $realip_remote_addr) { return 444; } | |
deploy/compose.yaml modified
| @@ -9,7 +9,7 @@ services: | ||
| 9 | 9 | GID: "${GITPOCKET_GID:?set GITPOCKET_GID}" |
| 10 | 10 | image: gitpocket |
| 11 | 11 | container_name: gitpocket |
| 12 | - command: ["serve", "-listen", ":3200", "-host", "${GITPOCKET_HOST:?set GITPOCKET_HOST}", "-proxy", "172.31.255.1/32"] | |
| 12 | + command: ["serve", "-listen", ":3200", "-host", "${GITPOCKET_HOST:?set GITPOCKET_HOST}", "-ssh-host", "${GITPOCKET_SSH_HOST:-}", "-proxy", "172.31.255.1/32"] | |
| 13 | 13 | user: "${GITPOCKET_UID:?set GITPOCKET_UID}:${GITPOCKET_GID:?set GITPOCKET_GID}" |
| 14 | 14 | ports: |
| 15 | 15 | - "127.0.0.1:3200:3200" |
deploy/domains.sh modified
| @@ -5,28 +5,32 @@ root=${GITPOCKET_ROOT:-/srv/gitpocket} | ||
| 5 | 5 | user=${GITPOCKET_USER:-git} |
| 6 | 6 | port=${GITPOCKET_PORT:-3200} |
| 7 | 7 | conf=${GITPOCKET_NGINX:-/etc/nginx/conf.d/gitpocket-domains.conf} |
| 8 | -webroot=/var/www/gitpocket-acme | |
| 9 | -state=/var/lib/gitpocket-domains | |
| 10 | -mkdir -p "$webroot" "$state" | |
| 8 | +cert=${GITPOCKET_CERT:-/etc/gitpocket/origin.pem} | |
| 9 | +key=${GITPOCKET_KEY:-/etc/gitpocket/origin.key} | |
| 10 | +cloudflare=${GITPOCKET_CLOUDFLARE:-/etc/gitpocket/cloudflare} | |
| 11 | 11 | |
| 12 | 12 | domains=$(runuser -u "$user" -- "$bin" -root "$root" domains | grep -E '^[a-z0-9.-]+$' || true) |
| 13 | 13 | |
| 14 | 14 | render() { |
| 15 | 15 | [ -n "$domains" ] || return 0 |
| 16 | - printf 'server {\n listen 80;\n listen [::]:80;\n server_name' | |
| 17 | - for d in $domains; do printf ' %s' "$d"; done | |
| 18 | - printf ';\n location /.well-known/acme-challenge/ { root %s; }\n location / { return 301 https://$host$request_uri; }\n}\n' "$webroot" | |
| 19 | - for d in $domains; do | |
| 20 | - [ -f "/etc/letsencrypt/live/gitpocket-$d/fullchain.pem" ] || continue | |
| 21 | - cat <<CONF | |
| 16 | + names=$(printf ' %s' $domains) | |
| 17 | + cat <<CONF | |
| 18 | +server { | |
| 19 | + listen 80; | |
| 20 | + listen [::]:80; | |
| 21 | + server_name$names; | |
| 22 | + include snippets/gitpocket-cloudflare.conf; | |
| 23 | + location / { return 301 https://\$host\$request_uri; } | |
| 24 | +} | |
| 22 | 25 | server { |
| 23 | 26 | listen 443 ssl; |
| 24 | 27 | listen [::]:443 ssl; |
| 25 | 28 | http2 on; |
| 26 | - server_name $d; | |
| 27 | - ssl_certificate /etc/letsencrypt/live/gitpocket-$d/fullchain.pem; | |
| 28 | - ssl_certificate_key /etc/letsencrypt/live/gitpocket-$d/privkey.pem; | |
| 29 | + server_name$names; | |
| 30 | + ssl_certificate $cert; | |
| 31 | + ssl_certificate_key $key; | |
| 29 | 32 | ssl_protocols TLSv1.2 TLSv1.3; |
| 33 | + include snippets/gitpocket-cloudflare.conf; | |
| 30 | 34 | client_max_body_size 1m; |
| 31 | 35 | add_header Strict-Transport-Security "max-age=31536000" always; |
| 32 | 36 | gzip on; |
| @@ -44,7 +48,6 @@ server { | ||
| 44 | 48 | } |
| 45 | 49 | } |
| 46 | 50 | CONF |
| 47 | - done | |
| 48 | 51 | } |
| 49 | 52 | |
| 50 | 53 | apply() { |
| @@ -66,14 +69,34 @@ apply() { | ||
| 66 | 69 | fi |
| 67 | 70 | } |
| 68 | 71 | |
| 72 | +hostnames() { | |
| 73 | + [ -r "$cloudflare" ] || return 0 | |
| 74 | + python3 - "$cloudflare" $domains <<'PY' | |
| 75 | +import json, sys, urllib.request | |
| 76 | +env = dict(l.strip().split("=", 1) for l in open(sys.argv[1]) if "=" in l) | |
| 77 | +want = set(sys.argv[2:]) - set(env.get("CF_SKIP", "").split()) | |
| 78 | +base = f"https://api.cloudflare.com/client/v4/zones/{env['CF_ZONE']}/custom_hostnames" | |
| 79 | +def call(method, url, body=None): | |
| 80 | + req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body else None, | |
| 81 | + headers={"Authorization": "Bearer " + env["CF_TOKEN"], "Content-Type": "application/json"}) | |
| 82 | + with urllib.request.urlopen(req, timeout=30) as r: | |
| 83 | + return json.load(r) | |
| 84 | +have, page = {}, 1 | |
| 85 | +while True: | |
| 86 | + res = call("GET", f"{base}?per_page=50&page={page}")["result"] | |
| 87 | + have.update({h["hostname"]: h["id"] for h in res}) | |
| 88 | + if len(res) < 50: | |
| 89 | + break | |
| 90 | + page += 1 | |
| 91 | +for name in sorted(want - have.keys()): | |
| 92 | + try: | |
| 93 | + call("POST", base, {"hostname": name, "ssl": {"method": "http", "type": "dv"}}) | |
| 94 | + except Exception as e: | |
| 95 | + print(f"custom hostname {name}: {e}", file=sys.stderr) | |
| 96 | +for name in sorted(have.keys() - want): | |
| 97 | + call("DELETE", f"{base}/{have[name]}") | |
| 98 | +PY | |
| 99 | +} | |
| 100 | + | |
| 69 | 101 | apply |
| 70 | -for d in $domains; do | |
| 71 | - [ -f "/etc/letsencrypt/live/gitpocket-$d/fullchain.pem" ] && continue | |
| 72 | - if [ -f "$state/$d" ] && [ -n "$(find "$state/$d" -mmin -60)" ]; then continue; fi | |
| 73 | - if certbot certonly -n --quiet --webroot -w "$webroot" --cert-name "gitpocket-$d" -d "$d"; then | |
| 74 | - rm -f "$state/$d" | |
| 75 | - else | |
| 76 | - touch "$state/$d" | |
| 77 | - fi | |
| 78 | -done | |
| 79 | -apply | |
| 102 | +hostnames | |
deploy/gitpocket-domains.service modified
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | [Unit] |
| 2 | -Description=Issue certificates for gitpocket custom domains | |
| 2 | +Description=Route gitpocket custom domains through Cloudflare | |
| 3 | 3 | After=network-online.target nginx.service |
| 4 | 4 | |
| 5 | 5 | [Service] |
deploy/nginx.conf modified
| @@ -4,6 +4,7 @@ server { | ||
| 4 | 4 | listen 80; |
| 5 | 5 | listen [::]:80; |
| 6 | 6 | server_name git.example.org *.git.example.org; |
| 7 | + include snippets/gitpocket-cloudflare.conf; | |
| 7 | 8 | location / { return 301 https://$host$request_uri; } |
| 8 | 9 | } |
| 9 | 10 | |
| @@ -12,8 +13,9 @@ server { | ||
| 12 | 13 | listen [::]:443 ssl; |
| 13 | 14 | http2 on; |
| 14 | 15 | server_name git.example.org; |
| 15 | - ssl_certificate /etc/letsencrypt/live/gitpocket/fullchain.pem; | |
| 16 | - ssl_certificate_key /etc/letsencrypt/live/gitpocket/privkey.pem; | |
| 16 | + ssl_certificate /etc/gitpocket/origin.pem; | |
| 17 | + ssl_certificate_key /etc/gitpocket/origin.key; | |
| 18 | + include snippets/gitpocket-cloudflare.conf; | |
| 17 | 19 | ssl_protocols TLSv1.2 TLSv1.3; |
| 18 | 20 | client_max_body_size 512m; |
| 19 | 21 | add_header Strict-Transport-Security "max-age=31536000" always; |
| @@ -45,8 +47,9 @@ server { | ||
| 45 | 47 | listen [::]:443 ssl; |
| 46 | 48 | http2 on; |
| 47 | 49 | server_name *.git.example.org; |
| 48 | - ssl_certificate /etc/letsencrypt/live/gitpocket-pages/fullchain.pem; | |
| 49 | - ssl_certificate_key /etc/letsencrypt/live/gitpocket-pages/privkey.pem; | |
| 50 | + ssl_certificate /etc/gitpocket/origin.pem; | |
| 51 | + ssl_certificate_key /etc/gitpocket/origin.key; | |
| 52 | + include snippets/gitpocket-cloudflare.conf; | |
| 50 | 53 | ssl_protocols TLSv1.2 TLSv1.3; |
| 51 | 54 | client_max_body_size 1m; |
| 52 | 55 | add_header Strict-Transport-Security "max-age=31536000" always; |