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,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)
@@ -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