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,129 @@
|
|
|
1
|
+
"""Self-signed certificate for the admin UI (Settings → Generate self-signed certificate).
|
|
2
|
+
|
|
3
|
+
Made in-process with the `cryptography` package — bundled in the standalone
|
|
4
|
+
executables — so no external `openssl` program is needed (Windows has none, and
|
|
5
|
+
minimal Linux installs often lack it). A source install without cryptography
|
|
6
|
+
falls back to the openssl command if one is on PATH (by full path: find_openssl).
|
|
7
|
+
|
|
8
|
+
Its dates come from the system clock. The certificate is valid from a day before now
|
|
9
|
+
(clients whose clock is a little behind this host's accept it at once) for DAYS days in
|
|
10
|
+
all - not DAYS from now: a longer lifetime is refused by Apple's clients. The openssl
|
|
11
|
+
fallback can't back-date (only recent OpenSSL releases let `openssl req` set the start
|
|
12
|
+
date): its certificates start now.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import datetime
|
|
18
|
+
import ipaddress
|
|
19
|
+
import os
|
|
20
|
+
import socket
|
|
21
|
+
import subprocess
|
|
22
|
+
|
|
23
|
+
from ..i18n import _
|
|
24
|
+
|
|
25
|
+
DAYS = 825 # the longest lifetime browsers accept for a TLS server certificate
|
|
26
|
+
BACKDATE = datetime.timedelta(days=1) # valid from this long before it is made
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CertError(RuntimeError):
|
|
30
|
+
"""Neither cryptography nor a working openssl command is available."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate(cert_path: str, key_path: str, common_name: str = "ser2netweb", days: int = DAYS) -> str:
|
|
34
|
+
"""Write a self-signed RSA-2048 certificate and its unencrypted private key (PEM).
|
|
35
|
+
|
|
36
|
+
Returns how it was made: "cryptography" or "openssl". Raises CertError when
|
|
37
|
+
neither is available or openssl fails; OSError if the files can't be written."""
|
|
38
|
+
try:
|
|
39
|
+
return _with_cryptography(cert_path, key_path, common_name, days)
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
missing = CertError(_("this needs the Python package cryptography (pip install "
|
|
43
|
+
"cryptography) or the openssl program"))
|
|
44
|
+
openssl = find_openssl()
|
|
45
|
+
if openssl is None:
|
|
46
|
+
raise missing
|
|
47
|
+
try:
|
|
48
|
+
subprocess.run([openssl, "req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
49
|
+
"-keyout", key_path, "-out", cert_path, "-days", str(days),
|
|
50
|
+
"-subj", f"/CN={common_name}"],
|
|
51
|
+
check=True, capture_output=True, timeout=30)
|
|
52
|
+
except FileNotFoundError:
|
|
53
|
+
raise missing from None
|
|
54
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
55
|
+
raise CertError(f"openssl failed: {e}") from e
|
|
56
|
+
return "openssl"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def find_openssl() -> str | None:
|
|
60
|
+
"""The openssl program by full path, looked up in the absolute PATH entries only.
|
|
61
|
+
Windows searches an unqualified program name in the current directory (and the
|
|
62
|
+
program's own folder) before PATH, so an openssl.exe planted in the working
|
|
63
|
+
directory would otherwise run; a relative PATH entry does the same anywhere."""
|
|
64
|
+
name = "openssl.exe" if os.name == "nt" else "openssl"
|
|
65
|
+
for entry in os.environ.get("PATH", "").split(os.pathsep):
|
|
66
|
+
entry = entry.strip().strip('"')
|
|
67
|
+
if not entry or not os.path.isabs(entry):
|
|
68
|
+
continue
|
|
69
|
+
path = os.path.join(entry, name)
|
|
70
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
71
|
+
return path
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _with_cryptography(cert_path: str, key_path: str, common_name: str, days: int) -> str:
|
|
76
|
+
from cryptography import x509
|
|
77
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
78
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
79
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
80
|
+
|
|
81
|
+
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
82
|
+
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
|
|
83
|
+
alt_names: list[x509.GeneralName] = [
|
|
84
|
+
x509.DNSName("localhost"),
|
|
85
|
+
x509.IPAddress(ipaddress.ip_address("127.0.0.1")),
|
|
86
|
+
x509.IPAddress(ipaddress.ip_address("::1")),
|
|
87
|
+
]
|
|
88
|
+
host = socket.gethostname()
|
|
89
|
+
if host and host != "localhost" and host.isascii():
|
|
90
|
+
alt_names.insert(0, x509.DNSName(host))
|
|
91
|
+
start = datetime.datetime.now(datetime.timezone.utc) - BACKDATE
|
|
92
|
+
cert = (
|
|
93
|
+
x509.CertificateBuilder()
|
|
94
|
+
.subject_name(name)
|
|
95
|
+
.issuer_name(name)
|
|
96
|
+
.public_key(key.public_key())
|
|
97
|
+
.serial_number(x509.random_serial_number())
|
|
98
|
+
.not_valid_before(start)
|
|
99
|
+
.not_valid_after(start + datetime.timedelta(days=days)) # days in all, back-dating included
|
|
100
|
+
.add_extension(x509.SubjectAlternativeName(alt_names), critical=False)
|
|
101
|
+
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
|
|
102
|
+
.add_extension(x509.KeyUsage(digital_signature=True, key_encipherment=True,
|
|
103
|
+
content_commitment=False, data_encipherment=False,
|
|
104
|
+
key_agreement=False, key_cert_sign=False, crl_sign=False,
|
|
105
|
+
encipher_only=False, decipher_only=False), critical=True)
|
|
106
|
+
.add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), critical=False)
|
|
107
|
+
.add_extension(x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False)
|
|
108
|
+
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(key.public_key()),
|
|
109
|
+
critical=False)
|
|
110
|
+
.sign(key, hashes.SHA256())
|
|
111
|
+
)
|
|
112
|
+
key_pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
113
|
+
serialization.NoEncryption())
|
|
114
|
+
# both files are written aside first, then swapped in: a failure never leaves a
|
|
115
|
+
# new key next to the old certificate (TLS would refuse to start with that pair)
|
|
116
|
+
_write_aside(key_path, key_pem, mode=0o600)
|
|
117
|
+
_write_aside(cert_path, cert.public_bytes(serialization.Encoding.PEM), mode=0o644)
|
|
118
|
+
os.replace(key_path + ".tmp", key_path)
|
|
119
|
+
os.replace(cert_path + ".tmp", cert_path)
|
|
120
|
+
return "cryptography"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_aside(path: str, data: bytes, mode: int) -> None:
|
|
124
|
+
tmp = path + ".tmp"
|
|
125
|
+
with contextlib.suppress(FileNotFoundError):
|
|
126
|
+
os.remove(tmp) # O_CREAT keeps an existing file's mode; start fresh
|
|
127
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), mode)
|
|
128
|
+
with os.fdopen(fd, "wb") as fh:
|
|
129
|
+
fh.write(data)
|
ser2netweb/web/server.py
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""Starlette application factory: routes + security/auth/CSRF middleware.
|
|
2
|
+
|
|
3
|
+
The app is built around a shared AppState. Auth and CSRF are enforced in one
|
|
4
|
+
middleware so every route is protected uniformly: while no password is set, all
|
|
5
|
+
traffic is funneled to /setup; afterwards a valid session cookie is required for
|
|
6
|
+
everything except the login/setup/static endpoints — unless an admin switched the
|
|
7
|
+
login requirement off, in which case a visitor without a session acts as the
|
|
8
|
+
anonymous pseudo-user (see auth.anonymous_user) and role checks still apply.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import functools
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from typing import TYPE_CHECKING
|
|
19
|
+
|
|
20
|
+
from markupsafe import Markup, escape
|
|
21
|
+
from starlette.applications import Starlette
|
|
22
|
+
from starlette.middleware import Middleware
|
|
23
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
24
|
+
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
|
|
25
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
26
|
+
from starlette.templating import Jinja2Templates
|
|
27
|
+
|
|
28
|
+
from .. import __version__, clock, i18n, licences
|
|
29
|
+
from ..config import ROLE_RANK, node_name
|
|
30
|
+
from ..engine import netguard
|
|
31
|
+
from ..explain import explain
|
|
32
|
+
from ..textfold import search_key
|
|
33
|
+
from . import auth, proxy
|
|
34
|
+
from .routes import build_routes
|
|
35
|
+
from .routes.common import bytesize, client_ip, domid, duration, since
|
|
36
|
+
from .routes.forms import in_text_box
|
|
37
|
+
|
|
38
|
+
if TYPE_CHECKING:
|
|
39
|
+
from ..state import AppState
|
|
40
|
+
|
|
41
|
+
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
|
42
|
+
STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
|
|
43
|
+
|
|
44
|
+
_SOFT_BREAK_AFTER = re.compile(r"([/\\_.:@-])")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def softbreak(value) -> Markup:
|
|
48
|
+
"""Escape *value* and allow a line break after / \\ _ . : @ - so a long device
|
|
49
|
+
path or name wraps at a separator instead of mid-word (<wbr> adds no text,
|
|
50
|
+
so copy/paste is unchanged). Safe: the separators never occur in entities."""
|
|
51
|
+
return Markup(_SOFT_BREAK_AFTER.sub(r"\1<wbr>", str(escape(value)))) # noqa: S704 - escaped
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
PUBLIC_PREFIXES = ("/static", "/auth/", "/lang/") # /auth/oidc/*: the SSO flow; /lang/xx: UI language
|
|
55
|
+
# The peer-facing cluster endpoints are authenticated by the shared cluster KEY
|
|
56
|
+
# (checked in the handler), not a user session — so they skip the session gate.
|
|
57
|
+
# (The *-peer / peer-* proxies are browser-facing and keep the normal session + CSRF.)
|
|
58
|
+
_CLUSTER_PEER_PATHS = {"/api/cluster/local", "/api/cluster/control",
|
|
59
|
+
"/api/cluster/mapping-data", "/api/cluster/mapping-save"}
|
|
60
|
+
PUBLIC_PATHS = {"/login", "/setup", "/healthz", "/favicon.ico"} | _CLUSTER_PEER_PATHS
|
|
61
|
+
|
|
62
|
+
# A request body larger than this is refused (413) before a handler reads it. A route can
|
|
63
|
+
# set its own limit: the sign-in forms a smaller one, the configuration import a larger.
|
|
64
|
+
MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
65
|
+
|
|
66
|
+
# High-frequency automatic UI refreshes — not logged to all.log to avoid flooding
|
|
67
|
+
# the audit trail (the user actions that drive them ARE logged).
|
|
68
|
+
QUIET_PATHS = {"/api/status", "/api/cluster/status", "/api/cluster/local",
|
|
69
|
+
"/api/ports/table", "/api/ports.json", "/healthz", "/favicon.ico"}
|
|
70
|
+
# ... and an open log panel's, every 5 s
|
|
71
|
+
QUIET_PATTERN = re.compile(r"/api/mappings/[^/]+/log")
|
|
72
|
+
# The REST API's counterpart: what monitoring polls. Every other /api/v1 request (any
|
|
73
|
+
# change, a read of the mappings — they hold the MQTT passwords — and any refusal) is.
|
|
74
|
+
API_QUIET_PATHS = {"/api/v1/health", "/api/v1/status", "/api/v1/ports", "/api/v1/metrics",
|
|
75
|
+
"/api/v1/openapi.json"}
|
|
76
|
+
# What monitoring polls in the web UI: not logged while it is answered (a refusal or an
|
|
77
|
+
# error is), and a client that is not a browser is refused with 401, not sent to the
|
|
78
|
+
# sign-in page (_monitoring_refusal)
|
|
79
|
+
MONITORING_PATHS = {"/metrics"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def logs_request(mode: str, method: str, path: str, status: int) -> bool:
|
|
83
|
+
"""Whether a web UI request gets its "HTTP ..." line in all.log (admin_ui.request_log):
|
|
84
|
+
"all" every request but the static files, the automatic refreshes (QUIET_PATHS,
|
|
85
|
+
QUIET_PATTERN) and the answered scrapes of MONITORING_PATHS; "changes" only those that
|
|
86
|
+
change something (not GET / HEAD / OPTIONS) and the refused or failed ones (400 and
|
|
87
|
+
up); "off" none."""
|
|
88
|
+
if path.startswith("/static") or mode == "off":
|
|
89
|
+
return False
|
|
90
|
+
if mode == "changes":
|
|
91
|
+
return method not in auth.SAFE_METHODS or status >= 400
|
|
92
|
+
return not (method == "GET" and (path in QUIET_PATHS or QUIET_PATTERN.fullmatch(path)
|
|
93
|
+
or (path in MONITORING_PATHS and status < 400)))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wants_json(request) -> bool:
|
|
97
|
+
return proxy.route_path(request).startswith("/api")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _login_url(request) -> str:
|
|
101
|
+
"""The sign-in page for a page request without a session, as proxy.redirect takes it
|
|
102
|
+
(a path under the base path): with ?next= (where the sign-in leads back to: the page's
|
|
103
|
+
path without the base path, then also given to proxy.redirect) for a page other than
|
|
104
|
+
the dashboard; a POST (a form whose session ended) goes back to the dashboard."""
|
|
105
|
+
path = proxy.route_path(request)
|
|
106
|
+
if request.method != "GET" or path == "/":
|
|
107
|
+
return "/login"
|
|
108
|
+
here = path + ("?" + request.url.query if request.url.query else "")
|
|
109
|
+
return "/login?next=" + urllib.parse.quote(here, safe="")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _monitoring_refusal(request, path: str):
|
|
113
|
+
"""The answer for MONITORING_PATHS without a session to a client that is not a browser
|
|
114
|
+
(it sends credentials, or doesn't ask for HTML): 401, which a Prometheus scrape reports
|
|
115
|
+
as such, instead of the redirect to the sign-in page, which it follows and then can't
|
|
116
|
+
parse. None for a browser (and every other path): it goes to the sign-in page."""
|
|
117
|
+
if path not in MONITORING_PATHS:
|
|
118
|
+
return None
|
|
119
|
+
if "authorization" not in request.headers and \
|
|
120
|
+
"text/html" in request.headers.get("accept", ""):
|
|
121
|
+
return None
|
|
122
|
+
return PlainTextResponse("unauthorized: sign in to the web UI, or read /api/v1/metrics "
|
|
123
|
+
"with a REST API token\n", status_code=401,
|
|
124
|
+
headers={"WWW-Authenticate": 'Bearer realm="ser2netweb"'})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _answer(request, call_next, state):
|
|
128
|
+
"""The handler's answer. One that failed with an exception (answered 500 by
|
|
129
|
+
server_error) is logged here, as the request line it would have had; its traceback
|
|
130
|
+
reaches all.log through uvicorn's error log (logsetup.LibraryLogHandler)."""
|
|
131
|
+
try:
|
|
132
|
+
return await call_next(request)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
ip = client_ip(request)
|
|
135
|
+
state.log(f"HTTP {ip} {request.method} {proxy.route_path(request)} -> 500 "
|
|
136
|
+
f"({type(e).__name__}: {e})")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class GuardMiddleware(BaseHTTPMiddleware):
|
|
141
|
+
"""Single gate: cross-site refusal + CSRF + setup-redirect + session auth + security
|
|
142
|
+
headers + csrf cookie."""
|
|
143
|
+
|
|
144
|
+
_cross_site_log: netguard.LogEvery | None = None
|
|
145
|
+
|
|
146
|
+
def _cross_site_note(self, state):
|
|
147
|
+
"""Refused cross-site requests are logged at most once a minute."""
|
|
148
|
+
if self._cross_site_log is None:
|
|
149
|
+
self._cross_site_log = netguard.LogEvery(lambda msg: state.log(msg))
|
|
150
|
+
return self._cross_site_log
|
|
151
|
+
|
|
152
|
+
async def dispatch(self, request, call_next):
|
|
153
|
+
state = request.app.state.appstate
|
|
154
|
+
cfg = state.config
|
|
155
|
+
# the path the UI's routes see: without the public URL's path when the request came
|
|
156
|
+
# under it (proxy.ProxyMiddleware), so every check below holds there too
|
|
157
|
+
path = proxy.route_path(request)
|
|
158
|
+
|
|
159
|
+
# JSON REST API (/api/v1): bearer-token auth, no cookie session, no CSRF — and
|
|
160
|
+
# English, like the logs (only the web UI is translated).
|
|
161
|
+
if path.startswith("/api/v1"):
|
|
162
|
+
response = await self._dispatch_api(request, call_next, cfg, path)
|
|
163
|
+
self._security_headers(response, state, path, request)
|
|
164
|
+
return response
|
|
165
|
+
|
|
166
|
+
lang = i18n.negotiate(auth.cookie(request, i18n.COOKIE),
|
|
167
|
+
request.headers.get("accept-language"), cfg.admin_ui.language)
|
|
168
|
+
request.state.lang = lang
|
|
169
|
+
token = i18n.set_language(lang)
|
|
170
|
+
try:
|
|
171
|
+
response = await self._dispatch_ui(request, call_next, state, cfg, path)
|
|
172
|
+
finally:
|
|
173
|
+
i18n.reset(token)
|
|
174
|
+
self._security_headers(response, state, path, request) # also on a refusal or a redirect
|
|
175
|
+
return response
|
|
176
|
+
|
|
177
|
+
async def _dispatch_ui(self, request, call_next, state, cfg, path):
|
|
178
|
+
ip = client_ip(request)
|
|
179
|
+
early = self._gate_ui(request, state, cfg, path)
|
|
180
|
+
if early is not None: # refused or sent elsewhere before any handler ran
|
|
181
|
+
# anyone can cause these as often as they like: one line per address and status
|
|
182
|
+
# a minute (state.log_refused); "changes" keeps the refusals, "off" none of them
|
|
183
|
+
mode = cfg.admin_ui.request_log
|
|
184
|
+
if mode == "all" or logs_request(mode, request.method, path, early.status_code):
|
|
185
|
+
state.log_refused((ip, early.status_code),
|
|
186
|
+
f"HTTP {ip} {request.method} {path} -> {early.status_code}")
|
|
187
|
+
return early
|
|
188
|
+
|
|
189
|
+
response = await _answer(request, call_next, state)
|
|
190
|
+
|
|
191
|
+
# all.log: every state-changing request, plus meaningful page loads (skip the
|
|
192
|
+
# static assets and the periodic poll endpoints), or less (admin_ui.request_log)
|
|
193
|
+
if logs_request(cfg.admin_ui.request_log, request.method, path, response.status_code):
|
|
194
|
+
state.log(f"HTTP {ip} {request.method} {path} -> {response.status_code}")
|
|
195
|
+
|
|
196
|
+
# The CSRF cookie lasts as long as a session: a browser restart that keeps the
|
|
197
|
+
# session cookie keeps it too, so there is no time without one in which a page of
|
|
198
|
+
# another site could put its own token into the browser.
|
|
199
|
+
if (request.state.csrf_token != request.state.csrf_cookie
|
|
200
|
+
or getattr(request.state, "csrf_renew", False)):
|
|
201
|
+
auth.set_cookie(response, request, cfg, auth.CSRF_COOKIE, request.state.csrf_token,
|
|
202
|
+
max_age=cfg.session_timeout_s)
|
|
203
|
+
# A page whose token is no longer the browser's (a sign-in in another tab gave the
|
|
204
|
+
# browser a new one: Ctx.new_csrf_token) gets the current one with the answer to its
|
|
205
|
+
# next htmx request, which app.js takes, so a form left open while the session ended
|
|
206
|
+
# can still be saved after signing in again. Only the page's own requests can read
|
|
207
|
+
# an answer's headers, as they can read its <meta name="csrf-token">.
|
|
208
|
+
if (request.headers.get("hx-request") == "true"
|
|
209
|
+
and request.headers.get("x-csrf-token") != request.state.csrf_token):
|
|
210
|
+
response.headers["X-CSRF-Token"] = request.state.csrf_token
|
|
211
|
+
if request.state.legacy_session is not None:
|
|
212
|
+
auth.move_session(response, request, cfg, request.state.legacy_session)
|
|
213
|
+
return response
|
|
214
|
+
|
|
215
|
+
def _gate_ui(self, request, state, cfg, path):
|
|
216
|
+
"""The answer for a UI request that may not reach its handler (a request of another
|
|
217
|
+
site, a CSRF failure, first-run setup, no session), or None. Sets
|
|
218
|
+
request.state.csrf_token (csrf_cookie: the one the browser sent under this
|
|
219
|
+
instance's name, or None; legacy_session: a 2.8 session cookie to move) and, for a
|
|
220
|
+
signed-in request, request.state.user."""
|
|
221
|
+
is_public = path in PUBLIC_PATHS or any(path.startswith(p) for p in PUBLIC_PREFIXES)
|
|
222
|
+
|
|
223
|
+
# A state-changing request that a page of another site made the browser send is
|
|
224
|
+
# refused before anything else (auth.cross_site): another port of this host or a
|
|
225
|
+
# sibling subdomain can put a CSRF cookie of its own into the browser, so the token
|
|
226
|
+
# alone doesn't prove the request came from this UI. Cluster peers call from their
|
|
227
|
+
# server, not a browser, and are checked by the key.
|
|
228
|
+
if request.method not in auth.SAFE_METHODS and path not in _CLUSTER_PEER_PATHS:
|
|
229
|
+
if why := auth.cross_site(request.headers, also=proxy.published_origins(cfg.admin_ui)):
|
|
230
|
+
ip = request.client.host if request.client else "?"
|
|
231
|
+
self._cross_site_note(state)(f"refused {request.method} {path} from {ip}: "
|
|
232
|
+
f"sent by a page of another site ({why}); "
|
|
233
|
+
f"{auth.PROXY_HINT}")
|
|
234
|
+
return PlainTextResponse(i18n.gettext(
|
|
235
|
+
"Refused: this request was sent by a page of another web site. If you used "
|
|
236
|
+
"ser2netweb’s own page, its address must reach ser2netweb unchanged (behind a "
|
|
237
|
+
"reverse proxy: as Host or X-Forwarded-Host)."), status_code=403)
|
|
238
|
+
|
|
239
|
+
# the CSRF token of this browser: its cookie's (one under the name of 2.8 and older
|
|
240
|
+
# is taken over, and set under its name now), or a new one. A handler may issue a
|
|
241
|
+
# new one (Ctx.new_csrf_token: a sign-in, a sign-out) or renew the cookie's lifetime
|
|
242
|
+
# with a session's (Ctx.set_session); the cookie is set in _dispatch_ui.
|
|
243
|
+
csrf_cookie = auth.cookie(request, auth.CSRF_COOKIE, legacy=False)
|
|
244
|
+
request.state.csrf_cookie = csrf_cookie
|
|
245
|
+
request.state.csrf_token = (csrf_cookie or auth.cookie(request, auth.CSRF_COOKIE)
|
|
246
|
+
or auth.new_csrf_token())
|
|
247
|
+
# a sign-in from before the upgrade, under the session cookie's old name: moved to
|
|
248
|
+
# its name now with this response (not a static file's, which may be cached)
|
|
249
|
+
request.state.legacy_session = (
|
|
250
|
+
None if path.startswith("/static")
|
|
251
|
+
else auth.legacy_session(request, cfg, state.revoked_sessions))
|
|
252
|
+
|
|
253
|
+
# CSRF: API/HTMX endpoints must carry a matching X-CSRF-Token header.
|
|
254
|
+
# Plain HTML form posts (login/setup/settings/logout) carry _csrf in the
|
|
255
|
+
# body and are validated inside their handlers — the body must NOT be read
|
|
256
|
+
# here, since doing so in a BaseHTTPMiddleware drains the stream before the
|
|
257
|
+
# route handler can parse the form.
|
|
258
|
+
if request.method in ("POST", "PUT", "DELETE", "PATCH") and path.startswith("/api/") \
|
|
259
|
+
and path not in _CLUSTER_PEER_PATHS: # peer-to-peer calls: key-authed, no cookie
|
|
260
|
+
if not auth.csrf_token_matches(request, request.headers.get("x-csrf-token")):
|
|
261
|
+
return PlainTextResponse(i18n.gettext("CSRF validation failed. Reload the page."),
|
|
262
|
+
status_code=403)
|
|
263
|
+
|
|
264
|
+
# first-run: force password setup (the health probe still answers, so a fresh
|
|
265
|
+
# container isn't reported unhealthy while it waits for its admin password)
|
|
266
|
+
if not cfg.password_set:
|
|
267
|
+
if path not in ("/setup", "/healthz") and not path.startswith(("/static", "/lang/")):
|
|
268
|
+
return proxy.redirect(request, "/setup")
|
|
269
|
+
else:
|
|
270
|
+
# password is set: /setup is no longer available
|
|
271
|
+
if path == "/setup":
|
|
272
|
+
return proxy.redirect(request, "/")
|
|
273
|
+
if not is_public:
|
|
274
|
+
token = auth.cookie(request, auth.SESSION_COOKIE)
|
|
275
|
+
user = auth.request_user(cfg, token, request.headers, state.revoked_sessions)
|
|
276
|
+
if user is None:
|
|
277
|
+
if _wants_json(request):
|
|
278
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401)
|
|
279
|
+
if (refused := _monitoring_refusal(request, path)) is not None:
|
|
280
|
+
return refused # a scraper: 401 (a browser goes to the sign-in page)
|
|
281
|
+
return proxy.redirect(request, _login_url(request))
|
|
282
|
+
request.state.user = user # available to handlers for role checks
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
async def _dispatch_api(self, request, call_next, cfg, path):
|
|
286
|
+
"""Gate for the JSON REST API: bearer-token auth, no cookies/CSRF, JSON errors.
|
|
287
|
+
The token found (config.ApiToken) is request.state.api_token: its role and its
|
|
288
|
+
name for the audit log. Requests are logged to all.log (but the successful polls
|
|
289
|
+
of API_QUIET_PATHS), refusals one line per address and kind a minute; one refused
|
|
290
|
+
for its token is recorded in the audit log too (api_auth_failed, bounded)."""
|
|
291
|
+
state = request.app.state.appstate
|
|
292
|
+
ip = client_ip(request)
|
|
293
|
+
early, token = self._gate_api(request, cfg, state, path)
|
|
294
|
+
if early is not None:
|
|
295
|
+
by = f" by {token.audit_label}" if token is not None else ""
|
|
296
|
+
state.log_refused((ip, early.status_code),
|
|
297
|
+
f"HTTP {ip} {request.method} {path} -> {early.status_code}{by}")
|
|
298
|
+
return early
|
|
299
|
+
response = await _answer(request, call_next, state)
|
|
300
|
+
if not (request.method in ("GET", "HEAD") and response.status_code < 400
|
|
301
|
+
and path in API_QUIET_PATHS):
|
|
302
|
+
by = f" by {token.audit_label}" if token is not None else ""
|
|
303
|
+
state.log(f"HTTP {ip} {request.method} {path} -> {response.status_code}{by}")
|
|
304
|
+
return response
|
|
305
|
+
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _gate_api(request, cfg, state, path):
|
|
308
|
+
"""(the answer for a REST API request that may not reach its handler, or None;
|
|
309
|
+
the API token it presented, also an expired one, or None)."""
|
|
310
|
+
if path in ("/api/v1/health", "/api/v1/openapi.json"):
|
|
311
|
+
return None, None
|
|
312
|
+
if not cfg.password_set:
|
|
313
|
+
return JSONResponse({"error": "setup_incomplete"}, status_code=503), None
|
|
314
|
+
if not cfg.api_token_hash and not cfg.api_tokens:
|
|
315
|
+
return JSONResponse({"error": "api_token_not_configured"}, status_code=401), None
|
|
316
|
+
given = auth.bearer_token(request)
|
|
317
|
+
token = auth.find_api_token(cfg, given)
|
|
318
|
+
if token is None or token.expired():
|
|
319
|
+
ip = client_ip(request)
|
|
320
|
+
why = ("expired token" if token is not None else
|
|
321
|
+
"wrong token" if given else "no token")
|
|
322
|
+
state.api_auth_failures.note(ip, token.audit_label if token is not None else "",
|
|
323
|
+
"api_auth_failed",
|
|
324
|
+
f"{request.method} {path[:200]}: {why}", key=(ip,))
|
|
325
|
+
if token is None:
|
|
326
|
+
return JSONResponse({"error": "unauthorized"}, status_code=401,
|
|
327
|
+
headers={"WWW-Authenticate": "Bearer"}), None
|
|
328
|
+
if token.expired():
|
|
329
|
+
return JSONResponse({"error": "unauthorized", "detail": "API token expired"},
|
|
330
|
+
status_code=401,
|
|
331
|
+
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'}), token
|
|
332
|
+
request.state.api_token = token
|
|
333
|
+
used = getattr(state, "api_token_used", None)
|
|
334
|
+
if used is not None: # in memory only (AppState.api_token_used)
|
|
335
|
+
used[token.id] = time.time()
|
|
336
|
+
# a 'viewer' token is read-only: block state-changing methods
|
|
337
|
+
if request.method in ("POST", "PUT", "DELETE", "PATCH") and \
|
|
338
|
+
ROLE_RANK.get(token.role, 0) < ROLE_RANK["operator"]:
|
|
339
|
+
return JSONResponse(
|
|
340
|
+
{"error": "forbidden", "detail": "API token role 'viewer' is read-only"},
|
|
341
|
+
status_code=403), token
|
|
342
|
+
return None, token
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _security_headers(response, state, path: str, request=None) -> None:
|
|
346
|
+
# Nothing but the static files may be stored by the browser: pages show a new API
|
|
347
|
+
# token or cluster key once, the mapping form an MQTT password, a download a
|
|
348
|
+
# backup or the export, and a copy in the disk cache or the back/forward cache
|
|
349
|
+
# outlives the session (Back after a logout on a shared PC). The static files
|
|
350
|
+
# carry ?v=<version> and stay cacheable.
|
|
351
|
+
cfg = state.config
|
|
352
|
+
if not path.startswith("/static"):
|
|
353
|
+
response.headers.setdefault("Cache-Control", "no-store")
|
|
354
|
+
# script-src stays 'self' (no inline JS — the important XSS guard). style-src
|
|
355
|
+
# allows 'unsafe-inline' because the console's xterm.js DOM renderer adds <style>
|
|
356
|
+
# elements and style attributes at runtime, without a nonce (xtermjs/xterm.js#4445).
|
|
357
|
+
# No <base>, no plugins, and forms only post to this UI.
|
|
358
|
+
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
359
|
+
response.headers.setdefault("X-Frame-Options", "DENY")
|
|
360
|
+
response.headers.setdefault("Referrer-Policy", "same-origin")
|
|
361
|
+
response.headers.setdefault(
|
|
362
|
+
"Content-Security-Policy",
|
|
363
|
+
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
|
364
|
+
"script-src 'self'; connect-src 'self'; frame-ancestors 'none'; "
|
|
365
|
+
"base-uri 'none'; form-action 'self'; object-src 'none'",
|
|
366
|
+
)
|
|
367
|
+
# HSTS covers the host name on every port (Settings -> Admin TLS sets how long; 0
|
|
368
|
+
# sends max-age=0, which makes browsers forget it). Behind a TLS proxy only when
|
|
369
|
+
# asked for (Settings -> Reverse proxy): a direct http://name:port would stop working.
|
|
370
|
+
# TLS: what this listener serves (AppState.serving_tls), not what waits for a restart
|
|
371
|
+
if state.serving_tls or (cfg.admin_ui.proxy_hsts and request is not None
|
|
372
|
+
and auth.is_https(request, cfg)):
|
|
373
|
+
response.headers.setdefault("Strict-Transport-Security",
|
|
374
|
+
f"max-age={cfg.admin_ui.hsts_max_age}")
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _page_context(request) -> dict:
|
|
378
|
+
"""What every template gets: base, the path prefix of the request, which its links go
|
|
379
|
+
under (ser2netweb/web/proxy.py); node_name, this instance's name ('' = none: Settings -> Admin
|
|
380
|
+
interface), which the signed-in pages (_shell.html) show in the title and the top bar;
|
|
381
|
+
login_notice / login_notice_url, the notice the sign-in and setup pages show (every one
|
|
382
|
+
of their renders, also a refusal's) and open-access visitors get a link to (Settings ->
|
|
383
|
+
Web UI access)."""
|
|
384
|
+
state = getattr(request.app.state, "appstate", None)
|
|
385
|
+
ui = state.config.admin_ui if state is not None else None
|
|
386
|
+
return {"base": proxy.base(request),
|
|
387
|
+
"node_name": node_name(ui) if ui is not None else "",
|
|
388
|
+
"login_notice": ui.login_notice if ui is not None else "",
|
|
389
|
+
"login_notice_url": ui.login_notice_url if ui is not None else ""}
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@functools.lru_cache(maxsize=None)
|
|
393
|
+
def asset_version(static_dir: str = STATIC_DIR) -> str:
|
|
394
|
+
"""A short hash of every file under static/ (names and contents)."""
|
|
395
|
+
h = hashlib.sha256()
|
|
396
|
+
for root, dirs, files in os.walk(static_dir):
|
|
397
|
+
dirs.sort()
|
|
398
|
+
for name in sorted(files):
|
|
399
|
+
path = os.path.join(root, name)
|
|
400
|
+
h.update(os.path.relpath(path, static_dir).replace(os.sep, "/").encode() + b"\0")
|
|
401
|
+
with open(path, "rb") as fh:
|
|
402
|
+
h.update(fh.read())
|
|
403
|
+
h.update(b"\0")
|
|
404
|
+
return h.hexdigest()[:12]
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def make_templates() -> Jinja2Templates:
|
|
408
|
+
"""The Jinja environment every page is rendered with (globals + filters)."""
|
|
409
|
+
# _page_context for every page; base_path(request) / route_path(request) for macros
|
|
410
|
+
# imported without the context
|
|
411
|
+
templates = Jinja2Templates(directory=TEMPLATES_DIR, context_processors=[_page_context])
|
|
412
|
+
|
|
413
|
+
# template globals used across pages
|
|
414
|
+
from ..config import (
|
|
415
|
+
COMMON_BAUDRATES, BYTESIZES, PARITIES, PARITY_LABELS, STOPBITS,
|
|
416
|
+
FLOWCONTROLS, PROTOCOLS, LINE_STATES, host_port, identity_text, shown_host,
|
|
417
|
+
)
|
|
418
|
+
templates.env.globals.update(
|
|
419
|
+
baudrates=COMMON_BAUDRATES, bytesizes=BYTESIZES, parities=PARITIES,
|
|
420
|
+
parity_labels=PARITY_LABELS, stopbits=STOPBITS, flowcontrols=FLOWCONTROLS,
|
|
421
|
+
protocols=PROTOCOLS, line_states=LINE_STATES, identity_text=identity_text,
|
|
422
|
+
host_port=host_port, shown_host=shown_host,
|
|
423
|
+
# ?v=<hash of the static files> on the asset URLs: browsers fetch the new CSS/JS as
|
|
424
|
+
# soon as an asset changes (an upgrade, a build from main) and may keep it for good
|
|
425
|
+
# until then (StaticAssets), also across restarts of the one-file executable
|
|
426
|
+
asset_version=asset_version(),
|
|
427
|
+
app_version=__version__, # what the pages name as this version
|
|
428
|
+
source_url=licences.source_url(), # the footer's source link (SER2NETWEB_SOURCE_URL)
|
|
429
|
+
# the server's time zone now ("UTC+03:00"), next to the times it shows
|
|
430
|
+
server_zone=clock.zone,
|
|
431
|
+
# the server's clock when it rendered the page (<body data-server-time>): app.js
|
|
432
|
+
# compares it with the browser's (not the Date header: a reverse proxy sets its own)
|
|
433
|
+
server_now=lambda: int(time.time()),
|
|
434
|
+
)
|
|
435
|
+
templates.env.filters["softbreak"] = softbreak
|
|
436
|
+
from ..engine.portlist import choice as port_choice
|
|
437
|
+
templates.env.globals["port_choice"] = port_choice
|
|
438
|
+
templates.env.filters["domid"] = domid
|
|
439
|
+
templates.env.filters["bytesize"] = bytesize
|
|
440
|
+
templates.env.filters["duration"] = duration # seconds, in the page's language
|
|
441
|
+
templates.env.filters["since"] = since # a Unix time until now, as duration
|
|
442
|
+
# what a raw OS / network error means, in the page's language (None: show it as it is)
|
|
443
|
+
templates.env.filters["explain"] = explain
|
|
444
|
+
# data-search of the dashboard rows: app.js folds what is typed the same way
|
|
445
|
+
templates.env.filters["searchkey"] = search_key
|
|
446
|
+
templates.env.filters["in_text_box"] = in_text_box # a value as a text box sends it
|
|
447
|
+
from ..notify import no_data_for
|
|
448
|
+
templates.env.globals["no_data_for"] = no_data_for # a mapping's no-data alarm, now
|
|
449
|
+
# _("...") in every template, translated for the request's language (ser2netweb/i18n.py);
|
|
450
|
+
# newstyle: _("Hello %(name)s", name=x) formats and escapes x
|
|
451
|
+
templates.env.add_extension("jinja2.ext.i18n")
|
|
452
|
+
templates.env.install_gettext_callables(i18n.gettext, i18n.ngettext, newstyle=True) # type: ignore[attr-defined]
|
|
453
|
+
templates.env.globals["languages"] = i18n.LANGUAGES
|
|
454
|
+
templates.env.globals.update(base_path=proxy.base, route_path=proxy.route_path)
|
|
455
|
+
return templates
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def server_error(request, exc) -> PlainTextResponse | JSONResponse:
|
|
459
|
+
"""The answer to a request that failed with an unexpected exception (the server logs
|
|
460
|
+
its traceback): JSON under the REST API, whose clients expect nothing else, else the
|
|
461
|
+
usual plain text."""
|
|
462
|
+
if request.url.path.startswith("/api/v1"):
|
|
463
|
+
return JSONResponse({"error": "internal", "detail": "internal server error"},
|
|
464
|
+
status_code=500)
|
|
465
|
+
return PlainTextResponse("Internal Server Error", status_code=500)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def build_app(state: AppState) -> Starlette:
|
|
469
|
+
templates = make_templates()
|
|
470
|
+
routes = build_routes(templates, state, STATIC_DIR)
|
|
471
|
+
# The body limit holds for every route, and sits inside GuardMiddleware: in front of
|
|
472
|
+
# it (Starlette(max_body_size=...)), a body over the limit would reach the handler as
|
|
473
|
+
# an exception group from BaseHTTPMiddleware's receive, a 500 instead of the 413.
|
|
474
|
+
# ProxyMiddleware goes first: the client address, the scheme and the path prefix of a
|
|
475
|
+
# request through a reverse proxy are what every check after it sees
|
|
476
|
+
middleware = [Middleware(proxy.ProxyMiddleware, state=state),
|
|
477
|
+
Middleware(GuardMiddleware),
|
|
478
|
+
Middleware(RequestBodyLimitMiddleware, max_body_size=MAX_BODY_BYTES)]
|
|
479
|
+
app = Starlette(routes=routes, middleware=middleware,
|
|
480
|
+
exception_handlers={Exception: server_error})
|
|
481
|
+
app.state.appstate = state
|
|
482
|
+
app.state.templates = templates
|
|
483
|
+
return app
|