| 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 | |
| 24 | set -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. |
| 48 | RELEASE_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`. |
| 53 | VERSION="${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. |
| 66 | FLAVOUR="gnu" |
| 67 | |
| 68 | DOMAIN="" |
| 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. |
| 73 | TARBALL="" |
| 74 | PORT="3000" |
| 75 | INSTALL_DIR="/opt/steid" |
| 76 | STATE_DIR="/var/lib/steid" |
| 77 | CONF_DIR="/etc/steid" |
| 78 | STEID_USER="steid" |
| 79 | INSTALL_CADDY=1 |
| 80 | |
| 81 | die() { echo "install.sh: $*" >&2; exit 1; } |
| 82 | say() { echo "==> $*"; } |
| 83 | |
| 84 | usage() { |
| 85 | cat >&2 <<'USAGE' |
| 86 | Usage: 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 |
| 102 | USAGE |
| 103 | exit "${1:-0}" |
| 104 | } |
| 105 | |
| 106 | # --- arguments -------------------------------------------------------------- |
| 107 | |
| 108 | while [ $# -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 |
| 119 | done |
| 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 | |
| 125 | if [ "$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." |
| 129 | fi |
| 130 | |
| 131 | if [ -n "$DOMAIN" ]; then |
| 132 | case "$DOMAIN" in |
| 133 | *[!A-Za-z0-9.-]*|-*|.*|*.) die "'$DOMAIN' does not look like a hostname" ;; |
| 134 | esac |
| 135 | fi |
| 136 | |
| 137 | case "$PORT" in |
| 138 | ''|*[!0-9]*) die "--port must be a number" ;; |
| 139 | esac |
| 140 | |
| 141 | case "$FLAVOUR" in |
| 142 | musl|gnu) ;; |
| 143 | *) die "--flavour must be 'musl' or 'gnu'" ;; |
| 144 | esac |
| 145 | |
| 146 | case "${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." ;; |
| 151 | esac |
| 152 | |
| 153 | command -v systemctl >/dev/null 2>&1 || die "no systemd here; follow the manual path in README.md" |
| 154 | command -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 | |
| 157 | case "$(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." ;; |
| 161 | esac |
| 162 | |
| 163 | TARGET="${ARCH}-unknown-linux-${FLAVOUR}" |
| 164 | NAME="steid-${VERSION}-${TARGET}" |
| 165 | URL="${RELEASE_BASE_URL}/v${VERSION}/${NAME}.tar.gz" |
| 166 | |
| 167 | say "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. |
| 174 | say "installing prerequisites (git, curl, ca-certificates)" |
| 175 | export DEBIAN_FRONTEND=noninteractive |
| 176 | apt-get update -qq |
| 177 | apt-get install -y -qq --no-install-recommends git curl ca-certificates |
| 178 | |
| 179 | # --- download --------------------------------------------------------------- |
| 180 | |
| 181 | TMP="$(mktemp -d)" |
| 182 | # shellcheck disable=SC2064 # $TMP is expanded now on purpose: it never changes. |
| 183 | trap "rm -rf '$TMP'" EXIT INT TERM |
| 184 | |
| 185 | if [ -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 |
| 205 | else |
| 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" |
| 215 | fi |
| 216 | |
| 217 | tar -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 | |
| 226 | if ! 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" |
| 231 | fi |
| 232 | |
| 233 | mkdir -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. |
| 236 | mkdir -p "${STATE_DIR}/repos" |
| 237 | chown -R "${STEID_USER}:${STEID_USER}" "$STATE_DIR" |
| 238 | chmod 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. |
| 245 | if systemctl is-active --quiet steid 2>/dev/null; then |
| 246 | say "stopping steid for the upgrade" |
| 247 | systemctl stop steid |
| 248 | fi |
| 249 | |
| 250 | say "installing to ${INSTALL_DIR}" |
| 251 | install -m 0755 "${TMP}/${NAME}/steid" "${INSTALL_DIR}/steid" |
| 252 | rm -rf "${INSTALL_DIR}/assets" |
| 253 | cp -R "${TMP}/${NAME}/assets" "${INSTALL_DIR}/assets" |
| 254 | if [ -f "${TMP}/${NAME}/README.md" ]; then |
| 255 | cp "${TMP}/${NAME}/README.md" "${INSTALL_DIR}/README.md" |
| 256 | fi |
| 257 | chown -R root:root "$INSTALL_DIR" |
| 258 | # Read-only to the service user on purpose: Steid never writes here. |
| 259 | chmod -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. |
| 265 | if [ ! -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. |
| 272 | STEID_DATABASE_URL=sqlite:${STATE_DIR}/steid.db?mode=rwc |
| 273 | STEID_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. |
| 278 | HOST=127.0.0.1 |
| 279 | PORT=${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. |
| 284 | EOF |
| 285 | chmod 640 "${CONF_DIR}/steid.env" |
| 286 | chown "root:${STEID_USER}" "${CONF_DIR}/steid.env" |
| 287 | else |
| 288 | say "keeping existing ${CONF_DIR}/steid.env" |
| 289 | fi |
| 290 | |
| 291 | say "writing /etc/systemd/system/steid.service" |
| 292 | cat > /etc/systemd/system/steid.service <<EOF |
| 293 | # Managed by install.sh. Re-running the installer rewrites this file. |
| 294 | [Unit] |
| 295 | Description=Steid |
| 296 | After=network-online.target |
| 297 | Wants=network-online.target |
| 298 | |
| 299 | [Service] |
| 300 | Type=simple |
| 301 | User=${STEID_USER} |
| 302 | Group=${STEID_USER} |
| 303 | WorkingDirectory=${INSTALL_DIR} |
| 304 | ExecStart=${INSTALL_DIR}/steid |
| 305 | EnvironmentFile=${CONF_DIR}/steid.env |
| 306 | Environment=HOME=${STATE_DIR} |
| 307 | Restart=on-failure |
| 308 | RestartSec=2s |
| 309 | |
| 310 | NoNewPrivileges=true |
| 311 | PrivateTmp=true |
| 312 | PrivateDevices=true |
| 313 | ProtectSystem=strict |
| 314 | ProtectHome=true |
| 315 | ProtectKernelTunables=true |
| 316 | ProtectKernelModules=true |
| 317 | ProtectControlGroups=true |
| 318 | RestrictSUIDSGID=true |
| 319 | RestrictNamespaces=true |
| 320 | LockPersonality=true |
| 321 | RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX |
| 322 | ReadWritePaths=${STATE_DIR} |
| 323 | |
| 324 | [Install] |
| 325 | WantedBy=multi-user.target |
| 326 | EOF |
| 327 | |
| 328 | systemctl daemon-reload |
| 329 | systemctl enable --quiet steid |
| 330 | say "starting steid" |
| 331 | systemctl restart steid |
| 332 | |
| 333 | # --- caddy ------------------------------------------------------------------ |
| 334 | |
| 335 | if [ "$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 | } |
| 368 | EOF |
| 369 | systemctl enable --quiet caddy |
| 370 | systemctl reload caddy 2>/dev/null || systemctl restart caddy |
| 371 | fi |
| 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. |
| 377 | sleep 2 |
| 378 | if ! 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 |
| 383 | fi |
| 384 | |
| 385 | if [ "$INSTALL_CADDY" = 1 ]; then |
| 386 | BASE_URL="https://${DOMAIN}" |
| 387 | else |
| 388 | BASE_URL="http://127.0.0.1:${PORT} (put your own TLS proxy in front of this)" |
| 389 | fi |
| 390 | |
| 391 | cat <<EOF |
| 392 | |
| 393 | Steid ${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 | |
| 400 | Next: 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 | |
| 409 | To upgrade later, re-run this script with a newer --version. It replaces the |
| 410 | binary and assets and leaves ${STATE_DIR} untouched. |
| 411 | EOF |