#!/usr/bin/env bash # SteamTradeX bot installer. # # curl -fsSL https://steamtradex.com/install.sh -o install.sh && sudo bash install.sh --code XXXX-XXXX # # That download is for the first install only. Every release carries this # file inside its signed bundle, checked like every other file of it, so once # the bot is installed the copy to run is the installed one, and nothing is # downloaded again to repair, uninstall or change the dashboard's route: # # sudo bash /opt/stx-bot/current/install.sh --repair # # One command installs the downloadable SteamTradeX bot on an Ubuntu 22.04, # 24.04 or 26.04, or Debian 12 or 13 server (x86_64 or arm64, with systemd): it swaps # the one-time install code from the Bot Script page for the licence key, # fetches the newest release, checks its signature with the release key # written into this file, checks Node and the bundle against the signed # checksums, lays the files out, asks the setup questions one at a time, # starts the bot, waits until it has signed in to Steam, and ends with a # summary (how to open the dashboard, the logs, updates, stop and start, and # uninstall) and, last of all, the dashboard password and a one-time sign in # link in a block of their own. # # Running it again is safe: it keeps the bot's settings and data, refreshes # the service files, installs a newer release if there is one, and prints # the summary again. Other forms (of the installed copy, as above): # # sudo bash install.sh --uninstall [--purge] remove it (keeps the data unless --purge) # sudo bash install.sh --repair lay the current release's files down again # sudo bash install.sh --dry-run print every action, change nothing # sudo bash install.sh --help everything it takes # # Exit status: 0 when the bot is installed and signed in to Steam (and after # an uninstall, --help, or a dry run that got to its end); 1 when the install # stopped (the line starting STOPPED says why and what to do, and the program # link is never left half switched); 2 when the bot is installed but needs # you: its setup is not finished (also when you skipped it with --skip-setup), # it stopped, or it had not signed in to Steam within 3 minutes (the # summary's first line says what to do). # # On a root login with no sudo (a minimal Debian), run it as `bash install.sh`; # run as root it never calls sudo, and its messages leave sudo out. # # The licence key is never taken from the command line or the environment # (ps and shell history see both): it comes from the install code, from the # bot's own folder on a re-run, or from a hidden question on the terminal. # # Everything this file does to the server is listed in the bundle's README. set -Eeuo pipefail umask 022 export LC_ALL=C # Raised whenever what this file writes outside the release changes (the # service units, the stx-bot command, the Caddy site). The updater compares it # with the mark below and tells the owner to run the installer once. # 3 (bot 1.0.2): the stx-bot command hands the picked Steam Guard file to # setup by itself, checks it when it is picked, and offers to delete the # uploaded files afterwards. INSTALLER_VERSION=3 # The oldest release this installer puts on a server. A first install has no # memory of what came before, so without a floor it would take any release # ever signed, however old. It is this release's own test build, 1.0.2-rc.1: # the stx-bot command below hands the Steam Guard file to setup in a way # older bots do not read, and the same installer, byte for byte, serves the # test build and the final 1.0.2 (a floor of 1.0.2 would be above its own # test build, which could then be neither built nor installed). A stable # server still never takes a test build: the channel decides that, not this. # Raised with each release (apps/bot/src/release/installer-channel.test.ts # keeps it at or below the bot's own version; the release build refuses one # above it). MIN_VERSION=1.0.2-rc.1 ORIGIN=https://steamtradex.com # The site by name, for sentences: a link is only ever written last on its # line with nothing after it (the owner's rule for everything a person reads), # so a sentence that goes on names the site, and does not link it. SITE=${ORIGIN#https://} GUIDE_URL=$ORIGIN/guides/self-host-steam-bot # Where things go (the real paths, as written into the service files). P_OPT=/opt/stx-bot P_DATA=/var/lib/stx-bot P_HOME=$P_DATA/home P_LOCKS=$P_DATA/locks P_UPDATE_STATE=/var/lib/stx-bot-update P_UPDATER_PUBLIC=/var/lib/stx-bot-updater P_WRAPPER=/usr/local/bin/stx-bot # The INSTALLER_VERSION that last wrote the service files and the command # (root's, readable): how the updater knows they are older than a release's. P_APPLIED=$P_UPDATER_PUBLIC/installer-applied # This file as the installed release carries it: listed in the signed # manifest and checked with every other file, so later runs need no download. P_SELF=$P_OPT/current/install.sh P_UNITS=/etc/systemd/system P_SUMMARY=/root/stx-bot-install-summary.txt P_CADDY_DIR=/etc/caddy P_CADDY_SNIPPET=$P_CADDY_DIR/stx-bot.caddy # Which releases this server takes, root's own and read by the updater too # (apps/bot/src/updater/release-rules.ts). P_CHANNEL: one word, test or # stable, written only by --channel. P_FLOOR: lines `min ` and # `withdrawn `, what signed manifests have said so far. P_CHANNEL=$P_UPDATE_STATE/channel P_FLOOR=$P_UPDATE_STATE/release-floor # The port Caddy proxies the dashboard's domain to: root's own word # (--dashboard-port), never read from the bot's settings.json, which the # bot's user can write. P_DASHBOARD_PORT=$P_UPDATE_STATE/dashboard-port # Written when this installer opened 80 and 443 in ufw, so only it closes them again. P_UFW_MARK=$P_UPDATE_STATE/ufw-opened-https BOT_USER=stx-bot DASHBOARD_PORT=4400 # Size caps for what is downloaded. MAX_ANSWER=$((16 * 1024 * 1024)) MAX_BUNDLE=$((300 * 1024 * 1024)) MAX_NODE=$((100 * 1024 * 1024)) MAX_UNPACKED=$((300 * 1024 * 1024)) # The release public keys, the same set the bot carries (RELEASE_KEYS in # apps/bot/src/licence/keys.ts) and the server's release tool carries. # apps/bot/src/release/installer.test.ts fails when they differ, so the key # ceremony (plan piece 11) changes them together. One entry per key: a line # `KEY `, then for a real key its SPKI PEM. The state is `signs`, # `retired` (refused for a release that has just arrived) or `placeholder` # (no real key yet: nothing can be installed). # BEGIN RELEASE KEYS RELEASE_KEYS=$(cat <<'KEYS' KEY R1 signs -----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAPFcBnsKZ5Y0FzQYSnkmCt89obcCFQc2fVL4YH3M6pDI= -----END PUBLIC KEY----- KEYS ) # END RELEASE KEYS # The licence key's alphabet (Crockford base32, as apps/bot/src/licence/key.ts). CROCKFORD=0123456789ABCDEFGHJKMNPQRSTVWXYZ # Only a test harness may move every file under a scratch folder; a real run # refuses the variable (see main). R=${STX_INSTALL_TEST_ROOT:-} DRY=0 WORK="" # How the owner's own commands start in this file's messages: "sudo " unless # this is a root login on a server with no sudo (set in main). S="sudo " # ---------------------------------------------------------------- output say() { printf '%s\n' "$*"; } step() { printf '\n==> %s\n' "$*"; } ok() { printf ' %s\n' "$*"; } warn() { printf ' NOTE: %s\n' "$*"; } # note TEXT: also repeated at the top of the final summary, so it is not lost in the scroll. NOTES="" note() { NOTES+="NOTE: $*"$'\n'; } # Stops the install: what went wrong, and what to do about it. stop() { printf '\nSTOPPED: %s\n' "$1" >&2 [ $# -ge 2 ] && printf 'What to do: %s\n' "$2" >&2 exit 1 } # Anything that fails without its own message (set -e) still says where it # stopped and that running again is safe, instead of ending in silence. on_unexpected_error() { printf '\nSTOPPED: a step failed unexpectedly (install.sh line %s, after the last step shown above).\n' "$1" >&2 printf 'What to do: run the same command again; it picks up where it is safe to. If it stops here again, send the lines above to SteamTradeX support.\n' >&2 exit 1 } # again: how this installer is run again, for the messages. Once a release # that carries it is installed, that is the installed copy (nothing is # downloaded again); before that, the file the owner downloaded. again() { if [ -f "$R$P_SELF" ] && [ ! -L "$R$P_SELF" ]; then printf '%sbash %s' "$S" "$P_SELF"; else printf '%sbash install.sh' "$S"; fi } # What to do when a release cannot be read with the keys and rules in this # file. Never "download the installer again" on its own: whoever answers for # the website chooses these answers, and an installer fetched on that advice # and run unchecked would undo the point of carrying a signed copy. WAIT_ADVICE="Nothing was installed, and a bot that is installed keeps running. Try again later. Only if SteamTradeX has announced a new release key or installer: download it, check its signature as the README says (Check the installer before the first install), and only then run it." # quiet COMMAND...: runs it with its own output hidden (the step's line says what happened). quiet() { "$@" >/dev/null 2>&1; } # act WHAT COMMAND...: an action that changes the server. In a dry run it is # printed instead (on the terminal, so no redirect can hide it); for real, a # failure stops the install and names the action. act() { local what=$1 shift if [ "$DRY" = 1 ]; then [ "${1:-}" = quiet ] && shift printf ' [dry run] %s: %s\n' "$what" "$*" return 0 fi "$@" && return 0 stop "Could not $what" "Run the same command again; if it stops here again, send the lines above to SteamTradeX support" } # act_try WHAT COMMAND...: as act, but a failure is the caller's to handle (returns its status). act_try() { local what=$1 shift if [ "$DRY" = 1 ]; then [ "${1:-}" = quiet ] && shift printf ' [dry run] %s: %s\n' "$what" "$*" return 0 fi "$@" } # Writes standard input to a file (atomically, mode and owner set before it # appears). In a dry run the text is printed instead. put_file() { local path=$1 mode=$2 owner=$3 tmp if [ "$DRY" = 1 ]; then printf ' [dry run] write %s (%s, %s):\n' "$path" "$mode" "$owner" sed 's/^/ | /' return 0 fi tmp=$(mktemp "$R$path.XXXXXX") cat > "$tmp" chmod "$mode" "$tmp" [ -n "$R" ] || chown "$owner" "$tmp" mv -f -- "$tmp" "$R$path" } # ---------------------------------------------------------------- questions # True when there is a terminal to ask on (also when this file was piped in). have_tty() { [ -z "$R" ] && { : /dev/null; } # ask "Question" default: the answer, or the default with no terminal or in a dry run. ask() { local question=$1 default=${2:-} answer="" if [ "$DRY" = 1 ] || ! have_tty; then printf '%s\n' "$default" return 0 fi printf '%s ' "$question" >/dev/tty IFS= read -r answer /dev/null || true) line=${line#*=} line=${line#\"} line=${line%\"} printf '%s' "$line" } check_system() { step "Checking this server" local id ver arch mem_kb free_kb id=$(os_release_value ID) ver=$(os_release_value VERSION_ID) if supported_os "$id" "$ver"; then ok "System: $(os_release_value PRETTY_NAME)" elif [ "$A_FORCE" = 1 ]; then warn "$(os_release_value PRETTY_NAME) is not supported (Ubuntu 22.04, 24.04 or 26.04, Debian 12 or 13 are); going on because of --force-unsupported" else stop "This server runs ${id:-an unknown system} ${ver}. The bot is supported on Ubuntu 22.04, 24.04 and 26.04, and Debian 12 and 13" \ "Use one of those (a fresh Ubuntu 24.04 is the easiest), or add --force-unsupported to try anyway" fi arch=$(node_arch "$(uname -m)") [ -n "$arch" ] || stop "This server's processor is $(uname -m); the bot runs on x86_64 and arm64 (aarch64)" NODE_ARCH=$arch ok "Processor: $(uname -m)" [ -d /run/systemd/system ] || stop "This server does not run systemd, which keeps the bot running" "Use a normal VPS, not a container" mem_kb=$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2>/dev/null || echo 0) if [ "${mem_kb:-0}" -lt 900000 ]; then warn "This server has $((mem_kb / 1024)) MB of memory; 1 GB is the minimum (2 GB for 3 or more bots)"; else ok "Memory: $((mem_kb / 1024)) MB"; fi free_kb=$(df -Pk / | awk 'NR == 2 { print $4 }') [ "${free_kb:-0}" -ge 1048576 ] || stop "Less than 1 GB of disk is free" "Free some space, then run this again" if [ "$free_kb" -lt 10485760 ]; then warn "$((free_kb / 1048576)) GB of disk is free; 10 GB is recommended"; else ok "Free disk: $((free_kb / 1048576)) GB"; fi local missing=() for tool in curl tar gzip sha256sum base64 perl runuser setsid getent useradd systemctl journalctl; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool") done [ ${#missing[@]} -eq 0 ] || stop "This server lacks: ${missing[*]}" "Install them (apt-get install ${missing[*]}), then run this again" if ! openssl version 2>/dev/null | grep -q '^OpenSSL 3\.'; then act "install OpenSSL 3, which checks the release signature" apt_install openssl [ "$DRY" = 1 ] || openssl version 2>/dev/null | grep -q '^OpenSSL 3\.' || stop "OpenSSL 3 is needed to check the release signature, and it could not be installed" fi ok "Tools: all there" } apt_install() { DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null } check_network() { local code [ "$DRY" = 1 ] && { ok "[dry run] would check that $SITE and nodejs.org can be reached"; return 0; } code=$(curl -sS -o /dev/null --proto '=https' --max-time 20 -w '%{http_code}' "$ORIGIN/" 2>/dev/null || true) [ "${code:-000}" != 000 ] || stop "This server cannot reach $ORIGIN" "Check that outgoing HTTPS (port 443) is allowed by your hosting company's firewall, then run this again" code=$(curl -sS -o /dev/null --proto '=https' --max-time 20 -w '%{http_code}' https://nodejs.org/dist/index.json 2>/dev/null || true) [ "${code:-000}" != 000 ] || stop "This server cannot reach nodejs.org, where Node.js comes from" "Check that outgoing HTTPS (port 443) is allowed, then run this again" ok "Network: $SITE and nodejs.org can be reached" } # ---------------------------------------------------------------- the licence key # check_licence_key TEXT: on success sets KEY_GROUPED (STX1-XXXX-...) and # KEY_COMPACT; otherwise sets KEY_PROBLEM and returns 1. The rule of # apps/bot/src/licence/key.ts: case, spaces and dashes ignored, O read as 0, # I and L as 1, `STX1` and 28 characters whose last two are a check. check_licence_key() { local text=$1 k rest i c v sum=0 before check KEY_GROUPED="" KEY_COMPACT="" KEY_PROBLEM="" if [ -z "${text//[[:space:]]/}" ]; then KEY_PROBLEM="No licence key was given."; return 1; fi if [ ${#text} -gt 200 ]; then KEY_PROBLEM="That licence key is not the right length. Copy it again from your SteamTradeX dashboard."; return 1; fi k=$(printf '%s' "$text" | tr 'a-z' 'A-Z') k=${k//[[:space:]]/} k=${k//-/} k=${k//O/0} k=${k//I/1} k=${k//L/1} if [ "${k:0:4}" != STX1 ]; then KEY_PROBLEM="A licence key starts with STX1. Copy it again from your SteamTradeX dashboard."; return 1; fi rest=${k:4} if [ ${#rest} -ne 28 ]; then KEY_PROBLEM="That licence key is not the right length. Copy it again from your SteamTradeX dashboard."; return 1; fi if [[ ! $rest =~ ^[0-9A-HJKMNP-TV-Z]{28}$ ]]; then KEY_PROBLEM="That licence key has a character a key never has. Copy it again from your SteamTradeX dashboard."; return 1; fi for ((i = 0; i < 26; i++)); do c=${rest:i:1} before=${CROCKFORD%%"$c"*} v=${#before} sum=$(((sum + (i + 1) * v) % 1021)) done check=${CROCKFORD:sum/32:1}${CROCKFORD:sum%32:1} if [ "$check" != "${rest:26:2}" ]; then KEY_PROBLEM="That licence key has a typo (its check does not match). Copy it again from your SteamTradeX dashboard."; return 1; fi KEY_COMPACT=$k KEY_GROUPED="STX1-${rest:0:4}-${rest:4:4}-${rest:8:4}-${rest:12:4}-${rest:16:4}-${rest:20:4}-${rest:24:4}" return 0 } # The licence's public id: lic_ and 32 hex of sha256("stx-licence-id-v1\0" + compact key). licence_id_of() { printf 'lic_%s' "$(printf 'stx-licence-id-v1\000%s' "$1" | sha256sum | cut -c1-32)" } # normalise_install_code TEXT: the code as the site reads it (XXXX-XXXX), or nothing. normalise_install_code() { local c [ ${#1} -le 32 ] || return 1 c=$(printf '%s' "$1" | tr 'a-z' 'A-Z') c=${c//[[:space:]]/} c=${c//-/} c=${c//O/0} c=${c//I/1} c=${c//L/1} [[ $c =~ ^[0-9A-HJKMNP-TV-Z]{8}$ ]] || return 1 printf '%s-%s' "${c:0:4}" "${c:4:4}" } # The key from the install code. Sets KEY (and only KEY) on success. key_from_code() { local code=$1 answer status body [ "$DRY" = 1 ] && { ok "[dry run] would swap the install code (it works once) at $ORIGIN/api/v1/script/install-code"; return 1; } answer=$(printf '{"code":"%s"}' "$code" | curl -sS --proto '=https' --max-time 30 --max-filesize 4096 \ -H 'content-type: application/json' --data-binary @- -w '\n%{http_code}' "$ORIGIN/api/v1/script/install-code" 2>/dev/null || true) status=${answer##*$'\n'} body=${answer%$'\n'*} case $status in 200) if [[ $body =~ \"licenceKey\":\"([A-Za-z0-9-]{1,80})\" ]] && check_licence_key "${BASH_REMATCH[1]}"; then KEY=$KEY_GROUPED answer="" body="" ok "Install code accepted: licence $(key_hint "$KEY")" return 0 fi warn "SteamTradeX's answer to the install code could not be read." ;; 404) warn "That install code has run out or was used already (a code works once, for 30 minutes)." ;; 429) warn "Too many install codes were tried from this server in the last hour." ;; *) warn "SteamTradeX could not swap the install code just now (answer ${status:-none})." ;; esac return 1 } # STX1-ABCD-...-WXYZ: enough to tell keys apart, never enough to use one. key_hint() { printf '%s-...-%s' "${1:0:9}" "${1: -4}"; } # Asks for the key on the terminal, hidden, until it passes its check (three tries). key_from_prompt() { local typed tries=0 have_tty || stop "No licence key: the install code did not work, and there is no terminal to ask on" \ "Run it from an SSH session: $(again), then paste the key from your Bot Script page (the Show button)" say " Paste your licence key from your SteamTradeX dashboard (Bot Script, the Show button)." say " It is not shown as you paste it; press Enter after." while [ $tries -lt 3 ]; do tries=$((tries + 1)) printf ' Paste your licence key: ' >/dev/tty IFS= read -rs typed /dev/tty if check_licence_key "$typed"; then KEY=$KEY_GROUPED typed="" ok "Licence $(key_hint "$KEY")" return 0 fi say " $KEY_PROBLEM" done typed="" stop "The licence key was not accepted three times" "Copy it again from your Bot Script page (the Show button) and run: $(again)" } # The key the bot already holds (a re-run), read from its secrets.json as untrusted text. key_from_bot_folder() { local file=$R$P_HOME/secrets.json text [ -f "$file" ] && [ ! -L "$file" ] || return 1 text=$(head -c 65536 -- "$file" 2>/dev/null) || return 1 [[ $text =~ \"licenceKey\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9\ -]{1,80})\" ]] || return 1 check_licence_key "${BASH_REMATCH[1]}" || return 1 KEY=$KEY_GROUPED return 0 } get_licence_key() { step "Your licence" KEY="" KEY_CHANGED=0 local saved="" saved_id="" code if key_from_bot_folder; then saved=$KEY saved_id=$(licence_id_of "$KEY_COMPACT") KEY="" fi # A code is swapped first: it may be another licence (the owner switching # licences). One that no longer works falls back to the saved key, so # pasting the Bot Script page's command again (its code used up long ago) # still just works, with no key to paste. if [ -n "$A_CODE" ]; then code=$(normalise_install_code "$A_CODE") || { warn "$A_CODE is not an install code (it looks like XXXX-XXXX)."; code=""; } if [ -n "$code" ] && key_from_code "$code"; then if [ -n "$saved" ] && [ "$(licence_id_of "$KEY_COMPACT")" != "$saved_id" ]; then KEY_CHANGED=1 ok "This is another licence than the one the bot had ($(key_hint "$saved")); it is activated here in its place" fi saved="" saved_id="" return 0 fi if [ -n "$saved" ]; then KEY=$saved saved="" saved_id="" warn "The install code did not work, so the licence this bot already has is used: $(key_hint "$KEY")" return 0 fi elif [ -n "$saved" ]; then KEY=$saved saved="" saved_id="" ok "Using the licence this bot already has: $(key_hint "$KEY")" return 0 fi if [ "$DRY" = 1 ]; then ok "[dry run] would ask for the licence key on the terminal, hidden" KEY="" return 0 fi key_from_prompt } # forget_key: every copy of the licence key this shell holds, once the bot # has it (the grouped and compact forms, and the last regex match). forget_key() { KEY="" KEY_COMPACT="" KEY_GROUPED="" [[ x =~ x ]] || true } # ---------------------------------------------------------------- the machine # This server's machine id, as apps/bot/src/licence/machine.ts reads it. machine_id() { local f text id for f in "$R/etc/machine-id" "$R/var/lib/dbus/machine-id"; do [ -f "$f" ] || continue text=$(head -c 129 -- "$f" 2>/dev/null) || continue [ ${#text} -le 128 ] || continue id=$(printf '%s' "$text" | tr -d '[:space:]-' | tr 'A-F' 'a-f') [[ $id =~ ^[0-9a-f]{32}$ ]] || continue [[ $id =~ ^0+$ ]] && continue printf '%s' "$id" return 0 done return 1 } # sha256("stx-machine-v1\0" + machine id + "\0" + licence id) in hex. machine_hash_of() { printf 'stx-machine-v1\000%s\000%s' "$1" "$2" | sha256sum | cut -c1-64 } # ---------------------------------------------------------------- versions # A release version (the licence's strict semver rule, no build part). valid_version() { local v=$1 pre id [[ $v =~ ^(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})\.(0|[1-9][0-9]{0,5})(-([0-9A-Za-z.-]+))?$ ]] || return 1 pre=${BASH_REMATCH[5]} [[ $v == *-* ]] || return 0 [ -n "$pre" ] && [ ${#pre} -le 40 ] || return 1 [[ $pre != .* && $pre != *. && $pre != *..* ]] || return 1 local IFS=. for id in $pre; do if [[ $id =~ ^[0-9]+$ ]]; then [[ $id == 0 || $id != 0* ]] || return 1; fi done return 0 } # semver_cmp A B: prints -1, 0 or 1 (pre-release rules of semver 2). semver_cmp() { local a=$1 b=$2 ac bc ap="" bp="" i x y ac=${a%%-*} bc=${b%%-*} [[ $a == *-* ]] && ap=${a#*-} [[ $b == *-* ]] && bp=${b#*-} local -a an bn IFS=. read -ra an <<<"$ac" IFS=. read -ra bn <<<"$bc" for i in 0 1 2; do if ((10#${an[i]} > 10#${bn[i]})); then echo 1; return; fi if ((10#${an[i]} < 10#${bn[i]})); then echo -1; return; fi done if [ "$ap" = "$bp" ]; then echo 0; return; fi [ -z "$ap" ] && { echo 1; return; } [ -z "$bp" ] && { echo -1; return; } local -a ai bi IFS=. read -ra ai <<<"$ap" IFS=. read -ra bi <<<"$bp" for ((i = 0; i < ${#ai[@]} || i < ${#bi[@]}; i++)); do [ $i -ge ${#ai[@]} ] && { echo -1; return; } [ $i -ge ${#bi[@]} ] && { echo 1; return; } x=${ai[i]} y=${bi[i]} [ "$x" = "$y" ] && continue if [[ $x =~ ^[0-9]+$ && $y =~ ^[0-9]+$ ]]; then if [ ${#x} -ne ${#y} ]; then [ ${#x} -gt ${#y} ] && echo 1 || echo -1; return; fi [[ $x > $y ]] && echo 1 || echo -1 return fi [[ $x =~ ^[0-9]+$ ]] && { echo -1; return; } [[ $y =~ ^[0-9]+$ ]] && { echo 1; return; } [[ $x > $y ]] && echo 1 || echo -1 return done echo 0 } # The version installed now, from the /opt/stx-bot/current link, or nothing. installed_version() { local target v [ -L "$R$P_OPT/current" ] || return 0 target=$(readlink -- "$R$P_OPT/current") v=${target##*/} valid_version "$v" && [ -d "$R$P_OPT/releases/$v" ] && printf '%s' "$v" return 0 } # ---------------------------------------------------------------- releases key_state() { awk -v k="$1" '$1 == "KEY" && $2 == k { print $3; exit }' <<<"$RELEASE_KEYS"; } key_pem() { awk -v k="$1" '$1 == "KEY" { on = ($2 == k); next } on' <<<"$RELEASE_KEYS"; } # split_latest ANSWER DIR: the site's {"manifest","manifest_sig","download"} # answer written out as DIR/manifest.json (the signed bytes exactly), # DIR/manifest.sig and DIR/download. Returns 2 when the site has no newer # release, 1 when the answer is not that shape. perl is part of every Debian # and Ubuntu system (perl-base is essential), and its regex reads a megabyte # in a moment where bash and sed take minutes. split_latest() { perl -e ' use strict; use warnings; my ($file, $dir) = @ARGV; open(my $in, "<:raw", $file) or exit 1; local $/; my $t = <$in>; close $in; exit 2 if $t =~ /\A\{"manifest":null,"manifest_sig":null,"download":null\}\z/; my $s = qr/"((?:[^"\\]++|\\.)*+)"/s; $t =~ /\A\{"manifest":$s,"manifest_sig":$s,"download":$s\}\z/s or exit 1; my @f = ($1, $2, $3); my %e = (q(") => q("), "\\" => "\\", "/" => "/", b => "\b", f => "\f", n => "\n", r => "\r", t => "\t"); for (@f) { s{\\(?:(["\\/bfnrt])|u([0-9a-fA-F]{4}))}{ defined $1 ? $e{$1} : do { my $c = chr(hex $2); utf8::encode($c); $c } }ge; } for my $p (["manifest.json", 0], ["manifest.sig", 1], ["download", 2]) { open(my $out, ">:raw", "$dir/$p->[0]") or exit 1; print $out $f[$p->[1]]; close $out or exit 1; } ' "$1" "$2" } # verify_manifest DIR [kept]: DIR/manifest.sig must be one line # ` ` made by a release key written into this file over # "stx-release-v1\0" and DIR/manifest.json's bytes (plan piece 11's # contract), checked with OpenSSL 3. Sets M_KID. Stops the install otherwise. # A release that has just arrived must carry a key that still signs; `kept` # (a release already on this server, checked again for a repair) may carry # one retired since, never a placeholder or an unknown one. verify_manifest() { local dir=$1 mode=${2:-new} text b64 state out text=$(cat -- "$dir/manifest.sig"; printf x) text=${text%x} case $text in *$'\r'*) stop "The release's signature line is damaged (it has a carriage return)" "Try again later; if it stays, tell SteamTradeX support" ;; esac text=${text%$'\n'} [[ $text =~ ^([A-Za-z0-9._-]{1,32})\ ([A-Za-z0-9+/]{86}==)$ ]] || stop "The release's signature is not one line of a key id and a signature" "Try again later; if it stays, tell SteamTradeX support" M_KID=${BASH_REMATCH[1]} b64=${BASH_REMATCH[2]} state=$(key_state "$M_KID") [ "$mode" = kept ] && [ "$state" = retired ] && state=signs case $state in signs) ;; retired) stop "The release is signed with release key $M_KID, which is retired" "$WAIT_ADVICE" ;; placeholder) stop "This installer carries no release key yet, so it cannot check a release" "The downloadable bot may not be released yet. $WAIT_ADVICE" ;; *) stop "The release is signed with key $M_KID, which this installer does not know" "$WAIT_ADVICE" ;; esac key_pem "$M_KID" > "$dir/release-key.pem" printf 'stx-release-v1\000' > "$dir/signed.bin" cat -- "$dir/manifest.json" >> "$dir/signed.bin" printf '%s' "$b64" | base64 -d > "$dir/manifest.sig.bin" if ! out=$(openssl pkeyutl -verify -pubin -inkey "$dir/release-key.pem" -rawin -in "$dir/signed.bin" -sigfile "$dir/manifest.sig.bin" 2>&1) \ || [[ $out != *"Signature Verified Successfully"* ]]; then stop "The release's signature does not verify with release key $M_KID, so nothing was installed" "Try again later; if it stays, tell SteamTradeX support (this protects your server from a changed download)" fi } # manifest_fields FILE ARCH: reads the verified manifest (two-space JSON in # a fixed order, apps/bot/src/release/manifest.ts) and sets M_VERSION, # M_CHANNEL, M_NODE_VERSION, M_NODE_URL, M_NODE_SHA, M_BUNDLE_FILE, # M_BUNDLE_BYTES and M_BUNDLE_SHA, and the two fields a release may carry: # M_MIN_VERSION (the oldest release still offered, or empty) and M_WITHDRAWN # (withdrawn versions, space separated, or empty). Node checks them all again # with JSON.parse once it is installed (VERIFY_JS). manifest_fields() { local fields fields=$(perl -e ' use strict; use warnings; my ($file, $arch) = @ARGV; open(my $in, "<:raw", $file) or exit 1; local $/; my $t = <$in>; close $in; $t =~ /\A\{\s*"product":\s*"stx-bot",\s*"version":\s*"([^"]{1,60})",\s*"channel":\s*"(stable|test)",/ or exit 1; my ($v, $ch) = ($1, $2); $t =~ /"node":\s*\{\s*"version":\s*"(22\.\d{1,4}\.\d{1,4})",\s*"linux-x64":\s*\{\s*"url":\s*"([^"]{1,300})",\s*"sha256":\s*"([0-9a-f]{64})"\s*\},\s*"linux-arm64":\s*\{\s*"url":\s*"([^"]{1,300})",\s*"sha256":\s*"([0-9a-f]{64})"\s*\}\s*\}/ or exit 1; my ($nv, $url, $sha) = $arch eq "arm64" ? ($1, $4, $5) : ($1, $2, $3); $t =~ /"bundle":\s*\{\s*"file":\s*"([^"]{1,100})",\s*"bytes":\s*(\d{1,12}),\s*"sha256":\s*"([0-9a-f]{64})"\s*\}/ or exit 1; my @b = ($1, $2, $3); # Top-level keys start a line with exactly two spaces; a string value never holds a raw line end. my $min = $t =~ /^ "minVersion": "([0-9A-Za-z.-]{1,60})",?$/m ? $1 : "-"; my $wd = "-"; if ($t =~ /^ "withdrawn": \[(.*?)\],?$/ms) { my $list = $1; my @w = $list =~ /"([0-9A-Za-z.-]{1,60})"/g; (my $rest = $list) =~ s/"[0-9A-Za-z.-]{1,60}"|[\s,]//g; exit 1 if $rest ne "" || @w > 200; $wd = join(" ", @w) if @w; } print join("\t", $v, $ch, $nv, $url, $sha, @b, $min, $wd), "\n"; ' "$1" "$2") || stop "The signed release does not describe itself the way this installer reads it" "$WAIT_ADVICE" IFS=$'\t' read -r M_VERSION M_CHANNEL M_NODE_VERSION M_NODE_URL M_NODE_SHA M_BUNDLE_FILE M_BUNDLE_BYTES M_BUNDLE_SHA M_MIN_VERSION M_WITHDRAWN <<<"$fields" [ "$M_MIN_VERSION" != - ] || M_MIN_VERSION="" [ "$M_WITHDRAWN" != - ] || M_WITHDRAWN="" valid_version "$M_VERSION" || stop "The signed release has a version this installer does not read ($M_VERSION)" local w if [ -n "$M_MIN_VERSION" ]; then valid_version "$M_MIN_VERSION" && [ "$(semver_cmp "$M_MIN_VERSION" "$M_VERSION")" != 1 ] || stop "The signed release names an oldest release this installer does not read" fi for w in $M_WITHDRAWN; do valid_version "$w" && [ "$w" != "$M_VERSION" ] || stop "The signed release lists a withdrawn version this installer does not read" done [ "$M_BUNDLE_FILE" = "stx-bot-$M_VERSION.tar.gz" ] || stop "The signed release names a bundle this installer does not expect" [ "$M_NODE_URL" = "https://nodejs.org/dist/v$M_NODE_VERSION/node-v$M_NODE_VERSION-linux-$2.tar.gz" ] || stop "The signed release takes Node from somewhere other than nodejs.org" [ "$M_BUNDLE_BYTES" -gt 0 ] && [ "$M_BUNDLE_BYTES" -le "$MAX_BUNDLE" ] || stop "The signed release's bundle size is out of range" } # ---------------------------------------------------------------- which releases this server takes # # A good signature says a release is ours. It does not say it is meant for # this server (a staff test build is signed too), or that it is still one we # stand behind. Neither answer may come from whoever answers the download, so # both are kept in root's own folder and the updater reads the same two files. # allowed_channel: test only when root's file (or this run's --channel) says so. allowed_channel() { local text="" if [ -n "$A_CHANNEL" ]; then printf '%s' "$A_CHANNEL"; return 0; fi if [ -f "$R$P_CHANNEL" ] && [ ! -L "$R$P_CHANNEL" ]; then text=$(head -c 64 -- "$R$P_CHANNEL" 2>/dev/null | tr -d '[:space:]' || true); fi if [ "$text" = test ]; then printf test; else printf stable; fi } # floor_lines WORD: the versions on P_FLOOR's lines `WORD `, one per line. floor_lines() { local word=$1 w v rest [ -f "$R$P_FLOOR" ] && [ ! -L "$R$P_FLOOR" ] || return 0 { head -c 65536 -- "$R$P_FLOOR" 2>/dev/null || true; } | while read -r w v rest; do if [ "$w" = "$word" ] && [ -z "$rest" ] && valid_version "$v"; then printf '%s\n' "$v"; fi done return 0 } # release_floor: the oldest release this server takes: this installer's own # floor, or a higher one a signed manifest named on this server before. release_floor() { local floor=$MIN_VERSION v while IFS= read -r v; do if [ "$(semver_cmp "$v" "$floor")" = 1 ]; then floor=$v; fi done < <(floor_lines min) printf '%s' "$floor" } # version_withdrawn VERSION: true when a signed manifest seen on this server withdrew it. version_withdrawn() { local v while IFS= read -r v; do if [ "$v" = "$1" ]; then return 0; fi done < <(floor_lines withdrawn) return 1 } # check_release_rules: stops unless the release just verified (the M_ fields) # is one this server takes: its channel, the floor, and not withdrawn. check_release_rules() { local allowed floor allowed=$(allowed_channel) if [ "$M_CHANNEL" = test ] && [ "$allowed" != test ]; then stop "Release $M_VERSION is a test build, and this server takes stable releases only, so nothing was installed" \ "Run the same command again later. Only if you test for SteamTradeX: $(again) --channel test" fi floor=$(release_floor) if [ "$(semver_cmp "$M_VERSION" "$floor")" = -1 ]; then stop "SteamTradeX offered version $M_VERSION, older than $floor, the oldest release this server takes, so nothing was installed" \ "Try again later; if it stays, tell SteamTradeX support (this protects your server from an old release being handed out again)" fi if version_withdrawn "$M_VERSION"; then stop "SteamTradeX offered version $M_VERSION, which was withdrawn, so nothing was installed" "Try again later; if it stays, tell SteamTradeX support" fi return 0 } # remember_release_rules: what this run decided and learned, written to # root's folder: the channel when --channel was given, and the signed # manifest's floor and withdrawn versions added to the ones kept (the floor # only rises, the list only grows; a manifest that names neither changes # nothing). remember_release_rules() { local floor="" w all="" if [ -n "$A_CHANNEL" ]; then printf '%s\n' "$A_CHANNEL" | put_file "$P_CHANNEL" 0600 root:root ok "Releases this server takes: $A_CHANNEL$([ "$A_CHANNEL" = test ] && printf ' and stable')" fi [ -n "${M_MIN_VERSION:-}${M_WITHDRAWN:-}" ] || return 0 [ "$DRY" = 1 ] && { ok "[dry run] would remember the oldest release still offered and the withdrawn versions the signed release names, in $P_FLOOR"; return 0; } while IFS= read -r w; do if [ -z "$floor" ] || [ "$(semver_cmp "$w" "$floor")" = 1 ]; then floor=$w; fi done < <(floor_lines min; if [ -n "${M_MIN_VERSION:-}" ]; then printf '%s\n' "$M_MIN_VERSION"; fi) all=$( { floor_lines withdrawn; for w in ${M_WITHDRAWN:-}; do printf '%s\n' "$w"; done; } | sort -u | tail -n 200) { if [ -n "$floor" ]; then printf 'min %s\n' "$floor"; fi if [ -n "$all" ]; then printf '%s\n' "$all" | sed 's/^/withdrawn /'; fi } | put_file "$P_FLOOR" 0600 root:root } # The download path the site gave (relative, its own host only). valid_download_path() { # bash's regex allows counts up to 255 only, so the length is checked apart. [ ${#1} -le 700 ] && [[ $1 =~ ^/api/v1/script/download/[0-9A-Za-z.-]{1,60}[?]t=[A-Za-z0-9._~%-]+$ ]] } # fetch_latest WORKDIR CURRENT_VERSION: asks the site for the newest release # above CURRENT_VERSION (all of them when empty). Returns 2 when there is none. fetch_latest() { local dir=$1 current=$2 body status split body=$(printf '{"licenceKey":"%s","machineHash":"%s"%s}' "$KEY" "$MACHINE_HASH" "${current:+,\"version\":\"$current\"}") status=$(printf '%s' "$body" | curl -sS --proto '=https' --max-time 60 --max-filesize "$MAX_ANSWER" \ -H 'content-type: application/json' --data-binary @- -o "$dir/latest.json" -w '%{http_code}' "$ORIGIN/api/v1/script/releases/latest" 2>/dev/null || true) body="" case $status in 200) ;; 404) stop "SteamTradeX does not know this licence key" "Copy the key again from your Bot Script page and run: $(again)" ;; 403) stop "This licence is revoked or refunded, so it gets no downloads" "Ask on the SteamTradeX Discord if you think this is wrong" ;; 409) stop "This licence is activated on another server (a re-imaged or cloned server, or one whose machine id was made again, counts as another server)" "Reset the machine on your Bot Script page (it moves the licence here and uses one of your 3 moves a month), then run: $(again)" ;; 429) stop "SteamTradeX was asked for updates too often from this licence" "Wait an hour, then run: $(again)" ;; *) stop "SteamTradeX could not be asked for the release just now (answer ${status:-none})" "Try again in a few minutes: $(again)" ;; esac split=0 split_latest "$dir/latest.json" "$dir" || split=$? [ "$split" = 2 ] && return 2 [ "$split" = 0 ] || stop "SteamTradeX's answer about the release could not be read" "Try again in a few minutes: $(again)" valid_download_path "$(cat "$dir/download")" || stop "SteamTradeX's download link is not one this installer takes" return 0 } # download URL FILE CAP: one HTTPS download, no bigger than CAP. download() { curl -fsS --proto '=https' --tlsv1.2 --max-time 900 --retry 2 --max-filesize "$3" -o "$2" "$1" 2>/dev/null } # sha_matches FILE BYTES SHA256 sha_matches() { [ "$(stat -c %s -- "$1")" = "$2" ] || return 1 [ "$(sha256sum -- "$1" | cut -c1-64)" = "$3" ] } # tar_listing_ok NAMES_FILE TYPES_FILE: every entry a plain file or folder, # with a relative path that never climbs out (`..`), nothing odd in its name, # and the two listings the same length. Prints the total size to stdout. tar_listing_ok() { local names=$1 verbose=$2 [ "$(wc -l < "$names")" = "$(wc -l < "$verbose")" ] || return 1 # GNU tar escapes odd characters with a backslash in listings; none is allowed. grep -q '\\' "$names" && return 1 grep -Eq '^/|(^|/)\.\.(/|$)' "$names" && return 1 awk '{ t = substr($1, 1, 1); if (t != "-" && t != "d") exit 1 }' "$verbose" || return 1 awk '{ s += $3 } END { printf "%d\n", s }' "$verbose" } # install_node_from VERSION URL SHA256 WORKDIR [repair]: that Node, checked # against the signed SHA-256, under /opt/stx-bot/node/. Already # there: kept, unless repairing, which downloads and checks it again and # swaps it in whole. install_node_from() { local version=$1 url=$2 sum=$3 dir=$4 mode=${5:-new} dest tarball dest=$R$P_OPT/node/$version tarball=$dir/node-$version.tar.gz if [ -x "$dest/bin/node" ] && [ ! -L "$dest" ] && [ "$mode" != repair ]; then ok "Node.js $version: already installed" return 0 fi if [ "$DRY" = 1 ]; then ok "[dry run] would download $url and check it against SHA-256 $sum"; return 0; fi download "$url" "$tarball" "$MAX_NODE" || stop "Node.js $version could not be downloaded from nodejs.org" "Try again in a few minutes: $(again)" [ "$(sha256sum -- "$tarball" | cut -c1-64)" = "$sum" ] || stop "The Node.js download is not the file the signed release names (SHA-256 differs), so it was not used" "Try again later: $(again)" rm -rf -- "$dest.partial" "$dest.old" mkdir -p -- "$dest.partial" tar -xzf "$tarball" -C "$dest.partial" --strip-components=1 --no-same-owner || stop "The Node.js download could not be unpacked" rm -f -- "$tarball" root_only "$dest.partial" "$dest.partial/bin/node" --version >/dev/null 2>&1 || stop "The downloaded Node.js does not run on this server" swap_in "$dest.partial" "$dest" ok "Node.js $version: downloaded and checked" } # install_node WORKDIR [repair]: the Node the release being installed pins. install_node() { install_node_from "$M_NODE_VERSION" "$M_NODE_URL" "$M_NODE_SHA" "$@"; } # root_only DIR: everything in it root's, nobody else may write, and no # setuid or setgid bit anywhere (a bundle or download never needs one). root_only() { chown -R root:root "$1" 2>/dev/null || [ -n "$R" ] chmod -R go-w,u-s,g-s "$1" } # swap_in NEW DEST: NEW takes DEST's place; a leftover DEST.old from a run # that was stopped half way is cleared first, so the swap cannot fail on it. swap_in() { local new=$1 dest=$2 rm -rf -- "$dest.old" if [ -e "$dest" ] || [ -L "$dest" ]; then mv -T -- "$dest" "$dest.old" mv -T -- "$new" "$dest" rm -rf -- "$dest.old" else mv -T -- "$new" "$dest" fi } # The program Node runs to check an unpacked release against its manifest: # every entry a plain file or folder, exactly the files the manifest lists, # each with its size and SHA-256, and the manifest's own fields the ones the # shell read. The two copies of the signed files the installer keeps in a # release folder (.manifest.json, .manifest.sig) are the only extra entries. VERIFY_JS=' const fs = require("fs"), path = require("path"), crypto = require("crypto"); const [manifestFile, root, version, bundleSha, bundleBytes, channel, minVersion, withdrawn] = process.argv.slice(1); const fail = (why) => { process.stderr.write(why + "\n"); process.exit(1); }; const m = JSON.parse(fs.readFileSync(manifestFile, "utf8")); if (m.product !== "stx-bot" || m.version !== version || m.bundle.sha256 !== bundleSha || String(m.bundle.bytes) !== bundleBytes) fail("the manifest does not say what the installer read from it"); // A release just arrived: the channel, the floor and the withdrawn list the shell decided on are the ones JSON.parse reads. if (channel !== undefined && (m.channel !== channel || (m.minVersion === undefined ? "" : m.minVersion) !== minVersion || (Array.isArray(m.withdrawn) ? m.withdrawn : []).join(" ") !== withdrawn)) fail("the manifest does not say what the installer read from it about which servers take it"); const want = new Map(); for (const f of m.files) { if (typeof f.path !== "string" || f.path.startsWith("/") || f.path.split("/").includes("..")) fail("the manifest lists an unsafe path"); want.set(f.path, f); } const kept = new Set([".manifest.json", ".manifest.sig"]); const seen = new Set(); (function walk(rel) { for (const name of fs.readdirSync(path.join(root, rel))) { const r = rel ? rel + "/" + name : name; const st = fs.lstatSync(path.join(root, r)); if (st.isDirectory()) { walk(r); continue; } if (!st.isFile()) fail(r + " is not a plain file"); if (!rel && kept.has(name)) continue; const f = want.get(r); if (!f) fail(r + " is not in the signed manifest"); if (st.size !== f.bytes) fail(r + " is not the size the manifest says"); if (crypto.createHash("sha256").update(fs.readFileSync(path.join(root, r))).digest("hex") !== f.sha256) fail(r + " is not the file the manifest names"); seen.add(r); } })(""); for (const p of want.keys()) if (!seen.has(p)) fail(p + " is missing"); ' # install_release WORKDIR [repair]: the bundle, checked against the signed # size and SHA-256, unpacked into /opt/stx-bot/releases/.partial, # every file checked against the manifest by Node, owned by root, then # renamed into place. Repairing replaces a folder of the same version in one # swap. install_release() { local dir=$1 mode=${2:-new} dest=$R$P_OPT/releases/$M_VERSION total node=$R$P_OPT/node/$M_NODE_VERSION/bin/node if [ -d "$dest" ] && [ "$mode" != repair ]; then ok "Release $M_VERSION: already on disk" return 0 fi if [ "$DRY" = 1 ]; then ok "[dry run] would download $M_BUNDLE_FILE, check it against SHA-256 $M_BUNDLE_SHA, unpack it into $P_OPT/releases/$M_VERSION and check every file"; return 0; fi download "$ORIGIN$(cat "$dir/download")" "$dir/bundle.tar.gz" "$MAX_BUNDLE" || stop "The bot could not be downloaded" "Try again in a few minutes: $(again)" sha_matches "$dir/bundle.tar.gz" "$M_BUNDLE_BYTES" "$M_BUNDLE_SHA" || stop "The download is not the bundle the signed release names (size or SHA-256 differs), so it was not used" "Try again later: $(again)" tar -tzf "$dir/bundle.tar.gz" > "$dir/names" && tar -tvzf "$dir/bundle.tar.gz" > "$dir/listing" || stop "The bundle could not be read" total=$(tar_listing_ok "$dir/names" "$dir/listing") || stop "The bundle holds something other than plain files and folders inside itself, so it was not unpacked" [ "$total" -le "$MAX_UNPACKED" ] || stop "The bundle would unpack to more than 300 MB, so it was not unpacked" rm -rf -- "$dest.partial" mkdir -p -- "$dest.partial" tar -xzf "$dir/bundle.tar.gz" -C "$dest.partial" --no-same-owner --no-overwrite-dir || stop "The bundle could not be unpacked" root_only "$dest.partial" env -u NODE_OPTIONS -u NODE_PATH "$node" -e "$VERIFY_JS" "$dir/manifest.json" "$dest.partial" "$M_VERSION" "$M_BUNDLE_SHA" "$M_BUNDLE_BYTES" "$M_CHANNEL" "$M_MIN_VERSION" "$M_WITHDRAWN" \ || { rm -rf -- "$dest.partial"; stop "The unpacked bot is not what the signed release lists, so it was taken away again" "Try again later: $(again)"; } cp -- "$dir/manifest.json" "$dest.partial/.manifest.json" cp -- "$dir/manifest.sig" "$dest.partial/.manifest.sig" root_only "$dest.partial" swap_in "$dest.partial" "$dest" ok "Release $M_VERSION: downloaded, signature and every file checked" } # verify_kept_release DIR WORKDIR: a release kept on disk (the one a # rollback would go back to) checked again from the signed copies it keeps: # their signature with this file's release keys, its Node downloaded and # checked again, then every file against the manifest with that Node. # Returns 1 when any of it fails. verify_kept_release() { local d=$1 work=$2 v=${1##*/} tmp fields kv ksha kbytes knode kurl knsha [ -f "$d/.manifest.json" ] && [ -f "$d/.manifest.sig" ] || return 1 tmp=$(mktemp -d "$work/kept.XXXXXX") cp -- "$d/.manifest.json" "$tmp/manifest.json" cp -- "$d/.manifest.sig" "$tmp/manifest.sig" # In a subshell: a check that stops ends only the subshell, and says nothing. fields=$( (verify_manifest "$tmp" kept && manifest_fields "$tmp/manifest.json" "$NODE_ARCH" \ && printf '%s\t%s\t%s\t%s\t%s\t%s' "$M_VERSION" "$M_BUNDLE_SHA" "$M_BUNDLE_BYTES" "$M_NODE_VERSION" "$M_NODE_URL" "$M_NODE_SHA") 2>/dev/null) || return 1 IFS=$'\t' read -r kv ksha kbytes knode kurl knsha <<<"$fields" [ "$kv" = "$v" ] || return 1 (install_node_from "$knode" "$kurl" "$knsha" "$work" repair) >/dev/null 2>&1 || return 1 env -u NODE_OPTIONS -u NODE_PATH "$R$P_OPT/node/$knode/bin/node" -e "$VERIFY_JS" "$tmp/manifest.json" "$d" "$kv" "$ksha" "$kbytes" >/dev/null 2>&1 } # repair_kept_releases WORKDIR: every release folder other than the one just # installed is checked again; one that fails is removed (it could no longer # be trusted as a rollback), and leftovers of stopped runs go. repair_kept_releases() { local work=$1 d v for d in "$R$P_OPT"/releases/* "$R$P_OPT"/node/*.partial "$R$P_OPT"/node/*.old; do [ -e "$d" ] || [ -L "$d" ] || continue v=${d##*/} case $v in *.partial | *.old) act "remove $v, left by a run that was stopped" rm -rf --one-file-system -- "$d"; continue ;; esac [ "$v" = "$M_VERSION" ] && continue if [ -d "$d" ] && [ ! -L "$d" ] && verify_kept_release "$d" "$work"; then ok "Kept release $v: signature, Node and every file checked again" else act "remove the kept release $v, which no longer checks out" rm -rf --one-file-system -- "$d" warn "Release $v was removed: it no longer matched its signed manifest, so it cannot be gone back to." fi done } # repair_current_release CURRENT WORKDIR: --repair while the newest release # is one the updater rolled back here. The installed version is checked again # from the signed copies it keeps (.manifest.json and .manifest.sig; its key # may have been retired since), its Node downloaded and checked again, and # every file checked with that Node. Its bundle cannot be downloaded again # (SteamTradeX offers only the newest release), so a file that no longer # matches stops the repair and says so. Sets the M_ fields to that release. repair_current_release() { local current=$1 work=$2 d=$R$P_OPT/releases/$1 tmp [ -f "$d/.manifest.json" ] && [ -f "$d/.manifest.sig" ] && [ ! -L "$d" ] \ || stop "Version $current keeps no signed copy of its manifest, so it cannot be checked for a repair" "Wait for the next version, or tell SteamTradeX support" tmp=$(mktemp -d "$work/current.XXXXXX") cp -- "$d/.manifest.json" "$tmp/manifest.json" cp -- "$d/.manifest.sig" "$tmp/manifest.sig" verify_manifest "$tmp" kept manifest_fields "$tmp/manifest.json" "$NODE_ARCH" [ "$M_VERSION" = "$current" ] || stop "The signed copy kept with version $current names another version" "Tell SteamTradeX support; the bot keeps running as it is" install_node "$work" repair env -u NODE_OPTIONS -u NODE_PATH "$R$P_OPT/node/$M_NODE_VERSION/bin/node" -e "$VERIFY_JS" "$tmp/manifest.json" "$d" "$M_VERSION" "$M_BUNDLE_SHA" "$M_BUNDLE_BYTES" >/dev/null 2>&1 \ || stop "Version $current's files no longer match its signed manifest, and the version SteamTradeX offers was rolled back on this server, so there is nothing safe to repair it from" "Tell SteamTradeX support; the bot keeps running as it is" ok "Release $current: signature, Node and every file checked again from its kept signed copy" } # version_marked_bad VERSION: true when the updater rolled this version back # on this server (piece 15's bad-versions.json, root's, read as text: only # the quoted version is looked for). version_marked_bad() { local file=$R$P_UPDATE_STATE/bad-versions.json text [ -f "$file" ] && [ ! -L "$file" ] || return 1 text=$(head -c 65536 -- "$file" 2>/dev/null) || return 1 [[ $text == *"\"$1\""* ]] } # point_link LINK TARGET: one rename, so the link is never half switched. point_link() { local link=$1 target=$2 act "point $link at $target" ln -sfn -- "$target" "$R$link.new" act "switch $link in one step" mv -T -- "$R$link.new" "$R$link" } # Keeps the releases in use now and before; older ones go. prune_releases() { local keep_a=$1 keep_b=$2 d v for d in "$R$P_OPT"/releases/*; do [ -d "$d" ] && [ ! -L "$d" ] || continue v=${d##*/} [ "$v" = "$keep_a" ] || [ "$v" = "$keep_b" ] && continue act "remove the old release $v" rm -rf --one-file-system -- "$d" done } # ---------------------------------------------------------------- layout # ownership_problems: from `find -printf '%U %G %m %y %p\n'` lines, the # paths that are not root's, that a group or others could write, or that # carry a setuid or setgid bit (a link's own mode means nothing, so only its # owner counts). ownership_problems() { awk '{ uid = $1; gid = $2; mode = $3; type = $4 p = $0; sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ /, "", p) bad = (uid != 0 || gid != 0) if (type != "l") { m = 0; n = length(mode) for (i = 1; i <= n; i++) m = m * 8 + substr(mode, i, 1) if (int(m / 16) % 2 == 1 || int(m / 2) % 2 == 1) bad = 1 if (int(m / 2048) % 2 == 1 || int(m / 1024) % 2 == 1) bad = 1 } if (bad) print p }' } # ownership_report: the first five problems under the program and the # command, or nothing. ownership_report() { local paths=() [ -e "$R$P_OPT" ] || [ -L "$R$P_OPT" ] && paths+=("$R$P_OPT") [ -e "$R$P_WRAPPER" ] || [ -L "$R$P_WRAPPER" ] && paths+=("$R$P_WRAPPER") [ ${#paths[@]} -gt 0 ] || return 0 { find "${paths[@]}" -printf '%U %G %m %y %p\n' 2>/dev/null || true; } | ownership_problems | head -n 5 } # The code is root's and nobody else can change it: the stx-bot user could # otherwise change what root's updater runs (plan piece 11's rule). assert_ownership() { local bad [ "$DRY" = 1 ] && { ok "[dry run] would check that $P_OPT and $P_WRAPPER are root's, not writable by anyone else, and carry no setuid or setgid bit"; return 0; } bad=$(ownership_report) [ -z "$bad" ] || stop "These are not owned by root, others can change them, or they carry a setuid or setgid bit: $bad" "The installed files cannot be trusted to repair themselves. Download the installer again from $SITE, check its signature as the README says (Check the installer before the first install), and only then run: ${S}bash install.sh --repair" ok "Files: owned by root, not writable by the bot's user" } # Before anything already installed is run (its Node checks a new release, its # stx-bot reads the licence), a re-run checks what is there. A repair runs # nothing that is there: it downloads and checks Node and the release again, # so it only says what it will put right. check_existing_install() { local bad [ -e "$R$P_OPT" ] || return 0 step "Checking the installed files before running any of them" if [ "$DRY" = 1 ]; then assert_ownership; return 0; fi bad=$(ownership_report) if [ -z "$bad" ]; then ok "Files: owned by root, not writable by the bot's user" elif [ "$A_REPAIR" = 1 ]; then warn "Not as they should be, and none of it will be run: $bad" ok "The repair downloads Node.js and the bot again, checks them, and makes everything root's" else stop "These are not owned by root, others can change them, or they carry a setuid or setgid bit, so nothing in them was run: $bad" "The installed files cannot be trusted to repair themselves. Download the installer again from $SITE, check its signature as the README says (Check the installer before the first install), and only then run: ${S}bash install.sh --repair" fi } ensure_user_and_folders() { if ! getent passwd "$BOT_USER" >/dev/null 2>&1; then act "make the system user $BOT_USER (no login shell)" useradd --system --user-group --home-dir "$P_DATA" --no-create-home --shell /usr/sbin/nologin "$BOT_USER" fi # The data folder is made once and never touched again by this installer. [ -d "$R$P_DATA" ] || act "make $P_DATA for the bot's settings and history" install -d -o "$BOT_USER" -g "$BOT_USER" -m 0700 "$R$P_DATA" [ -d "$R$P_HOME" ] || act "make $P_HOME" install -d -o "$BOT_USER" -g "$BOT_USER" -m 0700 "$R$P_HOME" [ -d "$R$P_LOCKS" ] || act "make $P_LOCKS" install -d -o "$BOT_USER" -g "$BOT_USER" -m 0700 "$R$P_LOCKS" act "make $P_OPT (root's)" install -d -o root -g root -m 0755 "$R$P_OPT" "$R$P_OPT/releases" "$R$P_OPT/node" act "make $P_UPDATE_STATE (root only)" install -d -o root -g root -m 0700 "$R$P_UPDATE_STATE" act "make $P_UPDATER_PUBLIC (root's, readable)" install -d -o root -g root -m 0755 "$R$P_UPDATER_PUBLIC" } # ---------------------------------------------------------------- generated files service_unit() { cat <` as the stx-bot user with the bot's folder, and # passes on who you are and how you reached this server (sudo drops both), # so `stx-bot info` can fill in the SSH tunnel command for you. set -euo pipefail export LC_ALL=C export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin if [ "$(id -u)" != 0 ]; then echo "Run it with sudo, for example: sudo stx-bot info" >&2 exit 1 fi real_user=${SUDO_USER:-} [[ $real_user =~ ^[a-z_][a-z0-9_.-]{0,31}$ ]] || real_user="" conn=${SSH_CONNECTION:-} if [ -z "$conn" ]; then # sudo leaves SSH_CONNECTION out; the login shell that ran sudo still has it. pid=$PPID for _ in 1 2 3 4 5 6 7 8; do [ "${pid:-1}" -gt 1 ] 2>/dev/null || break conn=$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n 1 || true) [ -n "$conn" ] && break pid=$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null || echo 1) done fi # Only the shape SSH writes: client address, port, server address, port. [[ $conn =~ ^[0-9A-Fa-f:.]{2,45}\ [0-9]{1,5}\ [0-9A-Fa-f:.]{2,45}\ [0-9]{1,5}$ ]] || conn="" envs=(PATH=/usr/local/bin:/usr/bin:/bin HOME=/var/lib/stx-bot LANG=C.UTF-8 "TERM=${TERM:-dumb}" STX_BOT_HOME=/var/lib/stx-bot/home STX_BOT_LOCK_DIR=/var/lib/stx-bot/locks NODE_OPTIONS=--no-deprecation) [ -n "$real_user" ] && envs+=("SUDO_USER=$real_user") [ -n "$conn" ] && envs+=("SSH_CONNECTION=$conn") if [ "${1:-}" = installer ]; then # The installer that came inside the installed, signed release: repair, # uninstall and the dashboard's route, with nothing downloaded again. shift self=/opt/stx-bot/current/install.sh # The file, and each folder on the way to it, must be root's own and writable by nobody else. real=$(readlink -f -- "$self" 2>/dev/null || true) folders=$(find /opt/stx-bot /opt/stx-bot/releases "${real%/*}" -maxdepth 0 -type d -user root ! -perm /022 2>/dev/null | wc -l) if [ -z "$(find "$self" -maxdepth 0 -type f -user root ! -perm /022 2>/dev/null)" ] || [ "$folders" != 3 ]; then echo "The installed release carries no installer that is root's own (it arrives with the next version). Update first: sudo stx-bot update now" >&2 exit 1 fi exec bash "$self" "$@" fi args=("$@") stage="" # The one Steam Guard file you picked for setup, and whether an old bot's .env was given. chosen="" from_env=0 trap '[ -n "$stage" ] && rm -rf -- "$stage"' EXIT if [ "${1:-}" = setup ]; then # The bot's user cannot read files in your home folder, so the files setup # may ask for are handed to it as copies, removed again when setup ends. # The folder and the copies stay root's: the bot's user (its group) may # read them and nothing more, so it can put nothing there for root to find. stage=$(mktemp -d /run/stx-bot-setup.XXXXXX) chown root:stx-bot "$stage" chmod 750 "$stage" for i in "${!args[@]}"; do if [ "${args[i]}" = --from-env ] && [ -n "${args[i + 1]:-}" ]; then src=${args[i + 1]} [ -f "$src" ] || { printf 'There is no file %q\n' "$src" >&2; exit 1; } install -o root -g stx-bot -m 640 -- "$src" "$stage/old-bot.env" args[i + 1]=$stage/old-bot.env from_env=1 fi done # A Steam Guard file holds an account's secrets. Only the one file you # name is handed to the bot's user, never every file found: the others may # belong to your main account or to other bots. # shown PATH: the path as text that is safe on your terminal and that a # shell reads back as the same path. A file's name is whatever its maker # chose (an escape sequence, a space, a quote); a plain one comes out as it is. shown() { printf '%q' "$1"; } # guard_file_problem FILE: why this file cannot be used, in plain words with # what to do, or nothing when it has the shape of a Steam Guard file. Read # here, by root, so a file that cannot work is said the moment it is picked. guard_file_problem() { local file=$1 f text f=$(shown "$file") if [ ! -f "$file" ] || [ -L "$file" ]; then echo "$f is not a plain file."; return 0; fi if [ "$(stat -c %s -- "$file" 2>/dev/null || echo 99999999)" -gt 65536 ]; then echo "$f is far too large to be a Steam Guard file."; return 0; fi if ! text=$(head -c 65536 -- "$file" 2>/dev/null | tr -d '\000-\040\177-\377'); then echo "$f cannot be read."; return 0; fi if [ -z "$text" ]; then echo "$f is empty. Copy it from Steam Desktop Authenticator's maFiles folder again."; return 0; fi case $text in "{"*'"shared_secret"'*) return 0 ;; "{"*) echo "$f is not a Steam Guard file: it has no shared_secret in it. Use the .maFile from Steam Desktop Authenticator's maFiles folder (not manifest.json)."; return 0 ;; *[!A-Za-z0-9+/=]*) echo "$f is not a Steam Guard file. Use the .maFile from Steam Desktop Authenticator's maFiles folder."; return 0 ;; esac # Letters, digits, + / = and nothing else: what SDA writes when a passkey is set. echo "$f is encrypted by Steam Desktop Authenticator, so it cannot be read here. On your computer open Steam Desktop Authenticator, press Manage Encryption, type your passkey, then leave the new passkey empty to remove it. Then upload the file again. Or go on without the file and type the two secrets yourself (option 2 in the Steam Guard question)." } for i in "${!args[@]}"; do if [ "${args[i]}" = --mafile ] && [ -n "${args[i + 1]:-}" ]; then chosen=${args[i + 1]} # The whole path, as it is from where you stand: this command changes # folder before setup runs, and the delete afterwards must mean the same file. case $chosen in /*) ;; *) chosen=$PWD/$chosen ;; esac [ -f "$chosen" ] || { echo "There is no file $(shown "$chosen")" >&2; exit 1; } # The file itself, never a link to one: a link could name a file its maker cannot read and you can. [ ! -L "$chosen" ] || { echo "$(shown "$chosen") is a link, not the file itself. Give the path of the .maFile." >&2; exit 1; } problem=$(guard_file_problem "$chosen") [ -z "$problem" ] || { echo "$problem" >&2; exit 1; } unset 'args[i]' 'args[i + 1]' args=("${args[@]}") break fi done if [ -z "$chosen" ] && [ -t 0 ]; then owner_home="" [ -n "$real_user" ] && owner_home=$(getent passwd "$real_user" | cut -d: -f6 || true) # A root login that typed sudo: root's home is searched below anyway, and once is enough. if [ "${owner_home%/}" = /root ]; then owner_home=""; fi # The first nine are listed (one digit picks one); how many more there # are is said, never hidden. The search itself stops at sixty. found=() more=0 while IFS= read -r -d '' f; do if [ ${#found[@]} -lt 9 ]; then found+=("$f"); else more=$((more + 1)); fi done < <(find ${owner_home:+"$owner_home"} /root -maxdepth 3 -type f -name '*.maFile' -size -64k -print0 2>/dev/null | head -z -n 60) if [ ${#found[@]} -gt 0 ]; then echo "Steam Guard files (.maFile) found on this server (none has been copied or opened):" for i in "${!found[@]}"; do echo " $((i + 1))) $(shown "${found[i]}")"; done if [ "$more" -gt 0 ]; then if [ "$more" -ge 51 ]; then echo " and more than 50 others that are not listed here."; else echo " and $more more that are not listed here."; fi echo " To use one that is not listed, press Ctrl+C and name it yourself: sudo stx-bot setup --mafile PATH" fi while :; do printf 'Which one is for the account you are setting up? Type its number, or press Enter for none: ' IFS= read -r pick || pick="" [ -n "$pick" ] || break if [[ $pick =~ ^[1-9]$ ]] && [ "$pick" -le ${#found[@]} ]; then problem=$(guard_file_problem "${found[pick - 1]}") if [ -z "$problem" ]; then chosen=${found[pick - 1]} break fi echo "$problem" echo "Pick another file, or press Enter to go on without one." else echo "Type a number from 1 to ${#found[@]}, or press Enter to go on without a file." fi done fi fi if [ -n "$chosen" ]; then install -o root -g stx-bot -m 640 -- "$chosen" "$stage/steam-guard.maFile" # Setup is told where the copy is, so it never asks you for a path. The # name is passed on only when it is plain text, since setup prints it. envs+=("STX_SETUP_GUARD_FILE=$stage/steam-guard.maFile") name=${chosen##*/} [[ $name =~ ^[A-Za-z0-9._-]{1,80}$ ]] && envs+=("STX_SETUP_GUARD_NAME=$name") # A bot older than 1.0.2 (one the updater put back, say) does not take # the file by itself: it asks for a path, so it is told the copy's path # as before. It never says the file was used, so no delete is offered. installed=$(readlink /opt/stx-bot/current 2>/dev/null || true) case ${installed##*/} in 1.0.0 | 1.0.0-* | 1.0.1 | 1.0.1-*) echo "Only that one file was copied for the setup. This bot is version ${installed##*/}, which asks for the file's path: choose 1 at the Steam Guard question, then paste this path:" echo " $stage/steam-guard.maFile" echo "(It is a copy of $(shown "$chosen"), removed when setup ends.)" ;; *) echo "Setup will read the Steam Guard secrets from $(shown "$chosen")" echo "Only that one file is handed to the bot, as a copy that is removed when setup ends." ;; esac echo fi fi cd /var/lib/stx-bot status=0 # No command of the bot's user ever holds your own terminal: a process of # that user could otherwise push keystrokes into the root shell you ran this # from. The commands that ask questions get a terminal of their own (a new # pseudo-terminal, which only passes text back and forth); every other one # runs in a session of its own with no terminal at all. case ${1:-} in setup | licence | dashboard-password) if [ -t 0 ]; then runuser --pty -u stx-bot -- env -i "${envs[@]}" /opt/stx-bot/current/bin/stx-bot "${args[@]}" || status=$? else setsid -w runuser -u stx-bot -- env -i "${envs[@]}" /opt/stx-bot/current/bin/stx-bot "${args[@]}" || status=$? fi ;; *) setsid -w runuser -u stx-bot -- env -i "${envs[@]}" /opt/stx-bot/current/bin/stx-bot "${args[@]}" /dev/null | tr -d '[:space:]' || true); fi if valid_port "$p"; then printf '%s' "$((10#$p))"; else printf '%s' "$DASHBOARD_PORT"; fi } # bot_settings_port: the port the bot's own settings name (text only, and only to tell the owner when it differs), or nothing. bot_settings_port() { local text p="" text=$(head -c 262144 -- "$R$P_HOME/settings.json" 2>/dev/null || true) if [[ $text =~ \"dashboard\.port\"[[:space:]]*:[[:space:]]*([0-9]{2,5})[^0-9] ]]; then p=${BASH_REMATCH[1]}; fi if valid_port "$p"; then printf '%s' "$((10#$p))"; fi return 0 } # caddy_site DOMAIN [CIDR]: the Caddy site block for the dashboard. caddy_site() { local domain=$1 cidr=${2:-} printf '# SteamTradeX bot dashboard. Written by install.sh.\n' printf '%s {\n' "$domain" if [ -n "$cidr" ]; then printf '\t@outside not remote_ip %s\n' "$cidr" printf '\trespond @outside "Not found" 404\n' fi # Browsers that have been here once never ask over plain http again. printf '\theader Strict-Transport-Security "max-age=31536000"\n' printf '\treverse_proxy 127.0.0.1:%s\n' "$(dashboard_port)" printf '}\n' } write_service_files() { step "Service files" service_unit | put_file "$P_UNITS/stx-bot.service" 0644 root:root update_service_unit | put_file "$P_UNITS/stx-bot-update.service" 0644 root:root update_timer_unit | put_file "$P_UNITS/stx-bot-update.timer" 0644 root:root update_path_unit | put_file "$P_UNITS/stx-bot-update.path" 0644 root:root wrapper_script | put_file "$P_WRAPPER" 0755 root:root printf '%s\n' "$INSTALLER_VERSION" | put_file "$P_APPLIED" 0644 root:root act "tell systemd about them" systemctl daemon-reload ok "Written: the stx-bot service, the update timer, and the stx-bot command ($P_WRAPPER)" } # ---------------------------------------------------------------- bot steps # run_bot ARG...: `stx-bot ARG...` for the installer's own calls, which ask # nothing: in a session of their own (setsid), so the bot's user never shares # root's terminal. Standard input is passed on (the licence key's pipe). run_bot() { setsid -w "$P_WRAPPER" "$@"; } # password_from_init: the dashboard password from `stx-bot init`'s output (it is shown once). password_from_init() { sed -n 's/^ Dashboard password: \([^ ]\{8,200\}\)$/\1/p' | head -n 1 } PASSWORD="" make_bot_folder() { local out if [ -f "$R$P_HOME/settings.json" ]; then ok "The bot's folder is there already ($P_HOME): settings, history and licence kept" return 0 fi if [ "$DRY" = 1 ]; then ok "[dry run] would run: stx-bot init (makes $P_HOME and a dashboard password)"; return 0; fi out=$(run_bot init 2>&1 /dev/null &1) || status=$? if [ $status -ne 0 ]; then say "$out" | sed 's/^/ /' if [[ $line == "Licence: Activated on another server"* ]]; then stop "This server's machine id is not the one the licence was activated with (a re-imaged or cloned server, or a new machine id, counts as a new server)" \ "Reset the machine on your Bot Script page (it uses one of your 3 moves a month), then run: $(again)" fi stop "The licence could not be activated on this server" "Do what the line above says, then run: $(again)" fi ok "$(printf '%s\n' "$out" | grep -m1 'is activated on this server' || echo 'Licence activated on this server')" } SETUP_DONE=0 run_setup() { step "Setting up the bot" if [ -n "$A_FROM_ENV" ]; then if act_try "take the Steam account and rates from $A_FROM_ENV" run_bot setup --from-env "$A_FROM_ENV"; then SETUP_DONE=1 [ "$FRESH_DATA" = 1 ] || RESTART_NEEDED=1 else ok "The old bot's settings did not all come across (the lines above say which). Finish with: ${S}stx-bot setup && ${S}systemctl start stx-bot" fi return 0 fi if [ "$A_SKIP_SETUP" = 1 ] && [ "$FRESH_DATA" = 0 ]; then # Nothing to skip: the bot's folder was here before this run, so its # settings are kept, exactly as answering no to the question below. ok "Settings kept (--skip-setup). To change them: ${S}stx-bot setup" SETUP_DONE=1 return 0 fi if [ "$A_SKIP_SETUP" = 1 ]; then ok "Skipped (--skip-setup). Finish with: ${S}stx-bot setup && ${S}systemctl start stx-bot" return 0 fi if [ "$FRESH_DATA" = 0 ]; then if ! yes_no " The bot's settings from before are kept. Change them now?" N; then ok "Settings kept" SETUP_DONE=1 return 0 fi fi if [ "$DRY" = 1 ]; then ok "[dry run] would run: stx-bot setup (the Steam account, your Steam ID and the rates, one question at a time)"; SETUP_DONE=1; return 0; fi if ! have_tty; then ok "There is no terminal to ask on. Finish with: ${S}stx-bot setup && ${S}systemctl start stx-bot" return 0 fi say " The setup asks one question at a time. Have ready: the bot's Steam login, its" say " Steam Guard .maFile (or its shared and identity secrets), and the link to your" say " own Steam profile (or your Steam ID)." say " Passwords and secrets are not shown as you type or paste them." say "" if "$P_WRAPPER" setup /dev/null || break conn=$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^SSH_CONNECTION=//p' | head -n 1 || true) [ -n "$conn" ] && break pid=$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null || echo 1) done fi [[ $conn =~ ^[0-9A-Fa-f:.]{2,45}\ [0-9]{1,5}\ [0-9A-Fa-f:.]{2,45}\ [0-9]{1,5}$ ]] && printf '%s' "$conn" return 0 } offer_firewall() { local port command -v ufw >/dev/null 2>&1 || return 0 ufw status 2>/dev/null | head -n 1 | grep -q 'Status: inactive' || return 0 port=$(ssh_port_of "$(ssh_connection)") step "Firewall" if [ -z "$port" ]; then ok "ufw is off. Not changed: this session's SSH port is not known, and a firewall that blocks it would lock you out" return 0 fi say " The firewall (ufw) is off. Turning it on keeps SSH (port $port) open and blocks every" say " other incoming connection: the bot needs none, but anything else this server offers to" say " the internet (a website, a game server) stops answering until you allow it (ufw allow PORT)." if yes_no " Turn on the firewall?" Y; then act "allow SSH on port $port" quiet ufw allow "$port/tcp" act "turn the firewall on" quiet ufw --force enable ok "Firewall on; SSH (port $port) stays open. The dashboard port $DASHBOARD_PORT is not opened" else ok "Firewall left off" fi } # dns_verdict "RESOLVED ADDRESSES" "THIS SERVER'S ADDRESSES": match, none or elsewhere. dns_verdict() { local resolved=$1 here=$2 a [ -n "${resolved//[[:space:]]/}" ] || { echo none; return; } for a in $resolved; do [[ " $here " == *" $a "* ]] && { echo match; return; } done echo elsewhere } # is_public_address ADDRESS: true for an address the internet can reach # (not private, shared, loopback, link-local, multicast or unique-local). is_public_address() { local a=$1 o1 o2 if [[ $a =~ ^([0-9]{1,3})\.([0-9]{1,3})\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then o1=$((10#${BASH_REMATCH[1]})) o2=$((10#${BASH_REMATCH[2]})) [ "$o1" -eq 0 ] || [ "$o1" -eq 10 ] || [ "$o1" -eq 127 ] || [ "$o1" -ge 224 ] && return 1 [ "$o1" -eq 100 ] && [ "$o2" -ge 64 ] && [ "$o2" -le 127 ] && return 1 [ "$o1" -eq 169 ] && [ "$o2" -eq 254 ] && return 1 [ "$o1" -eq 172 ] && [ "$o2" -ge 16 ] && [ "$o2" -le 31 ] && return 1 [ "$o1" -eq 192 ] && [ "$o2" -eq 168 ] && return 1 return 0 fi [[ $a == *:* ]] || return 1 a=$(printf '%s' "$a" | tr 'A-F' 'a-f') case $a in ::1 | :: | fe[89ab]?:* | fc*:* | fd*:* | ff*:* | ::ffff:*) return 1 ;; esac return 0 } # The global addresses of this server's own interfaces, IPv4 first (one per # line, "
"), leaving out container and VPN bridges. interface_addresses() { local family for family in -4 -6; do ip -o "$family" addr show scope global 2>/dev/null \ | awk '$2 !~ /^(docker|br-|veth|virbr|cni|flannel|lxc|lxd|tun|tap|wg|zt|tailscale)/ { sub(/\/.*/, "", $4); print $4 }' || true done } # public_address: the address a DNS record for the dashboard should name: # the one this SSH session came in on, when it is public; else the first # public address on an interface (IPv4 before IPv6); else nothing. public_address() { local a a=$(awk '{ print $3 }' <<<"$(ssh_connection)") if [ -n "$a" ] && is_public_address "$a"; then printf '%s' "$a"; return 0; fi while IFS= read -r a; do [ -n "$a" ] && is_public_address "$a" && { printf '%s' "$a"; return 0; } done < <(interface_addresses) return 0 } # dns_record_for DOMAIN: the record to create, in words. dns_record_for() { local a a=$(public_address) case $a in "") printf 'an A record %s -> this server'"'"'s public IP address (the one your hosting company shows)' "$1" ;; *:*) printf 'an AAAA record %s -> %s' "$1" "$a" ;; *) printf 'an A record %s -> %s' "$1" "$a" ;; esac } # Every address this server answers on, for matching a DNS record against. server_addresses() { local conn conn=$(ssh_connection) { [ -n "$conn" ] && printf '%s\n' "$(awk '{ print $3 }' <<<"$conn")" interface_addresses hostname -I 2>/dev/null | tr ' ' '\n' } | grep -v '^$' | sort -u | tr '\n' ' ' || true } # https_port_holders: the programs other than Caddy listening on port 80 or # 443 (Caddy needs both for its certificate), or nothing. https_port_holders() { command -v ss >/dev/null 2>&1 || return 0 { ss -H -ltnp 2>/dev/null || true; } | awk '$4 ~ /:(80|443)$/' \ | sed -n 's/.*users:(("\([^"]*\)".*/\1/p' | grep -vx caddy | sort -u | tr '\n' ' ' || true } # The domain the dashboard's Caddy site serves now, or nothing. caddy_site_domain() { [ -f "$R$P_CADDY_SNIPPET" ] || return 0 sed -n 's/^\([a-z0-9.-]\{1,253\}\) {$/\1/p' "$R$P_CADDY_SNIPPET" | head -n 1 } # setup_caddy DOMAIN [CIDR]: the dashboard on https://DOMAIN through Caddy. # Returns 1, with the reason and the fix said, when the domain cannot be used # yet: no DNS record or one pointing elsewhere, another program on port 80 or # 443, or Caddy that would not install, check or reload. Caddy is then left # as it was before: the domain served before is put back, or with none, the # dashboard's site is taken out, so a re-run never finds a broken snippet or # import line. The caller keeps the old domain or the SSH tunnel; a domain # never stops an install. setup_caddy() { local domain=$1 cidr=${2:-} resolved here verdict code tries=0 holders old_text="" had_old=0 step "Dashboard on https://$domain" if [ "$DRY" = 1 ]; then ok "[dry run] would check that $domain's DNS record points at this server, and that nothing else uses ports 80 and 443" else resolved=$(getent ahosts "$domain" 2>/dev/null | awk '{ print $1 }' | sort -u | tr '\n' ' ' || true) here=$(server_addresses) verdict=$(dns_verdict "$resolved" "$here") case $verdict in match) ok "DNS: $domain points at this server" ;; none) warn "$domain has no DNS record yet." note "To use $domain: at your domain's DNS provider add $(dns_record_for "$domain"), wait a few minutes, then run: $(again) --with-caddy $domain" return 1 ;; *) warn "$domain points at ${resolved% }, and this server's addresses are ${here% }." if ! yes_no " Some hosting companies hide the public address from the server. Is the record right?" N; then warn "$domain does not point at this server." note "To use $domain: at your domain's DNS provider change its record to $(dns_record_for "$domain"), wait a few minutes, then run: $(again) --with-caddy $domain" return 1 fi ;; esac holders=$(https_port_holders) if [ -n "$holders" ]; then warn "Ports 80 and 443, which HTTPS needs, are in use by: ${holders% }." note "To use $domain, the program on ports 80 and 443 (${holders% }) must make way for Caddy, or proxy $domain to 127.0.0.1:$(dashboard_port) itself. Then run: $(again) --with-caddy $domain" return 1 fi fi if [ -f "$R$P_CADDY_SNIPPET" ]; then old_text=$(cat -- "$R$P_CADDY_SNIPPET") had_old=1 fi if ! command -v caddy >/dev/null 2>&1; then if ! act_try "install Caddy from its official apt repository" install_caddy_package; then warn "Caddy could not be installed." note "Caddy did not install (apt). To see why: apt-get install caddy. Then run: $(again) --with-caddy $domain" return 1 fi fi if [ -n "$A_DASHBOARD_PORT" ]; then printf '%s\n' "$((10#$A_DASHBOARD_PORT))" | put_file "$P_DASHBOARD_PORT" 0600 root:root; fi local named named=$(bot_settings_port) if [ -n "$named" ] && [ "$named" != "$(dashboard_port)" ]; then warn "The bot's settings name port $named for the dashboard; $domain is sent to port $(dashboard_port)." note "If you changed the dashboard's port to $named yourself, tell the installer: $(again) --with-caddy $domain --dashboard-port $named" fi caddy_site "$domain" "$cidr" | put_file "$P_CADDY_SNIPPET" 0644 root:root act "have Caddy read $P_CADDY_SNIPPET" caddy_import_line if ! act_try "check Caddy's settings" quiet caddy validate --config "$P_CADDY_DIR/Caddyfile" --adapter caddyfile; then caddy_put_back "$had_old" "$old_text" warn "Caddy refused its settings with the dashboard's site in them." note "To see why: caddy validate --config $P_CADDY_DIR/Caddyfile --adapter caddyfile. Then run: $(again) --with-caddy $domain" return 1 fi if ! act_try "reload Caddy" quiet systemctl reload-or-restart caddy; then caddy_put_back "$had_old" "$old_text" warn "Caddy would not start with the dashboard's site." note "To see why: journalctl -u caddy -n 30. Then run: $(again) --with-caddy $domain" return 1 fi open_https_in_firewall set_dashboard_domain --domain "$domain" [ "$DRY" = 1 ] && return 0 # The certificate: Caddy gets one by itself; any answer over HTTPS means it has. while [ $tries -lt 30 ]; do code=$(curl -sS -o /dev/null --proto '=https' --max-time 10 -w '%{http_code}' "https://$domain/" 2>/dev/null || true) [ "${code:-000}" != 000 ] && break tries=$((tries + 1)) sleep 3 done if [ "${code:-000}" = 000 ]; then warn "The dashboard's domain does not answer yet. Caddy keeps trying to get its certificate for it: https://$domain" note "$domain did not answer over HTTPS during the install; Caddy keeps trying. If it stays that way: journalctl -u caddy -n 30" else ok "It answers with a certificate: https://$domain" fi return 0 } # caddy_put_back HAD_OLD OLD_TEXT: Caddy as it was before this run: the old # site block back (and Caddy reloaded with it), or the dashboard's site out. caddy_put_back() { if [ "$1" = 1 ]; then printf '%s\n' "$2" | put_file "$P_CADDY_SNIPPET" 0644 root:root act_try "reload Caddy with the site it had before" quiet systemctl reload-or-restart caddy || warn "Caddy could not be reloaded; check it with: systemctl status caddy" else remove_caddy_site fi } # Opens 80 and 443 when ufw is on, and remembers that this installer did, so # going back to the tunnel or uninstalling closes only what it opened. open_https_in_firewall() { command -v ufw >/dev/null 2>&1 || return 0 ufw status 2>/dev/null | head -n 1 | grep -q 'Status: active' || return 0 if ufw status 2>/dev/null | grep -Eq '^80,443/tcp +ALLOW'; then return 0; fi act "open ports 80 and 443 for HTTPS" quiet ufw allow 80,443/tcp printf 'ufw allow 80,443/tcp\n' | put_file "$P_UFW_MARK" 0600 root:root } close_https_in_firewall() { [ -f "$R$P_UFW_MARK" ] || return 0 act_try "close ports 80 and 443 again (opened by this installer)" quiet ufw delete allow 80,443/tcp || warn "Ports 80 and 443 could not be closed; close them with: ufw delete allow 80,443/tcp" act "forget that this installer opened them" rm -f -- "$R$P_UFW_MARK" } install_caddy_package() { apt_install ca-certificates curl gnupg curl -fsSL --proto '=https' https://dl.cloudsmith.io/public/caddy/stable/gpg.key | gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -fsSL --proto '=https' https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt > /etc/apt/sources.list.d/caddy-stable.list chmod o+r /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/sources.list.d/caddy-stable.list apt_install caddy } # Caddy's own Caddyfile imports ours. A fresh Caddy's sample site is replaced; # a Caddyfile somebody wrote keeps everything and gains one import line. caddy_import_line() { local file=$R$P_CADDY_DIR/Caddyfile line="import $P_CADDY_SNIPPET" if [ ! -f "$file" ] || grep -q 'The Caddyfile is an easy way to practice' "$file"; then printf '# Written by the SteamTradeX bot installer.\n%s\n' "$line" > "$file" elif ! grep -qxF "$line" "$file"; then printf '\n%s\n' "$line" >> "$file" fi } # Takes the dashboard's site out of Caddy (the snippet, and its import line # even when the snippet is already gone, since an import of a missing file # stops Caddy), closes 80 and 443 if this installer opened them, and leaves # the rest of Caddy as it was. remove_caddy_site() { local changed=0 if [ -f "$R$P_CADDY_SNIPPET" ]; then act "remove the dashboard's Caddy site" rm -f -- "$R$P_CADDY_SNIPPET" changed=1 fi if [ -f "$R$P_CADDY_DIR/Caddyfile" ] && grep -qxF "import $P_CADDY_SNIPPET" "$R$P_CADDY_DIR/Caddyfile"; then act "take the import line out of Caddy's settings" sed -i "\\|^import $P_CADDY_SNIPPET\$|d" "$R$P_CADDY_DIR/Caddyfile" changed=1 fi if [ "$changed" = 1 ] && command -v caddy >/dev/null 2>&1; then act_try "reload Caddy" quiet systemctl reload-or-restart caddy || warn "Caddy could not be reloaded; check it with: systemctl status caddy" fi close_https_in_firewall return 0 } # set_dashboard_domain --domain NAME | --clear: through the bot's own command # (the setting's check, never an edit of settings.json). A change needs the # running bot restarted, so RESTART_NEEDED is set when it changed anything. set_dashboard_domain() { local out if [ "$DRY" = 1 ]; then if [ "$1" = --clear ]; then ok "[dry run] would run: stx-bot dashboard-domain --clear (forgets a domain set before, if there is one)" else ok "[dry run] would run: stx-bot dashboard-domain --domain $2" fi return 0 fi out=$(run_bot dashboard-domain "$@" 2>&1 /dev/null | grep -E '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' || true) total=$(printf '%s' "$lines" | grep -c '' || true) if [ "$total" -gt "$shown" ]; then printf '%s\n' "$lines" | tail -n "+$((shown + 1))" | cut -d' ' -f2- | plain_text | cut -c1-160 | sed 's/^/ | /' shown=$total fi state=$(systemctl is-active stx-bot 2>/dev/null || true) if [ "$state" = failed ] || [ "$state" = inactive ]; then bot_stopped_report return 0 fi pid=$(systemctl show -p MainPID --value stx-bot 2>/dev/null || echo 0) health=$(head -c 65536 -- "$R$P_HOME/state/health.json" 2>/dev/null || true) if [[ $health == *"\"pid\":$pid,"* && $health == *'"steamConnected":true'* ]]; then SIGNED_IN=1 return 0 fi done return 0 } STOP_LINE="" bot_stopped_report() { local code last code=$(systemctl show -p ExecMainStatus --value stx-bot 2>/dev/null || echo "") last=$(journalctl -u stx-bot -o cat -n 40 --no-pager 2>/dev/null | grep -E ' (error|warn) ' | tail -n 1 | cut -d' ' -f3- | plain_text | cut -c1-300 || true) case $code in 78) STOP_LINE="The bot stopped because it needs something from you: ${last:-see journalctl -u stx-bot -n 50}" ;; *) STOP_LINE="The bot stopped (exit ${code:-unknown}): ${last:-see journalctl -u stx-bot -n 50}" ;; esac } start_and_check() { local was_running=$1 step "Starting the bot" if [ "$SETUP_DONE" != 1 ]; then ok "Not started: the setup is not finished. Finish with: ${S}stx-bot setup && ${S}systemctl start stx-bot" STOP_LINE="Finish the setup, then start the bot: ${S}stx-bot setup && ${S}systemctl start stx-bot" # Installed, but not running until the owner has answered the setup: # that is "the bot needs you" (2), whether the setup was skipped on # purpose (--skip-setup), left part way, or had no terminal to ask on. EXIT_STATUS=2 return 0 fi if [ "$was_running" = 1 ] && [ "$RESTART_NEEDED" = 0 ]; then ok "The bot is running already; not restarted (a restart costs a Steam sign in)" SIGNED_IN=1 return 0 fi if [ "$DRY" = 1 ]; then ok "[dry run] would start the bot and wait up to 3 minutes for it to sign in to Steam"; return 0; fi if [ "$was_running" = 1 ]; then act "restart the bot with the new version or settings (one Steam sign in)" systemctl restart stx-bot else act "start the bot" systemctl start stx-bot fi ok "Started. Waiting for it to sign in to Steam (up to 3 minutes); the bot's own lines:" follow_start if [ "$SIGNED_IN" = 1 ]; then ok "Your bot is signed in and trading." return 0 fi EXIT_STATUS=2 if [ -z "$STOP_LINE" ]; then STOP_LINE="The bot has not signed in after 3 minutes. It keeps trying; watch it with: journalctl -u stx-bot -f" fi say " $STOP_LINE" } # ---------------------------------------------------------------- summary # frame: the summary between two rules, as the guide shows it. frame() { local rule="======================================================================" printf '\n%s\n' "$rule" sed 's/^/ /' printf '%s\n' "$rule" } # sign_in_link_from: the link out of `stx-bot info --link`'s text, or nothing. sign_in_link_from() { sed -n 's/^ One-time sign in link: \(https\{0,1\}:\/\/[^[:space:]]\{1,400\}\)$/\1/p' | head -n 1 } # last_block PASSWORD LINK: the two things the owner must not miss, printed # after everything else with a blank line around them: the dashboard password # (only a first install has one to show) and the one-time sign in link. Each # stands alone on its own line, and nothing follows the link on its line. last_block() { local password=$1 link=$2 [ -n "$password$link" ] || return 0 printf '\n' if [ -n "$password" ]; then printf 'Your dashboard password, shown once. Save it now:\n' printf ' %s\n' "$password" printf '\n' fi if [ -n "$link" ]; then printf 'Your one-time sign in link. Open the dashboard as the summary above says, then open this link in the same browser. It works once, within 15 minutes:\n' printf ' %s\n' "$link" printf '\n' fi } # wait_for_enter: on a terminal, the install does not go back to the prompt # (where the password scrolls away with the next command) until the owner # says it is saved. With no terminal nothing waits. wait_for_enter() { local _ have_tty || return 0 printf 'Press Enter once you have saved it ' >/dev/tty IFS= read -r _ &1 /dev/null /dev/null 2>&1 && act "remove the $BOT_USER user" userdel "$BOT_USER" ok "Removed, with all its data." else ok "Not deleted (DELETE was not typed). The program is removed; $P_DATA is kept." fi else ok "Removed. Your settings and trade history are kept in $P_DATA; installing again picks them up." ok "To delete them too, later: ${S}rm -rf --one-file-system $P_DATA && ${S}userdel $BOT_USER" fi } # ---------------------------------------------------------------- re-running as root # this_file: the installer's own file when it was started from one (bash # install.sh), or nothing when it was piped in (curl ... | bash), where there # is no file sudo could run again. this_file() { local f=${BASH_SOURCE[-1]:-} [ -n "$f" ] && [ -f "$f" ] && [ -r "$f" ] && printf '%s' "$f" return 0 } # shell_words ARG...: the arguments as one line a shell reads back the same. shell_words() { local out="" a for a in "$@"; do out+=" $(printf '%q' "$a")"; done printf '%s' "$out" } # ensure_root ARG...: root goes on; anyone else runs this file again with # sudo (the same arguments). Root never calls sudo, so a root login on a # server without sudo works with plain `bash install.sh`. ensure_root() { local file [ "$(id -u)" = 0 ] && return 0 command -v sudo >/dev/null 2>&1 || stop "This needs root, and this server has no sudo" "Log in as root and run: bash install.sh$(shell_words "$@"), or install sudo first" file=$(this_file) # Piped in, there is no file to hand to sudo: the official form downloads it first. [ -n "$file" ] || stop "This needs root, and it was piped in, so it cannot run itself again with sudo" \ "Run: curl -fsSL $ORIGIN/install.sh -o install.sh && sudo bash install.sh$(shell_words "$@")" say "This needs root; running it again with sudo (it may ask for your password)." exec sudo bash "$file" "$@" } # ---------------------------------------------------------------- main FRESH_INSTALL=0 FRESH_DATA=0 FRESH_RELEASE=0 RESTART_NEEDED=0 NODE_ARCH="" KEY="" MACHINE_HASH="" M_VERSION="" M_NODE_VERSION="" M_CHANNEL="" M_MIN_VERSION="" M_WITHDRAWN="" EXIT_STATUS=0 # The Node version the install runs now, from the /opt/stx-bot/node/current link. installed_node_version() { local target v [ -L "$R$P_OPT/node/current" ] || return 0 target=$(readlink -- "$R$P_OPT/node/current") v=${target##*/} [[ $v =~ ^22\.[0-9]{1,4}\.[0-9]{1,4}$ ]] && printf '%s' "$v" return 0 } main() { [ -z "${STX_INSTALL_TEST_ROOT:-}" ] || stop "STX_INSTALL_TEST_ROOT is only for the installer's tests" trap 'on_unexpected_error $LINENO' ERR parse_args "$@" if [ "$A_HELP" = 1 ]; then usage; return 0; fi # A dry run changes nothing, so it may be read through without root first. [ "$DRY" = 1 ] || ensure_root "$@" export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin unset NODE_OPTIONS NODE_PATH # A root login on a server without sudo: the commands it prints leave sudo out. if [ "$(id -u)" = 0 ] && ! command -v sudo >/dev/null 2>&1; then S=""; fi if [ "$DRY" = 1 ]; then say "Dry run: every action is printed, nothing is changed and nothing is sent." [ "$(id -u)" = 0 ] || say "(Without sudo it cannot look inside the bot's own folder, so it reads it as a first install.)" fi if [ "$A_UNINSTALL" = 1 ]; then uninstall return 0 fi say "SteamTradeX bot installer $INSTALLER_VERSION" check_system check_network local current was_running=0 mid lid current=$(installed_version) [ -n "$current" ] || FRESH_INSTALL=1 [ -f "$R$P_HOME/settings.json" ] || FRESH_DATA=1 systemctl is-active --quiet stx-bot 2>/dev/null && was_running=1 if [ -n "$current" ]; then ok "Installed now: version $current (a re-run keeps its settings and data)"; fi check_existing_install get_licence_key mid=$(machine_id) || stop "This server has no machine id (/etc/machine-id is missing or empty), so the licence cannot be tied to it" "Run: ${S}systemd-machine-id-setup, then run this again (a new machine id counts as a new server for the licence)" if [ -n "$KEY" ]; then check_licence_key "$KEY" lid=$(licence_id_of "$KEY_COMPACT") MACHINE_HASH=$(machine_hash_of "$mid" "$lid") fi step "Getting the bot" local ask_for="" got=0 [ -n "$current" ] && [ "$A_REPAIR" = 0 ] && ask_for=$current if [ "$DRY" = 1 ]; then ok "[dry run] would ask $SITE for the newest release${ask_for:+ above $ask_for}, then check its signature with release key(s): $(awk '$1 == "KEY" { printf "%s (%s) ", $2, $3 }' <<<"$RELEASE_KEYS")" ok "[dry run] would refuse a release that is not for this server: a test build (this server takes $(allowed_channel)), one older than $(release_floor), or one withdrawn" ok "[dry run] would download Node.js from nodejs.org and the bot from $SITE, each checked against the SHA-256 in the signed release, and check every unpacked file" M_VERSION=${current:-NEW} M_NODE_VERSION=$(installed_node_version) M_NODE_VERSION=${M_NODE_VERSION:-22.x.y} else WORK=$(mktemp -d) chmod 700 "$WORK" trap 'rm -rf -- "$WORK"' EXIT fetch_latest "$WORK" "$ask_for" || got=$? local newest_line="You have the newest version ($current)" if [ "$got" != 2 ]; then verify_manifest "$WORK" manifest_fields "$WORK/manifest.json" "$NODE_ARCH" ok "Release $M_VERSION ($M_CHANNEL): signature verified with release key $M_KID" # Signed is not enough: its channel, the floor and the withdrawn list are this server's own to check. check_release_rules # A version the updater rolled back on this server is never put back by hand either. if version_marked_bad "$M_VERSION"; then [ -n "$current" ] || stop "Version $M_VERSION was rolled back on this server before (it did not come up here), so it is not installed again" "Wait for the next version, or tell SteamTradeX support" warn "Version $M_VERSION was rolled back on this server before (it did not come up here), so it is not installed again." if [ "$A_REPAIR" = 1 ]; then # A repair puts right the version that runs instead. repair_current_release "$current" "$WORK" ensure_user_and_folders repair_kept_releases "$WORK" act "make everything under $P_OPT root's, writable by root only, with no setuid or setgid bit" root_only "$R$P_OPT" FRESH_RELEASE=1 RESTART_NEEDED=1 got=3 else newest_line="Version $current stays" got=2 fi fi fi if [ "$got" = 3 ]; then ok "Version $current repaired" elif [ "$got" = 2 ]; then [ -n "$current" ] || stop "No release of the bot is published yet" "Try again later, or ask on the SteamTradeX Discord" ok "$newest_line" M_VERSION=$current M_NODE_VERSION=$(installed_node_version) else if [ -n "$current" ]; then case $(semver_cmp "$M_VERSION" "$current") in 1) ;; 0) [ "$A_REPAIR" = 1 ] || stop "SteamTradeX offered the version already installed" "Run the same command again later; if it says the same, tell SteamTradeX support" ;; *) stop "SteamTradeX offered $M_VERSION, older than $current; a version is never installed over a newer one" "Tell SteamTradeX support; the version you have keeps running" ;; esac fi if [ "$A_REPAIR" = 1 ]; then install_node "$WORK" repair; else install_node "$WORK"; fi ensure_user_and_folders if [ "$A_REPAIR" = 1 ] && [ "$M_VERSION" = "$current" ]; then install_release "$WORK" repair; else install_release "$WORK"; fi if [ "$A_REPAIR" = 1 ]; then repair_kept_releases "$WORK" act "make everything under $P_OPT root's, writable by root only, with no setuid or setgid bit" root_only "$R$P_OPT" fi FRESH_RELEASE=1 RESTART_NEEDED=1 fi fi step "Laying out the files" ensure_user_and_folders remember_release_rules if [ "$FRESH_RELEASE" = 1 ] || [ "$DRY" = 1 ]; then point_link "$P_OPT/node/current" "$P_OPT/node/$M_NODE_VERSION" point_link "$P_OPT/current" "$P_OPT/releases/$M_VERSION" point_link "$P_OPT/updater" "$P_OPT/current/updater" [ -z "$current" ] || [ "$current" = "$M_VERSION" ] || prune_releases "$M_VERSION" "$current" fi ok "Program: $P_OPT/releases/$M_VERSION (Node.js ${M_NODE_VERSION:-unknown}); data: $P_DATA" write_service_files assert_ownership step "The bot's folder and licence" make_bot_folder activate_licence forget_key run_setup offer_firewall choose_dashboard_route enable_units start_and_check "$was_running" print_summary # The status is this run's own answer (2: the bot needs you), not a step # that failed: it must never be reported as one. trap - ERR # A dry run that got to its end installed nothing, so nothing needs the owner: 0, as the header says. if [ "$DRY" = 1 ]; then EXIT_STATUS=0; fi return "$EXIT_STATUS" } if [ "${STX_INSTALL_SOURCED:-}" != 1 ]; then main "$@" fi