diff --git a/admin-web/server.py b/admin-web/server.py index 86768cb..92ceacb 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -8,7 +8,6 @@ Installers generate /root/pcatelegram_web-admin.password for credentials. from __future__ import annotations -import base64 import csv import fcntl import hashlib @@ -52,7 +51,10 @@ HOST = os.getenv("PCATELEGRAM_WEB_ADMIN_HOST", "0.0.0.0") PORT = int(os.getenv("PCATELEGRAM_WEB_ADMIN_PORT", "1984")) ADMIN_USER = os.getenv("PCATELEGRAM_WEB_ADMIN_USER", "admin") ADMIN_PASSWORD = os.getenv("PCATELEGRAM_WEB_ADMIN_PASSWORD", "") -ADMIN_REALM = os.getenv("PCATELEGRAM_WEB_ADMIN_REALM", "PCAtelegram_web") +ADMIN_AUTH_FILE = Path(os.getenv("PCATELEGRAM_WEB_ADMIN_AUTH_FILE", "/root/pcatelegram_web-admin.password")) +SESSION_COOKIE = "pcatelegram_web_session" +SESSION_TTL_SECONDS = 12 * 60 * 60 +SESSIONS: dict[str, float] = {} VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") @@ -69,15 +71,238 @@ TRAFFIC_WINDOWS = { } -def load_admin_password_file() -> str: - path = Path(os.getenv("PCATELEGRAM_WEB_ADMIN_AUTH_FILE", "/root/pcatelegram_web-admin.password")) +def load_admin_credentials() -> tuple[str, str]: + if ADMIN_PASSWORD: + return ADMIN_USER or "admin", ADMIN_PASSWORD + user = "admin" + password = "admin" try: - for line in path.read_text(encoding="utf-8").splitlines(): + for line in ADMIN_AUTH_FILE.read_text(encoding="utf-8").splitlines(): + if line.startswith("user="): + user = line.split("=", 1)[1].strip() or "admin" if line.startswith("password="): - return line.split("=", 1)[1].strip() + password = line.split("=", 1)[1].strip() or "admin" except OSError: - return "" - return "" + pass + return user, password + + +def write_admin_credentials(username: str, password: str) -> None: + ADMIN_AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + tmp = ADMIN_AUTH_FILE.with_suffix(".tmp") + body = "\n".join([ + f"user={username}", + f"password={password}", + f"url=http://{public_host_for_notes()}:{PORT}/", + "", + ]) + tmp.write_text(body, encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(ADMIN_AUTH_FILE) + + +def public_host_for_notes() -> str: + try: + host = socket.gethostbyname(socket.gethostname()) + if host and not host.startswith("127."): + return host + except OSError: + pass + code, out, _ = run(["hostname", "-I"], timeout=3) + if code == 0: + first = (out.strip().split() or [""])[0] + if first: + return first + return HOST if HOST != "0.0.0.0" else "127.0.0.1" + + +def make_session() -> str: + token = secrets.token_urlsafe(32) + SESSIONS[token] = time.time() + SESSION_TTL_SECONDS + return token + + +def session_is_valid(token: str) -> bool: + exp = SESSIONS.get(token) + if not exp: + return False + if exp < time.time(): + SESSIONS.pop(token, None) + return False + SESSIONS[token] = time.time() + SESSION_TTL_SECONDS + return True + + +def clear_session(token: str) -> None: + if token: + SESSIONS.pop(token, None) + + +def login_page() -> bytes: + return """ + + + + + PCAtelegram_web Login + + + +
+
+
PCA
+
+

PCAtelegram_web

+
Web admin panel
+
+
+
+ + +
+
+ + +
+
+

По умолчанию: admin / admin. Смените пароль в Settings после входа.

