#!/usr/bin/env bash # # qeli — one-shot server installer. During the run it asks which profile to deploy # (reality-tls default, fake-tls, or udp-quic) and which port to listen on (default :443). # # What it does, end to end: # 1. installs dependencies, # 2. gets qeli onto the box AND sets up everything the .deb does (the `qeli` user, # /etc/qeli + state dirs, *.conf.example files, the systemd unit, the polkit rule): # by default it downloads + installs the latest .deb from GitHub Releases; with # QELI_BIN= it installs that prebuilt binary and reproduces the same layout # itself (add QELI_SRC= for a fully offline / build-from-source run), # 3. asks for the profile (reality-tls | fake-tls | udp-quic) + listen port and writes # /etc/qeli/server.conf with ONLY that profile (taken from the packaged # multi-profile example) on the chosen port, full-tunnel NAT44 on, # and dual-stack NAT66 when the host has a public IPv6 WAN, # 4. generates the server identity key, # 5. creates 5 users and saves their ready-to-use qeli:// connection strings # under /etc/qeli/client-links/, # 6. enables + (re)starts the service. # # It is a FIRST install: if /etc/qeli/server.conf already exists it refuses to run, # because rewriting that file mints a new REALITY short_id and kills every qeli:// # link already issued. To upgrade an installed server use update-qeli-server.sh; # QELI_FORCE_RECONFIG=1 (below) overrides, keeping a timestamped backup. # # After it finishes you only paste/scan a connection string into the app. # # Usage (run as root — directly, or via sudo if you have it; sudo is NOT required # and is never installed): # ./install-qeli-server.sh [PUBLIC_HOST] # when already root # sudo ./install-qeli-server.sh [PUBLIC_HOST] # when sudo is available # PUBLIC_HOST Address clients connect to (IP or hostname). If omitted, the # public IP is auto-detected — pass it explicitly if your box has # separate inbound/outbound IPs or you use a domain. # QELI_PROFILE Optional. Pick the profile non-interactively (skips the prompt): # QELI_PROFILE=reality-tls | fake-tls | udp-quic. For curl|bash / automation. # QELI_PORT Optional. Pick the listen port non-interactively (default 443; # 1-65535, and not 8080 which the web panel uses). udp-quic listens on UDP. # QELI_BIN Optional. Path to a prebuilt qeli binary — install from it and # reproduce the .deb layout instead of downloading a .deb. # QELI_SRC Optional. Path to a repo checkout; with QELI_BIN, copy the exact # unit / examples from it (offline). Without it they are taken from the # SHA256-verified .deb of the release that matches the binary. # QELI_REF Optional. Release tag to take that unit / those examples from # (default: v). # QELI_RUN_AS Optional. OS user the service runs as: `qeli` (default, # unprivileged, least-privilege) or `root`. `root` removes privilege # separation — only pick it when the qeli user cannot work (a kernel/ # container without ambient capabilities) or to avoid the /etc/qeli # ownership + polkit setup. Applied via `qeli set-service-user`. # QELI_FORCE_RECONFIG=1 # Required to run against an EXISTING /etc/qeli/server.conf. The # installer REWRITES that file from scratch and mints a new REALITY # short_id, which kills every qeli:// link already handed out. With # the opt-in set, the old config is backed up with a timestamp first. # QELI_PANEL_PUBLIC=1 # Publish the web panel on 0.0.0.0 instead of loopback. REQUIRES # QELI_PANEL_ALLOWED_IPS= (the panel's source-IP allowlist). # Default: loopback only — reach it over an SSH tunnel. # set -euo pipefail # Everything this script creates is either a secret or config for a secret, so default to # owner-only and widen deliberately where a file must be readable (e.g. /etc/qeli itself, # chmod 755 below). Without this the umask decided: on a default 022 shell, server.conf — # which carries the panel's Argon2id hash and every profile's settings — landed 0644, and # `qeli set-web-password` only rewrites the file, it does not narrow the mode. # (Audit 2026-08-04.) umask 077 # Clean up on ANY exit, not just the happy path. # # The downloaded .deb and the directory `dpkg-deb -x` unpacks it into were removed only on # the paths that reached the removal statements; `set -e`, a failed dpkg, or the operator # pressing Ctrl-C left both behind in /tmp — a world-listable directory holding an unpacked # copy of the package tree, once per attempt. (Audit 2026-08-04.) QELI_TMP_PATHS=() cleanup_tmp() { local p for p in "${QELI_TMP_PATHS[@]:-}"; do [ -n "$p" ] && rm -rf -- "$p" 2>/dev/null || true done } trap cleanup_tmp EXIT trap 'exit 130' INT trap 'exit 143' TERM REPO="litvinovtd/qeli" PROFILE="" # chosen interactively below (or non-interactively via QELI_PROFILE) PORT=443 # default listen port; overridable via QELI_PORT / the prompt below PANEL_PORT=8080 # web admin panel — reserved (the VPN port cannot reuse it) NUM_USERS=5 USER_PREFIX="phone" CONF="/etc/qeli/server.conf" EXAMPLE="/etc/qeli/server-multiprofile.conf.example" LINKS_DIR="/etc/qeli/client-links" RUN_STAMP="$(date +%Y%m%d-%H%M%S)" # suffix for every backup this run makes log(){ printf '\n\033[1;36m== %s\033[0m\n' "$*"; } die(){ printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; } warn(){ printf '\033[1;33mWARNING: %s\033[0m\n' "$*" >&2; } # State paths are writable by the service; refuse links/nonregular names before # root writes. Repeat users validation at creation, never open its final name. _check_install_state(){ local state file for state in /etc/qeli /var/log/qeli /var/lib/qeli /etc/qeli/identity /etc/qeli/client-links; do if [ -L "$state" ] || { [ -e "$state" ] && [ ! -d "$state" ]; }; then die "refusing linked/non-directory state path: $state" fi done for file in "$CONF" "${FINAL_CONF:-$CONF}" /etc/qeli/users.conf "${LINKS_DIR}/CONNECTION-STRINGS.txt"; do if [ -L "$file" ] || { [ -e "$file" ] && [ ! -f "$file" ]; }; then die "refusing linked/nonregular state file: $file" fi done _check_perf_state local i for ((i=1; i<=NUM_USERS; i++)); do _check_private_file "${LINKS_DIR}/${USER_PREFIX}${i}.qeli" done } _ensure_users_file(){ local dest=/etc/qeli/users.conf tmp if [ -L "$dest" ] || { [ -e "$dest" ] && [ ! -f "$dest" ]; }; then die "refusing linked/nonregular users file: $dest" fi [ ! -f "$dest" ] || return 0 tmp="$(mktemp "${dest}.qeli-XXXXXX")" || return 1 QELI_TMP_PATHS+=("$tmp") if ! ln -T -- "$tmp" "$dest" 2>/dev/null; then [ ! -L "$dest" ] && [ -f "$dest" ] || die "could not publish empty users file: $dest" fi rm -f -- "$tmp" } # true iff $1 is a decimal port in 1..65535 _valid_port(){ case "$1" in ''|*[!0-9]*) return 1 ;; esac; [ "$1" -ge 1 ] && [ "$1" -le 65535 ]; } # Discover the interface and source address the kernel would use for public IPv6. # A ULA or link-local address is deliberately insufficient: NAT66 is enabled only # for a 2000::/3 GUA on an interface that also owns an IPv6 default route. Exclude # documentation, transition and benchmarking ranges that also sit inside 2000::/3. _native_public_ipv6_egress(){ local route_line route_dev route_src command -v ip6tables >/dev/null 2>&1 || return 1 ip6tables -t nat -S >/dev/null 2>&1 || return 1 route_line="$(ip -6 route get 2606:4700:4700::1111 2>/dev/null | head -n 1)" || return 1 route_dev="$(printf '%s\n' "$route_line" | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')" route_src="$(printf '%s\n' "$route_line" | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}' | tr '[:upper:]' '[:lower:]')" [ -n "$route_dev" ] && [ -n "$route_src" ] || return 1 ip -6 route show default dev "$route_dev" 2>/dev/null | grep -q '^default' || return 1 case "$route_src" in 2*|3*) ;; *) return 1 ;; esac case "$route_src" in 2001:db8:*|2001::*|2001:0:*|2001:2:*|2001:10:*|2001:20:*|2002:*|3fff:*) return 1 ;; esac IPV6_WAN_IF="$route_dev" IPV6_WAN_ADDR="$route_src" } # Print the digest for a bare asset name from a GNU sha256sum-format file. Strip a # possible CR left by a Windows-authored CRLF file: otherwise awk treats it as part # of field 2 and a correctly listed package looks absent on Linux. _checksum_for(){ awk -v n="$2" '{ sub(/\r$/, "", $2); if ($2 == n) { print $1; exit } }' "$1" } # ── obtaining the packaged unit / config examples for the QELI_BIN path ─────── # These files used to be pulled with a bare `curl https://raw.githubusercontent.com/ # /main/` and written straight to /lib/systemd/system/qeli.service and # /etc/polkit-1/rules.d/ — no integrity check at all, off a MOVING branch, while the # .deb path two screens down is fail-closed on SHA256. So the unit that runs as root # could differ from the binary's release, and a bad response was installed verbatim. # Now there are exactly two sources, both verifiable (Audit 2026-07-27, O2): # • QELI_SRC= — the operator's own files (offline / air-gapped), # • otherwise the .deb of the release matching the installed binary, downloaded and # SHA256-verified against that release's SHA256SUMS, then unpacked. Same artifact # the .deb path installs, so the unit always matches the binary. PKG_PAYLOAD="" # unpacked .deb tree, populated on demand PKG_PAYLOAD_TRIED=0 # Populate $PKG_PAYLOAD from the release .deb matching the installed binary. Fails # (non-zero) rather than falling back to anything unverified. _ensure_pkg_payload(){ [ -z "$PKG_PAYLOAD" ] || return 0 [ "$PKG_PAYLOAD_TRIED" -eq 0 ] || return 1 PKG_PAYLOAD_TRIED=1 command -v dpkg-deb >/dev/null 2>&1 || { warn "dpkg-deb is missing — cannot unpack the release package."; return 1; } local ref ver json deb_url sha_url tmp_deb tmp_sha want got dest arch pkg_name pkg_arch ref="${QELI_REF:-}" if [ -z "$ref" ]; then # Read the supplied binary before publishing it; an installed older binary # must not select the payload for this transaction. ver="$("$QELI_BIN" --version 2>/dev/null | awk '{print $2}')" [ -n "$ver" ] || { warn "cannot read the version from 'qeli --version' — pass QELI_REF= or QELI_SRC=."; return 1; } ref="v${ver}" fi echo " taking the unit + config examples from release ${ref} (SHA256-verified)" json="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/tags/${ref}" 2>/dev/null || true)" [ -n "$json" ] || { warn "release ${ref} not found on GitHub — pass QELI_SRC= (offline) or QELI_REF=."; return 1; } arch="$(dpkg --print-architecture)" || return 1 deb_url="$(printf '%s' "$json" | jq -r --arg arch "$arch" '.assets[]? | select(.name|endswith("_" + $arch + ".deb")) | .browser_download_url' | head -n1)" sha_url="$(printf '%s' "$json" | jq -r '.assets[]? | select(.name=="SHA256SUMS") | .browser_download_url' | head -n1)" [ -n "$deb_url" ] || { warn "release ${ref} publishes no .deb — pass QELI_SRC=."; return 1; } [ -n "$sha_url" ] || { warn "release ${ref} publishes no SHA256SUMS — refusing to install an unverifiable systemd unit. Pass QELI_SRC=."; return 1; } tmp_deb="$(mktemp --suffix=.deb)"; tmp_sha="$(mktemp)" # Register both for the EXIT trap so an abort (set -e, Ctrl-C, a failed dpkg) does not # strand them in /tmp. The explicit rm calls below stay: they free the space earlier. QELI_TMP_PATHS+=("$tmp_deb" "$tmp_sha") if ! curl -fL --retry 3 -o "$tmp_deb" "$deb_url" || ! curl -fL --retry 3 -o "$tmp_sha" "$sha_url"; then rm -f "$tmp_deb" "$tmp_sha"; warn "download from release ${ref} failed."; return 1 fi want="$(_checksum_for "$tmp_sha" "$(basename "$deb_url")")" got="$(sha256sum "$tmp_deb" | awk '{print $1}')" rm -f "$tmp_sha" if [ -z "$want" ] || [ "$want" != "$got" ]; then rm -f "$tmp_deb" warn "SHA256 check failed for the ${ref} package (want '${want:-}', got ${got}) — refusing to use it." return 1 fi if [ "${QELI_VERIFY_ATTESTATION:-0}" = 1 ]; then command -v gh >/dev/null 2>&1 || { warn "QELI_VERIFY_ATTESTATION=1 requires gh for the release payload."; return 1; } gh attestation verify "$tmp_deb" --repo "$REPO" >/dev/null 2>&1 \ || { warn "release payload attestation verification failed."; return 1; } fi pkg_name="$(dpkg-deb -f "$tmp_deb" Package)" || return 1 pkg_arch="$(dpkg-deb -f "$tmp_deb" Architecture)" || return 1 if [ "$pkg_name" != qeli ] || { [ "$pkg_arch" != "$arch" ] && [ "$pkg_arch" != all ]; }; then warn "release payload must be a qeli package for $arch (or all)." return 1 fi dest="$(mktemp -d)" QELI_TMP_PATHS+=("$dest") if ! dpkg-deb -x "$tmp_deb" "$dest" 2>/dev/null; then rm -rf "$tmp_deb" "$dest"; warn "could not unpack the ${ref} package."; return 1 fi rm -f "$tmp_deb" PKG_PAYLOAD="$dest" } # Install one packaged file. $1 = path inside a repo checkout, $2 = path inside the # .deb payload, $3 = destination. Returns non-zero when neither source has it. _pkg_file(){ local rel="$1" pkgrel="$2" dest="$3" if [ -n "${QELI_SRC:-}" ]; then [ -f "${QELI_SRC}/${rel}" ] || return 1 install -m644 "${QELI_SRC}/${rel}" "$dest" || return 1 return 0 fi _ensure_pkg_payload || return 1 [ -f "${PKG_PAYLOAD}/${pkgrel}" ] || return 1 install -m644 "${PKG_PAYLOAD}/${pkgrel}" "$dest" } # Set `KEY = VALUE` inside the [web] section of $CONF: drop any existing definition # (commented-out ones included) and write ours, appending when the section has none. # # Written with awk over a temp file instead of `sed -i "s|^…|key = ${VALUE}|"` because # the values here are not trusted: public_host comes from $1 or from a third-party echo # service, and a single `|` in it ended the s||| command and turned the remainder into # further sed commands run against the server config, as root. awk takes the value via # -v, so it is data and can never become syntax. It also replaces the pair of sed # expressions that used to enable TLS, one of which could never match (the example line # is `# tls = true # panel then speaks HTTPS …`, so a `$` anchor after `true` # fails). (Audit 2026-07-27, O3/O4) _conf_web_set(){ local key="$1" val="$2" tmp _check_private_file "$CONF" tmp="$(mktemp "${CONF}.qeli-XXXXXX")" || die "could not stage web config." QELI_TMP_PATHS+=("$tmp") awk -v k="$key" -v v="$val" ' BEGIN { insec = 0; done = 0 } /^\[/ { insec = ($0 == "[web]") print if (insec && !done) { print k " = " v; done = 1 } next } insec && $0 ~ ("^[[:space:]]*#?[[:space:]]*" k "[[:space:]]*=") { next } { print } END { if (!done) { print ""; print "[web]"; print k " = " v } } ' "$CONF" > "$tmp" || die "failed to set web.${key} in ${CONF}." # Never let a failed rewrite truncate the config we are standing on. [ -s "$tmp" ] || { rm -f "$tmp"; die "failed to set web.${key} in ${CONF}."; } chown --reference="$CONF" -- "$tmp" || die "could not preserve config ownership." _publish_private_file "$tmp" "$CONF" } # Install from a prebuilt binary ($QELI_BIN) and reproduce EXACTLY what the .deb sets # up — the binary, the `qeli` system user, /etc/qeli + /var/{log,lib}/qeli, the shipped # *.conf.example files, an empty users.conf, the systemd unit, ownership, and the polkit # rule. Used instead of downloading the .deb (build-from-source or air-gapped installs). # Point QELI_SRC at a repo checkout to copy the exact artifacts offline; otherwise they # come from the SHA256-verified .deb of the matching release (see _ensure_pkg_payload). _repair_service_state_ownership(){ _check_install_state # Receipts/journals and reserved backups are administrator data, not daemon state. find -P /etc/qeli -path /etc/qeli/client-links -prune -o -name '*.bak-*' -prune \ -o -exec chown -h qeli:qeli '{}' + || die "could not repair qeli state ownership." if [ -d /etc/qeli/client-links ]; then chown -h root:root /etc/qeli/client-links chmod 700 /etc/qeli/client-links fi } from_source_install(){ log "Installing from binary ${QELI_BIN} (reproducing the .deb layout)" [ -f "$QELI_BIN" ] && [ -x "$QELI_BIN" ] || die "QELI_BIN=$QELI_BIN is not a regular executable file." _check_install_state # Admit the complete payload before replacing the installed binary or state. local stage name stage="$(mktemp -d)" QELI_TMP_PATHS+=("$stage") for name in server server-multiprofile server-ipv6 server-maxobf users client client-reality client-maxobf; do _pkg_file "qeli/config/${name}.conf" "etc/qeli/${name}.conf.example" "$stage/${name}.conf.example" \ || die "could not obtain a VERIFIED ${name}.conf example (pass QELI_SRC= or QELI_REF=)." done _pkg_file qeli/debian/qeli.service lib/systemd/system/qeli.service "$stage/qeli.service" \ || die "could not obtain a VERIFIED qeli.service unit." _pkg_file qeli/debian/49-qeli.rules etc/polkit-1/rules.d/49-qeli.rules "$stage/49-qeli.rules" \ || die "could not obtain a VERIFIED polkit rule." install -o root -g root -m755 "$QELI_BIN" /usr/bin/qeli setcap -r /usr/bin/qeli 2>/dev/null || true if ! getent passwd qeli >/dev/null 2>&1; then adduser --system --group --no-create-home qeli 2>/dev/null \ || useradd --system --user-group --no-create-home --shell /usr/sbin/nologin qeli fi mkdir -p /etc/qeli /var/log/qeli /var/lib/qeli for name in server server-multiprofile server-ipv6 server-maxobf users client client-reality client-maxobf; do install -o root -g root -m644 "$stage/${name}.conf.example" "/etc/qeli/${name}.conf.example" done _ensure_users_file install -d /lib/systemd/system /etc/polkit-1/rules.d install -o root -g root -m644 "$stage/qeli.service" /lib/systemd/system/qeli.service install -o root -g root -m644 "$stage/49-qeli.rules" /etc/polkit-1/rules.d/49-qeli.rules _repair_service_state_ownership chown -h -R -P qeli:qeli /var/log/qeli /var/lib/qeli chmod 755 /etc/qeli systemctl daemon-reload 2>/dev/null || true command -v qeli >/dev/null || die "qeli is not on PATH after install." rm -rf -- "$stage" [ -z "$PKG_PAYLOAD" ] || rm -rf -- "$PKG_PAYLOAD" echo " from-binary install complete: binary, user, dirs, examples, unit, polkit rule." } # Install only missing OS prerequisites. Local payload + explicit host needs no # curl/jq/CA download setup; an offline run still requires existing package deps. _ensure_installer_deps(){ local remote=0 cmd pkg missing=() checks=() if { [ -z "${QELI_BIN:-}" ] && [ -z "${QELI_DEB:-}" ]; } \ || { [ -n "${QELI_BIN:-}" ] && [ -z "${QELI_SRC:-}" ]; }; then remote=1 fi for cmd in iptables ip openssl; do case "$cmd" in iptables) pkg=iptables ;; ip) pkg=iproute2 ;; openssl) pkg=openssl ;; esac checks+=("$cmd") command -v "$cmd" >/dev/null 2>&1 || missing+=("$pkg") done if ! command -v adduser >/dev/null 2>&1 && ! command -v useradd >/dev/null 2>&1; then checks+=(adduser); missing+=(adduser) fi if [ "$remote" = 1 ] || [ -z "${PUBLIC_HOST:-}" ]; then checks+=(curl) command -v curl >/dev/null 2>&1 || missing+=(curl) [ -s /etc/ssl/certs/ca-certificates.crt ] || missing+=(ca-certificates) fi if [ "$remote" = 1 ]; then checks+=(jq) command -v jq >/dev/null 2>&1 || missing+=(jq) fi if [ "${#missing[@]}" -gt 0 ]; then log "Installing missing dependencies: ${missing[*]}" apt-get update -y || die "dependency index refresh failed; qeli was not installed." apt-get install -y --no-install-recommends "${missing[@]}" \ || die "dependency installation failed; qeli was not installed." else log "OS dependencies already available; skipping apt dependency setup" fi for cmd in "${checks[@]}"; do command -v "$cmd" >/dev/null 2>&1 || die "required dependency command is unavailable: $cmd" done if { [ "$remote" = 1 ] || [ -z "${PUBLIC_HOST:-}" ]; } \ && [ ! -s /etc/ssl/certs/ca-certificates.crt ]; then die "CA certificate bundle is unavailable after dependency setup." fi } _validate_install_deb(){ local package="$1" name name="$(dpkg-deb -f "$package" Package 2>/dev/null)" || die "cannot read Debian package metadata: $package" [ "$name" = qeli ] || die "expected Package=qeli, got ${name}." DEB_ARCH="$(dpkg-deb -f "$package" Architecture)" || die "cannot read package architecture." HOST_DEB_ARCH="$(dpkg --print-architecture)" || die "cannot read host architecture." case "$DEB_ARCH" in "$HOST_DEB_ARCH"|all) ;; *) die "package architecture ${DEB_ARCH} is incompatible with host architecture ${HOST_DEB_ARCH}." ;; esac DEB_VERSION="$(dpkg-deb -f "$package" Version)" || die "cannot read package version." [ -n "$DEB_VERSION" ] || die "package version is empty." } _install_verified_deb(){ local package="$1" installed _validate_install_deb "$package" if [ "${QELI_VERIFY_ATTESTATION:-0}" = 1 ]; then command -v gh >/dev/null 2>&1 || die "QELI_VERIFY_ATTESTATION=1 requires gh; refusing package installation." gh attestation verify "$package" --repo "$REPO" >/dev/null 2>&1 \ || die "package attestation verification FAILED; refusing installation." fi if ! { apt-get install -y --no-install-recommends "$package" \ || { dpkg -i "$package" || true; apt-get install -y --no-install-recommends -f; }; }; then die "package installation failed; no config was generated." fi installed="$(dpkg-query -W -f='${Status} ${Version} ${Architecture}' qeli 2>/dev/null || true)" [ "$installed" = "install ok installed ${DEB_VERSION} ${DEB_ARCH}" ] \ || die "package installation did not configure the requested qeli --version/architecture; no config was generated." } # Root writes must never open a final secret/config name for truncation. # Pathname guards reject static unsafe entries; same-directory rename breaks hard # links and does not follow the destination. Ancestor replacement races remain. _check_private_file(){ local dest="$1" if [ -L "$dest" ] || { [ -e "$dest" ] && [ ! -f "$dest" ]; }; then die "refusing linked/nonregular private file: $dest" fi } _publish_private_file(){ local tmp="$1" dest="$2" _check_private_file "$dest" chmod 600 -- "$tmp" || die "could not make private temporary file: $tmp" mv -T -- "$tmp" "$dest" || die "could not publish private file: $dest" } _write_private_file(){ local dest="$1" tmp _check_private_file "$dest" tmp="$(mktemp "${dest}.qeli-XXXXXX")" || die "could not stage private file: $dest" QELI_TMP_PATHS+=("$tmp") cat > "$tmp" || die "could not write private file: $dest" _publish_private_file "$tmp" "$dest" } _backup_private_file(){ local file="$1" backup _check_private_file "$file" [ -f "$file" ] || return 0 backup="$(mktemp "${file}.bak-${RUN_STAMP}-XXXXXX")" || die "could not reserve backup: $file" if ! install -o root -g root -m 600 -- "$file" "$backup"; then rm -f -- "$backup" die "could not back up private file: $file" fi printf '%s\n' "$backup" } _create_client_links(){ local i U P ADD_OUT LINK kept_link summary_tmp journal journal_fd _check_install_state mkdir -p "$LINKS_DIR"; chmod 700 "$LINKS_DIR" _ensure_users_file SUMMARY="${LINKS_DIR}/CONNECTION-STRINGS.txt" SUMMARY_BAK="$(_backup_private_file "$SUMMARY")" || die "could not back up connection-string list." [ -z "$SUMMARY_BAK" ] || echo " previous connection-string list backed up → ${SUMMARY_BAK}" summary_tmp="$(mktemp "${SUMMARY}.qeli-XXXXXX")" || die "could not stage connection-string list." QELI_TMP_PATHS+=("$summary_tmp") # Retain this file on failure: add-client may commit the user before returning # a link/error. Journal the password first, while it is still recoverable. journal="$(mktemp "${LINKS_DIR}/INSTALL-PENDING-${RUN_STAMP}-XXXXXX")" || die "could not create pending-credentials journal." exec {journal_fd}>"$journal" printf 'Unfinished installer credentials; check users.conf before recovery.\n\n' >&"$journal_fd" echo " pending credentials on failure → ${journal}" for ((i=1; i<=NUM_USERS; i++)); do U="${USER_PREFIX}${i}" if grep -q "^\[user:${U}\]" /etc/qeli/users.conf 2>/dev/null; then kept_link="${LINKS_DIR}/${U}.qeli" if [ -f "$kept_link" ]; then # Preserve bytes, but make this generated secret private without chmod # following a hard link into an unrelated file. _write_private_file "$kept_link" < "$kept_link" fi echo " = ${U} already exists — kept (password unchanged)" printf 'user: %s\npass: \nlink: \n\n' \ "$U" "${SUMMARY_BAK:-the previous install or INSTALL-PENDING journal}" >> "$summary_tmp" continue fi P="$(openssl rand -hex 12)" || die "password generation failed; pending credentials: $journal" [ -n "$P" ] || die "password generation returned empty output; pending credentials: $journal" printf 'user: %s\npass: %s\nstatus: pending — account may already exist\n' "$U" "$P" >&"$journal_fd" \ || die "could not journal password; no add-client call for $U." if ! ADD_OUT="$(printf '%s' "$P" | qeli add-client "$U" --password-stdin --link \ --host "${PUBLIC_AUTHORITY_HOST}:${PORT}" --link-profile "$PROFILE" \ --config "$CONF" 2>&1)"; then printf '%s\n' "$ADD_OUT" >&2 die "add-client failed for ${U}; pending credentials: $journal" fi LINK="$(printf '%s\n' "$ADD_OUT" | grep -m1 '^qeli://' || true)" [ -n "$LINK" ] || die "add-client did not return a link for $U; pending credentials: $journal" printf 'link: %s\n\n' "$LINK" >&"$journal_fd" || die "could not journal link; pending credentials: $journal" _write_private_file "${LINKS_DIR}/${U}.qeli" <<< "$LINK" printf 'user: %s\npass: %s\nlink: %s\n\n' "$U" "$P" "$LINK" >> "$summary_tmp" \ || die "could not stage connection-string list; pending credentials: $journal" echo " + ${U}" done _publish_private_file "$summary_tmp" "$SUMMARY" exec {journal_fd}>&- rm -f -- "$journal" chown -h -R -P qeli:qeli /etc/qeli/users.conf 2>/dev/null || true } _validate_public_host(){ # Validate the moment it is known, BEFORE it reaches a config file or a share link. # The value is either $1 or whatever api.ipify.org / ifconfig.me / icanhazip.com # answered, and it used to be spliced straight into `sed -i "s|…|public_host = $X|"` — # so a `|` in it closed the s||| command and everything after it executed as further # sed commands against server.conf, as root. Accept only characters an IP or hostname # can legitimately contain, and bound the length. (Audit 2026-07-27, O3) case "$PUBLIC_HOST" in *[!A-Za-z0-9._:-]*) die "PUBLIC_HOST '${PUBLIC_HOST}' contains characters that cannot appear in an IP or hostname — pass a clean address as the first argument." ;; -*) die "PUBLIC_HOST '${PUBLIC_HOST}' starts with '-' — that is not an address." ;; esac [ "${#PUBLIC_HOST}" -le 253 ] || die "PUBLIC_HOST is ${#PUBLIC_HOST} characters long — no hostname is (max 253)." } _validate_installer_options(){ local flag value for flag in QELI_FORCE_RECONFIG QELI_PANEL_PUBLIC QELI_VERIFY_ATTESTATION QELI_ALLOW_UNVERIFIED; do value="${!flag:-0}" case "$value" in 0|1) ;; *) die "$flag must be 0 or 1 (got '$value')." ;; esac done [ -z "$PUBLIC_HOST" ] || _validate_public_host PANEL_PUBLIC=0 PANEL_ALLOWED="${QELI_PANEL_ALLOWED_IPS:-}" if [ "${QELI_PANEL_PUBLIC:-0}" = "1" ]; then [ -n "$PANEL_ALLOWED" ] || die "QELI_PANEL_PUBLIC=1 needs QELI_PANEL_ALLOWED_IPS= — a public panel needs an explicit source allowlist." # Same reasoning as PUBLIC_HOST: this value lands in the config, so it is data only. # Comma-separated, no spaces (e.g. QELI_PANEL_ALLOWED_IPS=203.0.113.4,198.51.100.0/24). case "$PANEL_ALLOWED" in *[!A-Za-z0-9./,:-]*) die "QELI_PANEL_ALLOWED_IPS must be a comma-separated list of addresses/CIDRs with no spaces (got '${PANEL_ALLOWED}')." ;; esac PANEL_PUBLIC=1 fi } _resolve_public_host(){ if [ -z "$PUBLIC_HOST" ]; then # 1) External echo services — authoritative public IP (esp. behind NAT). Try a # few, each time-bounded so a blocked/unreachable one can't hang the install. PUBLIC_HOST="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)" [ -n "$PUBLIC_HOST" ] || PUBLIC_HOST="$(curl -fsS --max-time 5 https://ifconfig.me 2>/dev/null || true)" [ -n "$PUBLIC_HOST" ] || PUBLIC_HOST="$(curl -fsS --max-time 5 https://icanhazip.com 2>/dev/null || true)" # 2) Local fallback — src address of the default route. Works even with a /32 WAN # IP (as many cloud VMs have). On a NAT'd box this is the PRIVATE IP, so warn. if [ -z "$PUBLIC_HOST" ]; then PUBLIC_HOST="$(ip -4 route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n1)" [ -n "$PUBLIC_HOST" ] && echo " (external lookup failed — using the local WAN address ${PUBLIC_HOST}; if this box is behind NAT, re-run with the real public IP)" fi PUBLIC_HOST="$(printf '%s' "$PUBLIC_HOST" | tr -d '[:space:]')" echo " Auto-detected public IP: ${PUBLIC_HOST:-}" echo " (Separate inbound/outbound IPs or a domain? Re-run with it as an argument.)" fi [ -n "$PUBLIC_HOST" ] || die "could not determine PUBLIC_HOST — pass it as an argument." _validate_public_host # Host:port authorities must bracket IPv6 literals (RFC 3986). Keep public_host itself bare: # qeli's config/share codec owns authority parsing and adds brackets where required. PUBLIC_AUTHORITY_HOST="$PUBLIC_HOST" PUBLIC_PANEL_BIND="0.0.0.0" ENABLE_IPV6_LISTENER=0 case "$PUBLIC_HOST" in *:*) PUBLIC_AUTHORITY_HOST="[${PUBLIC_HOST}]" PUBLIC_PANEL_BIND="::" ENABLE_IPV6_LISTENER=1 ;; esac # A hostname can gain/use an AAAA record without changing the installed config. When this # kernel has IPv6 enabled, make Quick Start dual-carrier-ready even if PUBLIC_HOST itself is # a DNS name (or today's auto-detected address happened to be IPv4). The second socket is # V6ONLY, so it neither steals nor duplicates the primary IPv4 listener. An explicit IPv6 # literal still requests this listener even on a misconfigured IPv6-disabled host, making the # final service start fail honestly instead of printing an unusable connection link. if [ -s /proc/net/if_inet6 ]; then ENABLE_IPV6_LISTENER=1 fi } _prepare_profile_config(){ local count _check_private_file "$CONF" [ -f "$EXAMPLE" ] && [ -r "$EXAMPLE" ] || die "profile example is not readable: $EXAMPLE" count="$(grep -Fxc -- "[profile:${PROFILE}]" "$EXAMPLE" || true)" [ "$count" = 1 ] || die "expected exactly one [profile:${PROFILE}] in $EXAMPLE, got ${count:-none}." FINAL_CONF="$CONF" CONF="$(mktemp "${FINAL_CONF}.qeli-XXXXXX")" || die "could not stage server config." # Real CLI writers create a persistent sibling lock for this private stage. # All synchronous writers finish before EXIT; retire only this unique stage lock. QELI_TMP_PATHS+=("$CONF" "$CONF.lock") awk '/^\[profile:/{exit} {print}' "$EXAMPLE" > "$CONF" || die "could not stage global config sections." # Add missing TCP defaults even when the packaged example predates OS-managed # buffers. Preserve explicit example overrides; never migrate a live config. awk -v p="[profile:${PROFILE}]" -v mode="$PROFILE" ' FNR==NR { if ($0==p) { selected=1; next } if (/^\[/) selected=0 if (selected && /^[[:space:]]*perf\.tcp\.send_buffer_size[[:space:]]*=/) send=1 if (selected && /^[[:space:]]*perf\.tcp\.recv_buffer_size[[:space:]]*=/) recv=1 next } $0==p { f=1; print if (mode!="udp-quic") { if (!send) print "perf.tcp.send_buffer_size = 0" if (!recv) print "perf.tcp.recv_buffer_size = 0" } next } /^\[profile:/ { f=0 } f { print } ' "$EXAMPLE" "$EXAMPLE" >> "$CONF" || die "could not stage selected profile." } _commit_profile_config(){ _check_private_file "$FINAL_CONF" CONF_BAK="$(_backup_private_file "$FINAL_CONF")" || die "could not back up server config." [ -z "$CONF_BAK" ] || echo " previous config backed up → ${CONF_BAK}" _publish_private_file "$CONF" "$FINAL_CONF" CONF="$FINAL_CONF" } _check_perf_state(){ local dir file for dir in /etc/sysctl.d /etc/modules-load.d; do if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then die "refusing linked/non-directory tuning path: $dir" fi done for file in /etc/sysctl.d/99-qeli-perf.conf /etc/modules-load.d/qeli-bbr.conf; do _check_private_file "$file" done } _apply_perf_tuning(){ local perf=/etc/sysctl.d/99-qeli-perf.conf module=/etc/modules-load.d/qeli-bbr.conf local key desired previous actual verified=0 failures=0 total=0 item owner group mode metadata _check_perf_state mkdir -p /etc/sysctl.d /etc/modules-load.d "$LINKS_DIR" # Retain the receipt on failure as well as success. Each forced reconfiguration # has its own snapshot; it must not replace the previous run's recovery data. PERF_RECEIPT="$(mktemp -d "${LINKS_DIR}/kernel-tuning-${RUN_STAMP}-XXXXXX")" \ || die "could not reserve kernel tuning recovery directory." chmod 700 "$PERF_RECEIPT" : > "$PERF_RECEIPT/runtime.before" : > "$PERF_RECEIPT/apply.log" : > "$PERF_RECEIPT/restore.txt" for item in "$perf" "$module"; do _check_private_file "$item" if [ -f "$item" ]; then install -o root -g root -m 600 -- "$item" "$PERF_RECEIPT/$(basename "$item").before" metadata="$(stat -c '%u %g %a' -- "$item")" || die "could not read previous tuning file metadata: $item" read -r owner group mode <<< "$metadata" printf 'install -o %s -g %s -m %s -- "%s/%s.before" "%s"\n' \ "$owner" "$group" "$mode" "$PERF_RECEIPT" "$(basename "$item")" "$item" >> "$PERF_RECEIPT/restore.txt" else printf 'rm -f -- "%s"\n' "$item" >> "$PERF_RECEIPT/restore.txt" fi done _perf_sysctl_values > "$PERF_RECEIPT/requested.conf" # Read the old values before any writes. Never change a runtime key whose old # value cannot be saved; the persistent file can still request it after reboot. while IFS='=' read -r key desired; do case "$key" in ''|'#'*) continue ;; esac if previous="$(sysctl -n "$key" 2>> "$PERF_RECEIPT/apply.log")" && [ -n "$previous" ] && [[ "$previous" != *$'\n'* ]]; then printf '%s=%s\n' "$key" "$previous" >> "$PERF_RECEIPT/runtime.before" else printf '%s: previous value unavailable; runtime write skipped\n' "$key" >> "$PERF_RECEIPT/apply.log" fi done < "$PERF_RECEIPT/requested.conf" printf 'sysctl -p "%s/runtime.before"\n' "$PERF_RECEIPT" >> "$PERF_RECEIPT/restore.txt" printf '# Inspect apply.log. Restore only if no later administrator changed these settings.\n# An already loaded tcp_bbr module is left loaded; this is not an installer/service rollback.\n' >> "$PERF_RECEIPT/restore.txt" _write_private_file "$perf" < "$PERF_RECEIPT/requested.conf" _write_private_file "$module" <<< tcp_bbr if ! modprobe tcp_bbr >> "$PERF_RECEIPT/apply.log" 2>&1; then printf 'tcp_bbr module load failed; an existing/built-in implementation may still be usable\n' >> "$PERF_RECEIPT/apply.log" fi while IFS='=' read -r key desired; do case "$key" in ''|'#'*) continue ;; esac total=$((total+1)) if grep -Fq "${key}=" "$PERF_RECEIPT/runtime.before"; then if ! sysctl -w "${key}=${desired}" >> "$PERF_RECEIPT/apply.log" 2>&1; then failures=$((failures+1)) fi else failures=$((failures+1)) fi # Kernel vector values use tabs; compare normalized whitespace, not formatting. actual="$(sysctl -n "$key" 2>> "$PERF_RECEIPT/apply.log" | awk '{$1=$1; print}')" || actual="" if [ "$actual" = "$desired" ]; then verified=$((verified+1)); fi done < "$perf" if [ "$verified" = "$total" ] && [ "$failures" = 0 ]; then SYSCTL_STATUS="kernel tuning applied and verified (${verified}/${total} values)" else SYSCTL_STATUS="kernel tuning incomplete (${verified}/${total} values effective; ${failures} writes unavailable/failed)" warn "${SYSCTL_STATUS}; inspect ${PERF_RECEIPT}/apply.log. Persistent requests remain in ${perf}." fi echo " ${SYSCTL_STATUS}" echo " Previous runtime values/files and restore commands: ${PERF_RECEIPT}" } _perf_sysctl_values(){ cat <<'SYSCTL' # qeli throughput + PMTU tuning; previous values/files are in the per-run receipt. net.core.default_qdisc=fq net.ipv4.tcp_congestion_control=bbr net.core.rmem_max=16777216 net.core.wmem_max=16777216 net.ipv4.tcp_rmem=4096 131072 16777216 net.ipv4.tcp_wmem=4096 65536 16777216 # TCP receive autotuning needs this switch AND OS-managed socket buffers. net.ipv4.tcp_moderate_rcvbuf=1 # Keep tcp_mem (the system-wide page budget) under OS/administrator control. net.ipv4.tcp_mtu_probing=1 # UDP has no kernel receive autotuning; qeli starts at 4 MiB and may grow # within its own RAM budget and rmem_max. Logs show the effective SO_RCVBUF. net.core.rmem_default=4194304 net.core.wmem_default=4194304 net.core.netdev_max_backlog=4000 SYSCTL } # Must run as root. Run it directly as root (no sudo needed), or — if you are a # normal user AND sudo is installed — it re-execs itself under sudo. We never # install sudo: on a root-only box (no sudo) just run it as root. if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then echo "Not root — re-running under sudo…" exec sudo -E bash "$0" "$@" fi die "must run as root, and 'sudo' is not installed. Switch to root and re-run: su -" fi export DEBIAN_FRONTEND=noninteractive _check_install_state if [ -n "${QELI_BIN:-}" ]; then [ -f "$QELI_BIN" ] && [ -x "$QELI_BIN" ] || die "QELI_BIN=$QELI_BIN is not a regular executable file." fi if [ -n "${QELI_DEB:-}" ] && [ ! -f "$QELI_DEB" ]; then die "QELI_DEB does not name a regular package file: $QELI_DEB" fi # Reject a wrong/corrupt local package before dependency or config changes. if [ -n "${QELI_DEB:-}" ] && [ -z "${QELI_BIN:-}" ]; then _validate_install_deb "$QELI_DEB" fi PUBLIC_HOST="${1:-}" # OS user the service will run as (default the unprivileged `qeli`). Validated up front so # a typo fails before we install anything; applied just before the service is started. QELI_RUN_AS="${QELI_RUN_AS:-qeli}" case "$QELI_RUN_AS" in qeli|root) ;; *) die "QELI_RUN_AS must be 'qeli' or 'root' (got '$QELI_RUN_AS')." ;; esac _validate_installer_options # ── pre-flight: never silently destroy a working deployment ───────────────── # This is a FIRST-INSTALL script: it writes /etc/qeli/server.conf from scratch and # mints a fresh REALITY short_id, which invalidates every qeli:// link already handed # out. Re-running it on a configured box used to do exactly that with no check at all, # also wiping anything added by hand (extra profiles, [web] allowed_ips, the panel # password_hash, perf.connection.max_clients) — and then DIE half-way, because # `qeli add-client phone1` refuses to recreate an existing user and the failing # pipeline killed the script under `set -e` before the service was ever restarted. # The box was left with a rewritten config, dead links and the OLD daemon running. # Refuse by default; the opt-in below keeps a timestamped backup of everything it # replaces. (Audit 2026-07-27, K2) if [ -e "$CONF" ]; then if [ "${QELI_FORCE_RECONFIG:-0}" = "1" ]; then warn "QELI_FORCE_RECONFIG=1 — ${CONF} will be BACKED UP and rewritten; existing qeli:// links stop working." else die "${CONF} already exists — this server is already configured, and re-running the installer would rewrite it: a new REALITY short_id (every qeli:// link already issued stops working) and the loss of every hand-made edit. • upgrade the binary, keeping the config: ./update-qeli-server.sh • add a user: qeli add-client --link --host • show the panel/link data again: cat ${LINKS_DIR}/CONNECTION-STRINGS.txt • really reconfigure from scratch (a timestamped backup is kept): QELI_FORCE_RECONFIG=1 $0${1:+ $1}" fi fi # ── 0. choose the profile: reality-tls (default) or fake-tls ──────────────── # Both disguise the tunnel as HTTPS on :443; they differ in HOW: # reality-tls — completes a REAL TLS session with a front site (e.g. www.microsoft.com) # and tunnels inside it. Strongest disguise (a real cert is relayed); # slightly heavier. This is the prod-grade default. # fake-tls — mimics a TLS-1.3 handshake without relaying a real cert. Lighter, # no upstream front dependency; a shade less robust to deep probing. # Priority: $QELI_PROFILE (non-interactive) → terminal prompt → default reality-tls. # Under `curl … | bash` stdin IS the script, so the prompt is read from /dev/tty; with # no controlling terminal at all we fall back to the default (overridable via the env). choose_profile() { local sel="${QELI_PROFILE:-}" if [ -n "$sel" ]; then case "$sel" in reality-tls|reality) PROFILE="reality-tls" ;; fake-tls|fake) PROFILE="fake-tls" ;; udp-quic|quic|udp) PROFILE="udp-quic" ;; 1) PROFILE="reality-tls" ;; 2) PROFILE="fake-tls" ;; 3) PROFILE="udp-quic" ;; *) die "QELI_PROFILE must be 'reality-tls', 'fake-tls' or 'udp-quic' (got '$sel')." ;; esac echo "Profile (from QELI_PROFILE): ${PROFILE}" return fi if [ -r /dev/tty ] && { : < /dev/tty; } 2>/dev/null; then { printf '\n\033[1;36m== Which server profile to install?\033[0m\n' printf ' 1) reality-tls — real TLS to a front site, strongest disguise [default]\n' printf ' 2) fake-tls — TLS-1.3-mimicking handshake, lighter, no front\n' printf ' 3) udp-quic — QUIC/HTTP3-shaped UDP (no TCP-over-TCP; good on lossy/mobile)\n' printf 'Choose [1/2/3] (default 1): ' } > /dev/tty local ans="" read -r ans < /dev/tty || ans="" case "$ans" in 2|fake-tls|fake) PROFILE="fake-tls" ;; 3|udp-quic|quic|udp) PROFILE="udp-quic" ;; ""|1|reality-tls|reality) PROFILE="reality-tls" ;; *) printf 'Unrecognised (%s) — using reality-tls.\n' "$ans" > /dev/tty PROFILE="reality-tls" ;; esac else PROFILE="reality-tls" echo "No terminal for a prompt and no QELI_PROFILE set — defaulting to ${PROFILE}." echo "(Pick fake-tls non-interactively with: QELI_PROFILE=fake-tls $0 …)" fi echo "Selected profile: ${PROFILE}" } choose_profile # ── 0b. choose the listen port (default 443) ──────────────────────────────── # 443 mimics HTTPS and is the recommended choice; some networks prefer 8443/993/etc. # Priority mirrors the profile: $QELI_PORT (non-interactive) → terminal prompt → # default 443. The panel port (8080) is reserved and refused here. choose_port() { local sel="${QELI_PORT:-}" if [ -n "$sel" ]; then _valid_port "$sel" || die "QELI_PORT must be a number 1-65535 (got '$sel')." [ "$sel" -ne "$PANEL_PORT" ] || die "QELI_PORT ${sel} is reserved for the web panel — pick another." PORT="$sel"; echo "Port (from QELI_PORT): ${PORT}"; return fi if [ -r /dev/tty ] && { : < /dev/tty; } 2>/dev/null; then local ans="" while :; do printf 'Listen port [1-65535] (default %s): ' "$PORT" > /dev/tty read -r ans < /dev/tty || ans="" [ -z "$ans" ] && break # empty → keep the default if ! _valid_port "$ans"; then printf 'Not a valid port (1-65535) — try again.\n' > /dev/tty; continue fi if [ "$ans" -eq "$PANEL_PORT" ]; then printf 'Port %s is reserved for the web panel — pick another.\n' "$PANEL_PORT" > /dev/tty; continue fi PORT="$ans"; break done else echo "No terminal for a prompt and no QELI_PORT set — using default port ${PORT}." fi echo "Selected port: ${PORT}" } choose_port # Transport of the chosen profile — udp-* profiles listen on UDP, the rest on TCP. # Drives the (TCP-only) outer MSS clamp below and the firewall hint at the end. case "$PROFILE" in udp-*) TRANSPORT="udp" ;; *) TRANSPORT="tcp" ;; esac TRANSPORT_UC="$(printf '%s' "$TRANSPORT" | tr '[:lower:]' '[:upper:]')" # ── 1. dependencies ───────────────────────────────────────────────────────── _ensure_installer_deps if ! command -v ip6tables >/dev/null 2>&1; then warn "the iptables package did not provide ip6tables; this install will use IPv4 only." fi # ── 2. obtain + install qeli ──────────────────────────────────────────────── # Two ways to get qeli onto the box, both ending in the SAME .deb layout: # • QELI_BIN= → install that prebuilt binary and reproduce the .deb setup # (user, dirs, examples, unit, polkit) — no download needed. # Add QELI_SRC= for a fully offline install. # • otherwise → download + install the .deb from GitHub Releases (default). if [ -n "${QELI_BIN:-}" ]; then from_source_install else # By default: newest GitHub release (the releases are pre-releases, so we read # /releases, not /releases/latest). Override with QELI_DEB= for # an offline / air-gapped install or to pin a specific build. log "Obtaining the qeli .deb" CLEANUP_DEB=0 HOST_DEB_ARCH="$(dpkg --print-architecture)" if [ -n "${QELI_DEB:-}" ]; then echo " using local .deb: $QELI_DEB" TMP_DEB="$QELI_DEB" else SHA_URL="" RELEASES_JSON=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases") DEB_URL=$(printf '%s' "$RELEASES_JSON" | jq -r --arg arch "$HOST_DEB_ARCH" 'map(select(.draft|not)) | .[0].assets[] | select(.name|endswith("_" + $arch + ".deb")) | .browser_download_url' | head -n1) [ -n "$DEB_URL" ] || die "no .deb asset for Debian architecture ${HOST_DEB_ARCH} found in the latest release." # SHA256SUMS asset (published since 0.7.6) — used to verify the download below. SHA_URL=$(printf '%s' "$RELEASES_JSON" | jq -r 'map(select(.draft|not)) | .[0].assets[] | select(.name=="SHA256SUMS") | .browser_download_url' | head -n1) echo " downloading: $DEB_URL" TMP_DEB="$(mktemp --suffix=.deb)"; CLEANUP_DEB=1 QELI_TMP_PATHS+=("$TMP_DEB") curl -fL --retry 3 -o "$TMP_DEB" "$DEB_URL" # Verify the .deb against the release's SHA256SUMS — FAIL CLOSED (S-10). A GitHub # download is trusted only once its SHA256 matches the release sums file; a missing # sums file or an unlisted .deb aborts unless the operator opts out with # QELI_ALLOW_UNVERIFIED=1. A QELI_DEB the operator supplied (local path) is exempt. ALLOW_UNVERIFIED="${QELI_ALLOW_UNVERIFIED:-0}" if [ -n "$SHA_URL" ]; then echo " verifying SHA256" TMP_SHA="$(mktemp)" QELI_TMP_PATHS+=("$TMP_SHA") curl -fL --retry 3 -o "$TMP_SHA" "$SHA_URL" DEB_NAME="$(basename "$DEB_URL")" WANT="$(_checksum_for "$TMP_SHA" "$DEB_NAME")" GOT="$(sha256sum "$TMP_DEB" | awk '{print $1}')" rm -f "$TMP_SHA" if [ -z "$WANT" ]; then if [ "$ALLOW_UNVERIFIED" = "1" ]; then echo " WARNING: $DEB_NAME not listed in SHA256SUMS — installing anyway (QELI_ALLOW_UNVERIFIED=1)" else rm -f "$TMP_DEB" die "$DEB_NAME is not listed in the release SHA256SUMS — refusing to install an unverifiable download. Set QELI_ALLOW_UNVERIFIED=1 to override." fi elif [ "$WANT" != "$GOT" ]; then rm -f "$TMP_DEB" die "SHA256 mismatch for $DEB_NAME (want $WANT, got $GOT) — refusing to install." else echo " SHA256 OK" fi elif [ -z "${QELI_DEB:-}" ]; then # Downloaded from GitHub but the release published NO SHA256SUMS — fail closed. if [ "$ALLOW_UNVERIFIED" = "1" ]; then echo " WARNING: release has no SHA256SUMS — installing unverified (QELI_ALLOW_UNVERIFIED=1)" else rm -f "$TMP_DEB" die "the release publishes no SHA256SUMS — cannot verify the download. Set QELI_ALLOW_UNVERIFIED=1 to override, or pass QELI_DEB=." fi fi fi # ── 3. install the package (pulls iptables / iproute2) ────────────────────── # Never pass a package for another CPU architecture to apt. Besides producing a # confusing dependency failure, choosing the first .deb asset used to install the # wrong package whenever a release carried more than one architecture. Explicit # QELI_DEB paths are checked as well because their file names are not trusted. # --no-install-recommends: the package Recommends systemd-resolved (only useful # for the CLIENT's resolvectl path). A server doesn't need it, and letting apt # pull it in repoints /etc/resolv.conf to the systemd stub mid-install, which can # transiently break DNS (e.g. the public-IP lookup below). Skip it on servers. log "Installing the package" _install_verified_deb "$TMP_DEB" [ "$CLEANUP_DEB" -eq 1 ] && rm -f "$TMP_DEB" fi # end obtain+install (from-binary vs .deb) command -v qeli >/dev/null || die "qeli is not on PATH after install." [ -f "$EXAMPLE" ] || die "$EXAMPLE missing — package too old (need >= 0.7.2)." # ── 4. build server.conf: the selected profile only, from the example ─────── log "Configuring the ${PROFILE} profile on :${PORT}" _resolve_public_host _prepare_profile_config # Force the listener onto :$PORT regardless of the example's per-profile port # (reality-tls already ships on 443; fake-tls ships on 8444 in the example). sed -i "s|^bind.port = .*|bind.port = ${PORT}|" "$CONF" # reality-tls carries a REALITY short_id — give THIS deployment its own random one # (not the example sample). fake-tls has no reality_proxy, so there is nothing to do. if grep -q '^obf.tls.reality_proxy.short_ids' "$CONF"; then SID="$(openssl rand -hex 8)" sed -i "s|^obf.tls.reality_proxy.short_ids = .*|obf.tls.reality_proxy.short_ids = ${SID}|" "$CONF" echo " generated REALITY short_id: ${SID}" fi # Leave IPv4 NAT auto-detection portable. For IPv6, keep dual-stack only when the # selected default-route interface has a real public GUA and ip6tables NAT support. sed -i "/^routing.nat.interface/d" "$CONF" IPV6_WAN_IF="" IPV6_WAN_ADDR="" if _native_public_ipv6_egress; then # RFC4193 locally assigned ULA: fd + 40 random Global-ID bits = one unique /48. # The final /64 subnet ID is different for each shipped profile and is retained. ULA_HEX="$(openssl rand -hex 5)" ULA_SITE="fd${ULA_HEX:0:2}:${ULA_HEX:2:4}:${ULA_HEX:6:4}" sed -i "s|fd71:e1:8000|${ULA_SITE}|g" "$CONF" sed -i "s|^routing.ipv6.interface =.*|routing.ipv6.interface = ${IPV6_WAN_IF}|" "$CONF" echo " IPv6 WAN: ${IPV6_WAN_ADDR} on ${IPV6_WAN_IF}" echo " tunnel ULA site prefix: ${ULA_SITE}::/48 (NAT66 enabled)" else # Do not leave a nominally dual config that cannot egress or program a fail-closed # IPv6 boundary. IPv4 remains fully configured and NAT44 stays enabled. sed -i \ -e 's/^tun.ip_mode = dual$/tun.ip_mode = ipv4/' \ -e '/^tun.ipv6_address =/d' \ -e '/^pool.ipv6.cidr =/d' \ -e 's/^routing.ipv6.mode = nat66$/routing.ipv6.mode = off/' \ -e '/^routing.ipv6.interface =/d' \ -e '/^dns.listen_ipv6 =/d' \ "$CONF" warn "no usable public IPv6 default-route + ip6tables NAT path; installed an IPv4-only active profile." fi # ── 5. server identity key (created + printed; pinned automatically in the link) log "Generating the server identity key" qeli show-identity --config "$CONF" PUBKEY=$(qeli show-identity --config "$CONF" 2>/dev/null | awk -v p="$PROFILE" '$1==p{print $NF}') chown -h -R -P qeli:qeli "$CONF" /etc/qeli/identity 2>/dev/null || true # ── 6. public host clients will connect to ────────────────────────────────── # Public endpoint was resolved/validated before preparing the staged INI. # The packaged multiprofile example uses an IPv4 primary listener. An IPv6 literal in the # generated link is useful only if the selected profile also owns an IPv6 socket; qeli makes # that socket V6ONLY deliberately, so 0.0.0.0 cannot accept it. Keep the IPv4 listener for # dual-carrier reachability and add/update the independent wildcard IPv6 listener. Do not add # a duplicate if a future packaged example already uses :: as its primary bind. if [ "$ENABLE_IPV6_LISTENER" = "1" ] && ! grep -Eq '^bind\.address = (::|\[::\])$' "$CONF"; then if grep -q '^listen = \[::\]:' "$CONF"; then sed -i "s|^listen = \[::\]:.*|listen = [::]:${PORT}|" "$CONF" else sed -i "/^bind.port = /a listen = [::]:${PORT}" "$CONF" fi fi # ── 7. create users + save ready qeli:// connection strings ───────────────── log "Creating ${NUM_USERS} users + connection strings" _create_client_links # ── 8. OS tuning for mobile / LTE paths (PMTU black-hole fix) ─────────────── # The post-quantum reality-tls / fake-tls ClientHello is one large TCP segment; on # LTE/CGNAT the path MTU is < 1500 and the ICMP "fragmentation needed" is dropped, so # that segment black-holes and the handshake hangs ("works on wired, fails on LTE"). # Fixed here: # • clamp the MSS the server advertises on its listening port (the OUTER handshake — # the in-tunnel vpn+ clamp from routing.nat does NOT cover it), # • enable TCP PMTU probing + BBR/fq (also lifts throughput). # All reversible — see the revert note printed at the end. log "Applying OS tuning (outer MSS clamp + sysctl) for mobile/LTE" # The outer MSS clamp is TCP-only — it fixes the large PQ ClientHello black-holing on # LTE/CGMAT for the TCP wire modes. A udp-quic profile has no outer TCP handshake to # clamp (QUIC handles its own PMTU over UDP), so skip it there. MSS_RULE="" MSS_INPUT_RULE="" MSS6_RULE="" MSS6_INPUT_RULE="" MSS_APPLIED=0 MSS6_APPLIED=0 MSS_ADDED=0 MSS6_ADDED=0 MSS_SUMMARY="" MSS_REVERT="" MSS_LAST_ADDED=0 # The clamp is a PERFORMANCE tweak, never a reason to abandon a half-finished install. # `iptables -t mangle -A OUTPUT $MSS_RULE && echo …` used to run bare under `set -e`, so # on a host without the mangle table (LXC/OpenVZ) or with an nft backend that rejects the # rule, the installer died HERE — config written, users created, service never enabled. # Both the rule and its persistence are best-effort now, and a failure only warns. # (Audit 2026-07-27, O5) apply_mss_clamp(){ local table_cmd="$1" chain="$2" rule="$3" family="$4" direction="$5" mss="$6" MSS_LAST_ADDED=0 command -v "$table_cmd" >/dev/null 2>&1 || return 1 # shellcheck disable=SC2086 # $rule is a deliberate multi-word argument list if "$table_cmd" -t mangle -C "$chain" $rule 2>/dev/null; then echo " ${family} ${direction} MSS clamp already present on :${PORT}"; return 0 fi # shellcheck disable=SC2086 # same if "$table_cmd" -t mangle -A "$chain" $rule 2>/dev/null; then MSS_LAST_ADDED=1 echo " + ${family} ${direction} MSS clamp ${mss} on :${PORT}"; return 0 fi return 1 } record_mss_clamp(){ local summary="$1" revert="$2" added="$3" if [ -n "$MSS_SUMMARY" ]; then MSS_SUMMARY="${MSS_SUMMARY} + ${summary}" else MSS_SUMMARY="$summary" fi # Do not claim ownership of a rule that predated this install. Removing an operator's # existing clamp from the printed Revert command is more destructive than leaving ours. if [ "$added" = "1" ]; then if [ -n "$MSS_REVERT" ]; then MSS_REVERT="${MSS_REVERT} ; ${revert}" else MSS_REVERT="$revert" fi fi } persist_new_ruleset(){ local save_cmd="$1" restore_cmd="$2" destination="$3" family="$4" tmp # Write beside the destination, validate non-empty output, then publish with a hard link. # `ln` is an atomic no-clobber create: even if another firewall manager creates the file # after our caller's -e check, qeli cannot replace it. A failed *-save leaves only a private # temporary file, which is removed instead of becoming the next boot's ruleset. tmp="$(mktemp "${destination}.qeli.XXXXXX")" || { echo " (could not create a temporary ${family} ruleset — clamp is live only)" return 1 } if ! "$save_cmd" >"$tmp" 2>/dev/null || [ ! -s "$tmp" ] \ || ! command -v "$restore_cmd" >/dev/null 2>&1 \ || ! "$restore_cmd" --test <"$tmp" >/dev/null 2>&1; then rm -f "$tmp" echo " (could not generate a complete ${family} ruleset — clamp is live only)" return 1 fi if ! chmod 600 "$tmp" || ! ln "$tmp" "$destination" 2>/dev/null; then rm -f "$tmp" echo " (could not publish ${destination} without overwriting an existing file — clamp is live only)" return 1 fi rm -f "$tmp" return 0 } persist_mss_clamps(){ # NEVER clobber an existing persisted ruleset. `*-save > /etc/iptables/rules.v*` # overwrites whatever iptables-persistent manages with the CURRENT live state, which # silently drops every rule that file loads but the running kernel does not have. # (Audit 2026-07-27, O5) # Do not call `netfilter-persistent save` here: it overwrites existing rules.v4/rules.v6 # with the current live state and can erase administrator rules that are persisted but # temporarily not loaded. A newly created conventional snapshot will be consumed by # iptables-persistent when installed; an existing file stays under its current owner. mkdir -p /etc/iptables 2>/dev/null || true if [ "$MSS_ADDED" = "1" ]; then if [ ! -e /etc/iptables/rules.v4 ]; then persist_new_ruleset iptables-save iptables-restore /etc/iptables/rules.v4 IPv4 || true else echo " (/etc/iptables/rules.v4 exists and is managed elsewhere — NOT overwriting it;" echo " add the IPv4 clamp there yourself if it should survive a reboot)" fi fi if [ "$MSS6_ADDED" = "1" ]; then if [ ! -e /etc/iptables/rules.v6 ]; then if command -v ip6tables-save >/dev/null 2>&1; then persist_new_ruleset ip6tables-save ip6tables-restore /etc/iptables/rules.v6 IPv6 || true else echo " (ip6tables-save is unavailable — the IPv6 clamp will not survive a reboot)" fi else echo " (/etc/iptables/rules.v6 exists and is managed elsewhere — NOT overwriting it;" echo " add the IPv6 clamp there yourself if it should survive a reboot)" fi fi if ! command -v netfilter-persistent >/dev/null 2>&1; then echo " (netfilter-persistent is unavailable — rules.v4/rules.v6 will not be" echo " restored automatically; install iptables-persistent or use your firewall manager)" fi } if [ "$TRANSPORT" = "tcp" ]; then # IPv4 has no protocol-level 1280-byte minimum, but 1280 is the conservative baseline # qeli documents and certifies for mobile/CGNAT paths. 1240 = 1280 - IPv4(20) - TCP(20). # Clamp the MSS from the client's incoming SYN before the local TCP stack consumes it; # this bounds server->client ServerHello segments as well as later application records. MSS_INPUT_RULE="-p tcp --dport ${PORT} --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240" if apply_mss_clamp iptables PREROUTING "$MSS_INPUT_RULE" IPv4 server-to-client 1240; then MSS_APPLIED=1 if [ "$MSS_LAST_ADDED" = "1" ]; then MSS_ADDED=1; fi record_mss_clamp "IPv4 server-to-client MSS 1240" \ "iptables -t mangle -D PREROUTING ${MSS_INPUT_RULE}" "$MSS_LAST_ADDED" else warn "could not install the outer IPv4 server-to-client MSS clamp. The install CONTINUES, but a large post-quantum ServerHello may black-hole. Retry by hand: iptables -t mangle -A PREROUTING ${MSS_INPUT_RULE}" fi # Clamp the server's outgoing SYN-ACK MSS; this bounds the client's PQ ClientHello. MSS_RULE="-p tcp --sport ${PORT} --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1240" if apply_mss_clamp iptables OUTPUT "$MSS_RULE" IPv4 client-to-server 1240; then MSS_APPLIED=1 if [ "$MSS_LAST_ADDED" = "1" ]; then MSS_ADDED=1; fi record_mss_clamp "IPv4 client-to-server MSS 1240" \ "iptables -t mangle -D OUTPUT ${MSS_RULE}" "$MSS_LAST_ADDED" else warn "could not install the outer IPv4 client-to-server MSS clamp (no mangle table on this host, or an nft backend refused the rule). The install CONTINUES — this only means large post-quantum ClientHellos may black-hole on LTE/CGNAT paths. Retry by hand: iptables -t mangle -A OUTPUT ${MSS_RULE}" fi if [ "$ENABLE_IPV6_LISTENER" = "1" ]; then # IPv6's minimum link MTU is 1280; subtract its 40-byte IP and 20-byte TCP headers. MSS6_INPUT_RULE="-p tcp --dport ${PORT} --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220" if apply_mss_clamp ip6tables PREROUTING "$MSS6_INPUT_RULE" IPv6 server-to-client 1220; then MSS6_APPLIED=1 if [ "$MSS_LAST_ADDED" = "1" ]; then MSS6_ADDED=1; fi record_mss_clamp "IPv6 server-to-client MSS 1220" \ "ip6tables -t mangle -D PREROUTING ${MSS6_INPUT_RULE}" "$MSS_LAST_ADDED" else warn "could not install the outer IPv6 server-to-client MSS clamp. IPv4 installation continues, but a large post-quantum ServerHello may black-hole. Retry by hand: ip6tables -t mangle -A PREROUTING ${MSS6_INPUT_RULE}" fi MSS6_RULE="-p tcp --sport ${PORT} --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 1220" if apply_mss_clamp ip6tables OUTPUT "$MSS6_RULE" IPv6 client-to-server 1220; then MSS6_APPLIED=1 if [ "$MSS_LAST_ADDED" = "1" ]; then MSS6_ADDED=1; fi record_mss_clamp "IPv6 client-to-server MSS 1220" \ "ip6tables -t mangle -D OUTPUT ${MSS6_RULE}" "$MSS_LAST_ADDED" else warn "could not install the outer IPv6 client-to-server MSS clamp although an IPv6 listener is enabled. IPv4 installation continues, but large post-quantum ClientHellos may black-hole on minimum-MTU IPv6 paths. Retry by hand: ip6tables -t mangle -A OUTPUT ${MSS6_RULE}" fi fi if [ "$MSS_ADDED" = "1" ] || [ "$MSS6_ADDED" = "1" ]; then persist_mss_clamps fi else echo " udp-quic: UDP transport has no outer TCP handshake — skipping MSS clamp." fi _apply_perf_tuning # ── 8b. web admin panel: enable over HTTPS with a generated password ───────── # BIND POLICY (Audit 2026-07-27, O4). This used to `sed 's/^bind = 127\.0\.0\.1/ # bind = 0.0.0.0/'` unconditionally: every install published an admin panel on the # public internet with NO allowed_ips filter, protected by one generated password — # and, because the sed that was supposed to turn TLS on could never match the example # line, it reached HTTPS only via a fallback insert. One grep matching something else # and the panel would have been on the internet in cleartext. # # The default is now LOOPBACK, which costs an SSH tunnel and removes the whole # exposure. Publishing it is a deliberate act: QELI_PANEL_PUBLIC=1 AND a non-empty # QELI_PANEL_ALLOWED_IPS — a public bind without a source allowlist is refused # outright rather than quietly accepted. TLS is enabled through ONE mechanism # (_conf_web_set), so there is nothing left that can silently fail to match. log "Enabling the web admin panel (HTTPS, generated password)" # Panel opt-in/allowlist input was checked before dependency/config changes. PANEL_PW="$(openssl rand -base64 18 2>/dev/null | tr -dc 'A-Za-z0-9' | head -c 20)" # Password on stdin, not in argv — see the note on add-client above. if [ -n "$PANEL_PW" ] && printf '%s' "$PANEL_PW" \ | qeli set-web-password --password-stdin --config "$CONF" >/dev/null 2>&1; then # set-web-password enabled the panel + wrote username/password_hash. TLS on either # way: even on loopback the password should not cross an unencrypted socket that # any local user could read. _conf_web_set tls true _conf_web_set public_host "$PUBLIC_HOST" # default host for share links/QR if [ "$PANEL_PUBLIC" = "1" ]; then _conf_web_set bind "$PUBLIC_PANEL_BIND" _conf_web_set allowed_ips "$PANEL_ALLOWED" PANEL_URL="https://${PUBLIC_AUTHORITY_HOST}:${PANEL_PORT}" else _conf_web_set bind 127.0.0.1 PANEL_URL="https://127.0.0.1:${PANEL_PORT} (loopback only — tunnel in: ssh -L ${PANEL_PORT}:127.0.0.1:${PANEL_PORT} root@${PUBLIC_HOST})" fi chown qeli:qeli "$CONF" 2>/dev/null || true else PANEL_PW="" PANEL_URL="" echo " (could not set a panel password — admin UI stays disabled; enable later: qeli set-web-password)" fi # ── 9. enable + start ─────────────────────────────────────────────────────── # `enable --now` only STARTS a stopped unit: on a re-run it is a no-op against an # already-active service, so the daemon kept serving the OLD config (old short_id, old # users) while this script printed "Done" and `systemctl is-active` passed on the stale # process. enable + restart makes the running daemon match the file we just wrote. # (Audit 2026-07-27, O1) # Run-as user: default `qeli` (least privilege). QELI_RUN_AS=root writes a systemd # drop-in (via the binary's own helper) BEFORE the first start, so the service comes up # as the chosen user. Reverting later: `qeli set-service-user qeli`. # Validate the FINAL file, after add-client and all web-panel rewrites. Earlier # `show-identity` only parsed the profile subset that existed before those mutations; # it neither rejected unread keys/bad values nor loaded users.conf. Refuse before # touching the running service, so a forced reconfiguration cannot restart a known-bad # config and turn an otherwise recoverable edit into an outage. log "Validating the final server and users configuration" if ! qeli check-config --config "$CONF"; then die "staged configuration validation failed; ${FINAL_CONF} was NOT replaced and qeli was NOT restarted. Users/identity/links/tuning may already have changed; inspect their recovery data before retrying." fi log "Configuring the service user: ${QELI_RUN_AS}" qeli set-service-user "$QELI_RUN_AS" _commit_profile_config log "Starting the service" systemctl enable qeli systemctl restart qeli sleep 2 systemctl is-active --quiet qeli || die "qeli failed to start — see: journalctl -u qeli -e" # ── done ──────────────────────────────────────────────────────────────────── PERF_NOTE="Perf tuning: ${SYSCTL_STATUS} Details and restore instructions: ${PERF_RECEIPT}/restore.txt" if [ "$MSS_APPLIED" = "1" ] || [ "$MSS6_APPLIED" = "1" ]; then PERF_NOTE="${PERF_NOTE} Mobile/LTE: ${MSS_SUMMARY}" if [ -n "$MSS_REVERT" ]; then PERF_NOTE="${PERF_NOTE} Revert installer-added rules: ${MSS_REVERT}" else PERF_NOTE="${PERF_NOTE} MSS ownership: existing rules were kept; this installer added none." fi elif [ "$TRANSPORT" = "tcp" ]; then PERF_NOTE="${PERF_NOTE} Mobile/LTE: outer IPv4 MSS clamps could NOT be installed (see warning above). Retry both directions once iptables works: iptables -t mangle -A PREROUTING ${MSS_INPUT_RULE} iptables -t mangle -A OUTPUT ${MSS_RULE}" else PERF_NOTE="${PERF_NOTE} udp-quic has no outer TCP handshake; no MSS clamp is needed." fi log "Done" cat <} Users: ${NUM_USERS} (${USER_PREFIX}1 … ${USER_PREFIX}${NUM_USERS}) Web panel: $([ -n "$PANEL_PW" ] && echo "${PANEL_URL} → login: admin / ${PANEL_PW}" || echo "disabled (set: qeli set-web-password)") $([ -n "$PANEL_PW" ] && printf ' \342\232\240 SAVE THIS PASSWORD NOW — shown once, only the hash is stored.\n') ${PERF_NOTE} Connection strings (qeli:// — paste or scan into the app): ${LINKS_DIR}/.qeli one file per user ${SUMMARY} all of them (with passwords) NEXT STEPS: • Open inbound ${TRANSPORT_UC} ${PORT}$([ "$PANEL_PUBLIC" = "1" ] && printf ' and TCP %s (panel)' "$PANEL_PORT") in your cloud firewall / security group. • Add a connection string to the app — that's all. To print one: cat ${LINKS_DIR}/${USER_PREFIX}1.qeli $([ "$PANEL_PUBLIC" = "1" ] && printf ' \342\200\242 The panel is PUBLIC at %s, restricted to allowed_ips = %s.\n Widen or narrow it in the [web] section of %s.\n' "$PANEL_URL" "$PANEL_ALLOWED" "$CONF") $([ "$PANEL_PUBLIC" = "0" ] && [ -n "$PANEL_PW" ] && printf ' \342\200\242 The panel listens on LOOPBACK only. Reach it with an SSH tunnel:\n ssh -L %s:127.0.0.1:%s root@%s then open https://127.0.0.1:%s\n To publish it instead, set web.bind = 0.0.0.0 AND web.allowed_ips in %s.\n' "$PANEL_PORT" "$PANEL_PORT" "$PUBLIC_HOST" "$PANEL_PORT" "$CONF") EOF