Files
runix/install.sh
T

1387 lines
49 KiB
Bash
Executable File

#!/bin/sh
# Runix installer — the one command that sets a host up.
#
# curl -fsSL https://vcs.astra-dev.online/svesnav/runix/raw/branch/main/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 a Gitea instance by default. Point GITEA at a
# GitHub-style host by clearing it (RUNIX_GITEA=) and setting a download
# base, if you mirror the release elsewhere.
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 <<EOF
Runix installer
Run with no arguments to be asked what you want. Every answer also has a
flag, so the same script works unattended.
Options:
--role ROLE all-in-one | server | agent
--db MODE docker | existing (control-plane roles)
--dsn DSN PostgreSQL DSN (implies --db existing)
--pg-port PORT Port for the provisioned database (default 5432)
--pg-image IMAGE PostgreSQL image (default $PG_IMAGE)
--bind ADDR IP the control plane listens on (default: all)
--port PORT Control-plane HTTP port (default 8080)
--public-url URL Where browsers reach the UI (sets CORS)
--admin-password PW Initial admin password (default: generated)
--url URL Control-plane URL (agent role)
--token TOKEN Enrollment token (agent role)
--data-dir PATH Agent state directory (default \$PREFIX/agent)
--server-user USER Control-plane service user (default $SERVER_USER)
--agent-user USER Agent service user (default $AGENT_USER)
--server-binary PATH Install a local control-plane build
--agent-binary PATH Install a local agent build
--version VERSION Release to install (default: latest)
--repo OWNER/NAME Release repository (default: $REPO)
--gitea URL Gitea instance serving releases (default: $GITEA)
--repo-token TOK Read token, if the instance requires sign-in
--prefix PATH Install root (default: $PREFIX)
--no-start Install and configure, but do not start services
-y, --yes Take defaults, do not ask
-h, --help Show this help
Environment equivalents: RUNIX_PREFIX, RUNIX_VERSION, RUNIX_REPO,
RUNIX_GITEA, RUNIX_TOKEN, RUNIX_DATABASE_DSN, RUNIX_ADMIN_PASSWORD,
RUNIX_HTTP_HOST, RUNIX_AGENT_SERVER_URL, RUNIX_AGENT_TOKEN,
RUNIX_POSTGRES_PORT.
Re-running the installer upgrades in place. Existing configuration is
kept, and a value you pass on the command line replaces just that one
setting, so any lines you added yourself survive the upgrade.
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--role) ROLE="$2"; shift 2 ;;
--db) DB_MODE="$2"; shift 2 ;;
--dsn) DSN="$2"; DB_MODE=existing; shift 2 ;;
--pg-port) PG_PORT="$2"; shift 2 ;;
--pg-image) PG_IMAGE="$2"; shift 2 ;;
--bind|--host|--listen) HTTP_HOST="$2"; shift 2 ;;
--port) HTTP_PORT="$2"; shift 2 ;;
--public-url) PUBLIC_URL="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--url) SERVER_URL="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--data-dir) DATA_DIR="$2"; shift 2 ;;
--server-user) SERVER_USER="$2"; shift 2 ;;
--agent-user) AGENT_USER="$2"; shift 2 ;;
--server-binary) SERVER_BIN="$2"; shift 2 ;;
--agent-binary) AGENT_BIN="$2"; shift 2 ;;
--version) VERSION="$2"; shift 2 ;;
--repo) REPO="$2"; shift 2 ;;
--gitea) GITEA="$2"; shift 2 ;;
--repo-token|--github-token) DL_TOKEN="$2"; shift 2 ;;
--prefix) PREFIX="$2"; shift 2 ;;
--no-start) NO_START=1; shift ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
if [ -z "$DOWNLOAD_BASE" ]; then
if [ -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/tty) >/dev/null 2>&1; then
exec </dev/tty
else
INTERACTIVE=0
fi
fi
# -y means unattended: take the default for anything not given on the
# command line rather than stopping to ask. Values with no safe default
# (the role, an agent's URL and token) still fail loudly.
if [ "$ASSUME_YES" -eq 1 ]; then
INTERACTIVE=0
fi
ask() {
# ask VARNAME "question" "default"
_var=$1; _q=$2; _def=${3:-}
if [ "$INTERACTIVE" -eq 0 ]; then
eval "$_var=\$_def"
return
fi
if [ -n "$_def" ]; then
printf ' %s [%s]: ' "$_q" "$_def"
else
printf ' %s: ' "$_q"
fi
read -r _ans || _ans=""
[ -n "$_ans" ] || _ans=$_def
eval "$_var=\$_ans"
}
ask_secret() {
_var=$1; _q=$2
if [ "$INTERACTIVE" -eq 0 ]; then
eval "$_var=''"
return
fi
printf ' %s: ' "$_q"
if command -v stty >/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 <t>" 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
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() {
_name=$1; _dest=$2
if [ -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/$_name" "$_dest" || return 1
elif [ -n "$DL_TOKEN" ]; then
_id=$(asset_id "$_name") || return 1
fetch "https://api.github.com/repos/$REPO/releases/assets/$_id" \
"$_dest" "application/octet-stream" || return 1
elif [ "$VERSION" = latest ]; then
fetch "$DOWNLOAD_BASE/latest/download/$_name" "$_dest" || return 1
else
fetch "$DOWNLOAD_BASE/download/$VERSION/$_name" "$_dest" || 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 "./<asset>"; 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" <<EOF
# Written by install.sh. Contains the database password: keep 0600.
POSTGRES_DB=$PG_DB
POSTGRES_USER=$PG_USER
POSTGRES_PASSWORD=$PG_PASSWORD
POSTGRES_IMAGE=$PG_IMAGE
POSTGRES_PORT=$PG_PORT
POSTGRES_CONTAINER=$PG_CONTAINER
EOF
chmod 0600 "$PG_DIR/.env"
ok "created $PG_DIR/.env"
else
ok "keeping existing $PG_DIR/.env"
fi
# Quoted heredoc: ${...} must reach the file, not be expanded here.
cat > "$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 <<EOF
[Unit]
Description=Runix control plane
Documentation=${GITEA:-https://github.com}/$REPO
After=$_after
Wants=network-online.target
[Service]
Type=simple
User=$SERVER_USER
EnvironmentFile=$SERVER_ENV
ExecStart=$BIN_DIR/runix-server
Restart=always
RestartSec=5
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=$CONFIG_DIR
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable runix-server >/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" <<EOF
# Written by install.sh. Contains the agent credential: keep 0600.
RUNIX_AGENT_SERVER_URL=$SERVER_URL
RUNIX_AGENT_TOKEN=$TOKEN
RUNIX_AGENT_DATA_DIR=$DATA_DIR
RUNIX_AGENT_LOG_FORMAT=json
EOF
chmod 0600 "$AGENT_ENV"
ok "created $AGENT_ENV"
else
# Leaving the file completely alone would strand the agent: a
# token passed on the command line, or one this installer just
# minted while enrolling the host, has to reach it or the agent
# keeps presenting a credential the control plane has retired.
# So update the keys that are ours and nothing else.
env_begin
env_set "$AGENT_ENV" RUNIX_AGENT_SERVER_URL "$SERVER_URL"
env_set "$AGENT_ENV" RUNIX_AGENT_TOKEN "$TOKEN"
env_set "$AGENT_ENV" RUNIX_AGENT_DATA_DIR "$DATA_DIR"
env_report "$AGENT_ENV"
fi
mkdir -p "$DATA_DIR"
if [ "$AGENT_USER" != root ]; then
id "$AGENT_USER" >/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 <<EOF
[Unit]
Description=Runix agent
Documentation=${GITEA:-https://github.com}/$REPO
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$AGENT_USER
EnvironmentFile=$AGENT_ENV
ExecStart=$BIN_DIR/runix-agent
Restart=always
RestartSec=5
# The agent manages host workloads, so it deliberately runs unconfined;
# restrict it here if your deployment does not need every provider.
NoNewPrivileges=no
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable runix-agent >/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 "$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 <token>"
fi
echo