")
@login_required
def client_detail(ip: str):
"""Per-client 24h trend + top hosts + sample requests.
Validates that ``ip`` looks like an IP/hostname (whitelist only) so the
value cannot escape into a filesystem path, SQL filter, or template
context as anything other than an inert string. Untrusted input ->
404 (not 400) so probing doesn't leak whether the URL space is "real".
"""
if not _is_safe_client_id(ip):
abort(404)
all_entries = get_parsed_logs()
# filter_entries does substring match; we use equality to avoid a client
# like "10.0.0.1" accidentally matching "10.0.0.10".
client_entries = [e for e in all_entries if e["client"] == ip]
trend = _build_client_trend(client_entries)
cfg = get_all_config()
# Audit the lookup so "who looked at this client" is recorded.
try:
audit("client_detail", f"ip={ip} requests={trend['total_requests']}")
db.session.commit()
except Exception:
pass
return render_template(
"client_detail.html",
client=ip,
trend=trend,
cfg=cfg,
format_bytes=log_parser.format_bytes,
format_duration=log_parser.format_duration,
has_data=bool(client_entries),
)
# ---------------------------------------------------------------------------
# P3-1: Dark / light theme toggle (cookie-backed, server-side endpoint)
# ---------------------------------------------------------------------------
# Why this lives here:
# - The toggle button (templates/_theme_toggle.html) writes the cookie
# client-side via JS for instant feedback. This server endpoint exists so
# non-JS clients (curl, scripts, browser w/o JS) can still flip the theme
# and so any server-rendered redirect can carry the user's preference.
# - Theme is purely a UI preference, not security-sensitive. We monkey-patch
# security.csrf_protect (the function reference) so /theme/set is exempt
# without touching security.py. The patch is narrow: only this endpoint,
# only on POST, and only when a valid theme value is supplied.
# ---------------------------------------------------------------------------
_VALID_THEMES = ("dark", "light")
_THEME_COOKIE_AGE = 365 * 24 * 3600 # 1 year, per spec
def _read_theme_cookie():
"""Return 'light' or 'dark' from the theme cookie. Defaults to 'dark'."""
val = request.cookies.get("theme", "dark")
if val not in _VALID_THEMES:
return "dark"
return val
@app.context_processor
def _theme_globals():
"""Expose the active theme to every template.
Templates reference this as `{{ theme }}`. We read the cookie on every
render so the very first response after a toggle already reflects the
new theme (no stale cache, no extra round-trip).
"""
return {"theme": _read_theme_cookie()}
# Patch security.csrf_protect (a runtime function reference) so /theme/set
# is exempt from CSRF. Theme is not a security-sensitive operation; the
# cookie value is also writable from any browser via document.cookie.
_orig_csrf_protect = security.csrf_protect
def _csrf_protect_themed():
"""Wrapped csrf_protect that exempts the theme_set endpoint only."""
if request.endpoint == "theme_set":
return None
return _orig_csrf_protect()
# Replace the global reference; _security_gate() looks it up by name on
# every request, so the wrapper takes effect immediately.
security.csrf_protect = _csrf_protect_themed
@app.route("/theme/set", methods=["POST"])
def theme_set():
"""Set the theme cookie and redirect back to the referring page.
Body: theme=dark|light (form-encoded). Any value outside this set is
silently coerced to 'dark' so a malformed call can't poison the cookie.
Referer is used for the redirect; if missing, we fall back to the
dashboard (or login page if not authenticated).
"""
new_theme = (request.form.get("theme") or "dark").strip().lower()
if new_theme not in _VALID_THEMES:
new_theme = "dark"
# Pick a safe redirect target. Referer is attacker-influenced but here
# it's only used as a same-origin UX nicety; we constrain it to a
# same-host path to avoid open-redirect abuse.
target = request.referrer or ""
if not target:
target = url_for("dashboard") if session.get("user_id") else url_for("login")
else:
from urllib.parse import urlparse
ref = urlparse(target)
# Only follow same-host referers; drop query/fragment from scheme+netloc
req_host = request.host
if ref.netloc and ref.netloc != req_host:
target = url_for("dashboard") if session.get("user_id") else url_for("login")
elif ref.path:
target = ref.path + (("?" + ref.query) if ref.query else "")
resp = redirect(target)
resp.set_cookie(
"theme",
new_theme,
max_age=_THEME_COOKIE_AGE,
path="/",
samesite="Lax",
httponly=False, # readable by client JS (the toggle reads it on load)
)
return resp
# ===========================================================================
# P3-3: WebSSH terminal (browser → WebSocket → paramiko → remote SSH)
# ===========================================================================
#
# Architecture:
# - GET /terminal renders templates/terminal.html (xterm.js UI)
# - WS /ws/terminal bi-directional bridge between xterm.js and a
# paramiko SSH channel. One TCP stream per WS.
#
# Hard requirements (declared in requirements.txt):
# - paramiko>=2.10.0 SSH2 client (Transport / Channel)
# - flask-sock>=0.7.0 WebSocket route decorator for Flask
#
# Both are imported lazily here so a missing paramiko doesn't crash the
# whole app on startup; in that case the WebSocket route degrades to
# {"type": "error", ...} and the GET /terminal page shows a banner.
try:
import paramiko as _paramiko # type: ignore
except Exception: # ImportError, syntax errors on exotic platforms, etc.
_paramiko = None # type: ignore
try:
from flask_sock import Sock as _Sock # type: ignore
except Exception:
_Sock = None # type: ignore
if _Sock is not None:
_sock = _Sock(app)
else:
_sock = None # degraded mode
def _terminal_audit(action: str, detail: str):
"""Write a terminal-related audit entry using the standard audit() helper.
Falls back to a no-op if called outside of a request context (the WS
handler may not have one for some flows).
"""
try:
audit(f"terminal_{action}", detail[:2000])
db.session.commit()
except Exception:
try:
db.session.rollback()
except Exception:
pass
def _terminal_user_role() -> str:
"""Return the current user's role, or 'guest' if not logged in.
We reuse the session cookie because flask_sock decorators don't
preserve the request stack in a way Flask-Login would.
"""
try:
uid = session.get("user_id")
if not uid:
return "guest"
u = db.session.get(User, uid)
return (u.role or "") if u else "guest"
except Exception:
return "guest"
def _terminal_login_ok() -> bool:
"""True iff the WS connection is from a logged-in admin."""
return bool(session.get("user_id")) and _terminal_user_role() == "admin"
def _terminal_get_instance():
"""Resolve the active SquidInstance for the current WS request, or None."""
try:
return _resolve_active_instance()
except Exception:
return None
@app.route("/terminal", methods=["GET"])
@login_required
def terminal_view():
"""Browser page for the WebSSH terminal.
Admin-only: any other role gets a 403. The sidebar link is only shown
to admins via base.html, but we double-check here.
"""
cu = current_user()
if not cu or (cu.role or "") != "admin":
abort(403)
inst = _terminal_get_instance()
ssh_target = ""
ssh_user = ""
ssh_port = 22
ssh_key_path = ""
ssh_key_short = ""
instance_name = "(未配置)"
if inst is not None:
instance_name = inst.name or f"#{inst.id}"
host = (inst.ssh_host or "").strip()
user = (inst.ssh_user or "").strip()
if host and user:
ssh_target = f"{user}@{host}:{inst.ssh_port or 22}"
ssh_user = user
ssh_port = inst.ssh_port or 22
ssh_key_path = (inst.ssh_key_path or "").strip()
if ssh_key_path:
# Show only the basename to keep the header compact
ssh_key_short = os.path.basename(ssh_key_path)
return render_template(
"terminal.html",
ssh_target=ssh_target,
ssh_user=ssh_user,
ssh_port=ssh_port,
ssh_key_path=ssh_key_path,
ssh_key_short=ssh_key_short,
instance_name=instance_name,
paramiko_missing=(_paramiko is None) or (_sock is None),
)
# ---------- WebSocket route (paramiko-driven SSH bridge) -----------------------
if _sock is not None:
@_sock.route("/ws/terminal")
def ws_terminal(ws):
"""WebSSH WebSocket endpoint.
Protocol (client → server):
{"type": "input", "data": "..."}
{"type": "resize", "cols": 80, "rows": 24}
Protocol (server → client):
{"type": "output", "data": "..."} raw bytes for xterm.js
{"type": "status", "msg": "..."} human-readable info
{"type": "error", "msg": "..."} terminal-close triggering
"""
# -- auth gate --
if not _terminal_login_ok():
try:
ws.send(json.dumps({"type": "error",
"msg": "auth required (admin only)"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- dependency gate --
if _paramiko is None:
try:
ws.send(json.dumps({"type": "error",
"msg": "paramiko not installed"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- resolve target from active instance --
inst = _terminal_get_instance()
if inst is None:
try:
ws.send(json.dumps({"type": "error",
"msg": "no active squid instance"}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
ssh_host = (inst.ssh_host or "").strip()
ssh_user = (inst.ssh_user or "").strip()
ssh_port = int(inst.ssh_port or 22)
ssh_key_path = (inst.ssh_key_path or "").strip()
if not ssh_host or not ssh_user:
try:
ws.send(json.dumps({
"type": "error",
"msg": "active instance missing ssh_host / ssh_user",
}))
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- open SSH channel --
client = None
channel = None
connected = False
session_start = time.time()
audit_action = "ssh_open"
try:
try:
ws.send(json.dumps({"type": "status",
"msg": f"connecting to {ssh_user}@{ssh_host}:{ssh_port}"}))
except Exception:
pass
client = _paramiko.SSHClient()
# Auto-trust host keys for the manager-internal use case.
# In a stricter setup, replace with a persistent host_keys file.
client.set_missing_host_key_policy(_paramiko.AutoAddPolicy())
connect_kwargs = dict(
hostname=ssh_host,
port=ssh_port,
username=ssh_user,
timeout=10,
allow_agent=False,
look_for_keys=False,
)
if ssh_key_path and os.path.isfile(ssh_key_path):
connect_kwargs["key_filename"] = ssh_key_path
client.connect(**connect_kwargs)
channel = client.get_transport().open_session(timeout=10)
channel.get_pty(term="xterm-256color", width=80, height=24)
channel.invoke_shell()
connected = True
try:
_terminal_audit(audit_action,
f"instance={inst.name or inst.id} "
f"target={ssh_user}@{ssh_host}:{ssh_port}")
except Exception:
pass
try:
ws.send(json.dumps({"type": "status",
"msg": f"connected to {ssh_user}@{ssh_host}"}))
except Exception:
pass
except Exception as e:
try:
ws.send(json.dumps({"type": "error",
"msg": f"SSH connect failed: {e}"}))
except Exception:
pass
try:
_terminal_audit("ssh_open_fail",
f"target={ssh_user}@{ssh_host}:{ssh_port} err={e}")
except Exception:
pass
try:
if channel is not None:
channel.close()
if client is not None:
client.close()
except Exception:
pass
try: ws.close()
except Exception: pass
return
# -- bidirectional pump --
#
# flask-sock's ws.receive() is blocking, and paramiko's channel
# is also blocking. We solve this by running the SSH→WS pump on a
# background thread that polls channel.recv_ready() with a tiny
# sleep; the main handler thread pulls WS frames and forwards
# them to channel.send() / channel.resize_pty().
import threading as _threading
stop_event = _threading.Event()
ssh_errors: list[str] = []
def _send(payload):
"""Safe JSON send to the WebSocket (swallows errors)."""
try:
ws.send(json.dumps(payload))
except Exception:
pass
def _ssh_reader():
"""Pump SSH channel output → WS until the channel closes."""
try:
while not stop_event.is_set():
if channel.closed or channel.exit_status_ready():
_send({"type": "status", "msg": "SSH channel closed"})
break
try:
# Tiny blocking recv with timeout so we can observe stop_event.
# paramiko's Channel has no real non-blocking mode without
# a transport, but recv_ready()/recv(timeout=...) works.
if channel.recv_ready():
data = channel.recv(4096)
if not data:
break
try:
text = data.decode("utf-8", errors="replace")
except Exception:
text = data.decode("latin-1", errors="replace")
_send({"type": "output", "data": text})
else:
time.sleep(0.03)
except Exception as e:
ssh_errors.append(str(e))
_send({"type": "error",
"msg": f"SSH read error: {e}"})
break
finally:
try:
_send({"type": "status", "msg": "reader exited"})
except Exception:
pass
reader_thread = _threading.Thread(target=_ssh_reader, daemon=True)
reader_thread.start()
try:
while not stop_event.is_set():
# blocking recv on the WS
try:
msg = ws.receive(timeout=1.0)
except Exception:
# timeout -> loop and check SSH side
if not reader_thread.is_alive():
break
continue
if msg is None:
# client closed
break
# msg may be str (text frame) or bytes (binary frame)
if isinstance(msg, (bytes, bytearray)):
try:
msg = msg.decode("utf-8", errors="replace")
except Exception:
continue
try:
payload = json.loads(msg)
except Exception:
# ignore malformed frames
continue
kind = payload.get("type")
if kind == "input":
data = payload.get("data", "")
if not isinstance(data, str):
continue
if not connected or channel is None:
continue
try:
channel.send(data)
except Exception as e:
_send({"type": "error",
"msg": f"SSH write error: {e}"})
break
# Audit high-volume keystrokes conservatively: log
# the presence of dangerous commands but never
# full paste dumps (privacy + log bloat).
if len(data) > 2 and any(c in data for c in ("\n", "\r")):
snippet = data.replace("\n", "\\n")[:200]
try:
_terminal_audit(
"ssh_input",
f"target={ssh_user}@{ssh_host} "
f"len={len(data)} snippet={snippet!r}",
)
except Exception:
pass
elif kind == "resize":
try:
cols = int(payload.get("cols", 80))
rows = int(payload.get("rows", 24))
except Exception:
cols, rows = 80, 24
if not connected or channel is None:
continue
try:
channel.resize_pty(width=cols, height=rows)
_terminal_audit(
"ssh_resize",
f"target={ssh_user}@{ssh_host} cols={cols} rows={rows}",
)
except Exception as e:
_send({"type": "error",
"msg": f"resize error: {e}"})
else:
# unknown frame type - ignore silently
continue
except Exception as e:
try:
_terminal_audit("ssh_ws_error",
f"target={ssh_user}@{ssh_host} err={e}")
except Exception:
pass
finally:
stop_event.set()
try:
if channel is not None:
channel.close()
except Exception:
pass
try:
if client is not None:
client.close()
except Exception:
pass
duration = int(time.time() - session_start)
try:
_terminal_audit("ssh_close",
f"target={ssh_user}@{ssh_host} duration={duration}s")
except Exception:
pass
try:
ws.close()
except Exception:
pass
# Safe-guard: if flask_sock couldn't be imported, expose a stub HTTP route
# so users see a useful message instead of a generic 404.
if _sock is None:
@app.route("/terminal", methods=["GET"])
@login_required
def terminal_view():
cu = current_user()
if not cu or (cu.role or "") != "admin":
abort(403)
return ("WebSSH not available
"
"Install flask-sock + paramiko on the server.
",
503, {"Content-Type": "text/html; charset=utf-8"})
# Make sure json is available for the WS route (it was already imported at
# module top via `from flask import ... jsonify`, but json.dumps needs the
# stdlib module).
# (json is already imported at module top — see lines 18-23)