#!/usr/bin/env bash # TrixOS installer: curl -fsSL https://trixos.sh/install | bash # # Installs (or updates) TrixOS on a fresh Ubuntu 22.04 / 24.04 or Debian 12 / 13 server (x86_64): # Docker, the latest release package from trixos.sh (its signed release record is verified with the # release key built into this script, then the package against the signed SHA-256), a generated .env # with fresh secrets, the containers, HTTPS with a Let's Encrypt certificate (--domain), and an admin # account. Afterwards: open the panel and activate the free Community Edition (up to 10 servers) or # your license key under Settings → License. # # Options (environment variables or arguments): # --domain panel.example.com serve the panel over HTTPS on this name (Let's Encrypt; ports 80 and 443, # DNS must point here) (TRIXOS_DOMAIN) # --email you@… e-mail for the Let's Encrypt account (optional) (ACME_EMAIL) # --key TRX-… activate this Pro / Enterprise key on start (TRIXOS_LICENSE_KEY) # --dir /opt/trixos install directory (TRIXOS_DIR) # --url https://… public URL of the panel (default: https://, else http://:) # (TRIXOS_PUBLIC_URL) # --port 8088 HTTP port of the panel (TRIXOS_PORT) # --version X.Y.Z a specific release instead of the latest (TRIXOS_VERSION) # --allow-downgrade allow --version older than the installed one (the database is not downgraded!) # --download-only DIR fetch and verify the release and unpack it into DIR; install nothing # Running it again updates an existing installation (keeps .env and the data); with --domain it also # turns HTTPS on for an existing installation. set -euo pipefail RELEASES="${TRIXOS_RELEASES:-https://trixos.sh/releases}" # Trusted release signing keys: "=" separated by spaces. The same # keys as packages/core/src/release-keys.ts and deploy/updater/trixos-update (scripts/release-sign.ts pin # updates all three). PINNED_KEYS="trx-rel-1=LCiD9FE9JIvyb6CTv90dXCMzt7NsF13lAl8DJPGKYi0=" DIR="${TRIXOS_DIR:-/opt/trixos}" KEY="${TRIXOS_LICENSE_KEY:-}" URL="${TRIXOS_PUBLIC_URL:-}" PORT="${TRIXOS_PORT:-8088}" WANT="${TRIXOS_VERSION:-}" DOMAIN="${TRIXOS_DOMAIN:-}" EMAIL="${ACME_EMAIL:-}" DOWNGRADE=0 ONLY="" while [ $# -gt 0 ]; do case "$1" in --domain) DOMAIN="$2"; shift 2 ;; --email) EMAIL="$2"; shift 2 ;; --allow-downgrade) DOWNGRADE=1; shift ;; --download-only) ONLY="$2"; shift 2 ;; --key) KEY="$2"; shift 2 ;; --dir) DIR="$2"; shift 2 ;; --url) URL="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;; --version) WANT="$2"; shift 2 ;; -h|--help) sed -n '2,27p' "$0" 2>/dev/null || true; exit 0 ;; *) echo "unknown option $1" >&2; exit 2 ;; esac done b=$'\e[1m'; d=$'\e[2m'; g=$'\e[32m'; r=$'\e[31m'; n=$'\e[0m' [ -t 1 ] || { b=; d=; g=; r=; n=; } step() { printf '%s==>%s %s\n' "$b" "$n" "$*"; } die() { printf '%serror:%s %s\n' "$r" "$n" "$*" >&2; exit 1; } case "$RELEASES" in https://*) ;; *) die "TRIXOS_RELEASES must be an https:// URL" ;; esac DOMAIN=$(printf '%s' "$DOMAIN" | tr 'A-Z' 'a-z') if [ -n "$DOMAIN" ]; then [[ "$DOMAIN" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] || die "--domain must be a host name like panel.example.com" fi if [ -n "$EMAIL" ]; then [[ "$EMAIL" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,63}$ ]] || die "--email is not an e-mail address" fi # ── the signed release record (docs/RELEASES.md): eight lines, the last one an Ed25519 signature over the # first seven, made with a key pinned above. Anything else is refused before the package is unpacked. verify_record() { # ; sets SHA, KID, COMMIT local f="$1" v="$2" k pub="" local -a L mapfile -t L < <(head -c 2048 "$f") [ "${#L[@]}" -eq 8 ] && [ "${L[0]}" = "trixos-release 1" ] || die "the release record of $v is malformed: refusing to install it" local -a RE=("^version ${v//./\\.}$" "^file trixos-${v//./\\.}\\.tar\\.gz$" '^sha256 [0-9a-f]{64}$' '^commit [0-9a-f]{40}$' \ '^date [0-9]{4}-[0-9]{2}-[0-9]{2}$' '^key [A-Za-z0-9._-]{1,32}$' '^signature [A-Za-z0-9+/]{86}==$') for k in 0 1 2 3 4 5 6; do [[ "${L[$((k + 1))]}" =~ ${RE[$k]} ]] || die "the release record of $v is malformed (${L[$((k + 1))]%% *}): refusing to install it" done SHA=${L[3]#sha256 }; COMMIT=${L[4]#commit }; KID=${L[6]#key } for k in $PINNED_KEYS; do [ "${k%%=*}" = "$KID" ] && pub=${k#*=}; done [ -n "$pub" ] || die "release $v is signed with an unknown key ($KID): refusing to install it" printf '%s\n' "${L[@]:0:7}" > "$tmp/msg" printf '%s' "${L[7]#signature }" | base64 -d > "$tmp/sig.bin" 2>/dev/null || die "the release record of $v has a broken signature" { printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00'; printf '%s' "$pub" | base64 -d; } > "$tmp/pub.der" openssl pkey -pubin -inform DER -in "$tmp/pub.der" -out "$tmp/pub.pem" 2>/dev/null || die "the pinned key $KID is invalid" openssl pkeyutl -verify -pubin -inkey "$tmp/pub.pem" -rawin -in "$tmp/msg" -sigfile "$tmp/sig.bin" >/dev/null 2>&1 \ || die "the signature of release $v does not verify: refusing to install it" } # ── download and verify a release into $tmp/trixos- (sets VERSION, FILE, SHA) fetch_release() { local meta meta=$(curl -fsSL --proto '=https' --tlsv1.2 "$RELEASES/latest.json") || die "cannot reach $RELEASES" VERSION=$(printf '%s' "$meta" | sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' | head -n1) [ -n "$WANT" ] && VERSION="$WANT" [[ "$VERSION" =~ ^[0-9]{1,4}\.[0-9]{1,4}\.[0-9]{1,4}$ ]] || die "invalid release index (version '$VERSION')" FILE="trixos-$VERSION.tar.gz" curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp/record" "$RELEASES/$FILE.sig" \ || die "release $VERSION has no signed release record ($FILE.sig): refusing to install it" verify_record "$tmp/record" "$VERSION" curl -fsSL --proto '=https' --tlsv1.2 -o "$tmp/$FILE" "$RELEASES/$FILE" || die "download of $FILE failed" echo "$SHA $tmp/$FILE" | sha256sum -c --quiet - >/dev/null 2>&1 || die "$FILE does not match its signed checksum: refusing to install it" tar -xzf "$tmp/$FILE" -C "$tmp" || die "$FILE cannot be unpacked" [ "$(tr -dc '0-9.' < "$tmp/trixos-$VERSION/VERSION" 2>/dev/null)" = "$VERSION" ] || die "$FILE holds another version" } tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT if [ -n "$ONLY" ]; then for t in curl openssl tar sha256sum base64; do command -v "$t" >/dev/null || die "$t is needed"; done step "Release" fetch_release mkdir -p "$ONLY" && cp -a "$tmp/trixos-$VERSION/." "$ONLY/" printf ' TrixOS %s %s(signed by %s, sha256 %s, commit %s)%s\n unpacked into %s\n' "$VERSION" "$d" "$KID" "$SHA" "$COMMIT" "$n" "$ONLY" exit 0 fi [ "$(id -u)" = 0 ] || die "run as root (sudo bash)" [ "$(uname -m)" = x86_64 ] || die "TrixOS needs an x86_64 server" . /etc/os-release 2>/dev/null || die "unknown operating system" case "$ID:${VERSION_ID:-}" in ubuntu:22.04|ubuntu:24.04|debian:12|debian:13) ;; *) [ "${TRIXOS_SKIP_OS_CHECK:-}" = 1 ] || die "supported: Ubuntu 22.04 / 24.04, Debian 12 / 13 (this is $PRETTY_NAME)" ;; esac mem=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo) [ "$mem" -ge 1800 ] || die "at least 2 GB RAM needed (${mem} MB found)" step "Packages" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq curl ca-certificates openssl tar util-linux iproute2 >/dev/null if ! command -v docker >/dev/null 2>&1 || ! docker compose version >/dev/null 2>&1; then step "Docker" curl -fsSL https://get.docker.com | sh >/dev/null fi systemctl enable --now docker >/dev/null 2>&1 || true step "Release" fetch_release printf ' TrixOS %s %s(signed by %s, sha256 %s)%s\n' "$VERSION" "$d" "$KID" "$SHA" "$n" # Only an empty directory or an existing TrixOS installation is touched. case "$DIR" in /*) ;; *) die "--dir must be an absolute path" ;; esac case "${DIR%/}" in ""|/|/root|/home|/opt|/usr|/usr/*|/etc|/etc/*|/var|/srv|/boot|/bin|/sbin|/lib|/lib64|/tmp) die "refusing to install into ${DIR}: use a directory of its own, e.g. /opt/trixos" ;; esac update=0 if [ -d "$DIR" ] && [ -n "$(ls -A "$DIR" 2>/dev/null)" ]; then [ -f "$DIR/VERSION" ] && [ -f "$DIR/docker-compose.yml" ] && [ -f "$DIR/.env" ] || die "$DIR is not empty and not a TrixOS installation" update=1 have=$(tr -dc '0-9.' < "$DIR/VERSION") if [ "$(printf '%s\n%s\n' "$have" "$VERSION" | sort -V | head -n1)" = "$VERSION" ] && [ "$have" != "$VERSION" ] && [ $DOWNGRADE = 0 ]; then die "$VERSION is older than the installed $have (the database is never downgraded); use --allow-downgrade only with a backup" fi fi https_on=0 [ -n "$DOMAIN" ] && https_on=1 if [ $https_on = 1 ] && ! { [ $update = 1 ] && grep -q '^COMPOSE_PROFILES=.*https' "$DIR/.env"; }; then # Caddy needs 80 (certificate challenge, redirect) and 443 on this host busy=$(ss -Hltn '( sport = :80 or sport = :443 )' 2>/dev/null | awk '{print $4}' | sort -u | tr '\n' ' ') [ -z "$busy" ] || die "ports 80/443 are in use ($busy): stop that web server or install without --domain and put your own proxy in front" addrs=$(getent ahosts "$DOMAIN" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') mine=" $(hostname -I 2>/dev/null) " hit=0; for a in $addrs; do case "$mine" in *" $a "*) hit=1 ;; esac; done [ $hit = 1 ] || printf ' %swarning: %s resolves to "%s", not to this server (%s): the certificate will only be issued once DNS points here (or Cloudflare proxies it with SSL "Full (strict)")%s\n' "$r" "$DOMAIN" "${addrs% }" "$(hostname -I 2>/dev/null | xargs)" "$n" fi mkdir -p "$DIR" # Replace the release files; .env, isos/ and the Docker volumes (database) stay. find "$DIR" -mindepth 1 -maxdepth 1 ! -name .env ! -name isos ! -name backups ! -name update -exec rm -rf {} + cp -a "$tmp/trixos-$VERSION/." "$DIR/" mkdir -p "$DIR/isos" && chown 1000:1000 "$DIR/isos" cd "$DIR" # host updater for one-click updates (Admin → Update under the version); before the containers start if [ -f deploy/updater/install-updater.sh ]; then bash deploy/updater/install-updater.sh --dir "$DIR" --refresh || printf ' %sone-click updates unavailable: the updater could not be installed%s\n' "$d" "$n" fi set_env() { if grep -q "^$1=" .env; then sed -i "s|^$1=.*|$1=$2|" .env; else echo "$1=$2" >> .env; fi; } if [ $update = 0 ]; then step "Configuration" ip=$(hostname -I 2>/dev/null | awk '{print $1}') if [ -z "$URL" ]; then if [ $https_on = 1 ]; then URL="https://$DOMAIN"; else URL="http://${ip:-localhost}:$PORT"; fi fi pg=$(openssl rand -hex 24) cp .env.example .env set_env NODE_ENV production set_env TRIXOS_PUBLIC_URL "$URL" set_env TRIXOS_MASTER_KEY "$(openssl rand -base64 32)" set_env TRIXOS_SESSION_SECRET "$(openssl rand -hex 32)" set_env POSTGRES_PASSWORD "$pg" set_env WEB_PORT "$PORT" set_env MIGRATE_ON_START true set_env TRIXOS_ALLOW_DESTRUCTIVE true [ -n "$KEY" ] && set_env TRIXOS_LICENSE_KEY "$KEY" # no reverse proxy in front: the web container believes no X-Forwarded-For (changed below with --domain) set_env TRIXOS_REAL_IP_FROM none chmod 600 .env else step "Updating the installation in $DIR" [ -n "$KEY" ] && set_env TRIXOS_LICENSE_KEY "$KEY" if [ $https_on = 1 ]; then [ -n "$URL" ] || URL="https://$DOMAIN" set_env TRIXOS_PUBLIC_URL "$URL" fi fi if [ $https_on = 1 ]; then # built-in HTTPS (compose profile "https", deploy/caddy/https.Caddyfile): Caddy on ports 80/443 gets the # certificate and is the only way in; the panel's own port is bound to 127.0.0.1 and trusts only it. set_env TRIXOS_DOMAIN "$DOMAIN" set_env ACME_EMAIL "$EMAIL" set_env COMPOSE_PROFILES https set_env WEB_BIND_ADDRESS 127.0.0.1 set_env TRIXOS_REAL_IP_FROM local # an older WEB_PORT=127.0.0.1:8088 (address in the port) becomes WEB_BIND_ADDRESS + a plain port wp=$(sed -n 's/^WEB_PORT=//p' .env | head -n1) case "$wp" in *:*) set_env WEB_PORT "${wp##*:}" ;; esac fi step "Containers (first build takes a few minutes)" docker compose up -d --build --remove-orphans >/dev/null for i in $(seq 1 90); do docker compose exec -T api node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1 && break [ "$i" = 90 ] && die "the API did not come up: docker compose logs api" sleep 2 done step "KVM viewer image (browser consoles for vendor KVMs)" docker compose --profile tools build kvm-viewer >/dev/null 2>&1 || printf ' %sskipped: build it later with docker compose --profile tools build kvm-viewer%s\n' "$d" "$n" if [ $https_on = 1 ]; then for i in $(seq 1 45); do curl -fsS -o /dev/null --max-time 5 "https://$DOMAIN/healthz" 2>/dev/null && break [ "$i" = 45 ] && printf ' %sHTTPS is not answering yet: the certificate is issued as soon as %s points to this server and ports 80/443 are reachable (docker compose logs https)%s\n' "$d" "$DOMAIN" "$n" sleep 2 done elif ! grep -q '^COMPOSE_PROFILES=.*https' .env; then printf '\n %swarning: the panel is served over plain HTTP on port %s. Use --domain for HTTPS with a\n Let'"'"'s Encrypt certificate, or put your own HTTPS proxy in front (docs: reference/security).%s\n' "$r" "$(sed -n 's/^WEB_PORT=//p' .env | head -n1)" "$n" fi if [ $update = 0 ]; then pw=$(openssl rand -base64 18 | tr -d '/+=' | cut -c1-20) docker compose exec -T api trixos user:create --username admin --name Administrator --role admin --password "$pw" >/dev/null printf '\n%sTrixOS %s is running.%s\n\n' "$g$b" "$VERSION" "$n" printf ' Panel %s\n Username admin\n Password %s %s(shown once; change it under Profile)%s\n\n' "$URL" "$pw" "$d" "$n" if [ -n "$KEY" ]; then printf ' Your license key is activated on start; check it under License.\n' else printf ' Next: open Settings → License in the panel and activate the free Community Edition\n (up to 10 servers, with your email address) or enter a license key.\n' fi printf ' Docs: https://docs.trixos.sh/installation/quick-start/\n\n' else printf '\n%sTrixOS updated to %s.%s\n' "$g$b" "$VERSION" "$n" fi