Repository: sanic-org/sanic

Issue:
Can't run normally when using SSLContext
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

```
   app.run(host='0.0.0.0', port=443, ssl=context, dev=True)
  File "/usr/lib/python3.10/site-packages/sanic/mixins/startup.py", line 209, in run
    serve(primary=self)  # type: ignore
  File "/usr/lib/python3.10/site-packages/sanic/mixins/startup.py", line 862, in serve
    manager.run()
  File "/usr/lib/python3.10/site-packages/sanic/worker/manager.py", line 94, in run
    self.start()
  File "/usr/lib/python3.10/site-packages/sanic/worker/manager.py", line 101, in start
    process.start()
  File "/usr/lib/python3.10/site-packages/sanic/worker/process.py", line 53, in start
    self._current_process.start()
  File "/usr/lib/python3.10/multiprocessing/process.py", line 121, in start
    self._popen = self._Popen(self)
  File "/usr/lib/python3.10/multiprocessing/context.py", line 284, in _Popen
    return Popen(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_spawn_posix.py", line 32, in __init__
    super().__init__(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_fork.py", line 19, in __init__
    self._launch(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_spawn_posix.py", line 47, in _launch
    reduction.dump(process_obj, fp)
  File "/usr/lib/python3.10/multiprocessing/reduction.py", line 60, in dump
    ForkingPickler(file, protocol).dump(obj)
TypeError: cannot pickle 'SSLContext' object
```

### Code snippet

```python
from sanic import Sanic
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("certs/fullchain.pem", "certs/privkey.pem")

app = Sanic('test')

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=443, ssl=context, dev=True)
```

### Expected Behavior

_No response_

### How do you run Sanic?

As a script (`app.run` or `Sanic.serve`)

### Operating System

linux

### Sanic Version

22.12.0

### Additional context

pickle does not support dump ssl.SSLContext causing this problem. 
`multiprocessing.context` use `pickle`

```python
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("certs/fullchain.pem", "certs/privkey.pem")
pickle.dumps(context)
```


Discussion:
This is known. You can pass a dict of the paths, or use context Wil a single process or legacy mode. Working in an alternative.
```python
from sanic import Sanic

app = Sanic('test')

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=443, ssl={
        "cert": "certs/fullchain.pem",
        "key": "certs/privkey.pem",
    }, dev=True)
```
@teixeirazeus That is equivalent to simply passing the cert dir as a string, as long as the files are named fullchain.pem and privkey.pem:
```python
app.run(host='0.0.0.0', port=443, ssl="certs", dev=True)
```

Or you may leave out `app.run` entirely (actually this is now preferred) and use the CLI instead. Assuming your file is main.py:
```sh
sanic --host 0.0.0.0 --port 443 --tls certs --dev main:app
```

