Files
kaskad_web_vpn/app.py
Андрей Бобырев 43daf188c2 feat: first-run /setup password + PRO-style iptables
compat DNAT/INPUT/FORWARD/MASQUERADE; optional chain mode; auth_store hash; static absolute URLs; install skips env password by default; README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 20:52:35 +03:00

245 lines
6.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Kaskad Web UI v2 — Flask, первичная настройка пароля, NAT CRUD."""
from __future__ import annotations
import functools
import logging
import os
import secrets
from pathlib import Path
from flask import Flask, Response, jsonify, redirect, render_template, request, url_for
import auth_store
from kaskad_store import (
IPTABLES_MODE,
iptables_chain_dump,
load_rules,
normalize_rule,
save_rules,
startup_resync,
sync_iptables,
)
from system_info import collect_services_rows, collect_system_payload
logging.basicConfig(level=os.environ.get("LOG_LEVEL", "INFO"))
log = logging.getLogger(__name__)
REALM = os.environ.get("BASIC_AUTH_REALM", "kaskad").strip() or "kaskad"
PANEL_URL = os.environ.get("PANEL_URL", "").strip()
def _flask_secret_key() -> str:
p = Path(os.environ.get("FLASK_SECRET_PATH", "/var/lib/kaskad/.flask_secret"))
env = os.environ.get("FLASK_SECRET_KEY", "").strip()
if env:
return env
try:
p.parent.mkdir(parents=True, exist_ok=True)
if p.is_file():
return p.read_text(encoding="utf-8").strip()
key = secrets.token_hex(32)
p.write_text(key + "\n", encoding="utf-8")
return key
except OSError:
return secrets.token_hex(16)
app = Flask(__name__)
app.secret_key = _flask_secret_key()
def check_auth(u: str, p: str) -> bool:
if not auth_store.password_is_configured():
return False
return auth_store.verify_credentials(u or "", p or "")
@app.before_request
def require_initial_setup():
if request.endpoint == "static":
return None
ep = request.endpoint or ""
if ep in ("health", "setup", "api_meta"):
return None
if auth_store.password_is_configured():
return None
if request.path.startswith("/api"):
return jsonify({"error": "needs_setup", "needs_setup": True}), 503
if ep != "setup":
return redirect(url_for("setup"))
return None
@app.get("/api/meta")
def api_meta():
return jsonify(
{
"needs_setup": not auth_store.password_is_configured(),
"iptables_mode": IPTABLES_MODE,
}
)
@app.route("/setup", methods=("GET", "POST"))
def setup():
if auth_store.password_is_configured():
return redirect(url_for("index"))
err = None
if request.method == "POST":
pw = (request.form.get("password") or "").strip()
pw2 = (request.form.get("password2") or "").strip()
user = (request.form.get("username") or auth_store.DEFAULT_USER).strip() or auth_store.DEFAULT_USER
if len(pw) < 8:
err = "Пароль не короче 8 символов."
elif pw != pw2:
err = "Пароли не совпадают."
else:
try:
auth_store.save_password(user, pw)
log.info("web auth создан для пользователя %s", user)
return redirect(url_for("index"))
except OSError as e:
err = f"Не удалось сохранить: {e}"
return render_template("setup.html", error=err, default_user=auth_store.DEFAULT_USER)
def requires_auth(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
auth = request.authorization
if auth is None or not check_auth(auth.username or "", auth.password or ""):
return Response(
"Требуется вход\n",
401,
{"WWW-Authenticate": f'Basic realm="{REALM}"'},
mimetype="text/plain; charset=utf-8",
)
return view(*args, **kwargs)
return wrapped
@app.get("/health")
def health():
return {"ok": True}
@app.get("/")
@requires_auth
def index():
title = os.environ.get("KASKAD_UI_TITLE", "Kaskad Web UI v2").strip()
return render_template(
"index.html",
panel_url=PANEL_URL,
ui_title=title,
iptables_mode=IPTABLES_MODE,
)
def _need_auth_json(view):
@functools.wraps(view)
def wrapped(*args, **kwargs):
auth = request.authorization
if auth is None or not check_auth(auth.username or "", auth.password or ""):
return jsonify({"error": "unauthorized"}), 401
return view(*args, **kwargs)
return wrapped
@app.get("/api/system")
@_need_auth_json
def api_system():
return jsonify(collect_system_payload())
@app.get("/api/services")
@_need_auth_json
def api_services():
return jsonify({"services": collect_services_rows()})
@app.get("/api/clients")
@_need_auth_json
def api_clients_list():
return jsonify({"clients": load_rules()})
@app.post("/api/clients")
@_need_auth_json
def api_clients_add():
body = request.get_json(force=True, silent=True) or {}
try:
rule = normalize_rule(body)
rules = load_rules()
rules.append(rule)
ok, msg = sync_iptables(rules)
if not ok:
return jsonify({"error": msg}), 500
save_rules(rules)
return jsonify({"client": rule}), 201
except (KeyError, TypeError, ValueError) as e:
return jsonify({"error": str(e)}), 400
@app.put("/api/clients/<cid>")
@_need_auth_json
def api_clients_update(cid: str):
body = request.get_json(force=True, silent=True) or {}
rules = load_rules()
idx = next((i for i, r in enumerate(rules) if str(r.get("id")) == cid), None)
if idx is None:
return jsonify({"error": "не найдено"}), 404
try:
rule = normalize_rule(body, rid=cid)
rules[idx] = rule
ok, msg = sync_iptables(rules)
if not ok:
return jsonify({"error": msg}), 500
save_rules(rules)
return jsonify({"client": rule})
except (KeyError, TypeError, ValueError) as e:
return jsonify({"error": str(e)}), 400
@app.delete("/api/clients/<cid>")
@_need_auth_json
def api_clients_delete(cid: str):
rules = load_rules()
new_rules = [r for r in rules if str(r.get("id")) != cid]
if len(new_rules) == len(rules):
return jsonify({"error": "не найдено"}), 404
ok, msg = sync_iptables(new_rules)
if not ok:
return jsonify({"error": msg}), 500
save_rules(new_rules)
return jsonify({"ok": True})
@app.post("/api/iptables/sync")
@_need_auth_json
def api_iptables_sync():
rules = load_rules()
ok, msg = sync_iptables(rules)
if not ok:
return jsonify({"error": msg}), 500
return jsonify({"ok": True})
@app.get("/api/iptables/raw")
@_need_auth_json
def api_iptables_raw():
return jsonify({"raw": iptables_chain_dump()})
startup_resync()
def main():
port = int(os.environ.get("PORT", "8088"))
app.run(host="0.0.0.0", port=port, threaded=True)
if __name__ == "__main__":
main()