mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 11:25:48 -07:00
Revert from Gitea Mirror Due to Catastrophic Destruction in Github
This commit is contained in:
496
Data/Server/Modules/admin/routes.py
Normal file
496
Data/Server/Modules/admin/routes.py
Normal file
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from Modules.guid_utils import normalize_guid
|
||||
|
||||
|
||||
VALID_TTL_HOURS = {1, 3, 6, 12, 24}
|
||||
|
||||
|
||||
def register(
|
||||
app,
|
||||
*,
|
||||
db_conn_factory: Callable[[], sqlite3.Connection],
|
||||
require_admin: Callable[[], Optional[Any]],
|
||||
current_user: Callable[[], Optional[Dict[str, str]]],
|
||||
log: Callable[[str, str, Optional[str]], None],
|
||||
) -> None:
|
||||
blueprint = Blueprint("admin", __name__)
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(tz=timezone.utc)
|
||||
|
||||
def _iso(dt: datetime) -> str:
|
||||
return dt.isoformat()
|
||||
|
||||
def _lookup_user_id(cur: sqlite3.Cursor, username: str) -> Optional[str]:
|
||||
if not username:
|
||||
return None
|
||||
cur.execute(
|
||||
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
|
||||
(username,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return str(row[0])
|
||||
return None
|
||||
|
||||
def _hostname_conflict(
|
||||
cur: sqlite3.Cursor,
|
||||
hostname: Optional[str],
|
||||
pending_guid: Optional[str],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not hostname:
|
||||
return None
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT d.guid, d.ssl_key_fingerprint, ds.site_id, s.name
|
||||
FROM devices d
|
||||
LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname
|
||||
LEFT JOIN sites s ON s.id = ds.site_id
|
||||
WHERE d.hostname = ?
|
||||
""",
|
||||
(hostname,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
existing_guid = normalize_guid(row[0])
|
||||
existing_fingerprint = (row[1] or "").strip().lower()
|
||||
pending_norm = normalize_guid(pending_guid)
|
||||
if existing_guid and pending_norm and existing_guid == pending_norm:
|
||||
return None
|
||||
site_id_raw = row[2]
|
||||
site_id = None
|
||||
if site_id_raw is not None:
|
||||
try:
|
||||
site_id = int(site_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
site_id = None
|
||||
site_name = row[3] or ""
|
||||
return {
|
||||
"guid": existing_guid or None,
|
||||
"ssl_key_fingerprint": existing_fingerprint or None,
|
||||
"site_id": site_id,
|
||||
"site_name": site_name,
|
||||
}
|
||||
|
||||
def _suggest_alternate_hostname(
|
||||
cur: sqlite3.Cursor,
|
||||
hostname: Optional[str],
|
||||
pending_guid: Optional[str],
|
||||
) -> Optional[str]:
|
||||
base = (hostname or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
base = base[:253]
|
||||
candidate = base
|
||||
pending_norm = normalize_guid(pending_guid)
|
||||
suffix = 1
|
||||
while True:
|
||||
cur.execute(
|
||||
"SELECT guid FROM devices WHERE hostname = ?",
|
||||
(candidate,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return candidate
|
||||
existing_guid = normalize_guid(row[0])
|
||||
if pending_norm and existing_guid == pending_norm:
|
||||
return candidate
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
if suffix > 50:
|
||||
return pending_norm or candidate
|
||||
|
||||
@blueprint.before_request
|
||||
def _check_admin():
|
||||
result = require_admin()
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
@blueprint.route("/api/admin/enrollment-codes", methods=["GET"])
|
||||
def list_enrollment_codes():
|
||||
status_filter = request.args.get("status")
|
||||
conn = db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
sql = """
|
||||
SELECT id,
|
||||
code,
|
||||
expires_at,
|
||||
created_by_user_id,
|
||||
used_at,
|
||||
used_by_guid,
|
||||
max_uses,
|
||||
use_count,
|
||||
last_used_at
|
||||
FROM enrollment_install_codes
|
||||
"""
|
||||
params: List[str] = []
|
||||
now_iso = _iso(_now())
|
||||
if status_filter == "active":
|
||||
sql += " WHERE use_count < max_uses AND expires_at > ?"
|
||||
params.append(now_iso)
|
||||
elif status_filter == "expired":
|
||||
sql += " WHERE use_count < max_uses AND expires_at <= ?"
|
||||
params.append(now_iso)
|
||||
elif status_filter == "used":
|
||||
sql += " WHERE use_count >= max_uses"
|
||||
sql += " ORDER BY expires_at ASC"
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
records = []
|
||||
for row in rows:
|
||||
records.append(
|
||||
{
|
||||
"id": row[0],
|
||||
"code": row[1],
|
||||
"expires_at": row[2],
|
||||
"created_by_user_id": row[3],
|
||||
"used_at": row[4],
|
||||
"used_by_guid": row[5],
|
||||
"max_uses": row[6],
|
||||
"use_count": row[7],
|
||||
"last_used_at": row[8],
|
||||
}
|
||||
)
|
||||
return jsonify({"codes": records})
|
||||
|
||||
@blueprint.route("/api/admin/enrollment-codes", methods=["POST"])
|
||||
def create_enrollment_code():
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
ttl_hours = int(payload.get("ttl_hours") or 1)
|
||||
if ttl_hours not in VALID_TTL_HOURS:
|
||||
return jsonify({"error": "invalid_ttl"}), 400
|
||||
|
||||
max_uses_value = payload.get("max_uses")
|
||||
if max_uses_value is None:
|
||||
max_uses_value = payload.get("allowed_uses")
|
||||
try:
|
||||
max_uses = int(max_uses_value)
|
||||
except Exception:
|
||||
max_uses = 2
|
||||
if max_uses < 1:
|
||||
max_uses = 1
|
||||
if max_uses > 10:
|
||||
max_uses = 10
|
||||
|
||||
user = current_user() or {}
|
||||
username = user.get("username") or ""
|
||||
|
||||
conn = db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
created_by = _lookup_user_id(cur, username) or username or "system"
|
||||
code_value = _generate_install_code()
|
||||
issued_at = _now()
|
||||
expires_at = issued_at + timedelta(hours=ttl_hours)
|
||||
record_id = str(uuid.uuid4())
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO enrollment_install_codes (
|
||||
id, code, expires_at, created_by_user_id, max_uses, use_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
""",
|
||||
(record_id, code_value, _iso(expires_at), created_by, max_uses),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO enrollment_install_codes_persistent (
|
||||
id,
|
||||
code,
|
||||
created_at,
|
||||
expires_at,
|
||||
created_by_user_id,
|
||||
used_at,
|
||||
used_by_guid,
|
||||
max_uses,
|
||||
last_known_use_count,
|
||||
last_used_at,
|
||||
is_active,
|
||||
archived_at,
|
||||
consumed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, NULL, ?, 0, NULL, 1, NULL, NULL)
|
||||
ON CONFLICT(id) DO UPDATE
|
||||
SET code = excluded.code,
|
||||
created_at = excluded.created_at,
|
||||
expires_at = excluded.expires_at,
|
||||
created_by_user_id = excluded.created_by_user_id,
|
||||
max_uses = excluded.max_uses,
|
||||
last_known_use_count = 0,
|
||||
used_at = NULL,
|
||||
used_by_guid = NULL,
|
||||
last_used_at = NULL,
|
||||
is_active = 1,
|
||||
archived_at = NULL,
|
||||
consumed_at = NULL
|
||||
""",
|
||||
(record_id, code_value, _iso(issued_at), _iso(expires_at), created_by, max_uses),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
log(
|
||||
"server",
|
||||
f"installer code created id={record_id} by={username} ttl={ttl_hours}h max_uses={max_uses}",
|
||||
)
|
||||
return jsonify(
|
||||
{
|
||||
"id": record_id,
|
||||
"code": code_value,
|
||||
"expires_at": _iso(expires_at),
|
||||
"max_uses": max_uses,
|
||||
"use_count": 0,
|
||||
"last_used_at": None,
|
||||
}
|
||||
)
|
||||
|
||||
@blueprint.route("/api/admin/enrollment-codes/<code_id>", methods=["DELETE"])
|
||||
def delete_enrollment_code(code_id: str):
|
||||
conn = db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM enrollment_install_codes WHERE id = ? AND use_count = 0",
|
||||
(code_id,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
if deleted:
|
||||
archive_ts = _iso(_now())
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE enrollment_install_codes_persistent
|
||||
SET is_active = 0,
|
||||
archived_at = COALESCE(archived_at, ?)
|
||||
WHERE id = ?
|
||||
""",
|
||||
(archive_ts, code_id),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not deleted:
|
||||
return jsonify({"error": "not_found"}), 404
|
||||
log("server", f"installer code deleted id={code_id}")
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
@blueprint.route("/api/admin/device-approvals", methods=["GET"])
|
||||
def list_device_approvals():
|
||||
status_raw = request.args.get("status")
|
||||
status = (status_raw or "").strip().lower()
|
||||
approvals: List[Dict[str, Any]] = []
|
||||
conn = db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
params: List[str] = []
|
||||
sql = """
|
||||
SELECT
|
||||
da.id,
|
||||
da.approval_reference,
|
||||
da.guid,
|
||||
da.hostname_claimed,
|
||||
da.ssl_key_fingerprint_claimed,
|
||||
da.enrollment_code_id,
|
||||
da.status,
|
||||
da.client_nonce,
|
||||
da.server_nonce,
|
||||
da.created_at,
|
||||
da.updated_at,
|
||||
da.approved_by_user_id,
|
||||
u.username AS approved_by_username
|
||||
FROM device_approvals AS da
|
||||
LEFT JOIN users AS u
|
||||
ON (
|
||||
CAST(da.approved_by_user_id AS TEXT) = CAST(u.id AS TEXT)
|
||||
OR LOWER(da.approved_by_user_id) = LOWER(u.username)
|
||||
)
|
||||
"""
|
||||
if status and status != "all":
|
||||
sql += " WHERE LOWER(da.status) = ?"
|
||||
params.append(status)
|
||||
sql += " ORDER BY da.created_at ASC"
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
record_guid = row[2]
|
||||
hostname = row[3]
|
||||
fingerprint_claimed = row[4]
|
||||
claimed_fp_norm = (fingerprint_claimed or "").strip().lower()
|
||||
conflict_raw = _hostname_conflict(cur, hostname, record_guid)
|
||||
fingerprint_match = False
|
||||
requires_prompt = False
|
||||
conflict = None
|
||||
if conflict_raw:
|
||||
conflict_fp = (conflict_raw.get("ssl_key_fingerprint") or "").strip().lower()
|
||||
fingerprint_match = bool(conflict_fp and claimed_fp_norm) and conflict_fp == claimed_fp_norm
|
||||
requires_prompt = not fingerprint_match
|
||||
conflict = {
|
||||
**conflict_raw,
|
||||
"fingerprint_match": fingerprint_match,
|
||||
"requires_prompt": requires_prompt,
|
||||
}
|
||||
alternate_hostname = (
|
||||
_suggest_alternate_hostname(cur, hostname, record_guid)
|
||||
if conflict_raw and requires_prompt
|
||||
else None
|
||||
)
|
||||
approvals.append(
|
||||
{
|
||||
"id": row[0],
|
||||
"approval_reference": row[1],
|
||||
"guid": record_guid,
|
||||
"hostname_claimed": hostname,
|
||||
"ssl_key_fingerprint_claimed": fingerprint_claimed,
|
||||
"enrollment_code_id": row[5],
|
||||
"status": row[6],
|
||||
"client_nonce": row[7],
|
||||
"server_nonce": row[8],
|
||||
"created_at": row[9],
|
||||
"updated_at": row[10],
|
||||
"approved_by_user_id": row[11],
|
||||
"hostname_conflict": conflict,
|
||||
"alternate_hostname": alternate_hostname,
|
||||
"conflict_requires_prompt": requires_prompt,
|
||||
"fingerprint_match": fingerprint_match,
|
||||
"approved_by_username": row[12],
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return jsonify({"approvals": approvals})
|
||||
|
||||
def _set_approval_status(
|
||||
approval_id: str,
|
||||
status: str,
|
||||
*,
|
||||
guid: Optional[str] = None,
|
||||
resolution: Optional[str] = None,
|
||||
):
|
||||
user = current_user() or {}
|
||||
username = user.get("username") or ""
|
||||
|
||||
conn = db_conn_factory()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT status,
|
||||
guid,
|
||||
hostname_claimed,
|
||||
ssl_key_fingerprint_claimed
|
||||
FROM device_approvals
|
||||
WHERE id = ?
|
||||
""",
|
||||
(approval_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return {"error": "not_found"}, 404
|
||||
existing_status = (row[0] or "").strip().lower()
|
||||
if existing_status != "pending":
|
||||
return {"error": "approval_not_pending"}, 409
|
||||
stored_guid = row[1]
|
||||
hostname_claimed = row[2]
|
||||
fingerprint_claimed = (row[3] or "").strip().lower()
|
||||
|
||||
guid_effective = normalize_guid(guid) if guid else normalize_guid(stored_guid)
|
||||
resolution_effective = (resolution.strip().lower() if isinstance(resolution, str) else None)
|
||||
|
||||
conflict = None
|
||||
if status == "approved":
|
||||
conflict = _hostname_conflict(cur, hostname_claimed, guid_effective)
|
||||
if conflict:
|
||||
conflict_fp = (conflict.get("ssl_key_fingerprint") or "").strip().lower()
|
||||
fingerprint_match = bool(conflict_fp and fingerprint_claimed) and conflict_fp == fingerprint_claimed
|
||||
if fingerprint_match:
|
||||
guid_effective = conflict.get("guid") or guid_effective
|
||||
if not resolution_effective:
|
||||
resolution_effective = "auto_merge_fingerprint"
|
||||
elif resolution_effective == "overwrite":
|
||||
guid_effective = conflict.get("guid") or guid_effective
|
||||
elif resolution_effective == "coexist":
|
||||
pass
|
||||
else:
|
||||
return {
|
||||
"error": "conflict_resolution_required",
|
||||
"hostname": hostname_claimed,
|
||||
}, 409
|
||||
|
||||
guid_to_store = guid_effective or normalize_guid(stored_guid) or None
|
||||
|
||||
approved_by = _lookup_user_id(cur, username) or username or "system"
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE device_approvals
|
||||
SET status = ?,
|
||||
guid = ?,
|
||||
approved_by_user_id = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
status,
|
||||
guid_to_store,
|
||||
approved_by,
|
||||
_iso(_now()),
|
||||
approval_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
resolution_note = f" ({resolution_effective})" if resolution_effective else ""
|
||||
log("server", f"device approval {approval_id} -> {status}{resolution_note} by {username}")
|
||||
payload: Dict[str, Any] = {"status": status}
|
||||
if resolution_effective:
|
||||
payload["conflict_resolution"] = resolution_effective
|
||||
return payload, 200
|
||||
|
||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/approve", methods=["POST"])
|
||||
def approve_device(approval_id: str):
|
||||
payload = request.get_json(force=True, silent=True) or {}
|
||||
guid = payload.get("guid")
|
||||
if guid:
|
||||
guid = str(guid).strip()
|
||||
resolution_val = payload.get("conflict_resolution")
|
||||
resolution = None
|
||||
if isinstance(resolution_val, str):
|
||||
cleaned = resolution_val.strip().lower()
|
||||
if cleaned:
|
||||
resolution = cleaned
|
||||
result, status_code = _set_approval_status(
|
||||
approval_id,
|
||||
"approved",
|
||||
guid=guid,
|
||||
resolution=resolution,
|
||||
)
|
||||
return jsonify(result), status_code
|
||||
|
||||
@blueprint.route("/api/admin/device-approvals/<approval_id>/deny", methods=["POST"])
|
||||
def deny_device(approval_id: str):
|
||||
result, status_code = _set_approval_status(approval_id, "denied")
|
||||
return jsonify(result), status_code
|
||||
|
||||
app.register_blueprint(blueprint)
|
||||
|
||||
|
||||
def _generate_install_code() -> str:
|
||||
raw = secrets.token_hex(16).upper()
|
||||
return "-".join(raw[i : i + 4] for i in range(0, len(raw), 4))
|
||||
Reference in New Issue
Block a user