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 секунды.
- Connect
- Disconnect
- Restart warp-svc
- Refresh
+ Подключить WARP
+ Отключить WARP
+ Перезапустить warp-svc
+ Обновить
-
Account (WARP registration)
+
Доступ к панели
+
Здесь можно сменить логин и пароль веб-панели. Настройки сохраняются в /etc/default/warp-webui, затем сервис перезапускается. После сохранения браузер попросит войти заново.
-
Account type
-
-
Account ID
-
-
Device ID
-
-
License
-
+
Текущий логин
-
+
Файл настроек
-
-
- Apply 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.
- Install WARP
- Uninstall WARP
+ Установить WARP
+ Удалить WARP
-
SOCKS proxy port (warp-cli)
+
SOCKS proxy порт
+
Режим warp-cli mode proxy поднимает локальный SOCKS на 127.0.0.1:порт. Его используют 3x-ui, Amnezia и другие клиенты на этом сервере.
-
Current port
-
-
Endpoint
-
+
Текущий порт
-
+
Локальный адрес
-
-
- Set port
- Use 40000
+
+ Сохранить порт
+ Поставить 40000
WARP Relay (beta)
+
Relay перенаправляет UDP WireGuard/WARP трафик через firewall rules. Это не гарантирует конкретную страну Cloudflare, но позволяет выбрать endpoint/IP, к которому будет идти подключение.
Firewall
-
-
Active
-
-
Endpoint IP
-
-
Rules
-
+
Активен
-
+
IP endpoint
-
+
Правил
-
-
-
+
+
- single UDP port
+ Один UDP порт
Cloudflare WARP multiport
-
-
+
+
- Apply relay
- Remove relay rules
+ Включить relay
+ Удалить relay rules
@@ -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.
-
Apply preset to x-ui
+
Добавляет outbound warp-socks на 127.0.0.1:PORT и правило маршрутизации geosite:google. Перед изменением создаётся backup конфига.
+
Применить preset к x-ui
@@ -1102,34 +1192,35 @@ INDEX_HTML = r"""
WARP только для выбранных клиентов
Отметьте клиентов по имени. Ничего не выбрано → общее правило geosite. Имя можно сохранить — переживёт перезапуск UI.
-
Loading clients...
+
Загрузка клиентов...
-
+
Применить WARP-маршрутизацию для выбранных
-
Client presets (JSON)
+
Готовые пресеты для клиентов (JSON)
+
Можно скопировать JSON и использовать как подсказку при настройке Xray/v2rayN/Amnezia.
-
Copy JSON
+
Скопировать JSON
-
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