112 lines
4.6 KiB
Python
112 lines
4.6 KiB
Python
"""
|
|
control/panel/__init__.py
|
|
--------------------------
|
|
Superadmin control panel — MT-4.
|
|
|
|
Serves as a STANDALONE Flask application at admin.jqc.app (separate WSGI
|
|
process). Shares only the `control` package (models, provision, tenant_migrate)
|
|
with the main app. Has NO import dependency on `app/`.
|
|
|
|
Entry point: wsgi_panel.py
|
|
gunicorn wsgi_panel:panel_app
|
|
|
|
Env vars required (same /etc/jqc/control.env sourced by the main app):
|
|
CONTROL_DATABASE_URL
|
|
CONTROL_FERNET_KEY
|
|
PANEL_SECRET_KEY — separate from the main app SECRET_KEY
|
|
PANEL_IMPERSONATE_KEY — HMAC key for impersonation tokens (32+ random bytes)
|
|
TENANT_BASE_DOMAIN — e.g. jqc.app
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from datetime import timedelta
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
from flask import Flask
|
|
from flask_wtf.csrf import CSRFProtect
|
|
|
|
from .auth import bp as auth_bp
|
|
from .tenants import bp as tenants_bp
|
|
|
|
csrf = CSRFProtect()
|
|
|
|
|
|
def create_panel_app():
|
|
app = Flask(__name__, template_folder='templates')
|
|
|
|
secret = os.environ.get('PANEL_SECRET_KEY')
|
|
if not secret:
|
|
raise RuntimeError(
|
|
'PANEL_SECRET_KEY is not set. '
|
|
'Generate one: python -c "import secrets; print(secrets.token_hex(32))"'
|
|
)
|
|
app.secret_key = secret
|
|
|
|
# ── Session lifetime ──────────────────────────────────────────────────
|
|
# Superadmin sessions expire after 4 hours of inactivity.
|
|
# Flask default when permanent=True is 31 days — too long for privileged access.
|
|
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=4)
|
|
|
|
# ── CSRF protection ───────────────────────────────────────────────────
|
|
# Protects all POST routes in the panel against cross-site request forgery.
|
|
# Templates include: <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
|
csrf.init_app(app)
|
|
|
|
# Make csrf_token() available in all panel templates
|
|
from flask_wtf.csrf import generate_csrf
|
|
app.jinja_env.globals['csrf_token'] = generate_csrf
|
|
|
|
# ── Logging ──────────────────────────────────────────────────────────
|
|
log_level = logging.INFO
|
|
formatter = logging.Formatter(
|
|
'[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S',
|
|
)
|
|
sh = logging.StreamHandler()
|
|
sh.setLevel(log_level)
|
|
sh.setFormatter(formatter)
|
|
|
|
log_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
|
|
os.path.abspath(__file__)))), 'logs')
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
fh = RotatingFileHandler(
|
|
os.path.join(log_dir, 'jqc_panel.log'),
|
|
maxBytes=5 * 1024 * 1024, backupCount=5)
|
|
fh.setLevel(log_level)
|
|
fh.setFormatter(formatter)
|
|
|
|
app.logger.setLevel(log_level)
|
|
for h in (sh, fh):
|
|
app.logger.addHandler(h)
|
|
|
|
# ── Blueprints ────────────────────────────────────────────────────────
|
|
app.register_blueprint(auth_bp) # /login, /logout
|
|
app.register_blueprint(tenants_bp) # /tenants/…
|
|
|
|
# ── Root redirect ─────────────────────────────────────────────────────
|
|
from flask import redirect, url_for
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return redirect(url_for('tenants.list_tenants'))
|
|
|
|
# ── Security headers ──────────────────────────────────────────────────
|
|
@app.after_request
|
|
def security_headers(response):
|
|
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
|
|
response.headers.setdefault('X-Frame-Options', 'DENY')
|
|
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
response.headers.setdefault(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; "
|
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
|
"font-src 'self' https://cdn.jsdelivr.net; "
|
|
"img-src 'self' data:; "
|
|
"frame-ancestors 'none';"
|
|
)
|
|
return response
|
|
|
|
return app
|