steid

@jamesgill /

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