diff --git a/Data/Engine/README.md b/Data/Engine/README.md index 266459c..cb67187 100644 --- a/Data/Engine/README.md +++ b/Data/Engine/README.md @@ -52,6 +52,26 @@ The Engine mirrors the legacy defaults so it can boot without additional configu | `BOREALIS_REPO_BRANCH` | Default branch tracked by the Engine GitHub integration. | `main` | | `BOREALIS_REPO_HASH_REFRESH` | Seconds between default repository head refresh attempts (clamped 30-3600). | `60` | | `BOREALIS_CACHE_DIR` | Directory used to persist Engine cache files (GitHub repo head cache). | `/Data/Engine/cache` | +| `BOREALIS_CERTIFICATES_ROOT` | Overrides where TLS certificates (root CA + leaf) are stored. | `/Certificates` | +| `BOREALIS_SERVER_CERT_ROOT` | Directly points to the Engine server certificate directory if certificates are staged elsewhere. | `/Certificates/Server` | + +## TLS and transport stack + +`Data/Engine/services/crypto/certificates.py` mirrors the legacy certificate +generator so the Engine always serves HTTPS with a self-managed root CA and +leaf certificate. During bootstrap the Engine: + +1. Runs the certificate helper to ensure the root CA, server key, and bundle + exist under `Certificates/Server/` (or the configured override path). +2. Exposes the resulting bundle via `BOREALIS_TLS_BUNDLE` so enrollment flows + can deliver the pinned certificate to agents. +3. Launches Socket.IO/Eventlet with the generated cert/key pair. A fallback to + Werkzeug’s TLS support keeps HTTPS available even if Socket.IO is disabled. + +`Data/Engine/interfaces/eventlet_compat.py` applies the same Eventlet monkey +patch as the legacy server so TLS handshakes presented to the HTTP listener are +handled quietly instead of surfacing `400 Bad Request` noise when non-TLS +clients connect. ## Logging expectations @@ -166,6 +186,8 @@ The suite currently validates: malformed requests. - SQLite schema migrations to ensure the Engine can provision required tables in a fresh database. +- TLS certificate provisioning helpers to guarantee HTTPS material exists before + the Engine starts serving requests. Successful execution prints a summary similar to: diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index 0593ca2..e16b272 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -2,7 +2,9 @@ from __future__ import annotations +import os from dataclasses import dataclass +from pathlib import Path from typing import Optional from flask import Flask @@ -13,10 +15,15 @@ from .interfaces import ( register_http_interfaces, register_ws_interfaces, ) +from .interfaces.eventlet_compat import apply_eventlet_patches from .repositories.sqlite import connection as sqlite_connection from .repositories.sqlite import migrations as sqlite_migrations from .server import create_app from .services.container import build_service_container +from .services.crypto.certificates import ensure_certificate + + +apply_eventlet_patches() @dataclass(frozen=True, slots=True) @@ -27,6 +34,9 @@ class EngineRuntime: settings: EngineSettings socketio: Optional[object] db_factory: sqlite_connection.SQLiteConnectionFactory + tls_certificate: Path + tls_key: Path + tls_bundle: Path def bootstrap() -> EngineRuntime: @@ -36,6 +46,17 @@ def bootstrap() -> EngineRuntime: logger = configure_logging(settings) logger.info("bootstrap-started") + cert_path, key_path, bundle_path = ensure_certificate() + os.environ.setdefault("BOREALIS_TLS_BUNDLE", str(bundle_path)) + logger.info( + "tls-material-ready", + extra={ + "cert_path": str(cert_path), + "key_path": str(key_path), + "bundle_path": str(bundle_path), + }, + ) + db_factory = sqlite_connection.connection_factory(settings.database_path) if settings.apply_migrations: logger.info("migrations-start") @@ -53,24 +74,38 @@ def bootstrap() -> EngineRuntime: register_ws_interfaces(socketio, services) services.scheduler_service.start(socketio) logger.info("bootstrap-complete") - return EngineRuntime(app=app, settings=settings, socketio=socketio, db_factory=db_factory) + return EngineRuntime( + app=app, + settings=settings, + socketio=socketio, + db_factory=db_factory, + tls_certificate=cert_path, + tls_key=key_path, + tls_bundle=bundle_path, + ) def main() -> None: runtime = bootstrap() socketio = runtime.socketio + certfile = str(runtime.tls_bundle) + keyfile = str(runtime.tls_key) + if socketio is not None: socketio.run( # type: ignore[call-arg] runtime.app, host=runtime.settings.server.host, port=runtime.settings.server.port, debug=runtime.settings.debug, + certfile=certfile, + keyfile=keyfile, ) else: runtime.app.run( host=runtime.settings.server.host, port=runtime.settings.server.port, debug=runtime.settings.debug, + ssl_context=(certfile, keyfile), ) diff --git a/Data/Engine/interfaces/eventlet_compat.py b/Data/Engine/interfaces/eventlet_compat.py new file mode 100644 index 0000000..66b1f5e --- /dev/null +++ b/Data/Engine/interfaces/eventlet_compat.py @@ -0,0 +1,75 @@ +"""Compatibility helpers for running Socket.IO under eventlet.""" + +from __future__ import annotations + +import ssl +from typing import Any + +try: # pragma: no cover - optional dependency + import eventlet # type: ignore +except Exception: # pragma: no cover - optional dependency + eventlet = None # type: ignore[assignment] + + +def _quiet_close(connection: Any) -> None: + try: + if hasattr(connection, "close"): + connection.close() + except Exception: + pass + + +def _close_connection_quietly(protocol: Any) -> None: + try: + setattr(protocol, "close_connection", True) + except Exception: + pass + conn = getattr(protocol, "socket", None) or getattr(protocol, "connection", None) + if conn is not None: + _quiet_close(conn) + + +def apply_eventlet_patches() -> None: + """Apply Borealis-specific eventlet tweaks when the dependency is available.""" + + if eventlet is None: # pragma: no cover - guard for environments without eventlet + return + + eventlet.monkey_patch(thread=False) + + try: + from eventlet.wsgi import HttpProtocol # type: ignore + except Exception: # pragma: no cover - import guard + return + + original = HttpProtocol.handle_one_request # type: ignore[attr-defined] + + def _handle_one_request(self: Any, *args: Any, **kwargs: Any) -> Any: # type: ignore[override] + try: + return original(self, *args, **kwargs) + except ssl.SSLError as exc: # type: ignore[arg-type] + reason = getattr(exc, "reason", "") or "" + message = " ".join(str(arg) for arg in exc.args if arg) + lower_reason = str(reason).lower() + lower_message = message.lower() + if ( + "http_request" in lower_message + or lower_reason == "http request" + or "unknown ca" in lower_message + or lower_reason == "unknown ca" + or "unknown_ca" in lower_message + ): + _close_connection_quietly(self) + return None + raise + except ssl.SSLEOFError: + _close_connection_quietly(self) + return None + except ConnectionAbortedError: + _close_connection_quietly(self) + return None + + HttpProtocol.handle_one_request = _handle_one_request # type: ignore[assignment] + + +__all__ = ["apply_eventlet_patches"] diff --git a/Data/Engine/services/__init__.py b/Data/Engine/services/__init__.py index 5a2d092..3e216c7 100644 --- a/Data/Engine/services/__init__.py +++ b/Data/Engine/services/__init__.py @@ -2,25 +2,8 @@ from __future__ import annotations -from .auth import ( - DeviceAuthService, - DeviceRecord, - RefreshTokenRecord, - TokenRefreshError, - TokenRefreshErrorCode, - TokenService, -) -from .enrollment import ( - EnrollmentRequestResult, - EnrollmentService, - EnrollmentStatus, - EnrollmentTokenBundle, - PollingResult, -) -from Data.Engine.domain.device_enrollment import EnrollmentValidationError -from .jobs.scheduler_service import SchedulerService -from .github import GitHubService, GitHubTokenPayload -from .realtime import AgentRealtimeService, AgentRecord +from importlib import import_module +from typing import Any, Dict, Tuple __all__ = [ "DeviceAuthService", @@ -41,3 +24,39 @@ __all__ = [ "GitHubService", "GitHubTokenPayload", ] + +_LAZY_TARGETS: Dict[str, Tuple[str, str]] = { + "DeviceAuthService": ("Data.Engine.services.auth.device_auth_service", "DeviceAuthService"), + "DeviceRecord": ("Data.Engine.services.auth.device_auth_service", "DeviceRecord"), + "RefreshTokenRecord": ("Data.Engine.services.auth.device_auth_service", "RefreshTokenRecord"), + "TokenService": ("Data.Engine.services.auth.token_service", "TokenService"), + "TokenRefreshError": ("Data.Engine.services.auth.token_service", "TokenRefreshError"), + "TokenRefreshErrorCode": ("Data.Engine.services.auth.token_service", "TokenRefreshErrorCode"), + "EnrollmentService": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentService"), + "EnrollmentRequestResult": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentRequestResult"), + "EnrollmentStatus": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentStatus"), + "EnrollmentTokenBundle": ("Data.Engine.services.enrollment.enrollment_service", "EnrollmentTokenBundle"), + "PollingResult": ("Data.Engine.services.enrollment.enrollment_service", "PollingResult"), + "EnrollmentValidationError": ("Data.Engine.domain.device_enrollment", "EnrollmentValidationError"), + "AgentRealtimeService": ("Data.Engine.services.realtime.agent_registry", "AgentRealtimeService"), + "AgentRecord": ("Data.Engine.services.realtime.agent_registry", "AgentRecord"), + "SchedulerService": ("Data.Engine.services.jobs.scheduler_service", "SchedulerService"), + "GitHubService": ("Data.Engine.services.github.github_service", "GitHubService"), + "GitHubTokenPayload": ("Data.Engine.services.github.github_service", "GitHubTokenPayload"), +} + + +def __getattr__(name: str) -> Any: + try: + module_name, attribute = _LAZY_TARGETS[name] + except KeyError as exc: + raise AttributeError(name) from exc + + module = import_module(module_name) + value = getattr(module, attribute) + globals()[name] = value + return value + + +def __dir__() -> Any: # pragma: no cover - interactive helper + return sorted(set(__all__)) diff --git a/Data/Engine/services/crypto/certificates.py b/Data/Engine/services/crypto/certificates.py new file mode 100644 index 0000000..1865a7a --- /dev/null +++ b/Data/Engine/services/crypto/certificates.py @@ -0,0 +1,366 @@ +"""Server TLS certificate management for the Borealis Engine.""" + +from __future__ import annotations + +import os +import ssl +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional, Tuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + +from Data.Engine.runtime import ensure_server_certificates_dir, runtime_path, server_certificates_path + +__all__ = [ + "build_ssl_context", + "certificate_paths", + "ensure_certificate", +] + +_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" +_CA_KEY_FILE = _CERT_DIR / "borealis-root-ca-key.pem" +_CA_CERT_FILE = _CERT_DIR / "borealis-root-ca.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" + +_ROOT_COMMON_NAME = "Borealis Root CA" +_ORG_NAME = "Borealis" +_ROOT_VALIDITY = timedelta(days=365 * 100) +_SERVER_VALIDITY = timedelta(days=365 * 5) + + +def ensure_certificate(common_name: str = "Borealis Engine") -> Tuple[Path, Path, Path]: + """Ensure the root CA, server certificate, and bundle exist on disk.""" + + ensure_server_certificates_dir() + _migrate_legacy_material_if_present() + + ca_key, ca_cert, ca_regenerated = _ensure_root_ca() + + server_cert = _load_certificate(_CERT_FILE) + needs_regen = ca_regenerated or _server_certificate_needs_regeneration(server_cert, ca_cert) + if needs_regen: + server_cert = _generate_server_certificate(common_name, ca_key, ca_cert) + + if server_cert is None: + server_cert = _generate_server_certificate(common_name, ca_key, ca_cert) + + _write_bundle(server_cert, ca_cert) + + return _CERT_FILE, _KEY_FILE, _BUNDLE_FILE + + +def _migrate_legacy_material_if_present() -> None: + if not _CERT_FILE.exists() or not _KEY_FILE.exists(): + legacy_cert = _LEGACY_CERT_FILE + legacy_key = _LEGACY_KEY_FILE + if legacy_cert.exists() and legacy_key.exists(): + try: + ensure_server_certificates_dir() + if not _CERT_FILE.exists(): + _safe_copy(legacy_cert, _CERT_FILE) + if not _KEY_FILE.exists(): + _safe_copy(legacy_key, _KEY_FILE) + except Exception: + pass + + +def _ensure_root_ca() -> Tuple[ec.EllipticCurvePrivateKey, x509.Certificate, bool]: + regenerated = False + + ca_key: Optional[ec.EllipticCurvePrivateKey] = None + ca_cert: Optional[x509.Certificate] = None + + if _CA_KEY_FILE.exists() and _CA_CERT_FILE.exists(): + try: + ca_key = _load_private_key(_CA_KEY_FILE) + ca_cert = _load_certificate(_CA_CERT_FILE) + if ca_cert is not None and ca_key is not None: + expiry = _cert_not_after(ca_cert) + subject = ca_cert.subject + subject_cn = "" + try: + subject_cn = subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value # type: ignore[index] + except Exception: + subject_cn = "" + try: + basic = ca_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined] + is_ca = bool(basic.ca) + except Exception: + is_ca = False + if ( + expiry <= datetime.now(tz=timezone.utc) + or not is_ca + or subject_cn != _ROOT_COMMON_NAME + ): + regenerated = True + else: + regenerated = True + except Exception: + regenerated = True + else: + regenerated = True + + if regenerated or ca_key is None or ca_cert is None: + ca_key = ec.generate_private_key(ec.SECP384R1()) + public_key = ca_key.public_key() + + now = datetime.now(tz=timezone.utc) + builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME), + ] + ) + ) + .issuer_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, _ROOT_COMMON_NAME), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME), + ] + ) + ) + .public_key(public_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + _ROOT_VALIDITY) + .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(public_key), + critical=False, + ) + ) + + builder = builder.add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key), + critical=False, + ) + + ca_cert = builder.sign(private_key=ca_key, algorithm=hashes.SHA384()) + + _CA_KEY_FILE.write_bytes( + ca_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + _CA_CERT_FILE.write_bytes(ca_cert.public_bytes(serialization.Encoding.PEM)) + + _tighten_permissions(_CA_KEY_FILE) + _tighten_permissions(_CA_CERT_FILE) + else: + regenerated = False + + return ca_key, ca_cert, regenerated + + +def _server_certificate_needs_regeneration( + server_cert: Optional[x509.Certificate], + ca_cert: x509.Certificate, +) -> bool: + if server_cert is None: + return True + + try: + if server_cert.issuer != ca_cert.subject: + return True + except Exception: + return True + + try: + expiry = _cert_not_after(server_cert) + if expiry <= datetime.now(tz=timezone.utc): + return True + except Exception: + return True + + try: + basic = server_cert.extensions.get_extension_for_class(x509.BasicConstraints).value # type: ignore[attr-defined] + if basic.ca: + return True + except Exception: + return True + + try: + eku = server_cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value # type: ignore[attr-defined] + if ExtendedKeyUsageOID.SERVER_AUTH not in eku: + return True + except Exception: + return True + + return False + + +def _generate_server_certificate( + common_name: str, + ca_key: ec.EllipticCurvePrivateKey, + ca_cert: x509.Certificate, +) -> x509.Certificate: + private_key = ec.generate_private_key(ec.SECP384R1()) + public_key = private_key.public_key() + + now = datetime.now(tz=timezone.utc) + ca_expiry = _cert_not_after(ca_cert) + candidate_expiry = now + _SERVER_VALIDITY + not_after = min(ca_expiry - timedelta(days=1), candidate_expiry) + + builder = ( + x509.CertificateBuilder() + .subject_name( + x509.Name( + [ + x509.NameAttribute(NameOID.COMMON_NAME, common_name), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, _ORG_NAME), + ] + ) + ) + .issuer_name(ca_cert.subject) + .public_key(public_key) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(not_after) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.DNSName("127.0.0.1"), + x509.DNSName("::1"), + ] + ), + critical=False, + ) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=False, + crl_sign=False, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(public_key), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + ) + + certificate = builder.sign(private_key=ca_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)) + + _tighten_permissions(_KEY_FILE) + _tighten_permissions(_CERT_FILE) + + return certificate + + +def _write_bundle(server_cert: x509.Certificate, ca_cert: x509.Certificate) -> None: + try: + server_pem = server_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip() + ca_pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("utf-8").strip() + except Exception: + return + + bundle = f"{server_pem}\n{ca_pem}\n" + _BUNDLE_FILE.write_text(bundle, encoding="utf-8") + _tighten_permissions(_BUNDLE_FILE) + + +def _safe_copy(src: Path, dst: Path) -> None: + try: + dst.write_bytes(src.read_bytes()) + except Exception: + pass + + +def _tighten_permissions(path: Path) -> None: + try: + if os.name == "posix": + path.chmod(0o600) + except Exception: + pass + + +def _load_private_key(path: Path) -> ec.EllipticCurvePrivateKey: + with path.open("rb") as fh: + return serialization.load_pem_private_key(fh.read(), password=None) + + +def _load_certificate(path: Path) -> Optional[x509.Certificate]: + try: + return x509.load_pem_x509_certificate(path.read_bytes()) + except Exception: + return None + + +def _cert_not_after(cert: x509.Certificate) -> datetime: + try: + return cert.not_valid_after_utc # type: ignore[attr-defined] + except AttributeError: + value = cert.not_valid_after + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + +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(bundle_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/Engine/tests/test_crypto_certificates.py b/Data/Engine/tests/test_crypto_certificates.py new file mode 100644 index 0000000..4fa2fa7 --- /dev/null +++ b/Data/Engine/tests/test_crypto_certificates.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import importlib +import os +import shutil +import ssl +import sys +import tempfile +import unittest +from pathlib import Path + +from Data.Engine import runtime + + +class CertificateGenerationTests(unittest.TestCase): + def setUp(self) -> None: + self._tmpdir = Path(tempfile.mkdtemp(prefix="engine-cert-tests-")) + self.addCleanup(lambda: shutil.rmtree(self._tmpdir, ignore_errors=True)) + + self._previous_env: dict[str, str | None] = {} + for name in ("BOREALIS_CERTIFICATES_ROOT", "BOREALIS_SERVER_CERT_ROOT"): + self._previous_env[name] = os.environ.get(name) + os.environ[name] = str(self._tmpdir / name.lower()) + + runtime.certificates_root.cache_clear() + runtime.server_certificates_root.cache_clear() + + module_name = "Data.Engine.services.crypto.certificates" + if module_name in sys.modules: + del sys.modules[module_name] + + try: + self.certificates = importlib.import_module(module_name) + except ModuleNotFoundError as exc: # pragma: no cover - optional deps absent + self.skipTest(f"cryptography dependency unavailable: {exc}") + + def tearDown(self) -> None: # pragma: no cover - environment cleanup + for name, value in self._previous_env.items(): + if value is None: + os.environ.pop(name, None) + else: + os.environ[name] = value + runtime.certificates_root.cache_clear() + runtime.server_certificates_root.cache_clear() + + def test_ensure_certificate_creates_material(self) -> None: + cert_path, key_path, bundle_path = self.certificates.ensure_certificate() + + self.assertTrue(cert_path.exists(), "certificate was not generated") + self.assertTrue(key_path.exists(), "private key was not generated") + self.assertTrue(bundle_path.exists(), "bundle was not generated") + + context = self.certificates.build_ssl_context() + self.assertIsInstance(context, ssl.SSLContext) + self.assertEqual(context.minimum_version, ssl.TLSVersion.TLSv1_3) + + def test_certificate_paths_returns_strings(self) -> None: + cert_path, key_path, bundle_path = self.certificates.certificate_paths() + self.assertIsInstance(cert_path, str) + self.assertIsInstance(key_path, str) + self.assertIsInstance(bundle_path, str) + + +if __name__ == "__main__": # pragma: no cover - convenience + unittest.main()