#!/bin/sh # Runix installer — the one command that sets a host up. # # curl -fsSL https://runix-releases.s3-alpha-web.astra-dev.online/install.sh | sudo sh # # Asks what this host should be (control plane, agent, or both), checks # the prerequisites, installs under /opt/runix and wires up systemd. # # Re-running upgrades in place: the binaries are replaced and the # services restarted, while the existing configuration — above all the # JWT and encryption secrets — is kept. Rotating those would invalidate # every session and make stored TOTP secrets unreadable. # # Non-interactive use (CI, config management) is supported: pass --role # and the values you would have typed, and nothing is prompted. # # POSIX sh on purpose: minimal images often have no bash. set -eu PREFIX=${RUNIX_PREFIX:-/opt/runix} # Releases are served from object storage by default: it needs no account, # so a plain `curl … | sh` works on a fresh box. The layout is # # /latest.json names the newest version # // the binaries, install.sh and SHA256SUMS # # Clear it (RUNIX_S3=) to fall back to the git forge below. S3_BASE=${RUNIX_S3:-https://runix-releases.s3-alpha-web.astra-dev.online} # The forge is the fallback source, used when S3_BASE is empty. GITEA=${RUNIX_GITEA:-https://vcs.astra-dev.online} REPO=${RUNIX_REPO:-svesnav/runix} VERSION=${RUNIX_VERSION:-latest} DOWNLOAD_BASE=${RUNIX_DOWNLOAD_BASE:-} # A read token for downloading the release, only needed if the server # requires sign-in to view it. A public repo on an open instance needs # none. (Distinct from the agent enrollment token below.) DL_TOKEN=${RUNIX_TOKEN:-${RUNIX_GITHUB_TOKEN:-${GITHUB_TOKEN:-}}} ROLE="" ASSUME_YES=0 NO_START=0 SERVER_BIN="" AGENT_BIN="" # Control plane. SERVER_USER=${RUNIX_SERVER_USER:-runix} DB_MODE="" # docker | existing DSN=${RUNIX_DATABASE_DSN:-} HTTP_HOST=${RUNIX_HTTP_HOST:-} # empty = every interface HTTP_PORT="" HTTP_ADDR="" API_HOST="" _api_authority="" PUBLIC_URL="" ADMIN_PASSWORD=${RUNIX_ADMIN_PASSWORD:-} PG_IMAGE=${RUNIX_POSTGRES_IMAGE:-postgres:17-alpine} PG_PORT="" PG_DB=${RUNIX_POSTGRES_DB:-runix} PG_USER=${RUNIX_POSTGRES_USER:-runix} PG_CONTAINER=${RUNIX_POSTGRES_CONTAINER:-runix-postgres} PG_PASSWORD="" # Agent. AGENT_USER=${RUNIX_AGENT_USER:-root} SERVER_URL=${RUNIX_AGENT_SERVER_URL:-} TOKEN=${RUNIX_AGENT_TOKEN:-} DATA_DIR=${RUNIX_AGENT_DATA_DIR:-} usage() { cat <&2; usage >&2; exit 2 ;; esac done if [ -z "$DOWNLOAD_BASE" ]; then if [ -n "$S3_BASE" ]; then DOWNLOAD_BASE="$S3_BASE" elif [ -n "$GITEA" ]; then DOWNLOAD_BASE="$GITEA/$REPO/releases" else DOWNLOAD_BASE="https://github.com/$REPO/releases" fi fi BIN_DIR="$PREFIX/bin" CONFIG_DIR="$PREFIX/etc" PG_DIR="$PREFIX/postgres" SERVER_ENV="$CONFIG_DIR/server.env" AGENT_ENV="$CONFIG_DIR/agent.env" # ------------------------------------------------------------------ output if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then C_B=$(printf '\033[1m'); C_DIM=$(printf '\033[2m'); C_OK=$(printf '\033[32m') C_WARN=$(printf '\033[33m'); C_ERR=$(printf '\033[31m'); C_0=$(printf '\033[0m') else C_B=""; C_DIM=""; C_OK=""; C_WARN=""; C_ERR=""; C_0="" fi say() { echo " ${C_DIM}$*${C_0}"; } ok() { echo " ${C_OK}✓${C_0} $*"; } warn() { echo " ${C_WARN}!${C_0} $*"; } fail() { echo "${C_ERR}[runix] error:${C_0} $*" >&2; exit 1; } head2() { echo; echo "${C_B}$*${C_0}"; } # --------------------------------------------------------------- interaction # `curl … | sh` leaves stdin pointing at the pipe, so reading answers from # it would consume the script itself. Reattach the terminal when there is # one; without it the run is non-interactive and every value must come # from a flag. # # The probe runs in a subshell on purpose: a failed `exec <` redirection # is fatal to a non-interactive shell, so testing it directly would kill # the installer without a word on any host that has /dev/tty present but # no controlling terminal (cron, CI, docker exec -T). INTERACTIVE=1 if [ ! -t 0 ]; then if (exec /dev/null 2>&1; then exec /dev/null 2>&1; then _old=$(stty -g 2>/dev/null || echo) stty -echo 2>/dev/null || true read -r _ans || _ans="" if [ -n "$_old" ]; then stty "$_old" 2>/dev/null || true else stty echo 2>/dev/null || true fi echo else read -r _ans || _ans="" fi eval "$_var=\$_ans" } confirm() { [ "$ASSUME_YES" -eq 1 ] && return 0 [ "$INTERACTIVE" -eq 0 ] && return 0 printf ' %s [Y/n]: ' "$1" read -r _a || _a="" case "$_a" in n|N|no|NO|No) return 1 ;; *) return 0 ;; esac } choose() { # choose VARNAME "question" "value:label" ... # The first option is the recommended one, and is what an unattended # run gets when the caller did not pass the matching flag. _var=$1; _q=$2; shift 2 if [ "$INTERACTIVE" -eq 0 ]; then eval "_cur=\${$_var:-}" if [ -z "$_cur" ]; then _first=$1 eval "$_var=\${_first%%:*}" fi return fi [ -n "$_q" ] && echo " $_q" _i=0 for _opt in "$@"; do _i=$((_i + 1)) echo " $_i) ${_opt#*:}" done while :; do printf ' choice [1]: ' read -r _n || _n="" [ -n "$_n" ] || _n=1 case "$_n" in ''|*[!0-9]*) echo " enter a number"; continue ;; esac if [ "$_n" -ge 1 ] && [ "$_n" -le "$#" ]; then _i=0 for _opt in "$@"; do _i=$((_i + 1)) if [ "$_i" -eq "$_n" ]; then eval "$_var=\${_opt%%:*}" return fi done fi echo " pick 1-$#" done } random_secret() { if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32 else head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' fi } # read_env FILE KEY — a value from an existing env file, or empty. read_env() { if [ -f "$1" ]; then sed -n "s/^$2=//p" "$1" | head -n1 fi } # update_env FILE KEY VALUE — set one key, leaving every other line alone. # # An upgrade must not flatten a config file: operators add their own # settings to these (log level, heartbeat interval, proxy variables) and # rewriting the file wholesale would throw them away. Only the key named # here is touched, and only when its value actually differs. # # Returns 0 when the file changed, 1 when it already held that value, so # callers can report honestly instead of claiming to have written # something. update_env() { _file=$1; _key=$2; _value=$3 if [ "$(read_env "$_file" "$_key")" = "$_value" ]; then return 1 fi # Staged beside the target rather than in /tmp: these files hold # tokens and database passwords, and $CONFIG_DIR is where they are # already protected. _tmp="$_file.$$.tmp" ( umask 077; : > "$_tmp" ) || fail "cannot write to $(dirname "$_file")" _found=0 if [ -f "$_file" ]; then while IFS= read -r _line || [ -n "$_line" ]; do case "$_line" in "$_key="*) printf '%s=%s\n' "$_key" "$_value" >> "$_tmp" _found=1 ;; *) printf '%s\n' "$_line" >> "$_tmp" ;; esac done < "$_file" fi if [ "$_found" -eq 0 ]; then printf '%s=%s\n' "$_key" "$_value" >> "$_tmp" fi # Copied rather than moved so the file keeps its owner and mode. cat "$_tmp" > "$_file" rm -f "$_tmp" return 0 } # env_begin / env_set / env_report — apply a batch of keys to an existing # file and say which ones actually moved. Reporting only real changes is # the point: an upgrade that prints "wrote agent.env" when it wrote # nothing teaches operators to distrust the output. ENV_CHANGED="" env_begin() { ENV_CHANGED=""; } env_set() { # An empty value means "not configured here", never "blank it out" — # clearing a token because enrollment failed would be worse than # leaving a stale one. [ -n "$3" ] || return 0 if update_env "$1" "$2" "$3"; then ENV_CHANGED="$ENV_CHANGED $2" fi } env_report() { if [ -n "$ENV_CHANGED" ]; then ok "updated $1:$ENV_CHANGED" else ok "kept $1 as it is" fi } port_busy() { # Braced so the following bracket is not read as an array subscript. _p="${1}" if command -v ss >/dev/null 2>&1; then if ss -ltn 2>/dev/null | grep -q ":${_p}[[:space:]]"; then return 0 fi elif command -v netstat >/dev/null 2>&1; then if netstat -ltn 2>/dev/null | grep -q ":${_p}[[:space:]]"; then return 0 fi fi return 1 } # join_addr HOST PORT — a Go listen address. An IPv6 literal needs # brackets, or the colons in it run into the port. join_addr() { case "$1" in '') printf ':%s\n' "$2" ;; *:*) printf '[%s]:%s\n' "$1" "$2" ;; *) printf '%s:%s\n' "$1" "$2" ;; esac } # host_has_address ADDR — true when some interface carries it. # # Binding to an address the host does not have is a listen() failure at # startup, well after the installer has declared success, so it is worth # catching here. Anything that is not an IP literal (a hostname) and any # host without the tools to check gets the benefit of the doubt. host_has_address() { case "$1" in 127.*|::1) return 0 ;; *[!0-9.]*) case "$1" in *[!0-9a-fA-F:]*) return 0 ;; # a name, not an IPv6 literal esac ;; esac if command -v ip >/dev/null 2>&1; then ip -o addr show 2>/dev/null | grep -qE "inet6?[[:space:]]+$1(/|[[:space:]])" && return 0 return 1 fi if command -v ifconfig >/dev/null 2>&1; then ifconfig -a 2>/dev/null | grep -qE "(inet6?|addr:)[[:space:]]*$1([[:space:]]|/|$)" && return 0 return 1 fi return 0 } # ------------------------------------------------------------------ preflight [ "$(id -u)" -eq 0 ] || fail "run as root (try: sudo sh $0 ...)" [ "$(uname -s)" = Linux ] || fail "Runix hosts are Linux only" ARCH=$(uname -m) case "$ARCH" in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; *) fail "unsupported architecture: $ARCH (amd64 and arm64 are built)" ;; esac command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1 \ || fail "need curl or wget" HOSTNAME_S=$(hostname 2>/dev/null || echo runix-host) HAS_SYSTEMD=0 [ -d /run/systemd/system ] && HAS_SYSTEMD=1 HAS_DOCKER=0 if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then HAS_DOCKER=1 fi COMPOSE="" if [ "$HAS_DOCKER" -eq 1 ]; then if docker compose version >/dev/null 2>&1; then COMPOSE="docker compose" elif command -v docker-compose >/dev/null 2>&1; then COMPOSE="docker-compose" fi fi # Releases before the /opt layout kept config in /etc/runix. Carry those # files forward so an upgrade keeps its secrets and enrollment token. migrate_legacy() { _legacy=$1; _new=$2 if [ ! -f "$_new" ] && [ -f "$_legacy" ]; then mkdir -p "$CONFIG_DIR" cp "$_legacy" "$_new" chmod 0600 "$_new" ok "migrated existing config from $_legacy" fi } migrate_legacy /etc/runix/server.env "$SERVER_ENV" migrate_legacy /etc/runix/agent.env "$AGENT_ENV" HAVE_SERVER=0; [ -f "$SERVER_ENV" ] && HAVE_SERVER=1 HAVE_AGENT=0; [ -f "$AGENT_ENV" ] && HAVE_AGENT=1 echo echo "${C_B}Runix installer${C_0} ${C_DIM}($REPO, $VERSION)${C_0}" echo "${C_DIM}────────────────────────────────────────────────${C_0}" echo " host $HOSTNAME_S (linux/$ARCH)" if [ "$HAS_SYSTEMD" -eq 1 ]; then ok "systemd"; else warn "no systemd — services must be started by hand"; fi if [ "$HAS_DOCKER" -eq 1 ]; then if [ -n "$COMPOSE" ]; then ok "docker + compose"; else warn "docker without compose plugin"; fi else warn "no docker — cannot provision PostgreSQL, and Docker runtimes will be unavailable" fi if [ "$HAVE_SERVER" -eq 1 ] || [ "$HAVE_AGENT" -eq 1 ]; then _found="" [ "$HAVE_SERVER" -eq 1 ] && _found="control plane" [ "$HAVE_AGENT" -eq 1 ] && _found="${_found:+$_found + }agent" ok "existing install at $PREFIX ($_found) — this upgrades it, keeping config" fi # ---------------------------------------------------------------- questions # An upgrade should not re-ask what this host already is. if [ -z "$ROLE" ]; then if [ "$HAVE_SERVER" -eq 1 ] && [ "$HAVE_AGENT" -eq 1 ]; then ROLE=all-in-one elif [ "$HAVE_SERVER" -eq 1 ]; then ROLE=server elif [ "$HAVE_AGENT" -eq 1 ]; then ROLE=agent fi [ -n "$ROLE" ] && say "keeping this host's existing role: $ROLE" fi if [ -z "$ROLE" ]; then head2 "What should this host run?" if [ "$INTERACTIVE" -eq 0 ]; then fail "--role is required in non-interactive mode (all-in-one|server|agent)" fi choose ROLE "" \ "all-in-one:Control plane + agent (single-host install)" \ "server:Control plane only" \ "agent:Agent only — join a control plane running elsewhere" fi case "$ROLE" in all-in-one|server|agent) ;; *) fail "unknown role: $ROLE (all-in-one, server, agent)" ;; esac WANT_SERVER=0; WANT_AGENT=0 case "$ROLE" in all-in-one) WANT_SERVER=1; WANT_AGENT=1 ;; server) WANT_SERVER=1 ;; agent) WANT_AGENT=1 ;; esac if [ "$WANT_SERVER" -eq 1 ]; then # Existing values are the defaults, so an upgrade needs no answers. _old_dsn=$(read_env "$SERVER_ENV" RUNIX_DATABASE_DSN) _old_addr=$(read_env "$SERVER_ENV" RUNIX_HTTP_ADDR) _old_cors=$(read_env "$SERVER_ENV" RUNIX_CORS_ORIGINS) [ -n "$DSN" ] || DSN=$_old_dsn if [ -z "$DB_MODE" ]; then if [ -n "$DSN" ]; then # Already pointed at a database: keep using it. A previously # provisioned container is recognised by its compose file. if [ -f "$PG_DIR/docker-compose.yml" ]; then DB_MODE=docker; else DB_MODE=existing; fi elif [ -n "$COMPOSE" ]; then head2 "Database" choose DB_MODE "" \ "docker:Run PostgreSQL for me, in Docker Compose (recommended)" \ "existing:Use a PostgreSQL server I already have" else warn "docker compose is unavailable, so PostgreSQL cannot be provisioned here" DB_MODE=existing fi fi if [ "$DB_MODE" = docker ]; then [ -n "$COMPOSE" ] || fail "docker compose is required to provision PostgreSQL install it (https://docs.docker.com/engine/install/), or pass --dsn to point at a database you already have" if [ -z "$PG_PORT" ]; then PG_PORT=$(read_env "$PG_DIR/.env" POSTGRES_PORT) [ -n "$PG_PORT" ] || PG_PORT=${RUNIX_POSTGRES_PORT:-5432} if [ ! -f "$PG_DIR/.env" ] && port_busy "$PG_PORT"; then warn "port $PG_PORT is already in use on this host" ask PG_PORT "port for the Runix database" 5433 fi fi else if [ -z "$DSN" ]; then head2 "Database" if [ "$INTERACTIVE" -eq 0 ]; then fail "a database DSN is required in non-interactive mode (pass --dsn)" fi say "example: postgres://runix:secret@127.0.0.1:5432/runix?sslmode=disable" ask DSN "PostgreSQL DSN" "" fi [ -n "$DSN" ] || fail "a DSN is required when not provisioning PostgreSQL" case "$DSN" in postgres://*|postgresql://*) ;; *) fail "that does not look like a PostgreSQL DSN: $DSN" ;; esac fi # RUNIX_HTTP_ADDR is one string, so an upgrade has to take it apart # again to keep whichever half the operator did not just override. # The last colon separates them, which leaves a bare IPv6 literal # intact because it is stored bracketed. _old_host="" if [ -n "$_old_addr" ]; then _old_host=${_old_addr%:*} case "$_old_host" in \[*\]) _old_host=${_old_host#[}; _old_host=${_old_host%]} ;; esac fi _asked_cp=0 if [ -z "$HTTP_PORT" ]; then if [ -n "$_old_addr" ]; then HTTP_PORT=${_old_addr##*:} else head2 "Control plane" _asked_cp=1 ask HTTP_PORT "HTTP port" 8080 fi fi case "$HTTP_PORT" in ''|*[!0-9]*) fail "invalid port: $HTTP_PORT" ;; esac if [ "$HTTP_PORT" -lt 1 ] || [ "$HTTP_PORT" -gt 65535 ]; then fail "port out of range: $HTTP_PORT" fi # Binding to one address is how you put the control plane on a # management network, or behind a reverse proxy on loopback only. if [ -z "$HTTP_HOST" ] && [ -n "$_old_addr" ]; then HTTP_HOST=$_old_host elif [ -z "$HTTP_HOST" ] && [ "$_asked_cp" -eq 1 ]; then say "an IP to listen on, or blank for every interface" ask HTTP_HOST "listen address" "" fi # These all mean "every interface"; store that as the empty host so # the address stays family-agnostic. case "$HTTP_HOST" in 0.0.0.0|::|'*'|all|any) HTTP_HOST="" ;; \[*\]) HTTP_HOST=${HTTP_HOST#[}; HTTP_HOST=${HTTP_HOST%]} ;; esac case "$HTTP_HOST" in *[!A-Za-z0-9.:_-]*) fail "invalid listen address: $HTTP_HOST" ;; esac if [ -n "$HTTP_HOST" ] && ! host_has_address "$HTTP_HOST"; then warn "no interface on this host has the address $HTTP_HOST" warn "the control plane will fail to start unless it appears before then" fi HTTP_ADDR=$(join_addr "$HTTP_HOST" "$HTTP_PORT") # Where this machine can reach its own control plane. Loopback works # for a wildcard bind, but not for one pinned to another address. API_HOST=${HTTP_HOST:-127.0.0.1} _api_authority=$(join_addr "$API_HOST" "$HTTP_PORT") _url_inherited=0 if [ -z "$PUBLIC_URL" ]; then if [ -n "$_old_cors" ]; then PUBLIC_URL=$_old_cors _url_inherited=1 else # A bind address is the one address the UI is certainly # reachable on, so it beats the hostname as a suggestion. _url_default="http://$HOSTNAME_S:$HTTP_PORT" [ -n "$HTTP_HOST" ] && _url_default="http://$_api_authority" say "where browsers will reach Runix; used for the CORS allow-list" ask PUBLIC_URL "public URL" "$_url_default" fi fi # The public URL is deliberately not derived from the listen address: # behind a reverse proxy the two are unrelated, and rewriting it would # break exactly the deployments that care. But a moved listener with # an inherited URL is usually an oversight, and the symptom — a UI # that loads and then fails every request on CORS — points nowhere # near the cause, so say so now. if [ "$_url_inherited" -eq 1 ] && [ -n "$_old_addr" ] && [ "$_old_addr" != "$HTTP_ADDR" ]; then warn "the listen address moved from $_old_addr to $HTTP_ADDR, but the" warn "public URL is still $PUBLIC_URL — pass --public-url if it changed too" fi # Only offered on a first install: on an upgrade the stored password # is reused and must not be changed behind the operator's back. if [ -z "$ADMIN_PASSWORD" ] && [ "$HAVE_SERVER" -eq 0 ] && [ "$INTERACTIVE" -eq 1 ]; then if ! confirm "Generate the initial admin password for me?"; then PW_AGAIN="" while :; do ask_secret ADMIN_PASSWORD "admin password (min 12 chars)" ask_secret PW_AGAIN "repeat it" if [ "$ADMIN_PASSWORD" != "$PW_AGAIN" ]; then echo " they do not match" elif [ "${#ADMIN_PASSWORD}" -lt 12 ]; then echo " too short" else break fi done fi fi fi if [ "$WANT_AGENT" -eq 1 ] && [ "$WANT_SERVER" -eq 0 ]; then [ -n "$SERVER_URL" ] || SERVER_URL=$(read_env "$AGENT_ENV" RUNIX_AGENT_SERVER_URL) [ -n "$TOKEN" ] || TOKEN=$(read_env "$AGENT_ENV" RUNIX_AGENT_TOKEN) if [ -z "$SERVER_URL" ] || [ -z "$TOKEN" ]; then head2 "Join a control plane" fi if [ -z "$SERVER_URL" ]; then [ "$INTERACTIVE" -eq 1 ] || fail "the control-plane url is required (pass --url)" ask SERVER_URL "control-plane URL" "" fi [ -n "$SERVER_URL" ] || fail "the control-plane url is required" case "$SERVER_URL" in http://*|https://*|ws://*|wss://*) ;; *) fail "the URL must start with http(s):// or ws(s)://" ;; esac if [ -z "$TOKEN" ]; then [ "$INTERACTIVE" -eq 1 ] || fail "an enrollment token is required (pass --token)" say "from the UI: Servers → Add server" ask TOKEN "enrollment token" "" fi [ -n "$TOKEN" ] || fail "an enrollment token is required" fi # An already-supervised daemon tree must keep its path, so an existing # data directory always wins over the new default. if [ "$WANT_AGENT" -eq 1 ]; then [ -n "$DATA_DIR" ] || DATA_DIR=$(read_env "$AGENT_ENV" RUNIX_AGENT_DATA_DIR) [ -n "$DATA_DIR" ] || DATA_DIR="$PREFIX/agent" fi # ------------------------------------------------------------------ summary head2 "Ready to install" echo " install root $PREFIX" case "$ROLE" in all-in-one) echo " role control plane + agent" ;; server) echo " role control plane" ;; agent) echo " role agent" ;; esac if [ "$WANT_SERVER" -eq 1 ]; then if [ "$DB_MODE" = docker ]; then echo " database PostgreSQL in Docker, 127.0.0.1:$PG_PORT" else echo " database existing server" fi if [ -n "$HTTP_HOST" ]; then echo " listen $HTTP_ADDR" else echo " listen $HTTP_ADDR (every interface)" fi echo " public URL $PUBLIC_URL" if [ "$HAVE_SERVER" -eq 1 ]; then echo " admin password unchanged" elif [ -n "$ADMIN_PASSWORD" ]; then echo " admin password (the one you entered)" else echo " admin password generated, shown at the end" fi fi if [ "$WANT_AGENT" -eq 1 ]; then [ "$WANT_SERVER" -eq 0 ] && echo " control plane $SERVER_URL" echo " agent state $DATA_DIR" fi echo confirm "Proceed?" || { echo " cancelled"; exit 0; } # ------------------------------------------------------------------ download WORKDIR=$(mktemp -d) # shellcheck disable=SC2064 # expand WORKDIR now, not at trap time trap "rm -rf '$WORKDIR'" EXIT # fetch URL DEST [ACCEPT] — curl or wget, carrying the token when set. # Returns non-zero on failure rather than exiting, so callers can decide. fetch() { _url=$1; _dest=$2; _accept=${3:-} # "token " is accepted by both Gitea and GitHub. if command -v curl >/dev/null 2>&1; then set -- -fsSL -o "$_dest" if [ -n "$DL_TOKEN" ]; then set -- "$@" -H "Authorization: token $DL_TOKEN" fi if [ -n "$_accept" ]; then set -- "$@" -H "Accept: $_accept" fi curl "$@" "$_url" else set -- -qO "$_dest" if [ -n "$DL_TOKEN" ]; then set -- "$@" --header="Authorization: token $DL_TOKEN" fi if [ -n "$_accept" ]; then set -- "$@" --header="Accept: $_accept" fi wget "$@" "$_url" fi } # redirect_of URL — the Location a URL redirects to, or empty. Used to read # the latest tag from the web "/releases/latest" redirect, which Gitea # serves without sign-in even when its API does not. redirect_of() { if command -v curl >/dev/null 2>&1; then curl -s -o /dev/null -w '%{redirect_url}' "$1" else wget -S --max-redirect=0 -O /dev/null "$1" 2>&1 \ | sed -n 's/^[[:space:]]*Location:[[:space:]]*//p' | head -n1 fi } # api_base is the releases API root for the configured host. api_base() { if [ -n "$GITEA" ]; then echo "$GITEA/api/v1"; else echo "https://api.github.com"; fi } release_api() { if [ "$VERSION" = latest ]; then echo "$(api_base)/repos/$REPO/releases/latest" else echo "$(api_base)/repos/$REPO/releases/tags/$VERSION" fi } # resolve_tag sets RESOLVED_TAG to the concrete tag to download from. Gitea # has no "/latest/download/" shortcut like GitHub, so "latest" is resolved # once and remembered. The web redirect is tried first because it needs no # sign-in; the API is the fallback (and the only path on GitHub). RESOLVED_TAG="" resolve_tag() { if [ "$VERSION" != latest ]; then RESOLVED_TAG="$VERSION"; return 0; fi [ -n "$RESOLVED_TAG" ] && return 0 # Object storage serves no API, so the newest version is named in a # small manifest published beside the binaries. if [ -n "$S3_BASE" ]; then _man=$(mktemp) if fetch "$S3_BASE/latest.json" "$_man" "application/json"; then RESOLVED_TAG=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$_man" | head -n1) fi rm -f "$_man" [ -n "$RESOLVED_TAG" ] && return 0 fi if [ -n "$GITEA" ]; then _loc=$(redirect_of "$DOWNLOAD_BASE/latest" 2>/dev/null || true) RESOLVED_TAG=$(printf '%s' "$_loc" | sed -n 's#.*/releases/tag/##p') fi if [ -z "$RESOLVED_TAG" ]; then _meta=$(mktemp) if fetch "$(release_api)" "$_meta" "application/json"; then RESOLVED_TAG=$(sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' "$_meta" | head -n1) fi rm -f "$_meta" fi [ -n "$RESOLVED_TAG" ] } # asset_id NAME — the numeric id of a release asset, or empty. Only needed # for private repositories, whose assets are downloadable solely through # the API. Splitting on '{' puts each asset's id and name on one line # (GitHub emits "name" before the nested "uploader" object), which avoids # depending on jq being installed. asset_id() { _name=$1 _meta=$(mktemp) if ! fetch "$(release_api)" "$_meta" "application/vnd.github+json"; then rm -f "$_meta" return 1 fi if command -v jq >/dev/null 2>&1; then _id=$(jq -r --arg n "$_name" '.assets[] | select(.name==$n) | .id' < "$_meta" | head -n1) else _id=$(tr '{' '\n' < "$_meta" \ | grep "\"name\": *\"$_name\"" \ | head -n1 \ | sed -n 's/.*"id": *\([0-9][0-9]*\).*/\1/p') fi rm -f "$_meta" [ -n "$_id" ] || return 1 echo "$_id" } # try_download NAME DEST — quiet, returns non-zero if the asset is absent. try_download() { # Distinct variable names: POSIX sh has no locals, and resolve_tag # below calls fetch, which would otherwise overwrite the _dest we were # handed — sending the binary to the manifest's temp file instead of # where the caller expects it. _tdname=$1; _tddest=$2 if [ -n "$S3_BASE" ]; then resolve_tag || return 1 fetch "$S3_BASE/$RESOLVED_TAG/$_tdname" "$_tddest" || return 1 elif [ -n "$GITEA" ]; then # Gitea assets download by tag; the token (if any) rides in fetch. resolve_tag || return 1 fetch "$DOWNLOAD_BASE/download/$RESOLVED_TAG/$_tdname" "$_tddest" || return 1 elif [ -n "$DL_TOKEN" ]; then _id=$(asset_id "$_tdname") || return 1 fetch "https://api.github.com/repos/$REPO/releases/assets/$_id" \ "$_tddest" "application/octet-stream" || return 1 elif [ "$VERSION" = latest ]; then fetch "$DOWNLOAD_BASE/latest/download/$_tdname" "$_tddest" || return 1 else fetch "$DOWNLOAD_BASE/download/$VERSION/$_tdname" "$_tddest" || return 1 fi } SUMS_FETCHED=0 verify_checksum() { # Distinct variable names: POSIX sh has no locals, and the # try_download call below reuses _name/_dest, which would otherwise # clobber the asset name mid-function and skip the first binary's check. _vfile=$1; _vname=$2 if ! command -v sha256sum >/dev/null 2>&1; then warn "sha256sum not available; skipping checksum verification" return 0 fi if [ "$SUMS_FETCHED" -eq 0 ]; then SUMS_FETCHED=1 try_download SHA256SUMS "$WORKDIR/SHA256SUMS" \ || warn "release publishes no SHA256SUMS; skipping verification" fi [ -f "$WORKDIR/SHA256SUMS" ] || return 0 # The release file lists names as "./"; accept either form. _want=$(sed -n "s|^\([0-9a-f]\{64\}\) \.\{0,1\}/\{0,1\}$_vname\$|\1|p" \ "$WORKDIR/SHA256SUMS" | head -n1) if [ -z "$_want" ]; then warn "no checksum listed for $_vname; skipping verification" return 0 fi _got=$(sha256sum "$_vfile" | cut -d' ' -f1) [ "$_want" = "$_got" ] || fail "checksum mismatch for $_vname expected $_want got $_got" ok "$_vname verified" } # install_binary NAME LOCAL_PATH install_binary() { _bin=$1; _local=${2:-} if [ -n "$_local" ]; then [ -f "$_local" ] || fail "binary not found: $_local" cp "$_local" "$WORKDIR/$_bin" ok "installing $_local" else _asset="${_bin}_linux_${ARCH}" say "downloading $_asset" if ! try_download "$_asset" "$WORKDIR/$_bin"; then fail "could not download $_asset from $REPO ($VERSION) if the instance requires sign-in, pass --repo-token; otherwise check the release exists, or pass --server-binary / --agent-binary" fi verify_checksum "$WORKDIR/$_bin" "$_asset" fi chmod 0755 "$WORKDIR/$_bin" mkdir -p "$BIN_DIR" # Replace via rename so a running binary is never written in place # (that would fail with ETXTBSY). mv "$WORKDIR/$_bin" "$BIN_DIR/$_bin.new" mv "$BIN_DIR/$_bin.new" "$BIN_DIR/$_bin" ok "installed $BIN_DIR/$_bin" } # ------------------------------------------------------------------ postgres provision_postgres() { docker info >/dev/null 2>&1 || fail "the docker daemon is not reachable (is it running?)" mkdir -p "$PG_DIR/data" chmod 0700 "$PG_DIR" # Keep the password across re-runs: the existing data directory would # not accept a new one. PG_PASSWORD=$(read_env "$PG_DIR/.env" POSTGRES_PASSWORD) if [ -n "$PG_PASSWORD" ]; then say "reusing the existing database password" else PG_PASSWORD=$(random_secret) fi if [ ! -f "$PG_DIR/.env" ]; then umask 077 cat > "$PG_DIR/.env" < "$PG_DIR/docker-compose.yml" <<'EOF' # Written by install.sh — re-running the installer rewrites this file, so # keep customisations in .env beside it where possible. services: postgres: image: ${POSTGRES_IMAGE} container_name: ${POSTGRES_CONTAINER} restart: unless-stopped environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # One level down, so a lost+found on a mounted volume cannot make # initdb refuse to start. PGDATA: /var/lib/postgresql/data/pgdata # Bound to loopback: the control plane is the only client, and an # internet-exposed database is how these installs get breached. ports: - "127.0.0.1:${POSTGRES_PORT}:5432" volumes: - ./data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 20 start_period: 10s EOF say "starting PostgreSQL ($PG_IMAGE) on 127.0.0.1:$PG_PORT" ( cd "$PG_DIR" && $COMPOSE up -d ) || fail "could not start the PostgreSQL container" say "waiting for the database to accept connections" _i=0 while [ "$_i" -lt 60 ]; do if docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then ok "database ready" break fi _i=$((_i + 1)) sleep 2 done if [ "$_i" -ge 60 ]; then echo "[runix] the database did not become ready; recent container logs:" >&2 ( cd "$PG_DIR" && $COMPOSE logs --tail 30 ) >&2 || true fail "PostgreSQL did not start" fi DSN="postgres://$PG_USER:$PG_PASSWORD@127.0.0.1:$PG_PORT/$PG_DB?sslmode=disable" } # -------------------------------------------------------------- control plane GENERATED_PASSWORD="" install_server() { head2 "Installing the control plane" if ! id "$SERVER_USER" >/dev/null 2>&1; then useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVER_USER" \ || fail "could not create user $SERVER_USER" ok "created service user $SERVER_USER" fi mkdir -p "$PREFIX" install_binary runix-server "$SERVER_BIN" [ "$DB_MODE" = docker ] && provision_postgres [ -n "$DSN" ] || fail "no database configured" mkdir -p "$CONFIG_DIR" umask 077 # Preserve secrets across upgrades: rotating them logs everyone out # and orphans encrypted TOTP secrets. _jwt=$(read_env "$SERVER_ENV" RUNIX_JWT_SECRET) _enc=$(read_env "$SERVER_ENV" RUNIX_ENCRYPTION_KEY) [ -n "$_jwt" ] || _jwt=$(random_secret) [ -n "$_enc" ] || _enc=$(random_secret) if [ -z "$ADMIN_PASSWORD" ]; then ADMIN_PASSWORD=$(read_env "$SERVER_ENV" RUNIX_ADMIN_PASSWORD) if [ -z "$ADMIN_PASSWORD" ]; then ADMIN_PASSWORD=$(random_secret | cut -c1-20) GENERATED_PASSWORD=$ADMIN_PASSWORD fi fi if [ ! -f "$SERVER_ENV" ]; then { echo "# Written by install.sh. Contains secrets: keep 0600." echo "RUNIX_ENV=production" echo "RUNIX_HTTP_ADDR=$HTTP_ADDR" echo "RUNIX_DATABASE_DSN=$DSN" echo "RUNIX_JWT_SECRET=$_jwt" echo "RUNIX_ENCRYPTION_KEY=$_enc" echo "RUNIX_ADMIN_PASSWORD=$ADMIN_PASSWORD" echo "RUNIX_LOG_FORMAT=json" if [ -n "$PUBLIC_URL" ]; then echo "RUNIX_CORS_ORIGINS=$PUBLIC_URL" fi } > "$SERVER_ENV" chown "$SERVER_USER" "$SERVER_ENV" chmod 0600 "$SERVER_ENV" ok "created $SERVER_ENV" else # Edited key by key rather than rewritten. Every value here has # already fallen back to what the file holds, so an upgrade with # no flags changes nothing, while --port or --dsn changes exactly # what was asked for and leaves the rest — including settings the # operator added by hand — where it is. env_begin env_set "$SERVER_ENV" RUNIX_HTTP_ADDR "$HTTP_ADDR" env_set "$SERVER_ENV" RUNIX_DATABASE_DSN "$DSN" env_set "$SERVER_ENV" RUNIX_JWT_SECRET "$_jwt" env_set "$SERVER_ENV" RUNIX_ENCRYPTION_KEY "$_enc" env_set "$SERVER_ENV" RUNIX_ADMIN_PASSWORD "$ADMIN_PASSWORD" env_set "$SERVER_ENV" RUNIX_CORS_ORIGINS "$PUBLIC_URL" env_report "$SERVER_ENV" fi if [ "$HAS_SYSTEMD" -eq 1 ]; then # With the database in Docker the unit must wait for the daemon, # or the control plane races the container on boot. _after="network-online.target" [ "$DB_MODE" = docker ] && _after="$_after docker.service" cat > /etc/systemd/system/runix-server.service </dev/null 2>&1 || true ok "wrote /etc/systemd/system/runix-server.service" if [ "$NO_START" -eq 0 ]; then systemctl restart runix-server sleep 2 if systemctl is-active --quiet runix-server; then ok "control plane running on $HTTP_ADDR" else echo "[runix] the service failed to start; recent logs:" >&2 journalctl -u runix-server -n 20 --no-pager >&2 || true fail "the control plane did not start" fi fi fi } # --------------------------------------------------------------------- agent install_agent() { head2 "Installing the agent" mkdir -p "$PREFIX" install_binary runix-agent "$AGENT_BIN" mkdir -p "$CONFIG_DIR" if [ ! -f "$AGENT_ENV" ]; then umask 077 cat > "$AGENT_ENV" </dev/null 2>&1 || fail "user does not exist: $AGENT_USER" chown -R "$AGENT_USER" "$DATA_DIR" "$CONFIG_DIR" fi if [ "$HAS_SYSTEMD" -eq 1 ]; then cat > /etc/systemd/system/runix-agent.service </dev/null 2>&1 || true ok "wrote /etc/systemd/system/runix-agent.service" if [ "$NO_START" -eq 0 ]; then systemctl restart runix-agent sleep 1 if systemctl is-active --quiet runix-agent; then ok "agent running" else echo "[runix] the service failed to start; recent logs:" >&2 journalctl -u runix-agent -n 20 --no-pager >&2 || true fail "the agent did not start" fi fi fi } # ---------------------------------------------------------------- enrollment # api_post PATH JSON_FILE OUT [BEARER] — returns non-zero on any error. api_post() { _p=$1; _body=$2; _out=$3; _bearer=${4:-} if command -v curl >/dev/null 2>&1; then set -- -fsS -X POST "$_p" -H 'Content-Type: application/json' \ --data-binary "@$_body" -o "$_out" [ -n "$_bearer" ] && set -- "$@" -H "Authorization: Bearer $_bearer" curl "$@" >/dev/null 2>&1 else set -- -q -O "$_out" --header='Content-Type: application/json' \ --post-file="$_body" [ -n "$_bearer" ] && set -- "$@" --header="Authorization: Bearer $_bearer" wget "$@" "$_p" >/dev/null 2>&1 fi } # For an all-in-one host the agent talks to the control plane over # loopback, and its enrollment token is minted here so the operator never # has to copy one by hand. enroll_self() { head2 "Enrolling this host with its own control plane" # Loopback when the listener is on every interface; otherwise the one # address it is actually bound to, since 127.0.0.1 would not answer. SERVER_URL="http://$_api_authority" _api="$SERVER_URL/api/v1" _existing_token=$(read_env "$AGENT_ENV" RUNIX_AGENT_TOKEN) say "waiting for the control plane to answer" _i=0 while [ "$_i" -lt 30 ]; do if fetch "$_api/health" "$WORKDIR/health" 2>/dev/null; then break fi _i=$((_i + 1)) sleep 1 done keep_or_fail() { # Fall back to whatever token this host already had. It may be # stale, in which case the agent will report 401 and the operator # can re-pair from the UI — but never silently drop a working one. if [ -n "$_existing_token" ]; then TOKEN=$_existing_token warn "keeping the token already on this host; if the agent cannot" warn "connect, rotate it from the UI: Servers → this host → Rotate token" return 0 fi return 1 } if [ -z "$ADMIN_PASSWORD" ]; then warn "the admin password is unknown, so this host cannot enroll itself" keep_or_fail && return 0 warn "add it from the UI: Servers → Add server" return 1 fi printf '{"identifier":"admin","password":"%s"}' "$ADMIN_PASSWORD" > "$WORKDIR/login.json" _access="" if api_post "$_api/auth/login" "$WORKDIR/login.json" "$WORKDIR/login.out"; then _access=$(tr ',' '\n' < "$WORKDIR/login.out" \ | sed -n 's/.*"accessToken":"\([^"]*\)".*/\1/p' | head -n1) fi if [ -z "$_access" ]; then warn "could not sign in to the control plane; skipping auto-enrollment" keep_or_fail && return 0 warn "add this host from the UI: Servers → Add server" return 1 fi # A token already in agent.env proves nothing on its own: it may # belong to a control plane this host no longer talks to, or to a # database that has since been recreated. So look this host up on # *this* control plane and decide from what is actually there. # Not fetch(): that helper carries the GitHub token, not the API one. _sid="" if command -v curl >/dev/null 2>&1; then curl -fsS -H "Authorization: Bearer $_access" "$_api/servers" \ -o "$WORKDIR/servers.out" >/dev/null 2>&1 || true else wget -q -O "$WORKDIR/servers.out" \ --header="Authorization: Bearer $_access" "$_api/servers" >/dev/null 2>&1 || true fi if [ -f "$WORKDIR/servers.out" ]; then # Each server object becomes one line, with "id" ahead of "name". _sid=$(tr '{' '\n' < "$WORKDIR/servers.out" \ | grep "\"name\": *\"$HOSTNAME_S\"" \ | head -n1 \ | sed -n 's/.*"id": *"\([^"]*\)".*/\1/p') fi # Nothing to do when this host is already paired with this control # plane: re-minting a working credential on every upgrade would churn # it for no reason. "Already paired" means a record exists here *and* # the agent is configured to talk to this control plane — the stale # case that motivated all this is precisely a token pointing # somewhere else. if [ -n "$_sid" ] && [ -n "$_existing_token" ] \ && [ "$(read_env "$AGENT_ENV" RUNIX_AGENT_SERVER_URL)" = "$SERVER_URL" ]; then TOKEN=$_existing_token ok "this host is already paired with this control plane — keeping its token" return 0 fi TOKEN="rnx_agt_$(random_secret)" if [ -n "$_sid" ]; then printf '{"agentToken":"%s"}' "$TOKEN" > "$WORKDIR/rotate.json" if api_post "$_api/servers/$_sid/token/rotate" "$WORKDIR/rotate.json" \ "$WORKDIR/rotate.out" "$_access"; then ok "re-paired the existing \"$HOSTNAME_S\" record with a fresh token" return 0 fi warn "could not rotate the token for \"$HOSTNAME_S\"" keep_or_fail && return 0 return 1 fi printf '{"name":"%s","address":"%s","description":"Installed by install.sh","agentToken":"%s"}' \ "$HOSTNAME_S" "$API_HOST" "$TOKEN" > "$WORKDIR/server.json" if api_post "$_api/servers" "$WORKDIR/server.json" "$WORKDIR/server.out" "$_access"; then ok "registered \"$HOSTNAME_S\" and minted its enrollment token" return 0 fi warn "could not register this host automatically" keep_or_fail && return 0 warn "add it from the UI: Servers → Add server" return 1 } # --------------------------------------------------------------------- run [ "$WANT_SERVER" -eq 1 ] && install_server if [ "$ROLE" = all-in-one ] && [ "$NO_START" -eq 0 ]; then enroll_self || WANT_AGENT=0 elif [ "$ROLE" = all-in-one ]; then warn "--no-start given, so this host was not enrolled; add it from the UI" WANT_AGENT=0 fi [ "$WANT_AGENT" -eq 1 ] && install_agent # -------------------------------------------------------------------- done head2 "Done" if [ "$WANT_SERVER" -eq 1 ]; then echo " UI / API $PUBLIC_URL" [ -n "$HTTP_HOST" ] && echo " listening on $HTTP_ADDR" echo " username admin" if [ -n "$GENERATED_PASSWORD" ]; then echo " password ${C_B}$GENERATED_PASSWORD${C_0} ${C_DIM}(change at first login)${C_0}" elif [ "$HAVE_SERVER" -eq 1 ]; then echo " password unchanged" else echo " password the one you entered" fi echo " config $SERVER_ENV" [ "$DB_MODE" = docker ] && echo " database $PG_DIR" fi if [ "$WANT_AGENT" -eq 1 ]; then echo " agent config $AGENT_ENV" echo " agent state $DATA_DIR" fi if [ "$HAS_SYSTEMD" -eq 1 ]; then if [ "$NO_START" -eq 1 ]; then echo say "installed but not started: systemctl start runix-server runix-agent" else echo say "systemctl status runix-server runix-agent" fi else echo say "no systemd here; run the binaries yourself, for example:" [ "$WANT_SERVER" -eq 1 ] && say " env \$(grep -v '^#' $SERVER_ENV | xargs) $BIN_DIR/runix-server" [ "$WANT_AGENT" -eq 1 ] && say " env \$(grep -v '^#' $AGENT_ENV | xargs) $BIN_DIR/runix-agent" fi if [ "$ROLE" = server ]; then echo echo " Add hosts from the UI (Servers → Add server), then run on each:" if [ -n "$S3_BASE" ]; then say " curl -fsSL $S3_BASE/install.sh | sudo sh -s -- \\" elif [ -n "$GITEA" ]; then say " curl -fsSL $GITEA/$REPO/raw/branch/main/install.sh | sudo sh -s -- \\" else say " curl -fsSL $DOWNLOAD_BASE/latest/download/install.sh | sudo sh -s -- \\" fi say " --role agent --url $PUBLIC_URL --token " fi echo