chore: harden admin and add CI

This commit is contained in:
Андрей Бобырев
2026-06-07 02:41:51 +03:00
parent be5656b2f2
commit 771d9446b1
9 changed files with 169 additions and 13 deletions

15
CHANGELOG.md Normal file
View File

@@ -0,0 +1,15 @@
# Changelog
## Unreleased
- Added CI sanity checks for Python, JavaScript, shell scripts, and JSON files.
- Added admin security headers and JSON content-type enforcement.
- Stopped putting web-admin password into systemd environment; admin now reads root-only auth file.
- Added public site manager for port 80 with install, remove, and custom HTML upload.
- Added README docs for custom domain and public site flow.
## 2.5.0
- Web-admin for PCAtelegram_web with keys, traffic, backups, WARP settings, routing, and service controls.
- Default web-admin login is `admin` / `admin`, changeable from Settings.
- Auto-refresh avoids overwriting active form input.

View File

@@ -55,6 +55,9 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i
| `pcatelegram_web-bot/` | Python Telegram bot | | `pcatelegram_web-bot/` | Python Telegram bot |
| `admin-web/` | локальная web-admin панель | | `admin-web/` | локальная web-admin панель |
| `templates_catalog.json` | каталог HTML-шаблонов | | `templates_catalog.json` | каталог HTML-шаблонов |
| `SECURITY.md` | правила по секретам, web-admin, backup |
| `CHANGELOG.md` | история изменений |
| `ROADMAP.md` | ближайшие улучшения |
## Переменные ## Переменные
@@ -93,6 +96,8 @@ http://SERVER:1984/
sudo cat /root/pcatelegram_web-admin.password sudo cat /root/pcatelegram_web-admin.password
``` ```
Пароль хранится в root-only файле. В новых установках он не кладётся в systemd `Environment`.
Для своего пароля передайте env до установки: Для своего пароля передайте env до установки:
```bash ```bash
@@ -101,6 +106,8 @@ PCATELEGRAM_WEB_ADMIN_PASSWORD='strong-password' bash bootstrap.sh
Без HTTPS Basic Auth гонит пароль открытым текстом. Для постоянного доступа лучше reverse proxy с TLS, но порт `1984` открыт по умолчанию по запросу проекта. Без HTTPS Basic Auth гонит пароль открытым текстом. Для постоянного доступа лучше reverse proxy с TLS, но порт `1984` открыт по умолчанию по запросу проекта.
API записи требуют session cookie и заголовок `X-PCAtelegram-Web-Admin: 1`. Ответы web-admin содержат security headers: CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
## WARP / WARP+ ## WARP / WARP+
В web-admin Settings есть блок `WARP / WARP+`: В web-admin Settings есть блок `WARP / WARP+`:

18
ROADMAP.md Normal file
View File

@@ -0,0 +1,18 @@
# Roadmap
## High Priority
- Optional HTTPS reverse proxy for web-admin with automatic certificate flow.
- Full per-client WARP routing via separate telemt route/service design.
- Automated restore test in CI or staging VPS.
## Medium Priority
- ShellCheck pass for installer scripts.
- Playwright visual smoke test for web-admin pages.
- Structured audit log for admin actions.
## Low Priority
- Docker-based local demo environment.
- Import/export custom public site templates.

40
SECURITY.md Normal file
View File

