From 4a9bc91acf210a2d49f558b52f30a5a31c2c85cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 5 Jun 2026 19:45:13 +0300 Subject: [PATCH] feat: add Russian auth settings UI --- README.md | 18 +++- app.py | 281 ++++++++++++++++++++++++++++++++++++++--------------- install.sh | 1 + 3 files changed, 219 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index e7b986a..64b02c1 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ sudo bash install.sh Откройте в браузере `http://АДРЕС_СЕРВЕРА:3030/` (подставьте выбранный порт). Войдите с логином и паролем, которые указали при установке. -Если WARP ещё не установлен — нажмите **Install WARP** в интерфейсе (или выполните `scripts/warp-install-cf.sh`, задав `WARP_PROXY_PORT`). +Если WARP ещё не установлен — нажмите **Установить WARP** в интерфейсе (или выполните `scripts/warp-install-cf.sh`, задав `WARP_PROXY_PORT`). ### Удаление @@ -100,6 +100,18 @@ install.sh / uninstall.sh # Установка и снятие «в одн Понятные имена клиентов Amnezia: `/etc/warp-webui/client-aliases.json`. +### Смена логина и пароля + +В панели есть блок **Доступ к панели**: + +1. Введите новый логин. Разрешены латиница, цифры и символы `._@-`. +2. Введите новый пароль, минимум 8 символов. Если поле пароля оставить пустым, изменится только логин. +3. Нажмите **Сохранить доступ**. +4. Панель сохранит настройки в `/etc/default/warp-webui`, создаст backup в `/var/backups/warp-webui/` и перезапустит сервис. +5. После перезапуска браузер попросит войти заново уже с новым логином и паролем. + +Важно: текущая версия использует HTTP Basic Auth. Для открытого сервера поставьте HTTPS через nginx/Caddy и ограничьте доступ firewall-ом. + ## Интеграция с 3x-ui (по желанию) 1. Установите и подключите WARP, включите режим proxy и задайте порт SOCKS (в панели или при `install.sh`). @@ -124,7 +136,7 @@ install.sh / uninstall.sh # Установка и снятие «в одн - выбрать endpoint: `engage.cloudflareclient.com`, IPv4 или свой hostname; - выбрать source IP сервера или оставить автоопределение; -- включить single UDP port, обычно `4500 -> 4500`; +- включить режим одного UDP-порта, обычно `4500 -> 4500`; - включить Cloudflare multiport mode; - удалить только managed relay rules. @@ -144,9 +156,11 @@ install.sh / uninstall.sh # Установка и снятие «в одн |-------|------|----------| | GET | `/` | HTML-интерфейс | | GET | `/status`, `/registration`, `/proxy`, `/logs` | Статус и диагностика | +| GET | `/auth-config` | Текущий логин панели и путь к env-файлу | | GET | `/relay` | Статус WARP Relay beta | | POST | `/connect`, `/disconnect`, `/restart` | Управление WARP | | POST | `/warp-install`, `/warp-uninstall` | Установка/удаление пакета | +| POST | `/auth-config` | Смена логина/пароля веб-панели | | POST | `/relay-apply`, `/relay-remove` | Применить/удалить WARP Relay rules | | POST | `/proxy-port`, `/license` | Порт SOCKS, ключ WARP+ | | POST | `/xui-preset`, `/amnezia-preset`, `/amnezia-routing` | Пресеты интеграций | diff --git a/app.py b/app.py index ebd171f..db86a82 100755 --- a/app.py +++ b/app.py @@ -21,6 +21,7 @@ if not PASS: raise SystemExit("WARP_WEBUI_PASS is not set. Configure /etc/default/warp-webui or run install.sh.") HOST = os.environ.get("WARP_WEBUI_HOST", "0.0.0.0") PORT = int(os.environ.get("WARP_WEBUI_PORT", "3030")) +ENV_FILE = os.environ.get("WARP_WEBUI_ENV_FILE", "/etc/default/warp-webui") LOG_DIR = os.environ.get("WARP_WEBUI_LOG_DIR", "/var/log/warp-webui") LOG_FILE = os.environ.get("WARP_WEBUI_LOG_FILE", os.path.join(LOG_DIR, "warp-webui.log")) @@ -117,6 +118,75 @@ def backup_file(path: str, label: str): return dest +def auth_config(): + return {"user": USER, "env_file": ENV_FILE} + + +def _env_quote(value: str) -> str: + return shlex.quote(value) + + +def _write_env_values(path: str, updates: dict): + lines = [] + seen = set() + if os.path.isfile(path): + with open(path, "r", encoding="utf-8") as f: + lines = f.read().splitlines() + new_lines = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + new_lines.append(line) + continue + key = line.split("=", 1)[0].strip() + if key in updates: + new_lines.append(f"{key}={_env_quote(updates[key])}") + seen.add(key) + else: + new_lines.append(line) + for key, value in updates.items(): + if key not in seen: + new_lines.append(f"{key}={_env_quote(value)}") + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(new_lines)) + f.write("\n") + os.chmod(tmp, 0o600) + os.replace(tmp, path) + + +def update_webui_credentials(user: str, password): + user = (user or "").strip() + if not re.fullmatch(r"[A-Za-z0-9_.@-]{3,64}", user): + return 400, {"error": "Логин: 3-64 символа, латиница/цифры/._@-"} + updates = {"WARP_WEBUI_USER": user} + if password is not None and password != "": + if len(password) < 8 or len(password) > 128: + return 400, {"error": "Пароль должен быть от 8 до 128 символов"} + if "\n" in password or "\r" in password or "\x00" in password: + return 400, {"error": "Пароль не должен содержать перенос строки"} + updates["WARP_WEBUI_PASS"] = password + backup = backup_file(ENV_FILE, "env") if os.path.isfile(ENV_FILE) else None + _write_env_values(ENV_FILE, updates) + log_event("info", "auth_config_updated", user=user, backup=backup) + if shutil.which("systemctl"): + subprocess.Popen( + ["/bin/sh", "-c", "sleep 0.6; systemctl restart warp-webui.service"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + return 200, { + "ok": True, + "user": user, + "backup": backup, + "env_file": ENV_FILE, + "restart": "scheduled", + "note": "Сервис перезапустится. Войдите заново с новым логином/паролем.", + } + + def parse_warp_status_text(stdout: str, stderr: str, code: int): connected = None health = None @@ -694,7 +764,7 @@ def get_public_ipv4() -> str: return _valid_ipv4((out or "").strip().splitlines()[0]) except Exception: continue - raise ValueError("cannot detect public IPv4; set sourceIp manually") + raise ValueError("Не удалось определить публичный IPv4; укажите sourceIp вручную") def detect_relay_firewall(): @@ -984,7 +1054,7 @@ def read_json_body(handler, max_bytes=16384): INDEX_HTML = r""" - + @@ -1008,81 +1078,101 @@ INDEX_HTML = r""" .ok { color:#6ee7b7; } .bad { color:#fca5a5; } .msg { margin-top:8px; font-size: 13px; } + .hint { color:#9aa7c7; font-size:12px; line-height:1.45; margin:8px 0 0 0; }

