#!/usr/bin/env bash # # W-UI installer. # # Brings a bare server to the point where the panel can actually serve tunnels: # WireGuard, AmneziaWG, OpenVPN, L2TP/IPsec, IKEv2, PPTP, OpenConnect, xray and # sing-box for the proxy outbounds, nftables, kernel forwarding, a systemd unit # and the panel itself. Safe to re-run — it upgrades in place rather than starting # over, and every step checks whether it is already done. # # curl -fsSL https://raw.githubusercontent.com/AbolfazlTafakori/w-ui/main/install.sh | sudo bash # # It asks first how to install: # # 1) Manual every choice is asked -- the port, the URL path, who signs # in, the database, the certificate, and which protocols # 2) Automatic three questions -- the database, a domain, a certificate for # the address -- and everything else is chosen and set up, every # protocol installed, and shown at the end: the address, the # username and the password # # Answer nothing and it uses the defaults below. Every answer can also be given # as a flag, which is what --yes needs to run unattended. # # Flags: # --local install a binary you already built instead of downloading # --from-source build from the repository in the current directory # --no-amnezia skip AmneziaWG (it builds a kernel module and needs headers) # --no-openvpn skip OpenVPN if you only sell WireGuard # --no-l2tp skip L2TP/IPsec (strongSwan, xl2tpd) # --no-ikev2 skip IKEv2 (strongSwan and its EAP plugins) # --no-pptp skip PPTP (pppd) # --no-openconnect skip OpenConnect (ocserv) # --no-proxy-cores skip xray and sing-box (the proxy outbounds) # Every protocol installed stays off until a tunnel uses # it, and one skipped is installed by the panel when the # first tunnel of its kind is made. # --port panel port (default: random) # --sub-port subscription service port (default: random) # --db sqlite (default) or postgres, for a large customer count # --username administrator name (default admin) # --password administrator password (default: generated) # --domain get a Let's Encrypt certificate for this domain # --ip-cert [addr] get a 6-day Let's Encrypt certificate for this server's # address (auto-detected unless given); renews itself # --email where the certificate authority sends expiry notices # --tls-cert use a certificate you already have # --tls-key its private key # --no-tls serve plain HTTP (only sane behind a proxy or tunnel) # --local-only bind to 127.0.0.1, for use behind a proxy or a tunnel # --auto the automatic install: three questions, the rest chosen # --manual the manual install: every question, as before # -y, --yes ask nothing; use flags, environment and defaults # --uninstall remove the service, binary and unit; keeps the database # --purge remove everything including the database # # errexit and nounset, deliberately without pipefail. # # Nearly every pipeline here ends in `head`, `grep -q` or `tail`, which is # how those tools are meant to be used: they close the pipe as soon as they # have what they need, and the command upstream dies of SIGPIPE. Under # pipefail that is reported as a failure of the whole pipeline -- exit 141 -- # and errexit then ends the install without printing anything at all. # # That is not a hypothetical. It killed a real install at the first random # password: `tr -dc … /dev/null 2>&1 } BIN_PATH=/usr/local/bin/wui MENU_PATH=/usr/local/bin/w-ui MENU_URL="${WUI_MENU_URL:-https://raw.githubusercontent.com/AbolfazlTafakori/w-ui/main/w-ui.sh}" DATA_DIR=/var/lib/wui CONF_DIR=/etc/wui UNIT=/etc/systemd/system/wui.service # Scheduled backups live outside the data directory on purpose: removing # the panel, or its data, must never remove the copies of it. BACKUP_DIR=/var/backups/wui SERVICE_USER=wui RELEASE_URL="${WUI_RELEASE_URL:-}" ASSET_URL="" SUMS_URL="" # Where to fetch from when nothing else was asked for. Overridable, because a # fork or a mirror is a normal thing to install from. REPO="${WUI_REPO:-AbolfazlTafakori/w-ui}" BRANCH="${WUI_BRANCH:-main}" # The panel binary's own default, kept only for an older install whose unit # names no port. A new install never gets it: unless the operator types a # port, it gets a random free one. PANEL_PORT=2096 # The subscription service's own port. Random, like the panel's: the panel # picks it and enables the service, so a customer's link works from the first # minute without the operator having to find the setting. SUB_PORT="${WUI_SUB_PORT:-}" SUB_KNOWN=0 # Set only when the port was read from an install already here: a port # given on the command line is still a fresh install to switch on. SUB_EXISTING=0 # Where the data lives. SQLite for most installs; PostgreSQL for one with a # lot of customers, the way the classic panel offers both. DB_DRIVER="${WUI_DB_DRIVER:-}" DB_KNOWN=0 DB_SOURCE="" DB_PASS="" WANT_AMNEZIA=1 WANT_OPENVPN=1 WANT_L2TP=1 WANT_IKEV2=1 WANT_PPTP=1 WANT_OPENCONNECT=1 WANT_PROXY=1 LOCAL_BIN="" FROM_SOURCE=0 ACTION=install # What the questions fill in. Pre-seeded from the environment so a cloud # image can set them once and never see a prompt. ADMIN_USER="${WUI_ADMIN_USER:-}" ADMIN_PASS="${WUI_ADMIN_PASSWORD:-}" ADMIN_GENERATED=0 API_TOKEN="" # Set by configure when a panel is already installed here: this run is an # update, and leaves what the first install handed out as it was. IS_UPDATE=0 TLS_MODE="" TLS_CERT="${WUI_TLS_CERT:-}" TLS_KEY="${WUI_TLS_KEY:-}" ACME_DOMAIN="${WUI_DOMAIN:-}" ACME_IP="${WUI_SERVER_IP:-}" ACME_IPV6="${WUI_SSL_IPV6:-}" TLS_DOMAIN="" TLS_KEPT=0 ACME_EMAIL="${WUI_ACME_EMAIL:-}" ACME_METHOD="" # Which address the panel binds to. 127.0.0.1 when something else is # terminating TLS in front of it, in which case the panel must not be # reachable from outside at all. LISTEN_ADDR="${WUI_LISTEN_ADDR:-0.0.0.0}" # The URL path the panel answers on, one segment, empty for the root. BASE_PATH="${WUI_BASE_PATH:-}" # Set when an existing install already decided these, so re-running does not # move a panel that people have bookmarked. PORT_KNOWN=0 BASE_KNOWN=0 ASSUME_YES=0 INTERACTIVE=0 # manual or auto; asked first when empty. From the environment so a script # can pick it the way it picks anything else. INSTALL_MODE="${WUI_INSTALL_MODE:-}" # ── output ─────────────────────────────────────────────────────────────────── # Colour only on a terminal, and never under NO_COLOR: a log, a pipe or a # test reads exactly the same words with none of it. if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; C=$'\e[36m'; M=$'\e[35m'; B=$'\e[1m'; D=$'\e[2m'; U=$'\e[4m'; N=$'\e[0m' # The panel's own colour, for its name. P=$'\e[38;5;203m' else R=""; G=""; Y=""; C=""; M=""; B=""; D=""; U=""; N=""; P="" fi # What each kind of line looks like: a step is a cyan heading, done is # green, a warning yellow and a failure red -- so the one line that needs # reading stands out of a long install. step() { printf '\n%s%s==>%s %s%s%s%s\n' "$B" "$C" "$N" "$B" "$C" "$*" "$N"; } info() { printf ' %s\n' "$*"; } ok() { printf ' %s%s✓%s %s%s%s\n' "$B" "$G" "$N" "$G" "$*" "$N"; } warn() { printf ' %s%s!%s %s%s%s\n' "$B" "$Y" "$N" "$Y" "$*" "$N"; } die() { printf '\n%s%serror:%s %s%s%s\n\n' "$B" "$R" "$N" "$R" "$*" "$N" >&2; exit 1; } # How a question is laid out: its heading magenta, each choice's number # green, a note yellow. The same words info would print, only coloured. qhead() { printf ' %s%s%s%s\n' "$B" "$M" "$*" "$N"; } opt() { printf ' %s%s%s)%s %s\n' "$B" "$G" "$1" "$N" "$2"; } note() { printf ' %s%s%s\n' "$Y" "$*" "$N"; } # kv LABEL VALUE [COLOUR] is one line of the summary: the label in cyan, the # value in green unless another colour is given. kv() { printf ' %s%-10s%s %s%s%s\n' "$C" "$1" "$N" "${3:-$G}" "$2" "$N"; } # state VALUE colours what the summary says about a part: missing or off is # red, not installed yellow, anything else -- a version, on -- green. state() { case "$1" in missing|off) printf '%s%s%s' "$R" "$1" "$N" ;; "not installed") printf '%s%s%s' "$Y" "$1" "$N" ;; *) printf '%s%s%s' "$G" "$1" "$N" ;; esac } # ── arguments ──────────────────────────────────────────────────────────────── # WUI_LIB_ONLY lets the test suites source this file for its functions without # installing anything. It is what makes them able to test what actually ships: # they used to cut the definitions out with sed, and sed on one machine split a # long comment line in half, so every test after it ran against a file that was # not this one. # # Checked here rather than further down because a sourced file inherits the # caller's positional parameters -- inside a function, that is the function's # own arguments, which this loop would then try to parse as install flags. LIB_ONLY="${WUI_LIB_ONLY:-0}" while [[ "$LIB_ONLY" != 1 && $# -gt 0 ]]; do case "$1" in --local) LOCAL_BIN="${2:?--local needs a path}"; shift 2 ;; --from-source) FROM_SOURCE=1; shift ;; --no-amnezia) WANT_AMNEZIA=0; shift ;; --no-openvpn) WANT_OPENVPN=0; shift ;; --no-l2tp) WANT_L2TP=0; shift ;; --no-ikev2) WANT_IKEV2=0; shift ;; --no-pptp) WANT_PPTP=0; shift ;; --no-openconnect) WANT_OPENCONNECT=0; shift ;; --no-proxy-cores) WANT_PROXY=0; shift ;; --port) PANEL_PORT="${2:?--port needs a number}"; PORT_KNOWN=1; shift 2 ;; --sub-port) SUB_PORT="${2:?--sub-port needs a number}"; SUB_KNOWN=1; shift 2 ;; --db) DB_DRIVER="${2:?--db needs sqlite or postgres}"; DB_KNOWN=1; shift 2 ;; --username) ADMIN_USER="${2:?--username needs a name}"; shift 2 ;; --password) ADMIN_PASS="${2:?--password needs a value}"; shift 2 ;; --domain) ACME_DOMAIN="${2:?--domain needs a name}"; shift 2 ;; --ip-cert) ACME_IP="${2:-}"; [[ -n "$ACME_IP" && "$ACME_IP" != -* ]] && shift; TLS_MODE=ip; shift ;; --email) ACME_EMAIL="${2:?--email needs an address}"; shift 2 ;; --tls-cert) TLS_CERT="${2:?--tls-cert needs a path}"; shift 2 ;; --tls-key) TLS_KEY="${2:?--tls-key needs a path}"; shift 2 ;; --path) BASE_PATH="${2:?--path needs a value}"; BASE_KNOWN=1; shift 2 ;; --no-path) BASE_PATH=""; BASE_KNOWN=1; shift ;; --local-only) LISTEN_ADDR=127.0.0.1; shift ;; --no-tls) TLS_MODE=none; shift ;; -y|--yes) ASSUME_YES=1; shift ;; --auto) INSTALL_MODE=auto; shift ;; --manual) INSTALL_MODE=manual; shift ;; --uninstall) ACTION=uninstall; shift ;; --purge) ACTION=purge; shift ;; -h|--help) awk '/^# Flags:/,/^set -/' "$0" | sed 's/^# \{0,1\}//; /^set -/d'; exit 0 ;; *) die "unknown option: $1" ;; esac done [[ "$LIB_ONLY" == 1 || $EUID -eq 0 ]] || die "run as root (sudo bash $0)" # ── platform ───────────────────────────────────────────────────────────────── detect_os() { [[ -r /etc/os-release ]] || die "cannot read /etc/os-release; unsupported system" # shellcheck disable=SC1091 . /etc/os-release OS_ID="${ID:-unknown}" OS_LIKE="${ID_LIKE:-}" OS_NAME="${PRETTY_NAME:-$OS_ID}" # shellcheck disable=SC2034 # for the scripts that source this one OS_VERSION="${VERSION_ID:-}" # The archive codename, which is what a third-party repository publishes # under. A release too new for a PPA is the usual reason apt breaks after # adding one. OS_CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}" case "$OS_ID" in ubuntu|debian) FAMILY=debian ;; fedora|rhel|centos|almalinux|rocky|ol) FAMILY=rhel ;; *) case "$OS_LIKE" in *debian*) FAMILY=debian ;; *rhel*|*fedora*) FAMILY=rhel ;; *) die "unsupported distribution: $OS_NAME" ;; esac ;; esac ARCH_RAW="$(uname -m)" case "$ARCH_RAW" in x86_64|amd64) ARCH=amd64 ;; aarch64|arm64) ARCH=arm64 ;; *) die "unsupported architecture: $ARCH_RAW" ;; esac KERNEL="$(uname -r)" } # ── uninstall ──────────────────────────────────────────────────────────────── # One archive of everything the panel holds, in root's home, before anything # is deleted. Cheap -- a few hundred kilobytes -- and the difference between # a mistake and a disaster. last_copy_of_data() { [[ -d "$DATA_DIR" ]] || return 0 local out out="/root/wui-last-copy-$(date +%Y%m%d-%H%M%S).tar.gz" # The panel's own archive when it can make one -- the database from either # engine, restorable into either -- and a plain copy when it cannot. local made="" if [[ -x "$BIN_PATH" && -r "$CONF_DIR/db.env" ]]; then # shellcheck disable=SC1091 made=$( (set -a; . "$CONF_DIR/db.env"; set +a; WUI_DATA_DIR="$DATA_DIR" "$BIN_PATH" backup create --dir /root 2>/dev/null | tail -1) ) fi if [[ -n "$made" && -f "$made" ]] && mv -f "$made" "$out"; then chmod 0600 "$out" ok "a copy of the database and keys is at $out" elif tar czf "$out" --exclude='*/backups' -C / "${DATA_DIR#/}" "${CONF_DIR#/}" 2>/dev/null; then chmod 0600 "$out" ok "a copy of the database and keys is at $out" else warn "could not copy $DATA_DIR before removing it" if [[ "$INTERACTIVE" == 1 ]]; then ask_yn "Delete it anyway?" n || die "nothing was deleted" fi fi } do_uninstall() { step "Removing W-UI" if have_systemd && systemctl list-unit-files 2>/dev/null | grep -q '^wui\.service'; then systemctl disable --now wui.service >/dev/null 2>&1 || true ok "service stopped and disabled" fi # With the panel stopped, and before the binary goes: the agent is part of # it, and is what takes an L2TP tunnel down. remove_l2tp_agent # What the firewall helper opened is closed, while the binary that knows # which rules are its own is still here; the operator's rules stay. if have_systemd && [[ -f /etc/systemd/system/wui-firewall.service ]]; then systemctl disable --now wui-firewall.path >/dev/null 2>&1 || true rm -f "$DATA_DIR/firewall/ports.json" [[ -x "$BIN_PATH" ]] && "$BIN_PATH" firewall-sync "$DATA_DIR" >/dev/null 2>&1 || true systemctl disable wui-firewall.service >/dev/null 2>&1 || true rm -f /etc/systemd/system/wui-firewall.service /etc/systemd/system/wui-firewall.path rm -rf /var/lib/wui-firewall fi have_systemd && systemctl disable --now wui-update.path >/dev/null 2>&1 || true have_systemd && systemctl disable wui-amnezia-kmod.service >/dev/null 2>&1 || true rm -f "$UNIT" /etc/systemd/system/wui-update.path /etc/systemd/system/wui-update.service \ /etc/systemd/system/wui-amnezia-kmod.service /usr/local/libexec/wui-amnezia-kmod have_systemd && systemctl daemon-reload || true rm -f "$BIN_PATH" "$MENU_PATH" ok "binary and unit removed" if [[ "$ACTION" == purge ]]; then # Never the only copy. A purge typed on the wrong server, or a script # that reached this with nobody watching, must leave the customers # recoverable: the database and keys go to root's home first. last_copy_of_data rm -rf "$DATA_DIR" "$CONF_DIR" id -u "$SERVICE_USER" >/dev/null 2>&1 && userdel "$SERVICE_USER" 2>/dev/null || true warn "database and configuration deleted" else info "database kept at $DATA_DIR — use --purge to delete it too" fi [[ -d "$BACKUP_DIR" ]] && info "scheduled backups kept at $BACKUP_DIR" # The VPN packages are deliberately left alone: they may predate this panel, # and removing WireGuard would take down tunnels the operator still needs. info "wireguard / openvpn / strongswan / xl2tpd / nftables were left installed" printf '\n%sW-UI removed.%s\n\n' "$G" "$N" exit 0 } # ── packages ───────────────────────────────────────────────────────────────── # Third-party repositories break. When one does, everything this installer # actually needs is still in the distribution's own archive, so a failed # refresh is reported and stepped over rather than being fatal. # # It is fatal only in the sense that `set -e` would make it so: `apt-get update` # returns non-zero if any single source failed, including one this installer # added itself on an earlier run. That is how an install could end at the first # step with nothing done. # keep_dkms_from_breaking_apt: on Ubuntu 24.10 and later, a DKMS module that # does not build for a newly installed kernel fails that kernel's package, and # dpkg is left half-configured: every apt install after it fails, the panel's # and the server's own updates alike. The AmneziaWG module does not build for # every kernel the day it comes out, so it would do that on a customer's # server at the next unattended upgrade. This file is DKMS's own switch for # it: a module that fails is reported and the kernel installs. For that # kernel, wui-amnezia-kmod tries the module again at boot, and the tunnels run # on amneziawg-go until one builds. keep_dkms_from_breaking_apt() { [[ "$FAMILY" == debian ]] || return 0 install -d -m 0755 /etc/dkms 2>/dev/null || return 0 [[ -f /etc/dkms/no-autoinstall-errors ]] && return 0 printf '%s\n' "# W-UI: a DKMS module that does not build for a new kernel must not leave" \ "# dpkg broken; the AmneziaWG module is rebuilt at boot by wui-amnezia-kmod." \ > /etc/dkms/no-autoinstall-errors } # drop_failed_amnezia_package takes out the AmneziaWG module package when # its own build failed. amneziawg-dkms builds the module in its postinst; # when the build fails the package stays half-configured, and apt then # refuses every install after it -- OpenVPN, L2TP, IKEv2, PPTP, OpenConnect, # and the server's own updates. Seen on Ubuntu 22.04 with kernel 5.15.0-46. # The userspace amneziawg-go carries the tunnels instead and needs none of it; # amneziawg-tools, configured on its own, stays. drop_failed_amnezia_package() { [[ "$FAMILY" == debian ]] || return 0 dpkg --audit 2>/dev/null | grep -q amneziawg || return 0 DEBIAN_FRONTEND=noninteractive dpkg --remove --force-remove-reinstreq \ amneziawg amneziawg-dkms >/dev/null 2>&1 || true DEBIAN_FRONTEND=noninteractive dpkg --configure -a >/dev/null 2>&1 || true if dpkg --audit 2>/dev/null | grep -q amneziawg; then warn "the AmneziaWG module package is still half-installed; apt may refuse other packages" warn " remove it with: dpkg --remove --force-remove-reinstreq amneziawg amneziawg-dkms" else ok "removed the AmneziaWG module package that did not build; apt works again" fi } # repair_dpkg finishes what a failed run left half-configured -- a kernel # whose DKMS module did not build, most often -- so the installs below can # work. Nothing is removed but an AmneziaWG module package that cannot build, # which would otherwise keep apt broken for good. repair_dpkg() { [[ "$FAMILY" == debian ]] || return 0 [[ -z "$(dpkg --audit 2>/dev/null)" ]] && return 0 warn "dpkg has packages left half-installed; finishing them" if [[ -d /var/lib/dkms/amneziawg ]]; then keep_dkms_from_breaking_apt fi if DEBIAN_FRONTEND=noninteractive dpkg --configure -a >/tmp/wui-dpkg.log 2>&1; then ok "dpkg is consistent again" return 0 fi drop_failed_amnezia_package if [[ -z "$(dpkg --audit 2>/dev/null)" ]]; then ok "dpkg is consistent again" else warn "dpkg could not finish them; see /tmp/wui-dpkg.log" fi } pkg_refresh() { case "$FAMILY" in debian) if ! DEBIAN_FRONTEND=noninteractive apt-get update -qq 2>/tmp/wui-apt.err; then # Name the repositories that failed, so the operator can see it is not # the distribution archive that is unreachable. local broken broken="$(grep -oE "https?://[^ ]+" /tmp/wui-apt.err 2>/dev/null | sort -u | head -3)" warn "some package sources could not be refreshed" [[ -n "$broken" ]] && printf '%s\n' "$broken" | sed 's/^/ /' warn "continuing with what the distribution archive has" fi rm -f /tmp/wui-apt.err ;; rhel) : ;; # dnf refreshes per transaction esac } # Remove an AmneziaWG PPA that this installer added and that does not publish # for this release. # # Left in place it breaks every `apt-get update` on the machine from then on, # including this installer's own -- which is exactly how a second run ends at # the first step. Only the file this installer writes is touched. repair_amnezia_ppa() { [[ "$FAMILY" == debian ]] || return 0 local f found=0 for f in /etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.list \ /etc/apt/sources.list.d/amnezia-ubuntu-ppa-*.sources; do [[ -e "$f" ]] || continue if ! ppa_publishes_for_this_release; then rm -f "$f" found=1 fi done if [[ "$found" == 1 ]]; then warn "removed the AmneziaWG repository: it publishes nothing for ${OS_CODENAME:-this release}" warn "that repository was breaking apt; standard WireGuard is unaffected" DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 || true fi } # Does the AmneziaWG PPA actually have packages for this release? # # `add-apt-repository` succeeds whether or not it does -- it only writes a file # -- so asking first is the difference between skipping a feature and leaving # the machine with an apt that no longer works. ppa_publishes_for_this_release() { [[ -n "${OS_CODENAME:-}" ]] || return 1 # Without curl there is no way to ask, and "cannot ask" must not be read as # "no" -- that would have the repair below remove a repository that is # working perfectly well. This runs before curl is installed. command -v curl >/dev/null 2>&1 || return 0 curl -fsSI --max-time 10 \ "https://ppa.launchpadcontent.net/amnezia/ppa/ubuntu/dists/${OS_CODENAME}/Release" \ >/dev/null 2>&1 } pkg_install() { case "$FAMILY" in debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq "$@" >/dev/null ;; # --allowerasing: RHEL 9 images ship curl-minimal, which conflicts with # the curl package and otherwise stops the whole install at step one. rhel) dnf install -y -q --allowerasing "$@" >/dev/null ;; esac } have() { command -v "$1" >/dev/null 2>&1; } install_base() { step "Installing base packages" # Before anything else: if an earlier run left a repository behind that this # release has no packages in, apt is broken and nothing below would work. repair_amnezia_ppa repair_dpkg pkg_refresh local base case "$FAMILY" in debian) base=(curl ca-certificates gnupg tar iproute2 nftables iptables procps) ;; rhel) base=(curl ca-certificates gnupg2 tar iproute nftables iptables procps-ng) ;; esac pkg_install "${base[@]}" || die "could not install base packages" ok "base tools installed" # qrencode draws QR codes in the terminal for the menu; the panel draws # its own. On RHEL it lives in EPEL, which a fresh machine may not have, # so its absence is a note rather than a failed install. if ! pkg_install qrencode; then if [[ "$FAMILY" == rhel ]] && pkg_install epel-release && pkg_install qrencode; then ok "qrencode installed from EPEL" else warn "qrencode is not available here; QR codes are still made by the panel itself" fi fi } install_wireguard() { step "Installing WireGuard" case "$FAMILY" in debian) pkg_install wireguard wireguard-tools ;; rhel) pkg_install wireguard-tools ;; esac have wg || die "wireguard-tools did not install; the panel cannot serve WireGuard without it" ok "wg $(wg --version 2>/dev/null | awk '{print $2}')" # The module is built into the kernel from 5.6 onward. Loading it now surfaces # a container or a stripped kernel here, rather than at the first customer. if modprobe wireguard 2>/dev/null; then ok "wireguard kernel module loaded" else warn "could not load the wireguard module (kernel $KERNEL)" warn "on a VPS this usually means the provider ships a kernel without it" fi } # AmneziaWG without a kernel module. # # The obfuscation lives in a fork of the WireGuard kernel module, and that fork # is packaged only for the distributions its project has caught up with. On a # release that is newer than those packages there is no module and no way to # build one, and the panel would be left offering plain WireGuard on exactly the # networks where plain WireGuard is what gets blocked. # # amneziawg-go speaks the same protocol over a TUN device in userspace. It costs # some throughput and nothing else: no headers, no DKMS, and no tunnel that dies # the next time the machine takes a kernel upgrade and reboots. AMNEZIA_TOOLS_TAG="v3.1.20260812" # The daemon from the same release as the tools: a version both ends of a # profile were made for, rather than whatever the branch holds that day. AMNEZIA_GO_TAG="v3.1.20260812" GO_BOOTSTRAP_VERSION="1.25.1" install_amnezia_userspace() { warn "falling back to userspace AmneziaWG (amneziawg-go)" local tmp tmp="$(mktemp -d)" || { AMNEZIA_OK=0; return 0; } if ! amnezia_build_tools "$tmp" || ! amnezia_build_go "$tmp"; then rm -rf "$tmp" AMNEZIA_OK=0 warn "AmneziaWG is unavailable; the panel will still serve standard WireGuard" warn " obfuscated tunnels will refuse to start and say why" return 0 fi rm -rf "$tmp" AMNEZIA_OK=1 ok "awg $(awg --version 2>/dev/null | awk '{print $2}') (userspace)" ok "amneziawg-go installed; obfuscated tunnels run without a kernel module" } # amnezia_build_tools builds awg and awg-quick, the userspace half that is # needed whichever way the device itself is provided. amnezia_build_tools() { local tmp="$1" have awg && return 0 case "$FAMILY" in debian) pkg_install git make gcc libc6-dev || return 1 ;; rhel) pkg_install git make gcc glibc-devel || return 1 ;; esac git clone --depth 1 -b "$AMNEZIA_TOOLS_TAG" \ https://github.com/amnezia-vpn/amneziawg-tools.git "$tmp/tools" >/dev/null 2>&1 || { warn "could not fetch amneziawg-tools" return 1 } make -C "$tmp/tools/src" -j"$(nproc 2>/dev/null || echo 2)" >/dev/null 2>&1 || { warn "amneziawg-tools did not build here" return 1 } make -C "$tmp/tools/src" install >/dev/null 2>&1 || return 1 have awg } # amnezia_build_go builds the daemon that provides the device. # # The project publishes no binaries, so this needs a Go toolchain. The one in # the distribution is used when it is new enough, and otherwise an official # toolchain is unpacked into a temporary directory and thrown away afterwards # rather than installed onto the machine. amnezia_build_go() { local tmp="$1" have amneziawg-go && return 0 local go="" if have go && go_is_recent_enough; then go="$(command -v go)" else go="$(amnezia_fetch_go "$tmp")" || { warn "no Go toolchain to build amneziawg-go with" return 1 } fi # Three tries: GitHub drops a connection now and then from some # countries, and one dropped clone is no reason to go without it. local try for try in 1 2 3; do rm -rf "$tmp/go-src" git clone --depth 1 -b "$AMNEZIA_GO_TAG" https://github.com/amnezia-vpn/amneziawg-go.git \ "$tmp/go-src" >"$tmp/clone.log" 2>&1 && break if [[ "$try" == 3 ]]; then warn "could not fetch amneziawg-go: $(tail -1 "$tmp/clone.log" 2>/dev/null)" return 1 fi sleep 5 done ( cd "$tmp/go-src" \ && GOFLAGS=-mod=mod GOPATH="$tmp/gopath" GOCACHE="$tmp/gocache" CGO_ENABLED=0 \ "$go" build -trimpath -ldflags "-s -w" -o "$tmp/amneziawg-go" . ) >/dev/null 2>&1 || { warn "amneziawg-go did not build here" return 1 } install -m755 "$tmp/amneziawg-go" /usr/local/bin/amneziawg-go || return 1 have amneziawg-go } # go_is_recent_enough compares the installed toolchain against what the source # asks for. A Go too old fails deep inside the build with a message about a # language feature, which is a bad way to find out. go_is_recent_enough() { local v v="$(go env GOVERSION 2>/dev/null | sed 's/^go//')" || return 1 [[ -n "$v" ]] || return 1 local major minor major="${v%%.*}" minor="${v#*.}" minor="${minor%%.*}" (( major > 1 )) || (( minor >= 25 )) } # amnezia_fetch_go unpacks an official toolchain into tmp and prints its path. # # From go.dev first, then from mirrors of it: Google's servers answer 404 to # some countries -- Iran's among them -- where a mirror still serves the same # file. Whichever it came from, the archive is checked against the checksum # go.dev publishes for it, so a mirror can only hand over the real toolchain # or nothing. amnezia_fetch_go() { local tmp="$1" arch sum case "$(uname -m)" in x86_64) arch=amd64 ;; aarch64) arch=arm64 ;; armv7l) arch=armv6l ;; *) return 1 ;; esac case "$GO_BOOTSTRAP_VERSION/$arch" in 1.25.1/amd64) sum=7716a0d940a0f6ae8e1f3b3f4f36299dc53e31b16840dbd171254312c41ca12e ;; 1.25.1/arm64) sum=65a3e34fb2126f55b34e1edfc709121660e1be2dee6bdf405fc399a63a95a87d ;; 1.25.1/armv6l) sum=eb949be683e82a99e9861dafd7057e31ea40b161eae6c4cd18fdc0e8c4ae6225 ;; *) return 1 ;; esac local file="go${GO_BOOTSTRAP_VERSION}.linux-${arch}.tar.gz" url got=0 for url in "https://go.dev/dl/$file" \ "https://mirrors.aliyun.com/golang/$file" \ "https://mirror.nju.edu.cn/golang/$file"; do rm -f "$tmp/go.tgz" curl -fsSL --max-time 300 "$url" -o "$tmp/go.tgz" 2>/dev/null || continue if [[ "$(sha256sum "$tmp/go.tgz" | cut -d' ' -f1)" == "$sum" ]]; then got=1 break fi done [[ "$got" == 1 ]] || return 1 tar -xzf "$tmp/go.tgz" -C "$tmp" 2>/dev/null || return 1 [[ -x "$tmp/go/bin/go" ]] || return 1 printf '%s' "$tmp/go/bin/go" } install_amnezia() { step "Installing AmneziaWG (anti-DPI)" # Building a kernel module needs headers matching the running kernel. Missing # them is the usual reason DKMS fails, so it is checked before the attempt. case "$FAMILY" in debian) if ! pkg_install "linux-headers-$KERNEL"; then warn "no headers for kernel $KERNEL; the AmneziaWG module cannot be built" install_amnezia_userspace return 0 fi # Asked before it is added. A PPA that publishes nothing for this # release still installs cleanly as a sources file and then breaks every # apt operation afterwards, including this installer's own on the next # run. Ubuntu releases routinely ship before their PPAs catch up. if ! ppa_publishes_for_this_release; then warn "AmneziaWG has no packages for ${OS_CODENAME:-this release} yet" install_amnezia_userspace return 0 fi pkg_install software-properties-common if ! add-apt-repository -y ppa:amnezia/ppa >/dev/null 2>&1; then warn "could not add ppa:amnezia/ppa" install_amnezia_userspace return 0 fi pkg_refresh keep_dkms_from_breaking_apt if ! pkg_install amneziawg; then warn "amneziawg failed to build against kernel $KERNEL" drop_failed_amnezia_package install_amnezia_userspace return 0 fi ;; rhel) pkg_install "kernel-devel-$KERNEL" dnf-plugins-core || true dnf copr enable -y amneziavpn/amneziawg >/dev/null 2>&1 || { warn "could not enable the AmneziaWG COPR" install_amnezia_userspace return 0 } pkg_install amneziawg-dkms amneziawg-tools || { warn "amneziawg failed to build against kernel $KERNEL" install_amnezia_userspace return 0 } ;; esac if have awg; then AMNEZIA_OK=1 ok "awg $(awg --version 2>/dev/null | awk '{print $2}')" if [[ -r /sys/module/amneziawg/version ]] || { modprobe amneziawg 2>/dev/null && [[ -r /sys/module/amneziawg/version ]]; }; then ok "kernel module $(cat /sys/module/amneziawg/version)" amnezia_future_kernels else # The tools are here but nothing can make the device. Rather than leave # that to be discovered when the first obfuscated tunnel refuses to start, # the userspace daemon is installed now. warn "the amneziawg module is not available on this kernel" install_amnezia_userspace return 0 fi else warn "awg not found after install" install_amnezia_userspace fi } # amnezia_future_kernels keeps the AmneziaWG module working across kernel # upgrades. The module is built by DKMS for each kernel, and DKMS can only # build it where that kernel's headers are installed: the headers of the # running kernel alone, which is all a VPS has by default, leave the next # kernel without a module and every obfuscated tunnel down after the reboot # that installs it. So: # # - the headers' meta-package follows the kernel's, so each new kernel # arrives with its headers and DKMS builds the module as it installs; # - wui-amnezia-kmod builds it at boot when that did not happen (a kernel # installed before this, or a headers package that came late); # - and amneziawg-go is there besides, so a kernel no module builds for # still carries the tunnels, in userspace, rather than none. amnezia_future_kernels() { local meta="" case "$FAMILY" in debian) # 6.8.0-45-generic -> generic, 6.1.0-25-cloud-amd64 -> cloud-amd64: # the flavour the kernel's own meta-package is named after. meta="linux-headers-${KERNEL#*-*-}" [[ "$meta" == "linux-headers-$KERNEL" ]] && meta="" ;; rhel) meta="kernel-devel" ;; esac if [[ -n "$meta" ]]; then if pkg_install "$meta"; then ok "$meta: each new kernel gets its headers, and the module is rebuilt for it" else warn "no $meta package; the module is rebuilt at boot for a new kernel instead" fi fi write_amnezia_kmod if ! have amneziawg-go; then local tmp tmp="$(mktemp -d)" || return 0 if amnezia_build_go "$tmp"; then ok "amneziawg-go installed besides, for a kernel the module does not build on" else warn "amneziawg-go did not build; a kernel the module cannot build on would leave obfuscated tunnels down" fi rm -rf "$tmp" fi } # write_amnezia_kmod installs the boot-time check: before the panel starts, # the module is loaded, or built for the running kernel and then loaded. It # never fails the boot -- the panel falls back to amneziawg-go on its own -- # and it leaves the reason in /var/log/wui-amnezia-kmod.log. write_amnezia_kmod() { have_systemd || return 0 install -d -m 0755 /usr/local/libexec cat >/usr/local/libexec/wui-amnezia-kmod <<'KMOD' #!/usr/bin/env bash # W-UI: the AmneziaWG module for the running kernel, built if it has to be. k="$(uname -r)" log=/var/log/wui-amnezia-kmod.log modprobe amneziawg 2>/dev/null && exit 0 command -v dkms >/dev/null 2>&1 || exit 0 { echo "== $(date -Is) kernel $k: no amneziawg module; building it" if [[ ! -d "/lib/modules/$k/build" ]]; then if command -v apt-get >/dev/null 2>&1; then DEBIAN_FRONTEND=noninteractive timeout 300 apt-get install -y -qq "linux-headers-$k" elif command -v dnf >/dev/null 2>&1; then timeout 300 dnf install -y -q "kernel-devel-$k" fi fi timeout 600 dkms autoinstall -k "$k" depmod -a "$k" if modprobe amneziawg; then echo "module loaded" else echo "still no module; obfuscated tunnels run on amneziawg-go" fi } >>"$log" 2>&1 exit 0 KMOD chmod 0755 /usr/local/libexec/wui-amnezia-kmod cat >/etc/systemd/system/wui-amnezia-kmod.service </dev/null 2>&1 || true ok "wui-amnezia-kmod (rebuilds the module at boot after a kernel upgrade)" } install_openvpn() { step "Installing OpenVPN" pkg_install openvpn easy-rsa || { warn "openvpn failed to install; the panel will only offer WireGuard" OPENVPN_OK=0 return 0 } # OpenVPN needs a tun device to exist before it will start. It is present on # almost every host and absent on almost every minimal container, and the # failure without it is a server that exits at boot with a message about a # file rather than about networking. modprobe tun 2>/dev/null || true if [[ -c /dev/net/tun ]]; then ok "/dev/net/tun present" else warn "/dev/net/tun is missing; OpenVPN interfaces will not start" warn " load it with: modprobe tun (and add 'tun' to /etc/modules-load.d/)" fi # A minimal image may not have the directory at all. install -d /etc/modules-load.d 2>/dev/null || true echo tun > /etc/modules-load.d/wui-tun.conf 2>/dev/null || true if have openvpn; then OPENVPN_OK=1 ok "$(openvpn --version 2>/dev/null | head -1 | cut -d' ' -f1-2)" else OPENVPN_OK=0 warn "openvpn not on PATH after install" fi } # ── L2TP/IPsec ─────────────────────────────────────────────────────────────── # strongSwan, xl2tpd and pppd, installed and left off. # # Nothing of it runs until an L2TP tunnel is made in the panel: the agent # starts it then and stops it when the tunnel goes. What is already on the # machine is not touched. A package that would have to be removed for these # -- libreswan, for another VPN -- stops this step instead, and a service # that was enabled or running before is the operator's and stays as it was. L2TP_UNITS=(xl2tpd.service strongswan.service strongswan-starter.service strongswan-swanctl.service ipsec.service) l2tp_packages() { case "$FAMILY" in debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-remove \ strongswan-swanctl charon-systemd xl2tpd ppp >/dev/null 2>&1 ;; rhel) # From EPEL on the RHEL family; Fedora carries them itself. [[ "$OS_ID" == fedora ]] || dnf install -y -q epel-release >/dev/null 2>&1 || true # Without --allowerasing: nothing already here is removed for these. dnf install -y -q strongswan xl2tpd ppp >/dev/null 2>&1 ;; esac } # The proxy outbounds -- VLESS, VMess, Trojan, Shadowsocks, Hysteria, SOCKS, # HTTP -- are run by xray, joined to the routing by sing-box. Both are # installed here, at versions this release was tested with, from their own # GitHub releases (reachable where Google's servers are not), and each # download is checked against its published SHA-256 before it is installed. # A server that has them at these versions already is left as it is. XRAY_VERSION="v26.3.27" SINGBOX_VERSION="1.14.1" install_proxy_cores() { step "Installing xray and sing-box (for proxy outbounds)" local xa sa xsum ssum tmp case "$ARCH" in amd64) xa=Xray-linux-64.zip xsum=23cd9af937744d97776ee35ecad4972cf4b2109d1e0fe6be9930467608f7c8ae sa="sing-box-${SINGBOX_VERSION}-linux-amd64" ssum=12cb2816b52febb356f6a885b740cc8758c3f30b8ae0ca8edba80f0d2d35343f ;; arm64) xa=Xray-linux-arm64-v8a.zip xsum=4d30283ae614e3057f730f67cd088a42be6fdf91f8639d82cb69e48cde80413c sa="sing-box-${SINGBOX_VERSION}-linux-arm64" ssum=6060b42fa84c5dcaeae1799af7f61b0f1ae4855d9d5ddc9e02baba17154b3ae2 ;; *) warn "no xray or sing-box for $ARCH; proxy outbounds will not come up"; return 0 ;; esac tmp="$(mktemp -d)" || return 0 if have xray && xray version 2>/dev/null | head -1 | grep -q "Xray ${XRAY_VERSION#v} "; then ok "xray ${XRAY_VERSION} already here" elif fetch_checked "https://github.com/XTLS/Xray-core/releases/download/${XRAY_VERSION}/${xa}" "$xsum" "$tmp/xray.zip" && unpack_zip_file "$tmp/xray.zip" xray "$tmp/xray" && install -m 0755 "$tmp/xray" /usr/local/bin/xray; then ok "xray ${XRAY_VERSION}" else warn "xray could not be installed; proxy outbounds will not come up until it is" fi if have sing-box && sing-box version 2>/dev/null | head -1 | grep -q "version ${SINGBOX_VERSION}$"; then ok "sing-box ${SINGBOX_VERSION} already here" elif fetch_checked "https://github.com/SagerNet/sing-box/releases/download/v${SINGBOX_VERSION}/${sa}.tar.gz" "$ssum" "$tmp/sb.tgz" && tar -xzf "$tmp/sb.tgz" -C "$tmp" "${sa}/sing-box" && install -m 0755 "$tmp/${sa}/sing-box" /usr/local/bin/sing-box; then ok "sing-box ${SINGBOX_VERSION}" else warn "sing-box could not be installed; proxy outbounds will not come up until it is" fi rm -rf "$tmp" } # fetch_checked downloads a file and keeps it only if its SHA-256 is the one # given. Three tries: a dropped connection is no reason to go without it. fetch_checked() { local url="$1" sum="$2" out="$3" try for try in 1 2 3; do if curl -fsSL --max-time 300 "$url" -o "$out" 2>/dev/null && [[ "$(sha256sum "$out" | cut -d' ' -f1)" == "$sum" ]]; then return 0 fi rm -f "$out" [[ "$try" == 3 ]] || sleep 5 done return 1 } # unpack_zip_file takes one file out of a zip, with unzip where there is one # and python3's zipfile where there is not. unpack_zip_file() { local zip="$1" name="$2" out="$3" if have unzip; then unzip -p "$zip" "$name" > "$out" 2>/dev/null && [[ -s "$out" ]] && return 0 fi have python3 && python3 - "$zip" "$name" "$out" <<'PYZ' import sys, zipfile with zipfile.ZipFile(sys.argv[1]) as z, open(sys.argv[3], "wb") as f: f.write(z.read(sys.argv[2])) PYZ } # ── IKEv2, PPTP and OpenConnect ────────────────────────────────────────────── # Installed the way L2TP is, and left off the same way: the panel's agent # runs each from its own files while a tunnel of that kind exists. The same # packages the agent would install the first time one is made, so a tunnel # made later starts at once rather than after an install. # # install_parked LABEL UNITS DEBIAN-PACKAGES RHEL-PACKAGES BINARIES # # Each list is one space-separated word. What is already enabled or running # here is the operator's and is left as it was; what the packages bring # with them is stopped and disabled, so nothing holds a port the agent's own # will need. A package that cannot be installed is said, not fatal: the # panel tries again when the first tunnel of its kind is made. install_parked() { local label="$1" units="$2" deb="$3" rpm="$4" bins="$5" u b theirs=" " installed=1 step "Installing $label (off until a tunnel uses it)" if have_systemd; then for u in $units; do if systemctl is-enabled --quiet "$u" 2>/dev/null || systemctl is-active --quiet "$u" 2>/dev/null; then theirs="$theirs$u " fi done fi # shellcheck disable=SC2086 parked_packages() { case "$FAMILY" in debian) DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-remove $deb >/dev/null 2>&1 ;; rhel) [[ "$OS_ID" == fedora ]] || dnf install -y -q epel-release >/dev/null 2>&1 || true dnf install -y -q $rpm >/dev/null 2>&1 ;; *) return 1 ;; esac } if ! parked_packages; then if [[ "$FAMILY" == debian ]]; then pkg_refresh parked_packages || installed=0 else installed=0 fi fi if have_systemd; then for u in $units; do [[ "$theirs" == *" $u "* ]] && continue systemctl disable --now "$u" >/dev/null 2>&1 || true systemctl stop "$u" >/dev/null 2>&1 || true done fi for b in $bins; do have "$b" || installed=0; done if [[ "$installed" == 1 ]]; then ok "$label: installed, off until a tunnel uses it" else warn "$label could not be installed now; the panel installs it when the first such tunnel is made" fi [[ "$theirs" == " " ]] || info "already in use here, left as it was:$theirs" } install_ikev2() { install_parked "IKEv2" \ "strongswan.service strongswan-starter.service strongswan-swanctl.service ipsec.service" \ "strongswan-swanctl charon-systemd libcharon-extauth-plugins libcharon-extra-plugins libstrongswan-standard-plugins" \ "strongswan" \ "swanctl" } # The PPTP server is the panel's own; pppd is all it needs of the system. install_pptp() { install_parked "PPTP" "pptpd.service" "ppp" "ppp" "pppd" } # ocserv's package starts its own on 443 (Debian by socket activation); the # agent runs ocserv from its own files instead. netbase carries the "radius" # service name ocserv's sign-in looks up, which a minimal image lacks. install_openconnect() { install_parked "OpenConnect" "ocserv.socket ocserv.service" "ocserv netbase" "ocserv" "ocserv occtl" } install_l2tp() { step "Installing L2TP/IPsec (off until a tunnel uses it)" local u theirs=" " installed=1 if have_systemd; then for u in "${L2TP_UNITS[@]}"; do if systemctl is-enabled --quiet "$u" 2>/dev/null || systemctl is-active --quiet "$u" 2>/dev/null; then theirs="$theirs$u " fi done fi if ! l2tp_packages; then # A stale package list is the usual reason: one refresh, one more try. if [[ "$FAMILY" == debian ]]; then pkg_refresh l2tp_packages || installed=0 else installed=0 fi fi if have_systemd; then for u in "${L2TP_UNITS[@]}"; do [[ "$theirs" == *" $u "* ]] && continue # And stopped on its own: a service from an init script -- Ubuntu's # xl2tpd -- is disabled by --now but left running, on L2TP's port. systemctl disable --now "$u" >/dev/null 2>&1 || true systemctl stop "$u" >/dev/null 2>&1 || true done fi if [[ "$installed" == 1 ]] && have xl2tpd && have pppd && have swanctl; then ok "strongSwan, xl2tpd and pppd: installed, off until an L2TP tunnel is made" else warn "L2TP/IPsec could not be installed now; the panel installs it when the first L2TP tunnel is made" fi [[ "$theirs" == " " ]] || info "already in use here, left as it was:$theirs" l2tp_ports_note } # udp_owner prints what holds a UDP port, or nothing when it is free. udp_owner() { have ss || return 0 local line who line=$(ss -lunpH "sport = :$1" 2>/dev/null | head -1) [[ -n "$line" ]] || return 0 who=$(sed -n 's/.*users:(("\([^"]*\)".*/\1/p' <<<"$line") printf '%s' "${who:-another service}" } # Who already holds L2TP's ports, said now rather than found when the first # tunnel will not start. strongSwan is not in the way -- the tunnel is added # to the one already running -- and on an update the agent's own are not. l2tp_ports_note() { if have_systemd && systemctl is-active --quiet wui-l2tp.service; then return 0; fi local p who for p in 500 4500 1701; do who=$(udp_owner "$p") [[ -z "$who" || "$who" == charon* ]] && continue warn "UDP $p is held by $who; an L2TP tunnel will not start here until it is free" done } # The L2TP agent, the root half of an L2TP tunnel. # # The panel cannot run pppd -- it is unprivileged and sandboxed -- so it # describes the tunnel in $DATA_DIR/l2tp/state.json, and this path unit runs # the agent, as root, for as long as that description exists. With no L2TP # tunnel there is none, and nothing runs. A panel updated from its own button # writes the same units through the update helper; a test holds the two to # the same text. # The firewall helper: with ufw or firewalld on, a tunnel's port is dropped # there whatever the panel's own tables say, and the tunnel reaches nobody. # The panel writes the ports its tunnels need to $DATA_DIR/firewall/ports.json; # this runs, as root, on every change of it and lets them through -- and # closes only what it opened itself. A test holds these units to the panel's. write_firewall_helper() { have_systemd || return 0 cat >/etc/systemd/system/wui-firewall.service </etc/systemd/system/wui-firewall.path </dev/null 2>&1 || { warn "could not enable wui-firewall.path; open the tunnels' ports in the firewall yourself"; return 0; } systemctl enable wui-firewall.service >/dev/null 2>&1 || true systemctl start --no-block wui-firewall.service >/dev/null 2>&1 || true } write_l2tp_agent() { have_systemd || return 0 write_firewall_helper cat >/etc/systemd/system/wui-l2tp.service </etc/systemd/system/wui-l2tp.path </dev/null 2>&1 || { warn "could not enable wui-l2tp.path; L2TP tunnels will not start"; return 0; } # An agent already running is on the binary just replaced: restarted onto # the new one, its sessions staying up. systemctl try-restart wui-l2tp.service >/dev/null 2>&1 || true ok "wui-l2tp.path (L2TP/IPsec; idle until a tunnel uses it)" } # Takes an L2TP tunnel down and removes its agent: for an uninstall, and for # a move back to a release from before L2TP, which has no agent to carry it. # # The agent does the taking down itself, while the binary it is part of is # still here: with the tunnel's description gone it ends every session, stops # xl2tpd, unloads IPsec, takes its firewall table away and exits. The panel is # stopped before this, or it would describe the tunnel again. remove_l2tp_agent() { [[ -f /etc/systemd/system/wui-l2tp.service || -f "$DATA_DIR/l2tp/state.json" ]] || return 0 if have_systemd; then systemctl disable --now wui-l2tp.path >/dev/null 2>&1 || true if [[ -f "$DATA_DIR/l2tp/state.json" ]]; then rm -f "$DATA_DIR/l2tp/state.json" systemctl start wui-l2tp.service >/dev/null 2>&1 || true for _ in $(seq 1 30); do systemctl is-active --quiet wui-l2tp.service || break sleep 1 done fi systemctl stop wui-l2tp.service >/dev/null 2>&1 || true else rm -f "$DATA_DIR/l2tp/state.json" fi rm -f /etc/systemd/system/wui-l2tp.path /etc/systemd/system/wui-l2tp.service if have_systemd; then systemctl daemon-reload || true; fi ok "L2TP/IPsec taken down; its agent removed" } # release_before_l2tp reports whether a release is from before L2TP, which # came in 3.0. dev-latest, or anything not a version, is taken as the newest. release_before_l2tp() { local t="${1#v}" major major="${t%%.*}" [[ "$major" =~ ^[0-9]+$ ]] || return 1 (( major < 3 )) } # binary_before_3 reports whether the panel binary just installed is from # before 3.0, by asking it. Every release answers `version`; a build from # source answers "dev", which is the newest. # # What 3.0 adds beside the panel -- the firewall helper, the L2TP agent -- # runs that binary with a subcommand an older one does not have, and an # older one given a subcommand it does not know starts the whole panel # instead: a second panel, as root, on the same tables. So none of it is # written for an older binary: one installed with --local, or the release # this script fetched while it was newer than that release. binary_before_3() { local v v="$("$BIN_PATH" version 2>/dev/null | head -1)" [[ -n "$v" ]] || return 1 release_before_l2tp "$v" } # remove_firewall_helper takes 3.0's firewall helper away, for a release from # before it: its unit runs `wui firewall-sync`, which an older binary does # not know, and starts the whole panel instead. The ports it opened stay # open; the tunnels on them are still there. remove_firewall_helper() { [[ -f /etc/systemd/system/wui-firewall.service || -f /etc/systemd/system/wui-firewall.path ]] || return 0 if have_systemd; then systemctl disable --now wui-firewall.path >/dev/null 2>&1 || true systemctl disable --now wui-firewall.service >/dev/null 2>&1 || true fi rm -f /etc/systemd/system/wui-firewall.path /etc/systemd/system/wui-firewall.service if have_systemd; then systemctl daemon-reload || true; fi ok "the firewall helper removed; the release being installed has none" } enable_forwarding() { step "Enabling kernel forwarding" # Written to a file rather than only applied, or the setting is lost on the # first reboot and every tunnel stops routing with no obvious cause. install -d /etc/sysctl.d cat >/etc/sysctl.d/99-wui.conf <<'SYSCTL' # Installed by W-UI. Without forwarding, packets arriving on a tunnel are # never routed out to the internet and customers connect but reach nothing. net.ipv4.ip_forward = 1 net.ipv6.conf.all.forwarding = 1 SYSCTL # sysctl comes from procps, which a minimal image may lack even after # the base packages; /proc/sys is the same switch without it. if have sysctl; then sysctl --system >/dev/null 2>&1 || sysctl -p /etc/sysctl.d/99-wui.conf >/dev/null 2>&1 || true else echo 1 > /proc/sys/net/ipv4/ip_forward 2>/dev/null || true echo 1 > /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || true fi local v4 v6 v4=$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null || echo 0) v6=$(cat /proc/sys/net/ipv6/conf/all/forwarding 2>/dev/null || echo 0) [[ "$v4" == 1 ]] && ok "IPv4 forwarding on" || warn "IPv4 forwarding is still off" [[ "$v6" == 1 ]] && ok "IPv6 forwarding on" || warn "IPv6 forwarding is still off" } check_nftables() { step "Checking the enforcement engine" have nft || die "nft missing; quota enforcement cannot run without nftables" ok "nft $(nft --version 2>/dev/null | awk '{print $2}')" # Proving the ruleset is writable now beats discovering it when the first # customer's limit silently fails to apply. if nft list tables >/dev/null 2>&1; then ok "kernel ruleset is readable" else warn "cannot read the nftables ruleset; the panel needs CAP_NET_ADMIN" fi # Everything the panel promises about data limits rests on this one module. # Without it the kernel cannot stop a transfer at a byte boundary, so limits # fall back to polling and a customer on a fast link overshoots their quota by # whatever they can pull between two collections. That is a silent failure — # the panel still shows a limit, it just does not hold — so it is worth being # noisy about at install time rather than at the end of the month. modprobe nft_quota 2>/dev/null || true if nft add table inet wui_probe 2>/dev/null && nft add quota inet wui_probe q '{ over 1000 bytes }' 2>/dev/null; then ok "kernel supports quota objects; data limits are enforced exactly" else warn "this kernel has no nft_quota support" warn " data limits will be enforced by polling instead, which means a" warn " customer can overshoot their quota before the panel notices." warn " Hetzner's stock Ubuntu and Debian kernels do support it; custom," warn " minimal or container kernels often do not." fi nft delete table inet wui_probe 2>/dev/null || true # Speed limits need a classful scheduler. Without one tc accepts the command # and the kernel quietly ignores it, so the panel would show a limit that no # packet ever meets. modprobe sch_htb 2>/dev/null || true if ip link add wui-htbprobe type dummy 2>/dev/null; then if tc qdisc replace dev wui-htbprobe root handle 1: htb default ffff 2>/dev/null; then ok "kernel supports HTB; speed limits are enforced" else warn "this kernel has no HTB scheduler" warn " per-customer speed limits will be recorded but never applied." fi ip link del wui-htbprobe 2>/dev/null || true fi } # ── the panel ──────────────────────────────────────────────────────────────── # The download for this machine's architecture in the newest release, if the # project has published one. # # Asked over the API rather than guessed at a URL, so a release whose assets # are named differently still resolves, and a repository with no releases at # all answers cleanly instead of 404-ing on a made-up path. release_asset_url() { local api json api="https://api.github.com/repos/${REPO}/releases/latest" json="$(curl -fsSL --max-time 20 "$api" 2>/dev/null)" || return 1 [[ -n "$json" ]] || return 1 local urls urls="$(printf '%s' "$json" | grep -o '"browser_download_url": *"[^"]*"' | sed 's/.*"browser_download_url": *"//; s/"$//')" ASSET_URL="$(grep -E "linux[-_]${ARCH}$" <<<"$urls" | head -1)" SUMS_URL="$(grep -E '/SHA256SUMS$' <<<"$urls" | head -1)" [[ -n "$ASSET_URL" ]] } # The release carries a checksum list; a download that does not match it is # not installed. A truncated transfer or a tampered mirror would otherwise # become the panel. verify_download() { local file="$1" name="$2" [[ -n "${SUMS_URL:-}" ]] || { warn "the release publishes no SHA256SUMS; installing unverified"; return 0; } local sums want got sums="$(curl -fsSL --max-time 20 "$SUMS_URL" 2>/dev/null)" || { warn "could not fetch SHA256SUMS; installing unverified"; return 0; } want="$(awk -v n="$name" '$2 == n || $2 == "*" n {print $1}' <<<"$sums" | head -1)" [[ -n "$want" ]] || { warn "SHA256SUMS has no entry for $name; installing unverified"; return 0; } got="$(sha256sum "$file" | awk '{print $1}')" [[ "$got" == "$want" ]] || die "checksum mismatch for $name: the download is not the file that was released" ok "checksum verified" } # Build the panel from the project's own source, without needing the repository # to have been cloned first. # # `--from-source` used to require a checkout -- it looked for go.mod in the # working directory -- which the one-line install can never have. It fetches a # tarball now, and installs Go if the machine has none, because "install Go and # come back" is not an answer an installer should be giving. build_from_tarball() { local work work="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '$work'" RETURN if ! have go; then info "installing Go to build with" pkg_install golang-go || pkg_install golang || die "could not install Go; use --local with a prebuilt binary" fi have go || die "Go is still not on PATH after installing it" info "fetching source" curl -fsSL --max-time 120 \ "https://codeload.github.com/${REPO}/tar.gz/refs/heads/${BRANCH}" \ -o "$work/src.tgz" || die "could not fetch the source" tar -xzf "$work/src.tgz" -C "$work" || die "could not unpack the source" local root root="$(find "$work" -maxdepth 1 -type d -name 'w-ui-*' | head -1)" [[ -n "$root" && -f "$root/go.mod" ]] || die "the source archive does not look like this project" info "building (this takes a minute)…" ( cd "$root" && CGO_ENABLED=0 GOFLAGS=-mod=mod go build -ldflags "-s -w" -o "$BIN_PATH" ./cmd/wui ) \ || die "build failed" chmod 0755 "$BIN_PATH" ok "built from source" } # ── asking ─────────────────────────────────────────────────────────────────── # An installer that asks nothing has already decided everything, and some of # those decisions are wrong for every operator: the port collides with what is # already on the box, the credentials are a random string nobody chose, and the # sign-in form that carries an administrator's password runs over plain HTTP. # # Everything is asked once, up front, before a single package is fetched — so # the operator answers for a minute and can then walk away, rather than being # called back to a prompt after the long part has finished. # A terminal to talk to, whatever stdin happens to be. # # `curl … | bash` gives the shell the script itself on stdin, so a plain `read` # there swallows the next line of the script instead of the operator's answer. # /dev/tty is the terminal this process is attached to no matter what stdin was # redirected to, which is what lets the documented one-liner ask anything at # all. When there is no terminal — cloud-init, CI, a pipe from a file — every # question falls back to its flag, its environment variable, or its default. open_tty() { INTERACTIVE=0 [[ "$ASSUME_YES" == 1 ]] && return 0 if [[ -e /dev/tty ]] && exec 3<>/dev/tty 2>/dev/null; then INTERACTIVE=1 fi return 0 } # The terminal went away mid-question — Ctrl-D, a closed session, a pipe that # ran out. Every question that validates its answer sits in a loop, and a loop # that keeps re-asking a terminal which will never answer again spins forever # on a machine nobody is watching. It stops here instead. no_more_input() { printf %s "$N" >&3 2>/dev/null || true die "no answer — the terminal closed before the questions were finished" } # Prompt text, written to the terminal rather than to stdout. # # Two reasons it is not a plain printf. An install whose output is being # captured to a file should still ask its questions on the screen, which is # what fd 3 is for; and a write that fails must not end the install. Under # errexit an unchecked `printf … >&3` onto a descriptor that is not writable # aborts the whole run with nothing but "Bad file descriptor" to show for it. tty_out() { printf "$@" >&3 2>/dev/null || true; } # ask VAR "question" "default" ask() { local __var="$1" __q="$2" __def="$3" __ans="" if [[ "$INTERACTIVE" == 1 ]]; then if [[ -n "$__def" ]]; then tty_out ' %s%s▸%s %s%s%s%s %s[%s]%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N" "$G" "$__def" "$N" else tty_out ' %s%s▸%s %s%s%s%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N" fi IFS= read -r __ans <&3 || no_more_input fi # Trimmed, because a pasted answer usually arrives with a space attached. __ans="${__ans#"${__ans%%[![:space:]]*}"}" __ans="${__ans%"${__ans##*[![:space:]]}"}" [[ -n "$__ans" ]] || __ans="$__def" printf -v "$__var" '%s' "$__ans" } # ask_secret VAR "question" — typed blind, confirmed, never echoed. ask_secret() { local __var="$1" __q="$2" a="" b="" if [[ "$INTERACTIVE" != 1 ]]; then printf -v "$__var" '%s' ""; return 0; fi while true; do tty_out ' %s%s▸%s %s%s%s%s: ' "$B" "$C" "$N" "$B" "$Y" "$__q" "$N" IFS= read -rs a <&3 || no_more_input tty_out '\n' if [[ -z "$a" ]]; then printf -v "$__var" '%s' ""; return 0; fi if (( ${#a} < 8 )); then tty_out ' %s%s!%s %sat least 8 characters%s\n' "$B" "$R" "$N" "$R" "$N" continue fi tty_out ' %sconfirm it%s: ' "$Y" "$N" IFS= read -rs b <&3 || no_more_input tty_out '\n' if [[ "$a" == "$b" ]]; then printf -v "$__var" '%s' "$a"; return 0; fi tty_out ' %s%s!%s %sthey do not match — try again%s\n' "$B" "$R" "$N" "$R" "$N" done } # ask_yn "question" default(y|n) — true when the answer is yes. ask_yn() { local q="$1" def="${2:-n}" ans="" hint="y/N" if [[ "$INTERACTIVE" != 1 ]]; then [[ "$def" == y ]]; return; fi [[ "$def" == y ]] && hint="Y/n" while true; do tty_out ' %s%s▸%s %s%s%s%s %s[%s]%s: ' "$B" "$C" "$N" "$B" "$Y" "$q" "$N" "$G" "$hint" "$N" IFS= read -r ans <&3 || no_more_input ans="${ans:-$def}" case "${ans,,}" in y|yes) return 0 ;; n|no) return 1 ;; *) printf ' %s%s!%s %sanswer y or n%s\n' "$B" "$R" "$N" "$R" "$N" >&3 ;; esac done } # Is something already listening on this TCP port? # # The panel must not land on a port another project is serving from. This can # run before iproute2 is guaranteed to be present, so: ss, then netstat, then # bash's own TCP connect — whichever the machine has. port_taken() { local p="$1" if have ss; then ss -ltnH "sport = :$p" 2>/dev/null | grep -q . && return 0 return 1 fi if have netstat; then netstat -ltn 2>/dev/null | grep -qE "[:.]${p}[[:space:]]" && return 0 return 1 fi # No tool for it: ask the port itself. An answer means somebody is home. if (exec 4<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then exec 4>&- return 0 fi return 1 } # Who is on that port, in words, so the operator recognises their own service # rather than being told only that the port is unavailable. port_owner() { local p="$1" who="" if have ss; then who=$(ss -ltnpH "sport = :$p" 2>/dev/null | sed -n 's/.*users:((\"\([^\"]*\)\".*/\1/p' | head -1) fi if [[ -n "$who" ]]; then printf '%s' "$who"; else printf 'another service'; fi } # What this machine is already running, so an upgrade does not move the panel. # # A re-run that handed out a fresh random port and a fresh random path would # leave every bookmark, every firewall rule and every note the operator wrote # down pointing at nothing. read_existing() { [[ -f "$UNIT" ]] || return 0 local v v=$(sed -n 's/^Environment=WUI_LISTEN=.*:\([0-9]\{1,5\}\)$/\1/p' "$UNIT" | head -1) if [[ -n "$v" && "$PORT_KNOWN" == 0 ]]; then PANEL_PORT="$v"; PORT_KNOWN=1; fi v=$(sed -n 's|^Environment=WUI_BASE_PATH=/\{0,1\}\(.*\)|\1|p' "$UNIT" | head -1) v="${v%/}" if [[ -n "$v" && "$BASE_KNOWN" == 0 ]]; then BASE_PATH="$v"; BASE_KNOWN=1; fi # The certificate it already serves, and the address it binds. An upgrade # that forgot either would put a panel back on plain HTTP, or on every # interface, without anyone having asked. local c k c=$(sed -n 's/^Environment=WUI_TLS_CERT=//p' "$UNIT" | head -1) k=$(sed -n 's/^Environment=WUI_TLS_KEY=//p' "$UNIT" | head -1) # Or in wui.env, which the unit reads too: a certificate kept there went on # being served after an update, while the update said there was none. if [[ -z "$c" && -r "$CONF_DIR/wui.env" ]]; then c=$(sed -n 's/^WUI_TLS_CERT=//p' "$CONF_DIR/wui.env" | head -1) k=$(sed -n 's/^WUI_TLS_KEY=//p' "$CONF_DIR/wui.env" | head -1) c="${c//\"/}"; k="${k//\"/}" fi if [[ -z "$TLS_MODE" && -z "$TLS_CERT" && -z "$ACME_DOMAIN" && -z "$ACME_IP" && -s "$c" && -s "$k" ]]; then TLS_MODE=files; TLS_CERT="$c"; TLS_KEY="$k"; TLS_KEPT=1 fi v=$(sed -n 's/^Environment=WUI_LISTEN=//p' "$UNIT" | head -1) v="${v%:*}" [[ -n "$v" && "$LISTEN_ADDR" == 0.0.0.0 ]] && LISTEN_ADDR="$v" # The engine it runs on. An upgrade that switched a PostgreSQL panel back # to SQLite would start it on an empty database. if [[ "$DB_KNOWN" == 0 && -r "$CONF_DIR/db.env" ]]; then v=$(sed -n 's/^WUI_DB_DRIVER=//p' "$CONF_DIR/db.env" | head -1) [[ -n "$v" ]] && { DB_DRIVER="$v"; DB_KNOWN=1; } v=$(sed -n 's/^WUI_DB_SOURCE=//p' "$CONF_DIR/db.env" | head -1) [[ -n "$v" ]] && DB_SOURCE="$v" fi if [[ "$DB_KNOWN" == 0 && -f "$DATA_DIR/wui.db" ]]; then DB_DRIVER=sqlite; DB_KNOWN=1 fi # And the subscription port the links already carry. if [[ "$SUB_KNOWN" == 0 && -r "$CONF_DIR/install-result.env" ]]; then v=$(sed -n 's/^WUI_SUB_PORT=//p' "$CONF_DIR/install-result.env" | head -1 | tr -d "'\"") [[ "$v" =~ ^[0-9]+$ ]] && { SUB_PORT="$v"; SUB_KNOWN=1; SUB_EXISTING=1; } fi } # ask_port VAR "question" [port it may not be] -- a free port the operator # types, or a random free one on enter. Whatever is listening on a port typed # here belongs to somebody, so it is never offered as something to take over. ask_port() { local __var="$1" __q="$2" __not="${3:-}" __p while true; do ask __p "$__q" "" if [[ -z "$__p" ]]; then if [[ -n "$__not" ]]; then __p="$(pick_sub_port)"; else __p="$(random_free_port)"; fi info "random port: $__p" break fi if [[ ! "$__p" =~ ^[0-9]+$ ]] || (( __p < 1 || __p > 65535 )); then warn "a port is a number from 1 to 65535" continue fi if [[ -n "$__not" && "$__p" == "$__not" ]]; then warn "port $__p is the panel's own — pick another" continue fi if port_taken "$__p"; then warn "port $__p is already served by $(port_owner "$__p") — pick another" continue fi break done printf -v "$__var" '%s' "$__p" } # A port for the subscription service that is free and is not the panel's. pick_sub_port() { local p tries=0 while (( tries++ < 20 )); do p="$(random_free_port)" [[ "$p" != "$PANEL_PORT" ]] && { printf '%s' "$p"; return 0; } done printf '%s' $(( PANEL_PORT % 60000 + 1025 )) } # The environment a wui subcommand needs to open the same database the # service does, as one string of assignments for env(1). panel_env() { printf 'WUI_DATA_DIR=%s WUI_DB_DRIVER=%s WUI_DB_SOURCE=%s' "$DATA_DIR" "$DB_DRIVER" "$DB_SOURCE" } # Whether this machine already has a panel's data, whichever engine holds it. existing_data() { [[ -f "$DATA_DIR/wui.db" ]] && return 0 [[ "$DB_DRIVER" == postgres && "$DB_KNOWN" == 1 ]] && return 0 return 1 } # Say plainly what a re-run does, because the wrong idea about it is the one # that causes real damage. # # A panel on this machine is upgraded in place: same port, same path, same data. # The installer cannot produce a second panel and should not — two on one # machine overwrite each other's firewall rules with neither noticing, and the # panel refuses to start rather than let that happen. # # The mistake worth heading off is "I will add a node to this server". A node is # another panel; it belongs on another machine. announce_existing() { [[ -f "$UNIT" ]] || return 0 local state="installed" if have_systemd && systemctl is-active --quiet wui.service 2>/dev/null; then state="installed and running" fi tty_out '\n%s%sA panel is already %s on this machine.%s\n' "$B" "$Y" "$state" "$N" tty_out '%sThis will upgrade it, keeping its port, its path and its data.%s\n' "$D" "$N" tty_out '%sIt cannot add a second panel: two on one machine overwrite each%s\n' "$D" "$N" tty_out "%sother's firewall rules, so the second one refuses to start. A node%s\n" "$D" "$N" tty_out '%sis another panel and belongs on another machine.%s\n\n' "$D" "$N" } # The certificate question, on its own so the update path can ask it too # when it finds a panel with none. ask_tls() { # ── how it is reached ───────────────────────────────────────────────────── # # The same four choices the classic panel's installer offers, in the same order, with # the same default: a certificate for the address itself. Whatever is # chosen, the panel answers on the one port above -- by name and by # address alike -- so a link written down today works tomorrow. tty_out '\n' qhead "SSL Certificate Setup (RECOMMENDED)" info "SSL is strongly recommended. Skip only if a reverse proxy" info "or SSH tunnel handles TLS for you." info "Let's Encrypt supports both domains and IP addresses." tty_out '\n' opt 1 "Let's Encrypt for Domain (90-day validity, auto-renews)" opt 2 "Let's Encrypt for IP Address (6-day validity, auto-renews)" opt 3 "Custom SSL Certificate (path to existing files)" opt 4 "Skip SSL (advanced — behind reverse proxy / SSH tunnel only)" note "Note: options 1 & 2 need port 80 reachable from the internet." note "Note: option 4 serves the panel over plain HTTP." tty_out '\n' local choice while true; do ask choice "Choose" "2" case "$choice" in 1|2|3|4) break ;; *) warn "answer 1, 2, 3 or 4" ;; esac done case "$choice" in 1) TLS_MODE=acme # Blank is a way out, not a mistake to be corrected. An operator who # picked this option and then realised the DNS is not ready yet should # be able to finish the install, not be held at a question whose only # valid answer they do not have. while true; do ask ACME_DOMAIN "Domain pointing at this server (blank: no certificate)" "${ACME_DOMAIN:-}" if [[ -z "$ACME_DOMAIN" ]]; then warn "no domain — the panel will serve plain HTTP" TLS_MODE=none break elif [[ ! "$ACME_DOMAIN" =~ ^[A-Za-z0-9._-]+\.[A-Za-z]{2,}$ ]]; then warn "that does not look like a domain name" else break fi done [[ "$TLS_MODE" == acme ]] && ask ACME_EMAIL "Email for expiry notices (optional)" "${ACME_EMAIL:-}" ;; 2) TLS_MODE=ip local mine; mine="$(public_ip)" if [[ -n "$mine" ]]; then if ask_yn "Is $mine the correct incoming public IPv4 address for this server?" y; then ACME_IP="$mine" fi else warn "could not auto-detect this server's public address" fi while [[ -z "$ACME_IP" ]]; do ask ACME_IP "This server's public IPv4 address" "" [[ "$ACME_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || { warn "that is not an IPv4 address"; ACME_IP=""; } done ask ACME_IPV6 "IPv6 address to include (blank to skip)" "${ACME_IPV6:-}" ;; 3) TLS_MODE=files ask TLS_DOMAIN "Domain the certificate was issued for (blank: use the address)" "${TLS_DOMAIN:-}" while true; do ask TLS_CERT "Path to the certificate (fullchain .crt or .pem)" "${TLS_CERT:-}" [[ -s "$TLS_CERT" ]] && break warn "no readable file at that path" done while true; do ask TLS_KEY "Path to the private key" "${TLS_KEY:-}" [[ -s "$TLS_KEY" ]] && break warn "no readable file at that path" done ;; 4) TLS_MODE=none tty_out '\n' warn "the panel will be installed WITHOUT SSL/TLS" warn "login credentials and cookies travel as plain HTTP; only safe when" warn " a reverse proxy (nginx, Caddy) terminates TLS for you, or" warn " you reach the panel exclusively via an SSH tunnel" ;; esac # A panel on a public address over plain HTTP puts an administrator's # password on the wire. Bound to the loopback it is reachable only through # an SSH tunnel or a proxy on this machine, which is the one way serving # plain HTTP is defensible at all. if [[ "$TLS_MODE" == none && "$LISTEN_ADDR" != 127.0.0.1 ]]; then tty_out '\n' if ask_yn "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access)" n; then LISTEN_ADDR=127.0.0.1 fi fi } # ── where the data lives ───────────────────────────────────────────────────── # # SQLite is one file, needs nothing installed, and carries a few thousand # customers without noticing. PostgreSQL is for the install that outgrows that. # Both are backed up in the same archive, and a backup from either restores # into the other. Asked the same way by both kinds of install. ask_database() { if [[ "$DB_KNOWN" == 1 ]]; then info "keeping the database this install already uses: $DB_DRIVER" return 0 fi tty_out '\n' qhead "Database" opt 1 "SQLite — one file, nothing to install; fine up to a few thousand customers (default)" opt 2 "PostgreSQL — for a large number of customers; installed and configured here" local dbc while true; do ask dbc "Choose" "1" case "$dbc" in 1) DB_DRIVER=sqlite; break ;; 2) DB_DRIVER=postgres; break ;; *) warn "answer 1 or 2" ;; esac done info "database: $DB_DRIVER" } # ── manual or automatic ────────────────────────────────────────────────────── # # The first question of a fresh install. Manual is every question the # installer has always asked, in the same order. Automatic is for someone who # does not want to decide what a port or a URL path should be: it asks only # what cannot be guessed -- the database, a domain, a certificate -- and # chooses the rest the way pressing enter through the manual questions would. choose_install_mode() { case "$INSTALL_MODE" in manual|auto) info "install mode: $INSTALL_MODE"; return 0 ;; "") ;; *) die "the install mode is manual or auto, not $INSTALL_MODE" ;; esac tty_out '\n' qhead "How do you want to install?" opt 1 "Manual — you choose the port, the URL path, the administrator, the database," info " the certificate and what is installed" opt 2 "Automatic — three questions (database, domain, certificate); everything else is" info " chosen and set up for you, and shown at the end (default)" local m while true; do ask m "Choose" "2" case "$m" in 1) INSTALL_MODE=manual; break ;; 2) INSTALL_MODE=auto; break ;; *) warn "answer 1 or 2" ;; esac done } # ── the automatic install ──────────────────────────────────────────────────── # # Three questions, and the rest decided as the manual install's defaults would # decide it: a random free port, a random URL path, a generated administrator # and password, a free subscription port, and every protocol. Nothing after # this asks anything: a certificate that cannot be issued -- port 80 served by # another project, a domain that does not point here yet -- is skipped rather # than asked about, and the closing summary says so beside everything needed to # sign in. configure_auto() { step "Automatic install" info "three questions; the port, the URL path, the administrator and the rest are chosen for you" info "and everything needed to sign in is shown at the end" ask_database # ── a domain, if there is one ──────────────────────────────────────────── if [[ -z "$TLS_MODE" ]]; then tty_out '\n' qhead "Domain" info " a domain whose DNS record points at this server gets a certificate for it" info " (Let's Encrypt, 90 days, renews itself). Leave it blank if you have none." local d here here="$(public_ip)" while true; do ask d "Domain (blank if you have none)" "${ACME_DOMAIN:-}" if [[ -z "$d" ]]; then ACME_DOMAIN="" break fi if [[ ! "$d" =~ ^[A-Za-z0-9._-]+\.[A-Za-z]{2,}$ ]]; then warn "that does not look like a domain name" continue fi if domain_points_here "$d"; then ACME_DOMAIN="$d"; TLS_MODE=acme break fi # Said now, while it can still be put right, rather than as a failed # certificate at the end of the install. warn "$d does not point at this server${here:+ ($here)} yet" warn " the certificate authority has to reach it here; add an A record, or leave it blank" if ask_yn "Use it anyway? (the certificate is tried, and skipped if it fails)" n; then ACME_DOMAIN="$d"; TLS_MODE=acme break fi done fi # ── otherwise, a certificate for the address itself ────────────────────── if [[ -z "$TLS_MODE" ]]; then tty_out '\n' if ask_yn "Get a free SSL certificate for this server's IP address? (recommended; 6 days, renews itself)" y; then ACME_IP="${ACME_IP:-$(public_ip)}" if [[ "$ACME_IP" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then TLS_MODE=ip else warn "could not find this server's public address; the panel will serve plain HTTP" warn " get a certificate later from the menu: w-ui → 20" ACME_IP=""; TLS_MODE=none fi else TLS_MODE=none warn "no certificate — the panel serves plain HTTP until you get one: w-ui → 20" fi fi # Everything else, as the manual defaults would have it. configure_defaults tty_out '\n' read_back } # What is about to be installed, read back before it is. Both kinds of install # show it; only the manual one asks to go ahead. read_back() { info "Panel port $PANEL_PORT" info "Sub port $SUB_PORT" info "Database $DB_DRIVER" info "Administrator $ADMIN_USER" local shown_path="/" [[ -n "$BASE_PATH" ]] && shown_path="/$BASE_PATH/" case "$TLS_MODE" in acme) info "Address https://$ACME_DOMAIN:$PANEL_PORT$shown_path (certificate from Let's Encrypt)" ;; ip) info "Address https://$ACME_IP:$PANEL_PORT$shown_path (6-day certificate from Let's Encrypt, renews itself)" ;; files) info "Address https://${TLS_DOMAIN:-}:$PANEL_PORT$shown_path (your own certificate)" ;; none) info "Address http://$([[ "$LISTEN_ADDR" == 127.0.0.1 ]] && echo 127.0.0.1 || echo ''):$PANEL_PORT$shown_path (no certificate)" ;; esac local extras="WireGuard" [[ "$WANT_OPENVPN" == 1 ]] && extras="$extras, OpenVPN" [[ "$WANT_AMNEZIA" == 1 ]] && extras="$extras, AmneziaWG" [[ "$WANT_L2TP" == 1 ]] && extras="$extras, L2TP/IPsec" [[ "$WANT_IKEV2" == 1 ]] && extras="$extras, IKEv2" [[ "$WANT_PPTP" == 1 ]] && extras="$extras, PPTP" [[ "$WANT_OPENCONNECT" == 1 ]] && extras="$extras, OpenConnect" [[ "$WANT_PROXY" == 1 ]] && extras="$extras, xray and sing-box" info "Installing $extras (each off until a tunnel uses it)" tty_out '\n' } # existing_install reports whether a panel is already installed here, which # makes this run an update. The unit is what every release's install wrote, # so an install from v1.0.0 on is found by it, whatever its database. existing_install() { [[ -f "$UNIT" ]] } configure() { open_tty read_existing announce_existing # A panel is already here: this is an update, and an update asks nothing. # The port, the path, the certificate, the database, the administrator and # the packages are whatever they are; only the binary and the scripts move. if existing_install; then IS_UPDATE=1 step "Update" info "keeping the port, path, certificate, database and administrator this install has" configure_upgrade return 0 fi if [[ "$INTERACTIVE" != 1 ]]; then # Said, not silently assumed. An operator who piped this from a file and # expected questions should learn that here, not at the summary. step "Configuration" info "no terminal to ask on — using flags, environment and defaults" configure_defaults return 0 fi choose_install_mode if [[ "$INSTALL_MODE" == auto ]]; then configure_auto return 0 fi step "Setup" info "a few questions, then the install runs on its own" tty_out '\n' # ── the port ────────────────────────────────────────────────────────────── # # Random unless the operator wants otherwise. A panel on a port that is # written down in a README is a panel that gets found by anyone sweeping the # address space for one, and what they find is a form to guess passwords at. # The question offers no port of its own for the same reason: enter gives a # random one, never a number from the documentation. if [[ "$PORT_KNOWN" == 1 ]]; then info "keeping the port this install already uses: $PANEL_PORT" elif ask_yn "Choose the panel port yourself? (otherwise a random one is used)" n; then ask_port PANEL_PORT "Panel port (enter for a random one)" "" else PANEL_PORT="$(random_free_port)" info "panel port: $PANEL_PORT" fi # ── the subscription port ───────────────────────────────────────────────── # # Asked as the panel's is. The customers' links carry it, so an operator # with a port open for it at their provider, or a CDN in front, sets it # here rather than finding it in the settings later. if [[ "$SUB_KNOWN" == 1 ]]; then info "keeping the subscription port this install already uses: $SUB_PORT" elif ask_yn "Choose the subscription port yourself? (otherwise a random one is used)" n; then ask_port SUB_PORT "Subscription port (enter for a random one)" "$PANEL_PORT" else SUB_PORT="$(pick_sub_port)" info "subscription port: $SUB_PORT" fi # ── the path it answers on ──────────────────────────────────────────────── # # The other half of not being found. With a random prefix there is nothing at # the root of this address at all: every path outside it answers 404, # including the sign-in page. It is not a replacement for the password — it # is what stops the password being the only thing in the way. if [[ "$BASE_KNOWN" == 1 ]]; then info "keeping the path this install already uses: /$BASE_PATH/" elif ask_yn "Choose the panel's URL path yourself? (otherwise a random one is used)" n; then while true; do ask BASE_PATH "URL path (one segment, blank for none)" "" # Surrounding slashes are how people write a path and are simply # dropped. A slash in the middle is a different path from the one # typed, so it is refused rather than quietly flattened -- an # operator who is handed a path they did not type will write down # the one they did. BASE_PATH="${BASE_PATH#/}" BASE_PATH="${BASE_PATH%/}" if [[ -z "$BASE_PATH" ]]; then warn "no path — the panel answers at the root, where anyone can find it" break fi if [[ "$BASE_PATH" == api ]]; then warn "\"api\" is where the API already answers; pick another" continue fi if [[ ! "$BASE_PATH" =~ ^[A-Za-z0-9._~-]+$ ]]; then warn "one segment of letters, digits and - _ . ~ — no slashes or spaces" continue fi break done else BASE_PATH="$(gen_string 18)" info "panel path: /$BASE_PATH/" fi # ── who signs in ────────────────────────────────────────────────────────── # # Generated unless asked otherwise, name included. "admin" is half of every # credential-stuffing attempt ever made against a panel, and a name nobody # can guess costs nothing to keep in the same place as the password. if ask_yn "Choose the administrator name and password yourself? (otherwise both are generated)" n; then ask ADMIN_USER "Administrator username" "${ADMIN_USER:-admin}" ask_secret ADMIN_PASS "Administrator password (blank to generate one)" [[ -n "$ADMIN_PASS" ]] || info "a password will be generated and shown at the end" else [[ -n "$ADMIN_USER" ]] || ADMIN_USER="$(gen_string 10)" info "administrator: $ADMIN_USER" info "the password is generated and shown once, at the end" fi ask_database ask_tls ask_protocols # ── read it back ────────────────────────────────────────────────────────── tty_out '\n' read_back ask_yn "Start the install with these?" y || die "cancelled — nothing was changed" } # What is installed alongside WireGuard. One question for all of it -- the # answer most operators want -- and only on a no, one for each, so a panel # that sells two protocols is not made to carry the rest. A protocol left out # here is not lost: the panel installs it when the first tunnel of its kind # is made. A --no-... flag already given is kept either way. ask_protocols() { tty_out '\n' qhead "Protocols" info " WireGuard always; besides it OpenVPN, AmneziaWG, L2TP/IPsec, IKEv2, PPTP," info " OpenConnect, and xray with sing-box for the proxy outbounds. Each stays off" info " until a tunnel uses it." ask_yn "Install every protocol?" y && return 0 if ask_yn "OpenVPN?" y; then WANT_OPENVPN=1; else WANT_OPENVPN=0; fi if ask_yn "AmneziaWG (obfuscated WireGuard; builds a kernel module)?" y; then WANT_AMNEZIA=1; else WANT_AMNEZIA=0; fi if ask_yn "L2TP/IPsec?" y; then WANT_L2TP=1; else WANT_L2TP=0; fi if ask_yn "IKEv2?" y; then WANT_IKEV2=1; else WANT_IKEV2=0; fi if ask_yn "PPTP?" y; then WANT_PPTP=1; else WANT_PPTP=0; fi if ask_yn "OpenConnect (Cisco AnyConnect compatible)?" y; then WANT_OPENCONNECT=1; else WANT_OPENCONNECT=0; fi if ask_yn "xray and sing-box (proxy outbounds: VLESS, VMess, Trojan, Shadowsocks...)?" y; then WANT_PROXY=1; else WANT_PROXY=0; fi } # Everything as it is. What read_existing could not find is left at what it # was on this machine: the tunnels that are installed stay installed and # nothing new is added, and a panel with no certificate keeps serving plain # HTTP -- the update script is what offers one. configure_upgrade() { [[ -n "$TLS_MODE" ]] || TLS_MODE=none [[ -n "$DB_DRIVER" ]] || DB_DRIVER=sqlite [[ -n "$SUB_PORT" ]] || SUB_PORT="$(pick_sub_port)" if [[ "$WANT_OPENVPN" == 1 && ! -x "$(command -v openvpn 2>/dev/null)" ]]; then WANT_OPENVPN=0; fi if [[ "$WANT_AMNEZIA" == 1 && ! -x "$(command -v awg 2>/dev/null)" ]]; then WANT_AMNEZIA=0; fi if [[ "$WANT_IKEV2" == 1 ]] && ! have swanctl; then WANT_IKEV2=0; fi if [[ "$WANT_PPTP" == 1 ]] && ! have pppd; then WANT_PPTP=0; fi if [[ "$WANT_OPENCONNECT" == 1 ]] && ! have ocserv; then WANT_OPENCONNECT=0; fi } # The same decisions, made without a terminal. configure_defaults() { # The same decisions the questions would have reached, made without a # terminal: unguessable unless something was passed in. ADMIN_USER="${ADMIN_USER:-$(gen_string 10)}" [[ "$BASE_KNOWN" == 1 ]] || BASE_PATH="$(gen_string 18)" if [[ "$PORT_KNOWN" == 0 ]]; then PANEL_PORT="$(random_free_port)" elif port_taken "$PANEL_PORT" && [[ "$(port_owner "$PANEL_PORT")" != wui ]]; then # The panel itself holding the port is an upgrade, not a conflict. die "port $PANEL_PORT is already served by $(port_owner "$PANEL_PORT"); pass --port with a free one" fi [[ "$SUB_KNOWN" == 1 ]] || SUB_PORT="$(pick_sub_port)" [[ -n "$DB_DRIVER" ]] || DB_DRIVER=sqlite case "$DB_DRIVER" in sqlite|postgres) ;; *) die "--db must be sqlite or postgres, not $DB_DRIVER" ;; esac if [[ -n "$TLS_MODE" ]]; then : # --no-tls, or a mode already chosen elif [[ -n "$ACME_DOMAIN" ]]; then TLS_MODE=acme elif [[ -n "$ACME_IP" || "${WUI_SSL_MODE:-}" == ip ]]; then TLS_MODE=ip [[ -n "$ACME_IP" ]] || ACME_IP="$(public_ip)" [[ -n "$ACME_IP" ]] || { warn "no public address found; the panel will serve plain HTTP"; TLS_MODE=none; } elif [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then TLS_MODE=files else TLS_MODE=none fi } # ── certificates ───────────────────────────────────────────────────────────── CERT_DIR="$CONF_DIR/certs" # Run a command as the panel's own account. # # The panel reads its database and its private key as this user, so anything # this script creates on its behalf is created by it too — otherwise the first # thing the service does on boot is fail to open a root-owned file. as_service_user() { if have runuser; then runuser -u "$SERVICE_USER" -- "$@" elif have sudo; then sudo -u "$SERVICE_USER" -- "$@" else # su takes a command string, so the arguments have to be quoted back up. local q="" a for a in "$@"; do q+="$(printf '%q ' "$a")"; done su -s /bin/sh -c "$q" "$SERVICE_USER" fi } # n random alphanumeric characters. Alphanumeric so the result survives being # copied out of a terminal, typed into a phone, or pasted through a chat app # that helpfully reformats punctuation. gen_string() { local n="${1:-16}" out="" # Read a fixed number of bytes rather than letting head close the pipe. # /dev/urandom is a file, so head reading from it kills nothing, and the # length is checked instead of assumed: a password that came out short # because a tool was interrupted is a security bug, not a cosmetic one. while (( ${#out} < n )); do out+="$(head -c 256 /dev/urandom 2>/dev/null | base64 2>/dev/null | LC_ALL=C tr -dc 'A-Za-z0-9')" [[ -n "$out" ]] || die "cannot read randomness from /dev/urandom" done printf '%s' "${out:0:n}" } # A high port nothing else is listening on. # # Random rather than fixed: a well-known port is the first thing a scanner # tries. Checked for a listener before it is offered, because a collision here # would take down whatever was already there. random_free_port() { local p tries=0 while (( tries++ < 60 )); do if have shuf; then p=$(shuf -i 1024-62000 -n 1); else p=$(( (RANDOM << 15 | RANDOM) % 60977 + 1024 )); fi port_taken "$p" || { printf '%s' "$p"; return 0; } done # Sixty collisions in a row is not a busy machine, it is a broken check. # Falling back to the prompt's own default beats returning nothing. printf '%s' "$PANEL_PORT" } gen_password() { # 16 characters from a real random source, alphanumeric so it survives being # copied out of a terminal, typed into a phone, or pasted through a chat app # that helpfully reformats punctuation. gen_string 16 } # This server's address as the internet sees it, for checking that a domain # actually points here before asking a certificate authority to confirm it. public_ip() { local u ip for u in https://api4.ipify.org https://ipv4.icanhazip.com https://v4.api.ipinfo.io/ip \ https://ipv4.myexternalip.com/raw https://4.ident.me https://check-host.net/ip; do ip="$(curl -fsS -4 --max-time 3 "$u" 2>/dev/null | tr -d '[:space:]"')" if [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then printf '%s' "$ip"; return 0; fi done hostname -I 2>/dev/null | awk '{print $1}' } # Does the domain resolve to this machine? # # Not fatal when it does not: a domain behind a proxy, or one whose DNS has not # propagated yet, both look like this. But it is by far the most common reason # an ACME challenge fails, and finding out before the certificate authority # says no saves an operator a confusing five minutes. domain_points_here() { local d="$1" mine resolved="" mine="$(public_ip)" [[ -n "$mine" ]] || return 0 if have getent; then resolved="$(getent ahostsv4 "$d" 2>/dev/null | awk '{print $1}' | sort -u)" elif have dig; then resolved="$(dig +short A "$d" 2>/dev/null)" else return 0 fi [[ -n "$resolved" ]] || return 1 grep -qx "$mine" <<<"$resolved" } setup_tls() { case "$TLS_MODE" in none) step "Certificate" warn "none — the panel serves plain HTTP" warn "put it behind a reverse proxy, or reach it over an SSH tunnel" return 0 ;; files) step "Certificate" if [[ "${TLS_KEPT:-0}" == 1 ]]; then ok "keeping the certificate this install already serves: $TLS_CERT" return 0 fi use_existing_cert return 0 ;; ip) step "Certificate for $ACME_IP" issue_ip_certificate || { warn "continuing without a certificate — the panel will serve plain HTTP" warn "check that port 80 is open, then run: w-ui → 20 → 6" TLS_MODE=none TLS_CERT=""; TLS_KEY="" } return 0 ;; acme) step "Certificate for $ACME_DOMAIN" issue_certificate || { # A failed certificate must not be a failed install. Everything else # works; the panel comes up on HTTP and the operator can re-run the # certificate step from the menu once the cause is fixed. warn "continuing without a certificate — the panel will serve plain HTTP" warn "fix the cause, then re-run this installer and choose a domain again" TLS_MODE=none TLS_CERT=""; TLS_KEY="" } return 0 ;; esac } # A certificate the operator already has. # # Left where it is rather than copied, so whatever renews it keeps working. The # only thing checked is that the panel's own account can read it, because a key # under /etc/letsencrypt/live is root-only by default and the service does not # run as root. use_existing_cert() { local ok_cert=1 ok_key=1 as_service_user test -r "$TLS_CERT" 2>/dev/null || ok_cert=0 as_service_user test -r "$TLS_KEY" 2>/dev/null || ok_key=0 if [[ "$ok_cert" == 1 && "$ok_key" == 1 ]]; then ok "using $TLS_CERT" ok "the service account can read both files" return 0 fi warn "$SERVICE_USER cannot read the certificate, so the panel could not start with it" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CERT_DIR" install -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0644 "$TLS_CERT" "$CERT_DIR/panel.crt" install -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0600 "$TLS_KEY" "$CERT_DIR/panel.key" TLS_CERT="$CERT_DIR/panel.crt" TLS_KEY="$CERT_DIR/panel.key" ok "copied into $CERT_DIR" warn "this is a copy: when the certificate is renewed, copy it again and" warn " restart the panel, or grant $SERVICE_USER read access to the original" } # ── acme.sh ────────────────────────────────────────────────────────────────── # A fixed home rather than $HOME. Under `sudo bash` the environment often # still carries the calling user's home, so acme.sh would install itself # into their directory while its renewal runs as root — and a later run of # this installer would not find it there. ACME_HOME="$( { getent passwd root 2>/dev/null || true; } | cut -d: -f6)" ACME_HOME="${ACME_HOME:-/root}/.acme.sh" acme() { "$ACME_HOME/acme.sh" --home "$ACME_HOME" "$@"; } install_acme() { [[ -x "$ACME_HOME/acme.sh" ]] && return 0 info "installing acme.sh" curl -fsSL --max-time 60 https://get.acme.sh -o /tmp/get-acme.sh || { warn "could not download acme.sh"; return 1; } ( HOME="${ACME_HOME%/.acme.sh}" sh /tmp/get-acme.sh "home=$ACME_HOME" ${ACME_EMAIL:+--accountemail "$ACME_EMAIL"} >/dev/null 2>&1 ) rm -f /tmp/get-acme.sh [[ -x "$ACME_HOME/acme.sh" ]] || { warn "acme.sh is not installed"; return 1; } # Let's Encrypt is named on each request below, never set as acme.sh's # default: another project on this server may keep certificates in the same # acme.sh with a different authority, and changing the default would move # their renewals. return 0 } # Renewal must happen with nobody watching, or the certificate expires and # the panel goes dark. acme.sh renews from a cron entry -- which is nothing # on the many small images that ship without a cron daemon. So a systemd # timer runs its renewal check every six hours as well; the two are harmless # together, since acme.sh only renews what is due. Six hours, not a day: an # address certificate lives six days and is renewed at six, and a daily # check could miss the window. ensure_renewal() { acme --install-cronjob >/dev/null 2>&1 || true cat > /etc/systemd/system/wui-cert-renew.service < /etc/systemd/system/wui-cert-renew.timer </dev/null 2>&1; then ok "renewal timer installed (wui-cert-renew.timer, every 6 hours)" else warn "could not enable the renewal timer; acme.sh's own cron entry is the fallback" fi } # The port the HTTP-01 challenge answers on. Let's Encrypt always knocks on # 80; when something else already holds it, the operator can forward 80 to # another port and name it here, as the classic panel's installer allows. acme_http_port() { ACME_HTTP_PORT=80 if ! port_taken 80; then return 0; fi local who; who="$(port_owner 80)" warn "port 80 is already served by $who" if [[ "$INSTALL_MODE" == auto ]]; then # Another project's port is left alone. The certificate can be had later, # once port 80 is free or forwarded, from the menu. warn "it belongs to something else on this server and is left alone — skipping the certificate" return 1 fi if [[ "$INTERACTIVE" != 1 ]]; then warn "no terminal to ask for another port — skipping the certificate" return 1 fi local alt ask alt "Another port for the ACME listener (forward external 80 to it; blank to give up)" "" [[ -n "$alt" ]] || return 1 [[ "$alt" =~ ^[0-9]+$ ]] && (( alt >= 1 && alt <= 65535 )) || { warn "not a port"; return 1; } port_taken "$alt" && { warn "port $alt is taken too"; return 1; } ACME_HTTP_PORT="$alt" info "reminder: Let's Encrypt still reaches port 80; forward external port 80 to $alt for validation" return 0 } # Put an issued certificate where the panel's own account can read it, and # keep it there across renewals. No restart in the reload command: the panel # re-reads the files itself when they change. install_cert_files() { local name="$1" dir="$CERT_DIR/$1" shift install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CERT_DIR" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$dir" acme --install-cert "$@" \ --fullchain-file "$dir/fullchain.pem" \ --key-file "$dir/privkey.pem" \ --reloadcmd "chown $SERVICE_USER:$SERVICE_USER $dir/fullchain.pem $dir/privkey.pem; chmod 600 $dir/privkey.pem" \ >/dev/null 2>&1 || true [[ -s "$dir/fullchain.pem" && -s "$dir/privkey.pem" ]] || { warn "the certificate was issued but could not be installed"; return 1; } chown "$SERVICE_USER:$SERVICE_USER" "$dir/fullchain.pem" "$dir/privkey.pem" chmod 644 "$dir/fullchain.pem" chmod 600 "$dir/privkey.pem" TLS_CERT="$dir/fullchain.pem" TLS_KEY="$dir/privkey.pem" ACME_METHOD=standalone ensure_renewal ok "issued and installed in $dir" ok "renewal is automatic; the panel picks the new certificate up by itself" warn "renewal needs port 80 reachable again; keep it open" return 0 } # A free certificate from Let's Encrypt for a domain, over the HTTP-01 # challenge on the standalone listener, as the classic panel's installer gets one. issue_certificate() { if ! domain_points_here "$ACME_DOMAIN"; then warn "$ACME_DOMAIN does not resolve to this server's address" warn " the certificate authority has to reach it here to issue anything" # The automatic install asked about this with the domain, and was told # to go ahead. if [[ "$INTERACTIVE" == 1 && "$INSTALL_MODE" != auto ]]; then ask_yn "Try anyway?" n || return 1 else warn "trying anyway" fi fi acme_http_port || return 1 pkg_install socat || warn "socat is missing; the standalone challenge may not work" install_acme || return 1 local listen="" ip -4 addr show scope global 2>/dev/null | grep -q "inet " || listen="--listen-v6" info "asking Let's Encrypt for a certificate (this takes a moment)" local out out=$(acme --issue -d "$ACME_DOMAIN" $listen --standalone --server letsencrypt --httpport "$ACME_HTTP_PORT" --keylength ec-256 --force 2>&1) || true if ! acme --list 2>/dev/null | awk '{print $1}' | grep -Fxq "$ACME_DOMAIN"; then warn "the certificate was not issued" # The operator needs the authority's own words, not a summary of them. printf '%s\n' "$out" | tail -12 | sed 's/^/ /' rm -rf "$ACME_HOME/${ACME_DOMAIN:?}" "$ACME_HOME/${ACME_DOMAIN:?}_ecc" return 1 fi install_cert_files "$ACME_DOMAIN" -d "$ACME_DOMAIN" --ecc } # A certificate for the address itself: Let's Encrypt's short-lived profile, # valid six days and renewed by the timer, so the panel is on HTTPS with no # domain at all -- what the classic panel's installer does by default. issue_ip_certificate() { acme_http_port || return 1 pkg_install socat || warn "socat is missing; the standalone challenge may not work" install_acme || return 1 local args=(-d "$ACME_IP") [[ -n "$ACME_IPV6" && "$ACME_IPV6" == *:* ]] && args+=(-d "$ACME_IPV6") info "asking Let's Encrypt for a 6-day certificate for $ACME_IP (this takes a moment)" local out out=$(acme --issue "${args[@]}" --standalone --server letsencrypt \ --certificate-profile shortlived --days 6 --httpport "$ACME_HTTP_PORT" --force 2>&1) || true if ! acme --list 2>/dev/null | awk '{print $1}' | grep -Fxq "$ACME_IP"; then warn "the certificate was not issued" printf '%s\n' "$out" | tail -12 | sed 's/^/ /' rm -rf "$ACME_HOME/${ACME_IP:?}" "$ACME_HOME/${ACME_IP:?}_ecc" [[ -n "$ACME_IPV6" ]] && rm -rf "$ACME_HOME/${ACME_IPV6:?}" "$ACME_HOME/${ACME_IPV6:?}_ecc" return 1 fi install_cert_files ip -d "$ACME_IP" --ecc } # ── the administrator ──────────────────────────────────────────────────────── # Created before the panel first starts, so the account is the one the operator # chose. The panel generates a random administrator only when it finds none, # which after this it never does. # The panel's own way into PostgreSQL, and nothing else. # # A RHEL-family cluster authenticates loopback connections by ident, which # refuses the panel's password login. This used to be fixed by rewriting the # loopback lines for every database to scram-sha-256 -- and on a server where # another project already uses PostgreSQL, that change is theirs too: a role # whose password is stored as md5 can no longer sign in, and the other project # stops working the moment this installer runs. Instead two lines for this # panel's database and role alone go first in pg_hba.conf, where the first # matching line wins, and every other line is left as it was. allow_panel_login() { local hba hba="$(runuser -u postgres -- psql -qAt -c 'SHOW hba_file' 2>/dev/null || true)" if [[ -z "$hba" || ! -f "$hba" ]]; then warn "could not find pg_hba.conf; if the panel cannot sign in to its database, allow the role wui over 127.0.0.1 with scram-sha-256" return 0 fi grep -Eq '^host[[:space:]]+wui[[:space:]]+wui[[:space:]]+127[.]0[.]0[.]1/32' "$hba" && return 0 local tmp tmp="$(mktemp)" { printf '# W-UI: its own database and role, over the loopback. Nothing else here was changed.\n' printf 'host wui wui 127.0.0.1/32 scram-sha-256\n' printf 'host wui wui ::1/128 scram-sha-256\n' cat "$hba" } > "$tmp" # Written through, not moved: the file keeps its owner and permissions. cat "$tmp" > "$hba" rm -f "$tmp" runuser -u postgres -- psql -qAt -c 'SELECT pg_reload_conf()' >/dev/null 2>&1 || true ok "PostgreSQL allows the panel's role into its database; other databases untouched" } # The database the panel opens. SQLite is one file under the data directory # and needs nothing. PostgreSQL is installed from the distribution, given a # role and a database of its own with a generated password, and the # connection string is kept root-only in $CONF_DIR/db.env, which the unit # reads. Re-runs keep what is there. setup_database() { step "Database" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$CONF_DIR" if [[ "$DB_DRIVER" == sqlite ]]; then DB_SOURCE="$DATA_DIR/wui.db" printf 'WUI_DB_DRIVER=sqlite\nWUI_DB_SOURCE=%s\n' "$DB_SOURCE" > "$CONF_DIR/db.env" chmod 600 "$CONF_DIR/db.env" ok "SQLite at $DB_SOURCE" return 0 fi if [[ -n "$DB_SOURCE" ]] && [[ "$DB_SOURCE" == postgres://* ]]; then ok "PostgreSQL, as already configured" ensure_postgres_running return 0 fi info "installing PostgreSQL" case "$FAMILY" in debian) pkg_install postgresql postgresql-contrib || die "could not install PostgreSQL" ;; rhel) pkg_install postgresql-server postgresql-contrib || die "could not install PostgreSQL" if [[ ! -s /var/lib/pgsql/data/PG_VERSION ]]; then # The distribution's wrapper wants systemd; where there is none, # initdb itself. Password logins over the loopback either way -- # the RHEL default is ident, which would refuse the panel's. install -d -o postgres -g postgres -m 700 /var/lib/pgsql/data if ! postgresql-setup --initdb >/dev/null 2>&1; then runuser -u postgres -- initdb -D /var/lib/pgsql/data --auth-local=peer --auth-host=scram-sha-256 -E UTF8 >/dev/null 2>&1 || die "could not initialise PostgreSQL" fi fi ;; esac ensure_postgres_running allow_panel_login DB_PASS="$(gen_string 32)" # One role and one database, owned by it. Created idempotently: a role # that exists gets the new password rather than an error. as_postgres() { runuser -u postgres -- psql -v ON_ERROR_STOP=1 -qAt -c "SET password_encryption = 'scram-sha-256'" "$@"; } if as_postgres -c "SELECT 1 FROM pg_roles WHERE rolname='wui'" | grep -q 1; then as_postgres -c "ALTER ROLE wui WITH LOGIN PASSWORD '$DB_PASS'" >/dev/null || die "could not set the database password" else as_postgres -c "CREATE ROLE wui WITH LOGIN PASSWORD '$DB_PASS'" >/dev/null || die "could not create the database role" fi if ! as_postgres -c "SELECT 1 FROM pg_database WHERE datname='wui'" | grep -q 1; then as_postgres -c "CREATE DATABASE wui OWNER wui" >/dev/null || die "could not create the database" fi DB_SOURCE="postgres://wui:$DB_PASS@127.0.0.1:5432/wui?sslmode=disable" local prev; prev=$(umask); umask 077 printf 'WUI_DB_DRIVER=postgres\nWUI_DB_SOURCE=%s\n' "$DB_SOURCE" > "$CONF_DIR/db.env" umask "$prev" chmod 600 "$CONF_DIR/db.env" ok "PostgreSQL database wui, credentials in $CONF_DIR/db.env" } ensure_postgres_running() { if have_systemd; then systemctl enable --now postgresql >/dev/null 2>&1 || true elif runuser -u postgres -- pg_isready -q 2>/dev/null; then : elif have service; then # A container or a host without systemd: the init script, or the # cluster tool Debian ships, or pg_ctl itself on RHEL. service postgresql start >/dev/null 2>&1 || true fi if ! runuser -u postgres -- pg_isready -q 2>/dev/null; then if have pg_ctlcluster; then local ver; ver=$(ls /etc/postgresql 2>/dev/null | sort -V | tail -1) [[ -n "$ver" ]] && pg_ctlcluster "$ver" main start >/dev/null 2>&1 || true elif [[ -d /var/lib/pgsql/data ]]; then # The socket directory systemd's tmpfiles would have made. install -d -o postgres -g postgres -m 755 /run/postgresql 2>/dev/null || true runuser -u postgres -- pg_ctl -D /var/lib/pgsql/data -l /var/lib/pgsql/startup.log -w start >/dev/null 2>&1 || true fi fi local _ for _ in $(seq 1 30); do runuser -u postgres -- pg_isready -q 2>/dev/null && return 0 sleep 1 done [[ -r /var/lib/pgsql/startup.log ]] && tail -5 /var/lib/pgsql/startup.log >&2 die "PostgreSQL did not come up; see: journalctl -u postgresql" } # The subscription service: on, on its own port, with the panel's # certificate. Only on a fresh install; an upgrade keeps whatever the # operator has set since. apply_sub_settings() { step "Subscription service" if [[ "$SUB_EXISTING" == 1 ]]; then ok "kept on port $SUB_PORT" return 0 fi local args=(--sub-enable --sub-port "$SUB_PORT") if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then args+=(--sub-cert "$TLS_CERT" --sub-key "$TLS_KEY") fi # shellcheck disable=SC2046 if as_service_user env $(panel_env) "$BIN_PATH" setting set "${args[@]}" >/dev/null 2>&1; then ok "enabled on port $SUB_PORT" else warn "could not enable the subscription service; turn it on under Settings → Subscription" fi } apply_admin() { step "Administrator account" # This installer is safe to re-run, and that has to include the account # somebody is already signing in with. Nothing was asked for here, so # nothing is changed: generating a fresh password over a working one # would lock an operator out of their own panel on an upgrade. if existing_data && [[ -z "$ADMIN_PASS" ]]; then ok "existing account left as it is" info "to change it: wui admin reset --username NAME" return 0 fi ADMIN_GENERATED=0 if [[ -z "$ADMIN_PASS" ]]; then ADMIN_PASS="$(gen_password)" ADMIN_GENERATED=1 fi # Piped, never passed as an argument: an argument is visible in ps to every # user on the machine for as long as the command runs. # shellcheck disable=SC2046 if printf '%s' "$ADMIN_PASS" | as_service_user env $(panel_env) \ "$BIN_PATH" admin reset --username "$ADMIN_USER" --password-stdin --quiet; then ok "$ADMIN_USER" else warn "could not create the administrator here" warn " the panel will generate one on first start; find it with:" warn " journalctl -u wui | grep -A6 'First run'" ADMIN_PASS="" fi } # local_bin_fits says whether a binary is an ELF program for this machine: # its e_machine, bytes 18-19, little-endian. One for another machine would # be installed, fail to run, and be read by the shell as a script, with an # error that says nothing about why. local_bin_fits() { local magic machine magic="$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' \n')" [[ "$magic" == "7f454c46" ]] || return 1 machine="$(od -An -tx1 -j18 -N2 "$1" 2>/dev/null | tr -d ' \n')" case "$ARCH" in amd64) [[ "$machine" == "3e00" ]] ;; arm64) [[ "$machine" == "b700" ]] ;; *) return 0 ;; esac } install_binary() { step "Installing the panel" if [[ -n "$LOCAL_BIN" ]]; then [[ -f "$LOCAL_BIN" ]] || die "no such file: $LOCAL_BIN" local_bin_fits "$LOCAL_BIN" || die "$LOCAL_BIN is not a Linux $ARCH program; build it for this server (GOARCH=$ARCH)" install -m 0755 "$LOCAL_BIN" "$BIN_PATH" ok "installed from $LOCAL_BIN" elif [[ "$FROM_SOURCE" == 1 ]]; then if [[ -f go.mod ]]; then # Run from a checkout: build what is in front of us, which is what # somebody testing a change expects. have go || die "--from-source needs Go on PATH" info "building (this takes a minute)…" CGO_ENABLED=0 go build -ldflags "-s -w" -o "$BIN_PATH" ./cmd/wui \ || die "build failed" chmod 0755 "$BIN_PATH" ok "built from source" else build_from_tarball fi elif [[ -n "$RELEASE_URL" ]]; then info "downloading $RELEASE_URL" curl -fsSL "$RELEASE_URL" -o /tmp/wui.new || die "download failed" install -m 0755 /tmp/wui.new "$BIN_PATH" rm -f /tmp/wui.new ok "installed from release" else # Nothing was asked for, which is what `curl … | bash` looks like. Work it # out rather than refusing: a published build if there is one, and the # source if there is not. # # This used to be a `die`, which meant the one-line install printed in the # README could not succeed on any machine -- it reached the last step and # stopped there having installed WireGuard, OpenVPN and nothing else. if release_asset_url; then info "downloading $ASSET_URL" curl -fsSL "$ASSET_URL" -o /tmp/wui.new || die "download failed" verify_download /tmp/wui.new "${ASSET_URL##*/}" install -m 0755 /tmp/wui.new "$BIN_PATH" rm -f /tmp/wui.new ok "installed from the latest release" else info "no published build for $ARCH; building from source instead" build_from_tarball fi fi [[ -x "$BIN_PATH" ]] || die "installed binary is not executable" ok "$BIN_PATH" } install_menu() { step "Installing the w-ui command" # Prefer the copy beside this installer. Someone running --from-source or # --local wants the script from their checkout, not whatever is on the branch. local src="" if [[ -f "$(dirname "$0")/w-ui.sh" ]]; then src="$(dirname "$0")/w-ui.sh" elif [[ -f ./w-ui.sh ]]; then src=./w-ui.sh fi if [[ -n "$src" ]]; then install -m 0755 "$src" "$MENU_PATH" ok "installed from $src" elif curl -fsSL "$MENU_URL" -o /tmp/w-ui.sh; then install -m 0755 /tmp/w-ui.sh "$MENU_PATH" rm -f /tmp/w-ui.sh ok "downloaded" else warn "could not install the w-ui command; the panel still works" return 0 fi # A CRLF here makes the shebang line unparseable, and the error then names # the interpreter rather than the file - a confusing first impression on a # repository that has been edited on Windows. local clean clean=$(mktemp) if tr -d '\015' < "$MENU_PATH" > "$clean"; then install -m 0755 "$clean" "$MENU_PATH" fi rm -f "$clean" ok "$MENU_PATH - run 'w-ui' to manage the panel" } create_user() { step "Creating the service account" if id -u "$SERVICE_USER" >/dev/null 2>&1; then ok "user $SERVICE_USER already exists" else useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" ok "user $SERVICE_USER created" fi install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$DATA_DIR" "$CONF_DIR" "$BACKUP_DIR" ok "$DATA_DIR (0750, owned by $SERVICE_USER)" } write_unit() { step "Installing the systemd unit" # Two lines or none. An empty WUI_TLS_CERT is not the same as an unset one: # the panel refuses to start when only half a pair is present, which is the # right answer for a typo and the wrong one for a plain-HTTP install. local TLS_ENV="" BASE_ENV="" [[ -n "$BASE_PATH" ]] && BASE_ENV="Environment=WUI_BASE_PATH=$BASE_PATH"$'\n' if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then TLS_ENV="Environment=WUI_TLS_CERT=$TLS_CERT"$'\n'"Environment=WUI_TLS_KEY=$TLS_KEY" fi cat >"$UNIT" </etc/systemd/system/wui-update.service </etc/systemd/system/wui-update.path </dev/null 2>&1 || { warn "could not enable wui-update.path; updating from the panel will not work"; return 0; } ok "wui-update.path (updates from the panel)" } open_firewall() { step "Opening the panel port" # A certificate issued through the standalone challenge is renewed the same # way, in about sixty days, on a machine nobody is watching. If port 80 is # closed by then the renewal fails silently and the panel serves an expired # certificate — so the port that made this work stays open. # Nothing is opened for a panel that only listens on the loopback: the # port is unreachable from outside by design, and a firewall rule for it # would be a lie in the operator's own rule list. local ports=() [[ "$LISTEN_ADDR" == 127.0.0.1 ]] || ports+=("$PANEL_PORT/tcp") [[ -n "$SUB_PORT" ]] && ports+=("$SUB_PORT/tcp") [[ "$ACME_METHOD" == standalone ]] && ports+=("80/tcp") local pr if [[ ${#ports[@]} -eq 0 ]]; then ok "nothing to open: the panel listens on 127.0.0.1 only" return 0 fi if have ufw && ufw status 2>/dev/null | grep -q "Status: active"; then for pr in "${ports[@]}"; do ufw allow "$pr" >/dev/null 2>&1 && ok "ufw: $pr allowed" done elif have firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then for pr in "${ports[@]}"; do firewall-cmd --permanent --add-port="$pr" >/dev/null 2>&1 && ok "firewalld: $pr allowed" done firewall-cmd --reload >/dev/null 2>&1 else info "no active host firewall detected" info "if your provider has one, allow TCP $PANEL_PORT and your tunnel's UDP port" fi } start_service() { if ! have_systemd; then # Nothing to start it with. Said plainly rather than failing: everything # else installed correctly and the panel runs perfectly well by hand. step "Starting W-UI" warn "skipped: no systemd on this host" return 0 fi step "Starting W-UI" local fresh=0 existing_data || fresh=1 systemctl enable wui.service >/dev/null 2>&1 systemctl restart wui.service for _ in $(seq 1 20); do if systemctl is-active --quiet wui.service; then break; fi sleep 0.5 done systemctl is-active --quiet wui.service \ || die "service failed to start — see: journalctl -u wui -n 50 --no-pager" ok "wui.service is running" if [[ "$fresh" == 1 && -z "$ADMIN_PASS" ]]; then # Only reached when creating the account up front did not work. The panel # prints its generated password once, to the journal, and stores it # nowhere in the clear; fishing it out here saves the operator the search. sleep 1 FIRST_RUN_PW=$(journalctl -u wui --since "-2 min" --no-pager 2>/dev/null \ | grep -A1 'password' | grep -oE '[A-Za-z0-9_-]{16}' | head -1 || true) fi } summary() { local ip host scheme ip=$(public_ip 2>/dev/null || echo "your-server-ip") [[ -n "$ip" ]] || ip="your-server-ip" # The address that will actually work. A certificate issued for a domain is # not valid for the address, so printing the IP there would hand the operator # a URL their browser refuses. scheme=http; host="$ip"; local port=":$PANEL_PORT" if [[ -n "$TLS_CERT" && -n "$TLS_KEY" ]]; then scheme=https; fi [[ "$TLS_MODE" == acme && -n "$ACME_DOMAIN" ]] && host="$ACME_DOMAIN" [[ "$TLS_MODE" == ip && -n "$ACME_IP" ]] && host="$ACME_IP" [[ "$TLS_MODE" == files && -n "$TLS_DOMAIN" ]] && host="$TLS_DOMAIN" # Behind a proxy the panel's own port is not the address anybody uses: nginx # answers on 443 for the domain and reaches the panel over the loopback. # Printing the panel's port here would hand the operator a URL that is # firewalled off from the internet. printf '\n%s────────────────────────────────────────────────────────────%s\n' "$M" "$N" printf ' %s%sW-UI is installed%s\n\n' "$B" "$G" "$N" local shown_path="/" [[ -n "$BASE_PATH" ]] && shown_path="/$BASE_PATH/" kv Panel "$scheme://$host$port$shown_path" "$B$G$U" kv Subs "$scheme://$host:$SUB_PORT/subscribe/" if [[ -n "$ADMIN_PASS" ]]; then kv Username "$ADMIN_USER" "$B$G" kv Password "$ADMIN_PASS" "$B$Y" if [[ "$ADMIN_GENERATED" == 1 ]]; then printf '\n %s%sThis password was generated and is shown once. Write it down.%s\n' "$B" "$Y" "$N" fi elif [[ -n "${FIRST_RUN_PW:-}" ]]; then kv Username admin "$B$G" kv Password "$FIRST_RUN_PW" "$B$Y" printf '\n %s%sThis password is shown once. Change it after signing in.%s\n' "$B" "$Y" "$N" elif ! have_systemd; then # Nothing started the panel, so no account exists yet. Claiming an existing # one here would send the operator looking for something that is not there. printf ' %sThe panel has not been started yet, so no account exists.%s\n' "$Y" "$N" printf ' It prints a generated password to its log the first time it runs.\n' elif existing_data; then printf ' Sign in with your existing admin account.\n' else printf ' %sThe first-run password could not be read from the log.%s\n' "$Y" "$N" printf ' Find it with: %sjournalctl -u wui | grep -A6 "First run"%s\n' "$C" "$N" fi # part LABEL VALUE: one line of what was installed, its state coloured. part() { printf ' %s%-12s%s %s\n' "$C" "$1" "$N" "$2"; } printf '\n %s%sInstalled%s\n' "$B" "$Y" "$N" part WireGuard "$(state "$(have wg && wg --version 2>/dev/null | awk '{print $2}' || echo 'missing')")" part AmneziaWG "$(state "$([[ "${AMNEZIA_OK:-0}" == 1 ]] && awg --version 2>/dev/null | awk '{print $2}' || echo 'not installed')")" part OpenVPN "$(state "$([[ "${OPENVPN_OK:-0}" == 1 ]] && openvpn --version 2>/dev/null | head -1 | awk '{print $2}' || echo 'not installed')")" part L2TP/IPsec "$(state "$(have xl2tpd && have swanctl && echo 'installed, off until used' || echo 'not installed')")" part IKEv2 "$(state "$(have swanctl && echo 'installed, off until used' || echo 'not installed')")" part PPTP "$(state "$(have pppd && echo 'installed, off until used' || echo 'not installed')")" part OpenConnect "$(state "$(have ocserv && echo 'installed, off until used' || echo 'not installed')")" part xray "$(state "$(have xray && echo "${XRAY_VERSION#v}" || echo 'not installed')")" part nftables "$(state "$(nft --version 2>/dev/null | awk '{print $2}')")" part forwarding "$(state "$([[ "$(sysctl -n net.ipv4.ip_forward)" == 1 ]] && echo on || echo off)")" if [[ "$LISTEN_ADDR" == 127.0.0.1 ]]; then part listening "$Y"'127.0.0.1 only — not reachable from outside this server'"$N" else part listening "$G$LISTEN_ADDR:$PANEL_PORT$N" fi part "URL path" "$([[ -n "$BASE_PATH" ]] && echo "$G$shown_path$N — nothing else on this address answers" || echo "$Y"'none (the panel is at the root)'"$N")" case "$TLS_MODE" in acme) part certificate "${G}Let's Encrypt, renews itself$N" ;; ip) part certificate "${G}Let's Encrypt for $ACME_IP, 6 days, renews itself every 6 hours$N" ;; files) part certificate "${G}yours, at $TLS_CERT$N" ;; *) part certificate "${Y}none — this panel serves plain HTTP$N" ;; esac # cmd COMMAND WHAT: a command in cyan, padded, and what it does. cmd() { printf ' %s%-27s%s %s\n' "$C" "$1" "$N" "$2"; } printf '\n %s%sCommands%s\n' "$B" "$Y" "$N" if have_systemd; then cmd w-ui 'the management menu' cmd 'systemctl status wui' 'service state' cmd 'journalctl -u wui -f' 'live log' cmd 'systemctl restart wui' restart else # Only what works here. Four systemctl lines on a host with no systemd are # four commands that fail. printf ' %s%s%s start it in the foreground\n' "$C" "$BIN_PATH" "$N" cmd 'w-ui settings' 'what it is configured with' cmd 'w-ui admin' 'reset the administrator' fi printf ' %sbash %s --uninstall%s remove (keeps the database)\n' "$C" "$0" "$N" printf '\n %s%sNext%s open the panel, add an interface, then add clients.\n' "$B" "$M" "$N" printf '%s────────────────────────────────────────────────────────────%s\n\n' "$M" "$N" # Everything the classic panel prints under "Panel Installation Complete", in one # place, for the operator who scrolls back for the one line they need. printf ' %s═══════════════════════════════════════════%s\n' "$G" "$N" printf ' %s%s Panel Installation Complete! %s\n' "$B" "$G" "$N" printf ' %s═══════════════════════════════════════════%s\n' "$G" "$N" # box LABEL VALUE [COLOUR]: a line of it, the label yellow, the value bold. box() { printf ' %s%-13s%s%s%s%s%s\n' "$Y" "$1" "$N" "$B" "${3:-$G}" "$2" "$N"; } box Username: "$ADMIN_USER" [[ -n "$ADMIN_PASS" ]] && box Password: "$ADMIN_PASS" "$R" box Port: "$PANEL_PORT" box 'Sub Port:' "$SUB_PORT" box WebBasePath: "${BASE_PATH:-/}" if [[ "$DB_DRIVER" == postgres ]]; then box Database: "PostgreSQL (wui on 127.0.0.1, credentials in $CONF_DIR/db.env)" else box Database: "SQLite ($DATA_DIR/wui.db)" fi box 'Access URL:' "$scheme://$host$port$shown_path" "$C$U" [[ -n "$API_TOKEN" ]] && box 'API Token:' "$API_TOKEN" "$M" printf ' %s═══════════════════════════════════════════%s\n' "$G" "$N" printf ' %s%s⚠ IMPORTANT: Save these credentials securely!%s\n' "$B" "$R" "$N" if [[ "$scheme" == https ]]; then printf ' %s⚠ SSL Certificate: Enabled and configured%s\n' "$G" "$N" else printf ' %s⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.%s\n' "$Y" "$N" fi write_install_result "$scheme" "$host$port" } # Mint the token the classic panel prints as apiToken: automation gets one from the # first minute without anybody signing in to make it. Never fatal. # # Once, at the install. An update mints none: each would be one more token # with the run of the panel, left behind on every update -- and the one the # install wrote down is still the one in install-result.env. issue_api_token() { [[ "$IS_UPDATE" == 1 ]] && return 0 have_systemd && systemctl is-active --quiet wui || return 0 # shellcheck disable=SC2046 API_TOKEN=$(env $(panel_env) "$BIN_PATH" token issue --name installer --quiet 2>/dev/null || true) } # The same facts as the summary, machine-readable, for cloud-init or a # login banner to pick up: the classic panel's install-result.env. Mode 600, root only, # because it holds the password. # # Written by the install alone. An update leaves the file as the install wrote # it -- its values, its mode, its owner: the password is not known to an # update, and automation reading the file must not find it emptied. An install # from before the file existed has none, and an update does not make one up. write_install_result() { local scheme="$1" hostport="$2" f="$CONF_DIR/install-result.env" if [[ "$IS_UPDATE" == 1 ]]; then if [[ -f "$f" ]]; then printf ' %sInstall result kept as it was: %s%s\n\n' "$D" "$f" "$N" else printf ' %sNo %s: this install is older than the file, and an update does not write one.%s\n\n' "$D" "$f" "$N" fi return 0 fi install -d -m 700 "$CONF_DIR" 2>/dev/null || true local prev; prev=$(umask); umask 077 { printf 'WUI_USERNAME=%q\n' "$ADMIN_USER" printf 'WUI_PASSWORD=%q\n' "$ADMIN_PASS" printf 'WUI_PANEL_PORT=%q\n' "$PANEL_PORT" printf 'WUI_WEB_BASE_PATH=%q\n' "${BASE_PATH:-}" printf 'WUI_ACCESS_URL=%q\n' "${scheme}://${hostport}/${BASE_PATH:+$BASE_PATH/}" printf 'WUI_API_TOKEN=%q\n' "$API_TOKEN" printf 'WUI_SUB_PORT=%q\n' "$SUB_PORT" printf 'WUI_SUB_URL=%q\n' "${scheme}://${hostport%%:*}:${SUB_PORT}/subscribe/" printf 'WUI_DB_TYPE=%q\n' "$DB_DRIVER" } > "$f" 2>/dev/null && chmod 600 "$f" && chown root:root "$f" 2>/dev/null \ && printf ' %sInstall result written to %s (mode 600).%s\n\n' "$G" "$f" "$N" \ || warn "could not write $f" umask "$prev" } # IP Limit rides on fail2ban; set it up now so the menu's jail works out # of the box, as the classic panel does. Opt out with WUI_ENABLE_FAIL2BAN=false. Never # fatal: a fail2ban that would not install must not fail the panel. setup_fail2ban() { if [[ -n "${WUI_ENABLE_FAIL2BAN+x}" && "$WUI_ENABLE_FAIL2BAN" != true ]]; then info "WUI_ENABLE_FAIL2BAN=$WUI_ENABLE_FAIL2BAN, skipping Fail2ban auto-setup" return 0 fi have_systemd || return 0 [[ -x "$MENU_PATH" ]] || return 0 step "Setting up Fail2ban for the IP Limit feature" info "installing fail2ban from the distribution; a minute on a fresh image" if "$MENU_PATH" setup-fail2ban /dev/null 2>&1; then ok "Fail2ban setup complete" else warn "Fail2ban setup did not finish; IP Limit stays off until you run 'w-ui' → IP Limit Management" fi } # the classic panel's closing box: the subcommands, so the operator's next command is # already on the screen. usage_box() { printf ' %s✓%s %s%s %s finished; the panel is running.%s\n\n' "$G" "$N" "$B" "W-UI $("$BIN_PATH" version 2>/dev/null || echo)" "${1:-installation}" "$N" printf ' ╭───────────────────────────────────────────────────────╮\n' printf ' │ %sThe w-ui command%s │\n' "$B" "$N" printf ' │ │\n' for row in "w-ui - Admin Management Script" \ "w-ui start - Start" \ "w-ui stop - Stop" \ "w-ui restart - Restart" \ "w-ui status - Current Status" \ "w-ui settings - Current Settings" \ "w-ui enable - Enable Autostart on OS Startup" \ "w-ui disable - Disable Autostart on OS Startup" \ "w-ui log - Check logs" \ "w-ui banlog - Check Fail2ban ban logs" \ "w-ui update - Update" \ "w-ui legacy - Legacy version" \ "w-ui install - Install" \ "w-ui uninstall - Uninstall"; do printf ' │ %s%-17s%s%s%-34s│\n' "$C" "${row%% - *}" "$N" "- " "${row#* - }" done printf ' ╰───────────────────────────────────────────────────────╯\n' } # ── run ────────────────────────────────────────────────────────────────────── # Everything above is definitions. A test suite that only wants those stops # here, with the file it sourced being byte for byte the file that installs. if [[ "$LIB_ONLY" == 1 ]]; then return 0 2>/dev/null || exit 0; fi detect_os [[ "$ACTION" == install ]] || do_uninstall printf '\n %s%sW-UI%s %s·%s %sWireGuard%s · %sOpenVPN%s · %sOpenConnect · IKEv2 · L2TP · PPTP%s %s%sinstaller%s\n' "$P" "$B" "$N" "$D" "$N" "$G" "$N" "$Y" "$N" "$M" "$N" "$B" "$C" "$N" printf ' %s%s · kernel %s · %s%s\n' "$D" "$OS_NAME" "$KERNEL" "$ARCH" "$N" # Asked first, so the rest runs unattended. configure install_base install_wireguard if [[ "$WANT_AMNEZIA" == 1 ]]; then install_amnezia; else info "AmneziaWG skipped (--no-amnezia)"; fi if [[ "$WANT_OPENVPN" == 1 ]]; then install_openvpn; else info "OpenVPN skipped (--no-openvpn)"; fi if [[ "$WANT_L2TP" == 1 ]]; then install_l2tp; else info "L2TP/IPsec skipped; the panel installs it with the first L2TP tunnel"; fi if [[ "$WANT_IKEV2" == 1 ]]; then install_ikev2; else info "IKEv2 skipped; the panel installs it with the first IKEv2 tunnel"; fi if [[ "$WANT_PPTP" == 1 ]]; then install_pptp; else info "PPTP skipped; the panel installs it with the first PPTP tunnel"; fi if [[ "$WANT_OPENCONNECT" == 1 ]]; then install_openconnect; else info "OpenConnect skipped; the panel installs it with the first OpenConnect tunnel"; fi if [[ "$WANT_PROXY" == 1 ]]; then install_proxy_cores; else info "xray and sing-box skipped (--no-proxy-cores)"; fi enable_forwarding check_nftables install_binary install_menu create_user setup_database setup_tls write_unit write_update_helper if binary_before_3; then remove_l2tp_agent remove_firewall_helper warn "this binary is from before 3.0: L2TP/IPsec, IKEv2, PPTP, OpenConnect and the firewall helper stay off" else write_l2tp_agent fi apply_admin apply_sub_settings open_firewall start_service issue_api_token setup_fail2ban summary usage_box