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 |
| `admin-web/` | локальная web-admin панель |
| `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
```
Пароль хранится в root-only файле. В новых установках он не кладётся в systemd `Environment`.
Для своего пароля передайте env до установки:
```bash
@@ -101,6 +106,8 @@ PCATELEGRAM_WEB_ADMIN_PASSWORD='strong-password' bash bootstrap.sh
Без 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+
В 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")
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:
body = login_page()
self.send_response(200)
self.send_security_headers()
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)))
@@ -2200,13 +2224,15 @@ class AdminHandler(BaseHTTPRequestHandler):
self.wfile.write(body)
def set_session_cookie(self, token: str) -> None:
secure = "; Secure" if self.is_https_request() else ""
self.send_header(
"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:
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:
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")
self.send_response(200)
self.set_session_cookie(token)
self.send_security_headers()
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)))
@@ -2238,6 +2265,7 @@ class AdminHandler(BaseHTTPRequestHandler):
body = b'{"ok": true}\n'
self.send_response(200)
self.clear_session_cookie()
self.send_security_headers()
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)))
@@ -2247,6 +2275,7 @@ class AdminHandler(BaseHTTPRequestHandler):
def send_json(self, payload: Any, status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_security_headers()
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)))
@@ -2255,6 +2284,7 @@ class AdminHandler(BaseHTTPRequestHandler):
def send_bytes(self, body: bytes, content_type: str, status: int = 200) -> None:
self.send_response(status)
self.send_security_headers()
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
@@ -2270,6 +2300,9 @@ class AdminHandler(BaseHTTPRequestHandler):
raise ValueError("request body too large")
if length <= 0:
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"))
def require_write_guard(self) -> bool:
@@ -2318,6 +2351,7 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(503, str(exc))
return
self.send_response(200)
self.send_security_headers()
self.send_header("Content-Type", "image/png")
self.send_header("Cache-Control", "no-store")
self.send_header("X-Proxy-Link", urllib.parse.quote(link, safe=""))
@@ -2715,6 +2749,7 @@ class AdminHandler(BaseHTTPRequestHandler):
return
mime = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
self.send_response(200)
self.send_security_headers()
self.send_header("Content-Type", mime)
self.send_header("Cache-Control", "no-store")
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"
rm -f "$ADMIN_WEB_DIR/token" 2>/dev/null || true
local python_bin admin_password
local python_bin
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
[Unit]
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_PORT=$ADMIN_WEB_PORT
Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin}
Environment=PCATELEGRAM_WEB_ADMIN_PASSWORD=$admin_password
[Install]
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
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
[Unit]
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_PORT=$ADMIN_WEB_PORT
Environment=PCATELEGRAM_WEB_ADMIN_USER=${PCATELEGRAM_WEB_ADMIN_USER:-admin}
Environment=PCATELEGRAM_WEB_ADMIN_PASSWORD=$ADMIN_WEB_PASSWORD
[Install]
WantedBy=multi-user.target

View File

@@ -19,6 +19,7 @@ import shlex
import shutil
import subprocess
import sys
import tempfile
import time
import toml
from datetime import datetime
@@ -405,6 +406,14 @@ def pro_template_map(context: ContextTypes.DEFAULT_TYPE) -> Dict[str, str]:
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:
"""Resolve a short Telegram callback key back to the real template 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 tpl in cat.get("templates", []):
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 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():
if stored_id == template_id:
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
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
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}"
# Clean previous copy
if os.path.isdir(target_dir):
shutil.rmtree(target_dir, ignore_errors=True)
tmp_dir = f"/tmp/{tpl_id}_clone"
if os.path.isdir(tmp_dir):
shutil.rmtree(tmp_dir, ignore_errors=True)
tmp_dir = tempfile.mkdtemp(prefix=f"{tpl_id}_", dir="/tmp")
cmd = ["git", "clone", "--depth", "1"]
if branch:
@@ -1198,11 +1205,14 @@ async def _download_custom_git_template(url_with_branch: str) -> Tuple[bool, str
proc.kill()
except ProcessLookupError:
pass
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_timeout"
if proc.returncode != 0:
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_invalid"
except Exception as e:
logger.warning("custom git clone failed: %s", e)
shutil.rmtree(tmp_dir, ignore_errors=True)
return False, tpl_id, "cg_invalid"
# Remove .git to enforce size guard and avoid leaking repo history