refactor(xray-proxy): срезана accidental-сложность CLI, тестов и спеки

- Зачем:
  - CLI и спека были переусложнены относительно принятой модели угроз
    (один пользователь, доверенная машина): лишняя ось версий Xray и
    защита от входов, которые не возникают при ручном редактировании.
- Что:
  - убрана поддержка Xray 25.12.8 — поддерживается только 26.3.27;
    allowInsecure валидируется, но никогда не эмитится в JSON.
  - удалена проверка владения сокетом через /proc/net/tcp; идентичность
    процесса и сверка адрес/порт со state сохранены.
  - валидация INI/профиля/адреса/query сведена к отказам на реальные
    опечатки; scoped IPv6 отклоняется одинаково на Python 3.8 и 3.9+.
  - удалены fsync/O_EXCL-церемония, SIGALRM-таймер, zombie-инспекция,
    мёртвый отпечаток профиля и двойной разбор extra/alpn.
  - shell.bash принимает вывод env без ре-валидации, сохранив атомарность;
    спека и issue 01–05 приведены к новому объёму (тикет 06, леджер).
- Проверка:
  - python3 -m unittest discover -s tests — 34 теста OK.
  - bash tests/test_proxy_launchers.sh — 8 проверок PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ddadminandClaude Fable 5 committed 2026-07-20 04:13:54 -04:00
1 parent 71e16a795a
commit 98bfd6338a
12 files changed
+384 -508

No files matched your search

