#!/usr/bin/env bash # # GRE tunnel panel installer. # # The panel is a single static Go binary with no runtime dependencies, so # installing it is a download plus a systemd unit. There is no interpreter, no # virtualenv and no package manager involved, and this script deliberately # stays that simple. # # Usage: # bash <(curl -Ls https://example.invalid/install.sh) # bash <(curl -Ls https://example.invalid/install.sh) --non-interactive \ # --username admin --password '...' --port 8443 --web-path abc123 --json # set -euo pipefail # ---------------------------------------------------------------- exit codes # # Documented and distinct, so wrapper tooling can tell what went wrong without # parsing prose. readonly EXIT_OK=0 readonly EXIT_NOT_ROOT=10 readonly EXIT_UNSUPPORTED_OS=11 readonly EXIT_NO_SYSTEMD=12 readonly EXIT_PORT_IN_USE=13 readonly EXIT_BAD_ARGUMENTS=14 readonly EXIT_DOWNLOAD_FAILED=15 readonly EXIT_CHECKSUM_FAILED=16 readonly EXIT_SERVICE_FAILED=17 readonly EXIT_NO_CONNECTIVITY=18 readonly SERVICE_NAME="gre-panel" readonly BINARY_PATH="/usr/local/bin/gre-panel" readonly DATA_DIR="/var/lib/gre-panel" readonly UNIT_PATH="/etc/systemd/system/gre-panel.service" readonly ENV_PATH="/etc/gre-panel.env" # The backend refuses anything shorter, so the installer refuses it too rather # than creating an account the panel will reject. readonly MIN_PASSWORD_LENGTH=12 # The marker the panel writes into every file it generates. It is what tells a # file the panel owns from one adopted from a previous setup, and it is the only # safe way for --purge-tunnels to decide what is its to remove. Kept identical # to persist.OwnershipMarker, which a test asserts. readonly PANEL_FILE_MARKER="# gre-panel:managed=1" readonly DEFAULT_RELEASE_BASE="https://tunnelpanel.bananalegends.com/dist/release" # ---------------------------------------------------------------- arguments USERNAME="" PASSWORD="" PORT="" WEB_PATH="" BIND_ADDRESS="0.0.0.0" LANGUAGE="" VERSION="latest" ARCH="" NON_INTERACTIVE=0 JSON_OUTPUT=0 ASSUME_YES=0 MODE="install" PURGE_TUNNELS=0 RELEASE_BASE="${GRE_PANEL_RELEASE_BASE:-$DEFAULT_RELEASE_BASE}" usage() { cat >&2 <<'USAGE' Install the GRE tunnel panel. --username Operator account to create on first run --password Its password (minimum 12 characters) --port Port the panel listens on --web-path Secret URL prefix the panel is served under --bind Address to bind (default 0.0.0.0) --language Initial interface language --version Release to install (default: latest) --arch Override architecture detection --non-interactive Never prompt; every required value must be given --json Print a machine-readable result on stdout --yes Do not ask for confirmation --upgrade Upgrade in place, preserving data and tunnels --uninstall Remove the panel, leaving tunnels running --purge-tunnels With --uninstall, also remove panel-managed tunnels -h, --help Show this message Exit codes: 0 ok, 10 not root, 11 unsupported OS, 12 no systemd, 13 port in use, 14 bad arguments, 15 download failed, 16 checksum failed, 17 service failed to start, 18 no outbound connectivity. USAGE } # Everything human-readable goes to stderr, so --json can own stdout entirely. say() { printf '%s\n' "$*" >&2; } step() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } fail() { local code="$1" shift printf '\033[1;31merror:\033[0m %s\n' "$*" >&2 exit "$code" } require_value() { local flag="$1" value="${2-}" if [[ -z "$value" || "$value" == --* ]]; then usage fail "$EXIT_BAD_ARGUMENTS" "$flag requires a value." fi } while [[ $# -gt 0 ]]; do case "$1" in --username) require_value "$1" "${2-}"; USERNAME="$2"; shift 2 ;; --password) require_value "$1" "${2-}"; PASSWORD="$2"; shift 2 ;; --port) require_value "$1" "${2-}"; PORT="$2"; shift 2 ;; --web-path) require_value "$1" "${2-}"; WEB_PATH="$2"; shift 2 ;; --bind) require_value "$1" "${2-}"; BIND_ADDRESS="$2"; shift 2 ;; --language) require_value "$1" "${2-}"; LANGUAGE="$2"; shift 2 ;; --version) require_value "$1" "${2-}"; VERSION="$2"; shift 2 ;; --arch) require_value "$1" "${2-}"; ARCH="$2"; shift 2 ;; --release-base) require_value "$1" "${2-}"; RELEASE_BASE="$2"; shift 2 ;; --non-interactive) NON_INTERACTIVE=1; shift ;; --json) JSON_OUTPUT=1; shift ;; --yes|-y) ASSUME_YES=1; shift ;; --upgrade) MODE="upgrade"; shift ;; --uninstall) MODE="uninstall"; shift ;; --purge-tunnels) PURGE_TUNNELS=1; shift ;; -h|--help) usage; exit "$EXIT_OK" ;; *) usage; fail "$EXIT_BAD_ARGUMENTS" "Unknown argument: $1" ;; esac done # ---------------------------------------------------------------- checks [[ $EUID -eq 0 ]] || fail "$EXIT_NOT_ROOT" "This installer must run as root. It configures kernel networking and a system service." command -v systemctl >/dev/null 2>&1 || fail "$EXIT_NO_SYSTEMD" "systemd is required and systemctl was not found." [[ -d /run/systemd/system ]] || fail "$EXIT_NO_SYSTEMD" "systemd is not the running init system." [[ "$(uname -s)" == "Linux" ]] || fail "$EXIT_UNSUPPORTED_OS" "This panel runs on Linux only." detect_arch() { case "$(uname -m)" in x86_64|amd64) printf 'amd64' ;; aarch64|arm64) printf 'arm64' ;; *) fail "$EXIT_UNSUPPORTED_OS" "Unsupported architecture: $(uname -m). Only amd64 and arm64 are published." ;; esac } [[ -n "$ARCH" ]] || ARCH="$(detect_arch)" case "$ARCH" in amd64|arm64) ;; *) fail "$EXIT_BAD_ARGUMENTS" "--arch must be amd64 or arm64." ;; esac DOWNLOADER="" if command -v curl >/dev/null 2>&1; then DOWNLOADER="curl" elif command -v wget >/dev/null 2>&1; then DOWNLOADER="wget" fi # Fetches a URL to a local path. A file:// source, or a bare absolute path, is # copied rather than downloaded: the release host does not exist yet, and the # whole flow has to be exercisable from a local directory without pretending to # be one. The default source is still an ordinary release URL. fetch() { local url="$1" destination="$2" case "$url" in file://*) cp -f "${url#file://}" "$destination" 2>/dev/null ;; /*) cp -f "$url" "$destination" 2>/dev/null ;; *) case "$DOWNLOADER" in curl) curl -fsSL --connect-timeout 15 --retry 2 -o "$destination" "$url" ;; wget) wget -q --timeout=15 --tries=3 -O "$destination" "$url" ;; *) return 1 ;; esac ;; esac } # True when the source needs the network at all. source_is_remote() { case "$RELEASE_BASE" in file://*|/*) return 1 ;; *) return 0 ;; esac } # ---------------------------------------------------------------- validation # # Anything the operator actually supplied is checked here, before the script # decides whether this is a fresh install or an upgrade. A password that is too # short is a bad argument whichever it turns out to be, and reporting it as a # download failure three steps later is no help at all. validate_supplied() { if [[ -n "$PASSWORD" ]] && (( ${#PASSWORD} < MIN_PASSWORD_LENGTH )); then fail "$EXIT_BAD_ARGUMENTS" "--password must be at least $MIN_PASSWORD_LENGTH characters; the panel refuses anything shorter." fi if [[ -n "$PORT" ]]; then [[ "$PORT" =~ ^[0-9]+$ ]] || fail "$EXIT_BAD_ARGUMENTS" "--port must be a number." (( PORT > 0 && PORT < 65536 )) || fail "$EXIT_BAD_ARGUMENTS" "--port must be between 1 and 65535." fi if [[ -n "$WEB_PATH" ]]; then [[ "$WEB_PATH" =~ ^[A-Za-z0-9._~-]+$ ]] || fail "$EXIT_BAD_ARGUMENTS" "--web-path may contain only letters, digits, dot, underscore, tilde and hyphen." fi if [[ -n "$LANGUAGE" && ! "$LANGUAGE" =~ ^[a-z]{2}(-[A-Za-z0-9]{2,8})?$ ]]; then fail "$EXIT_BAD_ARGUMENTS" "--language must be a language tag such as en or fa." fi } validate_supplied # ---------------------------------------------------------------- uninstall if [[ "$MODE" == "uninstall" ]]; then if [[ $ASSUME_YES -eq 0 ]]; then if [[ $NON_INTERACTIVE -eq 1 ]]; then fail "$EXIT_BAD_ARGUMENTS" "--uninstall without --yes needs a terminal to confirm on." fi say "This removes the panel from this server." if [[ $PURGE_TUNNELS -eq 1 ]]; then say "Because --purge-tunnels was given, the tunnels it manages will also be removed." else say "Configured tunnels will be left running." fi read -r -p "Continue? [y/N] " reply /dev/null || true systemctl disable "$SERVICE_NAME" 2>/dev/null || true if [[ $PURGE_TUNNELS -eq 1 ]]; then # Panel-managed tunnels are exactly the ones whose files carry the panel's # ownership marker. Selecting by name cannot work: the panel names a unit # after its interface, so gre-a-1.service is indistinguishable by name from # a legacy unit adopted from a previous setup — and adopting one explicitly # promises not to take it over. The marker is what adoption itself uses to # decide whether a file is the panel's to rewrite, so it is used here too. step "Removing panel-managed tunnels" shopt -s nullglob for unit in /etc/systemd/system/*.service; do case "$(basename "$unit")" in "$SERVICE_NAME.service"|gre-panel-rules.service) continue ;; esac grep -q "$PANEL_FILE_MARKER" "$unit" 2>/dev/null || continue tunnel_unit="$(basename "$unit")" interface="${tunnel_unit%.service}" systemctl stop "$tunnel_unit" 2>/dev/null || true systemctl disable "$tunnel_unit" 2>/dev/null || true # The keepalive unit is the panel's too, and is named for the interface. keepalive="/etc/systemd/system/gre-panel-keepalive-$interface.service" if [[ -f "$keepalive" ]]; then systemctl stop "$(basename "$keepalive")" 2>/dev/null || true systemctl disable "$(basename "$keepalive")" 2>/dev/null || true rm -f "$keepalive" fi ip link del "$interface" 2>/dev/null || true rm -f "$unit" done # Tunnels persisted through networkd have no unit; they have a .netdev and # a .network carrying the same marker. for netdev in /etc/systemd/network/*.netdev; do grep -q "$PANEL_FILE_MARKER" "$netdev" 2>/dev/null || continue interface="$(basename "$netdev" .netdev)" ip link del "$interface" 2>/dev/null || true rm -f "$netdev" "/etc/systemd/network/$interface.network" done shopt -u nullglob rm -rf "$DATA_DIR" else say "Tunnels and their unit files were left in place." say "The panel's data directory was kept at $DATA_DIR." fi rm -f "$UNIT_PATH" "$BINARY_PATH" "$ENV_PATH" systemctl daemon-reload if [[ $JSON_OUTPUT -eq 1 ]]; then printf '{"action":"uninstall","purged_tunnels":%s,"service":"%s"}\n' \ "$([[ $PURGE_TUNNELS -eq 1 ]] && echo true || echo false)" "$SERVICE_NAME" fi step "The panel has been removed." exit "$EXIT_OK" fi # ---------------------------------------------------------------- gather input random_port() { # A high port chosen at random is a better default than a memorable one: the # panel should not be where a scan expects it. local candidate for _ in $(seq 1 40); do candidate=$(( (RANDOM % 20000) + 20000 )) if ! port_in_use "$candidate"; then printf '%s' "$candidate" return 0 fi done printf '18080' } random_web_path() { # Proposed rather than left to the operator, so accepting the default is the # safe choice instead of the lazy one. if command -v openssl >/dev/null 2>&1; then openssl rand -hex 12 else head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' | cut -c1-24 fi } port_in_use() { local port="$1" if command -v ss >/dev/null 2>&1; then ss -Hltn "sport = :$port" 2>/dev/null | grep -q . && return 0 return 1 fi if command -v netstat >/dev/null 2>&1; then netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$port\$" && return 0 return 1 fi # With neither tool the check cannot be made; the service start will catch it. return 1 } prompt() { local label="$1" default="$2" answer="" read -r -p "$label [$default]: " answer &2 if (( ${#first} < MIN_PASSWORD_LENGTH )); then warn "The password must be at least $MIN_PASSWORD_LENGTH characters." continue fi read -r -s -p "Confirm password: " second &2 if [[ "$first" != "$second" ]]; then warn "The two passwords do not match." continue fi printf '%s' "$first" return 0 done } UPGRADE_EXISTING=0 if [[ -x "$BINARY_PATH" || -f "$UNIT_PATH" ]]; then UPGRADE_EXISTING=1 fi [[ "$MODE" == "upgrade" ]] && UPGRADE_EXISTING=1 if [[ $UPGRADE_EXISTING -eq 1 ]]; then # An upgrade keeps the database, the settings and every tunnel, so none of # the first-run questions apply. step "An existing installation was found; upgrading in place" if [[ -f "$ENV_PATH" ]]; then # shellcheck disable=SC1090 while IFS='=' read -r key value; do case "$key" in GRE_PANEL_BIND_PORT) PORT="${PORT:-$value}" ;; GRE_PANEL_WEB_PATH) WEB_PATH="${WEB_PATH:-$value}" ;; GRE_PANEL_BIND_HOST) BIND_ADDRESS="$value" ;; esac done < <(grep -E '^GRE_PANEL_' "$ENV_PATH" || true) fi else if [[ $NON_INTERACTIVE -eq 1 ]]; then missing=() [[ -n "$USERNAME" ]] || missing+=("--username") [[ -n "$PASSWORD" ]] || missing+=("--password") [[ -n "$PORT" ]] || missing+=("--port") [[ -n "$WEB_PATH" ]] || missing+=("--web-path") if (( ${#missing[@]} )); then # Never silently generate a password: an account nobody knows the # password to is worse than a failed install. fail "$EXIT_BAD_ARGUMENTS" "--non-interactive needs every value. Missing: ${missing[*]}" fi else [[ -t 0 || -e /dev/tty ]] || fail "$EXIT_BAD_ARGUMENTS" "No terminal to prompt on. Use --non-interactive with every flag." say "" say "Installing the GRE tunnel panel." say "" [[ -n "$USERNAME" ]] || USERNAME="$(prompt 'Admin username' 'admin')" [[ -n "$PASSWORD" ]] || PASSWORD="$(prompt_secret "Admin password (at least $MIN_PASSWORD_LENGTH characters)")" [[ -n "$PORT" ]] || PORT="$(prompt 'Panel port' "$(random_port)")" [[ -n "$WEB_PATH" ]] || WEB_PATH="$(prompt 'Web path' "$(random_web_path)")" [[ -n "$LANGUAGE" ]] || LANGUAGE="$(prompt 'Language (fa/en)' 'en')" fi # The prompts can have produced new values, so they are checked too. validate_supplied if port_in_use "$PORT"; then fail "$EXIT_PORT_IN_USE" "Port $PORT is already in use. Choose another with --port." fi fi [[ -n "$LANGUAGE" ]] || LANGUAGE="en" # ---------------------------------------------------------------- download [[ -n "$DOWNLOADER" ]] || fail "$EXIT_NO_CONNECTIVITY" "Neither curl nor wget is installed, so the release cannot be downloaded." if source_is_remote; then step "Checking outbound connectivity" [[ -n "$DOWNLOADER" ]] || fail "$EXIT_NO_CONNECTIVITY" "Neither curl nor wget is installed, so the release cannot be downloaded." fi STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT RELEASE_URL="$RELEASE_BASE/$VERSION/gre-panel-linux-$ARCH" CHECKSUM_URL="$RELEASE_URL.sha256" step "Downloading gre-panel $VERSION ($ARCH)" if ! fetch "$RELEASE_URL" "$STAGING/gre-panel"; then fail "$EXIT_DOWNLOAD_FAILED" "Could not download $RELEASE_URL. The existing installation was not touched." fi step "Verifying the checksum" if ! fetch "$CHECKSUM_URL" "$STAGING/gre-panel.sha256"; then fail "$EXIT_DOWNLOAD_FAILED" "Could not download the checksum from $CHECKSUM_URL. Refusing to install an unverified binary." fi EXPECTED_SUM="$(awk '{print $1}' "$STAGING/gre-panel.sha256" | head -n1)" ACTUAL_SUM="$(sha256sum "$STAGING/gre-panel" | awk '{print $1}')" if [[ -z "$EXPECTED_SUM" || "$EXPECTED_SUM" != "$ACTUAL_SUM" ]]; then # An unverified download must never reach an existing installation. fail "$EXIT_CHECKSUM_FAILED" "Checksum mismatch. Expected $EXPECTED_SUM, got $ACTUAL_SUM. Nothing was installed." fi chmod 0755 "$STAGING/gre-panel" if ! "$STAGING/gre-panel" --version >/dev/null 2>&1; then fail "$EXIT_DOWNLOAD_FAILED" "The downloaded binary did not run on this host." fi # ---------------------------------------------------------------- install step "Installing to $BINARY_PATH" install -m 0755 "$STAGING/gre-panel" "$BINARY_PATH" install -d -m 0700 "$DATA_DIR" # Every directory named in the unit's ReadWritePaths has to exist, or systemd # refuses to set up the mount namespace and the service will not start at all. # These are standard on any systemd host; creating them costs nothing and makes # the unit safe on one where a directory happens to be absent. install -d -m 0755 /etc/sysctl.d /etc/systemd/system /etc/systemd/network if [[ $UPGRADE_EXISTING -eq 0 ]]; then step "Writing $ENV_PATH" umask 077 cat > "$ENV_PATH" < "$UNIT_PATH" </fd # for the socket's inode. That is a ptrace-mode read: being the same uid is not # enough when the target holds capabilities the reader does not, and sshd holds # the full set while this unit deliberately holds a handful. Without it the walk # is denied for every process but the panel itself, and the panel concludes that # nothing is listening on SSH. It then protects port 22 as a precaution, which # is right on a stock host and wrong on one that moved SSH to 2222 — protected # on the port nothing uses, forwardable on the port that locks you out. Measured # on both hosts: without it SshPorts() is empty, with it the live port is found. # CAP_DAC_READ_SEARCH is not sufficient; ptrace access is what is being checked. User=root AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_SYS_PTRACE CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER CAP_SYS_MODULE CAP_SYS_PTRACE # Hardening that is safe here: # NoNewPrivileges - the panel never needs to gain privileges it did not start with # ProtectHome - it has no business in /home, /root or /run/user # ProtectSystem=full - /usr, /boot AND /etc read-only. Everything the panel # writes under /etc therefore has to be named in # ReadWritePaths below; a directory that is not listed is # read-only no matter that the panel runs as root. # PrivateTmp - its temporary files are its own # # Hardening that would BREAK tunnel management, and is deliberately absent: # PrivateNetwork=yes - would put the panel in its own netns, where the # host's interfaces and routes do not exist # ProtectSystem=strict - would make the whole filesystem read-only, /var # included, so not even the database could be written # ProtectKernelModules=yes - would stop ip_gre autoloading on first tunnel # RestrictAddressFamilies - without AF_NETLINK and AF_PACKET there is no # netlink and no raw ICMP probing # CapabilityBoundingSet without CAP_NET_ADMIN - no interface can be created NoNewPrivileges=yes ProtectHome=yes ProtectSystem=full PrivateTmp=yes # Every path the panel writes to. /etc/sysctl.d is here because the panel owns # one file in it, 99-gre-panel.conf, which is how enabling IP forwarding # survives a reboot: without the carve-out that write fails on a read-only # filesystem and the forwarding switch can never be turned on from the panel. ReadWritePaths=$DATA_DIR /etc/systemd/system /etc/systemd/network /etc/sysctl.d /run/systemd [Install] WantedBy=multi-user.target UNIT systemctl daemon-reload systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || true step "Starting the service" systemctl restart "$SERVICE_NAME" || fail "$EXIT_SERVICE_FAILED" "systemctl could not start $SERVICE_NAME. Run: journalctl -u $SERVICE_NAME -n 50" # ---------------------------------------------------------------- readiness # # The failure mode of the script this project replaces was printing success # because systemctl returned zero. Success is claimed here only once the panel # itself answers. HEALTH_HOST="$BIND_ADDRESS" [[ "$HEALTH_HOST" == "0.0.0.0" || "$HEALTH_HOST" == "::" ]] && HEALTH_HOST="127.0.0.1" HEALTH_URL="http://$HEALTH_HOST:$PORT/$WEB_PATH/api/v1/system/health" step "Waiting for the panel to answer" READY=0 for _ in $(seq 1 30); do if [[ "$DOWNLOADER" == "curl" ]]; then if curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then READY=1; break; fi else if wget -q --timeout=3 -O /dev/null "$HEALTH_URL" 2>/dev/null; then READY=1; break; fi fi sleep 1 done if [[ $READY -eq 0 ]]; then say "" systemctl status "$SERVICE_NAME" --no-pager -n 20 >&2 || true fail "$EXIT_SERVICE_FAILED" "The service started but the panel never answered at $HEALTH_URL." fi # ---------------------------------------------------------------- first account if [[ $UPGRADE_EXISTING -eq 0 && -n "$USERNAME" ]]; then step "Creating the operator account" SETUP_URL="http://$HEALTH_HOST:$PORT/$WEB_PATH/api/v1/auth/setup" SETUP_BODY="$(printf '{"username":%s,"password":%s}' \ "$(printf '%s' "$USERNAME" | sed 's/\\/\\\\/g; s/"/\\"/g; s/^/"/; s/$/"/')" \ "$(printf '%s' "$PASSWORD" | sed 's/\\/\\\\/g; s/"/\\"/g; s/^/"/; s/$/"/')")" if [[ "$DOWNLOADER" == "curl" ]]; then SETUP_STATUS="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$SETUP_URL" \ -H 'Content-Type: application/json' -d "$SETUP_BODY" || true)" else SETUP_STATUS="$(wget -q -O /dev/null --server-response --method=POST \ --header='Content-Type: application/json' --body-data="$SETUP_BODY" "$SETUP_URL" 2>&1 | awk '/HTTP\//{code=$2} END{print code}' || true)" fi case "$SETUP_STATUS" in 200|201) ;; 409) warn "An account already exists on this panel; the one given was not created." ;; *) warn "The account could not be created (HTTP ${SETUP_STATUS:-none}). Create it at the panel's first-run screen." ;; esac fi # ---------------------------------------------------------------- result PUBLIC_HOST="$BIND_ADDRESS" if [[ "$PUBLIC_HOST" == "0.0.0.0" || "$PUBLIC_HOST" == "::" ]]; then PUBLIC_HOST="$(hostname -I 2>/dev/null | awk '{print $1}')" [[ -n "$PUBLIC_HOST" ]] || PUBLIC_HOST="127.0.0.1" fi PANEL_URL="http://$PUBLIC_HOST:$PORT/$WEB_PATH/" INSTALLED_VERSION="$("$BINARY_PATH" --version 2>/dev/null | head -n1 | awk '{print $NF}')" if [[ $JSON_OUTPUT -eq 1 ]]; then printf '{"action":"%s","url":"%s","port":%s,"web_path":"%s","username":"%s","service":"%s","version":"%s","architecture":"%s"}\n' \ "$([[ $UPGRADE_EXISTING -eq 1 ]] && echo upgrade || echo install)" \ "$PANEL_URL" "$PORT" "$WEB_PATH" "$USERNAME" "$SERVICE_NAME" "${INSTALLED_VERSION:-unknown}" "$ARCH" fi say "" step "The panel is running." say "" say " URL: $PANEL_URL" say " Service: $SERVICE_NAME" say " Data: $DATA_DIR" say " Version: ${INSTALLED_VERSION:-unknown} ($ARCH)" say "" case "$BIND_ADDRESS" in 127.*|::1|localhost) ;; *) warn "The panel is bound to $BIND_ADDRESS and serves plain HTTP." warn "Passwords and session cookies will cross the network unencrypted." warn "Put a TLS-terminating reverse proxy in front of it, or bind to 127.0.0.1" warn "and reach it through an SSH tunnel." ;; esac exit "$EXIT_OK"