@@ -0,0 +1,40 @@
# Security
## Secrets
Do not commit real tokens, passwords, WARP+ keys, Telegram bot tokens, proxy secrets, backup passwords, or VPS credentials.
Runtime secrets live on server:
- `/root/pcatelegram_web-admin.password`
- `/opt/pcatelegram_web/config.json`
- `/opt/pcatelegram_web/warp.json`
- `/opt/pcatelegram_web-bot/.env`
- `/etc/telemt/config.toml`
Important permissions:
- auth file: `0600`
- WARP config: `0600`
- bot `.env`: `0600`
- telemt config: `0600`
## Web Admin
Default install uses `admin` / `admin`. Change it in web-admin Settings after first login.
Admin session cookie is `HttpOnly`, `SameSite=Lax`, and gains `Secure` when request comes through HTTPS reverse proxy via `X-Forwarded-Proto: https` or `X-Forwarded-Ssl: on`.
Write APIs require `X-PCAtelegram-Web-Admin: 1` and JSON content type. Responses include security headers: CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, and `Permissions-Policy`.
## Public Site On Port 80
Uploaded HTML is served publicly. Do not upload files with secrets, internal URLs, API tokens, private notes, or admin links.
## Backups
Backups can include proxy keys, WARP config, bot state, SSL files, admin panel files, and traffic history. Use encrypted backups for transport or off-server storage.
## Reporting
Report security issues privately to project owner. Do not open public issues with secrets or working exploit details.

View File

@@ -2190,9 +2190,33 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(401, "unauthorized") self.send_error_json(401, "unauthorized")
return False return False
def is_https_request(self) -> bool:
return (
self.headers.get("X-Forwarded-Proto", "").lower() == "https"
or self.headers.get("X-Forwarded-Ssl", "").lower() == "on"
)
def send_security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "same-origin")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"connect-src 'self'; "
"base-uri 'none'; "
"form-action 'self'; "
"frame-ancestors 'none'",
)
def send_login_page(self) -> None: def send_login_page(self) -> None:
body = login_page() body = login_page()
self.send_response(200) self.send_response(200)
self.send_security_headers()
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -2200,13 +2224,15 @@ class AdminHandler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
def set_session_cookie(self, token: str) -> None: def set_session_cookie(self, token: str) -> None:
secure = "; Secure" if self.is_https_request() else ""
self.send_header( self.send_header(
"Set-Cookie", "Set-Cookie",
f"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_TTL_SECONDS}", f"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax{secure}; Max-Age={SESSION_TTL_SECONDS}",
) )
def clear_session_cookie(self) -> None: def clear_session_cookie(self) -> None:
self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") secure = "; Secure" if self.is_https_request() else ""
self.send_header("Set-Cookie", f"{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax{secure}; Max-Age=0")
def credentials_match(self, username: str, password: str) -> bool: def credentials_match(self, username: str, password: str) -> bool:
expected_user, expected_password = load_admin_credentials() expected_user, expected_password = load_admin_credentials()
@@ -2227,6 +2253,7 @@ class AdminHandler(BaseHTTPRequestHandler):
payload = json.dumps({"ok": True, "data": {"user": username}}, ensure_ascii=False).encode("utf-8") payload = json.dumps({"ok": True, "data": {"user": username}}, ensure_ascii=False).encode("utf-8")
self.send_response(200) self.send_response(200)
self.set_session_cookie(token) self.set_session_cookie(token)
self.send_security_headers()
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(payload))) self.send_header("Content-Length", str(len(payload)))
@@ -2238,6 +2265,7 @@ class AdminHandler(BaseHTTPRequestHandler):
body = b'{"ok": true}\n' body = b'{"ok": true}\n'
self.send_response(200) self.send_response(200)
self.clear_session_cookie() self.clear_session_cookie()
self.send_security_headers()
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -2247,6 +2275,7 @@ class AdminHandler(BaseHTTPRequestHandler):
def send_json(self, payload: Any, status: int = 200) -> None: def send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8") body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_security_headers()
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -2255,6 +2284,7 @@ class AdminHandler(BaseHTTPRequestHandler):
def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None: def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
self.send_response(status) self.send_response(status)
self.send_security_headers()
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
@@ -2270,6 +2300,9 @@ class AdminHandler(BaseHTTPRequestHandler):
raise ValueError("request body too large") raise ValueError("request body too large")
if length <= 0: if length <= 0:
return {} return {}
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise ValueError("content-type must be application/json")
return json.loads(self.rfile.read(length).decode("utf-8")) return json.loads(self.rfile.read(length).decode("utf-8"))
def require_write_guard(self) -> bool: def require_write_guard(self) -> bool:
@@ -2318,6 +2351,7 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(503, str(exc)) self.send_error_json(503, str(exc))
return return
self.send_response(200) self.send_response(200)
self.send_security_headers()
self.send_header("Content-Type", "image/png") self.send_header("Content-Type", "image/png")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe="")) self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe=""))
@@ -2715,6 +2749,7 @@ class AdminHandler(BaseHTTPRequestHandler):
return return
mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream" mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
self.send_response(200) self.send_response(200)
self.send_security_headers()
self.send_header("Content-Type", mime) self.send_header("Content-Type", mime)
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))

