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,2135 @@
1
+ """The Settings page and its forms: password, web-UI access, users, LDAP, OIDC,
2
+ cluster, admin TLS, REST API token and mappings export/import."""
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import csv
7
+ import dataclasses
8
+ import io
9
+ import json
10
+ import os
11
+ import secrets
12
+ import shutil
13
+ import socket
14
+ import time
15
+ import types
16
+ import unicodedata
17
+ from urllib.parse import urlencode
18
+
19
+ from starlette.responses import PlainTextResponse, Response
20
+ from starlette.routing import Route
21
+
22
+ from ... import clock, console, envvars, i18n, importer, optional, reload, storage, tlsreload
23
+ from ...config import (
24
+ API_TOKEN_DAYS_MAX, API_TOKEN_NAME, API_TOKEN_ROLES, API_TOKENS_MAX, BACKUPS_MIN_KEPT,
25
+ EXPIRE_DAYS_MAX, EXPIRE_DAYS_MIN, HSTS_MAX_AGE_MAX, LOG_DAYS_MAX, NODE_NAME_ENV,
26
+ NODE_NAME_MAX, NOTIFY_EVENTS, REQUEST_LOG_MODES, ROLES, SMTP_SECURITY, TRACE_DAYS_MAX,
27
+ UI_LANGUAGES, WEBHOOK_FORMATS, LOGIN_NOTICE_MAX,
28
+ LOGIN_NOTICE_URL_MAX, ApiToken, AppConfig, ClusterSettings, ConfigError,
29
+ NotificationSettings, User, backup_time, clean_login_notice, data_path, host_port,
30
+ login_notice_problem, login_notice_url_problem, new_api_token_id, new_pwd_version,
31
+ node_name_from_env, node_name_problem, normalize_public_url, parse_cluster_peer,
32
+ proxy_entry_problem, public_url_problem, split_proxy_entries, tls_file_message,
33
+ )
34
+ from ...engine import tls
35
+ from ...engine.cluster import KEY_OVERLAP_S, key_bytes
36
+ from ...explain import explain
37
+ from ...i18n import _, ngettext
38
+ from ...textfold import search_key
39
+ from .. import auth, certinfo, ldap_auth, oidc_auth, proxy, selfsigned
40
+ from . import ops
41
+ from .common import Ctx, client_ip, csrf_failed, read_form
42
+ from .forms import (
43
+ _checkbox, _ldap_from_form, _oidc_from_form, _password_problem, _username_problem, json_text,
44
+ )
45
+
46
+ # a mappings file to import may be larger than other requests (server.MAX_BODY_BYTES):
47
+ # the importer's file limit, plus the form's other fields
48
+ IMPORT_MAX_BYTES = importer.MAX_FILE_BYTES + 64 * 1024
49
+
50
+ # how long what a Settings form did waits for the page its redirect leads to
51
+ FLASH_TTL_S = 120
52
+
53
+
54
+ class _Flash:
55
+ """What the Settings page says after a form's redirect (Post/Redirect/Get): the form's
56
+ message and section, and a token or key that is shown only once. Kept in memory for
57
+ one browser and user - the browser's CSRF cookie (a random value it keeps for its
58
+ session) and the user name - and taken by the next Settings page that browser loads;
59
+ never put in a URL or a cookie."""
60
+
61
+ def __init__(self) -> None:
62
+ self._items: dict[str, dict] = {}
63
+
64
+ def put(self, browser: str, user: str, **what) -> None:
65
+ if not browser:
66
+ return
67
+ now = time.monotonic()
68
+ for k in [k for k, v in self._items.items() if now - v["at"] > FLASH_TTL_S]:
69
+ del self._items[k]
70
+ self._items.pop(browser, None) # the newest, last
71
+ while len(self._items) >= 100:
72
+ self._items.pop(next(iter(self._items)))
73
+ self._items[browser] = {"at": now, "user": user, **what}
74
+
75
+ def take(self, browser: str, user: str) -> dict | None:
76
+ item = self._items.pop(browser, None) if browser else None
77
+ if item is None or item["user"] != user or time.monotonic() - item["at"] > FLASH_TTL_S:
78
+ return None
79
+ return item
80
+
81
+
82
+ def routes(ctx: Ctx) -> list:
83
+ state, render = ctx.state, ctx.render
84
+ current_user, require_role, set_session = ctx.current_user, ctx.require_role, ctx.set_session
85
+
86
+ def _cluster_view() -> dict:
87
+ """What the LAN cluster panel shows - never the key itself."""
88
+ c = state.config.cluster
89
+ keys = state.cluster.keys()
90
+ until = c.previous_until if len(keys) > 1 else 0.0
91
+ return {"cluster": c, "instance_id": state.config.instance_id,
92
+ "cluster_peers_text": "\n".join(c.peers),
93
+ "cluster_key_set": bool(c.key.strip()), "cluster_key_hint": _key_hint(c.key),
94
+ "cluster_previous": len(keys) > 1,
95
+ "cluster_previous_until": time.strftime("%Y-%m-%d %H:%M", time.gmtime(until))
96
+ if until else "",
97
+ "cluster_copies": state.cluster.copies(),
98
+ "cluster_legacy": state.cluster.legacy_nodes() if c.active else []}
99
+
100
+ def _package_problems():
101
+ """Why LDAP / OIDC sign-in can't work for want of its package, while it is on: a
102
+ real import (find_spec would call a package that fails to import present)."""
103
+ return (optional.load("ldap3", "ldap3")[1] if state.config.ldap.enabled else None,
104
+ optional.load("authlib.jose", "authlib")[1] if state.config.oidc.enabled else None)
105
+
106
+ def _sign_in_time(user) -> str:
107
+ when = state.signins.last(user)
108
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(when)) if when else "—"
109
+
110
+ def _server_clock(admin: bool) -> dict:
111
+ """The server's time for the Admin interface panel, and (admins only: it names the
112
+ newest backup's time) whether the clock reads earlier than it can (clock.behind)."""
113
+ now = time.time()
114
+ behind = clock.behind(state.store, now) if admin else None
115
+ return {"server_utc": clock.utc_text(now), "server_local": clock.stamp(now),
116
+ "clock_behind": {"what": behind[0], "when": clock.utc_text(behind[1])}
117
+ if behind else None}
118
+
119
+ def _tls_certificate() -> dict | None:
120
+ """What the admin UI's certificate file says (certinfo), for Settings → Admin TLS:
121
+ None without one, {"problem", "detail"} when it can't be read."""
122
+ path = state.config.admin_ui.tls_cert
123
+ if not path:
124
+ return None
125
+ try:
126
+ info = certinfo.read(path)
127
+ except certinfo.CertReadError as e:
128
+ return {"problem": e.code, "detail": str(e)}
129
+ now = time.time()
130
+ return {"subject": info.subject, "names": ", ".join(info.names),
131
+ "self_signed": info.self_signed, "state": info.state(now),
132
+ "days": info.days_left(now), "start": clock.utc_text(info.not_before)[:16],
133
+ "end": clock.utc_text(info.not_after)[:16]}
134
+
135
+ def _clock_and_certificate(admin: bool) -> dict:
136
+ return {**_server_clock(admin), "tls_certificate": _tls_certificate() if admin else None}
137
+ flash = _Flash()
138
+
139
+ def _browser(request) -> str:
140
+ """Which browser asks, for the flash: its CSRF token (a form's POST has one), as
141
+ GuardMiddleware read it from the cookie under this port's name (or moved it from
142
+ the name of 2.8), so the page the redirect leads to finds it under that name."""
143
+ return (getattr(request.state, "csrf_token", None)
144
+ or auth.cookie(request, auth.CSRF_COOKIE) or "")
145
+
146
+ async def settings_get(request, ok=None, error=None, new_api_token=None, import_preview=None,
147
+ new_cluster_key=None, restore_preview=None, section=None,
148
+ status_code=200, submitted=None):
149
+ """The Settings page. `section`: the id of the part whose form `ok` / `error` is
150
+ about (shown there); `submitted`: what a refused form had, shown again in it."""
151
+ me = current_user(request)
152
+ if request.method == "GET" and (done := flash.take(_browser(request), _who(request))):
153
+ ok, error, section = done["ok"], done["error"], done["section"]
154
+ new_api_token = done.get("new_api_token")
155
+ new_cluster_key = done.get("new_cluster_key")
156
+ admin = me is not None and me.role == "admin"
157
+ # the list reads the backup files: not on the event loop (ConfigStore.backups)
158
+ backups = await asyncio.to_thread(state.store.backups) if admin else []
159
+ # names in a backup that are no account now (deleted since): Settings can take
160
+ # them out of every backup
161
+ current = {u.username for u in state.config.users}
162
+ gone_accounts = sorted({a["username"] for b in backups for a in b["accounts"]
163
+ if a["username"] and a["username"] not in current})
164
+ disk = await asyncio.to_thread(storage.usage, state.data_dir) if admin else None
165
+ # config.json edited outside ser2netweb: not in use yet, or replaced by a save since
166
+ outside = await asyncio.to_thread(state.store.changed_on_disk) if admin else None
167
+ replaced = state.store.replaced_edit if admin else None
168
+ ldap_problem, oidc_problem = await asyncio.to_thread(_package_problems)
169
+ # the redirect URI a sign-in started from this page's address would send
170
+ published = proxy.public_url(state.config.admin_ui)
171
+ trusted = proxy.trusted_entries(state.config.admin_ui)
172
+ oidc_redirect = oidc_auth.redirect_uri(state.config.oidc, request, published)
173
+ server_clock = await asyncio.to_thread(_clock_and_certificate, admin)
174
+ # a page sent as the answer to a form names the page's own address: the address bar
175
+ # gets it (app.js), and the TR / EN switch goes back there (the form's URL may answer
176
+ # only POST); without the base path, as the TR / EN switch takes it
177
+ page_url = None if request.method == "GET" else "/settings" + (f"#{section}" if section else "")
178
+ serve_ip, serve_port, serving_tls = state.admin_serving()
179
+ configured = state.config.admin_ui
180
+ hint_kind, hint_command = console.reconfigure_hint(state.data_dir, state.service_name)
181
+ resp = render(request, "settings.html", status_code=status_code, ok=ok, error=error,
182
+ **server_clock, section=section, submitted=submitted or {},
183
+ page_url=page_url,
184
+ outside_edit=_local_time(outside) if outside else "",
185
+ outside_gone=outside == 0.0,
186
+ replaced_edit=dict(replaced, edited=_local_time(replaced["edited"]),
187
+ saved=_local_time(replaced["saved"]))
188
+ if replaced else None,
189
+ serving_address=host_port(serve_ip, serve_port), serving_tls=serving_tls,
190
+ # what config.json sets and the admin UI uses only from the next start
191
+ pending_address="" if (configured.bind_ip, configured.port) == (
192
+ serve_ip, serve_port) else host_port(configured.bind_ip, configured.port),
193
+ pending_tls=bool(state.admin_tls_waiting)
194
+ or configured.tls_enabled != serving_tls,
195
+ address_hint=hint_kind, address_command=hint_command,
196
+ import_preview=import_preview, restore_preview=restore_preview,
197
+ backups=backups, backup_gone_accounts=gone_accounts,
198
+ newer_writer=state.config.newer_writer,
199
+ unknown_keys=state.config.unknown_keys,
200
+ admin=state.config.admin_ui, hsts_max=HSTS_MAX_AGE_MAX,
201
+ hostname=socket.gethostname(), node_name_max=NODE_NAME_MAX,
202
+ request_log_modes=REQUEST_LOG_MODES, login_notice_max=LOGIN_NOTICE_MAX,
203
+ login_notice_url_max=LOGIN_NOTICE_URL_MAX,
204
+ # the variables that set them for this run, named as they are set
205
+ node_name_env=envvars.source(NODE_NAME_ENV) if node_name_from_env() else "",
206
+ public_url=published,
207
+ public_url_env=(envvars.source(proxy.PUBLIC_URL_ENV)
208
+ if proxy.public_url_from_env() else ""),
209
+ trusted=trusted, trusted_env=(trusted[1] if trusted[1] in (
210
+ proxy.TRUSTED_ENV, envvars.old_name(proxy.TRUSTED_ENV)) else ""),
211
+ api_token_set=bool(state.config.api_token_hash),
212
+ api_token_role=state.config.api_token_role,
213
+ api_token_last_used=_used(""), api_token_rows=_api_token_rows(),
214
+ api_token_max=API_TOKENS_MAX, api_token_days_max=API_TOKEN_DAYS_MAX,
215
+ new_api_token=new_api_token, new_cluster_key=new_cluster_key,
216
+ me=current_user(request), users=state.config.users, roles=ROLES,
217
+ last_sign_in={u.username: _sign_in_time(u) for u in state.config.users},
218
+ expire_days=state.config.external_users_expire_days,
219
+ expire_min=EXPIRE_DAYS_MIN, expire_max=EXPIRE_DAYS_MAX,
220
+ trace_days=state.config.trace_max_age_days, trace_days_max=TRACE_DAYS_MAX,
221
+ log_days=state.config.log_max_age_days, log_days_max=LOG_DAYS_MAX,
222
+ backups_min=BACKUPS_MIN_KEPT, disk=disk, size_text=storage.size_text,
223
+ ldap=state.config.ldap, ldap_problem=ldap_problem,
224
+ ldap_tls_problem=state.ldap_tls_problem,
225
+ oidc=state.config.oidc, oidc_problem=oidc_problem,
226
+ oidc_redirect_uri=oidc_redirect,
227
+ oidc_redirect_auto=not state.config.oidc.redirect_uri.strip() and not published,
228
+ oidc_redirect_public=not state.config.oidc.redirect_uri.strip() and bool(published),
229
+ oidc_redirect_http=oidc_auth.redirect_uri_is_plain_http(oidc_redirect),
230
+ group_warnings={src: [w for (s, _k), w in sorted(state.group_warnings.items())
231
+ if s == src] for src in ("ldap", "oidc")},
232
+ **_cluster_view(),
233
+ session_minutes=state.config.session_timeout_s // 60,
234
+ notify=state.config.notifications, notify_events=NOTIFY_EVENTS,
235
+ webhook_formats=WEBHOOK_FORMATS, smtp_security=SMTP_SECURITY)
236
+ if new_api_token or new_cluster_key:
237
+ resp.headers["Cache-Control"] = "no-store" # shown once: kept by no cache either
238
+ return resp
239
+
240
+ def _done(request, section: str, ok: str | None = None, error: str | None = None,
241
+ **once) -> Response:
242
+ """The answer to a Settings form that did its work (Post/Redirect/Get): a redirect
243
+ to the page at the form's section, which then shows `ok` (or `error`: saved, but
244
+ something after it failed) and `once` (a token or key shown only once) from the
245
+ flash. A reload or Back then loads the page instead of posting the form again (a
246
+ new API token, a new certificate, a restore that restarts every mapping), and the
247
+ address is one the TR / EN switch can go back to."""
248
+ flash.put(_browser(request), _who(request), section=section, ok=ok, error=error, **once)
249
+ return proxy.redirect(request, f"/settings#{section}")
250
+
251
+ async def _failed(request, section: str, error: str, **submitted) -> Response:
252
+ """The answer to a Settings form that changed nothing: the page at once, as a 400,
253
+ with the reason in the form's section and the form filled in with what was sent
254
+ (`submitted`; never a password or key)."""
255
+ return await settings_get(request, error=error, section=section, status_code=400,
256
+ submitted=submitted)
257
+
258
+ def _typed_again(form, *names) -> str:
259
+ """For the error of a form that is shown again: a password or key typed in it
260
+ isn't (it never goes into a page)."""
261
+ if any(str(form.get(n) or "").strip() for n in names):
262
+ return " " + _("Passwords and keys you typed are not shown again: type them once more.")
263
+ return ""
264
+
265
+ async def settings_page(request):
266
+ """GET /settings; ?restore=<backup> shows what restoring that backup would change,
267
+ ?import=<id> the preview of an uploaded mappings file (_import_preview)."""
268
+ if (pending := request.query_params.get("import")) is not None \
269
+ and not require_role(request, "operator"):
270
+ item = _pending_get(pending, _who(request))
271
+ if item is None:
272
+ return await settings_get(request, section="import", error=_(
273
+ "This import preview has expired. Upload the file again."))
274
+ return await settings_get(request, import_preview=_preview_of(pending, item))
275
+ name = request.query_params.get("restore")
276
+ if name is None or require_role(request, "admin"):
277
+ return await settings_get(request)
278
+ try:
279
+ backup = AppConfig.from_dict(json.loads(
280
+ await asyncio.to_thread(state.store.read_backup, name)))
281
+ except (ConfigError, OSError, ValueError, TypeError, AttributeError) as e:
282
+ return await settings_get(request, section="backups",
283
+ error=_("Could not read the backup: {0}").format(_unreadable(e)))
284
+ return await settings_get(request, restore_preview=_restore_preview(
285
+ name, state.config, backup, current_user(request)))
286
+
287
+ async def settings_password_post(request):
288
+ form = await read_form(request)
289
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
290
+ return csrf_failed()
291
+ user = current_user(request)
292
+ if user is not None and user.source == "anonymous":
293
+ return await _failed(request, "account", _("Sign in to change your password."))
294
+ if user is not None and user.source != "local":
295
+ return await _failed(request, "account",
296
+ _("Your account is managed by {0}; change your "
297
+ "password there.").format(user.source.upper()))
298
+ cur = form.get("current", "")
299
+ new = form.get("password", "")
300
+ new2 = form.get("password2", "")
301
+ if user is None:
302
+ return await _failed(request, "account", _("Current password is incorrect."))
303
+ # wrong current passwords count like failed sign-ins under this name (a session
304
+ # someone else got hold of must not be a way to guess the password itself)
305
+ if state.name_limiter.blocked(user.username):
306
+ return await _failed(request, "account", _("Too many attempts. Wait a few minutes "
307
+ "and try again."))
308
+ state.name_limiter.record_failure(user.username)
309
+ if not await state.password_work.run(auth.verify_password, cur, user.password_hash):
310
+ state.log(f"password change for {user.username} refused: wrong current password "
311
+ f"(from {ctx.client_ip(request)})")
312
+ ctx.audit(request, "password_change_failed", user.username)
313
+ return await _failed(request, "account", _("Current password is incorrect."))
314
+ state.name_limiter.reset(user.username)
315
+ err = _password_problem(new, new2)
316
+ if err:
317
+ return await _failed(request, "account", err)
318
+ new_hash = await state.password_work.run(auth.hash_password, new)
319
+ try:
320
+ async with state.edit_config():
321
+ user.password_hash = new_hash
322
+ user.pwd_version += 1
323
+ except ConfigError as e: # not saved: the password stays as it was
324
+ return await _failed(request, "account", str(e))
325
+ state.log(f"password changed for {user.username}; their other sessions signed out")
326
+ ctx.audit(request, "password_change", user.username)
327
+ # refresh THIS session so the user isn't logged out by their own change
328
+ resp = _done(request, "account", ok=_("Password updated. Your other sessions were "
329
+ "signed out."))
330
+ set_session(resp, request, user)
331
+ return resp
332
+
333
+ def _spellings(target) -> list:
334
+ """The other accounts of an LDAP user, in another letter case (made by earlier
335
+ versions, one per spelling; a sign-in now merges them): signing out or deleting
336
+ that user covers them too."""
337
+ if target.source != "ldap":
338
+ return []
339
+ return [u for u in state.config.external_accounts(target.username, "ldap")
340
+ if u is not target]
341
+
342
+ def _bump_sessions(username: str):
343
+ """Sign every session of `username` out (pwd_version + 1), in a state.edit_config()
344
+ block. Returns the user; raises ConfigError when there is none."""
345
+ target = state.config.get_user(username)
346
+ if target is None:
347
+ raise ConfigError(_("User not found."))
348
+ for u in (target, *_spellings(target)):
349
+ u.pwd_version += 1
350
+ return target
351
+
352
+ async def settings_signout_others(request):
353
+ """Any signed-in user: end their own sessions elsewhere, keep this one. Nothing
354
+ to do (no save) when no session was issued for the account since its last
355
+ click (AppState.others_signed_out)."""
356
+ form = await read_form(request)
357
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
358
+ return csrf_failed()
359
+ me = current_user(request)
360
+ if me is None or me.source == "anonymous":
361
+ return await _failed(request, "account", _("Sign in to manage your sessions."))
362
+ user = None
363
+ try:
364
+ async with state.edit_config():
365
+ if state.others_signed_out.get(me.username) != (
366
+ me.pwd_version, state.sessions_issued.get(me.username, 0)):
367
+ user = _bump_sessions(me.username)
368
+ except ConfigError as e:
369
+ return await _failed(request, "account", str(e))
370
+ if user is None:
371
+ return _done(request, "account", ok=_("Your other sessions are already signed out."))
372
+ state.log(f"{me.username} signed out their other sessions")
373
+ ctx.audit(request, "sessions_signout_others", me.username)
374
+ resp = _done(request, "account", ok=_("Your other sessions were signed out."))
375
+ set_session(resp, request, user)
376
+ state.others_signed_out[user.username] = (user.pwd_version,
377
+ state.sessions_issued.get(user.username, 0))
378
+ return resp
379
+
380
+ async def settings_tls_post(request):
381
+ if deny := require_role(request, "admin"):
382
+ return deny
383
+ form = await read_form(request)
384
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
385
+ return csrf_failed()
386
+ cert, key = form.get("tls_cert", "").strip(), form.get("tls_key", "").strip()
387
+ ui = state.config.admin_ui
388
+ # never shown: a blank field keeps the stored password, unless the box removes it
389
+ password = (form.get("tls_key_password", "") or (
390
+ "" if _checkbox(form, "tls_key_password_clear") else ui.tls_key_password))
391
+ hsts = ui.hsts_max_age # kept when the form has no such field (an older page)
392
+ # refused: the form again with what was sent (the stored key password only says
393
+ # whether one is stored; it is never shown)
394
+ shown = {"tls_cert": cert, "tls_key": key, "tls_key_password": ui.tls_key_password,
395
+ "hsts_max_age": form.get("hsts_max_age", hsts)}
396
+ if "hsts_max_age" in form:
397
+ raw = str(form.get("hsts_max_age") or "").strip()
398
+ if not (raw.isascii() and raw.isdigit() and int(raw) <= HSTS_MAX_AGE_MAX):
399
+ return await _failed(request, "tls", _(
400
+ "The HSTS max-age must be a whole number of seconds from 0 to {0}.")
401
+ .format(HSTS_MAX_AGE_MAX) + _typed_again(form, "tls_key_password"), tls=shown)
402
+ hsts = int(raw)
403
+ old_hsts = ui.hsts_max_age
404
+ try:
405
+ async with state.edit_config():
406
+ admin = state.config.admin_ui
407
+ admin.tls_cert, admin.tls_key, admin.hsts_max_age = cert, key, hsts
408
+ admin.tls_key_password = password
409
+ admin.check_tls_files() # here, not at every save
410
+ # a pair that exists but doesn't load would stop the next start
411
+ # (runtime._serve); one that loads is what new connections get
412
+ prepared = await _admin_tls(admin)
413
+ except ConfigError as e:
414
+ return await _failed(request, "tls", str(e) + _typed_again(form, "tls_key_password"),
415
+ tls=shown)
416
+ if hsts != old_hsts:
417
+ state.log(f"admin TLS: HSTS max-age set to {hsts} s"
418
+ + (" (max-age=0: browsers forget it)" if hsts == 0 else ""))
419
+ ctx.audit(request, "admin_tls_set", cert + ("" if hsts == old_hsts else f" (HSTS {hsts} s)"))
420
+ applied, switched = _tls_applied(request, prepared)
421
+ return await _tls_answer(request, "tls", switched, ok=_("Admin TLS saved.") + " " + applied)
422
+
423
+ async def _admin_tls(admin):
424
+ """The admin UI's TLS context for its settings (in an edit_config block): files
425
+ that can't be used refuse the change, as they would stop the next start. Without
426
+ a listener in this process the files are still loaded, to be checked."""
427
+ prepared = await state.prepare_admin_tls(admin)
428
+ if not prepared.error and prepared.later and admin.tls_enabled:
429
+ try:
430
+ await asyncio.to_thread(_load_tls_pair, admin.tls_cert, admin.tls_key,
431
+ admin.tls_key_password)
432
+ except tls.TlsFileError as e:
433
+ prepared = tlsreload.Prepared(error=str(e), exc=e)
434
+ if prepared.error:
435
+ why = (tls_file_message(prepared.exc) if isinstance(prepared.exc, tls.TlsFileError)
436
+ else prepared.error)
437
+ raise ConfigError(_("The TLS certificate and key could not be loaded: {0}").format(why))
438
+ return prepared
439
+
440
+ def _tls_applied(request, prepared) -> tuple[str, bool]:
441
+ """Hand a saved admin TLS change to the UI's listener, and say so: new connections
442
+ use it at once (the one this page came over stays as it is). Also whether they use
443
+ another scheme now (HTTPS for HTTP, or back)."""
444
+ was = state.serving_tls
445
+ if not state.use_admin_tls(prepared):
446
+ return _("Restart to apply."), False # (no listener in this process)
447
+ _ip, port, tls_on = state.admin_serving()
448
+ host = request.url.hostname or "127.0.0.1"
449
+ host = f"[{host}]" if ":" in host else host
450
+ url = f"{'https' if tls_on else 'http'}://{host}:{port}{proxy.base(request)}/settings"
451
+ if tls_on:
452
+ text = _("New connections to this UI use HTTPS now: continue at {0}").format(url)
453
+ else:
454
+ text = _("New connections to this UI use plain HTTP now: continue at {0}").format(url)
455
+ return text, tls_on != was
456
+
457
+ async def _tls_answer(request, section: str, switched: bool, **flash) -> Response:
458
+ """The answer to a form that changed the admin UI's TLS: the redirect of every Settings
459
+ form (_done), unless new connections use another scheme now: the browser would follow
460
+ the redirect with the old one, which the UI no longer serves, so the page itself is
461
+ the answer, over the connection the form came on (its message says where to go on)."""
462
+ if switched:
463
+ return await settings_get(request, section=section, **flash)
464
+ return _done(request, section, **flash)
465
+
466
+ async def settings_tls_generate(request):
467
+ if deny := require_role(request, "admin"):
468
+ return deny
469
+ form = await read_form(request)
470
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
471
+ return csrf_failed()
472
+ tdir = os.path.join(state.data_dir, "tls")
473
+ os.makedirs(tdir, exist_ok=True)
474
+ cert, key = os.path.join(tdir, "cert.pem"), os.path.join(tdir, "key.pem")
475
+ try:
476
+ # the ones generated before (the configured certificate, it may be) stay as
477
+ # *.bak: backups of config.json don't cover data/tls
478
+ kept = await asyncio.to_thread(_keep_previous, (cert, key))
479
+ await asyncio.to_thread(selfsigned.generate, cert, key)
480
+ except (selfsigned.CertError, OSError) as e:
481
+ return await _failed(request, "tls", _("Could not generate a certificate: {0}").format(
482
+ explain(e, "file") or e))
483
+ try:
484
+ async with state.edit_config():
485
+ admin = state.config.admin_ui
486
+ admin.tls_cert = cert
487
+ admin.tls_key = key
488
+ admin.tls_key_password = "" # (the key it writes is not encrypted)
489
+ prepared = await _admin_tls(admin)
490
+ except ConfigError as e:
491
+ return await _failed(request, "tls", _("The certificate was generated, but not set "
492
+ "up: {0}").format(e))
493
+ ctx.audit(request, "admin_tls_generate", "")
494
+ ok = _("Self-signed certificate generated (browsers warn about a self-signed "
495
+ "certificate until you accept it).")
496
+ applied, switched = _tls_applied(request, prepared)
497
+ ok += " " + applied
498
+ if kept:
499
+ ok += " " + _("The previous certificate and key are kept as {0}.").format(
500
+ ", ".join(os.path.basename(p) for p in kept))
501
+ # not refused: the clock may be right after all (a backup made while it was ahead)
502
+ behind = await asyncio.to_thread(clock.behind, state.store)
503
+ warning = _("The server’s clock reads {0} UTC, which can’t be right (Settings → "
504
+ "Admin interface says why), and the certificate is dated from it: clients "
505
+ "whose clock is right may take it for expired or not valid yet. Set the "
506
+ "clock, then generate the certificate again.").format(
507
+ clock.utc_text(time.time())) if behind else None
508
+ return await _tls_answer(request, "tls", switched, ok=ok, error=warning)
509
+
510
+ # ---------------- web-UI access (login requirement) ----------------
511
+ async def settings_access_post(request):
512
+ if deny := require_role(request, "admin"):
513
+ return deny
514
+ form = await read_form(request)
515
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
516
+ return csrf_failed()
517
+ require_login = _checkbox(form, "require_login")
518
+ role = form.get("anonymous_role", "viewer")
519
+ language = form.get("default_language") or state.config.admin_ui.language
520
+ if language not in UI_LANGUAGES:
521
+ language = state.config.admin_ui.language
522
+ submitted = {"access": {"require_login": require_login, "anonymous_role": role,
523
+ "language": language},
524
+ "session_minutes": form.get("session_minutes")}
525
+ if role not in ROLES:
526
+ return await _failed(request, "access", _("Invalid role for visitors without login."),
527
+ **submitted)
528
+ timeout = state.config.session_timeout_s
529
+ if "session_minutes" in form:
530
+ try:
531
+ timeout = int(float(form.get("session_minutes") or "") * 60)
532
+ except (ValueError, OverflowError): # OverflowError: "inf"
533
+ return await _failed(request, "access", _("Session lifetime must be a number "
534
+ "of minutes."), **submitted)
535
+ ui = state.config.admin_ui
536
+ old_timeout = state.config.session_timeout_s
537
+ try:
538
+ async with state.edit_config():
539
+ ui.require_login, ui.anonymous_role = require_login, role
540
+ state.config.session_timeout_s, ui.language = timeout, language
541
+ except ConfigError as e:
542
+ return await _failed(request, "access", str(e), **submitted)
543
+ if timeout != old_timeout:
544
+ state.log(f"web UI session lifetime set to {timeout // 60} min (new sign-ins)")
545
+ # said on the page after the redirect, in its language: the default saved here may
546
+ # be another one (for a visitor who hasn't picked one)
547
+ lang = i18n.set_language(i18n.negotiate(request.cookies.get(i18n.COOKIE),
548
+ request.headers.get("accept-language"), language))
549
+ if require_login:
550
+ state.log("web UI login requirement ENABLED (every visitor must sign in)")
551
+ ok = _("Login is required again for the web UI.")
552
+ else:
553
+ state.log(f"WARNING: web UI login requirement DISABLED — visitors without a "
554
+ f"session get the '{role}' role (by IP / localhost / host name)")
555
+ ok = _("Open access enabled: visitors without a login now get the '{0}' role. "
556
+ "Use this only on a trusted network.").format(role)
557
+ i18n.reset(lang)
558
+ ctx.audit(request, "web_access",
559
+ "login-required" if require_login else f"open:{role}")
560
+ return _done(request, "access", ok=ok)
561
+
562
+ # ---------------- the notice on the sign-in page ----------------
563
+ async def settings_login_notice_post(request):
564
+ if deny := require_role(request, "admin"):
565
+ return deny
566
+ form = await read_form(request)
567
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
568
+ return csrf_failed()
569
+ ui = state.config.admin_ui
570
+ text = str(form.get("login_notice", ui.login_notice))
571
+ url = str(form.get("login_notice_url", ui.login_notice_url)).strip()
572
+ shown = {"login_notice": text, "login_notice_url": url, "require_login": ui.require_login}
573
+ if problem := login_notice_problem(text) or login_notice_url_problem(url):
574
+ return await _failed(request, "access", problem, login_notice=shown)
575
+ text = clean_login_notice(text)
576
+ old = (ui.login_notice, ui.login_notice_url)
577
+ try:
578
+ async with state.edit_config():
579
+ old = (ui.login_notice, ui.login_notice_url)
580
+ ui.login_notice, ui.login_notice_url = text, url
581
+ except ConfigError as e: # not saved: nothing changed
582
+ return await _failed(request, "access", str(e), login_notice=shown)
583
+ what = (f"{len(text)} characters" if text else "none") + f", link {url or 'none'}"
584
+ if (text, url) != old:
585
+ state.log(f"sign-in notice set: {what}")
586
+ ctx.audit(request, "login_notice_set", what)
587
+ return _done(request, "access", ok=_("Sign-in notice saved.") if text or url else
588
+ _("Sign-in notice removed."))
589
+
590
+ # ---------------- this instance's name ----------------
591
+ async def settings_node_name_post(request):
592
+ if deny := require_role(request, "admin"):
593
+ return deny
594
+ form = await read_form(request)
595
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
596
+ return csrf_failed()
597
+ ui = state.config.admin_ui
598
+ if "node_name" not in form: # the environment sets it (the field is disabled)
599
+ return proxy.redirect(request, "/settings#admin-interface")
600
+ name = str(form.get("node_name") or "")
601
+ if problem := node_name_problem(name):
602
+ return await _failed(request, "admin-interface", problem, node={"node_name": name})
603
+ name = name.strip()
604
+ old = ui.node_name
605
+ try:
606
+ async with state.edit_config():
607
+ old, ui.node_name = ui.node_name, name
608
+ except ConfigError as e: # not saved: nothing changed
609
+ return await _failed(request, "admin-interface", str(e), node={"node_name": name})
610
+ if name != old:
611
+ state.log(f"instance name set to {name!r}" if name else
612
+ "instance name cleared: the host name is used")
613
+ ctx.audit(request, "node_name_set", name or "(host name)")
614
+ return _done(request, "admin-interface", ok=_("Instance name saved."))
615
+
616
+ # ---------------- which web UI requests all.log records ----------------
617
+ async def settings_request_log_post(request):
618
+ if deny := require_role(request, "admin"):
619
+ return deny
620
+ form = await read_form(request)
621
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
622
+ return csrf_failed()
623
+ mode = str(form.get("request_log") or "")
624
+ if mode not in REQUEST_LOG_MODES:
625
+ return await _failed(request, "audit-log", _("Choose which web UI requests all.log "
626
+ "records."))
627
+ ui = state.config.admin_ui
628
+ old = ui.request_log
629
+ try:
630
+ async with state.edit_config():
631
+ old, ui.request_log = ui.request_log, mode
632
+ except ConfigError as e: # not saved: nothing changed
633
+ return await _failed(request, "audit-log", str(e))
634
+ if mode != old: # logged before "off" would keep this request's own line out
635
+ state.log(f"web UI requests in all.log: {mode} (was {old})")
636
+ ctx.audit(request, "request_log_set", mode)
637
+ return _done(request, "audit-log", ok=_("Saved: which web UI requests all.log records."))
638
+
639
+ # ---------------- behind a reverse proxy ----------------
640
+ async def settings_proxy_post(request):
641
+ if deny := require_role(request, "admin"):
642
+ return deny
643
+ form = await read_form(request)
644
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
645
+ return csrf_failed()
646
+ ui = state.config.admin_ui
647
+ # a field the form doesn't have (disabled: the environment sets it) keeps its value
648
+ url, entries = ui.public_url, list(ui.trusted_proxies)
649
+ hsts = _checkbox(form, "proxy_hsts")
650
+ # refused: the form again with what was sent
651
+ shown = {"public_url": str(form.get("public_url", url) or "").strip(),
652
+ "trusted_proxies": split_proxy_entries(str(form.get("trusted_proxies") or ""))
653
+ if "trusted_proxies" in form else entries, "proxy_hsts": hsts}
654
+ if "public_url" in form:
655
+ raw = str(form.get("public_url") or "").strip()
656
+ if problem := public_url_problem(raw):
657
+ return await _failed(request, "reverse-proxy", problem, proxy=shown)
658
+ url = normalize_public_url(raw)
659
+ if "trusted_proxies" in form:
660
+ entries = list(dict.fromkeys(split_proxy_entries(str(form.get("trusted_proxies") or ""))))
661
+ if problem := next(filter(None, map(proxy_entry_problem, entries)), None):
662
+ return await _failed(request, "reverse-proxy", problem, proxy=shown)
663
+ old = (ui.public_url, list(ui.trusted_proxies), ui.proxy_hsts)
664
+ try:
665
+ async with state.edit_config():
666
+ old = (ui.public_url, list(ui.trusted_proxies), ui.proxy_hsts)
667
+ ui.public_url, ui.trusted_proxies, ui.proxy_hsts = url, entries, hsts
668
+ except ConfigError as e: # not saved: nothing changed
669
+ return await _failed(request, "reverse-proxy", str(e), proxy=shown)
670
+ changes = [what for what, changed in (
671
+ (f"public URL {url or '(none)'}", url != old[0]),
672
+ (f"trusted proxies {' '.join(entries) or '(none)'}", entries != old[1]),
673
+ (f"HSTS through the proxy {'on' if hsts else 'off'}", hsts != old[2])) if changed]
674
+ if changes:
675
+ state.log(f"reverse proxy settings: {'; '.join(changes)}")
676
+ ctx.audit(request, "reverse_proxy_set", "; ".join(changes) or "unchanged")
677
+ return _done(request, "reverse-proxy", ok=_("Reverse proxy settings saved."))
678
+
679
+ async def settings_sessions_revoke_all(request):
680
+ """A new session key: every web UI session ends at once, including sessions
681
+ signed with the key in an old copy of config.json or of a backup."""
682
+ if deny := require_role(request, "admin"):
683
+ return deny
684
+ form = await read_form(request)
685
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
686
+ return csrf_failed()
687
+ try:
688
+ async with state.edit_config():
689
+ state.config.secret_key = secrets.token_hex(32)
690
+ except ConfigError as e:
691
+ return await _failed(request, "access", str(e))
692
+ state.revoked_sessions.clear() # signed with the old key: dead anyway
693
+ state.log("new session key: every web UI session was signed out")
694
+ ctx.audit(request, "sessions_revoke_all", "")
695
+ resp = _done(request, "access", ok=_("Everyone was signed out. You stay signed in "
696
+ "here."))
697
+ me = current_user(request)
698
+ if me is not None and me.source != "anonymous":
699
+ set_session(resp, request, me)
700
+ return resp
701
+
702
+ # ---------------- REST API token ----------------
703
+ async def settings_api_token_post(request):
704
+ if deny := require_role(request, "admin"):
705
+ return deny
706
+ form = await read_form(request)
707
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
708
+ return csrf_failed()
709
+ role = form.get("api_token_role") # no field: a form from before token roles
710
+ role = "admin" if role is None else str(role).strip().lower()
711
+ if role not in ROLES:
712
+ return await _failed(request, "api-token", _("Invalid role."))
713
+ token = auth.new_api_token()
714
+ try:
715
+ async with state.edit_config():
716
+ state.config.api_token_hash = auth.hash_token(token)
717
+ state.config.api_token_role = role
718
+ except ConfigError as e: # not saved: the token in use stays (this one is never shown)
719
+ return await _failed(request, "api-token", str(e))
720
+ state.api_token_used.pop("", None) # the token it replaced was used, not this one
721
+ state.log(f"REST API token generated (role: {role})")
722
+ ctx.audit(request, "api_token_generate", role)
723
+ # show the token exactly once — only its hash is stored (on the page the redirect
724
+ # leads to, from the flash: never in a URL)
725
+ return _done(request, "api-token", new_api_token=token,
726
+ ok=_("New API token generated (role: {0}). Copy it now — it "
727
+ "is not shown again.").format(role))
728
+
729
+ async def settings_api_token_role(request):
730
+ """Give the REST API token another role, keeping the token: what uses it goes on
731
+ working (regenerating it to change the role broke every integration at once)."""
732
+ if deny := require_role(request, "admin"):
733
+ return deny
734
+ form = await read_form(request)
735
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
736
+ return csrf_failed()
737
+ role = str(form.get("api_token_role") or "").strip().lower()
738
+ if role not in ROLES:
739
+ return await _failed(request, "api-token", _("Invalid role."))
740
+ before = state.config.api_token_role
741
+ try:
742
+ async with state.edit_config():
743
+ before = state.config.api_token_role
744
+ if not state.config.api_token_hash:
745
+ raise ConfigError(_("There is no REST API token to give a role: generate one."))
746
+ state.config.api_token_role = role
747
+ except ConfigError as e: # not saved: nothing changed
748
+ return await _failed(request, "api-token", str(e))
749
+ state.log(f"REST API token role changed: {before} -> {role}")
750
+ ctx.audit(request, "api_token_role", f"{before} -> {role}")
751
+ return _done(request, "api-token", ok=_("The REST API token’s role is now {0}; the token "
752
+ "stays the same.").format(role))
753
+
754
+ async def settings_api_token_revoke(request):
755
+ if deny := require_role(request, "admin"):
756
+ return deny
757
+ form = await read_form(request)
758
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
759
+ return csrf_failed()
760
+ try:
761
+ async with state.edit_config():
762
+ state.config.api_token_hash = ""
763
+ except ConfigError as e: # not saved: the token still works
764
+ return await _failed(request, "api-token", str(e))
765
+ state.api_token_used.pop("", None)
766
+ state.log("REST API token revoked")
767
+ ctx.audit(request, "api_token_revoke", "")
768
+ if state.config.api_tokens:
769
+ return _done(request, "api-token", ok=_("API token revoked. The other tokens keep "
770
+ "working."))
771
+ return _done(request, "api-token", ok=_("API token revoked. The REST API is now disabled."))
772
+
773
+ # ---------------- named REST API tokens ----------------
774
+ def _used(token_id: str) -> str:
775
+ when = state.api_token_used.get(token_id)
776
+ return time.strftime("%Y-%m-%d %H:%M", time.localtime(when)) if when else "—"
777
+
778
+ def _api_token_rows() -> list[dict]:
779
+ def day(when: float) -> str:
780
+ return time.strftime("%Y-%m-%d", time.localtime(when)) if when else ""
781
+
782
+ return [{"id": t.id, "name": t.name, "role": t.role, "created": day(t.created) or "—",
783
+ "expires": day(t.expires), "expired": t.expired(), "last_used": _used(t.id)}
784
+ for t in state.config.api_tokens]
785
+
786
+ async def settings_api_tokens_create(request):
787
+ """Another REST API token, with its own name, role and optional expiry; the token
788
+ of api_token_hash stays as it is."""
789
+ if deny := require_role(request, "admin"):
790
+ return deny
791
+ form = await read_form(request)
792
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
793
+ return csrf_failed()
794
+ name = str(form.get("name") or "").strip()
795
+ role = str(form.get("role") or "viewer").strip().lower()
796
+ raw_days = str(form.get("expires_days") or "").strip()
797
+ try:
798
+ days = int(raw_days) if raw_days else 0
799
+ except ValueError:
800
+ days = -1
801
+ # refused: the form again with what was sent
802
+ shown = {"name": name, "role": role, "expires_days": raw_days}
803
+ if not API_TOKEN_NAME.fullmatch(name):
804
+ return await _failed(request, "api-token", _(
805
+ "A token name is 1-64 characters: English letters, digits, '.', '_' or '-'."),
806
+ new_token=shown)
807
+ if role not in API_TOKEN_ROLES:
808
+ return await _failed(request, "api-token", _("Invalid role."), new_token=shown)
809
+ if not 0 <= days <= API_TOKEN_DAYS_MAX:
810
+ return await _failed(request, "api-token", _(
811
+ "Enter 0 (never) or a number of days from 1 to {0}.").format(API_TOKEN_DAYS_MAX),
812
+ new_token=shown)
813
+ token = auth.new_api_token()
814
+ now = time.time()
815
+ try:
816
+ async with state.edit_config():
817
+ tokens = state.config.api_tokens
818
+ if any(t.name.casefold() == name.casefold() for t in tokens):
819
+ raise ConfigError(_("There is a token named '{0}' already.").format(name))
820
+ if len(tokens) >= API_TOKENS_MAX:
821
+ raise ConfigError(_("There are {0} tokens already, the most there can be: "
822
+ "revoke one first.").format(API_TOKENS_MAX))
823
+ tokens.append(ApiToken(id=new_api_token_id(), name=name,
824
+ sha256=auth.hash_token(token), role=role, created=now,
825
+ expires=now + days * 86400 if days else 0.0))
826
+ except ConfigError as e: # not saved: no token (this one is never shown)
827
+ return await _failed(request, "api-token", str(e), new_token=shown)
828
+ state.log(f"REST API token {name!r} created (role: {role}"
829
+ + (f", expires in {days} days)" if days else ")"))
830
+ ctx.audit(request, "api_token_create", f"{name}:{role}" + (f":{days}d" if days else ""))
831
+ # shown once, on the page the redirect leads to (from the flash: never in a URL)
832
+ return _done(request, "api-token", new_api_token=token, ok=_(
833
+ "New API token '{0}' (role: {1}). Copy it now — it is not shown again.").format(
834
+ name, role))
835
+
836
+ async def settings_api_tokens_revoke(request):
837
+ if deny := require_role(request, "admin"):
838
+ return deny
839
+ form = await read_form(request)
840
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
841
+ return csrf_failed()
842
+ token_id = str(form.get("id") or "")
843
+ try:
844
+ async with state.edit_config():
845
+ tokens = state.config.api_tokens
846
+ gone = next((t for t in tokens if t.id == token_id), None)
847
+ if gone is None:
848
+ raise ConfigError(_("No such token (it may have been revoked already)."))
849
+ tokens.remove(gone)
850
+ except ConfigError as e: # not saved: the token still works
851
+ return await _failed(request, "api-token", str(e))
852
+ state.api_token_used.pop(gone.id, None)
853
+ state.log(f"REST API token {gone.name!r} revoked")
854
+ ctx.audit(request, "api_token_revoke", gone.name)
855
+ return _done(request, "api-token", ok=_("API token '{0}' revoked.").format(gone.name))
856
+
857
+ # ---------------- LDAP / AD settings (admin only) ----------------
858
+ def _directory_off_problem(before: int, name: str) -> str | None:
859
+ """Switching LDAP/OIDC off signs its accounts out (auth.session_user): refuse
860
+ when that would leave no admin who can sign in."""
861
+ if before and not state.config.usable_admin_count():
862
+ return _("Not saved: no admin could sign in any more, because every admin account "
863
+ "comes from {0}. Add a local admin account first (Users), or keep {0} "
864
+ "sign-in on.").format(name)
865
+ return None
866
+
867
+ def _directory_off_note(was_on: bool, now_on: bool, source: str, name: str) -> str:
868
+ if was_on and not now_on and any(u.source == source for u in state.config.users):
869
+ return " " + _("{0} accounts are signed out and can't sign in while {0} is "
870
+ "off.").format(name)
871
+ return ""
872
+
873
+ def _ca_file_problem(ldap) -> str | None:
874
+ """Checked when the LDAP form is saved with LDAP on over TLS (the only time the
875
+ file is used; switching LDAP off must work while it is missing): the CA
876
+ certificate file can be read and holds a certificate."""
877
+ if not (ldap.enabled and ldap.uses_tls and ldap.tls_verify and ldap.ca_file.strip()):
878
+ return None
879
+ try:
880
+ ldap_auth.tls_context(ldap)
881
+ except ldap_auth.CaFileError as e:
882
+ return (_("Not saved: the CA certificate file {0} can't be read.") if e.unreadable
883
+ else _("Not saved: the CA certificate file {0} holds no certificate (PEM "
884
+ "or DER).")).format(e.path)
885
+ return None
886
+
887
+ def _clear_group_warnings(source: str) -> None:
888
+ """Saving that section answers its group-setting warnings (a sign-in that still
889
+ matches only part of a group name shows a new one)."""
890
+ for key in [k for k in state.group_warnings if k[0] == source]:
891
+ del state.group_warnings[key]
892
+
893
+ async def settings_ldap_post(request):
894
+ if deny := require_role(request, "admin"):
895
+ return deny
896
+ form = await read_form(request)
897
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
898
+ return csrf_failed()
899
+ old, new = state.config.ldap, None
900
+ try:
901
+ async with state.edit_config():
902
+ old = state.config.ldap
903
+ before = state.config.usable_admin_count()
904
+ state.config.ldap = new = _ldap_from_form(form, old)
905
+ if problem := (_directory_off_problem(before, "LDAP")
906
+ or _ca_file_problem(state.config.ldap)):
907
+ raise ConfigError(problem)
908
+ except ConfigError as e:
909
+ # refused: the form again with what was sent, but the stored bind password
910
+ shown = dataclasses.replace(new, bind_password=old.bind_password) if new else None
911
+ return await _failed(request, "ldap",
912
+ str(e) + _typed_again(form, "ldap_bind_password"), ldap=shown)
913
+ _clear_group_warnings("ldap")
914
+ state.ldap_tls_problem = None # answered; the next failing sign-in shows it again
915
+ state.log(f"LDAP settings updated (enabled={state.config.ldap.enabled})")
916
+ if old.tls_verify != state.config.ldap.tls_verify:
917
+ state.log("WARNING: LDAP: the directory server's certificate is no longer checked "
918
+ "(insecure)" if old.tls_verify else
919
+ "LDAP: the directory server's certificate is checked again")
920
+ ctx.audit(request, "ldap_settings",
921
+ "enabled" if state.config.ldap.enabled else "disabled")
922
+ return _done(request, "ldap", ok=_("LDAP settings saved.")
923
+ + _directory_off_note(old.enabled, state.config.ldap.enabled, "ldap", "LDAP"))
924
+
925
+ async def settings_oidc_post(request):
926
+ if deny := require_role(request, "admin"):
927
+ return deny
928
+ form = await read_form(request)
929
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
930
+ return csrf_failed()
931
+ old, new = state.config.oidc, None
932
+ try:
933
+ async with state.edit_config():
934
+ old = state.config.oidc
935
+ before = state.config.usable_admin_count()
936
+ state.config.oidc = new = _oidc_from_form(form, old)
937
+ if problem := _directory_off_problem(before, "OIDC"):
938
+ raise ConfigError(problem)
939
+ except ConfigError as e:
940
+ # refused: the form again with what was sent, but the stored client secret
941
+ shown = dataclasses.replace(new, client_secret=old.client_secret) if new else None
942
+ return await _failed(request, "oidc",
943
+ str(e) + _typed_again(form, "oidc_client_secret"), oidc=shown)
944
+ _clear_group_warnings("oidc")
945
+ state.log(f"OIDC settings updated (enabled={state.config.oidc.enabled})")
946
+ ctx.audit(request, "oidc_settings",
947
+ "enabled" if state.config.oidc.enabled else "disabled")
948
+ return _done(request, "oidc", ok=_("OIDC (SSO) settings saved.")
949
+ + _directory_off_note(old.enabled, state.config.oidc.enabled, "oidc", "OIDC"))
950
+
951
+ # ---------------- user management (admin only) ----------------
952
+ def _target_name(request, form) -> str:
953
+ """The account a Users-panel form acts on: its `username` field (any name works
954
+ there, also DOMAIN\\user or one with / # ?), or the older form's URL
955
+ /settings/users/<name>/... (kept for scripts and older pages)."""
956
+ return request.path_params.get("username") or str(form.get("username") or "")
957
+
958
+ def _is_me(request, username: str) -> bool:
959
+ """The signed-in account itself (never the open-access pseudo-user)."""
960
+ me = current_user(request)
961
+ return me is not None and me.source != "anonymous" and me.username == username
962
+
963
+ def _last_admin(target) -> bool:
964
+ """Would demoting / deleting this admin leave no admin, or none who can sign in
965
+ (an LDAP/OIDC admin can't while that method is off)?"""
966
+ cfg = state.config
967
+ return cfg.admin_count() <= 1 or (cfg.source_enabled(target)
968
+ and cfg.usable_admin_count() <= 1)
969
+
970
+ def _leaves_no_admin(gone) -> bool:
971
+ """Would deleting these accounts leave no admin, or none who can sign in?"""
972
+ cfg = state.config
973
+ admins = [u for u in gone if u.role == "admin"]
974
+ rest = [u for u in cfg.users if u.role == "admin" and not any(u is g for g in gone)]
975
+ return bool(admins) and (not rest or (any(cfg.source_enabled(u) for u in admins)
976
+ and not any(cfg.source_enabled(u) for u in rest)))
977
+
978
+ async def settings_users_create(request):
979
+ if deny := require_role(request, "admin"):
980
+ return deny
981
+ form = await read_form(request)
982
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
983
+ return csrf_failed()
984
+ username = (form.get("username") or "").strip()
985
+ role = form.get("role", "viewer")
986
+ pw, pw2 = form.get("password", ""), form.get("password2", "")
987
+
988
+ def taken():
989
+ """Also an account of this name in another letter case: LDAP names compare
990
+ that way, and two names that differ only in case are easily mistaken."""
991
+ other = state.config.named(username)
992
+ return _("User '{0}' already exists.").format(other[0].username) if other else None
993
+
994
+ err = (_username_problem(username)
995
+ or (None if role in ROLES else _("Invalid role."))
996
+ or _password_problem(pw, pw2)
997
+ or taken())
998
+ shown = {"username": username, "role": role} # the passwords are typed again
999
+ if err:
1000
+ return await _failed(request, "users", err, new_user=shown)
1001
+ pw_hash = await state.password_work.run(auth.hash_password, pw)
1002
+ try:
1003
+ async with state.edit_config():
1004
+ if err := taken(): # created while the password was hashed
1005
+ raise ConfigError(err)
1006
+ state.config.users.append(User(username=username, password_hash=pw_hash,
1007
+ role=role, pwd_version=new_pwd_version()))
1008
+ except ConfigError as e:
1009
+ return await _failed(request, "users", str(e), new_user=shown)
1010
+ state.log(f"user created: {username} ({role})")
1011
+ ctx.audit(request, "user_create", f"{username}:{role}")
1012
+ return _done(request, "users", ok=_("User '{0}' created.").format(username))
1013
+
1014
+ async def settings_users_role(request):
1015
+ if deny := require_role(request, "admin"):
1016
+ return deny
1017
+ form = await read_form(request)
1018
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1019
+ return csrf_failed()
1020
+ username = _target_name(request, form)
1021
+ role = form.get("role", "viewer")
1022
+ try:
1023
+ async with state.edit_config(): # checked under the lock: two admins at once
1024
+ target = state.config.get_user(username)
1025
+ if target is None:
1026
+ raise ConfigError(_("User not found."))
1027
+ if role not in ROLES:
1028
+ raise ConfigError(_("Invalid role."))
1029
+ if target.role == "admin" and role != "admin" and _last_admin(target):
1030
+ raise ConfigError(_("Cannot demote the last admin."))
1031
+ target.role = role
1032
+ target.pwd_version += 1 # force re-login so the new role applies everywhere
1033
+ except ConfigError as e:
1034
+ return await _failed(request, "users", str(e))
1035
+ state.log(f"user role changed: {username} -> {role}")
1036
+ ctx.audit(request, "user_role", f"{username}:{role}")
1037
+ return _done(request, "users", ok=_("Role updated for '{0}'. They must sign in again.").format(username))
1038
+
1039
+ async def settings_users_delete(request):
1040
+ if deny := require_role(request, "admin"):
1041
+ return deny
1042
+ form = await read_form(request)
1043
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1044
+ return csrf_failed()
1045
+ username = _target_name(request, form)
1046
+ try:
1047
+ async with state.edit_config(): # checked under the lock: two admins at once
1048
+ target = state.config.get_user(username)
1049
+ if target is None:
1050
+ raise ConfigError(_("User not found."))
1051
+ if _is_me(request, username):
1052
+ raise ConfigError(_("You cannot delete your own account."))
1053
+ if target.role == "admin" and _last_admin(target):
1054
+ raise ConfigError(_("Cannot delete the last admin."))
1055
+ others = [u for u in _spellings(target) if not _is_me(request, u.username)]
1056
+ if others and _leaves_no_admin([target, *others]):
1057
+ raise ConfigError(_("Cannot delete the last admin."))
1058
+ gone = [target, *others]
1059
+ state.config.users = [u for u in state.config.users
1060
+ if not any(u is g for g in gone)]
1061
+ except ConfigError as e:
1062
+ return await _failed(request, "users", str(e))
1063
+ for u in gone:
1064
+ state.log(f"user deleted: {u.username}")
1065
+ ctx.audit(request, "user_delete", u.username)
1066
+ ok = _("User '{0}' deleted.").format(username)
1067
+ if others:
1068
+ ok += " " + _("Its accounts in another letter case were deleted too: {0}.").format(
1069
+ ", ".join(u.username for u in others))
1070
+ if target.source != "local":
1071
+ ok += " " + _("They are signed out; while {0} still gives them a role, their next "
1072
+ "sign-in makes a new account.").format(target.source.upper())
1073
+ names = {u.username for u in gone}
1074
+ kept = sum(1 for b in await asyncio.to_thread(state.store.backups)
1075
+ if any(a["username"] in names for a in b["accounts"]))
1076
+ if kept:
1077
+ ok += " " + ngettext(
1078
+ "{0} configuration backup still holds it (made before the deletion; a restore "
1079
+ "with accounts would bring it back): Configuration backups below can remove it "
1080
+ "from that backup.",
1081
+ "{0} configuration backups still hold it (made before the deletion; a restore "
1082
+ "with accounts would bring it back): Configuration backups below can remove it "
1083
+ "from them.", kept).format(kept)
1084
+ return _done(request, "users", ok=ok)
1085
+
1086
+ async def settings_users_expiry(request):
1087
+ """How many days without a sign-in an LDAP/OIDC account is kept (0: for ever)."""
1088
+ if deny := require_role(request, "admin"):
1089
+ return deny
1090
+ form = await read_form(request)
1091
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1092
+ return csrf_failed()
1093
+ try:
1094
+ days = int(str(form.get("expire_days", "")).strip())
1095
+ except ValueError:
1096
+ days = -1
1097
+ shown = str(form.get("expire_days", ""))
1098
+ if not (days == 0 or EXPIRE_DAYS_MIN <= days <= EXPIRE_DAYS_MAX):
1099
+ return await _failed(request, "users", _(
1100
+ "Enter 0 (never) or a number of days from {0} to {1}.").format(
1101
+ EXPIRE_DAYS_MIN, EXPIRE_DAYS_MAX), expire_days=shown)
1102
+ try:
1103
+ async with state.edit_config():
1104
+ state.config.external_users_expire_days = days
1105
+ except ConfigError as e:
1106
+ return await _failed(request, "users", str(e), expire_days=shown)
1107
+ state.log(f"LDAP/OIDC accounts are removed after {days} days without a sign-in" if days
1108
+ else "LDAP/OIDC accounts are no longer removed for not signing in")
1109
+ ctx.audit(request, "users_expiry", str(days))
1110
+ gone = await state.expire_external_accounts()
1111
+ ok = (_("Saved: LDAP / OIDC accounts that haven’t signed in for {0} days are removed "
1112
+ "(checked every hour).").format(days) if days else
1113
+ _("Saved: LDAP / OIDC accounts are never removed for not signing in."))
1114
+ if gone:
1115
+ ok += " " + _("Removed now: {0}.").format(", ".join(u.username for u in gone))
1116
+ return _done(request, "users", ok=ok)
1117
+
1118
+ async def settings_retention(request):
1119
+ """How many days a trace file is kept after it was last written (0: until it is
1120
+ deleted in its mapping's Trace panel, the default), and how many days log and audit
1121
+ records and configuration backups are kept (0: only their size and count limits,
1122
+ the default). A setting the form doesn't send (a page of an older version) stays."""
1123
+ if deny := require_role(request, "admin"):
1124
+ return deny
1125
+ form = await read_form(request)
1126
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1127
+ return csrf_failed()
1128
+ wanted: dict[str, int] = {}
1129
+ # refused: the form again with what was sent (a field not sent: as it is)
1130
+ shown = {key: form.get(key, getattr(state.config, key))
1131
+ for key in ("trace_max_age_days", "log_max_age_days")}
1132
+ for key, top, problem in (
1133
+ ("trace_max_age_days", TRACE_DAYS_MAX,
1134
+ _("Enter 0 (keep them) or a number of days from 1 to {0}.")),
1135
+ ("log_max_age_days", LOG_DAYS_MAX,
1136
+ _("Enter 0 (only the size limits) or a number of days from 1 to {0}."))):
1137
+ if key not in form:
1138
+ continue
1139
+ try:
1140
+ days = int(str(form.get(key, "")).strip())
1141
+ except ValueError:
1142
+ days = -1
1143
+ if not 0 <= days <= top:
1144
+ return await _failed(request, "retention", problem.format(top), retention=shown)
1145
+ wanted[key] = days
1146
+ before = {key: getattr(state.config, key) for key in wanted}
1147
+ try:
1148
+ async with state.edit_config():
1149
+ for key, days in wanted.items():
1150
+ setattr(state.config, key, days)
1151
+ except ConfigError as e:
1152
+ return await _failed(request, "retention", str(e), retention=shown)
1153
+ parts = []
1154
+ if "trace_max_age_days" in wanted:
1155
+ days = wanted["trace_max_age_days"]
1156
+ if days != before["trace_max_age_days"]:
1157
+ state.log(f"trace files not written for {days} days are deleted every hour"
1158
+ if days else "trace files are kept until they are deleted by hand")
1159
+ ctx.audit(request, "trace_retention", str(days))
1160
+ gone = await state.run_trace_maintenance()
1161
+ parts.append(_("Saved: trace files not written for {0} days are deleted (checked "
1162
+ "every hour).").format(days) if days else
1163
+ _("Saved: trace files are kept until they are deleted in a mapping’s "
1164
+ "Trace panel."))
1165
+ if gone:
1166
+ parts.append(ngettext("Deleted now: {0} trace file.", "Deleted now: {0} trace files.",
1167
+ gone).format(gone))
1168
+ if "log_max_age_days" in wanted:
1169
+ days = wanted["log_max_age_days"]
1170
+ if days != before["log_max_age_days"]:
1171
+ state.log(f"log and audit records and configuration backups older than {days} "
1172
+ "days are deleted every hour" if days else
1173
+ "log and audit records and configuration backups are kept within "
1174
+ "their size and count limits")
1175
+ ctx.audit(request, "log_retention", str(days))
1176
+ gone = await state.run_retention()
1177
+ parts.append(_("Log and audit records and configuration backups older than {0} "
1178
+ "days are deleted (checked every hour; the newest {1} backups stay)."
1179
+ ).format(days, BACKUPS_MIN_KEPT) if days else
1180
+ _("Logs, the audit log and the configuration backups are kept within "
1181
+ "their size and count limits."))
1182
+ if gone:
1183
+ parts.append(ngettext("Deleted now: {0} log file or backup.",
1184
+ "Deleted now: {0} log files and backups.", gone).format(gone))
1185
+ return _done(request, "retention", ok=" ".join(parts) or _("Saved."))
1186
+
1187
+ async def settings_users_signout(request):
1188
+ if deny := require_role(request, "admin"):
1189
+ return deny
1190
+ form = await read_form(request)
1191
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1192
+ return csrf_failed()
1193
+ username = _target_name(request, form)
1194
+ try:
1195
+ async with state.edit_config():
1196
+ target = _bump_sessions(username)
1197
+ except ConfigError as e:
1198
+ return await _failed(request, "users", str(e))
1199
+ state.log(f"all sessions of {username} signed out")
1200
+ ctx.audit(request, "user_signout", username)
1201
+ resp = _done(request, "users", ok=_("'{0}' was signed out everywhere.").format(username))
1202
+ if _is_me(request, username):
1203
+ set_session(resp, request, target) # your own row: you stay signed in here
1204
+ return resp
1205
+
1206
+ async def settings_users_password(request):
1207
+ """An admin sets another local account's password (it forgot it, or it must
1208
+ be taken away from someone): the account is signed out everywhere."""
1209
+ if deny := require_role(request, "admin"):
1210
+ return deny
1211
+ form = await read_form(request)
1212
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1213
+ return csrf_failed()
1214
+ username = (form.get("username") or "").strip()
1215
+ pw, pw2 = form.get("password", ""), form.get("password2", "")
1216
+
1217
+ def problem():
1218
+ target = state.config.get_user(username)
1219
+ if target is None:
1220
+ return _("User not found.")
1221
+ if target.source != "local":
1222
+ return _("'{0}' signs in with {1}; their password is managed there.").format(
1223
+ username, target.source.upper())
1224
+ if _is_me(request, username):
1225
+ return _("To change your own password, use the form at the top (it asks for "
1226
+ "the current one).")
1227
+ return None
1228
+
1229
+ # refused: the same account stays chosen (not the list's first one)
1230
+ if err := problem() or _password_problem(pw, pw2):
1231
+ return await _failed(request, "users", err, reset_user=username)
1232
+ pw_hash = await state.password_work.run(auth.hash_password, pw)
1233
+ try:
1234
+ async with state.edit_config():
1235
+ if err := problem(): # changed while the password was hashed
1236
+ raise ConfigError(err)
1237
+ target = state.config.get_user(username)
1238
+ if target is None: # (problem() has just found it)
1239
+ raise ConfigError(_("User not found."))
1240
+ target.password_hash, target.pwd_version = pw_hash, target.pwd_version + 1
1241
+ except ConfigError as e:
1242
+ return await _failed(request, "users", str(e), reset_user=username)
1243
+ state.log(f"password of {username} set by an admin; their sessions were signed out")
1244
+ ctx.audit(request, "user_password_set", username)
1245
+ return _done(request, "users", ok=_("New password set for '{0}'; they were signed out "
1246
+ "everywhere.").format(username))
1247
+
1248
+ async def settings_users_totp_reset(request):
1249
+ """An admin turns off another local account's two-factor sign-in (its authenticator
1250
+ was lost): it is signed out everywhere, and its password alone signs it in until
1251
+ it sets two-factor sign-in up again."""
1252
+ if deny := require_role(request, "admin"):
1253
+ return deny
1254
+ form = await read_form(request)
1255
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1256
+ return csrf_failed()
1257
+ username = str(form.get("username") or "")
1258
+ try:
1259
+ async with state.edit_config(): # checked under the lock: two admins at once
1260
+ target = state.config.get_user(username)
1261
+ if target is None:
1262
+ raise ConfigError(_("User not found."))
1263
+ if _is_me(request, username):
1264
+ raise ConfigError(_("To turn off your own two-factor sign-in, use "
1265
+ "“Two-factor sign-in” at the top (it asks for a code)."))
1266
+ if not target.totp_on:
1267
+ raise ConfigError(_("'{0}' doesn’t use two-factor sign-in.").format(username))
1268
+ target.totp_secret, target.pwd_version = "", target.pwd_version + 1
1269
+ except ConfigError as e: # not saved: nothing changed
1270
+ return await _failed(request, "users", str(e))
1271
+ state.log(f"two-factor sign-in of {username} turned off by an admin; their sessions "
1272
+ "were signed out")
1273
+ ctx.audit(request, "user_totp_reset", username)
1274
+ return _done(request, "users", ok=_("Two-factor sign-in is off for '{0}'; they were "
1275
+ "signed out everywhere.").format(username))
1276
+
1277
+ # ---------------- config export / import ----------------
1278
+ async def config_export(request):
1279
+ # mappings only — never export password_hash / secret_key. Mappings can carry
1280
+ # secrets (the MQTT password and client key password, the TLS key password), so —
1281
+ # like import — this needs operator+ (the UI only offers it to operators; a viewer,
1282
+ # possibly an anonymous one, must not read them).
1283
+ if deny := require_role(request, "operator"):
1284
+ return deny
1285
+ mappings = state.config.mappings
1286
+ # non-ASCII text as it is (mapping and point names, topics), like config.json
1287
+ data = {"version": 1, "mappings": [m.to_dict() for m in mappings]}
1288
+ secrets_in = sum(1 for m in mappings if m.mqtt.password or m.mqtt.client_key_password
1289
+ or m.network.tls_key_password)
1290
+ ctx.audit(request, "config_export", f"{len(mappings)} mappings"
1291
+ + (f", {secrets_in} with a stored password (MQTT, MQTT or TLS key)"
1292
+ if secrets_in else ""))
1293
+ return Response(
1294
+ json_text(data, indent=2), media_type="application/json; charset=utf-8",
1295
+ headers={"Content-Disposition": "attachment; filename=ser2netweb-mappings.json"})
1296
+
1297
+ # ---- import: the form first shows a preview (step=preview), then applies it in
1298
+ # "merge" or "replace" mode. A POST without step (scripts written for 2.7)
1299
+ # keeps the old behaviour: replace everything at once, answered with the page;
1300
+ # a classic ser2net file (which 2.7 did not take) only goes through the preview.
1301
+ PENDING_TTL_S = 900 # (time.monotonic(): a clock step neither ends nor extends it)
1302
+
1303
+ def _pending_put(user: str, plan, mode: str) -> str:
1304
+ now, pend = time.monotonic(), state.pending_imports
1305
+ for k in [k for k, v in pend.items() if now - v["created"] > PENDING_TTL_S]:
1306
+ del pend[k]
1307
+ while len(pend) >= 20:
1308
+ pend.pop(next(iter(pend)))
1309
+ token = secrets.token_urlsafe(16)
1310
+ pend[token] = {"created": now, "user": user, "plan": plan, "mode": mode}
1311
+ return token
1312
+
1313
+ def _pending_get(token: str, user: str):
1314
+ item = state.pending_imports.get(token or "")
1315
+ if not item or item["user"] != user or time.monotonic() - item["created"] > PENDING_TTL_S:
1316
+ return None
1317
+ return item
1318
+
1319
+ def _who(request) -> str:
1320
+ u = current_user(request)
1321
+ return u.username if u is not None else ""
1322
+
1323
+ async def config_import(request):
1324
+ if deny := require_role(request, "operator"):
1325
+ return deny
1326
+ form = await request.form(max_files=1) # the mappings file
1327
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1328
+ return csrf_failed()
1329
+ upload = form.get("file")
1330
+ legacy = form.get("step") != "preview"
1331
+ try:
1332
+ raw = await upload.read() if hasattr(upload, "read") else str(upload or "").encode()
1333
+ # parsing is bounded, but a big file still takes a moment: not on the event loop
1334
+ plan = await asyncio.to_thread(importer.parse_for_preview, raw,
1335
+ getattr(upload, "filename", "") or "")
1336
+ except ConfigError as e:
1337
+ error = _("Import failed: {0}").format(e)
1338
+ if legacy: # answered as before
1339
+ return await settings_get(request, section="import", error=error)
1340
+ return await _failed(request, "import", error)
1341
+ mode = form.get("mode", "merge")
1342
+ if not legacy:
1343
+ if mode not in importer.MODES:
1344
+ return await _failed(request, "import", _("Unknown import mode."))
1345
+ return await _import_preview(request, plan, mode)
1346
+ if plan.classic: # what it changes and leaves out is only seen in the preview
1347
+ return await settings_get(request, section="import", status_code=400, error=_(
1348
+ "A classic ser2net file is imported through the preview: choose it in "
1349
+ "Settings → Mappings export / import (or post it with step=preview), check "
1350
+ "the preview, then apply it."))
1351
+ # legacy one-step import: replace all mappings
1352
+ return await _apply_import(request, plan, "replace", legacy=True)
1353
+
1354
+ async def _import_preview(request, plan, mode):
1355
+ """Keep `plan` for this user, and lead to the page that shows what importing it
1356
+ would change (?import=<id>: a reload or the TR / EN switch shows it again; the id
1357
+ is good only for this user, for a while). Nothing changes until it is applied."""
1358
+ token = _pending_put(_who(request), plan, mode)
1359
+ return proxy.redirect(request, f"/settings?import={token}#import")
1360
+
1361
+ def _preview_of(token: str, item) -> dict:
1362
+ """What importing a pending plan would change, against the mappings as they are now
1363
+ (ops.import_preview, shared with the REST API's dry run)."""
1364
+ plan, mode = item["plan"], item["mode"]
1365
+ rows, problem = ops.import_preview(state, plan, mode)
1366
+ general, _notes = plan.split_warnings() # a mapping's own warnings are in its row
1367
+ return {"token": token, "mode": mode, "rows": rows, "source": plan.source,
1368
+ "warnings": general, "problem": problem, "count": len(plan.mappings)}
1369
+
1370
+ async def config_import_apply(request):
1371
+ if deny := require_role(request, "operator"):
1372
+ return deny
1373
+ form = await read_form(request)
1374
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1375
+ return csrf_failed()
1376
+ token = form.get("token", "")
1377
+ item = _pending_get(token, _who(request))
1378
+ if item is None:
1379
+ return await _failed(request, "import", _("This import preview has expired. "
1380
+ "Upload the file again."))
1381
+ mode = form.get("mode", "merge")
1382
+ if mode not in importer.MODES:
1383
+ return await _failed(request, "import", _("Unknown import mode."))
1384
+ state.pending_imports.pop(token, None)
1385
+ return await _apply_import(request, item["plan"], mode)
1386
+
1387
+ async def _apply_import(request, plan, mode, legacy=False):
1388
+ """`legacy`: the one-step import of 2.7 scripts, answered with the page as before."""
1389
+ try:
1390
+ rows, new_maps, backed_up = await ops.import_mappings(state, plan, mode)
1391
+ except ConfigError as e:
1392
+ error = _("Import rejected: {0}").format(e)
1393
+ if legacy:
1394
+ return await settings_get(request, section="import", error=error)
1395
+ return await _failed(request, "import", error)
1396
+ count = ops.import_counts(rows)
1397
+ # in English (parse_for_preview): what the import left out or changed stays on record
1398
+ warnings = importer.log_lines(plan)
1399
+ state.log(f"mappings imported ({mode}) from {plan.log_source or plan.source}: "
1400
+ f"{count['add']} added, {count['update']} updated, {count['same']} unchanged, "
1401
+ f"{count['remove']} removed, {len(warnings)} warnings")
1402
+ for w in warnings:
1403
+ state.log(f"import warning: {w}")
1404
+ ctx.audit(request, f"config_import_{mode}", importer.audit_detail(plan))
1405
+ if mode == "replace":
1406
+ ok = ngettext("Imported {0} mapping (replaced the existing ones).",
1407
+ "Imported {0} mappings (replaced the existing ones).",
1408
+ len(new_maps)).format(len(new_maps))
1409
+ else:
1410
+ ok = _("Imported: {0} added, {1} updated, {2} unchanged.").format(
1411
+ count["add"], count["update"], count["same"])
1412
+ ok += " " + (_("The previous configuration was kept as a backup.") if backed_up else
1413
+ _("The previous configuration could not be kept as a backup (see all.log)."))
1414
+ if legacy:
1415
+ return await settings_get(request, section="import", ok=ok)
1416
+ return _done(request, "import", ok=ok)
1417
+
1418
+ # ---------------- event notifications (admin) ----------------
1419
+ def _notify_from_form(form, old: NotificationSettings) -> NotificationSettings:
1420
+ try:
1421
+ port = int(form.get("notify_smtp_port") or 587)
1422
+ repeat = int(float(form.get("notify_repeat_min") or 5) * 60)
1423
+ except (ValueError, OverflowError): # OverflowError: "inf"
1424
+ raise ConfigError(_("SMTP port and repeat interval must be numbers.")) from None
1425
+ url, pw = form.get("notify_webhook_url", "").strip(), form.get("notify_smtp_password", "")
1426
+ # the personal-data boxes: a form without them (a page opened before the upgrade)
1427
+ # keeps what is stored
1428
+ privacy = "notify_privacy_fields" in form
1429
+ return NotificationSettings.from_dict({
1430
+ "enabled": _checkbox(form, "notify_enabled"),
1431
+ "events": [e for e in NOTIFY_EVENTS if _checkbox(form, f"notify_event_{e}")],
1432
+ "repeat_s": repeat,
1433
+ "webhook_url": url if url else ("" if _checkbox(form, "notify_webhook_clear") else old.webhook_url),
1434
+ "webhook_format": form.get("notify_webhook_format", "json"),
1435
+ "telegram_chat_id": form.get("notify_telegram_chat_id", "").strip(),
1436
+ "email_to": form.get("notify_email_to", "").strip(),
1437
+ "email_from": form.get("notify_email_from", "").strip(),
1438
+ "smtp_host": form.get("notify_smtp_host", "").strip(),
1439
+ "smtp_port": port,
1440
+ "smtp_security": form.get("notify_smtp_security", "starttls"),
1441
+ "smtp_username": form.get("notify_smtp_username", "").strip(),
1442
+ "smtp_password": pw if pw else old.smtp_password, # blank => keep the stored one
1443
+ "mask_personal_data": (_checkbox(form, "notify_mask_personal_data") if privacy
1444
+ else old.mask_personal_data),
1445
+ "email_separately": (_checkbox(form, "notify_email_separately") if privacy
1446
+ else old.email_separately),
1447
+ })
1448
+
1449
+ async def settings_notifications_post(request):
1450
+ if deny := require_role(request, "admin"):
1451
+ return deny
1452
+ form = await read_form(request)
1453
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1454
+ return csrf_failed()
1455
+ old = sent = state.config.notifications
1456
+ try:
1457
+ async with state.edit_config():
1458
+ old = state.config.notifications
1459
+ state.config.notifications = sent = _notify_from_form(form, old)
1460
+ except ConfigError as e:
1461
+ # refused: the form again with what was sent (if it could be read), but the
1462
+ # stored webhook URL and SMTP password
1463
+ shown = (None if sent is old else dataclasses.replace(
1464
+ sent, webhook_url=old.webhook_url, smtp_password=old.smtp_password))
1465
+ return await _failed(request, "notifications", str(e) + _typed_again(
1466
+ form, "notify_webhook_url", "notify_smtp_password"), notify=shown)
1467
+ new = state.config.notifications
1468
+ state.log(f"notification settings saved (enabled={new.enabled}, events={','.join(new.events)}, "
1469
+ f"personal data {'masked' if new.mask_personal_data else 'sent'}, e-mail "
1470
+ f"{'to each recipient' if new.email_separately else 'to all recipients at once'})")
1471
+ ctx.audit(request, "notification_settings", "; ".join(
1472
+ ["enabled" if new.enabled else "disabled"]
1473
+ + (["personal data masked"] if new.mask_personal_data else [])
1474
+ + (["e-mail to each recipient"] if new.email_separately else [])))
1475
+ if form.get("then") == "test":
1476
+ # (the test message in the notification language: deliver() writes it)
1477
+ warnings: list[str] = []
1478
+ errors = await state.notifier.deliver("test", "", "", warnings=warnings)
1479
+ for problem in [str(f) for f in errors] + warnings:
1480
+ state.log(f"notifications: {problem} (test)")
1481
+ if errors: # saved: a reload must not send the test again either
1482
+ return _done(request, "notifications", error=_("Saved, but the test failed: {0}")
1483
+ .format("; ".join([f.shown() for f in errors] + warnings)))
1484
+ if warnings:
1485
+ return _done(request, "notifications", error=_(
1486
+ "Saved; the test notification was sent, but: {0}").format("; ".join(warnings)))
1487
+ return _done(request, "notifications", ok=_("Saved; the test notification was sent."))
1488
+ return _done(request, "notifications", ok=_("Notification settings saved."))
1489
+
1490
+ # ---------------- audit log viewer (admin) ----------------
1491
+ AUDIT_PAGE = 100
1492
+
1493
+ async def _audit_records(request):
1494
+ params = request.query_params
1495
+ q = (params.get("q") or "").strip()
1496
+ action = (params.get("action") or "").strip()
1497
+ user = (params.get("user") or "").strip()
1498
+ records, actions, users = await asyncio.to_thread(_audit_matching, q, action, user)
1499
+ return records, {"q": q, "action": action, "user": user}, actions, users
1500
+
1501
+ def _audit_matching(q: str, action: str, user: str):
1502
+ """(the audit records that match, every action, every user), in a worker thread:
1503
+ the search folds the text of each record (search_key: 'istanbul' finds
1504
+ 'İstanbul', 'dairesi' 'DAİRESİ'). The fields are joined with NUL, which the fold
1505
+ keeps, so a search doesn't match across two of them."""
1506
+ records = state.read_audit()
1507
+ actions = sorted({r["action"] for r in records})
1508
+ users = sorted({r["user"] for r in records if r["user"]})
1509
+ if action:
1510
+ records = [r for r in records if r["action"] == action]
1511
+ if user:
1512
+ records = [r for r in records if r["user"] == user]
1513
+ needle = search_key(q.replace("\0", ""))
1514
+ if needle:
1515
+ records = [r for r in records if needle in search_key("\0".join(r.values()))]
1516
+ return records, actions, users
1517
+
1518
+ async def audit_page(request):
1519
+ if deny := require_role(request, "admin"):
1520
+ return deny
1521
+ records, filters, actions, users = await _audit_records(request)
1522
+ try:
1523
+ page = max(1, int(request.query_params.get("page", "1")))
1524
+ except ValueError:
1525
+ page = 1
1526
+ pages = max(1, -(-len(records) // AUDIT_PAGE))
1527
+ page = min(page, pages)
1528
+ return render(request, "audit.html", rows=records[(page - 1) * AUDIT_PAGE:page * AUDIT_PAGE],
1529
+ log_days=state.config.log_max_age_days,
1530
+ total=len(records), page=page, pages=pages, filters=filters,
1531
+ actions=actions, users=users, me=current_user(request),
1532
+ qs=urlencode({k: v for k, v in filters.items() if v}))
1533
+
1534
+ async def audit_csv(request):
1535
+ if deny := require_role(request, "admin"):
1536
+ return deny
1537
+ records, filters, *_ = await _audit_records(request)
1538
+ shown = ", ".join(f"{k}={v}" for k, v in filters.items() if v)
1539
+ ctx.audit(request, "audit_export", f"{len(records)} records"
1540
+ + (f" ({shown})" if shown else ""))
1541
+ # ?excel=1: UTF-16 with a BOM, tab-separated — Excel opens that by double-click
1542
+ # with every letter and column whatever the regional settings. The default file
1543
+ # (UTF-8 without a BOM, commas) is what scripts read; Excel reads it in the ANSI
1544
+ # code page, split at the regional list separator (Turkish Windows: ';'), so all
1545
+ # in column A with garbled letters. Audit fields hold no tabs (state.audit).
1546
+ excel = request.query_params.get("excel") == "1"
1547
+ buf = io.StringIO()
1548
+ out = csv.writer(buf, delimiter="\t" if excel else ",")
1549
+ out.writerow(["time", "ip", "user", "action", "detail"])
1550
+ for r in records:
1551
+ out.writerow([_csv_cell(r[k]) for k in ("time", "ip", "user", "action", "detail")])
1552
+ if excel:
1553
+ return Response(("\ufeff" + buf.getvalue()).encode("utf-16-le"),
1554
+ media_type="text/csv; charset=utf-16",
1555
+ headers={"Content-Disposition":
1556
+ "attachment; filename=ser2netweb-audit-excel.csv"})
1557
+ return Response(buf.getvalue(), media_type="text/csv; charset=utf-8",
1558
+ headers={"Content-Disposition": "attachment; filename=ser2netweb-audit.csv"})
1559
+
1560
+ # ---------------- configuration backups (admin) ----------------
1561
+ async def backup_download(request):
1562
+ if deny := require_role(request, "admin"):
1563
+ return deny
1564
+ name = request.path_params["name"]
1565
+ try:
1566
+ text = await asyncio.to_thread(state.store.read_backup, name)
1567
+ except (ConfigError, OSError):
1568
+ return PlainTextResponse(_("No such backup."), status_code=404)
1569
+ ctx.audit(request, "config_backup_download", name)
1570
+ return Response(_without_session_key(text), media_type="application/json",
1571
+ headers={"Content-Disposition": f"attachment; filename={name}"})
1572
+
1573
+ async def backup_restore(request):
1574
+ """Restore a backup's mappings and settings. User accounts, the REST API token
1575
+ and the cluster key stay as they are unless `accounts` is ticked: a backup from
1576
+ before a password change, a deleted account or a revoked token would otherwise
1577
+ bring those back. With `accounts`, they come from the backup too and a new
1578
+ session key ends every session made since (the admin restoring stays signed in
1579
+ if the backup has their account). A POST without the box (a script written for
1580
+ 2.8) restores everything else, as before."""
1581
+ if deny := require_role(request, "admin"):
1582
+ return deny
1583
+ form = await read_form(request)
1584
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1585
+ return csrf_failed()
1586
+ name = form.get("name", "")
1587
+ with_accounts = _checkbox(form, "accounts")
1588
+ try:
1589
+ restored = AppConfig.from_dict(json.loads(await asyncio.to_thread(state.store.read_backup, name)))
1590
+ except (ConfigError, OSError, ValueError, TypeError, AttributeError) as e:
1591
+ return await _failed(request, "backups", _("Could not read the backup: {0}").format(_unreadable(e)))
1592
+ if with_accounts and not restored.users:
1593
+ return await _failed(request, "backups", _("This backup has no user accounts (it "
1594
+ "was made before the first-run setup); "
1595
+ "restoring it would open the setup page "
1596
+ "to anyone. Not restored."))
1597
+ if with_accounts and not restored.admin_count():
1598
+ return await _failed(request, "backups", _("This backup has no admin account, so its "
1599
+ "accounts can't be restored. Not restored."))
1600
+ # never from the backup: the session key (renewed below with the accounts), this
1601
+ # node's cluster id, what start-up accepted while broken, so a restore is refused
1602
+ # only for what it changes or adds (AppConfig.validate_for_save), and what start-up
1603
+ # found in config.json (Settings says so); and unless asked, the accounts and
1604
+ # credentials
1605
+ keep = {"secret_key", "instance_id", "load_notes", "ids_created", "tolerated",
1606
+ "unknown_keys", "newer_writer"}
1607
+ if not with_accounts:
1608
+ keep |= {"users", "api_token_hash", "api_token_role", "api_tokens"}
1609
+ notes: list[str] = []
1610
+ moved: list[str] = []
1611
+ cur = state.config
1612
+ prepared = None
1613
+ try:
1614
+ async with state.edit_config() as edit:
1615
+ previous = {f.name: getattr(cur, f.name) for f in dataclasses.fields(AppConfig)}
1616
+ applied_from = reload.capture(cur)
1617
+ usable_before = cur.usable_admin_count()
1618
+ for f in dataclasses.fields(AppConfig):
1619
+ if f.name not in keep:
1620
+ setattr(cur, f.name, getattr(restored, f.name))
1621
+ # a mapping the backup has as it is keeps its object (its runner's)
1622
+ kept = {m.id: m for m in previous["mappings"]}
1623
+ cur.mappings = [kept[m.id] if m.id in kept and kept[m.id].to_dict() == m.to_dict()
1624
+ else m for m in cur.mappings]
1625
+ reload.keep_env_address(cur.admin_ui)
1626
+ # a backup made before the data directory moved (or on another machine) names
1627
+ # TLS files of the old one: those of this data directory's tls folder are used
1628
+ moved = cur.find_moved_tls_files(state.data_dir)
1629
+ if moved:
1630
+ notes.append(_("TLS files the backup names in another data directory are "
1631
+ "used from this one's tls folder."))
1632
+ was_admin = previous["admin_ui"]
1633
+ if (cur.admin_ui.tls_cert, cur.admin_ui.tls_key, cur.admin_ui.tls_key_password) != (
1634
+ was_admin.tls_cert, was_admin.tls_key, was_admin.tls_key_password):
1635
+ prepared = await _admin_tls(cur.admin_ui) # (as Settings -> Admin TLS)
1636
+ if with_accounts:
1637
+ cur.secret_key = secrets.token_hex(32)
1638
+ else: # both cluster keys are credentials: the key and the one still accepted
1639
+ was = previous["cluster"]
1640
+ cur.cluster = dataclasses.replace(cur.cluster, key=was.key,
1641
+ previous_key=was.previous_key,
1642
+ previous_until=was.previous_until)
1643
+ if cur.cluster.enabled and not cur.cluster.key.strip():
1644
+ cur.cluster.enabled = False
1645
+ notes.append(_("The cluster stays off: this backup had it on, but its "
1646
+ "key was not restored."))
1647
+ if usable_before and not cur.usable_admin_count():
1648
+ raise ConfigError(_("no admin could sign in afterwards (LDAP/OIDC accounts "
1649
+ "need their sign-in method on)"))
1650
+ except ConfigError as e: # nothing restored
1651
+ return await _failed(request, "backups", _("Restore rejected: {0}").format(e))
1652
+ if with_accounts:
1653
+ state.revoked_sessions.clear() # signed with the old key: dead anyway
1654
+ # as a reload applies config.json: an unchanged mapping keeps running with its
1655
+ # clients, a changed one restarts, a removed one goes, the cluster restarts only
1656
+ # when its settings changed
1657
+ await reload.apply_changes(state, applied_from)
1658
+ if state.admin_guard is not None:
1659
+ state.admin_guard.use_limits(cur.admin_ui)
1660
+ if (state.store.replaced_edit or {}).get("backup") == name:
1661
+ state.store.replaced_edit = None # (that edit is the one in use now)
1662
+ before = types.SimpleNamespace(**previous)
1663
+ changes = _restore_changes(before, cur, with_accounts)
1664
+ tok = i18n.set_language("en") # the log and the audit log stay English
1665
+ try:
1666
+ audit_changes = _restore_changes(before, cur, with_accounts)
1667
+ finally:
1668
+ i18n.reset(tok)
1669
+ state.log(f"configuration restored from backup {name} "
1670
+ f"({'with' if with_accounts else 'without'} its accounts)")
1671
+ for note in restored.load_notes + moved: # what loading the backup changed, as at start-up
1672
+ state.log(note)
1673
+ ctx.audit(request, "config_backup_restore",
1674
+ "; ".join([name, "accounts restored" if with_accounts else "accounts kept",
1675
+ *audit_changes]))
1676
+ ok = [(_("Restored {0}; the configuration it replaced was backed up too.")
1677
+ if edit.backed_up else
1678
+ _("Restored {0}, but the configuration it replaced could not be backed up (see "
1679
+ "all.log).")).format(name),
1680
+ _("Mappings it leaves as they are kept running.")]
1681
+ switched = False
1682
+ if prepared is not None:
1683
+ applied, switched = _tls_applied(request, prepared)
1684
+ ok.append(applied)
1685
+ ip, port, _tls = state.admin_serving()
1686
+ if (cur.admin_ui.bind_ip, cur.admin_ui.port) != (ip, port):
1687
+ ok.append(_("Its admin UI address {0} takes effect at the next start.").format(
1688
+ f"{cur.admin_ui.bind_ip}:{cur.admin_ui.port}"))
1689
+ ok.append(_("Everyone else was signed out.") if with_accounts else
1690
+ _("User accounts, the REST API token and the cluster key were kept."))
1691
+ if changes:
1692
+ ok.append(_("Access-related changes: {0}.").format("; ".join(changes)))
1693
+ resp = await _tls_answer(request, "backups", switched, ok=" ".join(ok + notes))
1694
+ me = current_user(request)
1695
+ mine = (cur.get_user(me.username)
1696
+ if with_accounts and me is not None and me.source != "anonymous" else None)
1697
+ if mine is not None:
1698
+ set_session(resp, request, mine)
1699
+ return resp
1700
+
1701
+ async def backup_delete(request):
1702
+ """Delete one backup (`name`), or every one (`all`)."""
1703
+ if deny := require_role(request, "admin"):
1704
+ return deny
1705
+ form = await read_form(request)
1706
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1707
+ return csrf_failed()
1708
+ name = str(form.get("name") or "")
1709
+ async with state.config_lock: # no save makes or prunes a backup meanwhile
1710
+ try:
1711
+ if _checkbox(form, "all"):
1712
+ gone = await asyncio.to_thread(state.store.delete_backups)
1713
+ else:
1714
+ await asyncio.to_thread(state.store.delete_backup, name)
1715
+ except ConfigError as e:
1716
+ return await _failed(request, "backups", str(e))
1717
+ except OSError as e:
1718
+ return await _failed(request, "backups", _("Could not delete: {0}").format(e))
1719
+ if _checkbox(form, "all"):
1720
+ state.log(f"all configuration backups deleted ({gone})")
1721
+ ctx.audit(request, "config_backups_delete_all", f"{gone} backups")
1722
+ return _done(request, "backups", ok=ngettext(
1723
+ "{0} configuration backup deleted.", "{0} configuration backups deleted.",
1724
+ gone).format(gone))
1725
+ state.log(f"configuration backup {name} deleted")
1726
+ ctx.audit(request, "config_backup_delete", name)
1727
+ return _done(request, "backups", ok=_("Backup of {0} UTC deleted.").format(backup_time(name)))
1728
+
1729
+ async def backup_remove_account(request):
1730
+ """Take an account that no longer exists out of every backup that still has it."""
1731
+ if deny := require_role(request, "admin"):
1732
+ return deny
1733
+ form = await read_form(request)
1734
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1735
+ return csrf_failed()
1736
+ username = str(form.get("username") or "")
1737
+ async with state.config_lock:
1738
+ if any(u.username == username for u in state.config.users):
1739
+ return await _failed(request, "backups", _(
1740
+ "'{0}' is an account now: delete it under Users first.").format(username))
1741
+ try:
1742
+ changed = await asyncio.to_thread(state.store.remove_account, username)
1743
+ except OSError as e:
1744
+ return await _failed(request, "backups", _("Could not change the backups: "
1745
+ "{0}").format(e))
1746
+ if not changed:
1747
+ return await _failed(request, "backups", _("No backup holds an account named "
1748
+ "'{0}'.").format(username))
1749
+ state.log(f"account {username!r} removed from {changed} configuration backups")
1750
+ ctx.audit(request, "config_backup_account_remove", f"{username}: {changed} backups")
1751
+ return _done(request, "backups", ok=ngettext(
1752
+ "'{0}' was removed from {1} configuration backup.",
1753
+ "'{0}' was removed from {1} configuration backups.", changed).format(username, changed))
1754
+
1755
+ async def config_reload(request):
1756
+ """Read config.json again and apply it, as SIGHUP / `ser2netweb --reload` do
1757
+ (ser2netweb/reload.py): for an edit made outside the UI (by hand, Ansible ...)."""
1758
+ if deny := require_role(request, "admin"):
1759
+ return deny
1760
+ form = await read_form(request)
1761
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1762
+ return csrf_failed()
1763
+ ok, message = await reload.reload_config(state, "Settings", client_ip(request),
1764
+ ctx.actor(request))
1765
+ if ok:
1766
+ return _done(request, "backups", ok=_("config.json was read again and applied: "
1767
+ "{0}").format(message))
1768
+ return await _failed(request, "backups", _("config.json was not reloaded, the "
1769
+ "configuration in use stays: {0}").format(
1770
+ message))
1771
+
1772
+ async def backup_restore_mappings(request):
1773
+ """Restore only a backup's mappings, through the import preview (replace)."""
1774
+ if deny := require_role(request, "admin"):
1775
+ return deny
1776
+ form = await read_form(request)
1777
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1778
+ return csrf_failed()
1779
+ name = form.get("name", "")
1780
+ try:
1781
+ raw = await asyncio.to_thread(state.store.read_backup, name)
1782
+ plan = await asyncio.to_thread(importer.parse_for_preview, raw.encode("utf-8"), name)
1783
+ except (ConfigError, OSError) as e:
1784
+ return await _failed(request, "backups", _("Could not read the backup: {0}").format(_unreadable(e)))
1785
+ plan.source = _("configuration backup ({0} UTC)").format(backup_time(name))
1786
+ plan.log_source = f"configuration backup ({backup_time(name)} UTC)"
1787
+ return await _import_preview(request, plan, "replace")
1788
+
1789
+ async def settings_cluster_post(request):
1790
+ if deny := require_role(request, "admin"):
1791
+ return deny
1792
+ form = await read_form(request)
1793
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1794
+ return csrf_failed()
1795
+ def refused(problem: str):
1796
+ """The form again with what was sent - never the key (typed again)."""
1797
+ old, current_form = state.config.cluster, form.get("cluster_form") == "2"
1798
+ shown = types.SimpleNamespace(
1799
+ enabled=form.get("enabled") == "on",
1800
+ discovery_port=form.get("discovery_port") or old.discovery_port,
1801
+ advertise_ip=(form.get("advertise_ip") or "").strip(),
1802
+ peers_text=form.get("peers") or "",
1803
+ legacy_auth=_checkbox(form, "legacy_auth") if current_form else old.legacy_auth,
1804
+ discovery=_checkbox(form, "discovery") if current_form else old.discovery,
1805
+ keep_old_key=_checkbox(form, "keep_old_key"),
1806
+ clear_previous_key=_checkbox(form, "clear_previous_key"),
1807
+ clear_key=_checkbox(form, "clear_key"))
1808
+ return _failed(request, "cluster-settings", problem + _typed_again(form, "key"),
1809
+ cluster=shown)
1810
+
1811
+ try:
1812
+ port = int(form.get("discovery_port") or 41750)
1813
+ except ValueError:
1814
+ return await refused(_("Cluster discovery port must be a number."))
1815
+ peers = [ln.strip() for ln in (form.get("peers") or "").splitlines() if ln.strip()]
1816
+ try: # each entry saved here must name an IP address or host name
1817
+ for entry in peers:
1818
+ parse_cluster_peer(entry, strict=True)
1819
+ except ConfigError as e:
1820
+ return await refused(str(e))
1821
+ generate = form.get("then") == "generate"
1822
+ old = state.config.cluster
1823
+ try:
1824
+ async with state.edit_config():
1825
+ old = state.config.cluster
1826
+ # The key is never shown: a blank field keeps it, "Generate" makes one (shown
1827
+ # once). A changed key keeps the old one accepted for a while unless that is
1828
+ # unticked (a planned change vs. a leaked key), so the fleet isn't split
1829
+ # until every node has the new one.
1830
+ key = secrets.token_urlsafe(32) if generate else (
1831
+ (form.get("key") or "").strip() or old.key)
1832
+ if _checkbox(form, "clear_key") and not generate:
1833
+ key = ""
1834
+ previous, until = old.previous_key, old.previous_until
1835
+ changed = key_bytes(key) != key_bytes(old.key)
1836
+ if changed:
1837
+ keep = bool(old.key.strip()) and _checkbox(form, "keep_old_key")
1838
+ previous, until = (old.key, time.time() + KEY_OVERLAP_S) if keep else ("", 0.0)
1839
+ if _checkbox(form, "clear_previous_key") or not key.strip():
1840
+ previous, until = "", 0.0
1841
+ # a form from before these switches existed (an open tab during an upgrade, a
1842
+ # script) leaves them as they are; an unticked box is only "off" in this form
1843
+ current_form = form.get("cluster_form") == "2"
1844
+ new = ClusterSettings(
1845
+ enabled=form.get("enabled") == "on",
1846
+ key=key,
1847
+ discovery_port=port,
1848
+ advertise_ip=(form.get("advertise_ip") or "").strip(),
1849
+ peers=peers,
1850
+ previous_key=previous, previous_until=until,
1851
+ legacy_auth=_checkbox(form, "legacy_auth") if current_form else old.legacy_auth,
1852
+ discovery=_checkbox(form, "discovery") if current_form else old.discovery,
1853
+ )
1854
+ state.config.cluster = new
1855
+ except ConfigError as e:
1856
+ return await refused(str(e))
1857
+ # apply live: restart discovery so a key/port/enable change takes effect now
1858
+ await state.cluster.stop()
1859
+ await state.cluster.start()
1860
+ state.log(f"cluster settings updated (enabled={new.enabled}, manual_peers={len(peers)}, "
1861
+ f"legacy_auth={new.legacy_auth}, discovery={new.discovery})")
1862
+ ctx.audit(request, "cluster_settings",
1863
+ "enabled" if new.active else "disabled")
1864
+ ok = _("Cluster settings saved.")
1865
+ if changed:
1866
+ kept = bool(new.previous_key) and new.previous_key == old.key
1867
+ how = "generated" if generate else ("removed" if not key else "set")
1868
+ until_text = time.strftime("%Y-%m-%d %H:%M", time.gmtime(until)) if kept else ""
1869
+ previous_note = (f"previous key accepted until {until_text} UTC" if kept
1870
+ else "previous key not accepted")
1871
+ state.log(f"cluster key {how}, {previous_note}")
1872
+ ctx.audit(request, "cluster_key_change", f"{how}, {previous_note}")
1873
+ if kept:
1874
+ ok += " " + _("The previous key is still accepted until {0} UTC, so the nodes "
1875
+ "not changed yet keep working; set the new key on each of "
1876
+ "them.").format(until_text)
1877
+ elif key:
1878
+ ok += " " + _("Only the new key is accepted now: set it on every node.")
1879
+ elif old.previous_key and not new.previous_key:
1880
+ state.log("cluster: the previous key is no longer accepted")
1881
+ ctx.audit(request, "cluster_key_change", "previous key no longer accepted")
1882
+ if generate: # shown once, on the page the redirect leads to (never in a URL)
1883
+ return _done(request, "cluster-settings", new_cluster_key=key, ok=ok)
1884
+ return _done(request, "cluster-settings", ok=ok)
1885
+
1886
+ async def settings_cluster_identity(request):
1887
+ """Give this node a new cluster id and a new session key - the way out for a copy
1888
+ of another installation (its data folder, disk or VM image): the copies no longer
1889
+ hide each other in the cluster, and a sign-in to one is not valid on the other.
1890
+ Everyone signed in here is signed out, except the admin doing this."""
1891
+ if deny := require_role(request, "admin"):
1892
+ return deny
1893
+ form = await read_form(request)
1894
+ if not auth.csrf_token_matches(request, form.get("_csrf")):
1895
+ return csrf_failed()
1896
+ old = (state.config.instance_id, state.config.secret_key)
1897
+ try:
1898
+ async with state.edit_config():
1899
+ old = (state.config.instance_id, state.config.secret_key)
1900
+ state.config.instance_id = secrets.token_hex(8)
1901
+ state.config.secret_key = secrets.token_hex(32)
1902
+ except ConfigError as e:
1903
+ return await _failed(request, "cluster-settings", str(e))
1904
+ await state.cluster.stop()
1905
+ await state.cluster.start()
1906
+ new_id = state.config.instance_id
1907
+ state.log(f"new identity for this node: cluster id {old[0][:8]} -> {new_id[:8]}, new "
1908
+ "session key (everyone else signed in here is signed out)")
1909
+ ctx.audit(request, "node_new_identity", f"{old[0][:8]} -> {new_id[:8]}")
1910
+ resp = _done(request, "cluster-settings",
1911
+ ok=_("This node has a new identity (cluster ID {0}). "
1912
+ "Everyone else signed in to it was signed out.").format(new_id[:8]))
1913
+ user = current_user(request)
1914
+ if user is not None and user.source != "anonymous":
1915
+ set_session(resp, request, user) # signed with the new key: this admin stays in
1916
+ return resp
1917
+
1918
+ return [
1919
+ Route("/settings", settings_page, methods=["GET"]),
1920
+ Route("/settings/password", settings_password_post, methods=["POST"]),
1921
+ Route("/settings/sessions/signout-others", settings_signout_others, methods=["POST"]),
1922
+ Route("/settings/tls", settings_tls_post, methods=["POST"]),
1923
+ Route("/settings/tls/generate", settings_tls_generate, methods=["POST"]),
1924
+ Route("/settings/access", settings_access_post, methods=["POST"]),
1925
+ Route("/settings/proxy", settings_proxy_post, methods=["POST"]),
1926
+ Route("/settings/node-name", settings_node_name_post, methods=["POST"]),
1927
+ Route("/settings/request-log", settings_request_log_post, methods=["POST"]),
1928
+ Route("/settings/login-notice", settings_login_notice_post, methods=["POST"]),
1929
+ Route("/settings/sessions/revoke-all", settings_sessions_revoke_all, methods=["POST"]),
1930
+ Route("/settings/api-token", settings_api_token_post, methods=["POST"]),
1931
+ Route("/settings/api-token/role", settings_api_token_role, methods=["POST"]),
1932
+ Route("/settings/api-token/revoke", settings_api_token_revoke, methods=["POST"]),
1933
+ Route("/settings/api-tokens", settings_api_tokens_create, methods=["POST"]),
1934
+ Route("/settings/api-tokens/revoke", settings_api_tokens_revoke, methods=["POST"]),
1935
+ Route("/settings/users", settings_users_create, methods=["POST"]),
1936
+ Route("/settings/users/password", settings_users_password, methods=["POST"]),
1937
+ Route("/settings/users/role", settings_users_role, methods=["POST"]),
1938
+ Route("/settings/users/delete", settings_users_delete, methods=["POST"]),
1939
+ Route("/settings/users/signout", settings_users_signout, methods=["POST"]),
1940
+ Route("/settings/users/expiry", settings_users_expiry, methods=["POST"]),
1941
+ Route("/settings/users/totp-reset", settings_users_totp_reset, methods=["POST"]),
1942
+ Route("/settings/retention", settings_retention, methods=["POST"]),
1943
+ # the user name in the URL: older pages and scripts (only names without /)
1944
+ Route("/settings/users/{username}/role", settings_users_role, methods=["POST"]),
1945
+ Route("/settings/users/{username}/delete", settings_users_delete, methods=["POST"]),
1946
+ Route("/settings/users/{username}/signout", settings_users_signout, methods=["POST"]),
1947
+ Route("/settings/ldap", settings_ldap_post, methods=["POST"]),
1948
+ Route("/settings/oidc", settings_oidc_post, methods=["POST"]),
1949
+ Route("/settings/cluster", settings_cluster_post, methods=["POST"]),
1950
+ Route("/settings/cluster/identity", settings_cluster_identity, methods=["POST"]),
1951
+ Route("/settings/config/export", config_export),
1952
+ # the body is refused (413) past the import file limit, before it is spooled
1953
+ Route("/settings/config/import", config_import, methods=["POST"],
1954
+ max_body_size=IMPORT_MAX_BYTES),
1955
+ Route("/settings/config/import/apply", config_import_apply, methods=["POST"]),
1956
+ Route("/settings/notifications", settings_notifications_post, methods=["POST"]),
1957
+ Route("/settings/audit", audit_page),
1958
+ Route("/settings/audit.csv", audit_csv),
1959
+ Route("/settings/backups/restore", backup_restore, methods=["POST"]),
1960
+ Route("/settings/config/reload", config_reload, methods=["POST"]),
1961
+ Route("/settings/backups/restore-mappings", backup_restore_mappings, methods=["POST"]),
1962
+ Route("/settings/backups/delete", backup_delete, methods=["POST"]),
1963
+ Route("/settings/backups/remove-account", backup_remove_account, methods=["POST"]),
1964
+ Route("/settings/backups/{name}", backup_download),
1965
+ ]
1966
+
1967
+
1968
+ def _load_tls_pair(cert: str, key: str, password: str) -> None:
1969
+ """Load a certificate and key as the admin UI will, an encrypted key with ``password``
1970
+ (raises tls.TlsFileError). A file that is missing is left to the save, which says
1971
+ which one."""
1972
+ if not (os.path.isfile(data_path(cert)) and os.path.isfile(data_path(key))):
1973
+ return
1974
+ tls.tls_context(server_side=True, cert=cert, key=key, password=password)
1975
+
1976
+
1977
+ def _keep_previous(paths) -> list[str]:
1978
+ """Copy each of `paths` that exists to <path>.bak (permissions kept: a private key
1979
+ stays private); the copies made."""
1980
+ kept = []
1981
+ for path in paths:
1982
+ if os.path.isfile(path):
1983
+ shutil.copy2(path, path + ".bak")
1984
+ kept.append(path + ".bak")
1985
+ return kept
1986
+
1987
+
1988
+ def _local_time(epoch: float) -> str:
1989
+ return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(epoch)) if epoch else "?"
1990
+
1991
+
1992
+ def _key_hint(key: str) -> str:
1993
+ """Why the cluster key deserves a warning: "weak" (any host on the network can try
1994
+ to guess it offline from a beacon), "ascii" (nodes up to 2.8.0 can't use it) or ""."""
1995
+ k = unicodedata.normalize("NFKC", key or "").strip()
1996
+ if not k:
1997
+ return ""
1998
+ if len(k) < 16 or len(set(k)) < 8:
1999
+ return "weak"
2000
+ return "" if k.isascii() else "ascii"
2001
+
2002
+
2003
+ # ---------------- what a restore changes for sign-in and access ----------------
2004
+ def _setting_changes(old, new) -> list[str]:
2005
+ """Sign-in / access settings and the cluster's that differ between two configurations
2006
+ (AppConfig or the same fields), as lines for the user (and, in English, the audit
2007
+ log)."""
2008
+ def onoff(value) -> str:
2009
+ return _("enabled") if value else _("disabled")
2010
+
2011
+ def role(value) -> str:
2012
+ return value or _("— deny login —")
2013
+
2014
+ out = []
2015
+
2016
+ def diff(label, a, b) -> None:
2017
+ if a != b:
2018
+ out.append(f"{label}: {a} → {b}")
2019
+
2020
+ o, n = old.admin_ui, new.admin_ui
2021
+ diff(_("Login required"), onoff(o.require_login), onoff(n.require_login))
2022
+ diff(_("Role for visitors without login"), o.anonymous_role, n.anonymous_role)
2023
+ diff(_("Admin UI address"), host_port(o.bind_ip, o.port), host_port(n.bind_ip, n.port))
2024
+ diff(_("Instance name"), o.node_name or "—", n.node_name or "—")
2025
+ diff(_("Admin TLS (HTTPS)"), onoff(o.tls_enabled), onoff(n.tls_enabled))
2026
+ diff(_("HSTS max-age (seconds)"), o.hsts_max_age, n.hsts_max_age)
2027
+ diff(_("Public URL"), o.public_url or "—", n.public_url or "—")
2028
+ diff(_("Trusted proxies"), " ".join(o.trusted_proxies) or "—",
2029
+ " ".join(n.trusted_proxies) or "—")
2030
+ diff(_("HSTS through the reverse proxy"), onoff(o.proxy_hsts), onoff(n.proxy_hsts))
2031
+ for label, a, b in (("LDAP", old.ldap, new.ldap), ("OIDC", old.oidc, new.oidc)):
2032
+ diff(label, onoff(a.enabled), onoff(b.enabled))
2033
+ diff(f"{label} · {_('Default role')}", role(a.default_role), role(b.default_role))
2034
+ diff(f"{label} · {_('Admin group')}", a.admin_group or "—", b.admin_group or "—")
2035
+ diff(f"LDAP · {_('Server certificate check')}", onoff(old.ldap.tls_verify),
2036
+ onoff(new.ldap.tls_verify))
2037
+
2038
+ def days(value) -> str:
2039
+ return _("{0} days").format(value) if value else _("never")
2040
+
2041
+ diff(_("Remove LDAP / OIDC accounts not signed in for"),
2042
+ days(old.external_users_expire_days), days(new.external_users_expire_days))
2043
+ # the cluster's settings (its keys are credentials: _account_changes)
2044
+ o, n = old.cluster, new.cluster
2045
+ cluster = _("LAN cluster")
2046
+ diff(cluster, onoff(o.enabled), onoff(n.enabled))
2047
+ diff(f"{cluster} · {_('Legacy authentication')}", onoff(o.legacy_auth), onoff(n.legacy_auth))
2048
+ diff(f"{cluster} · {_('Automatic discovery')}", onoff(o.discovery), onoff(n.discovery))
2049
+ diff(f"{cluster} · {_('Discovery UDP port')}", o.discovery_port, n.discovery_port)
2050
+ diff(f"{cluster} · {_('Advertise IP')}", o.advertise_ip or "—", n.advertise_ip or "—")
2051
+ diff(f"{cluster} · {_('Manual peers')}", ", ".join(o.peers) or "—", ", ".join(n.peers) or "—")
2052
+ return out
2053
+
2054
+
2055
+ def _account_changes(old, new) -> list[str]:
2056
+ """What restoring a backup's accounts and credentials would change."""
2057
+ before = {u.username: u for u in old.users}
2058
+ after = {u.username: u for u in new.users}
2059
+ out = []
2060
+ if added := [n for n in after if n not in before]:
2061
+ out.append(_("Accounts it brings back: {0}").format(", ".join(added)))
2062
+ if removed := [n for n in before if n not in after]:
2063
+ out.append(_("Accounts it removes: {0}").format(", ".join(removed)))
2064
+ if changed := [n for n in after if n in before and
2065
+ (after[n].password_hash, after[n].role, after[n].source)
2066
+ != (before[n].password_hash, before[n].role, before[n].source)]:
2067
+ out.append(_("Accounts with another password or role: {0}").format(", ".join(changed)))
2068
+ if two_factor := [n for n in after if n in before
2069
+ and after[n].totp_secret != before[n].totp_secret]:
2070
+ out.append(_("Accounts whose two-factor sign-in changes: {0}").format(", ".join(two_factor)))
2071
+ if (old.api_token_hash, old.api_token_role) != (new.api_token_hash, new.api_token_role):
2072
+ out.append(_("The REST API token as it was then (the current one stops working)"))
2073
+ if ({(t.sha256, t.role, t.expires) for t in old.api_tokens}
2074
+ != {(t.sha256, t.role, t.expires) for t in new.api_tokens}):
2075
+ out.append(_("The other REST API tokens as they were then: {0}").format(
2076
+ ", ".join(t.name for t in new.api_tokens) or _("none")))
2077
+ if (old.cluster.key, old.cluster.previous_key) != (new.cluster.key, new.cluster.previous_key):
2078
+ out.append(_("The cluster key as it was then"))
2079
+ return out
2080
+
2081
+
2082
+ def _restore_changes(old, new, with_accounts: bool) -> list[str]:
2083
+ return _setting_changes(old, new) + (_account_changes(old, new) if with_accounts else [])
2084
+
2085
+
2086
+ def _restore_preview(name: str, cur, backup, me) -> dict:
2087
+ notes = []
2088
+ accounts_ok = bool(backup.users) and backup.admin_count() > 0
2089
+ if not accounts_ok:
2090
+ notes.append(_("This backup has no admin account, so its accounts can't be restored."))
2091
+ elif me is not None and me.source != "anonymous":
2092
+ mine = backup.get_user(me.username)
2093
+ if mine is None:
2094
+ notes.append(_("Your account “{0}” is not in this backup: restoring its accounts signs "
2095
+ "you out.").format(me.username))
2096
+ elif mine.role != me.role:
2097
+ notes.append(_("In this backup your account “{0}” has the role {1}.").format(
2098
+ me.username, mine.role))
2099
+ return {"name": name, "time": backup_time(name), "settings": _setting_changes(cur, backup),
2100
+ "accounts": _account_changes(cur, backup), "accounts_ok": accounts_ok, "notes": notes}
2101
+
2102
+
2103
+ def _without_session_key(text: str) -> str:
2104
+ """A downloaded backup leaves out secret_key, which signs the session cookies.
2105
+ Restoring never takes the key from a backup, and a config.json without one gets a
2106
+ new key at the next start."""
2107
+ try:
2108
+ doc = json.loads(text)
2109
+ except ValueError:
2110
+ return text
2111
+ if not isinstance(doc, dict) or "secret_key" not in doc:
2112
+ return text
2113
+ del doc["secret_key"]
2114
+ return json.dumps(doc, indent=2, ensure_ascii=False)
2115
+
2116
+
2117
+ def _unreadable(e: BaseException) -> str:
2118
+ """Why a backup could not be read, for the page — in its language for the usual
2119
+ causes, not Python's own text (a JSON parser's "Expecting ',' delimiter: line 3
2120
+ column 5 (char 20)", "'list' object has no attribute 'get'")."""
2121
+ if isinstance(e, ConfigError):
2122
+ return str(e)
2123
+ if isinstance(e, json.JSONDecodeError):
2124
+ return _("it is not valid JSON (line {0}, column {1})").format(e.lineno, e.colno)
2125
+ if isinstance(e, UnicodeDecodeError):
2126
+ return _("it is not UTF-8 text")
2127
+ if isinstance(e, OSError):
2128
+ return explain(e, "file") or str(e)
2129
+ return _("it is not a ser2netweb configuration")
2130
+
2131
+
2132
+ def _csv_cell(value: str) -> str:
2133
+ """A spreadsheet must not run a cell as a formula (mapping names and user names
2134
+ end up in the audit detail): prefix the characters that start one."""
2135
+ return "'" + value if value[:1] in ("=", "+", "-", "@", "\t", "\r") else value