WARP Web UI

-
Basic Auth. Auto-refresh every 4s.
+
Панель управления Cloudflare WARP. Авторизация Basic Auth. Автообновление каждые 4 секунды.
- - - - + + + +
-

Account (WARP registration)

+

Доступ к панели

+

Здесь можно сменить логин и пароль веб-панели. Настройки сохраняются в /etc/default/warp-webui, затем сервис перезапускается. После сохранения браузер попросит войти заново.

-
Account type
-
-
Account ID
-
-
Device ID
-
-
License
-
+
Текущий логин
-
+
Файл настроек
-
- - + + + +
+
+
+ +
+

Аккаунт WARP

+

Информация из warp-cli registration show. Если WARP ещё не установлен, здесь будет ошибка command not found: warp-cli.

+
+
Тип аккаунта
-
+
ID аккаунта
-
+
ID устройства
-
+
Лицензия
-
+
+
+ +
-

WARP package

+

Пакет Cloudflare WARP

+

Установка ставит официальный пакет cloudflare-warp для Debian/Ubuntu. После установки можно подключить WARP и включить SOCKS proxy.

- - + +
-

SOCKS proxy port (warp-cli)

+

SOCKS proxy порт

+

Режим warp-cli mode proxy поднимает локальный SOCKS на 127.0.0.1:порт. Его используют 3x-ui, Amnezia и другие клиенты на этом сервере.

-
Current port
-
-
Endpoint
-
+
Текущий порт
-
+
Локальный адрес
-
- - - + + +

WARP Relay (beta)

+

Relay перенаправляет UDP WireGuard/WARP трафик через firewall rules. Это не гарантирует конкретную страну Cloudflare, но позволяет выбрать endpoint/IP, к которому будет идти подключение.

Firewall
-
-
Active
-
-
Endpoint IP
-
-
Rules
-
+
Активен
-
+
IP endpoint
-
+
Правил
-
- - + + - - + +
- - + +