I have read the code and [documentation ](https://sanic.dev/en/guide/how-to/tls.html#single-domain-and-single-certificate)in detail. I know all these ways, @Tronic @teixeirazeus  thank you for your answer.

I need full control over details such as which crypto algorithms are permitted. 

@sdir The problem is that SSLContext cannot be transferred to worker processes by pickling. This can be avoided by using

```python
from sanic import Sanic

Sanic.start_method = "fork"
```

On Linux you should be fine with that as a workaround. Sanic normally uses spawn (and thus pickling) because the fork mode doesn't work properly on Mac or Windows.

I believe @ahopkins is working on a solution that would allow you to construct your custom SSLContext in each worker process, avoiding the pickling even in spawn mode, and the issue is open waiting for that as the ultimate solution. I hope that either the workaround or whatever Adam brews up helps you.
> I need full control over details such as which crypto algorithms are permitted.

@sdir On this detail I would like to know more. Since TLS 1.2 and 1.3 there isn't much to control, but we are open to any input to make Sanic's selections on those more secure. If the tweaks that you do are reasonably compatible with browsers and not for weakening security, we might wish to implement the same in Sanic as well.

EDIT: Sanic currently gets grade A on https://www.ssllabs.com/ssltest (likely A+ if you add HSTS headers which my test app didn't have).
> > I need full control over details such as which crypto algorithms are permitted.
> 
> @Tronic  I didn't express myself clearly, This is just an example, not actually a crypto algorithms issue. 
I just took what was mentioned in the document. If `Context` is not supported, should  modify the document?

eg:` load_verify_locations`

```python
import ssl
context = ssl.SSLcontext()
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("certs/fullchain.pem", "certs/privkey.pem")
context.load_verify_locations("certs/ca.crt")
```
And this is very useful for me.
```python
from sanic import Sanic
Sanic.start_method = "fork"
```

Normally `load_verify_locations` is only used on client side, to obtain CAs that are then used for verifying server certs. I am not sure if it is even possible (especially with Python) but in principle a server could also verify browsers' certificates. I have never seen this done anywhere. Unless you are doing that, you don't need the call.

For other checks and manipulations on certs, you can do it using temporary SSLContext (or other SSL tools because Python is fairly limited) and then *not* pass it to Sanic but let Sanic load the same cert files on its own.

As for cipher suites, Sanic only allows TLS 1.2 and 1.3, with the following cipher suites:
```
# TLS 1.3 (suites in server-preferred order)
TLS_AES_256_GCM_SHA384 (0x1302)   ECDH x25519 (eq. 3072 bits RSA)   FS	256
TLS_CHACHA20_POLY1305_SHA256 (0x1303)   ECDH x25519 (eq. 3072 bits RSA)   FS	256
TLS_AES_128_GCM_SHA256 (0x1301)   ECDH x25519 (eq. 3072 bits RSA)   FS	128
# TLS 1.2 (suites in server-preferred order)
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 (0xcca9)   ECDH x25519 (eq. 3072 bits RSA)   FS	256
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 (0xc02c)   ECDH x25519 (eq. 3072 bits RSA)   FS	256
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)   ECDH x25519 (eq. 3072 bits RSA)   FS	128
```
The weaker AES-128 MUST by implemented by the spec. The only thing I might change here is prefer CHACHA20 first on TLS 1.3 as well, but I don't think this can be configured with Python's SSLContext. But all three suites are actually very secure and supporting forward secrecy.

Another thing you could do with a customized context is to do something with the SNI sent in Client Hello (via a callback in SSLContext). Sanic uses this to select the correct vhost certificate if multiple certificates for different domains are available, or to reject the connection if no valid name was sent.

I'll leave it up to @sdir and @ahopkins to decide whether there is need to have a mechanism for constructing truly custom SSLContext (especially for platforms where fork cannot be used), or whether these issues are in fact not worth the effort.

+1 for this. My organization uses certificates for authentication internally, so `load_verify_locations` is mandatory because the server needs to have a copy of the companies CA that issues a certificate to each user. The server then verifies that the user is presenting a valid certificate signed by the company CA.

It works fine on the older Sanic, so it's a clear regression.
Then two paths to that, either specifically add support for load_verify_locations (assuming this is the missing piece that everyone needs), or add a signal for creating/manipulating SSLContext right as the workers are starting. Probably the latter as other needs may arise, and it is fairly simple to implement both in Sanic and for app developers (who will in any case need to make changes, unfortunately).
@goatpop try custom class
```python
class SSLContextSimple(ssl.SSLContext):

    def config(self, conf:dict):
        self.conf = conf
        self.load_config(conf)

    def load_config(self, conf):
        self.load_cert_chain(conf['cert'], conf['key'])
        self.load_verify_locations(conf['ca'])

    def __getnewargs__(self):
        return (self.protocol,)

    def __getstate__(self):
        return self.conf

    def __setstate__(self, state):
        self.load_config(state)

context = SSLContextSimple(ssl.PROTOCOL_TLS_SERVER)
context.config({
    'cert': 'certs/fullchain.pem',
    'key' : 'certs/privkey.pem',
    'ca': 'certs/ca.crt'
})


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=443, ssl=context, dev=True)

```
> add a signal for creating/manipulating SSLContext right as the workers are starting

Yes, this is the approach I was after.

Resolving patch:
diff --git a/sanic/app.py b/sanic/app.py
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -92,6 +92,7 @@
 from sanic.touchup import TouchUp, TouchUpMeta
 from sanic.types.shared_ctx import SharedContext
 from sanic.worker.inspector import Inspector
+from sanic.worker.loader import CertLoader
 from sanic.worker.manager import WorkerManager
 
 
@@ -139,6 +140,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
         "_test_client",
         "_test_manager",
         "blueprints",
+        "certloader_class",
         "config",
         "configure_logging",
         "ctx",
@@ -181,6 +183,7 @@ def __init__(
         loads: Optional[Callable[..., Any]] = None,
         inspector: bool = False,
         inspector_class: Optional[Type[Inspector]] = None,
+        certloader_class: Optional[Type[CertLoader]] = None,
     ) -> None:
         super().__init__(name=name)
         # logging
@@ -215,6 +218,9 @@ def __init__(
         self.asgi = False
         self.auto_reload = False
         self.blueprints: Dict[str, Blueprint] = {}
+        self.certloader_class: Type[CertLoader] = (
+            certloader_class or CertLoader
+        )
         self.configure_logging: bool = configure_logging
         self.ctx: Any = ctx or SimpleNamespace()
         self.error_handler: ErrorHandler = error_handler or ErrorHandler()
diff --git a/sanic/worker/loader.py b/sanic/worker/loader.py
--- a/sanic/worker/loader.py
+++ b/sanic/worker/loader.py
@@ -5,6 +5,7 @@
 
 from importlib import import_module
 from pathlib import Path
+from ssl import SSLContext
 from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
 
 from sanic.http.tls.context import process_to_context
@@ -103,8 +104,16 @@ class CertLoader:
         "trustme": TrustmeCreator,
     }
 
-    def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
+    def __init__(
+        self,
+        ssl_data: Optional[
+            Union[SSLContext, Dict[str, Union[str, os.PathLike]]]
+        ],
+    ):
         self._ssl_data = ssl_data
+        self._creator_class = None
+        if not ssl_data or not isinstance(ssl_data, dict):
+            return
 
         creator_name = cast(str, ssl_data.get("creator"))
 
diff --git a/sanic/worker/serve.py b/sanic/worker/serve.py
--- a/sanic/worker/serve.py
+++ b/sanic/worker/serve.py
@@ -73,8 +73,8 @@ def worker_serve(
                             info.settings["app"] = a
                         a.state.server_info.append(info)
 
-        if isinstance(ssl, dict):
-            cert_loader = CertLoader(ssl)
+        if isinstance(ssl, dict) or app.certloader_class is not CertLoader:
+            cert_loader = app.certloader_class(ssl or {})
             ssl = cert_loader.load(app)
             for info in app.state.server_info:
                 info.settings["ssl"] = ssl


Test patch:
diff --git a/tests/test_tls.py b/tests/test_tls.py
--- a/tests/test_tls.py
+++ b/tests/test_tls.py
@@ -12,7 +12,7 @@
 
 import pytest
 
-from sanic_testing.testing import HOST, PORT
+from sanic_testing.testing import HOST, PORT, SanicTestClient
 
 import sanic.http.tls.creators
 
@@ -29,6 +29,7 @@
     get_ssl_context,
 )
 from sanic.response import text
+from sanic.worker.loader import CertLoader
 
 
 current_dir = os.path.dirname(os.path.realpath(__file__))
@@ -427,6 +428,29 @@ async def handler(request):
     assert "No certificates" in str(excinfo.value)
 
 
+def test_custom_cert_loader():
+    class MyCertLoader(CertLoader):
+        def load(self, app: Sanic):
+            self._ssl_data = {
+                "key": localhost_key,
+                "cert": localhost_cert,
+            }
+            return super().load(app)
+
+    app = Sanic("custom", certloader_class=MyCertLoader)
+
+    @app.get("/test")
+    async def handler(request):
+        return text("ssl test")
+
+    client = SanicTestClient(app, port=44556)
+
+    request, response = client.get("https://localhost:44556/test")
+    assert request.scheme == "https"
+    assert response.status_code == 200
+    assert response.text == "ssl test"
+
+
 def test_logger_vhosts(caplog):
     app = Sanic(name="test_logger_vhosts")