install.sh
Rama main · réplica de lectura del repositorio original.
#!/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 <path> 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 <n> panel port (default: random)
# --sub-port <n> subscription service port (default: random)
# --db <engine> sqlite (default) or postgres, for a large customer count
# --username <name> administrator name (default admin)
# --password <pass> administrator password (default: generated)
# --domain <name> 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 <addr> where the certificate authority sends expiry notices
# --tls-cert <path> use a certificate you already have
# --tls-key <path> 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/urandom | head -c 16` exits 141 every single
# time, and the whole run stopped there with an empty log.
#
# Worse, it can give the wrong answer rather than no answer:
# `acme.sh --list | grep` returns 141 when grep matches and finds
# what it wanted, so the check reports the plugin missing when it is there.
#
# Nothing here depends on catching a failure in the middle of a pipeline;
# every pipeline's meaning is its last command.
set -eu
# Whether this host runs systemd. Checked once: a container and a few
# distributions do not, and the installer has to finish on them rather than
# aborting at the first systemctl call with a message about a bus.
have_systemd() {
[[ -d /run/systemd/system ]] && command -v systemctl >/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 <<UNIT
[Unit]
Description=W-UI — the AmneziaWG kernel module for the running kernel
After=network-online.target
Wants=network-online.target
Before=wui.service
[Service]
Type=oneshot
ExecStart=/usr/local/libexec/wui-amnezia-kmod
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable 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 <<HELPER
[Unit]
Description=W-UI — let the tunnels' ports through ufw or firewalld
After=network-online.target ufw.service firewalld.service
[Service]
Type=oneshot
ExecStart=$BIN_PATH firewall-sync $DATA_DIR
[Install]
WantedBy=multi-user.target
HELPER
cat >/etc/systemd/system/wui-firewall.path <<HELPER
[Unit]
Description=W-UI — let the tunnels' ports through the firewall whenever they change
[Path]
PathChanged=$DATA_DIR/firewall/ports.json
Unit=wui-firewall.service
[Install]
WantedBy=multi-user.target
HELPER
systemctl daemon-reload
systemctl enable --now 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 <<HELPER
[Unit]
Description=W-UI — PPP agent: runs the L2TP and PPTP tunnels the panel describes
After=network-online.target
Wants=network-online.target
# Started by wui-l2tp.path while the panel describes a tunnel. An agent that
# fails is tried again for as long as it fails, not given up on after five.
StartLimitIntervalSec=0
[Service]
Type=simple
ExecStart=$BIN_PATH l2tp-agent $DATA_DIR
# Only the agent is stopped, never xl2tpd, pptpd and the sessions they carry: a
# restart, an update's included, must not disconnect anyone. The next run
# adopts them.
KillMode=process
Restart=on-failure
RestartSec=5
HELPER
cat >/etc/systemd/system/wui-l2tp.path <<HELPER
[Unit]
Description=W-UI — start the PPP agent while the panel describes an L2TP, PPTP, IKEv2 or OpenConnect tunnel
[Path]
PathExists=$DATA_DIR/l2tp/state.json
PathExists=$DATA_DIR/pptp/state.json
PathExists=$DATA_DIR/ikev2/state.json
PathExists=$DATA_DIR/openconnect/state.json
Unit=wui-l2tp.service
[Install]
WantedBy=multi-user.target
HELPER
systemctl daemon-reload
systemctl enable --now 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 <path> 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:-<your host>}:$PANEL_PORT$shown_path (your own certificate)" ;;
none) info "Address http://$([[ "$LISTEN_ADDR" == 127.0.0.1 ]] && echo 127.0.0.1 || echo '<this server>'):$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 <<UNIT
[Unit]
Description=W-UI certificate renewal (acme.sh)
After=network-online.target
[Service]
Type=oneshot
Environment=HOME=${ACME_HOME%/.acme.sh}
ExecStart=$ACME_HOME/acme.sh --cron --home $ACME_HOME
UNIT
cat > /etc/systemd/system/wui-cert-renew.timer <<UNIT
[Unit]
Description=W-UI certificate renewal check, every six hours
[Timer]
OnCalendar=*-*-* 00/6:00:00
RandomizedDelaySec=30m
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
if systemctl enable --now 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" <<UNITFILE
[Unit]
Description=W-UI — WireGuard and OpenVPN panel
Documentation=https://github.com/abolfazl/w-ui
After=network-online.target nftables.service postgresql.service
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
Group=$SERVICE_USER
# The panel programs nftables and WireGuard through netlink, which needs
# CAP_NET_ADMIN. CAP_NET_BIND_SERVICE is for the tunnels themselves: a tunnel on
# UDP 443 is often the only one that survives a network which blocks by port,
# and a userspace tunnel binds that socket in this process. Granting exactly
# these two instead of running as root keeps a compromised panel from owning the
# whole machine.
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
NoNewPrivileges=true
Environment=WUI_LISTEN=$LISTEN_ADDR:$PANEL_PORT
Environment=WUI_DATA_DIR=$DATA_DIR
Environment=WUI_BACKUP_DIR=$BACKUP_DIR
$BASE_ENV$TLS_ENV
# The database: driver and connection string, root-only because the
# PostgreSQL one carries a password.
EnvironmentFile=-$CONF_DIR/db.env
EnvironmentFile=-$CONF_DIR/wui.env
ExecStart=$BIN_PATH
ExecReload=/bin/kill -HUP \$MAINPID
WorkingDirectory=$DATA_DIR
Restart=always
RestartSec=3
# Only the panel is stopped, not everything it started.
#
# Where there is no amneziawg kernel module the tunnel is carried by an
# amneziawg-go process the panel launches, and that process has to outlive it.
# The default here kills the whole control group, which would turn every panel
# restart -- including an automatic one after an update -- into a disconnection
# for every customer on an obfuscated tunnel.
KillMode=process
# The socket amneziawg-go answers on. Preserved across restarts for the same
# reason: removing it would orphan a tunnel that is still carrying traffic.
RuntimeDirectory=amneziawg
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes
# The panel needs the network and its own data directory, nothing else.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=$DATA_DIR $BACKUP_DIR
ProtectKernelTunables=false
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
[Install]
WantedBy=multi-user.target
UNITFILE
if have_systemd; then
systemctl daemon-reload
ok "$UNIT"
else
ok "$UNIT (written, not loaded)"
warn "this host is not running systemd, so the service was not registered"
warn " start the panel yourself with:"
warn " WUI_LISTEN=$LISTEN_ADDR:$PANEL_PORT WUI_DATA_DIR=$DATA_DIR $BIN_PATH"
fi
}
# Updating from the panel.
#
# The panel runs as $SERVICE_USER, in a sandbox where its own binary is
# read-only, so it cannot replace itself -- which is right for a process
# facing the internet. It downloads the release and checks the signature into
# $DATA_DIR/update and leaves a request there; this path unit sees the request
# and runs the installed binary, as root, to check it all again with its own
# key, refuse anything that is not a newer signed release, put it in place and
# restart the panel. Root only ever runs a build this project signed.
write_update_helper() {
have_systemd || return 0
install -d -m 0700 -o "$SERVICE_USER" -g "$SERVICE_USER" "$DATA_DIR/update"
cat >/etc/systemd/system/wui-update.service <<HELPER
[Unit]
Description=W-UI — install an update the panel downloaded and checked
[Service]
Type=oneshot
ExecStart=$BIN_PATH apply-update $DATA_DIR
# A release is checked and moved in seconds. The bound is for a staged file
# that is not one -- a pipe would otherwise hold this open for good.
TimeoutStartSec=120
HELPER
cat >/etc/systemd/system/wui-update.path <<HELPER
[Unit]
Description=W-UI — watch for an update the panel asks to install
[Path]
PathExists=$DATA_DIR/update/request
Unit=wui-update.service
[Install]
WantedBy=multi-user.target
HELPER
systemctl daemon-reload
systemctl enable --now 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/<token>"
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 >/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