mirror of
https://github.com/andrey271192/PCAtelegram_web.git
synced 2026-09-20 11:55:32 +00:00
chore: harden admin and add CI
This commit is contained in:
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal 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.
|
||||
@@ -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
18
ROADMAP.md
Normal 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
40
SECURITY.md
Normal 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.
|
||||
@@ -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)))
|
||||
|
||||
33
docs/ci/github-actions.yml
Normal file
33
docs/ci/github-actions.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user