@jpgilldev / steid

steid/install.sh
16.7 KBRaw
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=""
69# A local artefact to install instead of downloading one. This exists because of
70# a bootstrap: the very first instance is what will *serve* the releases, so at
71# that moment there is nowhere to download from. It doubles as the offline and
72# air-gapped path.
73TARBALL=""
74PORT="3000"
75INSTALL_DIR="/opt/steid"
76STATE_DIR="/var/lib/steid"
77CONF_DIR="/etc/steid"
78STEID_USER="steid"
79INSTALL_CADDY=1
80
81die() { echo "install.sh: $*" >&2; exit 1; }
82say() { echo "==> $*"; }
83
84usage() {
85 cat >&2 <<'USAGE'
86Usage: install.sh --domain <hostname> [options]
87
88 --tarball <path> install from a local tarball instead of downloading.
89 Needed for the first install, which has nowhere to
90 download from yet, and for offline installs.
91 --domain <hostname> the public hostname, e.g. git.example.com. Its DNS must
92 already point at this machine or the certificate cannot
93 be issued. Required.
94 --version <v> release to install (default: the pinned one above)
95 --flavour <musl|gnu> which build to fetch (default: musl)
96 --port <n> loopback port Steid listens on (default: 3000)
97 --no-caddy install and run Steid but do not touch Caddy. Only for
98 putting your own TLS-terminating proxy in front. Steid
99 has no TLS of its own; without a proxy, access tokens
100 cross the network in cleartext.
101 -h, --help this
102USAGE
103 exit "${1:-0}"
104}
105
106# --- arguments --------------------------------------------------------------
107
108while [ $# -gt 0 ]; do
109 case "$1" in
110 --domain) DOMAIN="${2:-}"; [ -n "$DOMAIN" ] || die "--domain needs a hostname"; shift 2 ;;
111 --tarball) TARBALL="${2:-}"; [ -n "$TARBALL" ] || die "--tarball needs a path"; shift 2 ;;
112 --version) VERSION="${2:-}"; [ -n "$VERSION" ] || die "--version needs a value"; shift 2 ;;
113 --flavour) FLAVOUR="${2:-}"; [ -n "$FLAVOUR" ] || die "--flavour needs a value"; shift 2 ;;
114 --port) PORT="${2:-}"; [ -n "$PORT" ] || die "--port needs a number"; shift 2 ;;
115 --no-caddy) INSTALL_CADDY=0; shift ;;
116 -h|--help) usage 0 ;;
117 *) echo "install.sh: unknown argument: $1" >&2; usage 1 ;;
118 esac
119done
120
121# Fail early and loudly rather than half-installing. Everything below this point
122# assumes these hold.
123[ "$(id -u)" = "0" ] || die "must run as root (try: sudo sh install.sh --domain …)"
124
125if [ "$INSTALL_CADDY" = 1 ]; then
126 [ -n "$DOMAIN" ] || die "--domain is required. Caddy needs a real hostname to
127 obtain a certificate for, and Steid has no TLS of its own. If you are putting
128 your own proxy in front, pass --no-caddy."
129fi
130
131if [ -n "$DOMAIN" ]; then
132 case "$DOMAIN" in
133 *[!A-Za-z0-9.-]*|-*|.*|*.) die "'$DOMAIN' does not look like a hostname" ;;
134 esac
135fi
136
137case "$PORT" in
138 ''|*[!0-9]*) die "--port must be a number" ;;
139esac
140
141case "$FLAVOUR" in
142 musl|gnu) ;;
143 *) die "--flavour must be 'musl' or 'gnu'" ;;
144esac
145
146case "${TARBALL:+local}${RELEASE_BASE_URL}" in
147 local*) : ;; # installing from a file; the download URL is irrelevant
148 *REPLACE-ME*) die "RELEASE_BASE_URL is still the placeholder. Edit the top of
149 this script (or set STEID_RELEASE_BASE_URL) to point at real release artefacts,
150 or pass --tarball to install from a local file." ;;
151esac
152
153command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md"
154command -v apt-get >/dev/null 2>&1 || die "this installer only knows apt (Debian/Ubuntu).
155 The manual path in README.md works on anything with systemd."
156
157case "$(uname -m)" in
158 x86_64|amd64) ARCH="x86_64" ;;
159 aarch64|arm64) ARCH="aarch64" ;;
160 *) die "unsupported architecture: $(uname -m). Only x86_64 and aarch64 are built." ;;
161esac
162
163TARGET="${ARCH}-unknown-linux-${FLAVOUR}"
164NAME="steid-${VERSION}-${TARGET}"
165URL="${RELEASE_BASE_URL}/v${VERSION}/${NAME}.tar.gz"
166
167say "installing Steid ${VERSION} (${TARGET}) for ${DOMAIN:-<no domain>}"
168
169# --- prerequisites ----------------------------------------------------------
170
171# git is not optional and not a runtime nicety: Steid shells out to `git init
172# --bare` to create a repository and to `git http-backend` to serve every clone
173# and push. Without it the install succeeds and the first repository fails.
174say "installing prerequisites (git, curl, ca-certificates)"
175export DEBIAN_FRONTEND=noninteractive
176apt-get update -qq
177apt-get install -y -qq --no-install-recommends git curl ca-certificates
178
179# --- download ---------------------------------------------------------------
180
181TMP="$(mktemp -d)"
182# shellcheck disable=SC2064 # $TMP is expanded now on purpose: it never changes.
183trap "rm -rf '$TMP'" EXIT INT TERM
184
185if [ -n "$TARBALL" ]; then
186 [ -f "$TARBALL" ] || die "--tarball: no such file: ${TARBALL}"
187 say "installing from ${TARBALL}"
188 cp "$TARBALL" "${TMP}/${NAME}.tar.gz"
189
190 # A checksum beside a local file is verified when present, but not demanded:
191 # whoever passes --tarball already chose the bytes, so refusing to proceed
192 # without a .sha256 would block the bootstrap this option exists for.
193 if [ -f "${TARBALL}.sha256" ]; then
194 say "verifying checksum"
195 # Compared by value, not with `sha256sum -c`: that matches on the filename
196 # recorded inside the .sha256, which need not be what the file is called
197 # by the time someone passes it here.
198 EXPECTED="$(cut -d" " -f1 < "${TARBALL}.sha256")"
199 ACTUAL="$(sha256sum < "${TMP}/${NAME}.tar.gz" | cut -d" " -f1)"
200 [ "$EXPECTED" = "$ACTUAL" ] \
201 || die "checksum mismatch — ${TARBALL} does not match its .sha256"
202 else
203 say "no ${TARBALL}.sha256 beside it; installing unverified"
204 fi
205else
206 say "downloading ${URL}"
207 curl -fsSL "$URL" -o "${TMP}/${NAME}.tar.gz" \
208 || die "download failed. Is version ${VERSION} published for ${TARGET}?"
209 curl -fsSL "${URL}.sha256" -o "${TMP}/${NAME}.tar.gz.sha256" \
210 || die "checksum file missing next to the tarball; refusing to install unverified"
211
212 say "verifying checksum"
213 ( cd "$TMP" && sha256sum -c "${NAME}.tar.gz.sha256" >/dev/null ) \
214 || die "checksum mismatch — the download is corrupt or tampered with"
215fi
216
217tar -xzf "${TMP}/${NAME}.tar.gz" -C "$TMP"
218[ -x "${TMP}/${NAME}/steid" ] || die "tarball has no steid binary at ${NAME}/steid"
219# The bundle must ship and must land beside the binary: AssetBundle::load() walks
220# up from the executable looking for assets/manifest.toml and the process exits
221# at startup without it.
222[ -f "${TMP}/${NAME}/assets/manifest.toml" ] || die "tarball has no assets/manifest.toml"
223
224# --- user and directories ---------------------------------------------------
225
226if ! id "$STEID_USER" >/dev/null 2>&1; then
227 say "creating system user ${STEID_USER}"
228 # --home is the state directory: git wants a HOME, and giving it the one
229 # directory the service can write keeps that from being a surprise later.
230 useradd --system --home-dir "$STATE_DIR" --shell /usr/sbin/nologin "$STEID_USER"
231fi
232
233mkdir -p "$INSTALL_DIR" "$STATE_DIR" "$CONF_DIR"
234# State is exactly two things: the SQLite database file and the repository
235# directory. Both live here, and together they are the entire backup surface.
236mkdir -p "${STATE_DIR}/repos"
237chown -R "${STEID_USER}:${STEID_USER}" "$STATE_DIR"
238chmod 750 "$STATE_DIR"
239
240# --- install files ----------------------------------------------------------
241
242# Stop before replacing the binary: overwriting a running executable in place
243# fails with ETXTBSY, and a half-swapped install/assets pair would serve stale
244# hashed CSS until the next restart anyway.
245if systemctl is-active --quiet steid 2>/dev/null; then
246 say "stopping steid for the upgrade"
247 systemctl stop steid
248fi
249
250say "installing to ${INSTALL_DIR}"
251install -m 0755 "${TMP}/${NAME}/steid" "${INSTALL_DIR}/steid"
252rm -rf "${INSTALL_DIR}/assets"
253cp -R "${TMP}/${NAME}/assets" "${INSTALL_DIR}/assets"
254if [ -f "${TMP}/${NAME}/README.md" ]; then
255 cp "${TMP}/${NAME}/README.md" "${INSTALL_DIR}/README.md"
256fi
257chown -R root:root "$INSTALL_DIR"
258# Read-only to the service user on purpose: Steid never writes here.
259chmod -R a+rX "$INSTALL_DIR"
260
261# --- configuration ----------------------------------------------------------
262
263# Written once and then left alone, so an upgrade cannot silently revert a
264# setting someone deliberately changed.
265if [ ! -f "${CONF_DIR}/steid.env" ]; then
266 say "writing ${CONF_DIR}/steid.env"
267 cat > "${CONF_DIR}/steid.env" <<EOF
268# Steid configuration. Restart after editing: systemctl restart steid
269
270# All state lives under ${STATE_DIR}. SQLite writes -wal and -shm siblings, so
271# the directory must be writable, not just the file.
272STEID_DATABASE_URL=sqlite:${STATE_DIR}/steid.db?mode=rwc
273STEID_DATA_DIR=${STATE_DIR}/repos
274
275# HOST and PORT are read by the web framework itself and are deliberately not
276# STEID_-prefixed. Loopback only: Caddy is the way in, and binding 0.0.0.0 would
277# expose plain HTTP — and therefore access tokens in cleartext — to the internet.
278HOST=127.0.0.1
279PORT=${PORT}
280
281# Deliberately absent: STEID_INSECURE_COOKIES. It strips Secure from the session
282# cookie and exists only for plain-HTTP local development. Setting it here would
283# hand out a session cookie that any network hop can read.
284EOF
285 chmod 640 "${CONF_DIR}/steid.env"
286 chown "root:${STEID_USER}" "${CONF_DIR}/steid.env"
287else
288 say "keeping existing ${CONF_DIR}/steid.env"
289fi
290
291say "writing /etc/systemd/system/steid.service"
292cat > /etc/systemd/system/steid.service <<EOF
293# Managed by install.sh. Re-running the installer rewrites this file.
294[Unit]
295Description=Steid
296After=network-online.target
297Wants=network-online.target
298
299[Service]
300Type=simple
301User=${STEID_USER}
302Group=${STEID_USER}
303WorkingDirectory=${INSTALL_DIR}
304ExecStart=${INSTALL_DIR}/steid
305EnvironmentFile=${CONF_DIR}/steid.env
306Environment=HOME=${STATE_DIR}
307Restart=on-failure
308RestartSec=2s
309
310NoNewPrivileges=true
311PrivateTmp=true
312PrivateDevices=true
313ProtectSystem=strict
314ProtectHome=true
315ProtectKernelTunables=true
316ProtectKernelModules=true
317ProtectControlGroups=true
318RestrictSUIDSGID=true
319RestrictNamespaces=true
320LockPersonality=true
321RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
322ReadWritePaths=${STATE_DIR}
323
324[Install]
325WantedBy=multi-user.target
326EOF
327
328systemctl daemon-reload
329systemctl enable --quiet steid
330say "starting steid"
331systemctl restart steid
332
333# --- caddy ------------------------------------------------------------------
334
335if [ "$INSTALL_CADDY" = 1 ]; then
336 if ! command -v caddy >/dev/null 2>&1; then
337 say "installing Caddy from its official apt repository"
338 apt-get install -y -qq --no-install-recommends debian-keyring debian-archive-keyring apt-transport-https gnupg
339 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
340 | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
341 curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
342 > /etc/apt/sources.list.d/caddy-stable.list
343 apt-get update -qq
344 apt-get install -y -qq caddy
345 else
346 say "Caddy already installed"
347 fi
348
349 # Rewritten every run so the domain and port always match this install. If
350 # you have hand-edited it, back it up first — this is the one file the
351 # installer overwrites.
352 say "writing /etc/caddy/Caddyfile for ${DOMAIN}"
353 mkdir -p /etc/caddy
354 cat > /etc/caddy/Caddyfile <<EOF
355# Managed by install.sh. Re-running the installer rewrites this file.
356#
357# Steid has no TLS of its own (Topcoat 0.5 ships none), and it authenticates git
358# over HTTP Basic — so without this proxy every push would send a personal access
359# token in cleartext. Caddy obtains and renews the certificate automatically,
360# provided ${DOMAIN} resolves here and ports 80 and 443 are open.
361${DOMAIN} {
362 reverse_proxy 127.0.0.1:${PORT} {
363 # Git's smart HTTP is a streaming protocol in both directions; buffering
364 # it turns a clone into a long silence and can stall negotiation.
365 flush_interval -1
366 }
367}
368EOF
369 systemctl enable --quiet caddy
370 systemctl reload caddy 2>/dev/null || systemctl restart caddy
371fi
372
373# --- report -----------------------------------------------------------------
374
375# A moment for the service to either come up or fall over, so the message below
376# reflects reality rather than optimism.
377sleep 2
378if ! systemctl is-active --quiet steid; then
379 echo >&2
380 echo "install.sh: steid is installed but not running. Look at:" >&2
381 echo " journalctl -u steid -n 50 --no-pager" >&2
382 exit 1
383fi
384
385if [ "$INSTALL_CADDY" = 1 ]; then
386 BASE_URL="https://${DOMAIN}"
387else
388 BASE_URL="http://127.0.0.1:${PORT} (put your own TLS proxy in front of this)"
389fi
390
391cat <<EOF
392
393Steid ${VERSION} is running.
394
395 Service systemctl status steid
396 Logs journalctl -u steid -f
397 Config ${CONF_DIR}/steid.env
398 State ${STATE_DIR} (the database and the repos — back up this directory)
399
400Next: claim the instance.
401
402 The setup token is printed to the log at startup, and ONLY while the instance
403 is unclaimed. It is held in memory, so every restart mints a new one:
404
405 journalctl -u steid --no-pager | tail -n 30
406
407 Then open ${BASE_URL}/auth/setup and paste it in.
408
409To upgrade later, re-run this script with a newer --version. It replaces the
410binary and assets and leaves ${STATE_DIR} untouched.
411EOF