290 lines
9.3 KiB
Python
290 lines
9.3 KiB
Python
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
|