ser2netweb 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|