#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
umask 077
ORIGINAL_PATH=$PATH
if [[ "${NAIVE_WIZARD_TEST_MODE:-0}" == '1' || "${NAIVE_WIZARD_SOURCE_ONLY:-0}" == '1' ]]; then
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${ORIGINAL_PATH}"
else
PATH='/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
fi
export PATH
readonly APP_NAME='NaiveProxy Server Wizard'
readonly APP_ID='naiveproxy-server-wizard'
readonly APP_VERSION='0.1.0'
# Reproducible upstream server build published by klzgrad/forwardproxy.
readonly CADDY_RELEASE_TAG='v2.11.2-naive'
readonly CADDY_ARCHIVE_NAME='caddy-forwardproxy-naive.tar.xz'
readonly CADDY_ARCHIVE_SHA256='19eccb7321dd877a5fb4a3dba6ef1b745185188b616c96cc6201f1a1fc0380a8'
readonly CADDY_ARCHIVE_URL="https://github.com/klzgrad/forwardproxy/releases/download/${CADDY_RELEASE_TAG}/${CADDY_ARCHIVE_NAME}"
readonly FORWARDPROXY_COMMIT='d62c80d3dd2c706b6b87579844d2397bddd18317'
readonly NAIVE_CLIENT_RELEASE='v150.0.7871.63-1'
# Default secure build: current patched Caddy with the pinned naïve plugin commit.
readonly CADDY_CORE_VERSION='v2.11.4'
readonly GO_VERSION='1.26.5'
readonly GO_AMD64_SHA256='5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053'
readonly GO_ARM64_SHA256='fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49'
readonly XCADDY_VERSION='0.4.5'
readonly XCADDY_AMD64_SHA256='2f96dde11b8ecbb7d652c20e05fddbfd2f58c622b464104309eae512406193cf'
readonly XCADDY_ARM64_SHA256='6ecb9665a2d654697fe79c379bff5d90a9e8aa9165d4b63f8cb21614c5e5e323'
readonly SERVICE_NAME='naiveproxy-caddy.service'
readonly SERVICE_USER='naiveproxy'
readonly SERVICE_GROUP='naiveproxy'
CONFIG_DIR="${NAIVE_WIZARD_CONFIG_DIR:-/etc/naiveproxy-wizard}"
STATE_DIR="${NAIVE_WIZARD_STATE_DIR:-/var/lib/naiveproxy-wizard}"
BACKUP_ROOT="${NAIVE_WIZARD_BACKUP_ROOT:-/var/lib/naiveproxy-wizard/backups}"
WEB_ROOT="${NAIVE_WIZARD_WEB_ROOT:-/var/www/naiveproxy-wizard}"
LOG_DIR="${NAIVE_WIZARD_LOG_DIR:-/var/log/naiveproxy-wizard}"
OUTPUT_DIR="${NAIVE_WIZARD_OUTPUT_DIR:-/root/naiveproxy-clients}"
CADDY_BIN="${NAIVE_WIZARD_CADDY_BIN:-/usr/local/bin/caddy-naive}"
SELF_INSTALL_PATH="${NAIVE_WIZARD_SELF_PATH:-/usr/local/sbin/naiveproxy-wizard}"
SERVICE_FILE="${NAIVE_WIZARD_SERVICE_FILE:-/etc/systemd/system/${SERVICE_NAME}}"
LOCK_FILE="${NAIVE_WIZARD_LOCK_FILE:-/run/lock/naiveproxy-server-wizard.lock}"
RUNTIME_DIR="${NAIVE_WIZARD_RUNTIME_DIR:-/run/naiveproxy-server-wizard}"
BUILD_ROOT="${NAIVE_WIZARD_BUILD_ROOT:-/var/tmp/naiveproxy-server-wizard}"
SETTINGS_FILE="${CONFIG_DIR}/settings.json"
USERS_FILE="${CONFIG_DIR}/users.tsv"
CADDY_CONFIG="${CONFIG_DIR}/Caddyfile"
MANIFEST_FILE="${CONFIG_DIR}/manifest.json"
MANAGED_CERT="${CONFIG_DIR}/tls/fullchain.pem"
MANAGED_KEY="${CONFIG_DIR}/tls/privkey.pem"
TRANSACTION_FILE="${STATE_DIR}/transaction.json"
CADDY_DATA_DIR="${STATE_DIR}/caddy"
SOURCE_ONLY="${NAIVE_WIZARD_SOURCE_ONLY:-0}"
TEST_MODE="${NAIVE_WIZARD_TEST_MODE:-0}"
FAILPOINT="${NAIVE_WIZARD_FAILPOINT:-}"
DOMAIN=''
LISTEN_PORT='443'
TLS_MODE='acme'
ACME_EMAIL=''
SOURCE_CERT_PATH=''
SOURCE_KEY_PATH=''
HTTP3_ENABLED='true'
AUTO_HTTPS_REDIRECTS='true'
DECOY_MODE='static'
DECOY_UPSTREAM=''
DECOY_REDIRECT=''
DECOY_TITLE='Welcome'
PROBE_MODE='fallthrough'
PROBE_SECRET=''
PAC_ENABLED='false'
PAC_PATH=''
HIDE_IP='true'
HIDE_VIA='true'
TARGET_PORTS_MODE='web'
TARGET_PORTS='80 443'
ACL_MODE='hardened'
ACL_CUSTOM=''
UPSTREAM_URL=''
DIAL_TIMEOUT='30s'
MAX_IDLE_CONNS='256'
MAX_IDLE_CONNS_PER_HOST='8'
MANAGE_UFW='true'
OPEN_HTTP_PORT='true'
INSTALL_MODE='quick'
SERVER_BUILD_MODE='source_patched'
declare -a USERNAMES=()
declare -a PASSWORDS=()
declare -a FIREWALL_RULES_ADDED=()
declare -a FIREWALL_RULES_REMOVED=()
TRANSACTION_ARMED=0
TRANSACTION_COMMITTED=0
ROLLBACK_IN_PROGRESS=0
CURRENT_BACKUP=''
SERVICE_WAS_ACTIVE=0
SERVICE_WAS_ENABLED=0
BUILD_SWAP_FILE=''
PREPARED_CANDIDATE=''
LOCK_ACQUIRED=0
RUNTIME_CLAIMED=0
BUILD_CLAIMED=0
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
readonly C_RESET=$'\033[0m'
readonly C_BLUE=$'\033[1;34m'
readonly C_GREEN=$'\033[1;32m'
readonly C_YELLOW=$'\033[1;33m'
readonly C_RED=$'\033[1;31m'
else
readonly C_RESET=''
readonly C_BLUE=''
readonly C_GREEN=''
readonly C_YELLOW=''
readonly C_RED=''
fi
info() {
printf '%s[INFO]%s %s\n' "$C_BLUE" "$C_RESET" "$*" >&2
}
success() {
printf '%s[OK]%s %s\n' "$C_GREEN" "$C_RESET" "$*" >&2
}
warn() {
printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2
}
error() {
printf '%s[ERROR]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2
}
die() {
error "$*"
return 1
}
have() {
command -v "$1" >/dev/null 2>&1
}
caddy_has_forwardproxy() {
local binary=$1
local modules
modules=$("$binary" list-modules --versions 2>/dev/null) || return 1
grep -Fq 'http.handlers.forward_proxy' <<<"$modules"
}
find_python() {
if have python3; then
printf '%s' 'python3'
elif have python; then
printf '%s' 'python'
else
return 1
fi
}
is_test_mode() {
[[ "$TEST_MODE" == '1' ]]
}
require_root() {
if (( EUID != 0 )); then
die 'Запустите мастер от root или через sudo.'
fi
}
require_tty() {
[[ -r /dev/tty && -w /dev/tty ]] || die 'Для интерактивного режима нужен доступный TTY.'
}
is_safe_absolute_path() {
local value=$1
[[ "$value" == /* ]] || return 1
# Bash variables cannot contain NUL; explicitly reject the representable line separators.
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || return 1
[[ "$value" != '/' && "$value" != '/etc' && "$value" != '/var' && "$value" != '/usr' ]] || return 1
}
assert_sensitive_file_security() {
local path=$1
local label=$2
[[ -f "$path" && ! -L "$path" ]] || die "Не найден безопасный ${label}: ${path}"
[[ "$TEST_MODE" == '1' || "$SOURCE_ONLY" == '1' || "$EUID" -ne 0 ]] && return 0
local uid mode group
uid=$(stat -c '%u' -- "$path")
mode=$(stat -c '%a' -- "$path")
group=$(stat -c '%G' -- "$path")
[[ "$uid" == '0' ]] || die "${label} должен принадлежать root."
case "$mode" in
600) ;;
640)
[[ "$group" == 'root' || "$group" == "$SERVICE_GROUP" ]] ||
die "${label} с правами 0640 должен принадлежать группе root или ${SERVICE_GROUP}."
;;
*) die "${label} должен иметь права 0600 или контролируемые 0640." ;;
esac
}
assert_runtime_paths() {
local path
for path in \
"$CONFIG_DIR" "$STATE_DIR" "$BACKUP_ROOT" "$WEB_ROOT" "$LOG_DIR" \
"$OUTPUT_DIR" "$CADDY_BIN" "$SELF_INSTALL_PATH" "$SERVICE_FILE" \
"$LOCK_FILE" "$RUNTIME_DIR" "$BUILD_ROOT"; do
is_safe_absolute_path "$path" || die "Небезопасный служебный путь: ${path}"
done
}
acquire_lock() {
if is_test_mode; then
LOCK_ACQUIRED=1
return 0
fi
have flock || die 'Не найдена команда flock.'
install -d -m 0755 -- "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
flock -n 9 || die 'Другой экземпляр мастера уже выполняется.'
LOCK_ACQUIRED=1
}
menu() {
local title=$1
shift
local -a options=("$@")
local answer index
printf '\n%s\n' "$title" >&2
for index in "${!options[@]}"; do
printf ' %d) %s\n' "$((index + 1))" "${options[$index]}" >&2
done
while true; do
printf '> ' >&2
IFS= read -r answer = 1 && 10#$answer <= ${#options[@]} )); then
printf '%s' "$((10#$answer))"
return 0
fi
warn "Введите число от 1 до ${#options[@]}."
done
}
prompt_value() {
local label=$1
local default_value=${2:-}
local answer
if [[ -n "$default_value" ]]; then
printf '%s [%s]: ' "$label" "$default_value" >&2
else
printf '%s: ' "$label" >&2
fi
IFS= read -r answer &2
IFS= read -rs first &2
IFS= read -rs second &2
[[ "$first" == "$second" ]] || {
warn 'Значения не совпадают.'
continue
}
[[ -n "$first" ]] || {
warn 'Пустое значение запрещено.'
continue
}
printf '%s' "$first"
return 0
done
}
confirm() {
local label=$1
local default_answer=${2:-no}
local suffix answer
if [[ "$default_answer" == 'yes' ]]; then
suffix='[Y/n]'
else
suffix='[y/N]'
fi
printf '%s %s: ' "$label" "$suffix" >&2
IFS= read -r answer = 4 && ${#value} <= 253 )) || return 1
[[ "$value" == *.* ]] || return 1
[[ "$value" =~ ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ ]] || return 1
[[ "$value" != *..* ]] || return 1
local label
local old_ifs=$IFS
IFS='.'
read -r -a labels <<<"$value"
IFS=$old_ifs
for label in "${labels[@]}"; do
(( ${#label} >= 1 && ${#label} <= 63 )) || return 1
[[ "$label" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]] || return 1
done
}
is_valid_email_or_empty() {
local value=$1
[[ -z "$value" ]] && return 0
[[ "$value" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]]
}
is_valid_port() {
local value=$1
[[ "$value" =~ ^[0-9]+$ ]] || return 1
(( 10#$value >= 1 && 10#$value <= 65535 ))
}
is_valid_count() {
local value=$1
[[ "$value" =~ ^[0-9]+$ ]] || return 1
(( 10#$value >= 1 && 10#$value <= 500 ))
}
is_valid_positive_int() {
local value=$1
[[ "$value" =~ ^[0-9]+$ ]] || return 1
(( 10#$value >= 1 && 10#$value <= 1000000 ))
}
is_valid_duration() {
[[ "$1" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]]
}
is_valid_username() {
local value=$1
(( ${#value} >= 1 && ${#value} <= 64 )) || return 1
[[ "$value" =~ ^[A-Za-z0-9._-]+$ ]]
}
is_valid_password() {
local value=$1
(( ${#value} >= 12 && ${#value} <= 128 )) || return 1
[[ "$value" =~ ^[A-Za-z0-9._~!$()*+,=@%/-]+$ ]]
}
is_valid_url_token() {
local value=$1
(( ${#value} >= 8 && ${#value} <= 2048 )) || return 1
[[ "$value" != *[[:space:]]* && "$value" != *\"* && "$value" != *\\* ]]
}
is_valid_upstream_url() {
local value=$1
is_valid_url_token "$value" || return 1
[[ "$value" =~ ^https:// || "$value" =~ ^http://127\.0\.0\.1: || "$value" =~ ^socks5://127\.0\.0\.1: ]]
}
is_valid_decoy_url() {
local value=$1
is_valid_url_token "$value" || return 1
[[ "$value" != *'@'* ]] || return 1
[[ "$value" =~ ^https:// ]]
}
is_valid_acl_token() {
[[ "$1" =~ ^[A-Za-z0-9.*:/_-]+$ ]]
}
normalize_space_list() {
local value=$1
local item
local -a result=()
value=${value//,/ }
local old_ifs=$IFS
IFS=' '
read -r -a raw_items <<<"$value"
IFS=$old_ifs
for item in "${raw_items[@]}"; do
[[ -n "$item" ]] || continue
result+=("$item")
done
(
IFS=' '
printf '%s' "${result[*]}"
)
}
validate_port_list() {
local value
value=$(normalize_space_list "$1")
[[ -n "$value" ]] || return 1
local item
local old_ifs=$IFS
IFS=' '
read -r -a items <<<"$value"
IFS=$old_ifs
for item in "${items[@]}"; do
is_valid_port "$item" || return 1
done
}
validate_acl_list() {
local value
value=$(normalize_space_list "$1")
[[ -n "$value" ]] || return 1
local item
local old_ifs=$IFS
IFS=' '
read -r -a items <<<"$value"
IFS=$old_ifs
for item in "${items[@]}"; do
is_valid_acl_token "$item" || return 1
done
}
generate_password() {
local value
while true; do
value=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9_-' | head -c 32)
(( ${#value} == 32 )) && {
printf '%s' "$value"
return 0
}
done
}
generate_secret_label() {
openssl rand -hex 12
}
username_exists() {
local needle=$1
local item
for item in "${USERNAMES[@]}"; do
[[ "$item" == "$needle" ]] && return 0
done
return 1
}
validate_user_arrays() {
(( ${#USERNAMES[@]} >= 1 && ${#USERNAMES[@]} <= 500 )) || return 1
(( ${#USERNAMES[@]} == ${#PASSWORDS[@]} )) || return 1
local index other
for index in "${!USERNAMES[@]}"; do
is_valid_username "${USERNAMES[$index]}" || return 1
is_valid_password "${PASSWORDS[$index]}" || return 1
for other in "${!USERNAMES[@]}"; do
(( other <= index )) && continue
[[ "${USERNAMES[$index]}" != "${USERNAMES[$other]}" ]] || return 1
done
done
}
create_generated_users() {
local count=$1
local prefix=${2:-user}
is_valid_count "$count" || die 'Количество пользователей должно быть от 1 до 500.'
is_valid_username "$prefix" || die 'Недопустимый префикс имени.'
USERNAMES=()
PASSWORDS=()
local width=3 index username
(( count >= 1000 )) && width=4
for ((index = 1; index <= count; index++)); do
printf -v username '%s-%0*d' "$prefix" "$width" "$index"
is_valid_username "$username" || die "Сгенерировано недопустимое имя: ${username}"
USERNAMES+=("$username")
PASSWORDS+=("$(generate_password)")
done
}
validate_settings() {
is_valid_domain "$DOMAIN" || die 'Некорректный домен.'
DOMAIN=${DOMAIN,,}
is_valid_port "$LISTEN_PORT" || die 'Некорректный порт сервера.'
[[ "$LISTEN_PORT" == '443' ]] ||
die 'Naïve forwardproxy должен слушать TCP/443: нестандартный серверный порт не поддерживается upstream-контрактом.'
[[ "$TLS_MODE" == 'acme' || "$TLS_MODE" == 'existing' ]] || die 'Некорректный режим TLS.'
is_valid_email_or_empty "$ACME_EMAIL" || die 'Некорректный email ACME.'
[[ "$HTTP3_ENABLED" == 'true' || "$HTTP3_ENABLED" == 'false' ]] || die 'Некорректный режим HTTP/3.'
[[ "$AUTO_HTTPS_REDIRECTS" == 'true' || "$AUTO_HTTPS_REDIRECTS" == 'false' ]] || die 'Некорректный режим HTTPS redirect.'
[[ "$DECOY_MODE" == 'static' || "$DECOY_MODE" == 'reverse_proxy' ||
"$DECOY_MODE" == 'redirect' || "$DECOY_MODE" == 'respond' ]] || die 'Некорректный режим сайта.'
if [[ "$DECOY_MODE" == 'reverse_proxy' ]]; then
is_valid_decoy_url "$DECOY_UPSTREAM" || die 'Некорректный HTTPS URL сайта-прокладки.'
fi
if [[ "$DECOY_MODE" == 'redirect' ]]; then
is_valid_decoy_url "$DECOY_REDIRECT" || die 'Некорректный HTTPS URL перенаправления.'
fi
(( ${#DECOY_TITLE} >= 1 && ${#DECOY_TITLE} <= 80 )) || die 'Заголовок сайта должен содержать 1–80 символов.'
[[ "$DECOY_TITLE" != *$'\n'* && "$DECOY_TITLE" != *$'\r'* ]] || die 'Недопустимый заголовок сайта.'
[[ "$PROBE_MODE" == 'fallthrough' || "$PROBE_MODE" == 'secret_domain' ]] || die 'Некорректный режим probe resistance.'
if [[ "$PROBE_MODE" == 'secret_domain' ]]; then
is_valid_domain "$PROBE_SECRET" || die 'Некорректный secret-domain для probe resistance.'
fi
[[ "$PAC_ENABLED" == 'true' || "$PAC_ENABLED" == 'false' ]] || die 'Некорректный режим PAC.'
if [[ "$PAC_ENABLED" == 'true' ]]; then
[[ "$PAC_PATH" =~ ^/[A-Za-z0-9._/-]{8,128}\.pac$ && "$PAC_PATH" != *..* ]] ||
die 'Некорректный секретный PAC path.'
fi
[[ "$HIDE_IP" == 'true' || "$HIDE_IP" == 'false' ]] || die 'Некорректный hide_ip.'
[[ "$HIDE_VIA" == 'true' || "$HIDE_VIA" == 'false' ]] || die 'Некорректный hide_via.'
[[ "$TARGET_PORTS_MODE" == 'web' || "$TARGET_PORTS_MODE" == 'common' ||
"$TARGET_PORTS_MODE" == 'custom' || "$TARGET_PORTS_MODE" == 'unrestricted' ]] ||
die 'Некорректный режим портов назначения.'
if [[ "$TARGET_PORTS_MODE" != 'unrestricted' ]]; then
validate_port_list "$TARGET_PORTS" || die 'Некорректный список портов назначения.'
TARGET_PORTS=$(normalize_space_list "$TARGET_PORTS")
fi
[[ "$ACL_MODE" == 'plugin_default' || "$ACL_MODE" == 'hardened' ||
"$ACL_MODE" == 'custom_deny' || "$ACL_MODE" == 'allowlist' ]] ||
die 'Некорректный режим ACL.'
if [[ "$ACL_MODE" == 'custom_deny' || "$ACL_MODE" == 'allowlist' ]]; then
validate_acl_list "$ACL_CUSTOM" || die 'Некорректный список ACL.'
ACL_CUSTOM=$(normalize_space_list "$ACL_CUSTOM")
fi
if [[ -n "$UPSTREAM_URL" ]]; then
is_valid_upstream_url "$UPSTREAM_URL" || die 'Некорректный upstream URL.'
fi
is_valid_duration "$DIAL_TIMEOUT" || die 'Некорректный dial timeout.'
is_valid_positive_int "$MAX_IDLE_CONNS" || die 'Некорректный max_idle_conns.'
is_valid_positive_int "$MAX_IDLE_CONNS_PER_HOST" || die 'Некорректный max_idle_conns_per_host.'
[[ "$MANAGE_UFW" == 'true' || "$MANAGE_UFW" == 'false' ]] || die 'Некорректный режим UFW.'
[[ "$OPEN_HTTP_PORT" == 'true' || "$OPEN_HTTP_PORT" == 'false' ]] || die 'Некорректный режим TCP/80.'
[[ "$SERVER_BUILD_MODE" == 'source_patched' || "$SERVER_BUILD_MODE" == 'upstream_prebuilt' ]] ||
die 'Некорректный режим сборки Caddy.'
[[ -z "$UPSTREAM_URL" || ( "$TARGET_PORTS_MODE" == 'unrestricted' && "$ACL_MODE" == 'plugin_default' ) ]] ||
die 'Upstream несовместим с ports и ACL: выберите unrestricted + plugin default.'
if [[ "$TLS_MODE" == 'existing' ]]; then
is_safe_absolute_path "$SOURCE_CERT_PATH" || die 'Некорректный путь сертификата.'
is_safe_absolute_path "$SOURCE_KEY_PATH" || die 'Некорректный путь ключа.'
fi
}
maybe_fail() {
local point=$1
[[ -z "$FAILPOINT" || "$FAILPOINT" != "$point" ]] || die "Активирован тестовый failpoint: ${point}"
}
print_banner() {
printf '\n%s %s\n' "$APP_NAME" "$APP_VERSION" >&2
printf '%s\n' 'Отдельный мастер Caddy + naïve forwardproxy для Ubuntu.' >&2
printf '%s\n\n' 'Внимание: upstream forwardproxy помечен авторами как experimental.' >&2
}
write_plan_json() {
local target=$1
local python_bin
python_bin=$(find_python) || die 'Для сериализации настроек нужен Python 3.'
printf '%s\0' \
"$DOMAIN" "$LISTEN_PORT" "$TLS_MODE" "$ACME_EMAIL" \
"$HTTP3_ENABLED" "$AUTO_HTTPS_REDIRECTS" \
"$DECOY_MODE" "$DECOY_UPSTREAM" "$DECOY_REDIRECT" "$DECOY_TITLE" \
"$PROBE_MODE" "$PROBE_SECRET" "$PAC_ENABLED" "$PAC_PATH" \
"$HIDE_IP" "$HIDE_VIA" "$TARGET_PORTS_MODE" "$TARGET_PORTS" \
"$ACL_MODE" "$ACL_CUSTOM" "$UPSTREAM_URL" "$DIAL_TIMEOUT" \
"$MAX_IDLE_CONNS" "$MAX_IDLE_CONNS_PER_HOST" \
"$MANAGE_UFW" "$OPEN_HTTP_PORT" "$INSTALL_MODE" "$SERVER_BUILD_MODE" |
"$python_bin" -c '
import json
import sys
raw = sys.stdin.buffer.read().split(b"\0")
if raw and raw[-1] == b"":
raw.pop()
values = [item.decode("utf-8") for item in raw]
keys = [
"domain", "listen_port", "tls_mode", "acme_email",
"http3_enabled", "auto_https_redirects",
"decoy_mode", "decoy_upstream", "decoy_redirect", "decoy_title",
"probe_mode", "probe_secret", "pac_enabled", "pac_path",
"hide_ip", "hide_via", "target_ports_mode", "target_ports",
"acl_mode", "acl_custom", "upstream_url", "dial_timeout",
"max_idle_conns", "max_idle_conns_per_host",
"manage_ufw", "open_http_port", "install_mode",
"server_build_mode",
]
if len(values) != len(keys):
raise SystemExit(f"expected {len(keys)} values, got {len(values)}")
v = dict(zip(keys, values))
as_bool = lambda name: v[name] == "true"
document = {
"schema_version": 1,
"wizard_version": "'"$APP_VERSION"'",
"server": {
"domain": v["domain"],
"listen_port": int(v["listen_port"]),
"http3": as_bool("http3_enabled"),
"auto_https_redirects": as_bool("auto_https_redirects"),
},
"tls": {
"mode": v["tls_mode"],
"acme_email": v["acme_email"],
"certificate": "'"$MANAGED_CERT"'" if v["tls_mode"] == "existing" else "",
"private_key": "'"$MANAGED_KEY"'" if v["tls_mode"] == "existing" else "",
},
"decoy": {
"mode": v["decoy_mode"],
"upstream": v["decoy_upstream"],
"redirect": v["decoy_redirect"],
"title": v["decoy_title"],
"root": "'"$WEB_ROOT"'",
},
"privacy": {
"probe_mode": v["probe_mode"],
"probe_secret": v["probe_secret"],
"hide_ip": as_bool("hide_ip"),
"hide_via": as_bool("hide_via"),
"exclude_activity_error_log": True,
},
"pac": {
"enabled": as_bool("pac_enabled"),
"path": v["pac_path"],
},
"egress": {
"target_ports_mode": v["target_ports_mode"],
"target_ports": [int(item) for item in v["target_ports"].split()] if v["target_ports"] else [],
"acl_mode": v["acl_mode"],
"acl_custom": v["acl_custom"].split() if v["acl_custom"] else [],
"upstream": v["upstream_url"],
"dial_timeout": v["dial_timeout"],
"max_idle_conns": int(v["max_idle_conns"]),
"max_idle_conns_per_host": int(v["max_idle_conns_per_host"]),
},
"firewall": {
"manage_ufw": as_bool("manage_ufw"),
"open_http_port": as_bool("open_http_port"),
},
"install_mode": v["install_mode"],
"server_build_mode": v["server_build_mode"],
"upstream_versions": {
"caddy_core": "'"$CADDY_CORE_VERSION"'",
"server_release": "'"$CADDY_RELEASE_TAG"'",
"forwardproxy_commit": "'"$FORWARDPROXY_COMMIT"'",
"recommended_client_release": "'"$NAIVE_CLIENT_RELEASE"'",
},
}
json.dump(document, sys.stdout, ensure_ascii=False, indent=2, sort_keys=True)
sys.stdout.write("\n")
' >"$target"
chmod 0600 "$target"
}
load_settings() {
local source=${1:-$SETTINGS_FILE}
assert_sensitive_file_security "$source" 'settings.json'
local python_bin
python_bin=$(find_python) || die 'Для чтения настроек нужен Python 3.'
local -a values=()
mapfile -d '' -t values < <(
"$python_bin" -c '
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
d = json.load(fh)
if d.get("schema_version") != 1:
raise SystemExit("unsupported settings schema")
def boolean(path, value):
if type(value) is not bool:
raise SystemExit(f"{path} must be a JSON boolean")
return "true" if value else "false"
fields = [
d["server"]["domain"],
str(d["server"]["listen_port"]),
d["tls"]["mode"],
d["tls"].get("acme_email", ""),
d["tls"].get("certificate", ""),
d["tls"].get("private_key", ""),
boolean("server.http3", d["server"]["http3"]),
boolean("server.auto_https_redirects", d["server"]["auto_https_redirects"]),
d["decoy"]["mode"],
d["decoy"].get("upstream", ""),
d["decoy"].get("redirect", ""),
d["decoy"].get("title", "Welcome"),
d["privacy"]["probe_mode"],
d["privacy"].get("probe_secret", ""),
boolean("pac.enabled", d["pac"]["enabled"]),
d["pac"].get("path", ""),
boolean("privacy.hide_ip", d["privacy"]["hide_ip"]),
boolean("privacy.hide_via", d["privacy"]["hide_via"]),
d["egress"]["target_ports_mode"],
" ".join(str(v) for v in d["egress"].get("target_ports", [])),
d["egress"]["acl_mode"],
" ".join(d["egress"].get("acl_custom", [])),
d["egress"].get("upstream", ""),
d["egress"]["dial_timeout"],
str(d["egress"]["max_idle_conns"]),
str(d["egress"]["max_idle_conns_per_host"]),
boolean("firewall.manage_ufw", d["firewall"]["manage_ufw"]),
boolean("firewall.open_http_port", d["firewall"]["open_http_port"]),
d.get("install_mode", "guided"),
d.get("server_build_mode", "source_patched"),
]
for field in fields:
sys.stdout.buffer.write(str(field).encode("utf-8") + b"\0")
' "$source"
)
(( ${#values[@]} == 30 )) || die 'settings.json имеет неполный контракт.'
DOMAIN=${values[0]}
LISTEN_PORT=${values[1]}
TLS_MODE=${values[2]}
ACME_EMAIL=${values[3]}
SOURCE_CERT_PATH=${values[4]}
SOURCE_KEY_PATH=${values[5]}
HTTP3_ENABLED=${values[6]}
AUTO_HTTPS_REDIRECTS=${values[7]}
DECOY_MODE=${values[8]}
DECOY_UPSTREAM=${values[9]}
DECOY_REDIRECT=${values[10]}
DECOY_TITLE=${values[11]}
PROBE_MODE=${values[12]}
PROBE_SECRET=${values[13]}
PAC_ENABLED=${values[14]}
PAC_PATH=${values[15]}
HIDE_IP=${values[16]}
HIDE_VIA=${values[17]}
TARGET_PORTS_MODE=${values[18]}
TARGET_PORTS=${values[19]}
ACL_MODE=${values[20]}
ACL_CUSTOM=${values[21]}
UPSTREAM_URL=${values[22]}
DIAL_TIMEOUT=${values[23]}
MAX_IDLE_CONNS=${values[24]}
MAX_IDLE_CONNS_PER_HOST=${values[25]}
MANAGE_UFW=${values[26]}
OPEN_HTTP_PORT=${values[27]}
INSTALL_MODE=${values[28]}
SERVER_BUILD_MODE=${values[29]}
if [[ "$TLS_MODE" == 'existing' ]]; then
[[ -n "$SOURCE_CERT_PATH" && -n "$SOURCE_KEY_PATH" ]] ||
die 'Для existing TLS settings.json должен содержать certificate и private_key.'
else
SOURCE_CERT_PATH=''
SOURCE_KEY_PATH=''
fi
validate_settings
}
write_users_file() {
local target=$1
validate_user_arrays || die 'Невалидный набор пользователей.'
: >"$target"
chmod 0600 "$target"
local index
for index in "${!USERNAMES[@]}"; do
printf '%s\t%s\n' "${USERNAMES[$index]}" "${PASSWORDS[$index]}" >>"$target"
done
}
load_users() {
local source=${1:-$USERS_FILE}
assert_sensitive_file_security "$source" 'users.tsv'
USERNAMES=()
PASSWORDS=()
local username password extra
while IFS=$'\t' read -r username password extra; do
username=${username%$'\r'}
password=${password%$'\r'}
extra=${extra%$'\r'}
[[ -n "$username" ]] || continue
[[ -z "${extra:-}" ]] || die 'users.tsv содержит лишние поля.'
USERNAMES+=("$username")
PASSWORDS+=("$password")
done <"$source"
validate_user_arrays || die 'users.tsv не прошёл валидацию.'
}
render_caddyfile() {
local plan_file=$1
local users_file=$2
local python_bin
python_bin=$(find_python) || die 'Для рендеринга Caddyfile нужен Python 3.'
"$python_bin" - "$plan_file" "$users_file" <<'PY'
import json
import sys
plan_path, users_path = sys.argv[1:3]
with open(plan_path, "r", encoding="utf-8") as fh:
plan = json.load(fh)
users = []
with open(users_path, "r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.rstrip("\r\n")
if not raw:
continue
username, password = raw.split("\t")
users.append((username, password))
if not users:
raise SystemExit("at least one user is required")
def q(value):
return json.dumps(str(value), ensure_ascii=False)
server = plan["server"]
tls = plan["tls"]
decoy = plan["decoy"]
privacy = plan["privacy"]
pac = plan["pac"]
egress = plan["egress"]
protocols = "h1 h2 h3" if server["http3"] else "h1 h2"
lines = [
"{",
" order forward_proxy first",
" admin off",
" servers {",
f" protocols {protocols}",
" }",
]
if not server["auto_https_redirects"]:
lines.append(" auto_https disable_redirects")
if privacy.get("exclude_activity_error_log", True):
lines.extend([
" log {",
" exclude http.log.error",
" exclude http.handlers.forward_proxy",
" }",
])
lines.extend([
"}",
"",
f":443, {server['domain']} {{",
" encode zstd gzip",
])
if tls["mode"] == "existing":
lines.append(f" tls {q(tls['certificate'])} {q(tls['private_key'])}")
elif tls.get("acme_email"):
lines.append(f" tls {q(tls['acme_email'])}")
lines.extend([
"",
" route {",
" forward_proxy {",
])
for username, password in users:
lines.append(f" basic_auth {q(username)} {q(password)}")
if privacy["hide_ip"]:
lines.append(" hide_ip")
if privacy["hide_via"]:
lines.append(" hide_via")
if privacy["probe_mode"] == "secret_domain":
lines.append(f" probe_resistance {q(privacy['probe_secret'])}")
else:
lines.append(" probe_resistance")
if pac["enabled"]:
lines.append(f" serve_pac {q(pac['path'])}")
if egress.get("upstream"):
lines.append(f" upstream {q(egress['upstream'])}")
else:
if egress["target_ports_mode"] != "unrestricted":
ports = " ".join(str(item) for item in egress["target_ports"])
lines.append(f" ports {ports}")
acl_mode = egress["acl_mode"]
if acl_mode != "plugin_default":
hardened_denies = [
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10",
"127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12",
"192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15",
"224.0.0.0/4", "::/128", "::1/128", "fc00::/7",
"fe80::/10", "ff00::/8",
]
lines.append(" acl {")
lines.append(" deny " + " ".join(hardened_denies))
custom = egress.get("acl_custom", [])
if acl_mode == "custom_deny" and custom:
lines.append(" deny " + " ".join(custom))
lines.append(" allow all")
elif acl_mode == "allowlist" and custom:
lines.append(" allow " + " ".join(custom))
lines.append(" deny all")
else:
lines.append(" allow all")
lines.append(" }")
lines.extend([
f" dial_timeout {egress['dial_timeout']}",
f" max_idle_conns {egress['max_idle_conns']}",
f" max_idle_conns_per_host {egress['max_idle_conns_per_host']}",
" }",
"",
])
mode = decoy["mode"]
if mode == "static":
lines.extend([
f" root * {q(decoy['root'])}",
" file_server",
])
elif mode == "reverse_proxy":
lines.append(f" reverse_proxy {q(decoy['upstream'])}")
elif mode == "redirect":
lines.append(f" redir {q(decoy['redirect'])} 302")
else:
lines.append(' respond "Not Found" 404')
lines.extend([
" }",
"}",
"",
])
print("\n".join(lines))
PY
}
stage_client_bundle() {
local plan_file=$1
local users_file=$2
local target_dir=$3
local python_bin
python_bin=$(find_python) || die 'Для генерации клиентских файлов нужен Python 3.'
[[ -d "$target_dir" && ! -L "$target_dir" ]] || die 'Небезопасный staging-каталог клиентов.'
"$python_bin" - "$plan_file" "$users_file" "$target_dir" <<'PY'
import hashlib
import json
import os
import pathlib
import sys
import urllib.parse
plan_path, users_path, target_value = sys.argv[1:4]
target = pathlib.Path(target_value)
with open(plan_path, "r", encoding="utf-8") as fh:
plan = json.load(fh)
users = []
with open(users_path, "r", encoding="utf-8") as fh:
for raw in fh:
raw = raw.rstrip("\r\n")
if raw:
users.append(tuple(raw.split("\t")))
server = plan["server"]
domain = server["domain"]
port = int(server["listen_port"])
http3 = bool(server["http3"])
suffix = "" if port == 443 else f":{port}"
def write_secret(path, content):
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
fd = os.open(path, flags, 0o600)
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as fh:
fh.write(content)
def proxy_url(scheme, username, password):
user = urllib.parse.quote(username, safe="")
secret = urllib.parse.quote(password, safe="")
return f"{scheme}://{user}:{secret}@{domain}{suffix}"
summary = [
"NaiveProxy Server Wizard",
f"Server: {domain}:{port}",
f"Recommended native client: {plan['upstream_versions']['recommended_client_release']}",
"Keep this file private.",
"",
]
for index, (username, password) in enumerate(users, 1):
h2_url = proxy_url("https", username, password)
base_name = f"{index:03d}-{username}"
native_h2 = {
"listen": "socks://127.0.0.1:1080",
"proxy": h2_url,
}
write_secret(
target / f"native-{base_name}-h2.json",
json.dumps(native_h2, ensure_ascii=False, indent=2) + "\n",
)
sing_h2 = {
"outbounds": [{
"type": "naive",
"tag": "naive-h2",
"server": domain,
"server_port": port,
"username": username,
"password": password,
"insecure_concurrency": 0,
"quic": False,
"tls": {"enabled": True, "server_name": domain},
}]
}
write_secret(
target / f"sing-box-{base_name}-h2.json",
json.dumps(sing_h2, ensure_ascii=False, indent=2) + "\n",
)
summary.extend([
f"[{index:03d}] {username}",
f"Password: {password}",
f"HTTPS/H2: {h2_url}",
])
if http3:
h3_url = proxy_url("quic", username, password)
native_h3 = {
"listen": "socks://127.0.0.1:1080",
"proxy": h3_url,
}
write_secret(
target / f"native-{base_name}-h3.json",
json.dumps(native_h3, ensure_ascii=False, indent=2) + "\n",
)
sing_h3 = {
"outbounds": [{
"type": "naive",
"tag": "naive-h3",
"server": domain,
"server_port": port,
"username": username,
"password": password,
"insecure_concurrency": 0,
"quic": True,
"quic_congestion_control": "bbr",
"tls": {"enabled": True, "server_name": domain},
}]
}
write_secret(
target / f"sing-box-{base_name}-h3.json",
json.dumps(sing_h3, ensure_ascii=False, indent=2) + "\n",
)
summary.append(f"QUIC/H3: {h3_url}")
summary.append("")
if plan["pac"]["enabled"]:
summary.append(f"PAC: https://{domain}{suffix}{plan['pac']['path']}")
summary.append("")
write_secret(target / "clients.txt", "\n".join(summary))
report = {
"schema_version": 1,
"wizard_version": plan["wizard_version"],
"server": server,
"tls_mode": plan["tls"]["mode"],
"decoy_mode": plan["decoy"]["mode"],
"privacy": {
"probe_mode": plan["privacy"]["probe_mode"],
"hide_ip": plan["privacy"]["hide_ip"],
"hide_via": plan["privacy"]["hide_via"],
},
"egress": {
"target_ports_mode": plan["egress"]["target_ports_mode"],
"acl_mode": plan["egress"]["acl_mode"],
"upstream_configured": bool(plan["egress"]["upstream"]),
},
"user_count": len(users),
"upstream_versions": plan["upstream_versions"],
}
write_secret(
target / "install-report.json",
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
)
checksum_lines = []
for path in sorted(target.iterdir()):
if path.name == "checksums.sha256" or not path.is_file():
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
checksum_lines.append(f"{digest} {path.name}")
write_secret(target / "checksums.sha256", "\n".join(checksum_lines) + "\n")
PY
find "$target_dir" -type f -exec chmod 0600 {} +
chmod 0700 "$target_dir"
}
render_static_site() {
local target_dir=$1
local title=$2
local python_bin
python_bin=$(find_python) || die 'Для генерации сайта нужен Python 3.'
[[ -d "$target_dir" && ! -L "$target_dir" ]] || die 'Небезопасный каталог сайта.'
printf '%s\0' "$title" |
"$python_bin" -c '
import html
import pathlib
import sys
title = sys.stdin.buffer.read().rstrip(b"\0").decode("utf-8")
target = pathlib.Path(sys.argv[1])
safe = html.escape(title)
page = f"""
{safe}
SERVICE STATUS
{safe}
The requested service is online. This page is intentionally minimal.
Learn more
"""
about = f"""
About — {safe}
ABOUT
{safe}
A small independent web service.
Home
"""
css = """html{color-scheme:light dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#0b1020;color:#e8ecff}
main{width:min(760px,calc(100% - 40px));padding:56px;border:1px solid #25304f;border-radius:24px;
background:linear-gradient(145deg,#151d35,#0d1326);box-shadow:0 32px 80px #0008}
.eyebrow{color:#7dd3fc;font-size:.78rem;font-weight:800;letter-spacing:.18em}.lead{max-width:560px;color:#b8c2df;
font-size:1.12rem;line-height:1.7}h1{font-size:clamp(2.4rem,8vw,5.8rem);line-height:.95;margin:.25em 0}
.button{display:inline-block;margin-top:20px;padding:12px 18px;border-radius:999px;background:#7dd3fc;color:#07101d;
font-weight:800;text-decoration:none}@media(max-width:600px){main{padding:32px 24px}}"""
js = """document.documentElement.dataset.ready="true";"""
assets = target / "assets"
assets.mkdir(mode=0o755, exist_ok=True)
assets.chmod(0o755)
for path, content in [
(target / "index.html", page),
(target / "about.html", about),
(assets / "site.css", css),
(assets / "site.js", js),
]:
path.write_text(content, encoding="utf-8", newline="\n")
path.chmod(0o644)
' "$target_dir"
}
check_supported_platform() {
[[ -r /etc/os-release ]] || die 'Не найден /etc/os-release.'
# shellcheck disable=SC1091
source /etc/os-release
[[ "${ID:-}" == 'ubuntu' ]] || die 'Поддерживается только Ubuntu Server.'
[[ "${VERSION_ID:-}" == '22.04' || "${VERSION_ID:-}" == '24.04' ]] ||
die "Поддерживаются Ubuntu 22.04 и 24.04; обнаружена ${VERSION_ID:-unknown}."
[[ "$(uname -s)" == 'Linux' ]] || die 'Поддерживается только Linux.'
case "$(uname -m)" in
x86_64 | aarch64 | arm64) ;;
*) die "Архитектура $(uname -m) пока не поддерживается." ;;
esac
have systemctl || die 'Не найден systemd.'
}
install_dependencies() {
info 'Устанавливаю проверенные системные зависимости.'
export DEBIAN_FRONTEND=noninteractive
apt-get -o DPkg::Lock::Timeout=300 update
apt-get -o DPkg::Lock::Timeout=300 install -y --no-install-recommends \
ca-certificates curl dnsutils iproute2 openssl python3 tar xz-utils util-linux
maybe_fail 'dependencies'
}
ensure_runtime_dir() {
(( RUNTIME_CLAIMED == 0 )) || return 0
(( LOCK_ACQUIRED == 1 )) || die 'Runtime dir нельзя захватывать без глобальной блокировки.'
[[ ! -L "$RUNTIME_DIR" ]] || die 'Runtime dir является symlink.'
if [[ -d "$RUNTIME_DIR" ]]; then
[[ -f "${RUNTIME_DIR}/.owner" && ! -L "${RUNTIME_DIR}/.owner" ]] ||
die 'Существующий runtime dir не имеет ownership marker.'
grep -Fxq "$APP_ID" "${RUNTIME_DIR}/.owner" ||
die 'Runtime dir принадлежит другому приложению.'
else
install -d -m 0700 -o root -g root -- "$RUNTIME_DIR"
printf '%s\n' "$APP_ID" >"${RUNTIME_DIR}/.owner"
chmod 0600 "${RUNTIME_DIR}/.owner"
fi
find "$RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name '.owner' -exec rm -rf -- {} +
RUNTIME_CLAIMED=1
}
cleanup_runtime_dir() {
(( RUNTIME_CLAIMED == 1 )) || return 0
[[ -d "$RUNTIME_DIR" && ! -L "$RUNTIME_DIR" ]] || return 0
[[ -f "${RUNTIME_DIR}/.owner" && ! -L "${RUNTIME_DIR}/.owner" ]] ||
{ error 'Runtime ownership marker повреждён; cleanup остановлен.'; return 1; }
grep -Fxq "$APP_ID" "${RUNTIME_DIR}/.owner" ||
{ error 'Runtime dir больше не принадлежит мастеру; cleanup остановлен.'; return 1; }
find "$RUNTIME_DIR" -mindepth 1 -maxdepth 1 ! -name '.owner' \
-exec rm -rf -- {} + || return 1
RUNTIME_CLAIMED=0
}
cleanup_build_area() {
(( BUILD_CLAIMED == 1 )) || return 0
if [[ -n "$BUILD_SWAP_FILE" ]]; then
if [[ "$BUILD_SWAP_FILE" == "${BUILD_ROOT}/"* ]]; then
if swap_file_is_active "$BUILD_SWAP_FILE"; then
if ! swapoff "$BUILD_SWAP_FILE" >/dev/null 2>&1; then
die "Не удалось отключить временный swap ${BUILD_SWAP_FILE}."
return 1
fi
fi
rm -f -- "$BUILD_SWAP_FILE" || return 1
fi
BUILD_SWAP_FILE=''
fi
[[ -d "$BUILD_ROOT" && ! -L "$BUILD_ROOT" ]] || return 0
[[ -f "${BUILD_ROOT}/.owner" && ! -L "${BUILD_ROOT}/.owner" ]] ||
{ error 'Build ownership marker повреждён; cleanup остановлен.'; return 1; }
grep -Fxq "$APP_ID" "${BUILD_ROOT}/.owner" ||
{ error 'Build root больше не принадлежит мастеру; cleanup остановлен.'; return 1; }
rm -rf --one-file-system -- "$BUILD_ROOT" || return 1
BUILD_CLAIMED=0
}
ensure_build_root() {
(( BUILD_CLAIMED == 0 )) || return 0
(( LOCK_ACQUIRED == 1 )) || die 'Build root нельзя захватывать без глобальной блокировки.'
[[ ! -L "$BUILD_ROOT" ]] || die 'Build root является symlink.'
if [[ -d "$BUILD_ROOT" ]]; then
[[ -f "${BUILD_ROOT}/.owner" && ! -L "${BUILD_ROOT}/.owner" ]] ||
die 'Существующий build root не имеет ownership marker.'
grep -Fxq "$APP_ID" "${BUILD_ROOT}/.owner" ||
die 'Build root принадлежит другому приложению.'
else
install -d -m 0700 -o root -g root -- "$BUILD_ROOT"
printf '%s\n' "$APP_ID" >"${BUILD_ROOT}/.owner"
chmod 0600 "${BUILD_ROOT}/.owner"
fi
local stale_swap
while IFS= read -r -d '' stale_swap; do
if swap_file_is_active "$stale_swap"; then
swapoff "$stale_swap" >/dev/null 2>&1 ||
die "Не удалось отключить оставшийся временный swap ${stale_swap}."
fi
done < <(find "$BUILD_ROOT" -mindepth 2 -maxdepth 2 -type f \
-name 'temporary-build.swap' -print0)
find "$BUILD_ROOT" -mindepth 1 -maxdepth 1 ! -name '.owner' -exec rm -rf -- {} +
BUILD_CLAIMED=1
}
swap_file_is_active() {
local path=$1
local active
active=$(swapon --show=NAME --noheadings --raw 2>/dev/null) || return 1
grep -Fxq "$path" <<<"$active"
}
cleanup_all() {
local original_status=$?
local failed=0
local had_errexit=0
[[ $- == *e* ]] && had_errexit=1
set +e
cleanup_runtime_dir || failed=1
cleanup_build_area || failed=1
(( had_errexit == 0 )) || set -e
(( failed == 0 )) || error 'Не все временные ресурсы удалось очистить; проверьте runtime/build dir и swap.'
if (( failed != 0 && original_status == 0 )); then
exit 1
fi
exit "$original_status"
}
resolve_public_ipv4() {
local endpoint value
for endpoint in \
'https://api4.ipify.org' \
'https://ipv4.icanhazip.com' \
'https://ifconfig.co/ip'; do
value=$(curl -4fsS --max-time 8 "$endpoint" 2>/dev/null | tr -d '\r\n ' || true)
if [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
printf '%s' "$value"
return 0
fi
done
return 1
}
resolve_public_ipv6() {
local endpoint value
for endpoint in 'https://api6.ipify.org' 'https://ipv6.icanhazip.com'; do
value=$(curl -6fsS --max-time 8 "$endpoint" 2>/dev/null | tr -d '\r\n ' || true)
if [[ "$value" == *:* ]]; then
printf '%s' "$value"
return 0
fi
done
return 1
}
validate_dns() {
info "Проверяю DNS для ${DOMAIN}."
local public_v4
public_v4=$(resolve_public_ipv4) || die 'Не удалось определить публичный IPv4 VPS.'
local -a records_v4=()
mapfile -t records_v4 < <(dig +short A "$DOMAIN" | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/')
(( ${#records_v4[@]} > 0 )) || die "У домена ${DOMAIN} нет A-записи."
local record
for record in "${records_v4[@]}"; do
[[ "$record" == "$public_v4" ]] ||
die "A-запись ${DOMAIN} содержит посторонний IPv4 ${record}."
done
local -a records_v6=()
mapfile -t records_v6 < <(dig +short AAAA "$DOMAIN" | awk '/:/')
if (( ${#records_v6[@]} > 0 )); then
local public_v6
public_v6=$(resolve_public_ipv6) ||
die "У ${DOMAIN} есть AAAA-запись, но VPS не имеет рабочего публичного IPv6."
local python_bin
python_bin=$(find_python) || die 'Для проверки IPv6 нужен Python 3.'
if ! "$python_bin" - "$public_v6" "${records_v6[@]}" <<'PY'
import ipaddress
import sys
public = ipaddress.ip_address(sys.argv[1])
records = [ipaddress.ip_address(item) for item in sys.argv[2:]]
raise SystemExit(0 if records and all(item == public for item in records) else 1)
PY
then
die "Не все AAAA-записи ${DOMAIN} совпадают с публичным IPv6 VPS."
fi
fi
success 'DNS указывает на этот VPS.'
}
service_is_active() {
systemctl is-active --quiet "$SERVICE_NAME"
}
service_is_enabled() {
systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null
}
socket_lines() {
local protocol=$1
local port=$2
if [[ "$protocol" == 'tcp' ]]; then
ss -H -ltnp | awk -v suffix=":${port}" '$4 ~ suffix"$" {print}'
else
ss -H -lunp | awk -v suffix=":${port}" '$4 ~ suffix"$" {print}'
fi
}
check_port_conflicts() {
service_is_active && {
info 'Управляемый сервис уже активен; его listener будет проверен после reload.'
return 0
}
local occupied
occupied=$(socket_lines tcp 443 || true)
[[ -z "$occupied" ]] || die 'TCP/443 уже занят сторонним процессом. Мастер не перезаписывает чужой web stack.'
if [[ "$HTTP3_ENABLED" == 'true' ]]; then
occupied=$(socket_lines udp 443 || true)
[[ -z "$occupied" ]] || die 'UDP/443 уже занят сторонним процессом; HTTP/3 нельзя включить безопасно.'
fi
if [[ "$OPEN_HTTP_PORT" == 'true' || "$AUTO_HTTPS_REDIRECTS" == 'true' ]]; then
occupied=$(socket_lines tcp 80 || true)
[[ -z "$occupied" ]] || die 'TCP/80 уже занят сторонним процессом; ACME/HTTPS redirect могут конфликтовать.'
fi
}
check_unmanaged_conflicts() {
if [[ -e "$MANIFEST_FILE" ]]; then
[[ -f "$MANIFEST_FILE" && ! -L "$MANIFEST_FILE" ]] ||
die 'Manifest мастера имеет небезопасный тип.'
return 0
fi
local path
for path in "$CONFIG_DIR" "$CADDY_BIN" "$SERVICE_FILE" "$WEB_ROOT"; do
if [[ -e "$path" || -L "$path" ]]; then
die "Обнаружен неуправляемый конфликт ${path}. Мастер не будет его перезаписывать."
fi
done
}
validate_existing_certificate() {
[[ -f "$SOURCE_CERT_PATH" && -r "$SOURCE_CERT_PATH" ]] || die 'Сертификат не читается.'
[[ -f "$SOURCE_KEY_PATH" && -r "$SOURCE_KEY_PATH" ]] || die 'Закрытый ключ не читается.'
openssl x509 -in "$SOURCE_CERT_PATH" -noout >/dev/null 2>&1 || die 'Некорректный PEM-сертификат.'
openssl pkey -in "$SOURCE_KEY_PATH" -noout >/dev/null 2>&1 || die 'Некорректный закрытый ключ.'
openssl x509 -in "$SOURCE_CERT_PATH" -checkend 86400 -noout >/dev/null 2>&1 ||
die 'Сертификат истёк или истечёт менее чем через 24 часа.'
openssl x509 -in "$SOURCE_CERT_PATH" -checkhost "$DOMAIN" -noout >/dev/null 2>&1 ||
die "Сертификат не содержит SAN для ${DOMAIN}."
local cert_pub key_pub
cert_pub=$(mktemp "${RUNTIME_DIR}/cert-pub.XXXXXX")
key_pub=$(mktemp "${RUNTIME_DIR}/key-pub.XXXXXX")
openssl x509 -in "$SOURCE_CERT_PATH" -pubkey -noout >"$cert_pub"
openssl pkey -in "$SOURCE_KEY_PATH" -pubout >"$key_pub"
cmp -s "$cert_pub" "$key_pub" || die 'Сертификат и закрытый ключ не образуют пару.'
}
verify_download() {
local path=$1
local expected=$2
local actual
actual=$(sha256sum "$path" | awk '{print $1}')
[[ "$actual" == "$expected" ]] || die "SHA-256 не совпал для $(basename "$path")."
}
download_file() {
local url=$1
local target=$2
curl -fL --retry 4 --retry-all-errors --connect-timeout 15 \
--max-time 600 "$url" -o "$target"
}
prepare_source_built_caddy() {
local build_dir=$1
local arch go_arch go_sha xcaddy_sha
arch=$(uname -m)
case "$arch" in
x86_64)
go_arch='amd64'
go_sha=$GO_AMD64_SHA256
xcaddy_sha=$XCADDY_AMD64_SHA256
;;
aarch64 | arm64)
go_arch='arm64'
go_sha=$GO_ARM64_SHA256
xcaddy_sha=$XCADDY_ARM64_SHA256
;;
*) die "Source build не поддерживает архитектуру ${arch}." ;;
esac
local go_archive="${build_dir}/go.tar.gz"
local xcaddy_archive="${build_dir}/xcaddy.tar.gz"
local go_url="https://go.dev/dl/go${GO_VERSION}.linux-${go_arch}.tar.gz"
local xcaddy_url="https://github.com/caddyserver/xcaddy/releases/download/v${XCADDY_VERSION}/xcaddy_${XCADDY_VERSION}_linux_${go_arch}.tar.gz"
info "Загружаю закреплённые Go ${GO_VERSION} и xcaddy ${XCADDY_VERSION}."
download_file "$go_url" "$go_archive"
verify_download "$go_archive" "$go_sha"
download_file "$xcaddy_url" "$xcaddy_archive"
verify_download "$xcaddy_archive" "$xcaddy_sha"
install -d -m 0700 -- "${build_dir}/toolchain" "${build_dir}/xcaddy"
tar -xzf "$go_archive" -C "${build_dir}/toolchain"
tar -xzf "$xcaddy_archive" -C "${build_dir}/xcaddy"
local go_bin="${build_dir}/toolchain/go/bin/go"
local xcaddy_bin="${build_dir}/xcaddy/xcaddy"
[[ -x "$go_bin" && -x "$xcaddy_bin" ]] || die 'Не удалось распаковать toolchain.'
local available_kib swap_kib total_kib free_kib required_free_kib
available_kib=$(awk '/MemAvailable:/{print $2}' /proc/meminfo)
swap_kib=$(awk '/SwapTotal:/{print $2}' /proc/meminfo)
total_kib=$((available_kib + swap_kib))
free_kib=$(df -Pk "$BUILD_ROOT" | awk 'NR==2{print $4}')
if (( total_kib < 1572864 )); then
required_free_kib=5242880
(( free_kib >= required_free_kib )) ||
die 'Недостаточно диска для безопасной сборки на малом объёме RAM (нужно около 5 GiB свободно после распаковки toolchain).'
BUILD_SWAP_FILE="${build_dir}/temporary-build.swap"
info 'RAM меньше 1.5 GiB: создаю временный swap 2 GiB только на время сборки.'
fallocate -l 2G "$BUILD_SWAP_FILE"
chmod 0600 "$BUILD_SWAP_FILE"
mkswap "$BUILD_SWAP_FILE" >/dev/null
swapon "$BUILD_SWAP_FILE"
else
required_free_kib=3145728
(( free_kib >= required_free_kib )) ||
die 'Недостаточно диска для source build (нужно около 3 GiB свободно после распаковки toolchain).'
fi
info "Собираю Caddy ${CADDY_CORE_VERSION} с закреплённым naïve forwardproxy."
(
cd "$build_dir"
export PATH="${build_dir}/toolchain/go/bin:${PATH}"
export GOTOOLCHAIN=local
export GOPATH="${build_dir}/gopath"
export GOCACHE="${build_dir}/gocache"
export CGO_ENABLED=0
export GOMAXPROCS=1
export GOFLAGS='-p=1'
"$xcaddy_bin" build "$CADDY_CORE_VERSION" \
--output "${build_dir}/caddy-candidate" \
--with "github.com/caddyserver/forwardproxy=github.com/klzgrad/forwardproxy@${FORWARDPROXY_COMMIT}" >&2
)
if [[ -n "$BUILD_SWAP_FILE" ]]; then
swapoff "$BUILD_SWAP_FILE"
rm -f -- "$BUILD_SWAP_FILE"
BUILD_SWAP_FILE=''
fi
PREPARED_CANDIDATE="${build_dir}/caddy-candidate"
}
prepare_upstream_prebuilt_caddy() {
local build_dir=$1
[[ "$(uname -m)" == 'x86_64' ]] ||
die 'Upstream prebuilt asset существует только для Linux amd64.'
warn 'Выбран upstream Caddy 2.11.2: он отстаёт от security-fix релизов Caddy 2.11.3/2.11.4.'
local archive="${build_dir}/${CADDY_ARCHIVE_NAME}"
download_file "$CADDY_ARCHIVE_URL" "$archive"
verify_download "$archive" "$CADDY_ARCHIVE_SHA256"
tar -xJf "$archive" -C "$build_dir"
local candidate="${build_dir}/caddy-forwardproxy-naive/caddy"
[[ -x "$candidate" ]] || chmod 0700 "$candidate"
PREPARED_CANDIDATE=$candidate
}
prepare_candidate_binary() {
ensure_runtime_dir
ensure_build_root
PREPARED_CANDIDATE=''
local build_dir
build_dir=$(mktemp -d "${BUILD_ROOT}/build.XXXXXX")
chmod 0700 "$build_dir"
if [[ "$SERVER_BUILD_MODE" == 'source_patched' ]]; then
prepare_source_built_caddy "$build_dir"
else
prepare_upstream_prebuilt_caddy "$build_dir"
fi
local candidate=$PREPARED_CANDIDATE
[[ -x "$candidate" ]] || die 'Кандидат Caddy не создан.'
"$candidate" version >/dev/null
caddy_has_forwardproxy "$candidate" ||
die 'Собранный Caddy не содержит http.handlers.forward_proxy.'
if [[ "$SERVER_BUILD_MODE" == 'source_patched' ]]; then
grep -Fq "${CADDY_CORE_VERSION#v}" <<<"$("$candidate" version)" ||
die 'Версия собранного Caddy не совпадает с закреплённой.'
fi
maybe_fail 'binary_staged'
}
ensure_service_identity() {
if ! getent group "$SERVICE_GROUP" >/dev/null; then
groupadd --system "$SERVICE_GROUP"
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system \
--gid "$SERVICE_GROUP" \
--home-dir "$CADDY_DATA_DIR" \
--no-create-home \
--shell /usr/sbin/nologin \
--comment 'NaiveProxy Caddy service' \
"$SERVICE_USER"
fi
}
install_layout() {
install -d -m 0750 -o root -g "$SERVICE_GROUP" -- "$CONFIG_DIR"
install -d -m 0750 -o root -g "$SERVICE_GROUP" -- "$STATE_DIR"
install -d -m 0750 -o "$SERVICE_USER" -g "$SERVICE_GROUP" -- "$CADDY_DATA_DIR" "$LOG_DIR"
install -d -m 0700 -o root -g root -- "$BACKUP_ROOT"
install -d -m 0755 -o root -g root -- "$WEB_ROOT"
}
render_service_unit() {
local target=$1
cat <"$target"
[Unit]
Description=NaiveProxy Caddy forward proxy
Documentation=https://github.com/klzgrad/naiveproxy
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
Environment=HOME=${CADDY_DATA_DIR}
Environment=XDG_DATA_HOME=${CADDY_DATA_DIR}
Environment=XDG_CONFIG_HOME=${CADDY_DATA_DIR}
ExecStart=${CADDY_BIN} run --environ --config ${CADDY_CONFIG} --adapter caddyfile
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=${STATE_DIR} ${LOG_DIR}
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
EOF
chmod 0644 "$target"
}
validate_service_unit() {
local unit=$1
systemd-analyze verify "$unit" >/dev/null
}
safe_remove_managed_target() {
local target=$1
local allowed=0 item
for item in \
"$CONFIG_DIR" "$CADDY_BIN" "$SERVICE_FILE" "$WEB_ROOT" "$OUTPUT_DIR" \
"$SELF_INSTALL_PATH" "$CADDY_DATA_DIR" "$LOG_DIR"; do
[[ "$target" == "$item" ]] && allowed=1
done
(( allowed == 1 )) || die "Отказ удаления неуправляемого пути: ${target}"
if [[ -L "$target" || -f "$target" ]]; then
rm -f -- "$target"
elif [[ -d "$target" ]]; then
rm -rf --one-file-system -- "$target"
fi
}
snapshot_target() {
local label=$1
local target=$2
local payload="${CURRENT_BACKUP}/payload"
if [[ -e "$target" || -L "$target" ]]; then
cp -a -- "$target" "${payload}/${label}"
printf '%s\n' "$label" >>"${CURRENT_BACKUP}/present.list"
fi
}
begin_transaction() {
FIREWALL_RULES_ADDED=()
FIREWALL_RULES_REMOVED=()
install -d -m 0700 -o root -g root -- "$BACKUP_ROOT"
CURRENT_BACKUP=$(mktemp -d "${BACKUP_ROOT}/transaction-$(date -u +%Y%m%dT%H%M%SZ)-XXXXXX")
chmod 0700 "$CURRENT_BACKUP"
install -d -m 0700 -- "${CURRENT_BACKUP}/payload"
: >"${CURRENT_BACKUP}/present.list"
service_is_active && SERVICE_WAS_ACTIVE=1 || SERVICE_WAS_ACTIVE=0
service_is_enabled && SERVICE_WAS_ENABLED=1 || SERVICE_WAS_ENABLED=0
printf 'service_active=%s\nservice_enabled=%s\n' \
"$SERVICE_WAS_ACTIVE" "$SERVICE_WAS_ENABLED" >"${CURRENT_BACKUP}/service.state"
snapshot_target 'config' "$CONFIG_DIR"
snapshot_target 'binary' "$CADDY_BIN"
snapshot_target 'unit' "$SERVICE_FILE"
snapshot_target 'web' "$WEB_ROOT"
snapshot_target 'clients' "$OUTPUT_DIR"
snapshot_target 'self' "$SELF_INSTALL_PATH"
snapshot_target 'caddy-data' "$CADDY_DATA_DIR"
snapshot_target 'logs' "$LOG_DIR"
install -d -m 0750 -o root -g "$SERVICE_GROUP" -- "$STATE_DIR"
local transaction_tmp
transaction_tmp=$(mktemp "${STATE_DIR}/.transaction.XXXXXX")
printf '{"schema_version":1,"status":"in_progress","backup":%s}\n' \
"$(printf '%s' "$CURRENT_BACKUP" | "$(find_python)" -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" \
>"$transaction_tmp"
chown root:"$SERVICE_GROUP" "$transaction_tmp"
chmod 0640 "$transaction_tmp"
mv -fT "$transaction_tmp" "$TRANSACTION_FILE"
# Arm immediately after the complete snapshot, before the first managed mutation.
TRANSACTION_ARMED=1
TRANSACTION_COMMITTED=0
maybe_fail 'backup_created'
}
backup_contains() {
local label=$1
grep -Fxq "$label" "${CURRENT_BACKUP}/present.list"
}
restore_target() {
local label=$1
local target=$2
safe_remove_managed_target "$target" || return 1
if backup_contains "$label"; then
cp -a -- "${CURRENT_BACKUP}/payload/${label}" "$target" || return 1
else
local rc=$?
(( rc == 1 )) || return "$rc"
fi
}
rollback_firewall_rules() {
have ufw || return 0
local failed=0
FIREWALL_RULES_ADDED=()
FIREWALL_RULES_REMOVED=()
if [[ -n "$CURRENT_BACKUP" && -f "${CURRENT_BACKUP}/firewall.added" ]]; then
mapfile -t FIREWALL_RULES_ADDED <"${CURRENT_BACKUP}/firewall.added"
fi
if [[ -n "$CURRENT_BACKUP" && -f "${CURRENT_BACKUP}/firewall.removed" ]]; then
mapfile -t FIREWALL_RULES_REMOVED <"${CURRENT_BACKUP}/firewall.removed"
fi
local rule
for rule in "${FIREWALL_RULES_ADDED[@]}"; do
if ufw_rule_exists "$rule"; then
ufw --force delete allow "$rule" comment "$APP_ID" >/dev/null 2>&1 || failed=1
fi
done
for rule in "${FIREWALL_RULES_REMOVED[@]}"; do
if ! ufw_rule_exists "$rule"; then
ufw allow "$rule" comment "$APP_ID" >/dev/null 2>&1 || failed=1
fi
done
(( failed == 0 ))
}
rollback_transaction() {
(( TRANSACTION_ARMED == 1 && TRANSACTION_COMMITTED == 0 )) || return 0
(( ROLLBACK_IN_PROGRESS == 0 )) || return 0
ROLLBACK_IN_PROGRESS=1
warn "Выполняю автоматический откат из ${CURRENT_BACKUP}."
set +e
local failed=0
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
service_is_active && failed=1
restore_target 'config' "$CONFIG_DIR" || failed=1
restore_target 'binary' "$CADDY_BIN" || failed=1
restore_target 'unit' "$SERVICE_FILE" || failed=1
restore_target 'web' "$WEB_ROOT" || failed=1
restore_target 'clients' "$OUTPUT_DIR" || failed=1
restore_target 'self' "$SELF_INSTALL_PATH" || failed=1
restore_target 'caddy-data' "$CADDY_DATA_DIR" || failed=1
restore_target 'logs' "$LOG_DIR" || failed=1
rollback_firewall_rules || failed=1
systemctl daemon-reload >/dev/null 2>&1 || failed=1
if (( SERVICE_WAS_ENABLED == 1 )); then
systemctl enable "$SERVICE_NAME" >/dev/null 2>&1 || failed=1
service_is_enabled || failed=1
else
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
service_is_enabled && failed=1
fi
if (( SERVICE_WAS_ACTIVE == 1 )); then
systemctl start "$SERVICE_NAME" >/dev/null 2>&1 || failed=1
service_is_active || failed=1
else
service_is_active && failed=1
fi
if (( failed == 0 )); then
rm -f -- "$TRANSACTION_FILE" || failed=1
fi
set -e
ROLLBACK_IN_PROGRESS=0
if (( failed != 0 )); then
error "Откат завершился не полностью. Маркер ${TRANSACTION_FILE} сохранён; требуется повторное восстановление."
return 1
fi
TRANSACTION_ARMED=0
warn 'Откат управляемых файлов завершён. Установленные apt-пакеты и системный пользователь не удалялись.'
}
commit_transaction() {
rm -f -- "$TRANSACTION_FILE"
printf '%s\n' 'committed' >"${CURRENT_BACKUP}/status"
TRANSACTION_COMMITTED=1
TRANSACTION_ARMED=0
}
handle_error() {
local exit_code=$1
local line=$2
local command=$3
trap - ERR
error "Сбой на строке ${line}; команда: ${command}."
rollback_transaction || true
exit "$exit_code"
}
handle_signal() {
trap - ERR INT TERM
error 'Выполнение прервано сигналом.'
rollback_transaction || true
exit 130
}
ufw_is_active() {
have ufw || return 1
local status
status=$(ufw status 2>/dev/null) || return 1
grep -Fq 'Status: active' <<<"${status%%$'\n'*}"
}
ufw_rule_exists() {
local rule=$1
local added
added=$(ufw show added 2>/dev/null) || return 1
grep -Fq "ufw allow ${rule} comment '${APP_ID}'" <<<"$added"
}
add_managed_ufw_rule() {
local rule=$1
ufw_rule_exists "$rule" && return 0
FIREWALL_RULES_ADDED+=("$rule")
[[ -n "$CURRENT_BACKUP" ]] && printf '%s\n' "$rule" >>"${CURRENT_BACKUP}/firewall.added"
ufw allow "$rule" comment "$APP_ID" >/dev/null
}
remove_managed_ufw_rule() {
local rule=$1
ufw_rule_exists "$rule" || return 0
FIREWALL_RULES_REMOVED+=("$rule")
[[ -n "$CURRENT_BACKUP" ]] && printf '%s\n' "$rule" >>"${CURRENT_BACKUP}/firewall.removed"
ufw --force delete allow "$rule" comment "$APP_ID" >/dev/null
}
apply_firewall() {
[[ "$MANAGE_UFW" == 'true' ]] || {
info 'Управление UFW отключено; проверьте firewall самостоятельно.'
return 0
}
if ! ufw_is_active; then
info 'UFW не активен; мастер не включает его автоматически.'
return 0
fi
add_managed_ufw_rule '443/tcp'
if [[ "$HTTP3_ENABLED" == 'true' ]]; then
add_managed_ufw_rule '443/udp'
else
remove_managed_ufw_rule '443/udp'
fi
if [[ "$OPEN_HTTP_PORT" == 'true' ]]; then
add_managed_ufw_rule '80/tcp'
else
remove_managed_ufw_rule '80/tcp'
fi
success 'Управляемые правила UFW синхронизированы.'
maybe_fail 'firewall_changed'
}
install_file_atomically() {
local source=$1
local target=$2
local mode=$3
local owner=$4
local group=$5
local parent temp
parent=$(dirname "$target")
if [[ -e "$parent" || -L "$parent" ]]; then
[[ -d "$parent" && ! -L "$parent" ]] ||
die "Небезопасный родительский каталог для атомарной установки: ${parent}"
else
install -d -m 0755 -- "$parent"
fi
temp=$(mktemp "${parent}/.$(basename "$target").new.XXXXXX")
install -m "$mode" -o "$owner" -g "$group" -- "$source" "$temp"
mv -fT -- "$temp" "$target"
}
prepare_tls_assets() {
if [[ "$TLS_MODE" != 'existing' ]]; then
return 0
fi
validate_existing_certificate
install -d -m 0750 -o root -g "$SERVICE_GROUP" -- "$(dirname "$MANAGED_CERT")"
local cert_tmp key_tmp
cert_tmp=$(mktemp "$(dirname "$MANAGED_CERT")/.fullchain.new.XXXXXX")
key_tmp=$(mktemp "$(dirname "$MANAGED_KEY")/.privkey.new.XXXXXX")
install -m 0644 -o root -g "$SERVICE_GROUP" -- "$SOURCE_CERT_PATH" "$cert_tmp"
install -m 0640 -o root -g "$SERVICE_GROUP" -- "$SOURCE_KEY_PATH" "$key_tmp"
mv -fT -- "$cert_tmp" "$MANAGED_CERT"
mv -fT -- "$key_tmp" "$MANAGED_KEY"
}
replace_static_site() {
if [[ "$DECOY_MODE" != 'static' ]]; then
safe_remove_managed_target "$WEB_ROOT"
return 0
fi
local parent stage old
parent=$(dirname "$WEB_ROOT")
install -d -m 0755 -- "$parent"
stage=$(mktemp -d "${parent}/.naiveproxy-web.new.XXXXXX")
chmod 0755 "$stage"
render_static_site "$stage" "$DECOY_TITLE"
old="${WEB_ROOT}.old.$$"
if [[ -e "$WEB_ROOT" || -L "$WEB_ROOT" ]]; then
[[ ! -e "$old" && ! -L "$old" ]] || die "Временный путь сайта уже существует: ${old}"
mv -T -- "$WEB_ROOT" "$old"
fi
mv -T -- "$stage" "$WEB_ROOT"
if [[ -e "$old" || -L "$old" ]]; then
rm -rf --one-file-system -- "$old"
fi
}
install_candidate_config() {
local plan_file=$1
local users_file=$2
local caddy_file=$3
install_file_atomically "$plan_file" "$SETTINGS_FILE" 0640 root "$SERVICE_GROUP"
install_file_atomically "$users_file" "$USERS_FILE" 0640 root "$SERVICE_GROUP"
install_file_atomically "$caddy_file" "$CADDY_CONFIG" 0640 root "$SERVICE_GROUP"
}
install_candidate_service() {
local unit_temp
unit_temp=$(mktemp "${RUNTIME_DIR}/naiveproxy-candidate.XXXXXX.service")
render_service_unit "$unit_temp"
validate_service_unit "$unit_temp"
install_file_atomically "$unit_temp" "$SERVICE_FILE" 0644 root root
systemctl daemon-reload
}
validate_live_candidate() {
[[ -x "$CADDY_BIN" ]] || die 'Caddy binary не установлен.'
caddy_has_forwardproxy "$CADDY_BIN" ||
die 'Установленный Caddy потерял forwardproxy module.'
chown root:"$SERVICE_GROUP" "$CADDY_CONFIG" "$SETTINGS_FILE" "$USERS_FILE"
chmod 0640 "$CADDY_CONFIG" "$SETTINGS_FILE" "$USERS_FILE"
local validation_log
validation_log=$(mktemp "${RUNTIME_DIR}/caddy-validation.XXXXXX")
chmod 0600 "$validation_log"
if ! runuser -u "$SERVICE_USER" -- env \
"HOME=${CADDY_DATA_DIR}" \
"XDG_DATA_HOME=${CADDY_DATA_DIR}" \
"XDG_CONFIG_HOME=${CADDY_DATA_DIR}" \
"$CADDY_BIN" adapt --config "$CADDY_CONFIG" --adapter caddyfile \
--validate >/dev/null 2>"$validation_log"; then
die 'Caddy adapt/validate отклонил конфигурацию; stderr скрыт, чтобы не раскрыть credentials.'
return 1
fi
: >"$validation_log"
if ! runuser -u "$SERVICE_USER" -- env \
"HOME=${CADDY_DATA_DIR}" \
"XDG_DATA_HOME=${CADDY_DATA_DIR}" \
"XDG_CONFIG_HOME=${CADDY_DATA_DIR}" \
"$CADDY_BIN" validate --config "$CADDY_CONFIG" --adapter caddyfile \
>/dev/null 2>"$validation_log"; then
die 'Caddy validate отклонил конфигурацию; stderr скрыт, чтобы не раскрыть credentials.'
return 1
fi
rm -f -- "$validation_log"
maybe_fail 'config_validated'
}
install_self_copy() {
local source_path=${BASH_SOURCE[0]}
if [[ -f "$source_path" && ! -L "$source_path" ]]; then
install_file_atomically "$source_path" "$SELF_INSTALL_PATH" 0755 root root
else
warn 'Мастер запущен из stdin; постоянная копия не установлена. Скачайте файл и запустите повторно.'
fi
}
replace_client_bundle() {
local plan_file=$1
local users_file=$2
local parent stage old
parent=$(dirname "$OUTPUT_DIR")
install -d -m 0700 -o root -g root -- "$parent"
[[ ! -L "$parent" ]] || die 'Родительский каталог экспорта является symlink.'
stage=$(mktemp -d "${parent}/.$(basename "$OUTPUT_DIR").new.XXXXXX")
chmod 0700 "$stage"
stage_client_bundle "$plan_file" "$users_file" "$stage"
old="${OUTPUT_DIR}.old.$$"
if [[ -e "$OUTPUT_DIR" || -L "$OUTPUT_DIR" ]]; then
[[ -d "$OUTPUT_DIR" && ! -L "$OUTPUT_DIR" ]] || die 'Текущий каталог клиентов имеет небезопасный тип.'
[[ ! -e "$old" && ! -L "$old" ]] || die 'Конфликт временного каталога клиентов.'
mv -T -- "$OUTPUT_DIR" "$old"
fi
mv -T -- "$stage" "$OUTPUT_DIR"
if [[ -e "$old" || -L "$old" ]]; then
rm -rf --one-file-system -- "$old"
fi
chmod 0700 "$OUTPUT_DIR"
find "$OUTPUT_DIR" -type f -exec chmod 0600 {} +
}
wait_for_service() {
local _
for _ in $(seq 1 30); do
service_is_active && return 0
sleep 1
done
systemctl status "$SERVICE_NAME" --no-pager -l >&2 || true
journalctl -u "$SERVICE_NAME" -n 80 --no-pager >&2 || true
die 'Сервис не перешёл в active.'
}
wait_for_tls_and_decoy() {
local _ code
for _ in $(seq 1 90); do
code=$(curl -sS -o /dev/null -w '%{http_code}' \
--resolve "${DOMAIN}:443:127.0.0.1" \
--connect-timeout 3 --max-time 8 \
"https://${DOMAIN}/" 2>/dev/null || true)
if [[ "$code" =~ ^[234][0-9][0-9]$ ]]; then
return 0
fi
sleep 2
done
journalctl -u "$SERVICE_NAME" -n 100 --no-pager >&2 || true
die 'TLS/decoy не вернули HTTP 2xx, 3xx или 4xx за 180 секунд.'
}
verify_h2_alpn() {
local output
output=$(mktemp "${RUNTIME_DIR}/openssl-alpn.XXXXXX")
chmod 0600 "$output"
timeout 15 openssl s_client \
-connect '127.0.0.1:443' \
-servername "$DOMAIN" -alpn h2 \
"$output" 2>/dev/null || true
if ! grep -Fq 'ALPN protocol: h2' "$output"; then
rm -f -- "$output"
die 'TLS listener не согласовал ALPN h2.'
fi
rm -f -- "$output"
}
verify_proxy_connect() {
local username=${USERNAMES[0]}
local password=${PASSWORDS[0]}
local curl_config
curl_config=$(mktemp "${RUNTIME_DIR}/curl-proxy.XXXXXX")
chmod 0600 "$curl_config"
{
printf 'proxy = "https://%s:443"\n' "$DOMAIN"
printf 'proxy-user = "%s:%s"\n' "$username" "$password"
printf 'resolve = "%s:443:127.0.0.1"\n' "$DOMAIN"
printf 'url = "https://example.com/"\n'
printf 'output = "/dev/null"\n'
printf 'silent\nshow-error\nfail\nconnect-timeout = 8\nmax-time = 30\n'
} >"$curl_config"
curl --config "$curl_config"
rm -f -- "$curl_config"
}
can_run_default_proxy_health_check() {
if [[ -z "$UPSTREAM_URL" && "$TARGET_PORTS_MODE" != 'unrestricted' ]]; then
[[ " ${TARGET_PORTS} " == *' 443 '* ]] || return 1
fi
[[ "$ACL_MODE" == 'hardened' || "$ACL_MODE" == 'plugin_default' ]] || return 1
}
verify_proxy_connect_if_supported() {
if can_run_default_proxy_health_check; then
verify_proxy_connect
success 'Авторизованный CONNECT к контрольному HTTPS-сайту работает.'
else
warn 'Полный CONNECT smoke пропущен: выбранные ACL/порты не гарантируют доступ к стандартной контрольной цели. TLS, конфигурация, H2 и listeners всё равно проверены.'
fi
}
verify_listeners() {
[[ -n "$(socket_lines tcp 443 || true)" ]] || die 'TCP/443 не слушается.'
if [[ "$HTTP3_ENABLED" == 'true' ]]; then
[[ -n "$(socket_lines udp 443 || true)" ]] || die 'HTTP/3 выбран, но UDP/443 не слушается.'
else
[[ -z "$(socket_lines udp 443 || true)" ]] ||
die 'HTTP/3 отключён, но UDP/443 всё равно слушается.'
fi
}
run_local_health_checks() {
info 'Жду TLS и выполняю локальные проверки.'
wait_for_service
wait_for_tls_and_decoy
verify_h2_alpn
verify_listeners
verify_proxy_connect_if_supported
success 'TLS, H2 и listener работают.'
maybe_fail 'local_health'
}
write_manifest() {
local target=$1
local python_bin
python_bin=$(find_python) || die 'Для manifest нужен Python 3.'
"$python_bin" - "$target" "$APP_VERSION" "$CONFIG_DIR" \
"$CADDY_BIN" "$CADDY_CONFIG" "$SETTINGS_FILE" "$USERS_FILE" \
"$SERVICE_FILE" "$SELF_INSTALL_PATH" "$MANAGED_CERT" "$MANAGED_KEY" \
"$WEB_ROOT" "$OUTPUT_DIR" <<'PY'
import hashlib
import json
import os
import pathlib
import sys
from datetime import datetime, timezone
target = pathlib.Path(sys.argv[1])
wizard_version = sys.argv[2]
config_root = pathlib.Path(sys.argv[3])
roots = [pathlib.Path(value) for value in sys.argv[4:]]
resources = {}
def add_resource(path):
if path.is_symlink() or not path.exists():
return
stat = path.stat()
entry = {
"kind": "directory" if path.is_dir() else "file",
"mode": oct(stat.st_mode & 0o777),
"uid": stat.st_uid,
"gid": stat.st_gid,
}
if path.is_file():
entry["sha256"] = hashlib.sha256(path.read_bytes()).hexdigest()
resources[str(path)] = entry
add_resource(config_root)
for root in roots:
add_resource(root)
if root.is_dir() and not root.is_symlink():
for path in sorted(root.rglob("*")):
add_resource(path)
document = {
"schema_version": 1,
"owner": "naiveproxy-server-wizard",
"wizard_version": wizard_version,
"created_at": datetime.now(timezone.utc).isoformat(),
"resources": resources,
}
target.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
target.chmod(0o640)
PY
}
install_manifest() {
local temp
temp=$(mktemp "${RUNTIME_DIR}/manifest.XXXXXX")
write_manifest "$temp"
install_file_atomically "$temp" "$MANIFEST_FILE" 0640 root "$SERVICE_GROUP"
}
verify_manifest_drift() {
[[ -f "$MANIFEST_FILE" && ! -L "$MANIFEST_FILE" ]] || return 0
local python_bin
python_bin=$(find_python) || die 'Для проверки drift нужен Python 3.'
"$python_bin" - "$MANIFEST_FILE" <<'PY'
import hashlib
import json
import os
import pathlib
import sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
manifest = json.load(fh)
bad = []
for name, expected in manifest.get("resources", {}).items():
path = pathlib.Path(name)
expected_kind = expected.get("kind", "file")
kind_matches = path.is_dir() if expected_kind == "directory" else path.is_file()
if not kind_matches or path.is_symlink():
bad.append(name)
continue
stat = path.stat()
metadata_matches = (
oct(stat.st_mode & 0o777) == expected.get("mode")
and stat.st_uid == expected.get("uid")
and stat.st_gid == expected.get("gid")
)
content_matches = (
expected_kind == "directory"
or hashlib.sha256(path.read_bytes()).hexdigest() == expected.get("sha256")
)
if not metadata_matches or not content_matches:
bad.append(name)
if bad:
for item in bad:
print(item)
raise SystemExit(2)
PY
}
prepare_plan_artifacts() {
ensure_runtime_dir
local plan_file users_file caddy_file
plan_file=$(mktemp "${RUNTIME_DIR}/plan.XXXXXX")
users_file=$(mktemp "${RUNTIME_DIR}/users.XXXXXX")
caddy_file=$(mktemp "${RUNTIME_DIR}/Caddyfile.XXXXXX")
write_plan_json "$plan_file"
write_users_file "$users_file"
render_caddyfile "$plan_file" "$users_file" >"$caddy_file"
chmod 0600 "$plan_file" "$users_file" "$caddy_file"
printf '%s\n%s\n%s\n' "$plan_file" "$users_file" "$caddy_file"
}
apply_installation() {
local candidate_binary=$1
local plan_file=$2
local users_file=$3
local caddy_file=$4
# The service account is intentionally outside the managed rollback boundary.
# It must exist before begin_transaction creates group-owned transaction state.
ensure_service_identity
begin_transaction
install_layout
prepare_tls_assets
replace_static_site
install_file_atomically "$candidate_binary" "$CADDY_BIN" 0755 root root
install_candidate_config "$plan_file" "$users_file" "$caddy_file"
maybe_fail 'config_committed'
install_candidate_service
validate_live_candidate
apply_firewall
install_self_copy
systemctl enable "$SERVICE_NAME" >/dev/null
systemctl restart "$SERVICE_NAME"
maybe_fail 'service_started'
run_local_health_checks
replace_client_bundle "$plan_file" "$users_file"
maybe_fail 'client_bundle_written'
install_manifest
commit_transaction
success "Установка завершена. Клиентские файлы: ${OUTPUT_DIR}"
}
prompt_domain() {
local default_value=${1:-}
local value
while true; do
value=$(prompt_value 'Домен с готовой A/AAAA-записью' "$default_value")
value=${value,,}
if is_valid_domain "$value"; then
DOMAIN=$value
return 0
fi
warn 'Введите полноценный домен, например proxy.example.com.'
done
}
prompt_acme_email() {
local default_value=${1:-}
local value
while true; do
value=$(prompt_value 'Email для уведомлений ACME (можно оставить пустым)' "$default_value")
if is_valid_email_or_empty "$value"; then
ACME_EMAIL=$value
return 0
fi
warn 'Некорректный email.'
done
}
prompt_count() {
local label=$1
local default_value=${2:-1}
local value
while true; do
value=$(prompt_value "$label" "$default_value")
if is_valid_count "$value"; then
printf '%s' "$((10#$value))"
return 0
fi
warn 'Введите число от 1 до 500.'
done
}
prompt_positive_int() {
local label=$1
local default_value=$2
local value
while true; do
value=$(prompt_value "$label" "$default_value")
if is_valid_positive_int "$value"; then
printf '%s' "$((10#$value))"
return 0
fi
warn 'Введите положительное целое число.'
done
}
prompt_duration() {
local label=$1
local default_value=$2
local value
while true; do
value=$(prompt_value "$label" "$default_value")
if is_valid_duration "$value"; then
printf '%s' "$value"
return 0
fi
warn 'Используйте формат 500ms, 30s, 2m или 1h.'
done
}
collect_manual_users() {
local count=$1
USERNAMES=()
PASSWORDS=()
local index username password
for ((index = 1; index <= count; index++)); do
while true; do
username=$(prompt_value "Логин пользователя ${index}")
is_valid_username "$username" || {
warn 'Логин: 1–64 символа A-Z, a-z, 0-9, точка, _ или -.'
continue
}
username_exists "$username" && {
warn 'Такой логин уже добавлен.'
continue
}
break
done
while true; do
password=$(prompt_secret "Пароль пользователя ${username}")
is_valid_password "$password" || {
warn 'Пароль: 12–128 URI-safe ASCII символов без пробелов, кавычек и двоеточия.'
continue
}
break
done
USERNAMES+=("$username")
PASSWORDS+=("$password")
done
}
collect_imported_users() {
local path
while true; do
path=$(prompt_value 'Абсолютный путь к TSV-файлу loginpassword')
if is_safe_absolute_path "$path" && [[ -f "$path" && ! -L "$path" ]]; then
load_users "$path"
return 0
fi
warn 'Нужен обычный абсолютный файл, не symlink.'
done
}
collect_users() {
local allow_keep=${1:-false}
local choice count prefix
local -a labels=(
'Сгенерировать логины и стойкие пароли'
'Ввести логины и пароли вручную'
'Импортировать защищённый TSV'
)
if [[ "$allow_keep" == 'true' && ${#USERNAMES[@]} -gt 0 ]]; then
labels+=("Сохранить текущих пользователей (${#USERNAMES[@]})")
fi
choice=$(menu 'Учётные данные' "${labels[@]}")
case "$choice" in
1)
count=$(prompt_count 'Количество пользователей' '1')
while true; do
prefix=$(prompt_value 'Префикс логинов' 'user')
is_valid_username "$prefix" && break
warn 'Недопустимый префикс.'
done
create_generated_users "$count" "$prefix"
;;
2)
count=$(prompt_count 'Количество пользователей' '1')
collect_manual_users "$count"
;;
3)
collect_imported_users
;;
4)
[[ "$allow_keep" == 'true' ]] || die 'Некорректный выбор пользователей.'
validate_user_arrays || die 'Текущий набор пользователей повреждён.'
;;
*) die 'Некорректный выбор пользователей.' ;;
esac
}
collect_tls() {
local choice
choice=$(menu 'TLS-сертификат' \
'Автоматический публичный сертификат Caddy/ACME' \
'Существующие fullchain.pem и private key')
if [[ "$choice" == '1' ]]; then
TLS_MODE='acme'
SOURCE_CERT_PATH=''
SOURCE_KEY_PATH=''
prompt_acme_email "$ACME_EMAIL"
else
TLS_MODE='existing'
while true; do
SOURCE_CERT_PATH=$(prompt_value 'Абсолютный путь к fullchain.pem' "$SOURCE_CERT_PATH")
is_safe_absolute_path "$SOURCE_CERT_PATH" && break
warn 'Некорректный абсолютный путь.'
done
while true; do
SOURCE_KEY_PATH=$(prompt_value 'Абсолютный путь к private key' "$SOURCE_KEY_PATH")
is_safe_absolute_path "$SOURCE_KEY_PATH" && break
warn 'Некорректный абсолютный путь.'
done
ACME_EMAIL=''
fi
}
collect_transport() {
local choice
choice=$(menu 'Транспорт' \
'HTTPS / HTTP/2 (TCP 443)' \
'HTTPS / HTTP/2 + QUIC / HTTP/3 (TCP и UDP 443)')
if [[ "$choice" == '1' ]]; then
HTTP3_ENABLED='false'
else
HTTP3_ENABLED='true'
fi
}
collect_decoy() {
local choice
choice=$(menu 'Обычный сайт на том же домене' \
'Встроенный многофайловый сайт-заглушка' \
'Reverse proxy на существующий HTTPS-сайт' \
'Перенаправление на другой HTTPS-сайт' \
'Нейтральный ответ 404')
case "$choice" in
1)
DECOY_MODE='static'
DECOY_UPSTREAM=''
DECOY_REDIRECT=''
DECOY_TITLE=$(prompt_value 'Заголовок сайта' "${DECOY_TITLE:-Welcome}")
;;
2)
DECOY_MODE='reverse_proxy'
while true; do
DECOY_UPSTREAM=$(prompt_value 'HTTPS URL upstream-сайта' "$DECOY_UPSTREAM")
is_valid_decoy_url "$DECOY_UPSTREAM" && break
warn 'Нужен HTTPS URL без пробелов и кавычек.'
done
;;
3)
DECOY_MODE='redirect'
while true; do
DECOY_REDIRECT=$(prompt_value 'HTTPS URL перенаправления' "$DECOY_REDIRECT")
is_valid_decoy_url "$DECOY_REDIRECT" && break
warn 'Нужен HTTPS URL без пробелов и кавычек.'
done
;;
4)
DECOY_MODE='respond'
;;
esac
}
collect_target_ports() {
local choice value
choice=$(menu 'Разрешённые порты назначения через proxy' \
'Только web: 80 и 443 (рекомендуется)' \
'Расширенный безопасный набор' \
'Собственный список' \
'Без ограничения портов (опаснее)')
case "$choice" in
1)
TARGET_PORTS_MODE='web'
TARGET_PORTS='80 443'
;;
2)
TARGET_PORTS_MODE='common'
TARGET_PORTS='53 80 123 443 465 587 853 993 995 5222 5223 8080 8443'
;;
3)
TARGET_PORTS_MODE='custom'
while true; do
value=$(prompt_value 'Порты через пробел или запятую' "$TARGET_PORTS")
if validate_port_list "$value"; then
TARGET_PORTS=$(normalize_space_list "$value")
break
fi
warn 'Некорректный список портов.'
done
;;
4)
TARGET_PORTS_MODE='unrestricted'
TARGET_PORTS=''
warn 'Без ports-ограничения пользователи смогут обращаться к любому внешнему TCP-порту.'
;;
esac
}
collect_acl() {
local choice value
choice=$(menu 'ACL назначения' \
'Усиленная блокировка private/link-local/metadata (рекомендуется)' \
'Усиленная блокировка + собственный deny-list' \
'Разрешать только собственный allow-list' \
'Встроенный ACL плагина')
case "$choice" in
1)
ACL_MODE='hardened'
ACL_CUSTOM=''
;;
2)
ACL_MODE='custom_deny'
while true; do
value=$(prompt_value 'CIDR/домены для дополнительной блокировки')
if validate_acl_list "$value"; then
ACL_CUSTOM=$(normalize_space_list "$value")
break
fi
warn 'Некорректный ACL.'
done
;;
3)
ACL_MODE='allowlist'
while true; do
value=$(prompt_value 'Разрешённые CIDR/домены')
if validate_acl_list "$value"; then
ACL_CUSTOM=$(normalize_space_list "$value")
break
fi
warn 'Некорректный ACL.'
done
;;
4)
ACL_MODE='plugin_default'
ACL_CUSTOM=''
warn 'Встроенный ACL закрывает не все private/metadata диапазоны.'
;;
esac
}
collect_probe_and_pac() {
local choice
choice=$(menu 'Probe resistance' \
'Неверная авторизация незаметно попадает на обычный сайт' \
'Секретный домен для browser-auth challenge')
if [[ "$choice" == '1' ]]; then
PROBE_MODE='fallthrough'
PROBE_SECRET=''
else
PROBE_MODE='secret_domain'
while true; do
PROBE_SECRET=$(prompt_secret 'Secret-domain (ввод скрыт)')
is_valid_domain "$PROBE_SECRET" && break
warn 'Некорректный secret-domain.'
done
fi
if confirm 'Создать секретный PAC endpoint?' 'no'; then
PAC_ENABLED='true'
PAC_PATH="/$(generate_secret_label)/proxy.pac"
else
PAC_ENABLED='false'
PAC_PATH=''
fi
}
collect_upstream() {
local choice
choice=$(menu 'Маршрут исходящего proxy-трафика' \
'Напрямую с этого VPS' \
'Через следующий HTTPS/SOCKS5 proxy hop')
if [[ "$choice" == '1' ]]; then
UPSTREAM_URL=''
else
while true; do
UPSTREAM_URL=$(prompt_secret 'Upstream URL (ввод скрыт; значение сохранится в root-owned config)')
is_valid_upstream_url "$UPSTREAM_URL" && break
warn 'Разрешены https://remote или http/socks5 на 127.0.0.1 без пробелов и кавычек.'
done
TARGET_PORTS_MODE='unrestricted'
TARGET_PORTS=''
ACL_MODE='plugin_default'
ACL_CUSTOM=''
warn 'Upstream несовместим с ports/ACL плагина; эти ограничения отключены.'
fi
}
collect_quick_configuration() {
local existing=${1:-false}
INSTALL_MODE='quick'
SERVER_BUILD_MODE='source_patched'
prompt_domain "$DOMAIN"
TLS_MODE='acme'
prompt_acme_email "$ACME_EMAIL"
if [[ "$existing" == 'true' ]]; then
validate_user_arrays || die 'Текущий набор пользователей повреждён.'
info "Быстрый режим сохраняет ${#USERNAMES[@]} текущих пользователей и их пароли."
else
local count prefix
count=$(prompt_count 'Количество пользователей' '1')
prefix=$(prompt_value 'Префикс логинов' 'user')
create_generated_users "$count" "$prefix"
fi
HTTP3_ENABLED='true'
AUTO_HTTPS_REDIRECTS='true'
DECOY_MODE='static'
DECOY_TITLE='Web Service'
PROBE_MODE='fallthrough'
PAC_ENABLED='false'
HIDE_IP='true'
HIDE_VIA='true'
TARGET_PORTS_MODE='web'
TARGET_PORTS='80 443'
ACL_MODE='hardened'
ACL_CUSTOM=''
UPSTREAM_URL=''
DIAL_TIMEOUT='30s'
MAX_IDLE_CONNS='256'
MAX_IDLE_CONNS_PER_HOST='8'
MANAGE_UFW='true'
OPEN_HTTP_PORT='true'
}
collect_guided_configuration() {
local allow_keep=${1:-false}
INSTALL_MODE='guided'
SERVER_BUILD_MODE='source_patched'
prompt_domain "$DOMAIN"
collect_tls
collect_transport
collect_users "$allow_keep"
collect_decoy
collect_target_ports
collect_acl
collect_probe_and_pac
HIDE_IP='true'
HIDE_VIA='true'
UPSTREAM_URL=''
DIAL_TIMEOUT='30s'
MAX_IDLE_CONNS='256'
MAX_IDLE_CONNS_PER_HOST='8'
MANAGE_UFW='true'
OPEN_HTTP_PORT='true'
}
collect_expert_configuration() {
local allow_keep=${1:-false}
INSTALL_MODE='expert'
prompt_domain "$DOMAIN"
collect_tls
collect_transport
collect_users "$allow_keep"
collect_decoy
collect_probe_and_pac
collect_upstream
if [[ -z "$UPSTREAM_URL" ]]; then
collect_target_ports
collect_acl
fi
confirm 'Скрывать IP пользователя в Forwarded?' 'yes' && HIDE_IP='true' || HIDE_IP='false'
confirm 'Удалять Via header?' 'yes' && HIDE_VIA='true' || HIDE_VIA='false'
confirm 'Оставить автоматический HTTP→HTTPS redirect?' 'yes' &&
AUTO_HTTPS_REDIRECTS='true' || AUTO_HTTPS_REDIRECTS='false'
confirm 'Открывать TCP/80 в UFW?' 'yes' && OPEN_HTTP_PORT='true' || OPEN_HTTP_PORT='false'
confirm 'Синхронизировать правила активного UFW?' 'yes' && MANAGE_UFW='true' || MANAGE_UFW='false'
DIAL_TIMEOUT=$(prompt_duration 'Таймаут подключения к сайту назначения' "$DIAL_TIMEOUT")
MAX_IDLE_CONNS=$(prompt_positive_int 'Максимум idle-соединений всего' "$MAX_IDLE_CONNS")
MAX_IDLE_CONNS_PER_HOST=$(prompt_positive_int 'Максимум idle-соединений на host' "$MAX_IDLE_CONNS_PER_HOST")
local build_choice
if [[ "$(uname -m)" != 'x86_64' ]]; then
SERVER_BUILD_MODE='source_patched'
info 'Для arm64 доступна только актуальная source build; legacy upstream asset опубликован лишь для amd64.'
else
build_choice=$(menu 'Сборка Caddy' \
"Caddy ${CADDY_CORE_VERSION} + pinned plugin commit (рекомендуется)" \
"Официальный upstream asset ${CADDY_RELEASE_TAG} (устаревший Caddy, только amd64)")
if [[ "$build_choice" == '1' ]]; then
SERVER_BUILD_MODE='source_patched'
else
SERVER_BUILD_MODE='upstream_prebuilt'
fi
fi
}
show_plan() {
printf '\nПлан установки\n' >&2
printf ' Домен: %s\n' "$DOMAIN" >&2
printf ' Listener: TCP/443%s\n' \
"$( [[ "$HTTP3_ENABLED" == 'true' ]] && printf ' + UDP/443' || true )" >&2
printf ' TLS: %s\n' "$TLS_MODE" >&2
printf ' Пользователей: %d\n' "${#USERNAMES[@]}" >&2
printf ' Сайт: %s\n' "$DECOY_MODE" >&2
printf ' Probe resistance: %s\n' "$PROBE_MODE" >&2
printf ' PAC: %s\n' "$PAC_ENABLED" >&2
printf ' Порты назначения: %s\n' "$TARGET_PORTS_MODE" >&2
printf ' ACL: %s\n' "$ACL_MODE" >&2
printf ' Upstream hop: %s\n' "$( [[ -n "$UPSTREAM_URL" ]] && printf 'настроен' || printf 'нет' )" >&2
printf ' UFW: %s\n' "$MANAGE_UFW" >&2
printf ' Caddy: %s\n' "$SERVER_BUILD_MODE" >&2
printf ' Клиентские секреты: %s (права 0700/0600)\n\n' "$OUTPUT_DIR" >&2
}
stage_installed_binary() {
[[ -x "$CADDY_BIN" && ! -L "$CADDY_BIN" ]] || return 1
caddy_has_forwardproxy "$CADDY_BIN" || return 1
local installed_version
installed_version=$("$CADDY_BIN" version 2>/dev/null) || return 1
if [[ "$SERVER_BUILD_MODE" == 'source_patched' ]]; then
grep -Fq "${CADDY_CORE_VERSION}" <<<"$installed_version" || return 1
else
grep -Fq 'v2.11.2' <<<"$installed_version" || return 1
fi
ensure_runtime_dir
local candidate
candidate=$(mktemp "${RUNTIME_DIR}/caddy-existing.XXXXXX")
install -m 0700 -- "$CADDY_BIN" "$candidate"
printf '%s' "$candidate"
}
confirm_drift_if_needed() {
local drift_output
if ! drift_output=$(verify_manifest_drift 2>&1); then
warn 'Управляемые файлы изменены вне мастера:'
printf '%s\n' "$drift_output" >&2
confirm 'Принять текущее состояние и продолжить с перезаписью?' 'no' ||
die 'Операция отменена из-за config drift.'
fi
}
run_preflight() {
check_supported_platform
check_unmanaged_conflicts
check_port_conflicts
validate_dns
if [[ "$TLS_MODE" == 'existing' ]]; then
validate_existing_certificate
fi
success 'Preflight завершён без блокирующих ошибок.'
}
run_install_flow() {
local existing='false'
if [[ -f "$SETTINGS_FILE" && -f "$USERS_FILE" ]]; then
existing='true'
load_settings
load_users
confirm_drift_if_needed
fi
local mode
mode=$(menu 'Режим настройки' \
'Быстрый — минимум вопросов и безопасные значения' \
'Пошаговый — основные варианты' \
'Экспертный — все поддерживаемые параметры')
case "$mode" in
1) collect_quick_configuration "$existing" ;;
2) collect_guided_configuration "$existing" ;;
3) collect_expert_configuration "$existing" ;;
esac
validate_settings
validate_user_arrays || die 'Невалидный набор пользователей.'
show_plan
confirm 'Применить этот план?' 'no' || die 'Установка отменена.'
install_dependencies
run_preflight
local candidate_binary
if [[ "$existing" == 'true' ]]; then
candidate_binary=$(stage_installed_binary || true)
fi
if [[ -z "${candidate_binary:-}" ]]; then
prepare_candidate_binary
candidate_binary=$PREPARED_CANDIDATE
fi
local -a artifacts=()
mapfile -t artifacts < <(prepare_plan_artifacts)
(( ${#artifacts[@]} == 3 )) || die 'Не удалось подготовить plan artifacts.'
apply_installation "$candidate_binary" "${artifacts[0]}" "${artifacts[1]}" "${artifacts[2]}"
}
append_generated_users() {
local count=$1
local prefix=$2
local index=1 added=0 username
while (( added < count )); do
printf -v username '%s-%03d' "$prefix" "$index"
((++index))
username_exists "$username" && continue
is_valid_username "$username" || die "Невалидное сгенерированное имя: ${username}"
USERNAMES+=("$username")
PASSWORDS+=("$(generate_password)")
((++added))
done
}
append_manual_users() {
local count=$1
local index username password
for ((index = 1; index <= count; index++)); do
while true; do
username=$(prompt_value "Новый логин ${index}")
is_valid_username "$username" || {
warn 'Недопустимый логин.'
continue
}
username_exists "$username" && {
warn 'Такой логин уже существует.'
continue
}
break
done
while true; do
password=$(prompt_secret "Пароль ${username}")
is_valid_password "$password" && break
warn 'Пароль не соответствует безопасному формату.'
done
USERNAMES+=("$username")
PASSWORDS+=("$password")
done
}
apply_state_change() {
local action_label=$1
validate_settings
validate_user_arrays || die 'Невалидный набор пользователей.'
show_plan
confirm "${action_label}?" 'no' || die 'Операция отменена.'
confirm_drift_if_needed
check_port_conflicts
local candidate_binary
candidate_binary=$(stage_installed_binary) || die 'Установленный Caddy не прошёл проверку.'
local -a artifacts=()
mapfile -t artifacts < <(prepare_plan_artifacts)
(( ${#artifacts[@]} == 3 )) || die 'Не удалось подготовить plan artifacts.'
apply_installation "$candidate_binary" "${artifacts[0]}" "${artifacts[1]}" "${artifacts[2]}"
}
add_users_flow() {
load_settings
load_users
local choice count prefix
choice=$(menu 'Добавление пользователей' \
'Сгенерировать автоматически' \
'Ввести вручную')
count=$(prompt_count 'Сколько пользователей добавить' '1')
(( ${#USERNAMES[@]} + count <= 500 )) || die 'Итоговое количество превысит 500.'
if [[ "$choice" == '1' ]]; then
while true; do
prefix=$(prompt_value 'Префикс новых логинов' 'user')
is_valid_username "$prefix" && break
warn 'Недопустимый префикс.'
done
append_generated_users "$count" "$prefix"
else
append_manual_users "$count"
fi
apply_state_change 'Добавить пользователей и перезагрузить конфигурацию'
}
remove_user_flow() {
load_settings
load_users
(( ${#USERNAMES[@]} > 1 )) || die 'Нельзя удалить единственного пользователя.'
local value index=-1 current
value=$(prompt_value 'Точный логин для удаления')
for current in "${!USERNAMES[@]}"; do
[[ "${USERNAMES[$current]}" == "$value" ]] && index=$current
done
(( index >= 0 )) || die 'Пользователь не найден.'
unset 'USERNAMES[index]' 'PASSWORDS[index]'
USERNAMES=("${USERNAMES[@]}")
PASSWORDS=("${PASSWORDS[@]}")
apply_state_change "Удалить пользователя ${value}"
}
rotate_user_flow() {
load_settings
load_users
local value index=-1 current choice password
value=$(prompt_value 'Точный логин для смены пароля')
for current in "${!USERNAMES[@]}"; do
[[ "${USERNAMES[$current]}" == "$value" ]] && index=$current
done
(( index >= 0 )) || die 'Пользователь не найден.'
choice=$(menu 'Новый пароль' 'Сгенерировать автоматически' 'Ввести вручную')
if [[ "$choice" == '1' ]]; then
password=$(generate_password)
else
while true; do
password=$(prompt_secret "Новый пароль ${value}")
is_valid_password "$password" && break
warn 'Пароль не соответствует безопасному формату.'
done
fi
PASSWORDS[index]=$password
apply_state_change "Сменить пароль пользователя ${value}"
}
export_clients_flow() {
load_settings
load_users
confirm_drift_if_needed
confirm "Пересоздать защищённый клиентский bundle в ${OUTPUT_DIR}?" 'yes' ||
die 'Экспорт отменён.'
begin_transaction
replace_client_bundle "$SETTINGS_FILE" "$USERS_FILE"
commit_transaction
success "Клиентские файлы обновлены: ${OUTPUT_DIR}"
}
show_installation_status() {
load_settings
load_users
printf '\nСтатус %s\n' "$APP_NAME"
printf 'Домен: %s\n' "$DOMAIN"
printf 'Пользователей: %d\n' "${#USERNAMES[@]}"
printf 'HTTP/3: %s\n' "$HTTP3_ENABLED"
printf 'Сервис active: %s\n' "$(service_is_active && printf yes || printf no)"
printf 'Сервис enabled: %s\n' "$(service_is_enabled && printf yes || printf no)"
if [[ -x "$CADDY_BIN" ]]; then
printf 'Caddy: %s\n' "$("$CADDY_BIN" version 2>/dev/null || printf unknown)"
printf 'Forwardproxy module: %s\n' \
"$(caddy_has_forwardproxy "$CADDY_BIN" && printf yes || printf no)"
fi
printf 'TCP/443 listener: %s\n' "$( [[ -n "$(socket_lines tcp 443 || true)" ]] && printf yes || printf no )"
printf 'UDP/443 listener: %s\n' "$( [[ -n "$(socket_lines udp 443 || true)" ]] && printf yes || printf no )"
if verify_manifest_drift >/dev/null 2>&1; then
printf 'Config drift: no\n'
else
printf 'Config drift: YES\n'
fi
}
diagnose_installation() {
show_installation_status
if service_is_active; then
wait_for_tls_and_decoy
verify_h2_alpn
verify_listeners
verify_proxy_connect_if_supported
success 'Полная локальная диагностика пройдена.'
else
die 'Сервис не активен.'
fi
}
remove_all_managed_ufw_rules() {
ufw_is_active || return 0
local rule
for rule in '80/tcp' '443/tcp' '443/udp'; do
remove_managed_ufw_rule "$rule"
done
}
uninstall_flow() {
load_settings
load_users
warn 'Будут удалены сервис, binary, Caddy config, сертификаты и сайт, управляемые только этим мастером.'
warn "Клиентские файлы ${OUTPUT_DIR} и backup ${BACKUP_ROOT} будут сохранены."
local phrase
phrase=$(prompt_value 'Для подтверждения введите УДАЛИТЬ NAIVEPROXY')
[[ "$phrase" == 'УДАЛИТЬ NAIVEPROXY' ]] || die 'Удаление отменено.'
confirm_drift_if_needed
begin_transaction
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
safe_remove_managed_target "$CONFIG_DIR"
safe_remove_managed_target "$CADDY_BIN"
safe_remove_managed_target "$SERVICE_FILE"
safe_remove_managed_target "$WEB_ROOT"
safe_remove_managed_target "$SELF_INSTALL_PATH"
safe_remove_managed_target "$CADDY_DATA_DIR"
safe_remove_managed_target "$LOG_DIR"
remove_all_managed_ufw_rules
systemctl daemon-reload
commit_transaction
success 'Управляемая установка удалена. Backup и клиентский bundle сохранены.'
}
restore_target_from_backup() {
local backup=$1
local label=$2
local target=$3
safe_remove_managed_target "$target"
if grep -Fxq "$label" "${backup}/present.list"; then
cp -a -- "${backup}/payload/${label}" "$target"
fi
}
rollback_previous_operation_flow() {
local -a backups=()
mapfile -t backups < <(
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'transaction-*' \
-exec test -f '{}/status' ';' -print | sort -r
)
(( ${#backups[@]} > 0 )) || die 'Нет завершённых backup для отката.'
local index=0 backup
printf '\nДоступные backup:\n' >&2
for backup in "${backups[@]}"; do
((++index))
printf ' %d) %s\n' "$index" "$(basename "$backup")" >&2
(( index >= 10 )) && break
done
local selected
selected=$(prompt_value 'Номер backup' '1')
[[ "$selected" =~ ^[0-9]+$ ]] || die 'Некорректный номер.'
(( 10#$selected >= 1 && 10#$selected <= index )) || die 'Некорректный номер.'
backup=${backups[$((10#$selected - 1))]}
confirm "Восстановить состояние до операции из $(basename "$backup")?" 'no' ||
die 'Откат отменён.'
begin_transaction
systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true
restore_target_from_backup "$backup" 'config' "$CONFIG_DIR"
restore_target_from_backup "$backup" 'binary' "$CADDY_BIN"
restore_target_from_backup "$backup" 'unit' "$SERVICE_FILE"
restore_target_from_backup "$backup" 'web' "$WEB_ROOT"
restore_target_from_backup "$backup" 'clients' "$OUTPUT_DIR"
restore_target_from_backup "$backup" 'self' "$SELF_INSTALL_PATH"
restore_target_from_backup "$backup" 'caddy-data' "$CADDY_DATA_DIR"
restore_target_from_backup "$backup" 'logs' "$LOG_DIR"
systemctl daemon-reload
local old_active old_enabled
old_active=$(awk -F= '$1=="service_active"{print $2}' "${backup}/service.state")
old_enabled=$(awk -F= '$1=="service_enabled"{print $2}' "${backup}/service.state")
if [[ "$old_enabled" == '1' && -f "$SERVICE_FILE" ]]; then
systemctl enable "$SERVICE_NAME" >/dev/null
else
systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true
fi
if [[ "$old_active" == '1' && -f "$SERVICE_FILE" ]]; then
systemctl start "$SERVICE_NAME"
load_settings
load_users
run_local_health_checks
fi
if [[ -f "$SETTINGS_FILE" ]]; then
load_settings
apply_firewall
else
remove_all_managed_ufw_rules
fi
commit_transaction
success 'Выбранное предыдущее состояние восстановлено.'
}
recover_incomplete_transaction() {
[[ -f "$TRANSACTION_FILE" && ! -L "$TRANSACTION_FILE" ]] || return 0
local python_bin backup
python_bin=$(find_python) || die 'Для восстановления transaction нужен Python 3.'
backup=$("$python_bin" -c '
import json,sys
with open(sys.argv[1], "r", encoding="utf-8") as fh:
print(json.load(fh)["backup"])
' "$TRANSACTION_FILE")
[[ "$backup" == "${BACKUP_ROOT}/transaction-"* && -d "$backup" ]] ||
die 'Найдена повреждённая незавершённая transaction.'
warn "Найдена незавершённая операция: $(basename "$backup")."
confirm 'Автоматически восстановить состояние из её snapshot?' 'yes' ||
die 'Сначала завершите восстановление незавершённой transaction.'
CURRENT_BACKUP=$backup
SERVICE_WAS_ACTIVE=$(awk -F= '$1=="service_active"{print $2}' "${backup}/service.state")
SERVICE_WAS_ENABLED=$(awk -F= '$1=="service_enabled"{print $2}' "${backup}/service.state")
TRANSACTION_ARMED=1
TRANSACTION_COMMITTED=0
rollback_transaction
}
print_help() {
cat </dev/null ||
die 'Обнаружен config drift; non-interactive apply запрещён.'
candidate_binary=$(stage_installed_binary || true)
fi
if [[ -z "${candidate_binary:-}" ]]; then
prepare_candidate_binary
candidate_binary=$PREPARED_CANDIDATE
fi
local -a artifacts=()
mapfile -t artifacts < <(prepare_plan_artifacts)
(( ${#artifacts[@]} == 3 )) || die 'Не удалось подготовить plan artifacts.'
apply_installation "$candidate_binary" "${artifacts[0]}" "${artifacts[1]}" "${artifacts[2]}"
}
apply_quick_noninteractive() {
[[ $# == 4 && "$4" == '--yes' ]] ||
die 'Формат: --apply-quick DOMAIN COUNT PREFIX --yes'
noninteractive_guard
DOMAIN=${1,,}
local count=$2
local prefix=$3
is_valid_domain "$DOMAIN" || die 'Некорректный домен.'
is_valid_count "$count" || die 'COUNT должен быть от 1 до 500.'
is_valid_username "$prefix" || die 'Некорректный PREFIX.'
INSTALL_MODE='quick'
SERVER_BUILD_MODE='source_patched'
TLS_MODE='acme'
ACME_EMAIL=''
HTTP3_ENABLED='true'
AUTO_HTTPS_REDIRECTS='true'
DECOY_MODE='static'
DECOY_TITLE='Web Service'
PROBE_MODE='fallthrough'
PAC_ENABLED='false'
HIDE_IP='true'
HIDE_VIA='true'
TARGET_PORTS_MODE='web'
TARGET_PORTS='80 443'
ACL_MODE='hardened'
ACL_CUSTOM=''
UPSTREAM_URL=''
DIAL_TIMEOUT='30s'
MAX_IDLE_CONNS='256'
MAX_IDLE_CONNS_PER_HOST='8'
MANAGE_UFW='true'
OPEN_HTTP_PORT='true'
create_generated_users "$count" "$prefix"
apply_noninteractive_loaded_plan
}
apply_manifest_noninteractive() {
[[ $# == 3 && "$3" == '--yes' ]] ||
die 'Формат: --apply-manifest SETTINGS_JSON USERS_TSV --yes'
noninteractive_guard
load_settings "$1"
load_users "$2"
apply_noninteractive_loaded_plan
}
interactive_main() {
require_root
require_tty
assert_runtime_paths
acquire_lock
ensure_runtime_dir
recover_incomplete_transaction
print_banner
if [[ -f "$SETTINGS_FILE" && -f "$USERS_FILE" ]]; then
local action
action=$(menu 'Действие' \
'Перенастроить сервер' \
'Добавить пользователей' \
'Удалить пользователя' \
'Сменить пароль пользователя' \
'Пересоздать клиентские файлы' \
'Диагностика' \
'Обновить/rebuild Caddy' \
'Откатить последнюю операцию' \
'Удалить управляемую установку')
case "$action" in
1) run_install_flow ;;
2) add_users_flow ;;
3) remove_user_flow ;;
4) rotate_user_flow ;;
5) export_clients_flow ;;
6) diagnose_installation ;;
7)
load_settings
load_users
SERVER_BUILD_MODE='source_patched'
show_plan
confirm 'Пересобрать и применить закреплённый Caddy?' 'no' || die 'Операция отменена.'
install_dependencies
run_preflight
local rebuilt
prepare_candidate_binary
rebuilt=$PREPARED_CANDIDATE
local -a rebuilt_artifacts=()
mapfile -t rebuilt_artifacts < <(prepare_plan_artifacts)
apply_installation "$rebuilt" "${rebuilt_artifacts[0]}" "${rebuilt_artifacts[1]}" "${rebuilt_artifacts[2]}"
;;
8) rollback_previous_operation_flow ;;
9) uninstall_flow ;;
esac
else
run_install_flow
fi
}
main() {
local command=${1:-}
case "$command" in
--version)
printf '%s %s\n' "$APP_NAME" "$APP_VERSION"
;;
--help | -h)
print_help
;;
--internal-render-config)
[[ $# == 3 ]] || die 'Нужны SETTINGS_JSON и USERS_TSV.'
render_caddyfile "$2" "$3"
;;
--internal-render-clients)
[[ $# == 4 ]] || die 'Нужны SETTINGS_JSON, USERS_TSV и OUTPUT_DIR.'
stage_client_bundle "$2" "$3" "$4"
;;
--internal-render-site)
[[ $# == 3 ]] || die 'Нужны OUTPUT_DIR и TITLE.'
render_static_site "$2" "$3"
;;
--internal-validate-settings)
[[ $# == 3 ]] || die 'Нужны SETTINGS_JSON и USERS_TSV.'
load_settings "$2"
load_users "$3"
printf '%s\n' 'OK'
;;
--status)
require_root
assert_runtime_paths
acquire_lock
ensure_runtime_dir
show_installation_status
;;
--diagnose)
require_root
assert_runtime_paths
acquire_lock
ensure_runtime_dir
diagnose_installation
;;
--apply-quick)
shift
apply_quick_noninteractive "$@"
;;
--apply-manifest)
shift
apply_manifest_noninteractive "$@"
;;
'')
interactive_main
;;
*)
die "Неизвестная команда: ${command}. Используйте --help."
;;
esac
}
if [[ "$SOURCE_ONLY" != '1' ]]; then
trap 'handle_error $? $LINENO "$BASH_COMMAND"' ERR
trap 'handle_signal' INT TERM
trap 'cleanup_all' EXIT
main "$@"
fi