@@ -1090,8 +1180,8 @@ INDEX_HTML = r"""
 
   

3x-ui / Xray

-

Adds outbound tag warp-socks → 127.0.0.1:PORT and routing rule geosite:google. Backs up config, restarts x-ui.

- +

Добавляет outbound warp-socks на 127.0.0.1:PORT и правило маршрутизации geosite:google. Перед изменением создаётся backup конфига.

+
@@ -1102,34 +1192,35 @@ INDEX_HTML = r"""

WARP только для выбранных клиентов

Отметьте клиентов по имени. Ничего не выбрано → общее правило geosite. Имя можно сохранить — переживёт перезапуск UI.

-
Loading clients...
+
Загрузка клиентов...
- +
-

Client presets (JSON)

+

Готовые пресеты для клиентов (JSON)

+

Можно скопировать JSON и использовать как подсказку при настройке Xray/v2rayN/Amnezia.

- +
-

Status

+

Статус WARP

-
Connected
-
+
Подключен
-
Health
-
-
Account
-
-
Device
-
-
Last stderr
-
Last stdout
+
Аккаунт
-
+
Устройство
-
+
Последний stderr
+
Последний stdout
-

Logs (recent)

+

Последние события


   
@@ -1160,6 +1251,14 @@ async function api(path, opts={}) { async function apiPost(path, payload) { return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}) }); } +async function refreshAuthConfig() { + try { + const cfg = await api('/auth-config'); + setText('authUser', cfg.user); + setText('authEnvFile', cfg.env_file); + if (!el('authUserInput').value) el('authUserInput').value = cfg.user || ''; + } catch (e) { setMsg('authMsg', 'Не удалось загрузить настройки доступа: ' + (e.message || e), false); } +} async function refreshStatus() { try { const data = await api('/status'); @@ -1169,12 +1268,12 @@ async function refreshStatus() { setText('sDevice', data.device); setText('sStdout', (data.stdout || '').slice(-4000)); setText('sStderr', (data.stderr || '').slice(-4000)); - if (data.connected === true) badge('CONNECTED', true); - else if (data.connected === false) badge('DISCONNECTED', false); - else badge('UNKNOWN'); + if (data.connected === true) badge('WARP подключен', true); + else if (data.connected === false) badge('WARP отключен', false); + else badge('Статус неизвестен'); el('btnConnect').disabled = data.connected === true; el('btnDisconnect').disabled = data.connected === false; - } catch (e) { badge('ERROR /status', false); } + } catch (e) { badge('Ошибка /status', false); } } async function refreshRegistration() { try { @@ -1183,7 +1282,7 @@ async function refreshRegistration() { setText('rAccountId', r.account_id); setText('rDeviceId', r.device_id); setText('rLicense', r.license_masked); - } catch (e) { setMsg('accountMsg', 'Failed to load registration', false); } + } catch (e) { setMsg('accountMsg', 'Не удалось загрузить регистрацию WARP', false); } } async function refreshProxy() { try { @@ -1191,13 +1290,13 @@ async function refreshProxy() { setText('proxyPort', p.port); setText('proxyEndpoint', p.port ? ('127.0.0.1:' + p.port) : '-'); if (p.port && !el('proxyPortInput').value) el('proxyPortInput').value = p.port; - } catch (e) { setMsg('proxyMsg', 'Failed to load proxy port', false); } + } catch (e) { setMsg('proxyMsg', 'Не удалось загрузить SOCKS порт', false); } } async function refreshPresets() { try { const p = await api('/presets'); el('presetsJson').value = JSON.stringify(p, null, 2); - } catch (e) { el('presetsJson').value = 'Failed to load presets'; } + } catch (e) { el('presetsJson').value = 'Не удалось загрузить пресеты'; } } async function refreshRelay() { try { @@ -1213,7 +1312,7 @@ async function refreshRelay() { if (!el('relayTargetPort').value) el('relayTargetPort').value = st.targetPort || 4500; el('relayMode').value = st.mode || 'single'; el('relayRules').textContent = (r.rules || []).join('\n'); - } catch (e) { setMsg('relayMsg', 'Failed to load relay status: ' + (e.message || e), false); } + } catch (e) { setMsg('relayMsg', 'Не удалось загрузить статус relay: ' + (e.message || e), false); } } async function refreshLogs() { try { @@ -1226,67 +1325,82 @@ async function refreshLogs() { const rc = (e.returncode !== undefined) ? ` rc=${e.returncode}` : ''; return `${ts} ${lvl} ${msg}${action}${rc}`; }); - el('logMeta').textContent = `entries: ${lines.length}`; + el('logMeta').textContent = `событий: ${lines.length}`; el('logText').textContent = lines.join('\n'); - } catch (e) { el('logMeta').textContent = 'failed to load logs'; } + } catch (e) { el('logMeta').textContent = 'не удалось загрузить события'; } } async function refreshAll() { - await Promise.all([refreshStatus(), refreshRegistration(), refreshProxy(), refreshPresets(), refreshRelay(), refreshLogs(), refreshAmneziaClients()]); + await Promise.all([refreshAuthConfig(), refreshStatus(), refreshRegistration(), refreshProxy(), refreshPresets(), refreshRelay(), refreshLogs(), refreshAmneziaClients()]); } async function doAction(path) { - badge('Working...', null); + badge('Выполняю...', null); try { await api(path, { method: 'POST' }); badge('OK ' + path, true); } - catch (e) { badge('FAIL ' + path, false); } + catch (e) { badge('Ошибка ' + path, false); } await refreshAll(); } el('btnConnect').onclick = () => doAction('/connect'); el('btnDisconnect').onclick = () => doAction('/disconnect'); el('btnRestart').onclick = () => doAction('/restart'); el('btnRefresh').onclick = () => refreshAll(); +el('btnAuthSave').onclick = async () => { + const user = el('authUserInput').value.trim(); + const password = el('authPassInput').value; + if (!user) return setMsg('authMsg', 'Введите новый логин', false); + if (password && password.length < 8) return setMsg('authMsg', 'Пароль должен быть минимум 8 символов', false); + if (!confirm('Сохранить новый доступ и перезапустить панель? После этого нужно войти заново.')) return; + setMsg('authMsg', 'Сохраняю доступ и перезапускаю сервис...', null); + try { + const payload = { user }; + if (password) payload.password = password; + const r = await apiPost('/auth-config', payload); + el('authPassInput').value = ''; + setMsg('authMsg', r.note || 'Сохранено. Войдите заново.', true); + } catch (e) { setMsg('authMsg', String(e.message || e), false); } +}; el('btnLicense').onclick = async () => { const key = el('licenseKey').value.trim(); - setMsg('accountMsg', 'Applying...', null); + setMsg('accountMsg', 'Применяю лицензию...', null); try { const r = await apiPost('/license', { key }); - setMsg('accountMsg', (r.stderr || r.stdout || 'License applied').slice(0, 500), true); + setMsg('accountMsg', (r.stderr || r.stdout || 'Лицензия применена').slice(0, 500), true); el('licenseKey').value = ''; await refreshRegistration(); } catch (e) { setMsg('accountMsg', String(e.message || e), false); } }; el('btnInstall').onclick = async () => { - if (!confirm('Install Cloudflare WARP package on this server?')) return; - setMsg('installMsg', 'Running install script...', null); + if (!confirm('Установить пакет Cloudflare WARP на этот сервер?')) return; + setMsg('installMsg', 'Запускаю установку WARP...', null); try { const r = await apiPost('/warp-install', {}); - setMsg('installMsg', (r.stdout || r.stderr || 'done').slice(0, 800), r.result_code === 0); + setMsg('installMsg', (r.stdout || r.stderr || 'Готово').slice(0, 800), r.result_code === 0); await refreshAll(); } catch (e) { setMsg('installMsg', String(e.message || e), false); } }; el('btnUninstall').onclick = async () => { - if (!confirm('Uninstall cloudflare-warp package?')) return; - setMsg('installMsg', 'Running uninstall...', null); + if (!confirm('Удалить пакет cloudflare-warp?')) return; + setMsg('installMsg', 'Запускаю удаление WARP...', null); try { const r = await apiPost('/warp-uninstall', {}); - setMsg('installMsg', (r.stdout || r.stderr || 'done').slice(0, 800), r.result_code === 0); + setMsg('installMsg', (r.stdout || r.stderr || 'Готово').slice(0, 800), r.result_code === 0); } catch (e) { setMsg('installMsg', String(e.message || e), false); } }; async function setPort(port) { - setMsg('proxyMsg', 'Setting port ' + port + '...', null); + setMsg('proxyMsg', 'Сохраняю порт ' + port + '...', null); try { const r = await apiPost('/proxy-port', { port }); - setMsg('proxyMsg', 'Port updated to ' + (r.proxy && r.proxy.port), true); + setMsg('proxyMsg', 'Порт обновлен: ' + (r.proxy && r.proxy.port), true); await refreshProxy(); await refreshPresets(); } catch (e) { setMsg('proxyMsg', String(e.message || e), false); } } el('btnSetPort').onclick = () => { const p = parseInt(el('proxyPortInput').value, 10); - if (!p) return setMsg('proxyMsg', 'Enter a valid port', false); + if (!p) return setMsg('proxyMsg', 'Введите корректный порт', false); setPort(p); }; el('btnPort40000').onclick = () => { el('proxyPortInput').value = 40000; setPort(40000); }; el('btnRelayApply').onclick = async () => { - if (!confirm('Apply WARP Relay firewall rules on this server?')) return; + if (!confirm('Включить WARP Relay и применить firewall rules на сервере?')) return; const payload = { endpoint: el('relayEndpoint').value.trim() || 'engage.cloudflareclient.com', sourceIp: el('relaySourceIp').value.trim(), @@ -1294,28 +1408,28 @@ el('btnRelayApply').onclick = async () => { relayPort: parseInt(el('relayPort').value || '4500', 10), targetPort: parseInt(el('relayTargetPort').value || el('relayPort').value || '4500', 10), }; - setMsg('relayMsg', 'Applying relay rules...', null); + setMsg('relayMsg', 'Применяю relay rules...', null); try { const r = await apiPost('/relay-apply', payload); const st = r.state || {}; - setMsg('relayMsg', 'OK: ' + st.sourceIp + ':' + st.relayPort + ' -> ' + st.endpointIp + ':' + st.targetPort, true); + setMsg('relayMsg', 'Готово: ' + st.sourceIp + ':' + st.relayPort + ' -> ' + st.endpointIp + ':' + st.targetPort, true); await refreshRelay(); } catch (e) { setMsg('relayMsg', String(e.message || e), false); } }; el('btnRelayRemove').onclick = async () => { - if (!confirm('Remove only WARP Web UI relay rules?')) return; - setMsg('relayMsg', 'Removing relay rules...', null); + if (!confirm('Удалить только WARP Web UI relay rules?')) return; + setMsg('relayMsg', 'Удаляю relay rules...', null); try { await apiPost('/relay-remove', {}); - setMsg('relayMsg', 'Relay rules removed', true); + setMsg('relayMsg', 'Relay rules удалены', true); await refreshRelay(); } catch (e) { setMsg('relayMsg', String(e.message || e), false); } }; el('btnXui').onclick = async () => { - setMsg('xuiMsg', 'Applying x-ui preset...', null); + setMsg('xuiMsg', 'Применяю preset x-ui...', null); try { const r = await apiPost('/xui-preset', {}); - setMsg('xuiMsg', 'Backup: ' + (r.backup || '-') + '; restart rc=' + r.restart_code, r.restart_code === 0); + setMsg('xuiMsg', 'Backup: ' + (r.backup || '-') + '; код перезапуска=' + r.restart_code, r.restart_code === 0); } catch (e) { setMsg('xuiMsg', String(e.message || e), false); } }; @@ -1385,7 +1499,7 @@ el('btnAmneziaRouting').onclick = async () => { el('btnCopyPresets').onclick = async () => { try { await navigator.clipboard.writeText(el('presetsJson').value); setMsg('accountMsg', 'Presets copied', true); } - catch (e) { setMsg('accountMsg', 'Copy failed', false); } + catch (e) { setMsg('accountMsg', 'Не удалось скопировать', false); } }; refreshAll(); setInterval(refreshAll, 4000); @@ -1438,6 +1552,8 @@ class Handler(BaseHTTPRequestHandler): return self._json(200, warp_registration()) if self.path == "/proxy": return self._json(200, get_proxy_port()) + if self.path == "/auth-config": + return self._json(200, auth_config()) if self.path == "/relay": return self._json(200, relay_rules_status()) if self.path == "/amnezia-clients": @@ -1492,6 +1608,13 @@ class Handler(BaseHTTPRequestHandler): code, payload = apply_license_key(body.get("key", "")) return self._json(code, payload) + if self.path == "/auth-config": + code, payload = update_webui_credentials( + body.get("user", ""), + body.get("password") if "password" in body else None, + ) + return self._json(code, payload) + if self.path == "/proxy-port": try: port = int(body.get("port")) diff --git a/install.sh b/install.sh index 78c9c95..759121a 100755 --- a/install.sh +++ b/install.sh @@ -109,6 +109,7 @@ WARP_WEBUI_USER=${WARP_WEBUI_USER} WARP_WEBUI_PASS=${WARP_WEBUI_PASS} WARP_WEBUI_HOST=0.0.0.0 WARP_WEBUI_PORT=${WARP_WEBUI_PORT} +WARP_WEBUI_ENV_FILE=${ENV_FILE} WARP_PROXY_PORT=${WARP_PROXY_PORT} WARP_PUBLIC_HOST=${WARP_PUBLIC_HOST} WARP_INSTALL_SCRIPT=${INSTALL_DIR}/scripts/warp-install-cf.sh