1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174 | #!/usr/bin/env bash
#
# Deploy makefuture.ro to the web box.
#
# DEPLOY_HOST=178.104.116.235 ./scripts/deploy.sh
#
# Ships three things that have to agree with each other, so they ship together:
# the static site, the multiplayer relay it talks to, and the Caddy config that
# routes between them. Deploying the site without its Caddy file is how you get
# a CSP that forbids the origin the page just started using.
#
# Safe to re-run. Content goes into a new release directory and becomes live
# only when the `current` symlink is flipped, which is atomic — nobody is ever
# served a half-rsynced site.
set -euo pipefail
HOST="${DEPLOY_HOST:?set DEPLOY_HOST to the web box address}"
USER="${DEPLOY_USER:-root}"
SSH_OPTS="${SSH_OPTS:--o StrictHostKeyChecking=accept-new}"
SITE_ROOT=/opt/web/sites/makefuture
RELAY_ROOT=/opt/web/apps/mp-relay
CADDY_CONF=/opt/web/caddy/etc/conf.d/makefuture.caddy
# How many old releases to keep. Enough to roll back through a bad afternoon,
# few enough that 11MB a piece never becomes a disk problem.
KEEP=5
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
RELEASE="$(date -u +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
REMOTE="${USER}@${HOST}"
ssh_() { ssh $SSH_OPTS "$REMOTE" "$@"; }
echo "==> Deploying release ${RELEASE} to ${REMOTE}"
# ---------------------------------------------------------------------------
# 1. Static site
# ---------------------------------------------------------------------------
# mp-relay is excluded from the document root deliberately: it is server code
# for the nordschleife games, not a page. It gets deployed in step 2, as a
# container.
echo "==> Uploading site"
ssh_ "mkdir -p ${SITE_ROOT}/releases/${RELEASE}"
rsync -az --delete \
--exclude='.git' \
--exclude='.github' \
--exclude='.DS_Store' \
--exclude='**/.DS_Store' \
--exclude='scripts' \
--exclude='deploy' \
--exclude='mp-relay' \
-e "ssh $SSH_OPTS" \
./ "${REMOTE}:${SITE_ROOT}/releases/${RELEASE}/"
# Relative symlink, so the tree stays valid inside the container, where this
# path is mounted at /srv/makefuture rather than /opt/web/sites/makefuture.
echo "==> Flipping current -> releases/${RELEASE}"
ssh_ "ln -sfn releases/${RELEASE} ${SITE_ROOT}/current"
# ---------------------------------------------------------------------------
# 2. Multiplayer relay
# ---------------------------------------------------------------------------
# Rebuilt every deploy, which is nearly free when nothing changed: Docker
# reuses the cached layers and `up -d` leaves the running container alone
# unless the image actually differs.
echo "==> Uploading relay"
rsync -az --delete -e "ssh $SSH_OPTS" \
mp-relay/ "${REMOTE}:${RELAY_ROOT}/app/"
rsync -az -e "ssh $SSH_OPTS" \
deploy/mp-relay.compose.yml "${REMOTE}:${RELAY_ROOT}/docker-compose.yml"
echo "==> Rebuilding relay"
# The leaderboard volume must exist and be writable by the container's
# unprivileged `node` user (uid 1000) before compose mounts it.
ssh_ "mkdir -p ${RELAY_ROOT}/data && chown -R 1000:1000 ${RELAY_ROOT}/data"
ssh_ "cd ${RELAY_ROOT} && docker compose up -d --build"
# ---------------------------------------------------------------------------
# 3. Caddy config
# ---------------------------------------------------------------------------
# Validated before it is loaded, and validated against the WHOLE Caddyfile —
# conf.d is shared with eratic.ro and opus.ro, so a syntax error here would
# otherwise take those down too. A failed `caddy reload` changes nothing: the
# previously loaded config keeps serving. The old file is restored anyway so
# the box never disagrees with what is running.
echo "==> Installing Caddy config"
ssh_ "cp ${CADDY_CONF} ${CADDY_CONF}.bak 2>/dev/null || true"
rsync -az -e "ssh $SSH_OPTS" deploy/makefuture.caddy "${REMOTE}:${CADDY_CONF}"
if ssh_ "docker exec web-caddy caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile" >/dev/null 2>&1; then
echo "==> Config valid, reloading Caddy"
ssh_ "docker exec web-caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile"
ssh_ "rm -f ${CADDY_CONF}.bak"
else
echo "!!! Caddy config INVALID — restoring previous and aborting" >&2
ssh_ "[ -f ${CADDY_CONF}.bak ] && mv ${CADDY_CONF}.bak ${CADDY_CONF} || rm -f ${CADDY_CONF}"
exit 1
fi
# ---------------------------------------------------------------------------
# 4. Prune old releases
# ---------------------------------------------------------------------------
# `current` is excluded from the sort by name rather than by resolving the
# symlink, because release names sort chronologically and the newest is always
# the one just deployed.
echo "==> Pruning old releases (keeping ${KEEP})"
ssh_ "cd ${SITE_ROOT}/releases && ls -1 | sort -r | tail -n +$((KEEP + 1)) | xargs -r rm -rf"
# ---------------------------------------------------------------------------
# 5. Verify over real HTTPS
# ---------------------------------------------------------------------------
# Against the public hostname, not localhost: this is the only check that
# exercises DNS, the certificate, Caddy's routing and the file on disk at the
# same time — which is to say, the only one that would have caught any of the
# ways this can go wrong.
echo "==> Verifying"
fail=0
# `|| true` on every probe, and not as a formality. Under `set -e` a curl that
# cannot resolve or connect exits non-zero and would kill this script on the
# spot — reporting curl's exit code and no failing URL, at the one moment the
# output matters. Swallowing it here turns "DNS is broken" into a printed 000
# next to the path that produced it.
probe() {
curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$@" || true
}
for path in / /index.html /privacy.html /games/ /games/nordschleife/ /games/curve/ /games/accept-the-cookie/ /feed.xml; do
code="$(probe "https://makefuture.ro${path}")"
printf ' %-45s %s\n' "$path" "$code"
[ "$code" = "200" ] || fail=1
done
# The old experiments/ URLs are redirects now, and a redirect that stops
# redirecting is a broken bookmark — so it gets verified like a page.
code="$(probe "https://makefuture.ro/experiments/nordschleife.html")"
printf ' %-45s %s\n' "/experiments/nordschleife.html (redirect)" "$code"
[ "$code" = "308" ] || fail=1
# play.makefuture.ro is deliberately not probed here: until its DNS record
# exists, a probe would fail every deploy for reasons no deploy can fix. Add
# it to the loop above once the record is live.
# The relay handshake, not just the port. A 101 means Caddy matched /ws, the
# web-edge network resolved `mp-relay`, and the Node process answered — the
# three things that actually break.
#
# The trailing --max-time overrides the 20s in probe(), because a SUCCESSFUL
# handshake never ends: curl prints 101 and then sits on an open WebSocket
# until the timeout kills it. The status code has arrived long before then, so
# waiting the full 20s would just add 20s to every deploy.
code="$(probe --http1.1 \
-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
--max-time 5 \
https://makefuture.ro/ws)"
printf ' %-45s %s\n' "/ws (websocket upgrade)" "$code"
[ "$code" = "101" ] || fail=1
if [ "$fail" -ne 0 ]; then
echo "!!! Verification FAILED — release ${RELEASE} is live but not healthy" >&2
echo " Roll back with: ssh ${REMOTE} 'ls ${SITE_ROOT}/releases && ln -sfn releases/<previous> ${SITE_ROOT}/current'" >&2
exit 1
fi
echo "==> Deployed ${RELEASE}"
|