summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVoid Agent <void@jayrup.hermes>2026-10-22 23:11:10 +0100
committerVoid Agent <void@jayrup.hermes>2026-10-22 23:11:10 +0100
commit70dffce202a8cc1d15473bbc2750bdf29a54b0fb (patch)
tree8383299dd8a49f7805f64d57415975cf9fd9410b
parentb81fa0b2040e5b1b97806ffbf242948e74c4ef88 (diff)
nginx: serve public_key to curl cleanly, restore lost security headers
`curl https://jayrup.me/public_key` is the documented way to get this key onto a VPS, so both the file and the config serving it need to be exact. Key file: drop the trailing space, one trailing newline. A key with no trailing newline makes `curl ... >> ~/.ssh/authorized_keys` glue itself onto the previous line and silently break every later key in the file. nginx: - `/assets/public_key` ignored $final_suffix, so curl got 404 while browsers got 200. Route both through the suffix-aware try_files. - add_header does not merge across levels: the asset, PDF and .txt locations each declared one, so they inherited none of the security headers. The asset location now repeats them; the PDF and .txt locations no longer declare any (default_type / mime.types + charset do the job), so they inherit again. - `add_header Content-Type application/pdf` appended a second Content-Type instead of setting it. Use default_type. - `expires 30d` plus an explicit Cache-Control emitted two Cache-Control headers. Use one explicit header. - add `charset utf-8` so text/plain (what curl gets) is not served charset-less while the content contains non-ASCII. deploy: ship nginx.conf alongside public/. It is bind-mounted into the container, so it never travelled with the rsync'd tree — the live config was from May and predated the hardening committed in 0ee667a. Test with `nginx -t` before reloading and restore the previous config on failure.
-rw-r--r--assets/public_key2
-rwxr-xr-xdeploy-docker.sh26
-rwxr-xr-xdeploy.sh29
-rw-r--r--nginx.conf43
4 files changed, 78 insertions, 22 deletions
diff --git a/assets/public_key b/assets/public_key
index dd5ffc4..9287723 100644
--- a/assets/public_key
+++ b/assets/public_key
@@ -1 +1 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJV3IQljIJbI0Uz5MN77zChMZNkMastBtoUp8RsHa3br
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJV3IQljIJbI0Uz5MN77zChMZNkMastBtoUp8RsHa3br
diff --git a/deploy-docker.sh b/deploy-docker.sh
index 2e72954..716bb8d 100755
--- a/deploy-docker.sh
+++ b/deploy-docker.sh
@@ -57,14 +57,32 @@ cp cv/index.txt public/cv.txt
cp -r assets/* public/
cp -r assets/* public/assets/
-# nginx maps curl/wget UAs to .txt, so `curl https://jayrup.me/public_key`
-# resolves to /public_key.txt. Single source of truth stays assets/public_key;
-# browsers keep getting the suffixless /public_key.
-cp assets/public_key public/public_key.txt
+# .txt twin for curl/wget: normalize to exactly one line ending in a newline,
+# so `curl https://jayrup.me/public_key >> ~/.ssh/authorized_keys` appends a
+# clean key instead of gluing itself onto the previous line.
+KEY="$(tr -d '\r' < assets/public_key | sed -e 's/[[:space:]]*$//' | tr -d '\n')"
+[ -n "$KEY" ] || { echo "assets/public_key is empty" >&2; exit 1; }
+printf '%s\n' "$KEY" > public/public_key.txt
# Deploy status marker — site-visible proof of the last successful deploy
printf 'deployed %s commit %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHORT_SHA" > public/last-deploy.txt
echo ">> pushing to $DEPLOY_HOST"
rsync -avz --delete public/ "$DEPLOY_HOST":~/homepage/public/
+
+# nginx config is bind-mounted into the container, so it does not travel with
+# public/. Test before reloading: a bad file must not take down the running
+# workers, so restore the previous one and fail loudly instead.
+echo ">> pushing nginx.conf"
+ssh "$DEPLOY_HOST" 'cp -a ~/homepage/nginx.conf ~/homepage/nginx.conf.prev'
+rsync -avz nginx.conf "$DEPLOY_HOST":~/homepage/nginx.conf
+if ssh "$DEPLOY_HOST" 'docker exec homepage nginx -t'; then
+ ssh "$DEPLOY_HOST" 'docker exec homepage nginx -s reload'
+ echo ">> nginx reloaded"
+else
+ echo "!! nginx config failed its test - restoring previous config" >&2
+ ssh "$DEPLOY_HOST" 'mv ~/homepage/nginx.conf.prev ~/homepage/nginx.conf'
+ exit 1
+fi
+
echo ">> deploy OK ($SHORT_SHA)"
diff --git a/deploy.sh b/deploy.sh
index 51159fe..306f666 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -11,6 +11,7 @@ set -euo pipefail
# call must be pointed at $REPO explicitly.
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SHA="$(git -C "$REPO" rev-parse --short HEAD)"
+DEPLOY_HOST="${DEPLOY_HOST:-jayrup.me}"
# 1. Render into a fresh temp dir so a partial build never gets deployed
BUILD_DIR="$(mktemp -d /tmp/homepage-local.XXXXXX)"
@@ -45,14 +46,32 @@ mkdir -p public/assets
cp -r assets/* public/
cp -r assets/* public/assets/
-# .txt twin: curl/wget UAs map to .txt, so `curl https://jayrup.me/public_key`
-# resolves to public_key.txt. Source of truth stays assets/public_key.
-cp assets/public_key public/public_key.txt
+# .txt twin for curl/wget: normalize to exactly one line ending in a newline,
+# so `curl https://jayrup.me/public_key >> ~/.ssh/authorized_keys` appends a
+# clean key instead of gluing itself onto the previous line.
+KEY="$(tr -d '\r' < assets/public_key | sed -e 's/[[:space:]]*$//' | tr -d '\n')"
+[ -n "$KEY" ] || { echo "assets/public_key is empty" >&2; exit 1; }
+printf '%s\n' "$KEY" > public/public_key.txt
# 6. Deploy marker
printf 'deployed %s commit %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SHA" > public/last-deploy.txt
echo "Pushing to vps"
-# Only reached if all renders succeeded (set -e)
-rsync -avz --delete public/ jayrup.me:~/homepage/public/
+# 7. Content. Only reached if all renders succeeded (set -e)
+rsync -avz --delete public/ "$DEPLOY_HOST":~/homepage/public/
+
+# 8. nginx config. It is bind-mounted into the container, so it does not travel
+# with public/. Test it before reloading: a bad file must not take down the
+# running workers, so restore the previous one and fail loudly instead.
+echo "Pushing nginx.conf"
+ssh "$DEPLOY_HOST" 'cp -a ~/homepage/nginx.conf ~/homepage/nginx.conf.prev'
+rsync -avz nginx.conf "$DEPLOY_HOST":~/homepage/nginx.conf
+if ssh "$DEPLOY_HOST" 'docker exec homepage nginx -t'; then
+ ssh "$DEPLOY_HOST" 'docker exec homepage nginx -s reload'
+ echo "nginx reloaded"
+else
+ echo "!! nginx config failed its test - restoring previous config" >&2
+ ssh "$DEPLOY_HOST" 'mv ~/homepage/nginx.conf.prev ~/homepage/nginx.conf'
+ exit 1
+fi
diff --git a/nginx.conf b/nginx.conf
index bae90e3..a7e345a 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -17,6 +17,12 @@ server {
# Don't leak nginx version on error pages / server header
server_tokens off;
+ # The whole site is UTF-8. Without this, text/plain responses (i.e. what
+ # curl gets) carry no charset at all, so em dashes and other non-ASCII
+ # bytes are left ambiguous.
+ charset utf-8;
+ charset_types text/css text/plain text/xml application/javascript application/json application/rss+xml;
+
# Gzip compression
gzip on;
gzip_vary on;
@@ -25,28 +31,42 @@ server {
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
# Security headers
+ #
+ # add_header does NOT merge across levels: a location that declares any
+ # add_header inherits none from here. Every location below that adds a
+ # header therefore repeats these three verbatim.
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
- # Static asset caching
+ # Static asset caching.
+ # Cache-Control is set explicitly rather than via `expires`, because
+ # `expires` emits its own Cache-Control and the pair would duplicate.
+ # No `always`: a cached 404 for a missing asset is worse than no cache.
location ~* \.(ico|css|js|gif|jpe?g|png|svg|woff2?)$ {
- expires 30d;
- add_header Cache-Control "public, no-transform";
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header Cache-Control "public, max-age=2592000, no-transform";
+ try_files $uri =404;
}
- # Compatibility for assets prefix
+ # Compatibility for the assets prefix. These must honour $final_suffix:
+ # the deploy script ships the key as both /public_key and /public_key.txt,
+ # and only the .txt twin exists for curl/wget.
location = /assets/public_key {
- try_files /public_key /assets/public_key =404;
+ try_files /public_key$final_suffix /public_key /assets/public_key =404;
}
location = /assets/dissertation.pdf {
try_files /dissertation.pdf /assets/dissertation.pdf =404;
}
- # Serve PDFs directly
+ # Serve PDFs inline. `default_type` sets Content-Type; using add_header
+ # here would emit a second Content-Type next to the one from mime.types.
+ # Content-Disposition is omitted deliberately — nginx already serves
+ # unknown-to-it types inline, and browsers render PDFs inline by default.
location ~* \.pdf$ {
- add_header Content-Type application/pdf;
- add_header Content-Disposition inline;
+ default_type application/pdf;
try_files $uri =404;
}
@@ -78,8 +98,7 @@ server {
=404;
}
- location ~* \.txt$ {
- add_header Content-Type text/plain;
- }
+ # No explicit .txt location: mime.types already maps .txt to text/plain and
+ # `charset utf-8` above appends the charset. Declaring one only to set
+ # Content-Type would drop the inherited security headers.
}
-