View File

@@ -0,0 +1,33 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
sanity:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Python syntax
run: python -m compileall admin-web pcatelegram_web-bot
- name: JavaScript syntax
run: node --check admin-web/static/app.js
- name: Shell syntax
run: bash -n bootstrap.sh install.sh install_pcatelegram_web_bot.sh lib/*.sh lib/lang/*.sh
- name: JSON syntax
run: |
python -m json.tool templates_catalog.json >/dev/null
python -m json.tool pcatelegram_web-bot/lang/en.json >/dev/null
python -m json.tool pcatelegram_web-bot/lang/ru.json >/dev/null

View File

@@ -900,9 +900,9 @@ install_admin_web() {
chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static" chmod 755 "$ADMIN_WEB_DIR/server.py" "$ADMIN_WEB_DIR/static"
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
local python_bin admin_password local python_bin
python_bin=$(command -v python3) python_bin=$(command -v python3)
admin_password=$(ensure_admin_web_password) ensure_admin_web_password >/dev/null
cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << SVCEOF cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << SVCEOF
[Unit] [Unit]
Description=PCAtelegram_web v${PCATELEGRAM_WEB_VERSION} Local Web Admin Description=PCAtelegram_web v${PCATELEGRAM_WEB_VERSION} Local Web Admin
@@ -917,7 +917,6 @@ RestartSec=5
Environment=PCATELEGRAM_WEB_ADMIN_HOST=$ADMIN_WEB_HOST Environment=PCATELEGRAM_WEB_ADMIN_HOST=$ADMIN_WEB_HOST
Environment=PCATELEGRAM_WEB_ADMIN_PORT=$ADMIN_WEB_PORT Environment=PCATELEGRAM_WEB_ADMIN_PORT=$ADMIN_WEB_PORT
Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin} Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin}
Environment=PCATELEGRAM_WEB_ADMIN_PASSWORD=$admin_password
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -148,7 +148,7 @@ if [ -f "$SCRIPT_DIR/admin-web/server.py" ]; then
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
PYTHON_BIN=$(command -v python3) PYTHON_BIN=$(command -v python3)
ADMIN_WEB_PASSWORD=$(ensure_admin_web_password) ensure_admin_web_password >/dev/null
cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF cat > "/etc/systemd/system/${ADMIN_WEB_SERVICE}.service" << EOF
[Unit] [Unit]
Description=PCAtelegram_web v2.5.0 Local Web Admin Description=PCAtelegram_web v2.5.0 Local Web Admin
@@ -163,7 +163,6 @@ RestartSec=5
Environment=PCATELEGRAM_WEB_ADMIN_HOST=$ADMIN_WEB_HOST Environment=PCATELEGRAM_WEB_ADMIN_HOST=$ADMIN_WEB_HOST
Environment=PCATELEGRAM_WEB_ADMIN_PORT=$ADMIN_WEB_PORT Environment=PCATELEGRAM_WEB_ADMIN_PORT=$ADMIN_WEB_PORT
Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin} Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin}
Environment=PCATELEGRAM_WEB_ADMIN_PASSWORD=$ADMIN_WEB_PASSWORD
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -19,6 +19,7 @@ import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
import time import time
import toml import toml
from datetime import datetime from datetime import datetime
@@ -405,6 +406,14 @@ def pro_template_map(context: ContextTypes.DEFAULT_TYPE) -> Dict[str, str]:
return mapping return mapping
def stable_short_digest(algo: str, value: str, length: int) -> str:
try:
digest = hashlib.new(algo, value.encode("utf-8"), usedforsecurity=False)
except TypeError:
digest = hashlib.new(algo, value.encode("utf-8"))
return digest.hexdigest()[:length]
def resolve_pro_template_id(context: ContextTypes.DEFAULT_TYPE, key_or_id: str) -> str: def resolve_pro_template_id(context: ContextTypes.DEFAULT_TYPE, key_or_id: str) -> str:
"""Resolve a short Telegram callback key back to the real template id.""" """Resolve a short Telegram callback key back to the real template id."""
mapped = pro_template_map(context).get(key_or_id) mapped = pro_template_map(context).get(key_or_id)
@@ -415,7 +424,7 @@ def resolve_pro_template_id(context: ContextTypes.DEFAULT_TYPE, key_or_id: str)
for cat in catalog.get("categories", []): for cat in catalog.get("categories", []):
for tpl in cat.get("templates", []): for tpl in cat.get("templates", []):
template_id = str(tpl.get("id", "")) template_id = str(tpl.get("id", ""))
if hashlib.sha1(template_id.encode("utf-8")).hexdigest()[:12] == key_or_id: if stable_short_digest("sha1", template_id, 12) == key_or_id:
return template_id return template_id
return str(key_or_id) return str(key_or_id)
@@ -428,7 +437,7 @@ def pro_template_key_for_id(context: ContextTypes.DEFAULT_TYPE, template_id: str
for key, stored_id in mapping.items(): for key, stored_id in mapping.items():
if stored_id == template_id: if stored_id == template_id:
return str(key) return str(key)
key = hashlib.sha1(template_id.encode("utf-8")).hexdigest()[:12] key = stable_short_digest("sha1", template_id, 12)
mapping[key] = template_id mapping[key] = template_id
return key return key
@@ -1169,16 +1178,14 @@ async def _download_custom_git_template(url_with_branch: str) -> Tuple[bool, str
# Trailing `@` with no branch — drop it so git doesn't treat it as userinfo # Trailing `@` with no branch — drop it so git doesn't treat it as userinfo
url = base url = base
tpl_id = "custom_" + hashlib.md5(url_with_branch.encode("utf-8")).hexdigest()[:10] tpl_id = "custom_" + stable_short_digest("md5", url_with_branch, 10)
target_dir = f"/opt/pcatelegram_web/custom_templates/{tpl_id}" target_dir = f"/opt/pcatelegram_web/custom_templates/{tpl_id}"
# Clean previous copy # Clean previous copy
if os.path.isdir(target_dir): if os.path.isdir(target_dir):
shutil.rmtree(target_dir, ignore_errors=True) shutil.rmtree(target_dir, ignore_errors=True)
tmp_dir = f"/tmp/{tpl_id}_clone" tmp_dir = tempfile.mkdtemp(prefix=f"{tpl_id}_", dir="/tmp")
if os.path.isdir(tmp_dir):
shutil.rmtree(tmp_dir, ignore_errors=True)
cmd = ["git", "clone", "--depth", "1"] cmd = ["git", "clone", "--depth", "1"]
if branch: if branch:
@@ -1198,11 +1205,14 @@ async def _download_custom_git_template(url_with_branch: str) -> Tuple[bool, str
proc.kill() proc.kill()
except ProcessLookupError: except ProcessLookupError:
pass pass
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_timeout" return False, tpl_id, "cg_timeout"
if proc.returncode != 0: if proc.returncode != 0:
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_invalid" return False, tpl_id, "cg_invalid"
except Exception as e: except Exception as e:
logger.warning("custom git clone failed: %s", e) logger.warning("custom git clone failed: %s", e)
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_invalid" return False, tpl_id, "cg_invalid"
# Remove .git to enforce size guard and avoid leaking repo history # Remove .git to enforce size guard and avoid leaking repo history