--restore: самоподписанный сертификат, если Let's Encrypt не выдал его на новый IP
Подключения из копии ссылаются на /root/cert/ip, а без файла Xray не стартует, хотя установщик писал «Готово». Теперь перед запуском панели проверяю файлы сертификатов из базы; если нет – кладу самоподписанный на тот же путь (только /root/cert/ip или /root/cert/self) и закрепляю отпечаток в подключениях, как при обычной установке. Предупреждение – сразу и в блоке «Внимание». В конце жду процесс Xray: если не поднялся – вместо «Готово» красное сообщение и код возврата 1. Тест: tools/test/restore_archive_test.py.
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
* Сайт-заглушка теперь у каждого сервера своя: при установке она собирается из двадцати тем и пяти вёрсток со случайным названием, текстами и цветами. Пекарня, переводчик или мастерская по велосипедам – как повезёт. Раньше у многих серверов был один и тот же «облачный» сосед.
|
||||
* `kit-sub` на своём порту (`--multi-port`) отвечает по HTTP/1.1 и с `Server: nginx` без лишнего пробела – как настоящий nginx, а не его двоюродный брат.
|
||||
* Исправил `kit net masq on`: Hysteria2 после него переставала слушать порт до перезапуска панели, а `kit net fp` и `kit net on` спотыкались о то же. Панель меняет подключение на ходу, а ядро такую правку не принимает. Теперь `kit` проверяет порты после каждой правки и, если что-то закрылось, перезапускает ядро и честно об этом говорит. А `kit check` научился смотреть, запущено ли ядро Xray и слушает ли каждое подключение свой порт, TCP и UDP отдельно: nginx на 443/tcp больше не выдаёт себя за Hysteria2 на 443/udp.
|
||||
* `--restore` на новый IP, где Let's Encrypt не выдал сертификат (лимит или закрытый порт 80), раньше говорил «Готово», а ядро Xray не запускалось: подключения ждали сертификат, которого нет. Теперь я ставлю самоподписанный, закрепляю его отпечаток в ссылках и прямо говорю об этом. А если ядро всё же не поднялось, «Готово» не будет: покажу, где смотреть.
|
||||
* JSON-подписка снова открывается в приложениях на Xray: там, где у vless не было `encryption`, подписка теперь дописывает `none`.
|
||||
* Отдельный установщик Hysteria2 ушёл на пенсию: Hysteria2 и так входит в основную установку, один из 11 протоколов. Кто ставил его отдельно – сервер работает как работал, а `hy2 update` вежливо расскажет, что дальше. Нужен один Hysteria2 без панели – официальный установщик: `bash <(curl -fsSL https://get.hy2.sh/)`.
|
||||
|
||||
|
||||
@@ -244,6 +244,11 @@ bash 3x-ui.sh --restore /root/kit-backup-ДАТА.tar.gz -y
|
||||
Подключения, ключи, пользователи и их лимиты вернутся прежними. Если адрес сервера сменился, подписку клиентам нужно
|
||||
добавить заново, а на домене ничего менять не придётся: достаточно переключить DNS.
|
||||
|
||||
Сертификат Let's Encrypt на новый IP выпускается заново. Если он не выдался (лимит – 5 сертификатов в неделю на один IP,
|
||||
или закрыт входящий порт 80), скрипт скажет об этом и поставит самоподписанный сертификат с отпечатком в ссылках:
|
||||
ссылки на подключения работают, а подписка в приложениях может не открыться. Если ядро Xray после восстановления
|
||||
не запустилось, скрипт не скажет «Готово», а покажет, где искать причину.
|
||||
|
||||
Восстанавливайте только из своей копии. Архив с чужими путями, ссылками или лишними настройками скрипт
|
||||
не примет («Это не резервная копия 3X-UI KIT…») и ничего не изменит.
|
||||
|
||||
|
||||
+98
-1
@@ -1738,6 +1738,85 @@ for f in files:
|
||||
PY
|
||||
}
|
||||
|
||||
# Файлы сертификатов, на которые ссылаются подключения в базе: без них Xray не стартует.
|
||||
db_cert_files() {
|
||||
python3 - <<'PY'
|
||||
import json, sqlite3
|
||||
db = sqlite3.connect("file:/etc/x-ui/x-ui.db?mode=ro", uri=True)
|
||||
out = set()
|
||||
for s, st in db.execute("SELECT settings, stream_settings FROM inbounds"):
|
||||
for raw, key in ((st, "stream"), (s, "settings")):
|
||||
try:
|
||||
j = json.loads(raw)
|
||||
if key == "stream":
|
||||
out.update(c.get("certificateFile") for c in j["tlsSettings"]["certificates"])
|
||||
else:
|
||||
out.add(j["server"]["certificate"]) # TUIC
|
||||
except Exception:
|
||||
pass
|
||||
for f in sorted(x for x in out if isinstance(x, str) and x and "\n" not in x):
|
||||
print(f)
|
||||
PY
|
||||
}
|
||||
|
||||
pin_cert_in_db() { # сертификат отпечаток: закрепить отпечаток в подключениях с этим сертификатом (уходит в ссылки)
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import json, sqlite3, sys
|
||||
cert, pin = sys.argv[1], sys.argv[2]
|
||||
db = sqlite3.connect("/etc/x-ui/x-ui.db")
|
||||
for iid, raw in db.execute("SELECT id, stream_settings FROM inbounds").fetchall():
|
||||
try:
|
||||
st = json.loads(raw)
|
||||
t = st["tlsSettings"]
|
||||
if not any(c.get("certificateFile") == cert for c in t["certificates"]):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
t["settings"] = dict(t.get("settings") or {}, pinnedPeerCertSha256=[pin])
|
||||
db.execute("UPDATE inbounds SET stream_settings = ? WHERE id = ?", (json.dumps(st, indent=2, ensure_ascii=False), iid))
|
||||
db.commit(); db.close()
|
||||
PY
|
||||
}
|
||||
|
||||
# Let's Encrypt на новый IP мог не выдаться (лимит выпусков, закрытый порт 80), а подключения из копии ссылаются
|
||||
# на его файлы. Тогда на тот же путь кладём самоподписанный сертификат и закрепляем его отпечаток в подключениях,
|
||||
# как при обычной установке без Let's Encrypt. Путь из базы – только наш (/root/cert/ip или /root/cert/self).
|
||||
restore_self_cert() { # путь-к-fullchain.pem
|
||||
local cert=$1 dir san="IP:$HOST" pin
|
||||
if [[ ! $cert =~ ^/root/cert/(ip|self)/fullchain\.pem$ ]]; then
|
||||
printf '%s\n' "${R}!${N} Подключения ссылаются на сертификат $cert, а его нет: Xray не запустится. Положите файл на место и перезапустите: systemctl restart x-ui" >&2
|
||||
later "Нет сертификата $cert, на который ссылаются подключения: Xray не запустится, пока файл не вернётся на место."
|
||||
return 0
|
||||
fi
|
||||
dir=${cert%/*}
|
||||
[[ $HOST =~ ^[0-9.]+$ ]] || san="DNS:$HOST"
|
||||
if [[ $dir == /root/cert/ip ]]; then
|
||||
printf '%s\n' "${R}!${N} Let's Encrypt не выдал сертификат на IP $HOST (лимит: 5 сертификатов в неделю на один IP, закрытый порт 80 или сбой; лог: /var/log/3x-ui-install.log)." >&2
|
||||
echo " Ставлю самоподписанный: ссылки на подключения работают (отпечаток в них), браузер покажет предупреждение, а подписка в приложениях может не открыться." >&2
|
||||
later "Сертификат на IP самоподписанный (Let's Encrypt не выдал): пользуйтесь ссылками на отдельные подключения (kit user link имя), подписка в приложениях может не открыться."
|
||||
SELF_IP_CERT=yes
|
||||
fi
|
||||
install -d -m 700 "$dir"
|
||||
(umask 077; openssl req -x509 -nodes -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "$dir/privkey.pem" \
|
||||
-out "$cert" -subj "/CN=$HOST" -addext "subjectAltName=$san" -days 3650 2>/dev/null)
|
||||
chmod 644 "$cert"
|
||||
chmod 600 "$dir/privkey.pem"
|
||||
pin=$(openssl x509 -in "$cert" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')
|
||||
# Панель берёт тот же путь из настроек копии. «x-ui cert» не зовём: он включает TLS и у встроенной подписки,
|
||||
# а kit-sub ходит к ней по http.
|
||||
pin_cert_in_db "$cert" "$pin"
|
||||
}
|
||||
|
||||
xray_alive() { # ждём ядро Xray (дочерний процесс x-ui) до 20 секунд
|
||||
local i pid
|
||||
for i in $(seq 1 10); do
|
||||
pid=$(systemctl show -p MainPID --value x-ui 2>/dev/null || true)
|
||||
[[ $pid =~ ^[1-9][0-9]*$ ]] && pgrep -P "$pid" '^xray-linux-' >/dev/null 2>&1 && return 0
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
restore_main() { # файл
|
||||
local file=$1 tmp old_ip=""
|
||||
[[ -f $file ]] || die "Нет файла $file. Сначала скопируйте копию на этот сервер: scp kit-backup-….tar.gz root@IP:"
|
||||
@@ -1882,6 +1961,10 @@ PY
|
||||
# Адрес в ссылках, взятый из копии, тоже был старым IP (со своим доменом он остаётся доменом).
|
||||
[[ ${LINK_HOST:-} == "$old_ip" ]] && LINK_HOST=$HOST
|
||||
fi
|
||||
local cf
|
||||
while IFS= read -r cf; do
|
||||
[[ -s $cf ]] || restore_self_cert "$cf"
|
||||
done < <(db_cert_files)
|
||||
systemctl start x-ui
|
||||
connect_panel
|
||||
set_xray_core
|
||||
@@ -1928,9 +2011,16 @@ PY
|
||||
local n_in n_cl
|
||||
n_in=$(api GET inbounds/list | jq length)
|
||||
n_cl=$(api GET inbounds/list | jq '[.[] | (.settings | if type == "string" then fromjson else . end).clients // [] | .[].email] | unique | length')
|
||||
local core_ok=yes
|
||||
xray_alive || core_ok=no
|
||||
kit_banner
|
||||
echo
|
||||
echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}"
|
||||
if [[ $core_ok == yes ]]; then
|
||||
echo "${G}${B}Готово! Сервер восстановлен из копии: $n_in подключений, $n_cl клиентских записей.${N}"
|
||||
else
|
||||
echo "${R}${B}Сервер восстановлен из копии ($n_in подключений, $n_cl клиентских записей), но ядро Xray не запустилось: подключения не работают.${N}"
|
||||
echo "Причина – в журнале: ${B}tail -n 30 /var/log/x-ui/3xui.log${N}, проверка: ${B}kit check${N}"
|
||||
fi
|
||||
echo "Логин и пароль панели прежние, адрес панели и подписки: ${B}cat $RESULT${N}"
|
||||
echo
|
||||
if [[ -n $old_ip ]]; then
|
||||
@@ -1942,6 +2032,13 @@ PY
|
||||
echo "Клиентам ничего менять не нужно: ключи, ссылки и подписки те же."
|
||||
fi
|
||||
[[ $PANEL_SSL == custom ]] && echo "Направьте A-запись домена ${B}$HOST${N} на IP этого сервера, если ещё не сделали."
|
||||
if ((${#WARNINGS[@]})); then
|
||||
echo
|
||||
echo "${B}ВНИМАНИЕ${N}"
|
||||
local w
|
||||
for w in "${WARNINGS[@]}"; do echo " ${Y}!${N} $w"; done
|
||||
fi
|
||||
[[ $core_ok == yes ]] || exit 1
|
||||
return 0
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Сертификаты при --restore (3x-ui.sh): python3 tools/test/restore_archive_test.py"""
|
||||
import json, os, sqlite3, subprocess, tempfile, unittest
|
||||
|
||||
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
src = open(os.path.join(root, "scripts", "3x-ui.sh"), encoding="utf-8").read()
|
||||
|
||||
|
||||
def heredoc(after):
|
||||
i = src.index(after)
|
||||
j = src.index("<<'PY'", i)
|
||||
body = src[src.index("\n", j) + 1:]
|
||||
return body[:body.index("\nPY\n") + 1]
|
||||
|
||||
|
||||
CERT_FILES = heredoc("db_cert_files() {")
|
||||
PIN = heredoc("pin_cert_in_db() {")
|
||||
|
||||
|
||||
def make_db():
|
||||
d = tempfile.mkdtemp()
|
||||
p = os.path.join(d, "x-ui.db")
|
||||
db = sqlite3.connect(p)
|
||||
db.execute("CREATE TABLE inbounds (id INTEGER PRIMARY KEY, remark TEXT, settings TEXT, stream_settings TEXT)")
|
||||
hy = {"network": "hysteria", "security": "tls", "tlsSettings": {"certificates": [
|
||||
{"certificateFile": "/root/cert/ip/fullchain.pem", "keyFile": "/root/cert/ip/privkey.pem"}], "settings": {"fingerprint": "chrome"}},
|
||||
"hysteriaSettings": {"masquerade": {"content": "<p>Кофейня</p>"}}}
|
||||
tuic = {"server": {"certificate": "/root/cert/ip/fullchain.pem"}}
|
||||
other = {"tlsSettings": {"certificates": [{"certificateFile": "/root/cert/custom/fullchain.pem"}]}}
|
||||
db.executemany("INSERT INTO inbounds (remark, settings, stream_settings) VALUES (?, ?, ?)", [
|
||||
("Hysteria2", "{}", json.dumps(hy)), ("TUIC", json.dumps(tuic), ""), ("Other", "{}", json.dumps(other)),
|
||||
("Broken", "not json", "[1]")])
|
||||
db.commit(); db.close()
|
||||
return p
|
||||
|
||||
|
||||
def run_db(code, p, *args):
|
||||
code = code.replace("/etc/x-ui/x-ui.db", p)
|
||||
return subprocess.run(["python3", "-", *args], input=code, capture_output=True, text=True)
|
||||
|
||||
|
||||
class Certs(unittest.TestCase):
|
||||
def test_cert_files(self):
|
||||
r = run_db(CERT_FILES, make_db())
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
self.assertEqual(r.stdout.split(), ["/root/cert/custom/fullchain.pem", "/root/cert/ip/fullchain.pem"])
|
||||
|
||||
def test_pin_only_matching_inbound(self):
|
||||
p = make_db()
|
||||
r = run_db(PIN, p, "/root/cert/ip/fullchain.pem", "ab" * 32)
|
||||
self.assertEqual(r.returncode, 0, r.stderr)
|
||||
rows = dict(sqlite3.connect(p).execute("SELECT remark, stream_settings FROM inbounds"))
|
||||
hy = json.loads(rows["Hysteria2"])
|
||||
self.assertEqual(hy["tlsSettings"]["settings"], {"fingerprint": "chrome", "pinnedPeerCertSha256": ["ab" * 32]})
|
||||
self.assertEqual(hy["hysteriaSettings"]["masquerade"]["content"], "<p>Кофейня</p>")
|
||||
self.assertNotIn("pinnedPeerCertSha256", rows["Other"])
|
||||
self.assertEqual(rows["Broken"], "[1]")
|
||||
|
||||
def test_restore_writes_only_own_paths(self):
|
||||
# Путь сертификата приходит из базы копии: генерировать можно только в /root/cert/ip и /root/cert/self.
|
||||
self.assertIn(r"^/root/cert/(ip|self)/fullchain\.pem$", src)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user