diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4b23d01 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 2a42f2d..3ace783 100644 --- a/README.md +++ b/README.md @@ -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+`: diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..251bef8 --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ec6648b --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/admin-web/server.py b/admin-web/server.py index 164b300..34ba290 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -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))) diff --git a/docs/ci/github-actions.yml b/docs/ci/github-actions.yml new file mode 100644 index 0000000..f6c84bc --- /dev/null +++ b/docs/ci/github-actions.yml @@ -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 diff --git a/install.sh b/install.sh index 4a6778d..82c7c6e 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/install_pcatelegram_web_bot.sh b/install_pcatelegram_web_bot.sh index 91223e7..07f1af1 100755 --- a/install_pcatelegram_web_bot.sh +++ b/install_pcatelegram_web_bot.sh @@ -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 diff --git a/pcatelegram_web-bot/bot.py b/pcatelegram_web-bot/bot.py index 7b621e0..5576c86 100644 --- a/pcatelegram_web-bot/bot.py +++ b/pcatelegram_web-bot/bot.py @@ -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