#!/bin/sh # curl -fsSL https://levelrail.com/install.sh | sudo sh # # Installs the levelrail control plane binary, a systemd unit, and Docker # (if missing) on a single Linux host. # # Usage: install.sh [install|check|upgrade|retain|install-cosign|uninstall] [--force] [--purge] # [--coexist|--no-coexist] [--yes] [--no-cosign] [--http-port N] # [--https-port N] [--domain HOST] # install (default) preflight checks, then install or repair. Safe to re-run. # check (also --check) read-only server readiness report: who holds ports # 80/443, Docker version and isolation, exposed Docker API, disk, # memory, firewall, and the DNS of --domain. Prints the recommended # install mode and the exact next command. Changes nothing, runs # without root (port owners are then only partly visible). # upgrade replace the binary with a newer release and restart, keeping # the unit file and data. The previous binaries (last 3) are # kept in ${LEVELRAIL_INSTALL_DIR}/levelrail.releases for rollback. # retain download and verify LEVELRAIL_VERSION and keep it beside the # installed binary, without installing it. Then run # `sudo levelrail rollback --to ` to switch to it. # install-cosign install the pinned cosign release to /usr/local/bin/cosign # so release signatures can be verified. install and upgrade do # this on their own when cosign is missing. # uninstall remove the service, unit, and binary. Data is kept unless # --purge is given. # --force continue even if a preflight check fails. # --coexist run behind a proxy that already holds ports 80/443: ingress on # 8088/8443, dashboard on loopback only. Chosen automatically # when 80/443 are taken by something else. # --no-coexist do not switch automatically: a taken 80/443 fails preflight # (same as LEVELRAIL_COEXIST=0). # --yes, -y accepted for scripts. The installer no longer prompts: it # switches to coexist mode by itself when 80/443 are taken. # --no-cosign do not install cosign (same as LEVELRAIL_NO_COSIGN=1). Releases # are then verified by SHA-256 checksum only. # --http-port / --https-port ingress ports (override the LEVELRAIL_* env). # --domain your domain, only used to print the exact proxy command. # # Chosen ports and addresses are written to a systemd drop-in, # /etc/systemd/system/levelrail.service.d/10-install.conf, which `upgrade` # reads back and never resets. # # Env overrides: # LEVELRAIL_VERSION release tag to install, e.g. v0.3.0 # LEVELRAIL_CHANNEL stable or beta. Unset means the newest stable # release, falling back to the newest pre-release # while no stable release exists. # LEVELRAIL_INSTALL_DIR where the binary goes (default: /usr/local/bin) # LEVELRAIL_DATA_DIR control plane data dir (default: /var/lib/levelrail-data) # LEVELRAIL_BINARY_FILE install this local binary instead of downloading # LEVELRAIL_BINARY_URL download the binary from this URL instead of a # GitHub release (no checksum verification) # LEVELRAIL_SKIP_CHECKSUM=1 install even when checksums.txt is missing or # does not list the binary (unverified; opt-out only) # LEVELRAIL_NO_COSIGN=1 never install cosign (see --no-cosign) # LEVELRAIL_COSIGN_URL download cosign from this URL instead of the pinned # sigstore release (needs LEVELRAIL_COSIGN_SHA256; # for tests and air-gapped mirrors) # LEVELRAIL_COSIGN_BIN where cosign is installed (default: /usr/local/bin/cosign) # APP_INSTALL_VERIFY signature check on checksums.txt (cosign keyless): # auto (default) verifies when cosign is installed # and the release ships a signature; require fails # unless the signature verifies; off skips it. The # SHA-256 checksum is always verified. # LEVELRAIL_RELEASE_BASE_URL download release assets from this https mirror # directory instead of GitHub (LEVELRAIL_INSECURE_MIRROR=1 # permits http, for tests only) # LEVELRAIL_INSTALL_CLI 0 skips installing levelrail-cli to /usr/local/bin # (default: installed from the same release) # LEVELRAIL_PUBLIC_IP skip public IP discovery # LEVELRAIL_SKIP_REACHABILITY=1 skip the external port 80/443 self-test # LEVELRAIL_MIN_RAM_MB RAM warning threshold (default: 1024) # LEVELRAIL_MIN_DISK_GB free disk requirement (default: 10) # LEVELRAIL_MIN_DOCKER_MAJOR minimum Docker major version (default: 24) # LEVELRAIL_HEALTH_WAIT seconds to wait for the service (default: 60) # LEVELRAIL_CONFIGURE_UFW set to 1 to allow SSH, 80/tcp, 443/tcp, and 443/udp in # ufw and enable it if inactive. Off by default. # LEVELRAIL_DASHBOARD_PORT dashboard/API port (default: 8080). If taken and # left at its default, the installer picks the # next free port automatically; set this to pin one. # LEVELRAIL_HTTP_PORT ingress HTTP port (default: 80) # LEVELRAIL_HTTPS_PORT ingress HTTPS port (default: 443). Moving either # ingress port off 80/443 disables automatic ACME # TLS (Let's Encrypt only validates those two), so # the installer never does this on its own; it # fails preflight instead and tells you to set # these explicitly once you've accepted that trade. # LEVELRAIL_COEXIST 1 is the same as --coexist # LEVELRAIL_COEXIST_HTTP_PORT / LEVELRAIL_COEXIST_HTTPS_PORT ingress ports # coexist mode uses (default: 8088 / 8443) # APP_PUBLIC_HOST written to the drop-in when set # LEVELRAIL_SOCKET_ACTIVATION systemd owns the ingress sockets, so a # restart or upgrade of the control plane queues # connections instead of refusing them (see # docs/resilience.md). On by default for a new # install; set 0 to opt out (the control plane # then binds 80/443 itself). On upgrade, unset # keeps the current mode, 1 switches an existing # install over (one brief stop) and 0 switches # it back. set -eu REPO="glincker/levelrail" BINARY_NAME="levelrail" SERVICE_NAME="levelrail" INSTALL_DIR="${LEVELRAIL_INSTALL_DIR:-/usr/local/bin}" DATA_DIR="${LEVELRAIL_DATA_DIR:-/var/lib/levelrail-data}" UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service" SOCKET_ACTIVATION="${LEVELRAIL_SOCKET_ACTIVATION:-}" HTTP_SOCKET_UNIT="${SERVICE_NAME}-http.socket" HTTPS_SOCKET_UNIT="${SERVICE_NAME}-https.socket" BIN_PATH="${INSTALL_DIR}/${BINARY_NAME}" RELEASES_DIR="${LEVELRAIL_RELEASES_DIR:-${INSTALL_DIR}/${BINARY_NAME}.releases}" DASHBOARD_PORT="${LEVELRAIL_DASHBOARD_PORT:-8080}" DASHBOARD_PORT_PINNED=0 [ -z "${LEVELRAIL_DASHBOARD_PORT:-}" ] || DASHBOARD_PORT_PINNED=1 DASHBOARD_BIND="" HTTP_PORT="${LEVELRAIL_HTTP_PORT:-80}" HTTPS_PORT="${LEVELRAIL_HTTPS_PORT:-443}" HTTP_PORT_PINNED=0 HTTPS_PORT_PINNED=0 [ -z "${LEVELRAIL_HTTP_PORT:-}" ] || HTTP_PORT_PINNED=1 [ -z "${LEVELRAIL_HTTPS_PORT:-}" ] || HTTPS_PORT_PINNED=1 COEXIST=0 NO_COEXIST=0 [ "${LEVELRAIL_COEXIST:-}" != "1" ] || COEXIST=1 [ "${LEVELRAIL_COEXIST:-}" != "0" ] || NO_COEXIST=1 COEXIST_ACTIVE=0 SETTINGS_CHANGED=0 COEXIST_HTTP_PORT="${LEVELRAIL_COEXIST_HTTP_PORT:-8088}" COEXIST_HTTPS_PORT="${LEVELRAIL_COEXIST_HTTPS_PORT:-8443}" DOMAIN_HINT="${LEVELRAIL_DOMAIN:-}" PUBLIC_HOST="${APP_PUBLIC_HOST:-}" PUBLIC_HOST_PINNED=0 [ -z "$PUBLIC_HOST" ] || PUBLIC_HOST_PINNED=1 DROPIN_DIR="/etc/systemd/system/${SERVICE_NAME}.service.d" DROPIN_PATH="${DROPIN_DIR}/10-install.conf" PROXY_DOCS_URL="https://levelrail.com/behind-an-existing-proxy" # The dashboard is plain HTTP until the operator configures a domain with TLS. DASHBOARD_SCHEME="http" HTTPS_ONLY="=https" MIN_RAM_MB="${LEVELRAIL_MIN_RAM_MB:-1024}" MIN_DISK_GB="${LEVELRAIL_MIN_DISK_GB:-10}" MIN_DOCKER_MAJOR="${LEVELRAIL_MIN_DOCKER_MAJOR:-24}" HEALTH_WAIT="${LEVELRAIL_HEALTH_WAIT:-60}" VERIFY_MODE="${APP_INSTALL_VERIFY:-auto}" COSIGN_IDENTITY_REGEXP="https://github.com/${REPO}/\\.github/workflows/release\\.yml@.*" COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com" COSIGN_VERSION="v3.1.3" # SHA-256 of sigstore/cosign ${COSIGN_VERSION} cosign-linux-, from the release's cosign_checksums.txt. COSIGN_SHA256_AMD64="4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71" COSIGN_SHA256_ARM64="c5d324e091826b0d7a78eb16fef316450b4eb9aaec045611c08ba06f5e73220a" COSIGN_BIN="${LEVELRAIL_COSIGN_BIN:-/usr/local/bin/cosign}" NO_COSIGN=0 [ "${LEVELRAIL_NO_COSIGN:-0}" != "1" ] || NO_COSIGN=1 log() { printf '%s\n' "$*"; } warn() { printf 'warning: %s\n' "$*" >&2; } fatal() { printf 'error: %s\n' "$*" >&2 exit 1 } usage() { cat <<'EOF' Usage: install.sh [install|check|upgrade|retain|install-cosign|uninstall] [--force] [--purge] [--coexist|--no-coexist] [--yes] [--no-cosign] [--http-port N] [--https-port N] [--domain HOST] install (default) preflight checks, then install or repair check (also --check) read-only readiness report and recommended mode upgrade replace the binary with a newer release and restart retain download and verify LEVELRAIL_VERSION and keep it for rollback install-cosign install cosign so release signatures can be verified uninstall remove the service, unit, and binary (data kept unless --purge) --force continue even if a preflight check fails --coexist ingress on 8088/8443 and dashboard on loopback, for a server whose ports 80/443 an existing proxy already holds (chosen automatically when they are taken) --no-coexist fail preflight instead of switching to coexist mode --yes accepted for scripts, the installer never prompts --no-cosign do not install cosign (checksum-only verification) EOF } MODE="install" FORCE=0 PURGE=0 while [ "$#" -gt 0 ]; do arg="$1" shift case "$arg" in --http-port | --https-port | --domain) [ "$#" -gt 0 ] || fatal "$arg needs a value" arg="$arg=$1" shift ;; esac case "$arg" in install | check | upgrade | retain | install-cosign | uninstall) MODE="$arg" ;; --check) MODE="check" ;; --no-coexist) NO_COEXIST=1 ;; --no-cosign) NO_COSIGN=1 ;; --force) FORCE=1 ;; --purge) PURGE=1 ;; --coexist) COEXIST=1 NO_COEXIST=0 ;; --yes | -y) ;; --http-port=*) HTTP_PORT="${arg#*=}" HTTP_PORT_PINNED=1 ;; --https-port=*) HTTPS_PORT="${arg#*=}" HTTPS_PORT_PINNED=1 ;; --domain=*) DOMAIN_HINT="${arg#*=}" ;; -h | --help) usage exit 0 ;; *) fatal "unknown argument: $arg (see --help)" ;; esac done [ "$PURGE" -eq 0 ] || [ "$MODE" = "uninstall" ] || fatal "--purge only applies to uninstall" case "$VERIFY_MODE" in auto | require | off) ;; *) fatal "APP_INSTALL_VERIFY must be auto, require or off, got: $VERIFY_MODE" ;; esac # These values land in a systemd unit and, for DATA_DIR, in rm -rf on purge. safe_dir() { case "$2" in / | /bin | /boot | /dev | /etc | /home | /lib | /lib64 | /opt | /proc | /root | /run | /sbin | /srv | /sys | /tmp | /usr | /usr/bin | /usr/local | /usr/local/bin | /var | /var/lib) if [ "$1" = "LEVELRAIL_DATA_DIR" ]; then fatal "$1 must not be a system directory: $2"; fi ;; esac case "$2" in /*) ;; *) fatal "$1 must be an absolute path: $2" ;; esac case "$2" in *[!A-Za-z0-9._/-]* | *..*) fatal "$1 may only contain letters, digits, dot, dash, underscore and slash, and no '..': $2" ;; esac } safe_port() { case "$2" in '' | *[!0-9]*) fatal "$1 must be a port number: $2" ;; esac { [ "$2" -ge 1 ] && [ "$2" -le 65535 ]; } || fatal "$1 must be between 1 and 65535: $2" } safe_host() { case "$2" in '') return 0 ;; *[!A-Za-z0-9.:-]*) fatal "$1 may only contain letters, digits, dot, colon and dash: $2" ;; esac } safe_dir LEVELRAIL_DATA_DIR "$DATA_DIR" safe_dir LEVELRAIL_INSTALL_DIR "$INSTALL_DIR" safe_port LEVELRAIL_DASHBOARD_PORT "$DASHBOARD_PORT" safe_port LEVELRAIL_HTTP_PORT "$HTTP_PORT" safe_port LEVELRAIL_HTTPS_PORT "$HTTPS_PORT" safe_port LEVELRAIL_COEXIST_HTTP_PORT "$COEXIST_HTTP_PORT" safe_port LEVELRAIL_COEXIST_HTTPS_PORT "$COEXIST_HTTPS_PORT" safe_host APP_PUBLIC_HOST "$PUBLIC_HOST" safe_host --domain "$DOMAIN_HINT" case "${LEVELRAIL_VERSION:-}" in *[!A-Za-z0-9._+-]*) fatal "LEVELRAIL_VERSION may only contain letters, digits, dot, dash, plus and underscore" ;; esac [ "$MODE" = "check" ] || [ "$(id -u)" -eq 0 ] || fatal "must run as root, e.g.: curl -fsSL | sudo sh" github_api() { curl -fsSL --proto "$HTTPS_ONLY" --tlsv1.2 --connect-timeout 10 --max-time 30 \ -H "Accept: application/vnd.github+json" "https://api.github.com/repos/${REPO}/$1" 2>/dev/null } # list_releases prints " " per # release, where has-asset is 1 when the release ships asset $1. Sorted by # created_at here because the API's own list order is not reliable. list_releases() { github_api "releases?per_page=30" | awk -v asset="\"name\": \"$1\"" ' /"tag_name":/ { if (tag != "" && index(tag, "/") == 0) print tag, pre, has, created tag = $0; sub(/.*"tag_name": *"/, "", tag); sub(/".*/, "", tag) pre = "false"; has = 0; created = "" } /"prerelease": *true/ { pre = "true" } /"created_at":/ && created == "" { created = $0 sub(/.*"created_at": *"/, "", created); sub(/".*/, "", created) } index($0, asset) { has = 1 } END { if (tag != "" && index(tag, "/") == 0) print tag, pre, has, created }' | sort -k4,4r } # resolve_version picks the newest release on the channel that actually # ships a binary for this architecture, skipping releases with no assets. resolve_version() { if [ -n "${LEVELRAIL_VERSION:-}" ]; then VERSION="$LEVELRAIL_VERSION" return fi channel="${LEVELRAIL_CHANNEL:-}" case "$channel" in "" | stable | beta) ;; *) fatal "LEVELRAIL_CHANNEL must be stable or beta, got: $channel" ;; esac asset="levelrail-linux-${GOARCH}" releases="$(list_releases "$asset" || true)" [ -n "$releases" ] || fatal "could not list releases from GitHub. Set LEVELRAIL_VERSION=vX.Y.Z to pin one." newest_stable="$(printf '%s\n' "$releases" | awk '$2 == "false" { print $1; exit }')" stable="$(printf '%s\n' "$releases" | awk '$2 == "false" && $3 == 1 { print $1; exit }')" newest_any="$(printf '%s\n' "$releases" | awk '{ print $1; exit }')" any="$(printf '%s\n' "$releases" | awk '$3 == 1 { print $1; exit }')" case "$channel" in stable) VERSION="$stable" newest="$newest_stable" ;; beta) VERSION="$any" newest="$newest_any" ;; *) VERSION="$stable" newest="$newest_stable" if [ -z "$VERSION" ]; then VERSION="$any" newest="$newest_any" [ -z "$VERSION" ] || log "No stable release with a ${asset} binary yet, using pre-release ${VERSION}." fi ;; esac [ -n "$VERSION" ] || fatal "no ${channel:-published} release ships ${asset}. Set LEVELRAIL_VERSION=vX.Y.Z to pin one, or LEVELRAIL_BINARY_URL to a binary." if [ -n "$newest" ] && [ "$newest" != "$VERSION" ]; then warn "${newest} has no ${asset} binary, installing ${VERSION} instead" fi } detect_arch() { arch="$(uname -m)" case "$arch" in x86_64 | amd64) GOARCH="amd64" ;; aarch64 | arm64) GOARCH="arm64" ;; *) GOARCH="" ;; esac } # port_listening reports whether anything listens on TCP port $1, read from # /proc so it works without ss or netstat installed. port_listening() { hex="$(printf ':%04X' "$1")" for table in /proc/net/tcp /proc/net/tcp6; do [ -r "$table" ] || continue if awk -v p="$hex" 'NR > 1 && $4 == "0A" && substr($2, length($2) - 4) == p { found = 1 } END { exit !found }' "$table"; then return 0 fi done return 1 } service_active() { systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null || systemctl is-active --quiet "$HTTP_SOCKET_UNIT" 2>/dev/null } # find_free_port scans start..start+19 and prints the first port # port_listening reports free, or nothing if all 20 are taken. find_free_port() { start="$1" i=0 while [ "$i" -lt 20 ]; do candidate=$((start + i)) if ! port_listening "$candidate"; then printf '%s' "$candidate" return 0 fi i=$((i + 1)) done return 1 } # port_holders describes who listens on TCP port $1: a Docker container # (name and image) when one publishes it, else the process names ss reports. # ss shows process names only to root, so this stays best effort. port_holders() { found="" if command -v docker >/dev/null 2>&1; then found="$(docker ps --filter "publish=$1" --format 'container {{.Names}} (image {{.Image}})' 2>/dev/null | tr '\n' ',' | sed 's/,$//; s/,/, /g' || true)" fi if [ -z "$found" ] && command -v ss >/dev/null 2>&1; then found="$(ss -ltnp "sport = :$1" 2>/dev/null | sed -n 's/.*users:(("\([^"]*\)",pid=\([0-9]*\).*/\1 (pid \2)/p' | sort -u | tr '\n' ',' | sed 's/,$//; s/,/, /g' || true)" fi [ -n "$found" ] || found="an unidentified process (run as root with ss installed to see which)" printf '%s' "$found" } valid_port() { case "$1" in '' | *[!0-9]*) return 1 ;; esac [ "$1" -ge 1 ] && [ "$1" -le 65535 ] } # unit_env prints the last Environment=KEY=value from unit file $1. unit_env() { [ -r "$1" ] || return 0 sed -n 's/^Environment="\{0,1\}'"$2"'=\([^"]*\)"\{0,1\}$/\1/p' "$1" | tail -n 1 } # setting reads KEY from the drop-in first, then from the main unit, where # installs made before the drop-in existed keep their values. setting() { v="$(unit_env "$DROPIN_PATH" "$1")" [ -n "$v" ] || v="$(unit_env "$UNIT_PATH" "$1")" printf '%s' "$v" } # load_settings reads back what an earlier install chose. Flags and env win. load_settings() { v="$(setting APP_INGRESS_HTTP_ADDR)" if [ "$HTTP_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then HTTP_PORT="${v##*:}"; fi v="$(setting APP_INGRESS_HTTPS_ADDR)" if [ "$HTTPS_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then HTTPS_PORT="${v##*:}"; fi v="$(setting APP_HTTP_ADDR)" if [ "$DASHBOARD_PORT_PINNED" -eq 0 ] && valid_port "${v##*:}"; then DASHBOARD_PORT="${v##*:}"; fi case "${v%:*}" in 127.0.0.1 | localhost) DASHBOARD_BIND="127.0.0.1" ;; esac if [ "$PUBLIC_HOST_PINNED" -eq 0 ]; then v="$(setting APP_PUBLIC_HOST)" case "$v" in *[!A-Za-z0-9.:-]*) ;; *) PUBLIC_HOST="$v" ;; esac fi if [ "$DASHBOARD_BIND" = "127.0.0.1" ] && [ "$HTTP_PORT" != "80" ] && [ "$HTTPS_PORT" != "443" ]; then COEXIST_ACTIVE=1 fi } # apply_coexist moves ingress off 80/443 (unless a port was set explicitly) # and binds the dashboard to loopback, the safe default behind another proxy. apply_coexist() { if [ "$HTTP_PORT_PINNED" -eq 0 ] && [ "$HTTP_PORT" = "80" ]; then HTTP_PORT="$COEXIST_HTTP_PORT"; fi if [ "$HTTPS_PORT_PINNED" -eq 0 ] && [ "$HTTPS_PORT" = "443" ]; then HTTPS_PORT="$COEXIST_HTTPS_PORT"; fi DASHBOARD_BIND="127.0.0.1" COEXIST_ACTIVE=1 SETTINGS_CHANGED=1 } # offer_coexist handles ports 80/443 held by something else: it names the # holder and switches to coexist mode by itself, because the only other # outcomes are a failed install or a Levelrail that fights your proxy for the # ports. --no-coexist (LEVELRAIL_COEXIST=0) keeps the old failing preflight. offer_coexist() { if [ "$COEXIST" -eq 1 ]; then apply_coexist else [ "$HTTP_PORT_PINNED" -eq 0 ] && [ "$HTTPS_PORT_PINNED" -eq 0 ] || return 0 [ "$COEXIST_ACTIVE" -eq 0 ] || return 0 taken="" for port in "$HTTP_PORT" "$HTTPS_PORT"; do if port_listening "$port" && ! service_active; then taken="$taken $port"; fi done [ -n "$taken" ] || return 0 log "Ports${taken} are already in use on this server:" for port in $taken; do log " ${port}: $(port_holders "$port")"; done if [ "$NO_COEXIST" -eq 1 ]; then log "Not switching to coexist mode (--no-coexist). Re-run without it to run behind that service." return 0 fi log "Switching to coexist mode: ingress on ${COEXIST_HTTP_PORT}/${COEXIST_HTTPS_PORT}, dashboard on loopback only," log "and your existing proxy forwards your domains to it (no automatic TLS from Levelrail then)." apply_coexist fi service_active && return 0 if port_listening "$HTTP_PORT" && free="$(find_free_port "$HTTP_PORT")"; then warn "port ${HTTP_PORT} is also taken, using ${free} for ingress HTTP instead" HTTP_PORT="$free" SETTINGS_CHANGED=1 fi if port_listening "$HTTPS_PORT" && free="$(find_free_port "$HTTPS_PORT")"; then warn "port ${HTTPS_PORT} is also taken, using ${free} for ingress HTTPS instead" HTTPS_PORT="$free" SETTINGS_CHANGED=1 fi } PREFLIGHT_FAILS=0 row() { printf ' %-10s %-5s %s\n' "$1" "$2" "$3" [ "$2" != "FAIL" ] || PREFLIGHT_FAILS=$((PREFLIGHT_FAILS + 1)) } docker_major() { v="$(docker version --format '{{.Server.Version}}' 2>/dev/null || true)" [ -n "$v" ] || v="$(docker --version 2>/dev/null | sed -E 's/^[^0-9]*([0-9]+).*/\1/' || true)" printf '%s' "${v%%.*}" } first_existing_dir() { d="$1" while [ ! -d "$d" ]; do d="$(dirname "$d")"; done printf '%s' "$d" } # docker_rootless is true when the daemon reports rootless mode. docker_rootless() { docker info --format '{{.SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless' } # docker_tcp_exposed is true when something listens on 2375 (the unencrypted # Docker API) on an address other than loopback. docker_tcp_exposed() { port_listening 2375 || return 1 if command -v ss >/dev/null 2>&1; then ss -ltnH "sport = :2375" 2>/dev/null | awk '{ print $4 }' | grep -vqE '^(127\.|\[::1\])' else return 0 fi } # firewall_blocks prints a fix when an active ufw or firewalld does not allow # ports 80 and 443, and nothing otherwise. firewall_blocks() { if command -v ufw >/dev/null 2>&1; then st="$(ufw status 2>/dev/null || true)" case "$st" in *"Status: active"*) missing="" for p in 80 443; do printf '%s\n' "$st" | grep -Eq "^(${p}(/tcp)?|Nginx Full|Apache Full|WWW Full)[[:space:]].*ALLOW" || missing="$missing $p" done [ -z "$missing" ] || printf 'ufw is active and does not allow port(s)%s: sudo ufw allow 80/tcp && sudo ufw allow 443/tcp' "$missing" return 0 ;; esac elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then svc="$(firewall-cmd --list-services 2>/dev/null) $(firewall-cmd --list-ports 2>/dev/null)" case "$svc" in *http*https* | *443/tcp*80/tcp* | *80/tcp*443/tcp*) ;; *) printf 'firewalld is active and does not list http and https: sudo firewall-cmd --permanent --add-service=http --add-service=https && sudo firewall-cmd --reload' ;; esac fi return 0 } # preflight_environment adds the checks that are about the surroundings: # Docker isolation and exposure, the firewall, and the DNS of --domain. preflight_environment() { if command -v docker >/dev/null 2>&1; then if docker_rootless; then row docker-mode warn "rootless Docker cannot publish ports 80/443 until net.ipv4.ip_unprivileged_port_start=80 is set" fi sock=/var/run/docker.sock if [ -S "$sock" ]; then perm="$(stat -c %a "$sock" 2>/dev/null || true)" case "${perm#"${perm%?}"}" in 2 | 3 | 6 | 7) row docker-socket warn "${sock} is writable by every local user (mode ${perm}), which is root on this host: chmod 660 ${sock}" ;; esac fi fi if docker_tcp_exposed; then row docker-api FAIL "the Docker API listens on a network address without TLS (port 2375): anyone who reaches it owns this server. Remove the tcp:// host from the daemon settings" fi fw="$(firewall_blocks)" if [ -n "$fw" ]; then row firewall warn "$fw"; fi if [ -n "$DOMAIN_HINT" ]; then resolved="$(getent ahostsv4 "$DOMAIN_HINT" 2>/dev/null | awk '{ print $1 }' | sort -u | tr '\n' ' ' | sed 's/ $//')" if [ -z "$resolved" ]; then row dns warn "${DOMAIN_HINT} does not resolve: create an A record pointing at this server's public IPv4 address" else here=0 for ip in $(local_ips); do case " $resolved " in *" $ip "*) here=1 ;; esac done if [ "$here" -eq 1 ]; then row dns ok "${DOMAIN_HINT} points at this server (${resolved})" else row dns warn "${DOMAIN_HINT} resolves to ${resolved}, which is not an address on this server (fine behind NAT or a load balancer)" fi fi fi } preflight() { log "Preflight checks:" if [ "$(id -u)" -eq 0 ]; then row root ok "running as root" else row root warn "not root, so process names of port holders may be hidden (re-run with sudo for full detail)" fi detect_arch if [ -n "$GOARCH" ]; then row arch ok "$(uname -m)"; else row arch FAIL "$(uname -m) (supported: x86_64, aarch64)"; fi os="$(uname -s)" distro="" # shellcheck disable=SC1091 if [ -r /etc/os-release ]; then distro="$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-}")"; fi if [ "$os" = "Linux" ]; then row os ok "${distro:-Linux}"; else row os FAIL "$os (Linux only)"; fi if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then row systemd ok "running" else row systemd FAIL "systemd is required as the init system" fi if command -v curl >/dev/null 2>&1; then row curl ok "found"; else row curl FAIL "curl is required"; fi if command -v docker >/dev/null 2>&1; then major="$(docker_major)" case "$major" in '' | *[!0-9]*) row docker warn "installed, version unknown" ;; *) if [ "$major" -ge "$MIN_DOCKER_MAJOR" ]; then row docker ok "version $major"; else row docker FAIL "version $major, need >= $MIN_DOCKER_MAJOR"; fi ;; esac else row docker ok "not found, will install via get.docker.com" fi mem_kb="$(awk '/^MemTotal:/ { print $2 }' /proc/meminfo 2>/dev/null || echo 0)" mem_mb=$((${mem_kb:-0} / 1024)) if [ "$mem_mb" -ge "$MIN_RAM_MB" ]; then row ram ok "${mem_mb} MB"; else row ram warn "${mem_mb} MB, ${MIN_RAM_MB} MB or more recommended"; fi disk_dir="$(first_existing_dir "$DATA_DIR")" free_kb="$(df -Pk "$disk_dir" 2>/dev/null | awk 'NR == 2 { print $4 }')" free_gb=$((${free_kb:-0} / 1024 / 1024)) if [ "$free_gb" -ge "$MIN_DISK_GB" ]; then row disk ok "${free_gb} GB free on ${disk_dir}"; else row disk FAIL "${free_gb} GB free on ${disk_dir}, need ${MIN_DISK_GB} GB"; fi offer_coexist for port in "$HTTP_PORT" "$HTTPS_PORT"; do if ! port_listening "$port"; then row "port $port" ok "free" elif service_active; then row "port $port" ok "in use, presumably by ${SERVICE_NAME} (reinstall)" else row "port $port" FAIL "already in use by $(port_holders "$port"). Automatic TLS needs 80/443. Re-run with --coexist to run behind it, or set LEVELRAIL_HTTP_PORT/LEVELRAIL_HTTPS_PORT to pick other ports (accepting that automatic ACME certs won't work)" fi done if ! port_listening "$DASHBOARD_PORT"; then row "port $DASHBOARD_PORT" ok "free" elif service_active; then row "port $DASHBOARD_PORT" ok "in use, presumably by ${SERVICE_NAME} (reinstall)" elif [ "$DASHBOARD_PORT_PINNED" -eq 0 ] && free_port="$(find_free_port "$DASHBOARD_PORT")"; then row "port $DASHBOARD_PORT" ok "taken, using $free_port instead" DASHBOARD_PORT="$free_port" SETTINGS_CHANGED=1 else row "port $DASHBOARD_PORT" FAIL "already in use by another process" fi preflight_environment [ "$MODE" != "check" ] || return 0 if [ "$PREFLIGHT_FAILS" -gt 0 ]; then [ "$FORCE" -eq 1 ] || fatal "${PREFLIGHT_FAILS} preflight check(s) failed. Fix them, or re-run with --force to continue anyway." warn "${PREFLIGHT_FAILS} preflight check(s) failed, continuing because of --force" fi [ -n "$GOARCH" ] || fatal "cannot continue on an unsupported architecture" } ensure_docker() { if ! command -v docker >/dev/null 2>&1; then log "Installing Docker via get.docker.com..." curl -fsSL --proto "$HTTPS_ONLY" --tlsv1.2 --connect-timeout 10 --max-time 120 https://get.docker.com | sh fi systemctl enable --now docker } fetch_binary() { dest="$1" if [ -n "${LEVELRAIL_BINARY_FILE:-}" ]; then [ -f "$LEVELRAIL_BINARY_FILE" ] || fatal "LEVELRAIL_BINARY_FILE not found: $LEVELRAIL_BINARY_FILE" log "Using local binary ${LEVELRAIL_BINARY_FILE}." cp "$LEVELRAIL_BINARY_FILE" "$dest" VERSION="${LEVELRAIL_VERSION:-local build}" return fi if [ -n "${LEVELRAIL_BINARY_URL:-}" ]; then log "Downloading ${LEVELRAIL_BINARY_URL}..." curl -fsSL --connect-timeout 10 --max-time 300 -o "$dest" "$LEVELRAIL_BINARY_URL" || fatal "download failed: $LEVELRAIL_BINARY_URL" VERSION="${LEVELRAIL_VERSION:-custom build}" return fi [ "$NO_COSIGN" -eq 1 ] || [ "$VERIFY_MODE" = "off" ] || ensure_cosign resolve_version asset="levelrail-linux-${GOARCH}" base_url="${LEVELRAIL_RELEASE_BASE_URL:-https://github.com/${REPO}/releases/download/${VERSION}}" proto="$HTTPS_ONLY" case "$base_url" in https://*) ;; http://*) [ "${LEVELRAIL_INSECURE_MIRROR:-}" = "1" ] || fatal "LEVELRAIL_RELEASE_BASE_URL must be https (LEVELRAIL_INSECURE_MIRROR=1 allows http for tests)" proto="=http" ;; *) fatal "LEVELRAIL_RELEASE_BASE_URL must start with https://" ;; esac log "Downloading ${asset} ${VERSION}..." curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 300 -o "$dest" "${base_url}/${asset}" || fatal "download failed: ${base_url}/${asset}. The release may not ship this binary; pick another with LEVELRAIL_VERSION." sums="${dest}.checksums" if curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 60 -o "$sums" "${base_url}/checksums.txt" 2>/dev/null; then verify_signature "$sums" "$base_url" "$proto" expected="$(grep " ${asset}\$" "$sums" | awk '{ print $1 }')" if [ -n "$expected" ]; then actual="$(sha256sum "$dest" | awk '{ print $1 }')" [ "$expected" = "$actual" ] || fatal "checksum mismatch for ${asset}: expected ${expected}, got ${actual}" log "Checksum verified." else checksum_unavailable "${asset} is not listed in checksums.txt for ${VERSION}" fi else checksum_unavailable "no checksums.txt published for ${VERSION}" fi } # ensure_cosign installs the pinned cosign release when none is on PATH, so # the release signature can be checked. Unless $1 is "strict" a failure only # warns: the SHA-256 checksum is still verified and the install carries on. ensure_cosign() { strict="${1:-}" if command -v cosign >/dev/null 2>&1; then log "cosign already installed ($(command -v cosign))." return 0 fi cosign_url="${LEVELRAIL_COSIGN_URL:-}" cosign_proto="$HTTPS_ONLY" if [ -n "$cosign_url" ]; then cosign_sha="${LEVELRAIL_COSIGN_SHA256:-}" if [ -z "$cosign_sha" ]; then cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL needs LEVELRAIL_COSIGN_SHA256" return 0 fi case "$cosign_url" in https://*) ;; http://*) if [ "${LEVELRAIL_INSECURE_MIRROR:-}" != "1" ]; then cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL must be https" return 0 fi cosign_proto="=http" ;; *) cosign_unavailable "$strict" "LEVELRAIL_COSIGN_URL must start with https://" return 0 ;; esac else case "$GOARCH" in amd64) cosign_sha="$COSIGN_SHA256_AMD64" ;; arm64) cosign_sha="$COSIGN_SHA256_ARM64" ;; *) cosign_unavailable "$strict" "no pinned cosign for $(uname -m)" return 0 ;; esac cosign_url="https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-${GOARCH}" fi cosign_tmp="$(mktemp)" log "Installing cosign ${COSIGN_VERSION} to ${COSIGN_BIN}..." if ! curl -fsSL --proto "$cosign_proto" --tlsv1.2 --connect-timeout 10 --max-time 120 -o "$cosign_tmp" "$cosign_url" 2>/dev/null; then rm -f "$cosign_tmp" cosign_unavailable "$strict" "could not download cosign from ${cosign_url}" return 0 fi cosign_actual="$(sha256sum "$cosign_tmp" | awk '{ print $1 }')" if [ "$cosign_actual" != "$cosign_sha" ]; then rm -f "$cosign_tmp" cosign_unavailable "$strict" "cosign checksum mismatch: expected ${cosign_sha}, got ${cosign_actual}" return 0 fi if ! install -m 0755 "$cosign_tmp" "$COSIGN_BIN" 2>/dev/null; then rm -f "$cosign_tmp" cosign_unavailable "$strict" "could not write ${COSIGN_BIN}" return 0 fi rm -f "$cosign_tmp" log "Installed cosign ${COSIGN_VERSION}." } cosign_unavailable() { if [ "$1" = "strict" ]; then fatal "$2" fi warn "$2. Continuing with SHA-256 checksum verification only (LEVELRAIL_NO_COSIGN=1 skips this step)." } # verify_signature checks the cosign keyless bundle over checksums.txt. A # failed verification is always fatal; only a missing cosign or bundle is # tolerated, and never under APP_INSTALL_VERIFY=require. verify_signature() { sums="$1" base_url="$2" proto="$3" [ "$VERIFY_MODE" != "off" ] || return 0 if ! command -v cosign >/dev/null 2>&1; then [ "$VERIFY_MODE" != "require" ] || fatal "APP_INSTALL_VERIFY=require but cosign is not installed. Install it from https://docs.sigstore.dev/cosign/system_config/installation/ or unset APP_INSTALL_VERIFY." log "cosign not found: verifying the SHA-256 checksum only (install cosign to also verify the release signature)." return 0 fi bundle="${sums}.sigstore.json" if ! curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 60 -o "$bundle" "${base_url}/checksums.txt.sigstore.json" 2>/dev/null; then [ "$VERIFY_MODE" != "require" ] || fatal "APP_INSTALL_VERIFY=require but ${VERSION} publishes no checksums.txt.sigstore.json" warn "${VERSION} publishes no signature for checksums.txt, verifying the SHA-256 checksum only" return 0 fi cosign verify-blob --bundle "$bundle" \ --certificate-identity-regexp "$COSIGN_IDENTITY_REGEXP" \ --certificate-oidc-issuer "$COSIGN_OIDC_ISSUER" "$sums" >/dev/null 2>&1 || fatal "cosign signature verification of checksums.txt failed for ${VERSION}, refusing to install" log "Signature verified (cosign, keyless)." } checksum_unavailable() { if [ "$VERIFY_MODE" = "require" ]; then fatal "$1, and APP_INSTALL_VERIFY=require does not allow an unverified install" fi if [ "${LEVELRAIL_SKIP_CHECKSUM:-}" = "1" ]; then warn "$1, skipping verification because LEVELRAIL_SKIP_CHECKSUM=1" return 0 fi fatal "$1, refusing to install an unverified binary. Pick another release with LEVELRAIL_VERSION, or set LEVELRAIL_SKIP_CHECKSUM=1 to install without verification." } # supports_retain is true when the binary at $1 has the retain subcommand. It # greps for the usage text rather than running the binary: an older release # would ignore an unknown argument and start serving. supports_retain() { [ -f "$1" ] && grep -aq 'retain --version --file ' "$1" } # retain_releases keeps the previous and the new binary for rollback. It never # fails an install: rollback is a convenience, not a precondition. retain_releases() { previous="$1" supports_retain "$BIN_PATH" || return 0 mkdir -p "$RELEASES_DIR" 2>/dev/null || return 0 marker="${RELEASES_DIR}/.current" if [ -n "$previous" ] && [ -f "$marker" ]; then old="$(cat "$marker")" "$BIN_PATH" retain --version "$old" --file "$previous" --releases-dir "$RELEASES_DIR" >/dev/null 2>&1 || warn "could not keep the previous binary (${old}) for rollback" fi case "$VERSION" in v[0-9]*) if "$BIN_PATH" retain --version "$VERSION" --file "$BIN_PATH" --releases-dir "$RELEASES_DIR" >/dev/null 2>&1; then printf '%s\n' "$VERSION" >"$marker" else warn "could not keep ${VERSION} for rollback" fi ;; esac } install_binary() { tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT fetch_binary "$tmp_dir/$BINARY_NAME" mkdir -p "$INSTALL_DIR" previous="" if [ -x "$BIN_PATH" ]; then cp -p "$BIN_PATH" "$tmp_dir/previous" previous="$tmp_dir/previous" fi install -m 0755 "$tmp_dir/$BINARY_NAME" "$BIN_PATH" retain_releases "$previous" } write_brand() { mkdir -p "$DATA_DIR" chmod 0750 "$DATA_DIR" [ ! -f "$DATA_DIR/brand.yaml" ] || return 0 cat >"$DATA_DIR/brand.yaml" <<-EOF name: Levelrail short_name: Levelrail binary_name: levelrail domain: glinr.com/levelrail support_url: https://github.com/GLINCKER/levelrail/issues docs_url: https://levelrail.glinr.com primary_color: "" logo_svg: "" EOF } write_socket_units() { cat >"/etc/systemd/system/${HTTP_SOCKET_UNIT}" <"/etc/systemd/system/${HTTPS_SOCKET_UNIT}" </dev/null 2>&1 || true rm -f "/etc/systemd/system/${HTTP_SOCKET_UNIT}" "/etc/systemd/system/${HTTPS_SOCKET_UNIT}" } # render_unit prints the main unit. Addresses live in the drop-in, so an # upgrade that rewrites this file can never reset them. render_unit() { socket_deps="" if [ "$SOCKET_ACTIVATION" = "1" ] || [ -f "/etc/systemd/system/${HTTP_SOCKET_UNIT}" ]; then socket_deps="Requires=${HTTP_SOCKET_UNIT} ${HTTPS_SOCKET_UNIT} After=${HTTP_SOCKET_UNIT} ${HTTPS_SOCKET_UNIT}" fi cat <"$UNIT_PATH" } # write_restart_policy stops systemd from restart-looping a binary that # refused to start because the database is newer than it (exit status 78). RESTART_POLICY_PATH="${DROPIN_DIR}/20-restart-policy.conf" write_restart_policy() { mkdir -p "$DROPIN_DIR" printf '%s\n' "# Written by install.sh. A binary older than the database exits 78 and must not be restarted." \ "[Service]" "RestartPreventExitStatus=78" >"$RESTART_POLICY_PATH" } # write_dropin stores every installer-chosen address, so upgrades keep them. write_dropin() { mkdir -p "$DROPIN_DIR" { printf '%s\n' "# Written by install.sh. Upgrades read this back and never reset it." \ "# After editing: systemctl daemon-reload && systemctl restart ${SERVICE_NAME}" \ "[Service]" \ "Environment=APP_HTTP_ADDR=${DASHBOARD_BIND}:${DASHBOARD_PORT}" \ "Environment=APP_INGRESS_HTTP_ADDR=:${HTTP_PORT}" \ "Environment=APP_INGRESS_HTTPS_ADDR=:${HTTPS_PORT}" [ -z "$PUBLIC_HOST" ] || printf 'Environment=APP_PUBLIC_HOST=%s\n' "$PUBLIC_HOST" } >"$DROPIN_PATH" } # ensure_dropin keeps an existing drop-in untouched unless settings were # passed this run, and builds it from the legacy main unit's values otherwise. ensure_dropin() { if [ -f "$DROPIN_PATH" ] && [ "$HTTP_PORT_PINNED" -eq 0 ] && [ "$HTTPS_PORT_PINNED" -eq 0 ] && [ "$DASHBOARD_PORT_PINNED" -eq 0 ] && [ "$PUBLIC_HOST_PINNED" -eq 0 ] && [ "$COEXIST" -eq 0 ] && [ "$SETTINGS_CHANGED" -eq 0 ]; then return 0 fi write_dropin } # warn_unit_drift flags a main unit that differs from what this installer # would write, ignoring the address lines that moved to the drop-in. UNIT_DRIFTED=0 strip_addr_lines() { grep -v -e '^Environment=APP_HTTP_ADDR=' -e '^Environment=APP_INGRESS_HTTP_ADDR=' -e '^Environment=APP_INGRESS_HTTPS_ADDR=' || true } warn_unit_drift() { [ -f "$UNIT_PATH" ] || return 0 drift_dir="$(mktemp -d)" render_unit | strip_addr_lines >"$drift_dir/want" strip_addr_lines <"$UNIT_PATH" >"$drift_dir/have" if ! cmp -s "$drift_dir/want" "$drift_dir/have"; then UNIT_DRIFTED=1 warn "${UNIT_PATH} differs from what this installer writes (hand edits or an older installer). Keep overrides in ${DROPIN_DIR}/." fi rm -rf "$drift_dir" } # warn_taken_ports tells an upgrading operator when the configured ingress # ports are held by something else while the service is down. warn_taken_ports() { service_active && return 0 for port in "$HTTP_PORT" "$HTTPS_PORT"; do if port_listening "$port"; then warn "port ${port} is held by $(port_holders "$port"); the control plane cannot bind it. Run: install.sh upgrade --coexist (see ${PROXY_DOCS_URL})" fi done } # configure_ufw allows SSH before anything else and only enables ufw if it # was not already active, so it can never lock the operator out. configure_ufw() { [ "${LEVELRAIL_CONFIGURE_UFW:-0}" = "1" ] || return 0 if [ "$COEXIST_ACTIVE" -eq 1 ]; then log "Coexist mode: leaving ufw alone, your existing proxy owns the public ports." return 0 fi if ! command -v ufw >/dev/null 2>&1; then log "LEVELRAIL_CONFIGURE_UFW=1 set, but ufw is not installed, skipping." return 0 fi log "Configuring ufw (LEVELRAIL_CONFIGURE_UFW=1)..." was_active=0 ufw status 2>/dev/null | grep -q "^Status: active" && was_active=1 ufw allow OpenSSH >/dev/null 2>&1 || ufw allow 22/tcp >/dev/null 2>&1 ufw allow "${HTTP_PORT}/tcp" >/dev/null 2>&1 ufw allow "${HTTPS_PORT}/tcp" >/dev/null 2>&1 ufw allow "${HTTPS_PORT}/udp" >/dev/null 2>&1 if [ "$was_active" -eq 1 ]; then log "ufw was already active, added rules without re-enabling." else ufw --force enable >/dev/null 2>&1 log "ufw enabled: SSH, ${HTTP_PORT}/tcp, ${HTTPS_PORT}/tcp, and ${HTTPS_PORT}/udp (HTTP/3) are allowed, everything else denied by default." fi } wait_healthy() { log "Waiting for the control plane to come up..." waited=0 while [ "$waited" -lt "$HEALTH_WAIT" ]; do if curl -fsS --max-time 3 -o /dev/null "http://127.0.0.1:${DASHBOARD_PORT}/healthz" 2>/dev/null; then log "Control plane is healthy." return 0 fi sleep 2 waited=$((waited + 2)) done fatal "control plane did not become healthy within ${HEALTH_WAIT}s. Check: systemctl status ${SERVICE_NAME} && journalctl -u ${SERVICE_NAME} -n 100 --no-pager" } # healthy_within polls /healthz for up to $HEALTH_WAIT seconds and reports the # result without exiting, so an upgrade can roll back instead of dying. healthy_within() { waited=0 while [ "$waited" -lt "$HEALTH_WAIT" ]; do if curl -fsS --max-time 3 -o /dev/null "http://127.0.0.1:${DASHBOARD_PORT}/healthz" 2>/dev/null; then return 0 fi sleep 2 waited=$((waited + 2)) done return 1 } # rollback_failed_upgrade puts the binary saved before the upgrade back and # restarts. It cannot undo a schema migration: if the previous binary refuses # the migrated database, the printed recovery commands do that. rollback_failed_upgrade() { previous="$1" warn "${VERSION} did not become healthy within ${HEALTH_WAIT}s, restoring the previous binary" systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true rm -f "${DATA_DIR}/upgrade-context.json" install -m 0755 "$previous" "$BIN_PATH" systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || true if healthy_within; then rm -f "$previous" fatal "upgrade to ${VERSION} rolled back: the previous release is running again and your data was not touched by the installer. Check: journalctl -u ${SERVICE_NAME} -n 100 --no-pager" fi fatal "upgrade to ${VERSION} failed and the previous binary is not healthy either. It is kept at ${previous}. The database may have been migrated: list restore points with 'sudo ${BIN_PATH} restore-snapshot --list'. For an upgrade that also restores data automatically use: levelrail-cli upgrade --apply" } discover_public_ip() { PUBLIC_IP="${LEVELRAIL_PUBLIC_IP:-}" [ -z "$PUBLIC_IP" ] || return 0 case "$PUBLIC_HOST" in *[!0-9.]* | "") ;; *) PUBLIC_IP="$PUBLIC_HOST" return 0 ;; esac for svc in https://api.ipify.org https://ifconfig.me/ip; do PUBLIC_IP="$(curl -4 -fsS --proto "$HTTPS_ONLY" --connect-timeout 3 --max-time 5 "$svc" 2>/dev/null | tr -d '[:space:]' || true)" case "$PUBLIC_IP" in *[!0-9.]* | "") PUBLIC_IP="" ;; *) return 0 ;; esac done } # start_temp_listener serves port $1 for at most 20s so the reachability # probe has something to connect to when nothing else listens there yet. TEMP_PID="" start_temp_listener() { TEMP_PID="" command -v python3 >/dev/null 2>&1 || return 1 command -v timeout >/dev/null 2>&1 || return 1 timeout 20 python3 -m http.server "$1" --bind 0.0.0.0 >/dev/null 2>&1 & TEMP_PID=$! tries=0 while [ "$tries" -lt 10 ]; do port_listening "$1" && return 0 sleep 0.3 2>/dev/null || sleep 1 tries=$((tries + 1)) done return 1 } stop_temp_listener() { [ -n "$TEMP_PID" ] || return 0 kill "$TEMP_PID" 2>/dev/null || true wait "$TEMP_PID" 2>/dev/null || true TEMP_PID="" } # reachability_test probes 80/443 on the public IP, the way a visitor or an # ACME server would. Curl exit 7 (refused) or 28 (timeout) means blocked; # anything else, TLS errors included, proves the TCP port is reachable. reachability_test() { [ "${LEVELRAIL_SKIP_REACHABILITY:-0}" != "1" ] || return 0 if [ "$HTTP_PORT" != "80" ] || [ "$HTTPS_PORT" != "443" ]; then log "Ingress is on ${HTTP_PORT}/${HTTPS_PORT}, not 80/443, so automatic ACME TLS is already off; skipping the reachability test." return 0 fi if [ -z "$PUBLIC_IP" ]; then warn "could not determine this server's public IP, skipping the port 80/443 reachability test" return 0 fi log "Checking that ports 80 and 443 are reachable at ${PUBLIC_IP}..." blocked="" for port in "$HTTP_PORT" "$HTTPS_PORT"; do scheme="http" [ "$port" != "443" ] || scheme="https" if ! port_listening "$port" && ! start_temp_listener "$port"; then log " port $port: skipped (nothing listening and no python3 for a temporary listener)" continue fi rc=0 curl -k -s -o /dev/null --connect-timeout 4 --max-time 8 "${scheme}://${PUBLIC_IP}:${port}/" || rc=$? stop_temp_listener case "$rc" in 7 | 28) log " port $port: NOT reachable" blocked="$blocked $port" ;; *) log " port $port: reachable" ;; esac done [ -n "$blocked" ] || return 0 warn "port(s)${blocked} did not answer on ${PUBLIC_IP}. Apps and TLS certificates need 80 and 443 open." cat </dev/null 2>&1; then ip -o addr show scope global 2>/dev/null | while read -r _ ifname fam addr _; do case "$ifname" in docker* | br-* | veth* | cni* | flannel* | virbr* | lo) continue ;; esac case "$fam" in inet) printf '%s\n' "${addr%%/*}" ;; esac done return 0 fi for ip in $(hostname -I 2>/dev/null || true); do case "$ip" in *:* | 172.1[6-9].* | 172.2[0-9].* | 172.3[01].*) continue ;; esac printf '%s\n' "$ip" done } dashboard_link() { case "$1" in *:*) host="[$1]" ;; *) host="$1" ;; esac if [ -n "$token" ]; then printf '%s://%s:%s/login?setup=%s' "$DASHBOARD_SCHEME" "$host" "$DASHBOARD_PORT" "$token" else printf '%s://%s:%s' "$DASHBOARD_SCHEME" "$host" "$DASHBOARD_PORT" fi } # print_coexist_summary replaces the public-IP links: the dashboard is bound # to loopback, so the way in is an SSH tunnel until the proxy fronts it. print_coexist_summary() { domain="${DOMAIN_HINT:-}" login="http://127.0.0.1:${DASHBOARD_PORT}" [ -z "$token" ] || login="${login}/login?setup=${token}" host="${PUBLIC_IP:-}" cat <:${DASHBOARD_PORT}" fi log "" if [ -n "$token" ]; then cat <:${DASHBOARD_PORT} levelrail-cli auth login --device Service status: systemctl status ${SERVICE_NAME} Logs: journalctl -u ${SERVICE_NAME} -f Data directory: ${DATA_DIR} Upgrade: curl -fsSL https://levelrail.com/install.sh | sudo sh -s upgrade Uninstall: curl -fsSL https://levelrail.com/install.sh | sudo sh -s uninstall Locked out: sudo APP_DATA_DIR=${DATA_DIR} ${BIN_PATH} recover-admin --username admin EOF } # sslip_hint explains the zero-DNS HTTPS path when the public address is a # plain IPv4: .sslip.io already resolves to it. sslip_hint() { case "$PUBLIC_IP" in "" | *[!0-9.]*) return 0 ;; esac dashed="$(printf '%s' "$PUBLIC_IP" | tr '.' '-')" cat </dev/null || return 0 ( umask 077 printf '{"to_version":"%s","initiator":"%s","method":"%s","channel":"%s","written_at":"%s"}\n' \ "$VERSION" "${who:-unknown}" "$method" "$chan" "$when" >"$DATA_DIR/upgrade-context.json.tmp" && mv "$DATA_DIR/upgrade-context.json.tmp" "$DATA_DIR/upgrade-context.json" ) 2>/dev/null || warn "could not record who ran this upgrade for the upgrade history" } # do_check is the read-only readiness report: the preflight rows, then the # recommended install mode and the exact next command. It changes nothing. do_check() { load_settings preflight url="https://raw.githubusercontent.com/${REPO}/main/install.sh" domain_arg="" [ -z "$DOMAIN_HINT" ] || domain_arg=" --domain ${DOMAIN_HINT}" log "" if [ "$COEXIST_ACTIVE" -eq 1 ] || [ "$COEXIST" -eq 1 ]; then log "Recommended mode: behind your existing proxy (ingress ${COEXIST_HTTP_PORT}/${COEXIST_HTTPS_PORT}, dashboard on loopback)." for port in 80 443; do if port_listening "$port" && ! service_active; then log " port ${port} is held by $(port_holders "$port")"; fi done log "Next step:" log " curl -fsSL ${url} | sudo sh -s -- --coexist${domain_arg}" [ -z "$DOMAIN_HINT" ] || log " then: levelrail-cli proxy --domain ${DOMAIN_HINT} --verify" else log "Recommended mode: own ports (80/443 are free, automatic HTTPS)." log "Next step:" if [ -n "$DOMAIN_HINT" ]; then log " curl -fsSL ${url} | sudo sh -s --${domain_arg}"; else log " curl -fsSL ${url} | sudo sh"; fi fi if [ "$PREFLIGHT_FAILS" -gt 0 ]; then log "" log "${PREFLIGHT_FAILS} check(s) failed: fix those first." exit 1 fi } CLI_INSTALLER_URL="${LEVELRAIL_CLI_INSTALLER_URL:-https://levelrail.com/install-cli.sh}" # install_cli reuses install-cli.sh so the CLI gets the same checksum and # signature checks as the server binary. Never fatal: the server is already up. install_cli() { cli_hint="curl -fsSL ${CLI_INSTALLER_URL} | sudo sh" [ "${LEVELRAIL_INSTALL_CLI:-1}" != "0" ] || return 0 if [ -n "${LEVELRAIL_BINARY_FILE:-}${LEVELRAIL_BINARY_URL:-}" ]; then log "levelrail-cli not installed: run ${cli_hint}" return 0 fi cli_script="$(mktemp)" if curl -fsSL --proto "$HTTPS_ONLY" --tlsv1.2 --connect-timeout 10 --max-time 30 -o "$cli_script" "$CLI_INSTALLER_URL" 2>/dev/null && LEVELRAIL_VERSION="$VERSION" LEVELRAIL_CLI_INSTALL_DIR="${LEVELRAIL_CLI_INSTALL_DIR:-/usr/local/bin}" sh "$cli_script"; then rm -f "$cli_script" return 0 fi rm -f "$cli_script" warn "levelrail-cli was not installed" log "levelrail-cli not installed: run ${cli_hint}" } do_install() { [ -n "$SOCKET_ACTIVATION" ] || SOCKET_ACTIVATION=1 load_settings preflight ensure_docker install_binary write_brand ensure_dropin write_restart_policy write_unit [ "$SOCKET_ACTIVATION" != "1" ] || write_socket_units configure_ufw systemctl daemon-reload systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 if [ "$SOCKET_ACTIVATION" = "1" ]; then systemctl enable --now "$HTTP_SOCKET_UNIT" "$HTTPS_SOCKET_UNIT" >/dev/null 2>&1 fi write_upgrade_marker "install.sh install" systemctl restart "$SERVICE_NAME" wait_healthy discover_public_ip reachability_test install_cli print_summary } do_upgrade() { [ -x "$BIN_PATH" ] || fatal "${BIN_PATH} not found, nothing to upgrade. Run the installer without arguments first." detect_arch [ -n "$GOARCH" ] || fatal "unsupported architecture: $(uname -m)" load_settings [ "$COEXIST" -eq 0 ] || apply_coexist warn_taken_ports warn_unit_drift pre_upgrade_bin="${BIN_PATH}.pre-upgrade" cp -p "$BIN_PATH" "$pre_upgrade_bin" install_binary ensure_dropin write_restart_policy case "$SOCKET_ACTIVATION" in 1) [ "$UNIT_DRIFTED" -eq 0 ] || cp -p "$UNIT_PATH" "${UNIT_PATH}.pre-upgrade" write_unit write_socket_units systemctl daemon-reload systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl enable --now "$HTTP_SOCKET_UNIT" "$HTTPS_SOCKET_UNIT" >/dev/null 2>&1 ;; 0) [ "$UNIT_DRIFTED" -eq 0 ] || cp -p "$UNIT_PATH" "${UNIT_PATH}.pre-upgrade" remove_socket_units write_unit ;; *) [ -f "$UNIT_PATH" ] || write_unit ;; esac systemctl daemon-reload write_upgrade_marker "install.sh upgrade" systemctl restart "$SERVICE_NAME" log "Waiting for the control plane to come up..." healthy_within || rollback_failed_upgrade "$pre_upgrade_bin" rm -f "$pre_upgrade_bin" log "Control plane is healthy." log "Upgraded to ${VERSION}." } do_retain() { [ -x "$BIN_PATH" ] || fatal "${BIN_PATH} not found. Run the installer without arguments first." supports_retain "$BIN_PATH" || fatal "the installed ${BINARY_NAME} predates release retention. Upgrade it first (install.sh upgrade), then retain." [ -n "${LEVELRAIL_VERSION:-}" ] || fatal "set LEVELRAIL_VERSION=vX.Y.Z to the release you want to roll back to" detect_arch [ -n "$GOARCH" ] || fatal "unsupported architecture: $(uname -m)" tmp_dir="$(mktemp -d)" trap 'rm -rf "$tmp_dir"' EXIT fetch_binary "$tmp_dir/$BINARY_NAME" schema=-1 if [ -z "${LEVELRAIL_BINARY_FILE:-}${LEVELRAIL_BINARY_URL:-}" ] && curl -fsSL --proto "$proto" --tlsv1.2 --connect-timeout 10 --max-time 30 -o "$tmp_dir/manifest.json" "${base_url}/release-manifest.json" 2>/dev/null; then found="$(sed -n 's/.*"schema_version": *\([0-9][0-9]*\).*/\1/p' "$tmp_dir/manifest.json" | head -n 1)" [ -z "$found" ] || schema="$found" fi mkdir -p "$RELEASES_DIR" "$BIN_PATH" retain --version "$VERSION" --file "$tmp_dir/$BINARY_NAME" --schema-version "$schema" --releases-dir "$RELEASES_DIR" log "Next, on this host: sudo ${BINARY_NAME} rollback --to ${VERSION}" } do_install_cosign() { detect_arch [ -n "$GOARCH" ] || fatal "unsupported architecture: $(uname -m)" ensure_cosign strict } do_uninstall() { if [ -f "$UNIT_PATH" ]; then systemctl disable --now "$SERVICE_NAME" >/dev/null 2>&1 || true remove_socket_units rm -f "$UNIT_PATH" "$DROPIN_PATH" "$RESTART_POLICY_PATH" rmdir "$DROPIN_DIR" 2>/dev/null || true systemctl daemon-reload fi rm -f "$BIN_PATH" log "Removed the ${SERVICE_NAME} service and ${BIN_PATH}." if [ "$PURGE" -eq 1 ]; then case "$DATA_DIR" in "" | / | /var | /var/lib | /usr | /etc | /home | /root) fatal "refusing to delete suspicious data dir: '$DATA_DIR'" ;; esac rm -rf "$DATA_DIR" log "Deleted ${DATA_DIR}." else log "Kept ${DATA_DIR} (database, master key, certificates). Re-run with --purge to delete it." fi log "Docker and any app containers it runs were left untouched." } case "$MODE" in install) do_install ;; check) do_check ;; upgrade) do_upgrade ;; retain) do_retain ;; install-cosign) do_install_cosign ;; uninstall) do_uninstall ;; esac