+4 -29
View File
@@ -6,24 +6,14 @@ _proxy_cli() {
}
_proxy_apply_env_file() {
local output_file="$1" line key value nul_prefix
local output_file="$1" line key value
local -A proxy_values=()
local -A proxy_seen=()
local -a allowed_keys=(
HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
http_proxy https_proxy all_proxy no_proxy
)
if IFS= read -r -d '' nul_prefix < "$output_file"; then
echo "Ошибка: xray-proxy env вернул запрещённый NUL" >&2
return 1
fi
while IFS= read -r line || [ -n "$line" ]; do
if [[ "$line" == *$'\r'* ]]; then
echo "Ошибка: xray-proxy env вернул запрещённый CR" >&2
return 1
fi
if [[ "$line" != *=* ]]; then
echo "Ошибка: xray-proxy env вернул строку без '='" >&2
return 1
@@ -37,30 +27,15 @@ _proxy_apply_env_file() {
return 1
;;
esac
if [[ -n "${proxy_seen[$key]+set}" ]]; then
echo "Ошибка: xray-proxy env повторил ключ: $key" >&2
return 1
fi
proxy_seen["$key"]=1
proxy_values["$key"]="$value"
done < "$output_file"
for key in "${allowed_keys[@]}"; do
if [[ -z "${proxy_seen[$key]+set}" ]]; then
echo "Ошибка: xray-proxy env не вернул обязательный ключ: $key" >&2
return 1
if [[ -n "${proxy_values[$key]+set}" ]]; then
printf -v "$key" '%s' "${proxy_values[$key]}"
export "$key"
fi
done
if [ "${#proxy_seen[@]}" -ne 8 ]; then
echo "Ошибка: xray-proxy env должен вернуть ровно восемь ключей" >&2
return 1
fi
for key in "${allowed_keys[@]}"; do
printf -v "$key" '%s' "${proxy_values[$key]}"
done
export HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY
export http_proxy https_proxy all_proxy no_proxy
}
proxy_on() {
+54 -201
View File
@@ -11,11 +11,9 @@ if sys.version_info < (3, 8):
import argparse
import configparser
import hashlib
import http.client
import ipaddress
import json
import math
import os
import re
import shutil
@@ -34,8 +32,7 @@ DEFAULT_NAME = "Hiddify"
DEFAULT_ADDRESS = "hproxy.local"
DEFAULT_PORT = 12334
DEFAULT_NO_PROXY_EXTRA = (".concept-software.ru",)
DNS_LABEL = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
VERSION_LINE = re.compile(r"^Xray (25\.12\.8|26\.3\.27)(?: .*)?$")
VERSION_LINE = re.compile(r"^Xray (26\.3\.27)(?: .*)?$")
QUERY_KEYS = {
"type",
"security",
@@ -66,7 +63,6 @@ STATE_KEYS = {
"config_path",
"address",
"port",
"profile_fingerprint",
}
@@ -74,10 +70,6 @@ class ProxyError(Exception):
pass
class PreflightExpired(Exception):
pass
class ProxyConfig:
def __init__(self, proxy_type, name, address, port, no_proxy_extra):
self.proxy_type = proxy_type
@@ -88,11 +80,13 @@ class ProxyConfig:
class VlessProfile:
def __init__(self, server, port, profile_id, query):
def __init__(self, server, port, profile_id, query, alpn, extra):
self.server = server
self.port = port
self.profile_id = profile_id
self.query = query
self.alpn = alpn
self.extra = extra
@property
def transport(self):
@@ -133,27 +127,21 @@ def generated_config_path():
def validate_address(value):
if not value or not value.isascii():
raise ProxyError("поле address должно содержать IPv4, IPv6 или ASCII DNS-имя")
if any(character.isspace() for character in value):
raise ProxyError("поле address не должно содержать пробелы")
if any(separator in value for separator in ("%", "[", "]", "/", "\\", "@", "?", "#")):
raise ProxyError(
"поле address должно быть адресом без zone ID, скобок и URL-разделителей"
)
try:
return ipaddress.ip_address(value)
parsed = ipaddress.ip_address(value)
except ValueError:
pass
if len(value) > 253:
raise ProxyError("DNS-имя в поле address слишком длинное")
labels = value.split(".")
if not labels or any(not DNS_LABEL.fullmatch(label) for label in labels):
raise ProxyError(
"поле address должно быть адресом без схемы, порта, скобок и URL-разделителей"
)
else:
if "%" in value:
raise ProxyError("поле address не поддерживает IPv6 zone ID")
return parsed
if (
not value
or not value.isascii()
or any(character.isspace() for character in value)
or any(separator in value for separator in ("://", "@", "/", "[", "]", "%"))
):
raise ProxyError("поле address должно быть IP-адресом или простым ASCII-именем")
return None
@@ -186,15 +174,9 @@ def load_config():
raise ProxyError("машинный конфиг не является обычным файлом: {}".format(path))
try:
raw = path.read_bytes()
text = path.read_text(encoding="utf-8")
except OSError as error:
raise ProxyError("не удалось прочитать машинный конфиг {}: {}".format(path, error))
if b"\0" in raw:
raise ProxyError("машинный конфиг содержит запрещённый NUL")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
raise ProxyError("машинный конфиг должен быть в UTF-8")
parser = configparser.ConfigParser(
interpolation=None,
@@ -202,14 +184,11 @@ def load_config():
allow_no_value=False,
empty_lines_in_values=False,
)
parser.optionxform = str
try:
parser.read_string(text, source=str(path))
except configparser.Error as error:
raise ProxyError("неверный машинный INI: {}".format(error))
if parser.defaults():
raise ProxyError("машинный INI не допускает секцию [DEFAULT]")
if parser.sections() != ["proxy"]:
raise ProxyError("машинный INI должен содержать только секцию [proxy]")
@@ -226,8 +205,6 @@ def load_config():
raise ProxyError("неверные ключи [proxy] ({})".format("; ".join(details)))
values = {key: section.get(key, raw=True) for key in CONFIG_KEYS}
if any("\n" in value or "\r" in value or "\0" in value for value in values.values()):
raise ProxyError("многострочные значения в машинном INI запрещены")
proxy_type = values["type"].strip()
if proxy_type not in ("external", "xray"):
@@ -255,35 +232,17 @@ def preflight_timeout():
raw = os.environ.get("PROXY_PREFLIGHT_TIMEOUT", "2")
try:
timeout = float(raw)
if not timeout > 0:
raise ValueError
except ValueError:
raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд")
if not math.isfinite(timeout) or timeout <= 0:
raise ProxyError("PROXY_PREFLIGHT_TIMEOUT должен быть положительным числом секунд")
return timeout
def check_reachable(config, timeout):
def expire_preflight(_signal_number, _frame):
raise PreflightExpired()
connection = None
previous_handler = signal.signal(signal.SIGALRM, expire_preflight)
try:
signal.setitimer(signal.ITIMER_REAL, timeout)
connection = socket.create_connection((config.address, config.port), timeout=timeout)
signal.setitimer(signal.ITIMER_REAL, 0)
except PreflightExpired:
raise ProxyError(
"прокси {} ({}:{}) недоступен: таймаут {} с".format(
config.name, config.address, config.port, timeout
)
)
except (OverflowError, ValueError) as error:
raise ProxyError(
"PROXY_PREFLIGHT_TIMEOUT={} нельзя применить на этой платформе: {}".format(
timeout, error
)
)
except OSError as error:
raise ProxyError(
"прокси {} ({}:{}) недоступен: {}".format(
@@ -291,8 +250,6 @@ def check_reachable(config, timeout):
)
)
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
signal.signal(signal.SIGALRM, previous_handler)
if connection is not None:
connection.close()
@@ -341,14 +298,9 @@ def private_directory(path):
def write_private_json(path, value):
private_directory(path.parent)
temporary = path.with_name(path.name + ".tmp.{}".format(os.getpid()))
descriptor = None
try:
descriptor = os.open(
str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600
)
descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
descriptor = None
json.dump(
value,
stream,
@@ -357,19 +309,9 @@ def write_private_json(path, value):
allow_nan=False,
)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.replace(str(temporary), str(path))
path.chmod(0o600)
except (OSError, ValueError) as error:
raise ProxyError("не удалось записать закрытый служебный файл: {}".format(error))
finally:
if descriptor is not None:
os.close(descriptor)
try:
temporary.unlink()
except FileNotFoundError:
pass
def read_profile(path):
@@ -385,25 +327,16 @@ def read_profile(path):
"конечный файл профиля доступен группе или другим пользователям; выполните chmod 600"
)
try:
raw = resolved.read_bytes()
text = resolved.read_text(encoding="utf-8")
except OSError as error:
raise ProxyError("не удалось прочитать файл профиля: {}".format(error))
if b"\0" in raw:
raise ProxyError("файл профиля содержит запрещённый NUL")
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
raise ProxyError("файл профиля должен быть в UTF-8")
if text.endswith("\n"):
text = text[:-1]
if not text or "\n" in text or "\r" in text or text != text.strip():
lines = [line.strip() for line in text.splitlines() if line.strip()]
if len(lines) != 1:
raise ProxyError("файл профиля должен содержать ровно одну непустую VLESS-ссылку")
return text
return lines[0]
def parse_query(raw_query):
if re.search(r"%(?![0-9A-Fa-f]{2})", raw_query):
raise ProxyError("профиль содержит неверное URL-кодирование")
try:
pairs = parse_qsl(
raw_query,
@@ -416,8 +349,6 @@ def parse_query(raw_query):
raise ProxyError("профиль содержит неверную строку параметров")
query = {}
for key, value in pairs:
if any(ord(character) < 32 or ord(character) == 127 for character in key + value):
raise ProxyError("профиль содержит управляющий символ")
if key in query:
raise ProxyError("профиль содержит повтор query-параметра")
if key not in QUERY_KEYS:
@@ -471,7 +402,7 @@ def parse_json_object(value):
return parsed
def parse_vless(url, version):
def parse_vless(url):
try:
parsed = urlsplit(url)
except ValueError:
@@ -509,6 +440,8 @@ def parse_vless(url, version):
if "core" in query and query["core"] != "xray":
raise ProxyError("поддерживается только метаданные core=xray")
legacy_insecure = parse_boolean_fields(query)
alpn = None
extra = None
if query["type"] == "tcp" and query["security"] == "reality":
required(query, "flow", "pbk", "sid")
@@ -519,8 +452,6 @@ def parse_vless(url, version):
required(query, "host", "path")
if "flow" in query or "pbk" in query or "sid" in query:
raise ProxyError("параметры профиля несовместимы с XHTTP/TLS")
if version == "25.12.8" and ("pcs" in query or "vcn" in query):
raise ProxyError("pcs/vcn несовместимы с Xray 25.12.8")
if "pcs" in query:
hashes = list_value(query["pcs"])
if any(re.fullmatch(r"[0-9A-Fa-f]{64}", item) is None for item in hashes):
@@ -528,15 +459,15 @@ def parse_vless(url, version):
if "vcn" in query:
list_value(query["vcn"])
if "alpn" in query:
alpn = query["alpn"].split(",")
alpn = list_value(query["alpn"])
if not alpn or any(item not in ALPN_VALUES for item in alpn):
raise ProxyError("профиль содержит неподдерживаемый ALPN")
if "extra" in query:
parse_json_object(query["extra"])
extra = parse_json_object(query["extra"])
else:
raise ProxyError("поддерживаются только TCP/REALITY и XHTTP/TLS")
profile = VlessProfile(server, port, profile_id, query)
profile = VlessProfile(server, port, profile_id, query, alpn, extra)
profile.legacy_insecure = legacy_insecure
return profile
@@ -548,7 +479,7 @@ def list_value(value):
return items
def build_xray_config(profile, local_address, local_port, version):
def build_xray_config(profile, local_address, local_port):
query = profile.query
settings = {
"address": profile.server,
@@ -574,18 +505,15 @@ def build_xray_config(profile, local_address, local_port, version):
xhttp = {"host": query["host"], "path": query["path"]}
if "mode" in query:
xhttp["mode"] = query["mode"]
if "extra" in query:
xhttp["extra"] = parse_json_object(query["extra"])
if profile.extra is not None:
xhttp["extra"] = profile.extra
tls = {"serverName": query["sni"], "fingerprint": query["fp"]}
if "alpn" in query:
tls["alpn"] = list_value(query["alpn"])
if version == "26.3.27":
if "pcs" in query:
tls["pinnedPeerCertSha256"] = query["pcs"]
if "vcn" in query:
tls["verifyPeerCertByName"] = query["vcn"]
elif profile.legacy_insecure:
tls["allowInsecure"] = True
if profile.alpn is not None:
tls["alpn"] = profile.alpn
if "pcs" in query:
tls["pinnedPeerCertSha256"] = query["pcs"]
if "vcn" in query:
tls["verifyPeerCertByName"] = query["vcn"]
stream = {
"network": "xhttp",
"security": "tls",
@@ -660,12 +588,9 @@ def run_xray_test(path, config_path):
def process_alive(pid):
try:
stat_fields = Path("/proc/{}/stat".format(pid)).read_text(encoding="ascii").split()
if len(stat_fields) > 2 and stat_fields[2] == "Z":
return False
os.kill(pid, 0)
return True
except (OSError, ValueError):
except OSError:
return False
@@ -706,60 +631,6 @@ def process_argument_resolves_to(pid, arguments, expected_path):
return False
def process_socket_inodes(pid):
inodes = set()
try:
descriptors = Path("/proc/{}/fd".format(pid)).iterdir()
for descriptor in descriptors:
try:
target = os.readlink(str(descriptor))
except OSError:
continue
match = re.fullmatch(r"socket:\[([0-9]+)\]", target)
if match is not None:
inodes.add(match.group(1))
except OSError:
return set()
return inodes
def decode_proc_address(encoded):
raw = bytes.fromhex(encoded)
if len(raw) == 4:
raw = raw[::-1]
elif len(raw) == 16:
raw = b"".join(raw[index : index + 4][::-1] for index in range(0, 16, 4))
else:
raise ValueError("unexpected proc address size")
return ipaddress.ip_address(raw)
def process_owns_listener(pid, address, port):
inodes = process_socket_inodes(pid)
if not inodes:
return False
expected_address = ipaddress.ip_address(address)
for table_name in ("tcp", "tcp6"):
table = Path("/proc/{}/net/{}".format(pid, table_name))
try:
lines = table.read_text(encoding="ascii").splitlines()[1:]
except OSError:
continue
for line in lines:
fields = line.split()
if len(fields) < 10 or fields[3] != "0A" or fields[9] not in inodes:
continue
encoded_address, encoded_port = fields[1].split(":", 1)
try:
local_address = decode_proc_address(encoded_address)
local_port = int(encoded_port, 16)
except (ValueError, TypeError):
continue
if local_address == expected_address and local_port == port:
return True
return False
def read_managed_state():
path = state_path()
try:
@@ -779,7 +650,7 @@ def read_managed_state():
raise ProxyError("состояние управляемого Xray не подтверждено")
if not all(isinstance(value[key], int) and not isinstance(value[key], bool) for key in ("supervisor_pid", "xray_pid", "port")):
raise ProxyError("состояние управляемого Xray не подтверждено")
if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address", "profile_fingerprint")):
if not all(isinstance(value[key], str) for key in ("xray_path", "config_path", "address")):
raise ProxyError("состояние управляемого Xray не подтверждено")
return value
@@ -803,11 +674,8 @@ def verify_managed_state(config=None, require_port=True):
or "-c" not in child_args
or state["config_path"] not in child_args
or process_parent(child) != supervisor
or not process_owns_listener(child, state["address"], state["port"])
):
raise ProxyError(
"личность процесса или listener управляемого Xray не подтверждены"
)
raise ProxyError("личность процесса управляемого Xray не подтверждена")
if config is not None and (
config.proxy_type != "xray"
or config.address != state["address"]
@@ -819,9 +687,8 @@ def verify_managed_state(config=None, require_port=True):
return state
def safe_summary(profile, local_port, xray_path, version):
def safe_summary(profile, local_port):
query = profile.query
print("Xray: {} ({})".format(xray_path, version), flush=True)
print(
"Профиль: сервер {}:{}, транспорт {}, защита {}, SNI {}, fingerprint {}, локальный порт {}".format(
profile.server,
@@ -848,15 +715,11 @@ def safe_summary(profile, local_port, xray_path, version):
print("UUID и ключи: скрыты", flush=True)
def warn_legacy(profile, version):
if version == "26.3.27" and profile.legacy_insecure:
def warn_legacy(profile):
if profile.legacy_insecure:
sys.stderr.write(
"Предупреждение: legacy insecure не переносится; сертификат проверяется обычным способом.\n"
)
if version == "25.12.8" and profile.legacy_insecure:
sys.stderr.write(
"Предупреждение: Xray 25.12.8 использует allowInsecure=true; проверка сертификата отключена.\n"
)
def diagnose_ip(config):
@@ -967,13 +830,14 @@ def command_start(config, profile_file):
raw_profile = read_profile(profile_path)
executable = resolve_xray()
version = xray_version(executable)
profile = parse_vless(raw_profile, version)
generated = build_xray_config(profile, config.address, config.port, version)
profile = parse_vless(raw_profile)
generated = build_xray_config(profile, config.address, config.port)
write_private_json(generated_config_path(), generated)
try:
run_xray_test(executable, generated_config_path())
safe_summary(profile, config.port, executable, version)
warn_legacy(profile, version)
print("Xray: {} ({})".format(executable, version), flush=True)
safe_summary(profile, config.port)
warn_legacy(profile)
stopping = {"signal": None}
def request_stop(signal_number, _frame):
@@ -993,7 +857,6 @@ def command_start(config, profile_file):
"config_path": str(generated_config_path()),
"address": config.address,
"port": config.port,
"profile_fingerprint": hashlib.sha256(raw_profile.encode("utf-8")).hexdigest(),
}
write_private_json(state_path(), state)
deadline = time.monotonic() + max(1.0, preflight_timeout())
@@ -1003,19 +866,13 @@ def command_start(config, profile_file):
raise ProxyError("Xray завершился до готовности локального порта")
try:
check_endpoint(config.address, config.port)
if not process_owns_listener(child.pid, config.address, config.port):
raise ProxyError(
"настроенный listener не принадлежит дочернему Xray"
)
last_error = None
break
except ProxyError as error:
last_error = error
time.sleep(0.03)
if last_error is not None:
raise ProxyError(
"дочерний Xray не подтвердил владение настроенным listener"
)
raise ProxyError("локальный порт дочернего Xray не стал доступен")
diagnose_ip(config)
while child.poll() is None and stopping["signal"] is None:
time.sleep(0.05)
@@ -1050,10 +907,10 @@ def stop_timeout():
raw = os.environ.get("XRAY_PROXY_STOP_TIMEOUT", "5")
try:
value = float(raw)
if not value > 0:
raise ValueError
except ValueError:
raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд")
if not math.isfinite(value) or value <= 0:
raise ProxyError("XRAY_PROXY_STOP_TIMEOUT должен быть положительным числом секунд")
return value
@@ -1066,11 +923,7 @@ def command_stop():
raise ProxyError("не удалось послать сигнал подтверждённому супервизору")
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if (
not process_alive(state["supervisor_pid"])
and not process_alive(state["xray_pid"])
and not state_path().exists()
):
if not process_alive(state["xray_pid"]) and not state_path().exists():
print("Управляемый Xray остановлен")
return 0
time.sleep(0.05)