+
+ + + +""".encode("utf-8") def utc_now() -> str: @@ -1297,35 +1522,76 @@ class AdminHandler(BaseHTTPRequestHandler): def log_message(self, fmt: str, *args: Any) -> None: print("%s - %s" % (self.address_string(), fmt % args)) - def auth_enabled(self) -> bool: - return bool(ADMIN_PASSWORD or load_admin_password_file()) + def cookie_session(self) -> str: + raw = self.headers.get("Cookie", "") + for item in raw.split(";"): + item = item.strip() + if item.startswith(SESSION_COOKIE + "="): + return item.split("=", 1)[1] + return "" def is_authorized(self) -> bool: - if not self.auth_enabled(): - return True - header = self.headers.get("Authorization", "") - if not header.startswith("Basic "): - return False - try: - decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8") - username, password = decoded.split(":", 1) - except Exception: - return False - expected_password = ADMIN_PASSWORD or load_admin_password_file() - return hmac.compare_digest(username, ADMIN_USER) and hmac.compare_digest(password, expected_password) + return session_is_valid(self.cookie_session()) def require_auth(self) -> bool: if self.is_authorized(): return True - body = b"Authentication required\n" - self.send_response(401) - self.send_header("WWW-Authenticate", f'Basic realm="{ADMIN_REALM}"') - self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_error_json(401, "unauthorized") + return False + + def send_login_page(self) -> None: + body = login_page() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def set_session_cookie(self, token: str) -> None: + self.send_header( + "Set-Cookie", + f"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_TTL_SECONDS}", + ) + + def clear_session_cookie(self) -> None: + self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") + + def credentials_match(self, username: str, password: str) -> bool: + expected_user, expected_password = load_admin_credentials() + return hmac.compare_digest(username, expected_user) and hmac.compare_digest(password, expected_password) + + def handle_login(self) -> None: + try: + body = self.read_json_body() + except Exception: + self.send_error_json(400, "bad request") + return + username = str(body.get("username", "")).strip() + password = str(body.get("password", "")) + if not self.credentials_match(username, password): + self.send_error_json(401, "invalid credentials") + return + token = make_session() + payload = json.dumps({"ok": True, "data": {"user": username}}, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.set_session_cookie(token) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def handle_logout(self) -> None: + clear_session(self.cookie_session()) + body = b'{"ok": true}\n' + self.send_response(200) + self.clear_session_cookie() + self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) - return False def send_json(self, payload: Any, status: int = 200) -> None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") @@ -1611,6 +1877,28 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(400, str(exc)) return self.send_json({"ok": True, "data": lang_payload}) + elif path == "/api/settings/auth": + current_password = str(body.get("current_password", "")) + username = str(body.get("username", "")).strip() + new_password = str(body.get("new_password", "")) + current_user, current_expected = load_admin_credentials() + if not hmac.compare_digest(current_password, current_expected): + self.send_error_json(403, "current password is wrong") + return + if not USER_RE.match(username): + self.send_error_json(400, "invalid username") + return + if len(new_password) < 4: + self.send_error_json(400, "password must be at least 4 chars") + return + try: + write_admin_credentials(username, new_password) + except Exception as exc: + self.send_error_json(500, f"failed to save credentials: {exc}") + return + self.send_json({"ok": True, "data": {"user": username, "changed": username != current_user or new_password != current_expected}}) + elif path == "/api/auth/logout": + self.handle_logout() elif path.startswith("/api/services/") and path.endswith("/restart"): service = path[len("/api/services/"):-len("/restart")] allowed = {"telemt", "nginx", "pcatelegram_web-bot", "pcatelegram_web-stats"} @@ -1678,18 +1966,25 @@ class AdminHandler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self) -> None: - if not self.require_auth(): - return parsed = urllib.parse.urlparse(self.path) + if not self.is_authorized(): + if parsed.path.startswith("/api/"): + self.send_error_json(401, "unauthorized") + else: + self.send_login_page() + return if parsed.path.startswith("/api/"): self.route_get_api(parsed) else: self.send_static(parsed) def do_POST(self) -> None: + parsed = urllib.parse.urlparse(self.path) + if parsed.path == "/api/auth/login": + self.handle_login() + return if not self.require_auth(): return - parsed = urllib.parse.urlparse(self.path) if parsed.path.startswith("/api/"): self.route_post_api(parsed) else: diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 0dd6e49..3348198 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -100,6 +100,12 @@ const i18n = { panelLanguage: "Panel language", theme: "Theme", bindAddress: "Bind address", + authSettingsTitle: "Login credentials", + authCurrentPassword: "Current password", + authUsername: "Username", + authNewPassword: "New password", + authSave: "Save login", + authSaved: "Login updated", dashboard: "Dashboard", noKeys: "No keys yet", noBackups: "No backups yet", @@ -327,6 +333,12 @@ const i18n = { panelLanguage: "Язык панели", theme: "Тема", bindAddress: "Адрес привязки", + authSettingsTitle: "Данные входа", + authCurrentPassword: "Текущий пароль", + authUsername: "Логин", + authNewPassword: "Новый пароль", + authSave: "Сохранить вход", + authSaved: "Данные входа обновлены", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", @@ -636,6 +648,31 @@ async function setLanguage(lang) { } } +async function updateAuthSettings(eventObj) { + eventObj.preventDefault(); + const form = eventObj.currentTarget; + const btn = form.querySelector("button[type='submit']"); + const body = { + current_password: form.current_password.value, + username: form.username.value.trim(), + new_password: form.new_password.value, + }; + btn.disabled = true; + try { + await api("/api/settings/auth", { + method: "POST", + body: JSON.stringify(body), + }); + form.current_password.value = ""; + form.new_password.value = ""; + toast(t("authSaved")); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + function setPage(page, push = true) { const next = $(`[data-page="${page}"]`) ? page : "dashboard"; state.page = next; @@ -1744,6 +1781,7 @@ $("#createBackupBtn").addEventListener("click", createBackup); $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); $("#collectStatsBtn").addEventListener("click", collectStats); +$("#authSettingsForm").addEventListener("submit", updateAuthSettings); window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false)); setPage((location.hash || "#dashboard").slice(1), false); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 2a981d9..97b810e 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -342,6 +342,22 @@ 127.0.0.1:1984
+
+

Login credentials

+ + + + +
@@ -393,6 +409,6 @@ - + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 580772d..3879746 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -1253,6 +1253,33 @@ td small { padding: 12px; } +.settings-form { + display: grid; + gap: 12px; + margin-top: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-soft); + padding: 14px; +} + +.settings-form h3 { + margin: 0; + font-size: 16px; +} + +.settings-form label { + display: grid; + gap: 6px; + color: var(--muted); + font-weight: 800; + font-size: 12px; +} + +.settings-form button { + justify-self: start; +} + .backup-item span, .event small { display: block; diff --git a/install_pcatelegram_web_bot.sh b/install_pcatelegram_web_bot.sh index 3d72785..91223e7 100755 --- a/install_pcatelegram_web_bot.sh +++ b/install_pcatelegram_web_bot.sh @@ -25,13 +25,7 @@ ensure_admin_web_password() { elif [ -f "$ADMIN_WEB_AUTH_FILE" ] && [ -s "$ADMIN_WEB_AUTH_FILE" ]; then pw=$(sed -n 's/^password=//p' "$ADMIN_WEB_AUTH_FILE" | head -1) fi - if [ -z "$pw" ]; then - if command -v openssl >/dev/null 2>&1; then - pw=$(openssl rand -base64 24 | tr -d '\n') - else - pw=$(head -c 24 /dev/urandom | base64 | tr -d '\n') - fi - fi + [ -n "$pw" ] || pw="admin" umask 077 cat > "$ADMIN_WEB_AUTH_FILE" </dev/null 2>&1; then - pw=$(openssl rand -base64 24 | tr -d '\n') - else - pw=$(head -c 24 /dev/urandom | base64 | tr -d '\n') - fi - fi + [ -n "$pw" ] || pw="admin" umask 077 cat > "$ADMIN_WEB_AUTH_FILE" <