diff --git a/.gitignore b/.gitignore index 95a1e9b..6897328 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,11 @@ Borealis-Server.exe # Production Deployment Folders /Agent/ /Server/ +/Certificates/ /ElectronApp/ /Logs/ +/Temp/ +database.db # On-the-Fly Downloaded Dependencies /Dependencies/NodeJS/ @@ -20,11 +23,4 @@ Borealis-Server.exe # Misc Files/Folders .vs/s __pycache__ -/Agent/Python_API_Endpoints/__pycache__/ -/Update_Staging/ -agent_settings.json -agent_settings_svc.json -agent_settings_user.json -users.json -database.db -/Temp/ \ No newline at end of file +/Update_Staging/ \ No newline at end of file diff --git a/Borealis.ps1 b/Borealis.ps1 index e272e04..8405af7 100644 --- a/Borealis.ps1 +++ b/Borealis.ps1 @@ -852,7 +852,7 @@ function InstallOrUpdate-BorealisAgent { if (-not (Test-Path $serverUrlPath) -and (Test-Path $oldServerUrlPath)) { try { Move-Item -Path $oldServerUrlPath -Destination $serverUrlPath -Force } catch { try { Copy-Item $oldServerUrlPath $serverUrlPath -Force } catch {} } } - $defaultUrl = 'http://localhost:5000' + $defaultUrl = 'https://localhost:5000' $currentUrl = $defaultUrl if ($existingServerUrl -and $existingServerUrl.Trim()) { $currentUrl = $existingServerUrl.Trim() diff --git a/Data/Agent/security.py b/Data/Agent/security.py index 2db54c7..108782d 100644 --- a/Data/Agent/security.py +++ b/Data/Agent/security.py @@ -12,6 +12,7 @@ import platform import stat import time from dataclasses import dataclass +from pathlib import Path from typing import List, Optional, Tuple import ssl @@ -55,6 +56,49 @@ def _restrict_permissions(path: str) -> None: pass +def _resolve_agent_certificate_dir(settings_dir: str, scope: str) -> str: + scope_name = (scope or "CURRENTUSER").strip().upper() or "CURRENTUSER" + + def _as_path(value: Optional[str]) -> Optional[Path]: + if not value: + return None + try: + return Path(value).expanduser().resolve() + except Exception: + try: + return Path(value).expanduser() + except Exception: + return Path(value) + + env_agent_root = _as_path(os.environ.get("BOREALIS_AGENT_CERT_ROOT")) + env_cert_root = _as_path(os.environ.get("BOREALIS_CERTIFICATES_ROOT")) or _as_path( + os.environ.get("BOREALIS_CERT_ROOT") + ) + + if env_agent_root is not None: + base = env_agent_root + elif env_cert_root is not None: + base = env_cert_root / "Agent" + else: + settings_path = Path(settings_dir).resolve() + try: + project_root = settings_path.parents[2] + except Exception: + project_root = settings_path.parent + base = project_root / "Certificates" / "Agent" + + target = base / "Trusted_Server_Cert" + if scope_name not in {"SYSTEM", "CURRENTUSER"}: + target = target / scope_name + + try: + target.mkdir(parents=True, exist_ok=True) + except Exception: + pass + + return str(target) + + class _FileLock: def __init__(self, path: str) -> None: self.path = path @@ -226,15 +270,17 @@ class AgentIdentity: class AgentKeyStore: def __init__(self, settings_dir: str, scope: str = "CURRENTUSER") -> None: self.settings_dir = settings_dir - self.scope_system = scope.upper() == "SYSTEM" + self.scope_name = (scope or "CURRENTUSER").strip().upper() or "CURRENTUSER" + self.scope_system = self.scope_name == "SYSTEM" _ensure_dir(self.settings_dir) + self._certificate_dir = _resolve_agent_certificate_dir(self.settings_dir, self.scope_name) self._private_path = os.path.join(self.settings_dir, "agent_key.ed25519") self._public_path = os.path.join(self.settings_dir, "agent_key.pub") self._guid_path = os.path.join(self.settings_dir, "guid.txt") self._access_token_path = os.path.join(self.settings_dir, "access.jwt") self._refresh_token_path = os.path.join(self.settings_dir, "refresh.token") self._token_meta_path = os.path.join(self.settings_dir, "access.meta.json") - self._server_certificate_path = os.path.join(self.settings_dir, "server_certificate.pem") + self._server_certificate_path = os.path.join(self._certificate_dir, "server_certificate.pem") self._server_signing_key_path = os.path.join(self.settings_dir, "server_signing_key.pub") self._identity_lock_path = os.path.join(self.settings_dir, "identity.lock") self._installer_cache_path = os.path.join(self.settings_dir, "installer_code.shared.json") diff --git a/Data/Server/Modules/agents/routes.py b/Data/Server/Modules/agents/routes.py index c65e9d6..990f684 100644 --- a/Data/Server/Modules/agents/routes.py +++ b/Data/Server/Modules/agents/routes.py @@ -141,6 +141,18 @@ def register( # Another device already claims this hostname; keep the existing # canonical hostname assigned during enrollment to avoid breaking # the unique constraint and continue updating the remaining fields. + existing_guid_for_hostname: Optional[str] = None + if "hostname" in updates: + try: + cur.execute( + "SELECT guid FROM devices WHERE hostname = ?", + (updates["hostname"],), + ) + row = cur.fetchone() + if row and row[0]: + existing_guid_for_hostname = normalize_guid(row[0]) + except Exception: + existing_guid_for_hostname = None if "hostname" in updates: updates.pop("hostname", None) try: @@ -148,12 +160,23 @@ def register( except sqlite3.IntegrityError: raise else: - log( - "server", - "heartbeat hostname collision ignored for guid=" - f"{ctx.guid}", - context_label, - ) + try: + current_guid = normalize_guid(ctx.guid) + except Exception: + current_guid = ctx.guid + if ( + existing_guid_for_hostname + and current_guid + and existing_guid_for_hostname == current_guid + ): + pass # Same device contexts; no log needed. + else: + log( + "server", + "heartbeat hostname collision ignored for guid=" + f"{ctx.guid}", + context_label, + ) else: raise diff --git a/Data/Server/Modules/crypto/certificates.py b/Data/Server/Modules/crypto/certificates.py index ff6b1db..cf95b61 100644 --- a/Data/Server/Modules/crypto/certificates.py +++ b/Data/Server/Modules/crypto/certificates.py @@ -19,13 +19,18 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID -from Modules.runtime import ensure_runtime_dir, runtime_path +from Modules.runtime import ensure_server_certificates_dir, server_certificates_path, runtime_path -_CERT_DIR = runtime_path("certs") +_CERT_DIR = server_certificates_path() _CERT_FILE = _CERT_DIR / "borealis-server-cert.pem" _KEY_FILE = _CERT_DIR / "borealis-server-key.pem" _BUNDLE_FILE = _CERT_DIR / "borealis-server-bundle.pem" +_LEGACY_CERT_DIR = runtime_path("certs") +_LEGACY_CERT_FILE = _LEGACY_CERT_DIR / "borealis-server-cert.pem" +_LEGACY_KEY_FILE = _LEGACY_CERT_DIR / "borealis-server-key.pem" +_LEGACY_BUNDLE_FILE = _LEGACY_CERT_DIR / "borealis-server-bundle.pem" + # 100-year lifetime (effectively "never" for self-signed deployments). _CERT_VALIDITY = timedelta(days=365 * 100) @@ -37,7 +42,8 @@ def ensure_certificate(common_name: str = "Borealis Server") -> Tuple[Path, Path Returns (cert_path, key_path, bundle_path). """ - ensure_runtime_dir("certs") + ensure_server_certificates_dir() + _migrate_legacy_material_if_present() regenerate = not (_CERT_FILE.exists() and _KEY_FILE.exists()) if not regenerate: @@ -62,6 +68,38 @@ def ensure_certificate(common_name: str = "Borealis Server") -> Tuple[Path, Path return _CERT_FILE, _KEY_FILE, _BUNDLE_FILE +def _migrate_legacy_material_if_present() -> None: + if _CERT_FILE.exists() and _KEY_FILE.exists(): + return + + legacy_cert = _LEGACY_CERT_FILE + legacy_key = _LEGACY_KEY_FILE + legacy_bundle = _LEGACY_BUNDLE_FILE + + if not legacy_cert.exists() or not legacy_key.exists(): + return + + try: + ensure_server_certificates_dir() + if not _CERT_FILE.exists(): + try: + legacy_cert.replace(_CERT_FILE) + except Exception: + _CERT_FILE.write_bytes(legacy_cert.read_bytes()) + if not _KEY_FILE.exists(): + try: + legacy_key.replace(_KEY_FILE) + except Exception: + _KEY_FILE.write_bytes(legacy_key.read_bytes()) + if legacy_bundle.exists() and not _BUNDLE_FILE.exists(): + try: + legacy_bundle.replace(_BUNDLE_FILE) + except Exception: + _BUNDLE_FILE.write_bytes(legacy_bundle.read_bytes()) + except Exception: + return + + def _generate_certificate(common_name: str) -> None: private_key = ec.generate_private_key(ec.SECP384R1()) public_key = private_key.public_key() diff --git a/Data/Server/Modules/crypto/signing.py b/Data/Server/Modules/crypto/signing.py index b74e537..1c6ff7b 100644 --- a/Data/Server/Modules/crypto/signing.py +++ b/Data/Server/Modules/crypto/signing.py @@ -10,15 +10,22 @@ from typing import Tuple from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 -from Modules.runtime import ensure_runtime_dir, runtime_path +from Modules.runtime import ( + ensure_server_certificates_dir, + server_certificates_path, + runtime_path, +) from .keys import base64_from_spki_der -_KEY_DIR = runtime_path("script_signing_keys") +_KEY_DIR = server_certificates_path("Code-Signing") _SIGNING_KEY_FILE = _KEY_DIR / "borealis-script-ed25519.key" _SIGNING_PUB_FILE = _KEY_DIR / "borealis-script-ed25519.pub" _LEGACY_KEY_FILE = runtime_path("keys") / "borealis-script-ed25519.key" _LEGACY_PUB_FILE = runtime_path("keys") / "borealis-script-ed25519.pub" +_OLD_RUNTIME_KEY_DIR = runtime_path("script_signing_keys") +_OLD_RUNTIME_KEY_FILE = _OLD_RUNTIME_KEY_DIR / "borealis-script-ed25519.key" +_OLD_RUNTIME_PUB_FILE = _OLD_RUNTIME_KEY_DIR / "borealis-script-ed25519.pub" class ScriptSigner: @@ -45,7 +52,7 @@ def load_signer() -> ScriptSigner: def _load_or_create() -> ed25519.Ed25519PrivateKey: - ensure_runtime_dir("script_signing_keys") + ensure_server_certificates_dir("Code-Signing") _migrate_legacy_material_if_present() if _SIGNING_KEY_FILE.exists(): @@ -80,11 +87,30 @@ def _load_or_create() -> ed25519.Ed25519PrivateKey: def _migrate_legacy_material_if_present() -> None: + if _SIGNING_KEY_FILE.exists(): + return + + # First migrate from legacy runtime path embedded in Server runtime. + try: + if _OLD_RUNTIME_KEY_FILE.exists() and not _SIGNING_KEY_FILE.exists(): + ensure_server_certificates_dir("Code-Signing") + try: + _OLD_RUNTIME_KEY_FILE.replace(_SIGNING_KEY_FILE) + except Exception: + _SIGNING_KEY_FILE.write_bytes(_OLD_RUNTIME_KEY_FILE.read_bytes()) + if _OLD_RUNTIME_PUB_FILE.exists() and not _SIGNING_PUB_FILE.exists(): + try: + _OLD_RUNTIME_PUB_FILE.replace(_SIGNING_PUB_FILE) + except Exception: + _SIGNING_PUB_FILE.write_bytes(_OLD_RUNTIME_PUB_FILE.read_bytes()) + except Exception: + pass + if not _LEGACY_KEY_FILE.exists() or _SIGNING_KEY_FILE.exists(): return try: - ensure_runtime_dir("script_signing_keys") + ensure_server_certificates_dir("Code-Signing") try: _LEGACY_KEY_FILE.replace(_SIGNING_KEY_FILE) except Exception: @@ -97,4 +123,3 @@ def _migrate_legacy_material_if_present() -> None: _SIGNING_PUB_FILE.write_bytes(_LEGACY_PUB_FILE.read_bytes()) except Exception: return - diff --git a/Data/Server/Modules/runtime.py b/Data/Server/Modules/runtime.py index 8ff0138..40a841f 100644 --- a/Data/Server/Modules/runtime.py +++ b/Data/Server/Modules/runtime.py @@ -82,13 +82,47 @@ def ensure_runtime_dir(*parts: str) -> Path: def certificates_root() -> Path: """Base directory for persisted certificate material.""" - env = _env_path("BOREALIS_CERT_ROOT") + env = _env_path("BOREALIS_CERTIFICATES_ROOT") or _env_path("BOREALIS_CERT_ROOT") if env: env.mkdir(parents=True, exist_ok=True) return env root = project_root() / "Certificates" root.mkdir(parents=True, exist_ok=True) + # Ensure expected subdirectories exist for agent and server material. + try: + (root / "Server").mkdir(parents=True, exist_ok=True) + (root / "Agent").mkdir(parents=True, exist_ok=True) + except Exception: + pass + return root + + +@lru_cache(maxsize=None) +def server_certificates_root() -> Path: + """Base directory for server certificate material.""" + + env = _env_path("BOREALIS_SERVER_CERT_ROOT") + if env: + env.mkdir(parents=True, exist_ok=True) + return env + + root = certificates_root() / "Server" + root.mkdir(parents=True, exist_ok=True) + return root + + +@lru_cache(maxsize=None) +def agent_certificates_root() -> Path: + """Base directory for agent certificate material.""" + + env = _env_path("BOREALIS_AGENT_CERT_ROOT") + if env: + env.mkdir(parents=True, exist_ok=True) + return env + + root = certificates_root() / "Agent" + root.mkdir(parents=True, exist_ok=True) return root @@ -104,3 +138,31 @@ def ensure_certificates_dir(*parts: str) -> Path: path = certificates_path(*parts) path.mkdir(parents=True, exist_ok=True) return path + + +def server_certificates_path(*parts: str) -> Path: + """Return a path under the server certificates root.""" + + return server_certificates_root().joinpath(*parts) + + +def ensure_server_certificates_dir(*parts: str) -> Path: + """Create (if required) and return a server certificates subdirectory.""" + + path = server_certificates_path(*parts) + path.mkdir(parents=True, exist_ok=True) + return path + + +def agent_certificates_path(*parts: str) -> Path: + """Return a path under the agent certificates root.""" + + return agent_certificates_root().joinpath(*parts) + + +def ensure_agent_certificates_dir(*parts: str) -> Path: + """Create (if required) and return an agent certificates subdirectory.""" + + path = agent_certificates_path(*parts) + path.mkdir(parents=True, exist_ok=True) + return path diff --git a/Data/Server/server.py b/Data/Server/server.py index 9972c40..147ec24 100644 --- a/Data/Server/server.py +++ b/Data/Server/server.py @@ -50,25 +50,40 @@ else: _original_handle_one_request = HttpProtocol.handle_one_request def _quiet_tls_http_mismatch(self): # type: ignore[override] + def _close_connection_quietly(): + try: + self.close_connection = True # type: ignore[attr-defined] + except Exception: + pass + try: + conn = getattr(self, "socket", None) or getattr(self, "connection", None) + if conn: + conn.close() + except Exception: + pass + try: return _original_handle_one_request(self) except ssl.SSLError as exc: # type: ignore[arg-type] reason = getattr(exc, "reason", "") reason_text = str(reason).lower() if reason else "" message = " ".join(str(arg) for arg in exc.args if arg).lower() - if "http_request" in message or reason_text == "http request": - try: - self.close_connection = True # type: ignore[attr-defined] - except Exception: - pass - try: - conn = getattr(self, "socket", None) or getattr(self, "connection", None) - if conn: - conn.close() - except Exception: - pass + if ( + "http_request" in message + or reason_text == "http request" + or "unknown ca" in message + or reason_text == "unknown ca" + or "unknown_ca" in message + ): + _close_connection_quietly() return None raise + except ssl.SSLEOFError: + _close_connection_quietly() + return None + except ConnectionAbortedError: + _close_connection_quietly() + return None HttpProtocol.handle_one_request = _quiet_tls_http_mismatch # type: ignore[assignment]