ser2netweb 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""What a TLS certificate file says about itself - its subject, names and validity - for
|
|
2
|
+
Settings → Admin TLS and the start-up log, for a self-signed certificate and a CA-issued
|
|
3
|
+
one alike. Only reads the file, found as every TLS file of the configuration is
|
|
4
|
+
(engine/tls.py: a relative path is one in the data directory).
|
|
5
|
+
|
|
6
|
+
Read with the optional `cryptography` package when it is there (the standalone executables
|
|
7
|
+
and the Docker image have it), otherwise with the decoder in Python's ssl module.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import dataclasses
|
|
13
|
+
import datetime
|
|
14
|
+
import ipaddress
|
|
15
|
+
import ssl
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
from ..config import data_path
|
|
19
|
+
from ..engine import tls
|
|
20
|
+
|
|
21
|
+
SOON_DAYS = 30 # a certificate that ends sooner than this is flagged
|
|
22
|
+
_MAX_BYTES = 1024 * 1024 # a certificate file larger than this is not read
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclasses.dataclass
|
|
26
|
+
class CertInfo:
|
|
27
|
+
subject: str # its common name (CN), else the whole subject
|
|
28
|
+
names: list[str] # its subject alternative names (DNS names, IP addresses)
|
|
29
|
+
not_before: float # valid from (epoch seconds)
|
|
30
|
+
not_after: float # valid until
|
|
31
|
+
self_signed: bool # issued by itself
|
|
32
|
+
|
|
33
|
+
def state(self, now: float | None = None) -> str:
|
|
34
|
+
""""not_yet_valid", "expired", "expiring" (within SOON_DAYS) or "valid"."""
|
|
35
|
+
now = time.time() if now is None else now
|
|
36
|
+
if now < self.not_before:
|
|
37
|
+
return "not_yet_valid"
|
|
38
|
+
if now > self.not_after:
|
|
39
|
+
return "expired"
|
|
40
|
+
return "expiring" if self.not_after - now < SOON_DAYS * 86400 else "valid"
|
|
41
|
+
|
|
42
|
+
def days_left(self, now: float | None = None) -> int:
|
|
43
|
+
"""Whole days until it ends (0 on its last day; negative once it has ended)."""
|
|
44
|
+
return int((self.not_after - (time.time() if now is None else now)) // 86400)
|
|
45
|
+
|
|
46
|
+
def describe(self, now: float | None = None) -> str:
|
|
47
|
+
"""One line for the log: subject, validity in UTC and what that means now."""
|
|
48
|
+
def day(t: float) -> str:
|
|
49
|
+
return time.strftime("%Y-%m-%d %H:%M", time.gmtime(t))
|
|
50
|
+
state, days = self.state(now), self.days_left(now)
|
|
51
|
+
count = f"{days} day" if days == 1 else f"{days} days"
|
|
52
|
+
what = {"not_yet_valid": "NOT YET VALID (this host's clock is behind its start?)",
|
|
53
|
+
"expired": "EXPIRED", "expiring": f"ends in {count}",
|
|
54
|
+
"valid": f"{count} left"}[state]
|
|
55
|
+
kind = ", self-signed" if self.self_signed else ""
|
|
56
|
+
return (f"{self.subject}{kind}, valid {day(self.not_before)} to "
|
|
57
|
+
f"{day(self.not_after)} UTC: {what}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class CertReadError(Exception):
|
|
61
|
+
"""The file can't be read as a certificate. `code`: "unreadable" (str(self) says
|
|
62
|
+
why), "not_pem" (no PEM certificate in it) or "no_decoder" (neither cryptography nor
|
|
63
|
+
the ssl module's decoder is available)."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, code: str, detail: str = "") -> None:
|
|
66
|
+
super().__init__(detail or code)
|
|
67
|
+
self.code = code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read(path: str) -> CertInfo:
|
|
71
|
+
"""The first certificate in the PEM file at *path* (a path from the configuration:
|
|
72
|
+
config.data_path). Raises CertReadError. Blocking file I/O: call via
|
|
73
|
+
asyncio.to_thread from the event loop."""
|
|
74
|
+
path = data_path(path)
|
|
75
|
+
try:
|
|
76
|
+
with open(path, "rb") as fh:
|
|
77
|
+
data = fh.read(_MAX_BYTES + 1)
|
|
78
|
+
except OSError as e:
|
|
79
|
+
raise CertReadError("unreadable", e.strerror or str(e)) from None
|
|
80
|
+
if len(data) > _MAX_BYTES or b"-----BEGIN CERTIFICATE-----" not in data:
|
|
81
|
+
raise CertReadError("not_pem")
|
|
82
|
+
try:
|
|
83
|
+
return _with_cryptography(data)
|
|
84
|
+
except ImportError:
|
|
85
|
+
pass
|
|
86
|
+
return _with_ssl(path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _utc(value: datetime.datetime) -> float:
|
|
90
|
+
if value.tzinfo is None: # cryptography before 42: naive UTC
|
|
91
|
+
value = value.replace(tzinfo=datetime.timezone.utc)
|
|
92
|
+
return value.timestamp()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _with_cryptography(data: bytes) -> CertInfo:
|
|
96
|
+
from cryptography import x509
|
|
97
|
+
from cryptography.x509.oid import NameOID
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
cert = x509.load_pem_x509_certificate(data)
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise CertReadError("not_pem") from None
|
|
103
|
+
cn = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)
|
|
104
|
+
names: list[str] = []
|
|
105
|
+
try:
|
|
106
|
+
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value
|
|
107
|
+
names = [*san.get_values_for_type(x509.DNSName),
|
|
108
|
+
*(str(ip) for ip in san.get_values_for_type(x509.IPAddress))]
|
|
109
|
+
except x509.ExtensionNotFound:
|
|
110
|
+
pass
|
|
111
|
+
return CertInfo(
|
|
112
|
+
subject=str(cn[0].value) if cn else cert.subject.rfc4514_string(), names=names,
|
|
113
|
+
not_before=_utc(getattr(cert, "not_valid_before_utc", None) or cert.not_valid_before),
|
|
114
|
+
not_after=_utc(getattr(cert, "not_valid_after_utc", None) or cert.not_valid_after),
|
|
115
|
+
self_signed=cert.issuer == cert.subject)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _with_ssl(path: str) -> CertInfo:
|
|
119
|
+
"""ssl's own certificate decoder (the one its tests use; CPython has it on every
|
|
120
|
+
platform): a source install without cryptography still sees its certificate."""
|
|
121
|
+
decode = getattr(getattr(ssl, "_ssl", None), "_test_decode_cert", None)
|
|
122
|
+
if decode is None:
|
|
123
|
+
raise CertReadError("no_decoder")
|
|
124
|
+
try:
|
|
125
|
+
d = decode(path)
|
|
126
|
+
subject = [kv for rdn in d.get("subject", ()) for kv in rdn]
|
|
127
|
+
issuer = [kv for rdn in d.get("issuer", ()) for kv in rdn]
|
|
128
|
+
cn = [v for k, v in subject if k == "commonName"]
|
|
129
|
+
names = [_ip(v) if k == "IP Address" else str(v) for k, v in d.get("subjectAltName", ())
|
|
130
|
+
if k in ("DNS", "IP Address")]
|
|
131
|
+
return CertInfo(subject=cn[0] if cn else tls.subject(d),
|
|
132
|
+
names=names, not_before=float(ssl.cert_time_to_seconds(d["notBefore"])),
|
|
133
|
+
not_after=float(ssl.cert_time_to_seconds(d["notAfter"])),
|
|
134
|
+
self_signed=subject == issuer)
|
|
135
|
+
except (ssl.SSLError, ValueError, KeyError, TypeError):
|
|
136
|
+
raise CertReadError("not_pem") from None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _ip(value) -> str:
|
|
140
|
+
"""An IP address as cryptography writes it ('::1', not '0:0:0:0:0:0:0:1')."""
|
|
141
|
+
text = str(value).strip()
|
|
142
|
+
with contextlib.suppress(ValueError):
|
|
143
|
+
return str(ipaddress.ip_address(text))
|
|
144
|
+
return text
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Connection limits for the admin UI's listener: uvicorn's h11 protocol, subclassed.
|
|
2
|
+
|
|
3
|
+
uvicorn's h11 server keeps a connection for as long as its client does while no complete
|
|
4
|
+
request arrives: no timer runs before the first request, and the keep-alive timer after a
|
|
5
|
+
response stops at the first byte of the next one. limit_concurrency answers requests with
|
|
6
|
+
503 but never closes a socket. So anyone who could reach the port could hold any number of
|
|
7
|
+
idle sockets, unauthenticated, until the process ran out of descriptors (on Windows, until
|
|
8
|
+
select() refused a socket and the event loop stopped).
|
|
9
|
+
|
|
10
|
+
``protocol_class(guard)`` is the class given to uvicorn.Config(http=...); ``AdminGuard``
|
|
11
|
+
holds what its connections share:
|
|
12
|
+
- admission right after accept, before TLS: the process's budget (netguard.BUDGET), then
|
|
13
|
+
admin_ui.max_connections in all and admin_ui.max_connections_per_ip from one address.
|
|
14
|
+
A refused socket is closed, and logged at most once a minute;
|
|
15
|
+
- TLS (admin_ui.tls_cert / tls_key) is done here once a connection is admitted, as on a
|
|
16
|
+
mapping's listener (netguard.begin_tls: handshake timeout, handshakes in progress
|
|
17
|
+
capped); uvicorn itself is given no certificate;
|
|
18
|
+
- a deadline (admin_ui.request_timeout_s) for every request: from the accept and from
|
|
19
|
+
the end of each response until a complete request head is in, then for its body,
|
|
20
|
+
which has to keep coming at BODY_MIN_RATE on average (a body the app answered before
|
|
21
|
+
reading too), until the whole request is in;
|
|
22
|
+
- a send deadline: a connection whose client takes nothing of what it was sent for
|
|
23
|
+
admin_ui.request_timeout_s is aborted (also while it closes after a response);
|
|
24
|
+
- a connection that runs out of time is aborted, not closed (a TLS close would wait for
|
|
25
|
+
the client). A WebSocket (the console) is not timed once it is upgraded;
|
|
26
|
+
- a request answered before its body was all in (a refusal) whose client asked to close
|
|
27
|
+
the connection: the rest of the body is read (and dropped) under the request deadline
|
|
28
|
+
before the connection closes (``_Transport``). Closed at once, the socket would reset
|
|
29
|
+
the connection, with the body still coming or unread in it, and a client on Windows
|
|
30
|
+
then loses the answer it has not read yet.
|
|
31
|
+
|
|
32
|
+
``listen`` makes the listener's sockets (listening already) before the mappings start,
|
|
33
|
+
for uvicorn.Server.serve(sockets=...).
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import contextlib
|
|
39
|
+
import errno
|
|
40
|
+
import os
|
|
41
|
+
import socket
|
|
42
|
+
import ssl
|
|
43
|
+
import sys
|
|
44
|
+
from typing import Any, Callable, Optional, cast
|
|
45
|
+
|
|
46
|
+
import h11
|
|
47
|
+
from uvicorn.protocols.http.h11_impl import H11Protocol
|
|
48
|
+
|
|
49
|
+
from ..engine import netguard, tls
|
|
50
|
+
|
|
51
|
+
# a request's body has admin_ui.request_timeout_s from the end of its head, and one second
|
|
52
|
+
# more for each BODY_MIN_RATE bytes of it that came in: a client that sends it slower than
|
|
53
|
+
# this on average runs out of time
|
|
54
|
+
BODY_MIN_RATE = 1024
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AdminGuard:
|
|
58
|
+
"""What the admin UI's connections share (one per server)."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, admin: Any, log: Callable[[str], None],
|
|
61
|
+
ssl_context: Optional[ssl.SSLContext] = None) -> None:
|
|
62
|
+
self.connections = netguard.Pool(admin.max_connections, admin.max_connections_per_ip)
|
|
63
|
+
self.request_timeout = admin.request_timeout_s
|
|
64
|
+
self.ssl = ssl_context
|
|
65
|
+
self.server: Any = None # the uvicorn.Server: should_exit = shutting down
|
|
66
|
+
self.timeouts = 0 # connections aborted: no complete request, or nothing taken, in time
|
|
67
|
+
self._refused = netguard.LogEvery(log)
|
|
68
|
+
self._failed = netguard.LogEvery(lambda msg: log(f"admin UI: {msg}"))
|
|
69
|
+
self._handshakes: dict[Any, asyncio.Task] = {}
|
|
70
|
+
|
|
71
|
+
def stopping(self) -> bool:
|
|
72
|
+
return bool(self.server is not None and self.server.should_exit)
|
|
73
|
+
|
|
74
|
+
def use_limits(self, admin: Any) -> None:
|
|
75
|
+
"""admin_ui's connection limits as they are now (a reload): from the next
|
|
76
|
+
connection or request on."""
|
|
77
|
+
self.connections.total = admin.max_connections
|
|
78
|
+
self.connections.per_ip = admin.max_connections_per_ip
|
|
79
|
+
self.request_timeout = admin.request_timeout_s
|
|
80
|
+
|
|
81
|
+
def admit(self, transport: Any) -> bool:
|
|
82
|
+
"""Whether an accepted connection may in; a refused one is closed and logged."""
|
|
83
|
+
ip = netguard.peer_ip(transport)
|
|
84
|
+
why = netguard.BUDGET.headroom()
|
|
85
|
+
if not why:
|
|
86
|
+
why = self.connections.full(ip)
|
|
87
|
+
if why:
|
|
88
|
+
netguard.BUDGET.note_refused("admin_ui")
|
|
89
|
+
why = f"too many connections to the admin UI ({why})"
|
|
90
|
+
if not why and self.ssl is not None:
|
|
91
|
+
why = netguard.BUDGET.handshake_room(ip)
|
|
92
|
+
if why or self.stopping():
|
|
93
|
+
if why:
|
|
94
|
+
self._refused(f"admin UI: refused {netguard.peer_label(transport)}: {why}")
|
|
95
|
+
transport.close()
|
|
96
|
+
return False
|
|
97
|
+
self.connections.add(transport, ip)
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
def begin_tls(self, transport: Any, protocol: asyncio.Protocol) -> None:
|
|
101
|
+
task = netguard.begin_tls(transport, self.ssl, protocol, netguard.peer_label(transport),
|
|
102
|
+
self._failed, lambda msg: self._refused(f"admin UI: {msg}"))
|
|
103
|
+
self._handshakes[transport] = task
|
|
104
|
+
task.add_done_callback(lambda _t: self._handshakes.pop(transport, None))
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
"""Shutting down: end the TLS handshakes still in progress (or waiting)."""
|
|
108
|
+
for transport, task in list(self._handshakes.items()):
|
|
109
|
+
transport.abort()
|
|
110
|
+
task.cancel()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Transport:
|
|
114
|
+
"""The transport uvicorn's protocol and its request cycles are given: the connection's
|
|
115
|
+
own, but a close() while the client is still sending the body of a request that was
|
|
116
|
+
answered already (``GuardedH11Protocol._body_coming``) only marks it closing; the rest
|
|
117
|
+
of the body is read and dropped, and the connection closed once it is in
|
|
118
|
+
(``GuardedH11Protocol.data_received``) or at the request deadline. A second close()
|
|
119
|
+
closes it at once (a shutdown)."""
|
|
120
|
+
|
|
121
|
+
def __init__(self, transport: Any, protocol: "GuardedH11Protocol") -> None:
|
|
122
|
+
self._transport = transport
|
|
123
|
+
self._protocol = protocol
|
|
124
|
+
self.lingering = False
|
|
125
|
+
|
|
126
|
+
def __getattr__(self, name: str) -> Any:
|
|
127
|
+
return getattr(self._transport, name)
|
|
128
|
+
|
|
129
|
+
def is_closing(self) -> bool:
|
|
130
|
+
return self.lingering or self._transport.is_closing()
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
if not self.lingering and self._protocol._body_coming():
|
|
134
|
+
self.lingering = True
|
|
135
|
+
return
|
|
136
|
+
self._transport.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class GuardedH11Protocol(H11Protocol):
|
|
140
|
+
"""H11Protocol with admission, TLS after admission, a request deadline and a send
|
|
141
|
+
deadline."""
|
|
142
|
+
|
|
143
|
+
guard: AdminGuard # set by protocol_class()
|
|
144
|
+
|
|
145
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
146
|
+
super().__init__(*args, **kwargs)
|
|
147
|
+
self._accepted: Any = None # the accepted socket's transport (TLS: the one under it)
|
|
148
|
+
self._served_transport: Any = None # the one uvicorn serves (TLS: the TLS one)
|
|
149
|
+
self._closing: Optional[_Transport] = None # what uvicorn is given in its place
|
|
150
|
+
self._served = False # H11Protocol.connection_made ran: uvicorn serves it
|
|
151
|
+
self._upgraded = False # a WebSocket now: not timed any more
|
|
152
|
+
# the request deadline: for its head ("head"), then for its body ("body"), whose
|
|
153
|
+
# deadline grows with each part of it that comes in (_body_since/_body_bytes)
|
|
154
|
+
self._request_timer: Optional[asyncio.TimerHandle] = None
|
|
155
|
+
self._waiting_for = ""
|
|
156
|
+
self._body_since = 0.0
|
|
157
|
+
self._body_bytes = 0
|
|
158
|
+
# the send deadline (_watch_send)
|
|
159
|
+
self._send_timer: Optional[asyncio.TimerHandle] = None
|
|
160
|
+
self._send_mark: Optional[tuple[int, int]] = None
|
|
161
|
+
self._resumed = 0 # how often the client took enough for writing to resume
|
|
162
|
+
|
|
163
|
+
def connection_made(self, transport: Any) -> None:
|
|
164
|
+
if self._accepted is not None: # the TLS transport, from netguard.start_tls
|
|
165
|
+
self._serve(transport)
|
|
166
|
+
return
|
|
167
|
+
if not self.guard.admit(transport):
|
|
168
|
+
return
|
|
169
|
+
self._accepted = transport
|
|
170
|
+
if self.guard.ssl is None:
|
|
171
|
+
self._serve(transport)
|
|
172
|
+
else:
|
|
173
|
+
self.guard.begin_tls(transport, self)
|
|
174
|
+
|
|
175
|
+
def _serve(self, transport: Any) -> None:
|
|
176
|
+
if self.guard.stopping():
|
|
177
|
+
transport.close()
|
|
178
|
+
return
|
|
179
|
+
self._served = True
|
|
180
|
+
super().connection_made(transport)
|
|
181
|
+
self._served_transport = transport
|
|
182
|
+
self._closing = _Transport(transport, self)
|
|
183
|
+
self.transport = cast(asyncio.Transport, self._closing) # (FlowControl keeps the real one)
|
|
184
|
+
self._follow_request(0, fresh=True)
|
|
185
|
+
|
|
186
|
+
def data_received(self, data: bytes) -> None:
|
|
187
|
+
if not self._served:
|
|
188
|
+
return
|
|
189
|
+
if self._lingering():
|
|
190
|
+
self._drop_body(data)
|
|
191
|
+
return
|
|
192
|
+
super().data_received(data)
|
|
193
|
+
self._follow_request(len(data))
|
|
194
|
+
|
|
195
|
+
def on_response_complete(self) -> None:
|
|
196
|
+
super().on_response_complete()
|
|
197
|
+
if self.transport.is_closing():
|
|
198
|
+
self._watch_send() # what is left of the response still has to go out
|
|
199
|
+
if self._lingering():
|
|
200
|
+
self.flow.resume_reading() # (uvicorn returns before this for a closing one)
|
|
201
|
+
self._follow_request(0, fresh=True) # the next request has the same time
|
|
202
|
+
|
|
203
|
+
def handle_websocket_upgrade(self, event: h11.Request) -> None:
|
|
204
|
+
self._upgraded = True
|
|
205
|
+
self._stop_timers()
|
|
206
|
+
self.transport = self._served_transport # the WebSocket protocol gets the real one
|
|
207
|
+
super().handle_websocket_upgrade(event)
|
|
208
|
+
|
|
209
|
+
def _lingering(self) -> bool:
|
|
210
|
+
return self._closing is not None and self._closing.lingering
|
|
211
|
+
|
|
212
|
+
def _body_coming(self) -> bool:
|
|
213
|
+
"""Whether the client is still sending the body of a request (that was answered:
|
|
214
|
+
uvicorn closes the connection after the answer). The wait ends with the body, with
|
|
215
|
+
the client's own close (one that sent "Expect: 100-continue" may send no body after
|
|
216
|
+
the answer) or at the request deadline: not without one."""
|
|
217
|
+
return (self._served and not self._upgraded and self.guard.request_timeout > 0
|
|
218
|
+
and self.conn.their_state is h11.SEND_BODY)
|
|
219
|
+
|
|
220
|
+
def _drop_body(self, data: bytes) -> None:
|
|
221
|
+
"""More of the body of an answered request on a closing connection: dropped; the
|
|
222
|
+
connection closes once the body is all in (or anything else comes)."""
|
|
223
|
+
try:
|
|
224
|
+
self.conn.receive_data(data)
|
|
225
|
+
while isinstance(event := self.conn.next_event(), h11.Data):
|
|
226
|
+
pass
|
|
227
|
+
except h11.RemoteProtocolError:
|
|
228
|
+
event = None
|
|
229
|
+
if event is h11.NEED_DATA:
|
|
230
|
+
self._follow_request(len(data))
|
|
231
|
+
else:
|
|
232
|
+
self._stop_request_timer()
|
|
233
|
+
self._served_transport.close()
|
|
234
|
+
|
|
235
|
+
def pause_writing(self) -> None:
|
|
236
|
+
super().pause_writing()
|
|
237
|
+
self._watch_send()
|
|
238
|
+
|
|
239
|
+
def resume_writing(self) -> None:
|
|
240
|
+
super().resume_writing()
|
|
241
|
+
self._resumed += 1
|
|
242
|
+
|
|
243
|
+
def connection_lost(self, exc: Optional[Exception]) -> None:
|
|
244
|
+
self._stop_timers()
|
|
245
|
+
if self._accepted is not None:
|
|
246
|
+
self.guard.connections.discard(self._accepted)
|
|
247
|
+
if self._served:
|
|
248
|
+
super().connection_lost(exc)
|
|
249
|
+
|
|
250
|
+
def _stop_timers(self) -> None:
|
|
251
|
+
self._stop_request_timer()
|
|
252
|
+
if self._send_timer is not None:
|
|
253
|
+
self._send_timer.cancel()
|
|
254
|
+
self._send_timer = None
|
|
255
|
+
|
|
256
|
+
def _abort(self) -> None:
|
|
257
|
+
if not self.transport.is_closing():
|
|
258
|
+
self.guard.timeouts += 1
|
|
259
|
+
# abort, not close: nothing more can be sent on time, and closing a TLS connection
|
|
260
|
+
# waits for the client's close_notify, which a silent client never sends (on Python
|
|
261
|
+
# 3.10 for good)
|
|
262
|
+
self.transport.abort()
|
|
263
|
+
|
|
264
|
+
# -- the request deadline ------------------------------------------------------------
|
|
265
|
+
def _follow_request(self, received: int, fresh: bool = False) -> None:
|
|
266
|
+
"""Set the request deadline for where the request is now (``received``: bytes
|
|
267
|
+
that just came in; ``fresh``: a new connection, or a response just ended)."""
|
|
268
|
+
if self._upgraded or self.guard.request_timeout <= 0:
|
|
269
|
+
return
|
|
270
|
+
state = self.conn.their_state
|
|
271
|
+
if state is h11.IDLE: # waiting for a request head, or part of one is in
|
|
272
|
+
if fresh or self._waiting_for != "head":
|
|
273
|
+
self._waiting_for = "head"
|
|
274
|
+
self._set_request_timer(self.loop.time() + self.guard.request_timeout)
|
|
275
|
+
elif state is h11.SEND_BODY: # the head is in, the body is coming
|
|
276
|
+
if self._waiting_for != "body":
|
|
277
|
+
self._waiting_for, self._body_since, self._body_bytes = \
|
|
278
|
+
"body", self.loop.time(), 0
|
|
279
|
+
else:
|
|
280
|
+
self._body_bytes += received
|
|
281
|
+
self._set_request_timer(self._body_since + self.guard.request_timeout
|
|
282
|
+
+ self._body_bytes / BODY_MIN_RATE)
|
|
283
|
+
else: # the whole request is in (or the connection is ending)
|
|
284
|
+
self._stop_request_timer()
|
|
285
|
+
|
|
286
|
+
def _set_request_timer(self, when: float) -> None:
|
|
287
|
+
if self._request_timer is not None:
|
|
288
|
+
self._request_timer.cancel()
|
|
289
|
+
self._request_timer = self.loop.call_at(when, self._request_expired)
|
|
290
|
+
|
|
291
|
+
def _stop_request_timer(self) -> None:
|
|
292
|
+
self._waiting_for = ""
|
|
293
|
+
if self._request_timer is not None:
|
|
294
|
+
self._request_timer.cancel()
|
|
295
|
+
self._request_timer = None
|
|
296
|
+
|
|
297
|
+
def _request_expired(self) -> None:
|
|
298
|
+
self._request_timer = None
|
|
299
|
+
if self._waiting_for == "body" and (self.flow.read_paused or
|
|
300
|
+
self.conn.they_are_waiting_for_100_continue):
|
|
301
|
+
# the server holds the body up, not the client: the app has not taken what
|
|
302
|
+
# came yet (uvicorn reads no more until it does), or the client waits for the
|
|
303
|
+
# "100 Continue" sent once the app reads; the rest gets the full time again
|
|
304
|
+
self._body_since = self.loop.time() - self._body_bytes / BODY_MIN_RATE
|
|
305
|
+
self._follow_request(0)
|
|
306
|
+
return
|
|
307
|
+
self._abort()
|
|
308
|
+
|
|
309
|
+
# -- the send deadline ---------------------------------------------------------------
|
|
310
|
+
def _watch_send(self) -> None:
|
|
311
|
+
"""Abort the connection when the client takes nothing of what it was sent for a
|
|
312
|
+
whole request_timeout_s. Judged only while nothing can be added to what is waiting
|
|
313
|
+
to go out (writing is paused until the client takes some, or the response is over
|
|
314
|
+
and the connection closing): if it did not shrink, the client took none of it."""
|
|
315
|
+
if self._send_timer is None and self.guard.request_timeout > 0 and not self._upgraded:
|
|
316
|
+
self._send_mark = self._send_state()
|
|
317
|
+
self._send_timer = self.loop.call_later(self.guard.request_timeout,
|
|
318
|
+
self._check_send)
|
|
319
|
+
|
|
320
|
+
def _send_state(self) -> Optional[tuple[int, int]]:
|
|
321
|
+
"""(times writing resumed, bytes waiting to go out) while nothing can be added to
|
|
322
|
+
them, else None."""
|
|
323
|
+
if not (self.flow.write_paused or self.transport.is_closing()):
|
|
324
|
+
return None
|
|
325
|
+
unsent = 0
|
|
326
|
+
for transport in {self._served_transport, self._accepted}: # TLS: both layers' buffers
|
|
327
|
+
with contextlib.suppress(Exception): # (one that is gone holds nothing)
|
|
328
|
+
unsent += transport.get_write_buffer_size()
|
|
329
|
+
return self._resumed, unsent
|
|
330
|
+
|
|
331
|
+
def _check_send(self) -> None:
|
|
332
|
+
self._send_timer = None
|
|
333
|
+
before, now = self._send_mark, self._send_state()
|
|
334
|
+
if now is None or not now[1]:
|
|
335
|
+
return # more may be written now, or all of it is out: a pause watches again
|
|
336
|
+
if before is not None and now[0] == before[0] and now[1] >= before[1]:
|
|
337
|
+
self._abort() # the client took nothing in all that time
|
|
338
|
+
return
|
|
339
|
+
self._send_mark = now
|
|
340
|
+
self._send_timer = self.loop.call_later(self.guard.request_timeout, self._check_send)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def protocol_class(guard: AdminGuard) -> type[GuardedH11Protocol]:
|
|
344
|
+
"""The http protocol class for uvicorn.Config(http=...), bound to ``guard``."""
|
|
345
|
+
return type("AdminH11Protocol", (GuardedH11Protocol,), {"guard": guard})
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def ssl_context(certfile: str, keyfile: str, config: Any, password: str = "") -> ssl.SSLContext:
|
|
349
|
+
"""The admin UI's TLS context, made as uvicorn makes it from ssl_certfile/ssl_keyfile
|
|
350
|
+
(its defaults, read from ``config``, a uvicorn.Config, for the rest), except that an
|
|
351
|
+
encrypted key is decrypted with ``password`` and never asked for on a terminal (uvicorn
|
|
352
|
+
leaves OpenSSL to prompt when it has no password): raises engine.tls.TlsFileError."""
|
|
353
|
+
ctx = ssl.SSLContext(config.ssl_version)
|
|
354
|
+
tls.load_cert_chain(ctx, certfile, keyfile, password)
|
|
355
|
+
ctx.verify_mode = ssl.VerifyMode(config.ssl_cert_reqs)
|
|
356
|
+
if config.ssl_ca_certs:
|
|
357
|
+
ctx.load_verify_locations(config.ssl_ca_certs)
|
|
358
|
+
if config.ssl_ciphers:
|
|
359
|
+
ctx.set_ciphers(config.ssl_ciphers)
|
|
360
|
+
return ctx
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def listen(host: str, port: int, backlog: int) -> list[socket.socket]:
|
|
364
|
+
"""The admin UI's sockets for ``host``:``port``, bound and listening, as asyncio's
|
|
365
|
+
create_server makes them for uvicorn ("" = every interface; every address ``host``
|
|
366
|
+
resolves to; SO_REUSEADDR on POSIX only, IPV6_V6ONLY on IPv6; an address the machine
|
|
367
|
+
has no such family for is skipped). Made before the mappings start, and listening at
|
|
368
|
+
once: on Linux two SO_REUSEADDR sockets can share a port until one of them listens, so
|
|
369
|
+
a mapping on the same address and port then fails instead of taking it. On Windows,
|
|
370
|
+
where a socket on a specific address may otherwise share the port of one on the
|
|
371
|
+
wildcard address (and take that address's connections), SO_EXCLUSIVEADDRUSE keeps
|
|
372
|
+
other programs off an overlapping address; netguard.reserve keeps this process's
|
|
373
|
+
mappings off it on every OS. Raises OSError when the address can't be used."""
|
|
374
|
+
loop = asyncio.get_running_loop()
|
|
375
|
+
infos = await loop.getaddrinfo(host or None, port, family=socket.AF_UNSPEC,
|
|
376
|
+
type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE)
|
|
377
|
+
sockets: list[socket.socket] = []
|
|
378
|
+
try:
|
|
379
|
+
for family, kind, proto, _name, address in dict.fromkeys(infos):
|
|
380
|
+
try:
|
|
381
|
+
sock = socket.socket(family, kind, proto)
|
|
382
|
+
except OSError: # a family this machine has no support for
|
|
383
|
+
continue
|
|
384
|
+
sockets.append(sock)
|
|
385
|
+
if os.name == "posix" and sys.platform != "cygwin":
|
|
386
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
387
|
+
elif sys.platform == "win32":
|
|
388
|
+
# (connections in TIME_WAIT don't keep a restart from taking the port
|
|
389
|
+
# again; ones of an earlier run still open or closing do, until they are
|
|
390
|
+
# gone: the start-up then retries as for a port another program holds)
|
|
391
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
|
392
|
+
if family == socket.AF_INET6 and hasattr(socket, "IPPROTO_IPV6"):
|
|
393
|
+
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
394
|
+
try:
|
|
395
|
+
sock.bind(address)
|
|
396
|
+
except OSError as e:
|
|
397
|
+
if e.errno == errno.EADDRNOTAVAIL and len(infos) > 1:
|
|
398
|
+
sockets.pop().close() # e.g. "localhost" -> ::1 without IPv6
|
|
399
|
+
continue
|
|
400
|
+
where = f"{str(address[0])} port {str(address[1])}"
|
|
401
|
+
raise OSError(e.errno, f"cannot listen on {where}: {e.strerror or e}") from None
|
|
402
|
+
if not sockets:
|
|
403
|
+
raise OSError(f"cannot listen on {host or '*'} port {port}: none of its addresses "
|
|
404
|
+
"is on this machine")
|
|
405
|
+
for sock in sockets:
|
|
406
|
+
sock.listen(backlog)
|
|
407
|
+
sock.setblocking(False)
|
|
408
|
+
netguard.reserve(sockets, "the admin UI")
|
|
409
|
+
except BaseException:
|
|
410
|
+
for sock in sockets:
|
|
411
|
+
sock.close()
|
|
412
|
+
raise
|
|
413
|
+
return sockets
|