~gitpocket/gitpocket

7

build(deploy): front the origin with cloudflare and drop certbot

commit
1a064e3197566c3d25bde134038ab6c0571b87fe browse tree
parent
a289514c9abfecce583a90db2f01c87f2dc0fdf8
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 10:46 UTC

7 files changed, +88 −36

INSTALL.md modified

@@ -21,7 +21,7 @@ The compose file runs the server as the `git` account with a read only root file
2121 ```
2222 git clone https://gitpocket.io/~gitpocket/gitpocket /opt/gitpocket
2323 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
2525 docker compose -f deploy/compose.yaml up -d --build
2626 ```
2727
@@ -53,18 +53,20 @@ The unit sets `GOMEMLIMIT` to 192 MiB, caps the server and every git it starts a
5353
5454 ## Reverse proxy
5555
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.
5757
5858 ```
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
5962 cp deploy/nginx.conf /etc/nginx/sites-available/gitpocket
6063 ln -s ../sites-available/gitpocket /etc/nginx/sites-enabled/
61-certbot certonly --nginx -d git.example.org
6264 nginx -t && systemctl reload nginx
6365 ```
6466
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.
6668
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.
6870
6971 ```
7072 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/
7274 systemctl enable --now gitpocket-domains.timer
7375 ```
7476
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.
7678
7779 ## SSH
7880

README.md modified

@@ -60,7 +60,7 @@ Pushing again with the same topic updates that pull request and records a new re
6060
6161 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.
6262
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.
6464
6565 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.
6666

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:
99 GID: "${GITPOCKET_GID:?set GITPOCKET_GID}"
1010 image: gitpocket
1111 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"]
1313 user: "${GITPOCKET_UID:?set GITPOCKET_UID}:${GITPOCKET_GID:?set GITPOCKET_GID}"
1414 ports:
1515 - "127.0.0.1:3200:3200"

deploy/domains.sh modified

@@ -5,28 +5,32 @@ root=${GITPOCKET_ROOT:-/srv/gitpocket}
55 user=${GITPOCKET_USER:-git}
66 port=${GITPOCKET_PORT:-3200}
77 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}
1111
1212 domains=$(runuser -u "$user" -- "$bin" -root "$root" domains | grep -E '^[a-z0-9.-]+$' || true)
1313
1414 render() {
1515 [ -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+}
2225 server {
2326 listen 443 ssl;
2427 listen [::]:443 ssl;
2528 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;
2932 ssl_protocols TLSv1.2 TLSv1.3;
33+ include snippets/gitpocket-cloudflare.conf;
3034 client_max_body_size 1m;
3135 add_header Strict-Transport-Security "max-age=31536000" always;
3236 gzip on;
@@ -44,7 +48,6 @@ server {
4448 }
4549 }
4650 CONF
47- done
4851 }
4952
5053 apply() {
@@ -66,14 +69,34 @@ apply() {
6669 fi
6770 }
6871
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+
69101 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 @@
11 [Unit]
2-Description=Issue certificates for gitpocket custom domains
2+Description=Route gitpocket custom domains through Cloudflare
33 After=network-online.target nginx.service
44
55 [Service]

deploy/nginx.conf modified

@@ -4,6 +4,7 @@ server {
44 listen 80;
55 listen [::]:80;
66 server_name git.example.org *.git.example.org;
7+ include snippets/gitpocket-cloudflare.conf;
78 location / { return 301 https://$host$request_uri; }
89 }
910
@@ -12,8 +13,9 @@ server {
1213 listen [::]:443 ssl;
1314 http2 on;
1415 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;
1719 ssl_protocols TLSv1.2 TLSv1.3;
1820 client_max_body_size 512m;
1921 add_header Strict-Transport-Security "max-age=31536000" always;
@@ -45,8 +47,9 @@ server {
4547 listen [::]:443 ssl;
4648 http2 on;
4749 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;
5053 ssl_protocols TLSv1.2 TLSv1.3;
5154 client_max_body_size 1m;
5255 add_header Strict-Transport-Security "max-age=31536000" always;