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.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. 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