diff --git a/Data/Server/Modules/__init__.py b/Data/Server/Modules/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/Modules/auth/__init__.py b/Data/Server/Modules/auth/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/auth/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/Modules/auth/dpop.py b/Data/Server/Modules/auth/dpop.py new file mode 100644 index 0000000..1049b9a --- /dev/null +++ b/Data/Server/Modules/auth/dpop.py @@ -0,0 +1,109 @@ +""" +DPoP proof verification helpers. +""" + +from __future__ import annotations + +import hashlib +import time +from threading import Lock +from typing import Dict, Optional + +import jwt + +_DP0P_MAX_SKEW = 300.0 # seconds + + +class DPoPVerificationError(Exception): + pass + + +class DPoPReplayError(DPoPVerificationError): + pass + + +class DPoPValidator: + def __init__(self) -> None: + self._observed_jti: Dict[str, float] = {} + self._lock = Lock() + + def verify( + self, + method: str, + htu: str, + proof: str, + access_token: Optional[str] = None, + ) -> str: + """ + Verify the presented DPoP proof. Returns the JWK thumbprint on success. + """ + + if not proof: + raise DPoPVerificationError("DPoP proof missing") + + try: + header = jwt.get_unverified_header(proof) + except Exception as exc: + raise DPoPVerificationError("invalid DPoP header") from exc + + jwk = header.get("jwk") + alg = header.get("alg") + if not jwk or not isinstance(jwk, dict): + raise DPoPVerificationError("missing jwk in DPoP header") + if alg not in ("EdDSA", "ES256", "ES384", "ES512"): + raise DPoPVerificationError(f"unsupported DPoP alg {alg}") + + try: + key = jwt.PyJWK(jwk) + public_key = key.key + except Exception as exc: + raise DPoPVerificationError("invalid jwk in DPoP header") from exc + + try: + claims = jwt.decode( + proof, + public_key, + algorithms=[alg], + options={"require": ["htm", "htu", "jti", "iat"]}, + ) + except Exception as exc: + raise DPoPVerificationError("invalid DPoP signature") from exc + + htm = claims.get("htm") + proof_htu = claims.get("htu") + jti = claims.get("jti") + iat = claims.get("iat") + ath = claims.get("ath") + + if not isinstance(htm, str) or htm.lower() != method.lower(): + raise DPoPVerificationError("DPoP htm mismatch") + if not isinstance(proof_htu, str) or proof_htu != htu: + raise DPoPVerificationError("DPoP htu mismatch") + if not isinstance(jti, str): + raise DPoPVerificationError("DPoP jti missing") + if not isinstance(iat, (int, float)): + raise DPoPVerificationError("DPoP iat missing") + + now = time.time() + if abs(now - float(iat)) > _DP0P_MAX_SKEW: + raise DPoPVerificationError("DPoP proof outside allowed skew") + + if ath and access_token: + expected_ath = jwt.utils.base64url_encode( + hashlib.sha256(access_token.encode("utf-8")).digest() + ).decode("ascii") + if expected_ath != ath: + raise DPoPVerificationError("DPoP ath mismatch") + + with self._lock: + expiry = self._observed_jti.get(jti) + if expiry and expiry > now: + raise DPoPReplayError("DPoP proof replay detected") + self._observed_jti[jti] = now + _DP0P_MAX_SKEW + # Opportunistic cleanup + stale = [key for key, exp in self._observed_jti.items() if exp <= now] + for key in stale: + self._observed_jti.pop(key, None) + + thumbprint = jwt.PyJWK(jwk).thumbprint() + return thumbprint.decode("ascii") diff --git a/Data/Server/Modules/auth/jwt_service.py b/Data/Server/Modules/auth/jwt_service.py new file mode 100644 index 0000000..9d77859 --- /dev/null +++ b/Data/Server/Modules/auth/jwt_service.py @@ -0,0 +1,118 @@ +""" +JWT access-token helpers backed by an Ed25519 signing key. +""" + +from __future__ import annotations + +import hashlib +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +_KEY_DIR = Path(__file__).resolve().parent.parent / "keys" +_KEY_FILE = _KEY_DIR / "borealis-jwt-ed25519.key" + + +class JWTService: + def __init__(self, private_key: ed25519.Ed25519PrivateKey, key_id: str): + self._private_key = private_key + self._public_key = private_key.public_key() + self._key_id = key_id + + @property + def key_id(self) -> str: + return self._key_id + + def issue_access_token( + self, + guid: str, + ssl_key_fingerprint: str, + token_version: int, + expires_in: int = 900, + extra_claims: Optional[Dict[str, Any]] = None, + ) -> str: + now = int(time.time()) + payload: Dict[str, Any] = { + "sub": f"device:{guid}", + "guid": guid, + "ssl_key_fingerprint": ssl_key_fingerprint, + "token_version": int(token_version), + "iat": now, + "nbf": now, + "exp": now + int(expires_in), + } + if extra_claims: + payload.update(extra_claims) + + token = jwt.encode( + payload, + self._private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + algorithm="EdDSA", + headers={"kid": self._key_id}, + ) + return token + + def decode(self, token: str, *, audience: Optional[str] = None) -> Dict[str, Any]: + options = {"require": ["exp", "iat", "sub"]} + public_pem = self._public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return jwt.decode( + token, + public_pem, + algorithms=["EdDSA"], + audience=audience, + options=options, + ) + + def public_jwk(self) -> Dict[str, Any]: + public_bytes = self._public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + # PyJWT expects base64url without padding. + jwk_x = jwt.utils.base64url_encode(public_bytes).decode("ascii") + return {"kty": "OKP", "crv": "Ed25519", "kid": self._key_id, "alg": "EdDSA", "use": "sig", "x": jwk_x} + + +def load_service() -> JWTService: + private_key = _load_or_create_private_key() + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + key_id = hashlib.sha256(public_bytes).hexdigest()[:16] + return JWTService(private_key, key_id) + + +def _load_or_create_private_key() -> ed25519.Ed25519PrivateKey: + _KEY_DIR.mkdir(parents=True, exist_ok=True) + if _KEY_FILE.exists(): + with _KEY_FILE.open("rb") as fh: + return serialization.load_pem_private_key(fh.read(), password=None) + + private_key = ed25519.Ed25519PrivateKey.generate() + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + with _KEY_FILE.open("wb") as fh: + fh.write(pem) + try: + if _KEY_FILE.exists() and hasattr(_KEY_FILE, "chmod"): + _KEY_FILE.chmod(0o600) + except Exception: + pass + return private_key + diff --git a/Data/Server/Modules/auth/rate_limit.py b/Data/Server/Modules/auth/rate_limit.py new file mode 100644 index 0000000..5b0c923 --- /dev/null +++ b/Data/Server/Modules/auth/rate_limit.py @@ -0,0 +1,41 @@ +""" +Tiny in-memory rate limiter suitable for single-process development servers. +""" + +from __future__ import annotations + +import time +from collections import deque +from dataclasses import dataclass +from threading import Lock +from typing import Deque, Dict, Tuple + + +@dataclass +class RateLimitDecision: + allowed: bool + retry_after: float + + +class SlidingWindowRateLimiter: + def __init__(self) -> None: + self._buckets: Dict[str, Deque[float]] = {} + self._lock = Lock() + + def check(self, key: str, limit: int, window_seconds: float) -> RateLimitDecision: + now = time.monotonic() + with self._lock: + bucket = self._buckets.get(key) + if bucket is None: + bucket = deque() + self._buckets[key] = bucket + + while bucket and now - bucket[0] > window_seconds: + bucket.popleft() + + if len(bucket) >= limit: + retry_after = max(0.0, window_seconds - (now - bucket[0])) + return RateLimitDecision(False, retry_after) + + bucket.append(now) + return RateLimitDecision(True, 0.0) diff --git a/Data/Server/Modules/crypto/__init__.py b/Data/Server/Modules/crypto/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/crypto/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/Modules/crypto/certificates.py b/Data/Server/Modules/crypto/certificates.py new file mode 100644 index 0000000..adbae73 --- /dev/null +++ b/Data/Server/Modules/crypto/certificates.py @@ -0,0 +1,133 @@ +""" +Self-signed certificate management for Borealis. + +The production Flask server and the Vite dev server both consume the same +certificate chain so agents and browsers can pin a single trust anchor during +enrollment. +""" + +from __future__ import annotations + +import os +import ssl +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Tuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +_CERT_DIR = Path(__file__).resolve().parent.parent / "certs" +_CERT_FILE = _CERT_DIR / "borealis-server-cert.pem" +_KEY_FILE = _CERT_DIR / "borealis-server-key.pem" +_BUNDLE_FILE = _CERT_DIR / "borealis-server-bundle.pem" + +# 100-year lifetime (effectively "never" for self-signed deployments). +_CERT_VALIDITY = timedelta(days=365 * 100) + + +def ensure_certificate(common_name: str = "Borealis Server") -> Tuple[Path, Path, Path]: + """ + Ensure the self-signed certificate and key exist on disk. + + Returns (cert_path, key_path, bundle_path). + """ + + _CERT_DIR.mkdir(parents=True, exist_ok=True) + + regenerate = not (_CERT_FILE.exists() and _KEY_FILE.exists()) + if not regenerate: + try: + with _CERT_FILE.open("rb") as fh: + cert = x509.load_pem_x509_certificate(fh.read()) + if cert.not_valid_after.replace(tzinfo=timezone.utc) <= datetime.now(tz=timezone.utc): + regenerate = True + except Exception: + regenerate = True + + if regenerate: + _generate_certificate(common_name) + + if not _BUNDLE_FILE.exists(): + _BUNDLE_FILE.write_bytes(_CERT_FILE.read_bytes()) + + return _CERT_FILE, _KEY_FILE, _BUNDLE_FILE + + +def _generate_certificate(common_name: str) -> None: + private_key = ec.generate_private_key(ec.SECP384R1()) + public_key = private_key.public_key() + + now = datetime.now(tz=timezone.utc) + builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Borealis"), + ] + ) + ) + .issuer_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + ] + ) + ) + .public_key(public_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + _CERT_VALIDITY) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.DNSName("127.0.0.1"), + x509.DNSName("::1"), + ] + ), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + ) + + certificate = builder.sign(private_key=private_key, algorithm=hashes.SHA384()) + + _KEY_FILE.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + _CERT_FILE.write_bytes(certificate.public_bytes(serialization.Encoding.PEM)) + + # Propagate filesystem permissions to restrict accidental disclosure. + _tighten_permissions(_KEY_FILE) + _tighten_permissions(_CERT_FILE) + + +def _tighten_permissions(path: Path) -> None: + try: + if os.name == "posix": + path.chmod(0o600) + except Exception: + pass + + +def build_ssl_context() -> ssl.SSLContext: + cert_path, key_path, _bundle_path = ensure_certificate() + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_3 + context.load_cert_chain(certfile=str(cert_path), keyfile=str(key_path)) + return context + + +def certificate_paths() -> Tuple[str, str, str]: + cert_path, key_path, bundle_path = ensure_certificate() + return str(cert_path), str(key_path), str(bundle_path) + diff --git a/Data/Server/Modules/crypto/keys.py b/Data/Server/Modules/crypto/keys.py new file mode 100644 index 0000000..d3e6e1b --- /dev/null +++ b/Data/Server/Modules/crypto/keys.py @@ -0,0 +1,71 @@ +""" +Utility helpers for working with Ed25519 keys and fingerprints. +""" + +from __future__ import annotations + +import base64 +import hashlib +import re +from typing import Tuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import load_der_public_key +from cryptography.hazmat.primitives.asymmetric import ed25519 + + +def generate_ed25519_keypair() -> Tuple[ed25519.Ed25519PrivateKey, bytes]: + """ + Generate a new Ed25519 keypair. + + Returns the private key object and the public key encoded as SubjectPublicKeyInfo DER bytes. + """ + + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return private_key, public_key + + +def normalize_base64(data: str) -> str: + """ + Collapse whitespace and normalise URL-safe encodings so we can reliably decode. + """ + + cleaned = re.sub(r"\\s+", "", data or "") + return cleaned.replace("-", "+").replace("_", "/") + + +def spki_der_from_base64(spki_b64: str) -> bytes: + return base64.b64decode(normalize_base64(spki_b64), validate=True) + + +def base64_from_spki_der(spki_der: bytes) -> str: + return base64.b64encode(spki_der).decode("ascii") + + +def fingerprint_from_spki_der(spki_der: bytes) -> str: + digest = hashlib.sha256(spki_der).hexdigest() + return digest.lower() + + +def fingerprint_from_base64_spki(spki_b64: str) -> str: + return fingerprint_from_spki_der(spki_der_from_base64(spki_b64)) + + +def private_key_to_pem(private_key: ed25519.Ed25519PrivateKey) -> bytes: + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +def public_key_to_pem(public_spki_der: bytes) -> bytes: + public_key = load_der_public_key(public_spki_der) + return public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) diff --git a/Data/Server/Modules/crypto/signing.py b/Data/Server/Modules/crypto/signing.py new file mode 100644 index 0000000..18403c9 --- /dev/null +++ b/Data/Server/Modules/crypto/signing.py @@ -0,0 +1,70 @@ +""" +Code-signing helpers for delivering scripts to agents. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Tuple + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from .keys import base64_from_spki_der + +_KEY_DIR = Path(__file__).resolve().parent.parent / "keys" +_SIGNING_KEY_FILE = _KEY_DIR / "borealis-script-ed25519.key" +_SIGNING_PUB_FILE = _KEY_DIR / "borealis-script-ed25519.pub" + + +class ScriptSigner: + def __init__(self, private_key: ed25519.Ed25519PrivateKey): + self._private = private_key + self._public = private_key.public_key() + + def sign(self, payload: bytes) -> bytes: + return self._private.sign(payload) + + def public_spki_der(self) -> bytes: + return self._public.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def public_base64_spki(self) -> str: + return base64_from_spki_der(self.public_spki_der()) + + +def load_signer() -> ScriptSigner: + private_key = _load_or_create() + return ScriptSigner(private_key) + + +def _load_or_create() -> ed25519.Ed25519PrivateKey: + _KEY_DIR.mkdir(parents=True, exist_ok=True) + if _SIGNING_KEY_FILE.exists(): + with _SIGNING_KEY_FILE.open("rb") as fh: + return serialization.load_pem_private_key(fh.read(), password=None) + + private_key = ed25519.Ed25519PrivateKey.generate() + pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + with _SIGNING_KEY_FILE.open("wb") as fh: + fh.write(pem) + try: + if hasattr(_SIGNING_KEY_FILE, "chmod"): + _SIGNING_KEY_FILE.chmod(0o600) + except Exception: + pass + + pub_der = private_key.public_key().public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + _SIGNING_PUB_FILE.write_bytes(pub_der) + + return private_key + diff --git a/Data/Server/Modules/db_migrations.py b/Data/Server/Modules/db_migrations.py new file mode 100644 index 0000000..5edd6c9 --- /dev/null +++ b/Data/Server/Modules/db_migrations.py @@ -0,0 +1,375 @@ +""" +Database migration helpers for Borealis. + +This module centralises schema evolution so the main server module can stay +focused on request handling. The migration functions are intentionally +idempotent — they can run repeatedly without changing state once the schema +matches the desired shape. +""" + +from __future__ import annotations + +import sqlite3 +import uuid +from datetime import datetime, timezone +from typing import List, Optional, Sequence, Tuple + + +DEVICE_TABLE = "devices" + + +def apply_all(conn: sqlite3.Connection) -> None: + """ + Run all known schema migrations against the provided sqlite3 connection. + """ + + _ensure_devices_table(conn) + _ensure_device_aux_tables(conn) + _ensure_refresh_token_table(conn) + _ensure_install_code_table(conn) + _ensure_device_approval_table(conn) + + conn.commit() + + +def _ensure_devices_table(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + if not _table_exists(cur, DEVICE_TABLE): + _create_devices_table(cur) + return + + column_info = _table_info(cur, DEVICE_TABLE) + col_names = [c[1] for c in column_info] + pk_cols = [c[1] for c in column_info if c[5]] + + needs_rebuild = pk_cols != ["guid"] + required_columns = { + "guid": "TEXT", + "hostname": "TEXT", + "description": "TEXT", + "created_at": "INTEGER", + "agent_hash": "TEXT", + "memory": "TEXT", + "network": "TEXT", + "software": "TEXT", + "storage": "TEXT", + "cpu": "TEXT", + "device_type": "TEXT", + "domain": "TEXT", + "external_ip": "TEXT", + "internal_ip": "TEXT", + "last_reboot": "TEXT", + "last_seen": "INTEGER", + "last_user": "TEXT", + "operating_system": "TEXT", + "uptime": "INTEGER", + "agent_id": "TEXT", + "ansible_ee_ver": "TEXT", + "connection_type": "TEXT", + "connection_endpoint": "TEXT", + "ssl_key_fingerprint": "TEXT", + "token_version": "INTEGER", + "status": "TEXT", + "key_added_at": "TEXT", + } + + missing_columns = [col for col in required_columns if col not in col_names] + if missing_columns: + needs_rebuild = True + + if needs_rebuild: + _rebuild_devices_table(conn, column_info) + else: + _ensure_column_defaults(cur) + + _ensure_device_indexes(cur) + + +def _ensure_device_aux_tables(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS device_keys ( + id TEXT PRIMARY KEY, + guid TEXT NOT NULL, + ssl_key_fingerprint TEXT NOT NULL, + added_at TEXT NOT NULL, + retired_at TEXT + ) + """ + ) + cur.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_device_keys_guid_fingerprint + ON device_keys(guid, ssl_key_fingerprint) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_device_keys_guid + ON device_keys(guid) + """ + ) + + +def _ensure_refresh_token_table(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id TEXT PRIMARY KEY, + guid TEXT NOT NULL, + token_hash TEXT NOT NULL, + dpop_jkt TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + last_used_at TEXT + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_guid + ON refresh_tokens(guid) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at + ON refresh_tokens(expires_at) + """ + ) + + +def _ensure_install_code_table(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS enrollment_install_codes ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_by_user_id TEXT, + used_at TEXT, + used_by_guid TEXT + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_eic_expires_at + ON enrollment_install_codes(expires_at) + """ + ) + + +def _ensure_device_approval_table(conn: sqlite3.Connection) -> None: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS device_approvals ( + id TEXT PRIMARY KEY, + approval_reference TEXT NOT NULL UNIQUE, + guid TEXT, + hostname_claimed TEXT NOT NULL, + ssl_key_fingerprint_claimed TEXT NOT NULL, + enrollment_code_id TEXT NOT NULL, + status TEXT NOT NULL, + client_nonce TEXT NOT NULL, + server_nonce TEXT NOT NULL, + agent_pubkey_der BLOB NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + approved_by_user_id TEXT + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_da_status + ON device_approvals(status) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_da_fp_status + ON device_approvals(ssl_key_fingerprint_claimed, status) + """ + ) + + +def _create_devices_table(cur: sqlite3.Cursor) -> None: + cur.execute( + """ + CREATE TABLE devices ( + guid TEXT PRIMARY KEY, + hostname TEXT, + description TEXT, + created_at INTEGER, + agent_hash TEXT, + memory TEXT, + network TEXT, + software TEXT, + storage TEXT, + cpu TEXT, + device_type TEXT, + domain TEXT, + external_ip TEXT, + internal_ip TEXT, + last_reboot TEXT, + last_seen INTEGER, + last_user TEXT, + operating_system TEXT, + uptime INTEGER, + agent_id TEXT, + ansible_ee_ver TEXT, + connection_type TEXT, + connection_endpoint TEXT, + ssl_key_fingerprint TEXT, + token_version INTEGER DEFAULT 1, + status TEXT DEFAULT 'active', + key_added_at TEXT + ) + """ + ) + _ensure_device_indexes(cur) + + +def _ensure_device_indexes(cur: sqlite3.Cursor) -> None: + cur.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_devices_hostname + ON devices(hostname) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_devices_ssl_key + ON devices(ssl_key_fingerprint) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_devices_status + ON devices(status) + """ + ) + + +def _ensure_column_defaults(cur: sqlite3.Cursor) -> None: + cur.execute( + """ + UPDATE devices + SET token_version = COALESCE(token_version, 1) + WHERE token_version IS NULL + """ + ) + cur.execute( + """ + UPDATE devices + SET status = COALESCE(status, 'active') + WHERE status IS NULL OR status = '' + """ + ) + + +def _rebuild_devices_table(conn: sqlite3.Connection, column_info: Sequence[Tuple]) -> None: + cur = conn.cursor() + cur.execute("PRAGMA foreign_keys=OFF") + cur.execute("BEGIN IMMEDIATE") + + cur.execute("ALTER TABLE devices RENAME TO devices_legacy") + _create_devices_table(cur) + + legacy_columns = [c[1] for c in column_info] + cur.execute(f"SELECT {', '.join(legacy_columns)} FROM devices_legacy") + rows = cur.fetchall() + + insert_sql = ( + """ + INSERT OR REPLACE INTO devices ( + guid, hostname, description, created_at, agent_hash, memory, + network, software, storage, cpu, device_type, domain, external_ip, + internal_ip, last_reboot, last_seen, last_user, operating_system, + uptime, agent_id, ansible_ee_ver, connection_type, connection_endpoint, + ssl_key_fingerprint, token_version, status, key_added_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + ) + + for row in rows: + record = dict(zip(legacy_columns, row)) + guid = _normalized_guid(record.get("guid")) + if not guid: + guid = str(uuid.uuid4()) + hostname = record.get("hostname") + created_at = record.get("created_at") + key_added_at = record.get("key_added_at") + if key_added_at is None: + key_added_at = _default_key_added_at(created_at) + + params: Tuple = ( + guid, + hostname, + record.get("description"), + created_at, + record.get("agent_hash"), + record.get("memory"), + record.get("network"), + record.get("software"), + record.get("storage"), + record.get("cpu"), + record.get("device_type"), + record.get("domain"), + record.get("external_ip"), + record.get("internal_ip"), + record.get("last_reboot"), + record.get("last_seen"), + record.get("last_user"), + record.get("operating_system"), + record.get("uptime"), + record.get("agent_id"), + record.get("ansible_ee_ver"), + record.get("connection_type"), + record.get("connection_endpoint"), + record.get("ssl_key_fingerprint"), + record.get("token_version") or 1, + record.get("status") or "active", + key_added_at, + ) + cur.execute(insert_sql, params) + + cur.execute("DROP TABLE devices_legacy") + cur.execute("COMMIT") + cur.execute("PRAGMA foreign_keys=ON") + + +def _default_key_added_at(created_at: Optional[int]) -> Optional[str]: + if created_at: + try: + dt = datetime.fromtimestamp(int(created_at), tz=timezone.utc) + return dt.isoformat() + except Exception: + pass + return datetime.now(tz=timezone.utc).isoformat() + + +def _table_exists(cur: sqlite3.Cursor, name: str) -> bool: + cur.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + (name,), + ) + return cur.fetchone() is not None + + +def _table_info(cur: sqlite3.Cursor, name: str) -> List[Tuple]: + cur.execute(f"PRAGMA table_info({name})") + return cur.fetchall() + + +def _normalized_guid(value: Optional[str]) -> str: + if not value: + return "" + return str(value).strip() diff --git a/Data/Server/Modules/enrollment/__init__.py b/Data/Server/Modules/enrollment/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Data/Server/Modules/enrollment/__init__.py @@ -0,0 +1 @@ + diff --git a/Data/Server/WebUI/vite.config.mts b/Data/Server/WebUI/vite.config.mts index 326bf09..f3d5aaa 100644 --- a/Data/Server/WebUI/vite.config.mts +++ b/Data/Server/WebUI/vite.config.mts @@ -2,6 +2,20 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +import fs from 'fs'; + +const defaultCert = path.resolve(__dirname, '../certs/borealis-server-cert.pem'); +const defaultKey = path.resolve(__dirname, '../certs/borealis-server-key.pem'); + +const certPath = process.env.BOREALIS_TLS_CERT ?? defaultCert; +const keyPath = process.env.BOREALIS_TLS_KEY ?? defaultKey; + +const httpsOptions = fs.existsSync(certPath) && fs.existsSync(keyPath) + ? { + cert: fs.readFileSync(certPath), + key: fs.readFileSync(keyPath), + } + : undefined; export default defineConfig({ plugins: [react()], @@ -12,13 +26,20 @@ export default defineConfig({ // Allow LAN/IP access during dev (so other devices can reach Vite) // If you want to restrict, replace `true` with an explicit allowlist. allowedHosts: true, + https: httpsOptions, proxy: { - // Ensure cookies/headers are forwarded correctly to Flask + // Ensure cookies/headers are forwarded correctly to Flask over TLS '/api': { - target: 'http://127.0.0.1:5000', + target: 'https://127.0.0.1:5000', changeOrigin: true, + secure: false, }, - '/socket.io': { target:'ws://127.0.0.1:5000', ws:true, changeOrigin: true } + '/socket.io': { + target: 'wss://127.0.0.1:5000', + ws: true, + changeOrigin: true, + secure: false, + } } }, build: { diff --git a/Data/Server/server-requirements.txt b/Data/Server/server-requirements.txt index 09a26af..4fc32d8 100644 --- a/Data/Server/server-requirements.txt +++ b/Data/Server/server-requirements.txt @@ -11,6 +11,7 @@ flask_socketio flask-cors eventlet cryptography +PyJWT[crypto] pyotp qrcode diff --git a/Data/Server/server.py b/Data/Server/server.py index af1b275..d8ef38b 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -48,6 +48,10 @@ from threading import Lock from datetime import datetime, timezone +from Modules import db_migrations +from Modules.auth import jwt_service as jwt_service_module +from Modules.crypto import certificates + try: from cryptography.fernet import Fernet # type: ignore except Exception: @@ -136,6 +140,13 @@ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) _GITHUB_TOKEN_CACHE: Dict[str, Any] = {"token": None, "loaded_at": 0.0, "known": False} _GITHUB_TOKEN_LOCK = Lock() +TLS_CERT_PATH, TLS_KEY_PATH, TLS_BUNDLE_PATH = certificates.certificate_paths() +os.environ.setdefault("BOREALIS_TLS_CERT", TLS_CERT_PATH) +os.environ.setdefault("BOREALIS_TLS_KEY", TLS_KEY_PATH) +os.environ.setdefault("BOREALIS_TLS_BUNDLE", TLS_BUNDLE_PATH) + +JWT_SERVICE = jwt_service_module.load_service() + def _set_cached_github_token(token: Optional[str]) -> None: with _GITHUB_TOKEN_LOCK: @@ -509,6 +520,7 @@ app.config.update( SESSION_COOKIE_SAMESITE=os.environ.get('BOREALIS_COOKIE_SAMESITE', 'Lax'), # set to 'None' when UI/API are on different sites SESSION_COOKIE_SECURE=(os.environ.get('BOREALIS_COOKIE_SECURE', '0').lower() in ('1', 'true', 'yes')), ) +app.config.setdefault("PREFERRED_URL_SCHEME", "https") # Optionally pin cookie domain if served under a fixed hostname (leave unset for host-only/IP dev) _cookie_domain = os.environ.get('BOREALIS_COOKIE_DOMAIN') # e.g. ".example.com" or "borealis.bunny-lab.io" @@ -3773,11 +3785,11 @@ _DEVICE_JSON_OBJECT_FIELDS = { _DEVICE_TABLE_COLUMNS = [ + "guid", "hostname", "description", "created_at", "agent_hash", - "guid", "memory", "network", "software", @@ -3796,6 +3808,10 @@ _DEVICE_TABLE_COLUMNS = [ "ansible_ee_ver", "connection_type", "connection_endpoint", + "ssl_key_fingerprint", + "token_version", + "status", + "key_added_at", ] @@ -4407,6 +4423,7 @@ def _secret_fingerprint(secret_blob: Optional[bytes]) -> str: def init_db(): """Initialize all required tables in the unified database.""" conn = _db_conn() + db_migrations.apply_all(conn) cur = conn.cursor() # Device table (renamed from historical device_details) @@ -7946,4 +7963,5 @@ def relay_ansible_run(data): if __name__ == "__main__": # Use SocketIO runner so WebSocket transport works with eventlet. - socketio.run(app, host="0.0.0.0", port=5000) + ssl_context = certificates.build_ssl_context() + socketio.run(app, host="0.0.0.0", port=5000, ssl_context=ssl_context)