steid

@jamesgill /

steid/install.sh
17.8 KBCode·Blame·Raw
1#!/bin/sh
2#
3# Steid installer for a fresh Debian/Ubuntu server.
4#
5# curl -fsSL https://.../install.sh | sh -s -- --domain git.example.com
6#
7# It installs Steid to /opt/steid, keeps state in /var/lib/steid, runs it as an
8# unprivileged system user under systemd, and puts Caddy in front of it with an
9# automatic HTTPS certificate for the domain you give.
10#
11# Re-running it is an upgrade: it downloads the requested version, replaces the
12# binary and assets, rewrites the unit and the Caddyfile, and restarts. It never
13# touches /var/lib/steid, and it never overwrites /etc/steid/steid.env once that
14# exists, so anything you have edited there survives.
15#
16# ON `curl | sh`: you are being asked to run a script you have not read, as root,
17# from a URL. That is a real trust decision and "it's convenient" is not an
18# answer to it. Two honest alternatives: download it first and read it
19# (`curl -fsSL … -o install.sh; less install.sh; sh install.sh --domain …`), or
20# follow the manual path in README.md, which is the same dozen commands written
21# out. Nothing here is magic; the script exists to save typing, not to be trusted
22# blindly.
23
24set -eu
25
26# --- PLACEHOLDER ------------------------------------------------------------
27#
28# !! Nothing is published there yet — the host does not resolve until the first
29# !! instance is up. That is the bootstrap `--tarball` exists for.
30#
31# The layout expected here, and produced by release.sh, is:
32#
33# ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz
34# ${RELEASE_BASE_URL}/v${VERSION}/steid-${VERSION}-${TARGET}.tar.gz.sha256
35#
36# Steid is distributed from a Steid instance rather than from a code-hosting
37# service, which is the point of the project rather than a flourish. The cost is
38# discovery: nobody stumbles across it. That is a marketing problem, not a
39# technical dependency, and a mirror can solve it later without this URL moving.
40# The *project's* distribution host — a constant, and NOT the `--domain` the
41# person running this installs onto. Everyone downloads Steid from here; each
42# installer then runs their own instance on their own hostname.
43#
44# Scoped under the repository rather than a root-level /releases: Steid serves
45# profiles at /{handle}, so a root path would squat its own namespace. This is
46# also exactly where a real release feature would put these files, so published
47# links survive that feature landing.
48RELEASE_BASE_URL="${STEID_RELEASE_BASE_URL:-https://jpgill.dev/jamesgill/repos/steid/releases}"
49
50# The version to install. Pinned rather than "latest" because there is no
51# redirect to resolve "latest" against, and a pinned default makes the upgrade
52# path explicit: `--version 0.2.0`.
53VERSION="${STEID_VERSION:-0.2.0}"
54# ----------------------------------------------------------------------------
55
56# The build target to fetch. musl is preferred — one static binary that does not
57# care which glibc the host has — but whether Steid builds against musl at all is
58# still being established, so the choice is a variable rather than a fact.
59# Override with --flavour gnu if the published artefacts are glibc.
60# gnu, matching what release.sh builds. musl was tried and rejected: it fails on
61# `ring` with Debian's musl-gcc wrapper, and — the decisive part — musl buys a
62# binary with no runtime dependencies while Steid hard-requires `git` on PATH, so
63# the portability cannot be used. THIS MUST AGREE WITH release.sh: they were
64# briefly out of step and the symptom was a confusing "checksum mismatch",
65# because the installer was looking for a musl tarball that was never built.
66FLAVOUR="gnu"
67
68DOMAIN=""
69WWW=0
70FORCE_CADDYFILE=0
71# A local artefact to install instead of downloading one. This exists because of
72# a bootstrap: the very first instance is what will *serve* the releases, so at
73# that moment there is nowhere to download from. It doubles as the offline and
74# air-gapped path.
75TARBALL=""
76PORT="3000"
77INSTALL_DIR="/opt/steid"
78STATE_DIR="/var/lib/steid"
79CONF_DIR="/etc/steid"
80STEID_USER="steid"
81INSTALL_CADDY=1
82
83die() { echo "install.sh: $*" >&2; exit 1; }
84say() { echo "==> $*"; }
85
86usage() {
87 cat >&2 <<'USAGE'
88Usage: install.sh --domain <hostname> [options]
89
90 --tarball <path> install from a local tarball instead of downloading.
91 Needed for the first install, which has nowhere to
92 download from yet, and for offline installs.
93 --www also serve www.<hostname>, redirecting it to the bare
94 hostname. Requires a www DNS record pointing here —
95 without one Caddy retries a certificate it cannot get.
96 --force-caddyfile overwrite an existing /etc/caddy/Caddyfile. Off by
97 default so an upgrade cannot silently discard your edits.
98 --domain <hostname> the public hostname, e.g. git.example.com. Its DNS must
99 already point at this machine or the certificate cannot
100 be issued. Required.
101 --version <v> release to install (default: the pinned one above)
102 --flavour <musl|gnu> which build to fetch (default: musl)
103 --port <n> loopback port Steid listens on (default: 3000)
104 --no-caddy install and run Steid but do not touch Caddy. Only for
105 putting your own TLS-terminating proxy in front. Steid
106 has no TLS of its own; without a proxy, access tokens
107 cross the network in cleartext.
108 -h, --help this
109USAGE
110 exit "${1:-0}"
111}
112
113# --- arguments --------------------------------------------------------------
114
115while [ $# -gt 0 ]; do
116 case "$1" in
117 --domain) DOMAIN="${2:-}"; [ -n "$DOMAIN" ] || die "--domain needs a hostname"; shift 2 ;;
118 --tarball) TARBALL="${2:-}"; [ -n "$TARBALL" ] || die "--tarball needs a path"; shift 2 ;;
119 --www) WWW=1; shift ;;
120 --force-caddyfile) FORCE_CADDYFILE=1; shift ;;
121 --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
122 --flavour) FLAVOUR="${2:-}"; [ -n "$FLAVOUR" ] || die "--flavour needs a value"; shift 2 ;;
123 --port) PORT="${2:-}"; [ -n "$PORT" ] || die "--port needs a number"; shift 2 ;;
124 --no-caddy) INSTALL_CADDY=0; shift ;;
125 -h|--help) usage 0 ;;
126 *) echo "install.sh: unknown argument: $1" >&2; usage 1 ;;
127 esac
128done
129
130# Fail early and loudly rather than half-installing. Everything below this point
131# assumes these hold.
132[ "$(id -u)" = "0" ] || die "must run as root (try: sudo sh install.sh --domain …)"
133
134if [ "$INSTALL_CADDY" = 1 ]; then
135 [ -n "$DOMAIN" ] || die "--domain is required. Caddy needs a real hostname to
136 obtain a certificate for, and Steid has no TLS of its own. If you are putting
137 your own proxy in front, pass --no-caddy."
138fi
139
140if [ -n "$DOMAIN" ]; then
141 case "$DOMAIN" in
142 *[!A-Za-z0-9.-]*|-*|.*|*.) die "'$DOMAIN' does not look like a hostname" ;;
143 esac
144fi
145
146case "$PORT" in
147 ''|*[!0-9]*) die "--port must be a number" ;;
148esac
149
150case "$FLAVOUR" in
151 musl|gnu) ;;
152 *) die "--flavour must be 'musl' or 'gnu'" ;;
153esac
154
155case "${TARBALL:+local}${RELEASE_BASE_URL}" in
156 local*) : ;; # installing from a file; the download URL is irrelevant
157 *REPLACE-ME*) die "RELEASE_BASE_URL is still the placeholder. Edit the top of
158 this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts,
159 or pass --tarball to install from a local file." ;;
160esac
161
162command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md"
163command -v apt-get >/dev/null 2>&1 || die "this installer only knows apt (Debian/Ubuntu).
164 The manual path in README.md works on anything with systemd."
165
166case "$(uname -m)" in
167 x86_64|amd64) ARCH="x86_64" ;;
168 aarch64|arm64) ARCH="aarch64" ;;
169 *) die "unsupported architecture: $(uname -m). Only x86_64 and aarch64 are built." ;;
170esac
171
172TARGET="${ARCH}-unknown-linux-${FLAVOUR}"
173NAME="steid-${VERSION}-${TARGET}"
174URL="${RELEASE_BASE_URL}/v${VERSION}/${NAME}.tar.gz"
175
176say "installing Steid ${VERSION} (${TARGET}) for ${DOMAIN:-<no domain>}"
177
178# --- prerequisites ----------------------------------------------------------
179
180# git is not optional and not a runtime nicety: Steid shells out to `git init
181# --bare` to create a repository and to `git http-backend` to serve every clone
182# and push. Without it the install succeeds and the first repository fails.
183say "installing prerequisites (git, curl, ca-certificates)"
184export DEBIAN_FRONTEND=noninteractive
185apt-get update -qq
186apt-get install -y -qq --no-install-recommends git curl ca-certificates
187
188# --- download ---------------------------------------------------------------
189
190TMP="$(mktemp -d)"
191# shellcheck disable=SC2064 # $TMP is expanded now on purpose: it never changes.
192trap "rm -rf '$TMP'" EXIT INT TERM
193
194if [ -n "$TARBALL" ]; then
195 [ -f "$TARBALL" ] || die "--tarball: no such file: ${TARBALL}"
196 say "installing from ${TARBALL}"
197 cp "$TARBALL" "${TMP}/${NAME}.tar.gz"
198
199 # A checksum beside a local file is verified when present, but not demanded:
200 # whoever passes --tarball already chose the bytes, so refusing to proceed
201 # without a .sha256 would block the bootstrap this option exists for.
202 if [ -f "${TARBALL}.sha256" ]; then
203 say "verifying checksum"
204 # Compared by value, not with `sha256sum -c`: that matches on the filename
205 # recorded inside the .sha256, which need not be what the file is called
206 # by the time someone passes it here.
207 EXPECTED="$(cut -d" " -f1 < "${TARBALL}.sha256")"
208 ACTUAL="$(sha256sum < "${TMP}/${NAME}.tar.gz" | cut -d" " -f1)"
209 [ "$EXPECTED" = "$ACTUAL" ] \
210 || die "checksum mismatch — ${TARBALL} does not match its .sha256"
211 else
212 say "no ${TARBALL}.sha256 beside it; installing unverified"
213 fi
214else
215 say "downloading ${URL}"
216 curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
217 || die "download failed. Is version ${VERSION} published for ${TARGET}?"
218 curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
219 || die "checksum file missing next to the tarball; refusing to install unverified"
220
221 say "verifying checksum"
222 ( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
223 || die "checksum mismatch — the download is corrupt or tampered with"
224fi
225
226tar -xzf "${TMP}/${NAME}.tar.gz" -C "$TMP"
227[ -x "${TMP}/${NAME}/steid" ] || die "tarball has no steid binary at ${NAME}/steid"
228# The bundle must ship and must land beside the binary: AssetBundle::load() walks
229# up from the executable looking for assets/manifest.toml and the process exits
230# at startup without it.
231[ -f "${TMP}/${NAME}/assets/manifest.toml" ] || die "tarball has no assets/manifest.toml"
232
233# --- user and directories ---------------------------------------------------
234
235if ! id "$STEID_USER" >/dev/null 2>&1; then
236 say "creating system user ${STEID_USER}"
237 # --home is the state directory: git wants a HOME, and giving it the one
238 # directory the service can write keeps that from being a surprise later.
239 useradd --system --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$STEID_USER"
240fi
241
242mkdir -p "$INSTALL_DIR" "$STATE_DIR" "$CONF_DIR"
243# State is exactly two things: the SQLite database file and the repository
244# directory. Both live here, and together they are the entire backup surface.
245mkdir -p "${STATE_DIR}/repos"
246chown -R "${STEID_USER}:${STEID_USER}" "$STATE_DIR"
247chmod 750 "$STATE_DIR"
248
249# --- install files ----------------------------------------------------------
250
251# Stop before replacing the binary: overwriting a running executable in place
252# fails with ETXTBSY, and a half-swapped install/assets pair would serve stale
253# hashed CSS until the next restart anyway.
254if systemctl is-active --quiet steid 2>/dev/null; then
255 say "stopping steid for the upgrade"
256 systemctl stop steid
257fi
258
259say "installing to ${INSTALL_DIR}"
260install -m 0755 "${TMP}/${NAME}/steid" "${INSTALL_DIR}/steid"
261rm -rf "${INSTALL_DIR}/assets"
262cp -R "${TMP}/${NAME}/assets" "${INSTALL_DIR}/assets"
263if [ -f "${TMP}/${NAME}/README.md" ]; then
264 cp "${TMP}/${NAME}/README.md" "${INSTALL_DIR}/README.md"
265fi
266chown -R root:root "$INSTALL_DIR"
267# Read-only to the service user on purpose: Steid never writes here.
268chmod -R a+rX "$INSTALL_DIR"
269
270# --- configuration ----------------------------------------------------------
271
272# Written once and then left alone, so an upgrade cannot silently revert a
273# setting someone deliberately changed.
274if [ ! -f "${CONF_DIR}/steid.env" ]; then
275 say "writing ${CONF_DIR}/steid.env"
276 cat > "${CONF_DIR}/steid.env" <<EOF
277# Steid configuration. Restart after editing: systemctl restart steid
278
279# All state lives under ${STATE_DIR}. SQLite writes -wal and -shm siblings, so
280# the directory must be writable, not just the file.
281STEID_DATABASE_URL=sqlite:${STATE_DIR}/steid.db?mode=rwc
282STEID_DATA_DIR=${STATE_DIR}/repos
283
284# HOST and PORT are read by the web framework itself and are deliberately not
285# STEID_-prefixed. Loopback only: Caddy is the way in, and binding 0.0.0.0 would
286# expose plain HTTP — and therefore access tokens in cleartext — to the internet.
287HOST=127.0.0.1
288PORT=${PORT}
289
290# Deliberately absent: STEID_INSECURE_COOKIES. It strips Secure from the session
291# cookie and exists only for plain-HTTP local development. Setting it here would
292# hand out a session cookie that any network hop can read.
293EOF
294 chmod 640 "${CONF_DIR}/steid.env"
295 chown "root:${STEID_USER}" "${CONF_DIR}/steid.env"
296else
297 say "keeping existing ${CONF_DIR}/steid.env"
298fi
299
300say "writing /etc/systemd/system/steid.service"
301cat > /etc/systemd/system/steid.service <<EOF
302# Managed by install.sh. Re-running the installer rewrites this file.
303[Unit]
304Description=Steid
305After=network-online.target
306Wants=network-online.target
307
308[Service]
309Type=simple
310User=${STEID_USER}
311Group=${STEID_USER}
312WorkingDirectory=${INSTALL_DIR}
313ExecStart=${INSTALL_DIR}/steid
314EnvironmentFile=${CONF_DIR}/steid.env
315Environment=HOME=${STATE_DIR}
316Restart=on-failure
317RestartSec=2s
318
319NoNewPrivileges=true
320PrivateTmp=true
321PrivateDevices=true
322ProtectSystem=strict
323ProtectHome=true
324ProtectKernelTunables=true
325ProtectKernelModules=true
326ProtectControlGroups=true
327RestrictSUIDSGID=true
328RestrictNamespaces=true
329LockPersonality=true
330RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
331ReadWritePaths=${STATE_DIR}
332
333[Install]
334WantedBy=multi-user.target
335EOF
336
337systemctl daemon-reload
338systemctl enable --quiet steid
339say "starting steid"
340systemctl restart steid
341
342# --- caddy ------------------------------------------------------------------
343
344if [ "$INSTALL_CADDY" = 1 ]; then
345 if ! command -v caddy >/dev/null 2>&1; then
346 say "installing Caddy from its official apt repository"
347 apt-get install -y -qq --no-install-recommends debian-keyring debian-archive-keyring apt-transport-https gnupg
348 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
349 | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
350 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
351 > /etc/apt/sources.list.d/caddy-stable.list
352 apt-get update -qq
353 apt-get install -y -qq caddy
354 else
355 say "Caddy already installed"
356 fi
357
358 # NOT rewritten on upgrade. This used to be overwritten every run, which
359 # silently discarded operator edits — a www redirect added by hand vanished on
360 # the next install and took the site's www hostname with it, with nothing in the
361 # output to say so. The env file was already treated this way; the two now agree.
362 if [ -f /etc/caddy/Caddyfile ] && [ "$FORCE_CADDYFILE" = 0 ]; then
363 say "keeping existing /etc/caddy/Caddyfile (--force-caddyfile to replace)"
364 else
365 say "writing /etc/caddy/Caddyfile for ${DOMAIN}"
366 mkdir -p /etc/caddy
367
368 WWW_BLOCK=""
369 if [ "$WWW" = 1 ]; then
370 WWW_BLOCK="
371# www redirects to the bare hostname so there is one canonical URL.
372www.${DOMAIN} {
373 redir https://${DOMAIN}{uri} permanent
374}"
375 fi
376
377 cat > /etc/caddy/Caddyfile <<EOF
378# Written by install.sh on first install only. Later runs leave it alone, so edits
379# here survive an upgrade. Use --force-caddyfile to have the installer replace it.
380#
381# Steid has no TLS of its own (Topcoat 0.5 ships none), and it authenticates git
382# over HTTP Basic — so without this proxy every push would send a personal access
383# token in cleartext. Caddy obtains and renews the certificate automatically,
384# provided ${DOMAIN} resolves here and ports 80 and 443 are open.
385${DOMAIN} {
386 reverse_proxy 127.0.0.1:${PORT} {
387 # Git's smart HTTP is a streaming protocol in both directions; buffering
388 # it turns a clone into a long silence and can stall negotiation.
389 flush_interval -1
390 }
391}
392${WWW_BLOCK}
393EOF
394 fi
395
396 systemctl enable --quiet caddy
397 systemctl reload caddy 2>/dev/null || systemctl restart caddy
398fi
399
400# --- report -----------------------------------------------------------------
401
402# A moment for the service to either come up or fall over, so the message below
403# reflects reality rather than optimism.
404sleep 2
405if ! systemctl is-active --quiet steid; then
406 echo >&2
407 echo "install.sh: steid is installed but not running. Look at:" >&2
408 echo " journalctl -u steid -n 50 --no-pager" >&2
409 exit 1
410fi
411
412if [ "$INSTALL_CADDY" = 1 ]; then
413 BASE_URL="https://${DOMAIN}"
414else
415 BASE_URL="http://127.0.0.1:${PORT} (put your own TLS proxy in front of this)"
416fi
417
418cat <<EOF
419
420Steid ${VERSION} is running.
421
422 Service systemctl status steid
423 Logs journalctl -u steid -f
424 Config ${CONF_DIR}/steid.env
425 State ${STATE_DIR} (the database and the repos — back up this directory)
426
427Next: claim the instance.
428
429 The setup token is printed to the log at startup, and ONLY while the instance
430 is unclaimed. It is held in memory, so every restart mints a new one:
431
432 journalctl -u steid --no-pager | tail -n 30
433
434 Then open ${BASE_URL}/auth/setup and paste it in.
435
436To upgrade later, re-run this script with a newer --version. It replaces the
437binary and assets and leaves ${STATE_DIR} untouched.
438EOF