from __future__ import annotations import ssl import subprocess import sys from abc import ABC, abstractmethod from contextlib import suppress from pathlib import Path from tempfile import mkdtemp from types import ModuleType from typing import TYPE_CHECKING, cast from sanic.application.constants import Mode from sanic.application.spinner import loading from sanic.constants import ( DEFAULT_LOCAL_TLS_CERT, DEFAULT_LOCAL_TLS_KEY, LocalCertCreator, ) from sanic.exceptions import SanicException from sanic.helpers import Default from sanic.http.tls.context import CertSimple, SanicSSLContext try: import trustme TRUSTME_INSTALLED = True except (ImportError, ModuleNotFoundError): trustme = ModuleType("trustme") TRUSTME_INSTALLED = False if TYPE_CHECKING: from sanic import Sanic # Only allow secure ciphers, notably leaving out AES-CBC mode # OpenSSL chooses ECDSA or RSA depending on the cert in use CIPHERS_TLS12 = [ "ECDHE-ECDSA-CHACHA20-POLY1305", "ECDHE-ECDSA-AES256-GCM-SHA384", "ECDHE-ECDSA-AES128-GCM-SHA256", "ECDHE-RSA-CHACHA20-POLY1305", "ECDHE-RSA-AES256-GCM-SHA384", "ECDHE-RSA-AES128-GCM-SHA256", ] def _make_path(maybe_path: Path | str, tmpdir: Path | None) -> Path: if isinstance(maybe_path, Path): return maybe_path else: path = Path(maybe_path) if not path.exists(): if not tmpdir: raise RuntimeError("Reached an unknown state. No tmpdir.") return tmpdir / maybe_path return path def get_ssl_context(app: Sanic, ssl: ssl.SSLContext | None) -> ssl.SSLContext: if ssl: return ssl if app.state.mode is Mode.PRODUCTION: raise SanicException( "Cannot run Sanic as an HTTPS server in PRODUCTION mode " "without passing a TLS certificate. If you are developing " "locally, please enable DEVELOPMENT mode and Sanic will " "generate a localhost TLS certificate. For more information " "please see: https://sanic.dev/en/guide/deployment/development." "html#automatic-tls-certificate." ) creator = CertCreator.select( app, cast(LocalCertCreator, app.config.LOCAL_CERT_CREATOR), app.config.LOCAL_TLS_KEY, app.config.LOCAL_TLS_CERT, ) context = creator.generate_cert(app.config.LOCALHOST) return context class CertCreator(ABC): def __init__(self, app, key, cert) -> None: self.app = app self.key = key self.cert = cert self.tmpdir = None if isinstance(self.key, Default) or isinstance(self.cert, Default): self.tmpdir = Path(mkdtemp()) key = ( DEFAULT_LOCAL_TLS_KEY if isinstance(self.key, Default) else self.key ) cert = ( DEFAULT_LOCAL_TLS_CERT if isinstance(self.cert, Default) else self.cert ) self.key_path = _make_path(key, self.tmpdir) self.cert_path = _make_path(cert, self.tmpdir) @abstractmethod def check_supported(self) -> None: # no cov ... @abstractmethod def generate_cert(self, localhost: str) -> ssl.SSLContext: # no cov ... @classmethod def select( cls, app: Sanic, cert_creator: LocalCertCreator, local_tls_key, local_tls_cert, ) -> CertCreator: creator: CertCreator | None = None cert_creator_options: tuple[ tuple[type[CertCreator], LocalCertCreator], ... ] = ( (MkcertCreator, LocalCertCreator.MKCERT), (TrustmeCreator, LocalCertCreator.TRUSTME), ) for creator_class, local_creator in cert_creator_options: creator = cls._try_select( app, creator, creator_class, local_creator, cert_creator, local_tls_key, local_tls_cert, ) if creator: break if not creator: raise SanicException( "Sanic could not find package to create a TLS certificate. " "You must have either mkcert or trustme installed. See " "https://sanic.dev/en/guide/deployment/development.html" "#automatic-tls-certificate for more details." ) return creator @staticmethod def _try_select( app: Sanic, creator: CertCreator | None, creator_class: type[CertCreator], creator_requirement: LocalCertCreator, creator_requested: LocalCertCreator, local_tls_key, local_tls_cert, ): if creator or ( creator_requested is not LocalCertCreator.AUTO and creator_requested is not creator_requirement ): return creator instance = creator_class(app, local_tls_key, local_tls_cert) try: instance.check_supported() except SanicException: if creator_requested is creator_requirement: raise else: return None return instance class MkcertCreator(CertCreator): def check_supported(self) -> None: try: subprocess.run( # nosec B603 B607 ["mkcert", "-help"], check=True, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL, ) except Exception as e: raise SanicException( "Sanic is attempting to use mkcert to generate local TLS " "certificates since you did not supply a certificate, but " "one is required. Sanic cannot proceed since mkcert does not " "appear to be installed. Alternatively, you can use trustme. " "Please install mkcert, trustme, or supply TLS certificates " "to proceed. Installation instructions can be found here: " "https://github.com/FiloSottile/mkcert.\n" "Find out more information about your options here: " "https://sanic.dev/en/guide/deployment/development.html#" "automatic-tls-certificate" ) from e def generate_cert(self, localhost: str) -> ssl.SSLContext: try: if not self.cert_path.exists(): message = "Generating TLS certificate" # TODO: Validate input for security with loading(message): cmd = [ "mkcert", "-key-file", str(self.key_path), "-cert-file", str(self.cert_path), localhost, ] resp = subprocess.run( # nosec B603 cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, ) sys.stdout.write("\r" + " " * (len(message) + 4)) sys.stdout.flush() sys.stdout.write(resp.stdout) finally: @self.app.main_process_stop async def cleanup(*_): # no cov if self.tmpdir: with suppress(FileNotFoundError): self.key_path.unlink() self.cert_path.unlink() self.tmpdir.rmdir() context = CertSimple(self.cert_path, self.key_path) context.sanic["creator"] = "mkcert" context.sanic["localhost"] = localhost SanicSSLContext.create_from_ssl_context(context) return context class TrustmeCreator(CertCreator): def check_supported(self) -> None: if not TRUSTME_INSTALLED: raise SanicException( "Sanic is attempting to use trustme to generate local TLS " "certificates since you did not supply a certificate, but " "one is required. Sanic cannot proceed since trustme does not " "appear to be installed. Alternatively, you can use mkcert. " "Please install mkcert, trustme, or supply TLS certificates " "to proceed. Installation instructions can be found here: " "https://github.com/python-trio/trustme.\n" "Find out more information about your options here: " "https://sanic.dev/en/guide/deployment/development.html#" "automatic-tls-certificate" ) def generate_cert(self, localhost: str) -> ssl.SSLContext: context = SanicSSLContext.create_from_ssl_context( ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ) context.sanic = { "cert": self.cert_path.absolute(), "key": self.key_path.absolute(), } ca = trustme.CA() server_cert = ca.issue_cert(localhost) server_cert.configure_cert(context) ca.configure_trust(context) ca.cert_pem.write_to_path(str(self.cert_path.absolute())) server_cert.private_key_and_cert_chain_pem.write_to_path( str(self.key_path.absolute()) ) context.sanic["creator"] = "trustme" context.sanic["localhost"] = localhost return context