ser2netweb 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. ser2netweb/__init__.py +20 -0
  2. ser2netweb/__main__.py +6 -0
  3. ser2netweb/accounts.py +503 -0
  4. ser2netweb/cli.py +489 -0
  5. ser2netweb/clock.py +128 -0
  6. ser2netweb/config.py +4491 -0
  7. ser2netweb/console.py +181 -0
  8. ser2netweb/datalock.py +181 -0
  9. ser2netweb/engine/__init__.py +0 -0
  10. ser2netweb/engine/access.py +173 -0
  11. ser2netweb/engine/bindhint.py +115 -0
  12. ser2netweb/engine/bridge.py +1928 -0
  13. ser2netweb/engine/cluster.py +1140 -0
  14. ser2netweb/engine/modbus.py +530 -0
  15. ser2netweb/engine/modbus_gateway.py +1465 -0
  16. ser2netweb/engine/mqtt_pub.py +632 -0
  17. ser2netweb/engine/netguard.py +805 -0
  18. ser2netweb/engine/netinfo.py +199 -0
  19. ser2netweb/engine/portdiag.py +414 -0
  20. ser2netweb/engine/portlist.py +471 -0
  21. ser2netweb/engine/protocols/__init__.py +28 -0
  22. ser2netweb/engine/protocols/base.py +117 -0
  23. ser2netweb/engine/protocols/raw.py +12 -0
  24. ser2netweb/engine/protocols/rfc2217.py +586 -0
  25. ser2netweb/engine/protocols/telnet.py +172 -0
  26. ser2netweb/engine/serial_compat.py +598 -0
  27. ser2netweb/engine/serial_io.py +210 -0
  28. ser2netweb/engine/serial_link.py +414 -0
  29. ser2netweb/engine/sockopts.py +103 -0
  30. ser2netweb/engine/sparkplug.py +538 -0
  31. ser2netweb/engine/sparkplug_node.py +550 -0
  32. ser2netweb/engine/supervisor.py +533 -0
  33. ser2netweb/engine/tls.py +232 -0
  34. ser2netweb/envvars.py +57 -0
  35. ser2netweb/explain.py +117 -0
  36. ser2netweb/healthcheck.py +78 -0
  37. ser2netweb/i18n.py +110 -0
  38. ser2netweb/importer.py +550 -0
  39. ser2netweb/layout.py +118 -0
  40. ser2netweb/licences.py +86 -0
  41. ser2netweb/locale/tr.json +1569 -0
  42. ser2netweb/logsetup.py +253 -0
  43. ser2netweb/logtext.py +23 -0
  44. ser2netweb/notify.py +761 -0
  45. ser2netweb/optional.py +76 -0
  46. ser2netweb/reload.py +295 -0
  47. ser2netweb/runtime.py +762 -0
  48. ser2netweb/sdnotify.py +85 -0
  49. ser2netweb/ser2net_import.py +2001 -0
  50. ser2netweb/signins.py +98 -0
  51. ser2netweb/standalone.py +154 -0
  52. ser2netweb/state.py +1457 -0
  53. ser2netweb/storage.py +130 -0
  54. ser2netweb/textfold.py +51 -0
  55. ser2netweb/tlsreload.py +37 -0
  56. ser2netweb/watchdog.py +201 -0
  57. ser2netweb/web/__init__.py +0 -0
  58. ser2netweb/web/api.py +793 -0
  59. ser2netweb/web/auth.py +699 -0
  60. ser2netweb/web/certinfo.py +144 -0
  61. ser2netweb/web/httpguard.py +413 -0
  62. ser2netweb/web/ldap_auth.py +556 -0
  63. ser2netweb/web/oidc_auth.py +330 -0
  64. ser2netweb/web/proxy.py +398 -0
  65. ser2netweb/web/routes/__init__.py +78 -0
  66. ser2netweb/web/routes/cluster.py +669 -0
  67. ser2netweb/web/routes/common.py +359 -0
  68. ser2netweb/web/routes/console.py +451 -0
  69. ser2netweb/web/routes/forms.py +853 -0
  70. ser2netweb/web/routes/login.py +573 -0
  71. ser2netweb/web/routes/mappings.py +470 -0
  72. ser2netweb/web/routes/ops.py +374 -0
  73. ser2netweb/web/routes/settings.py +2135 -0
  74. ser2netweb/web/routes/system.py +308 -0
  75. ser2netweb/web/routes/totp.py +282 -0
  76. ser2netweb/web/selfsigned.py +129 -0
  77. ser2netweb/web/server.py +483 -0
  78. ser2netweb/web/static/app.css +327 -0
  79. ser2netweb/web/static/app.js +1102 -0
  80. ser2netweb/web/static/htmx.min.js +1 -0
  81. ser2netweb/web/static/points.js +218 -0
  82. ser2netweb/web/static/theme.js +7 -0
  83. ser2netweb/web/static/xterm-addon-fit.js +2 -0
  84. ser2netweb/web/static/xterm.css +218 -0
  85. ser2netweb/web/static/xterm.js +2 -0
  86. ser2netweb/web/templates/_cluster_body.html +109 -0
  87. ser2netweb/web/templates/_console.html +21 -0
  88. ser2netweb/web/templates/_footer.html +13 -0
  89. ser2netweb/web/templates/_login_notice.html +8 -0
  90. ser2netweb/web/templates/_macros.html +21 -0
  91. ser2netweb/web/templates/_mapping_form.html +477 -0
  92. ser2netweb/web/templates/_mapping_log.html +28 -0
  93. ser2netweb/web/templates/_mapping_traces.html +44 -0
  94. ser2netweb/web/templates/_mappings_body.html +149 -0
  95. ser2netweb/web/templates/_modbus_read.html +18 -0
  96. ser2netweb/web/templates/_ports_table.html +37 -0
  97. ser2netweb/web/templates/_serial_port_select.html +48 -0
  98. ser2netweb/web/templates/_shell.html +41 -0
  99. ser2netweb/web/templates/audit.html +55 -0
  100. ser2netweb/web/templates/base.html +77 -0
  101. ser2netweb/web/templates/dashboard.html +72 -0
  102. ser2netweb/web/templates/licenses.html +18 -0
  103. ser2netweb/web/templates/login.html +32 -0
  104. ser2netweb/web/templates/login_totp.html +23 -0
  105. ser2netweb/web/templates/settings.html +830 -0
  106. ser2netweb/web/templates/setup.html +43 -0
  107. ser2netweb/web/templates/totp.html +41 -0
  108. ser2netweb/web/totp.py +94 -0
  109. ser2netweb/winservice.py +1793 -0
  110. ser2netweb-3.0.0.dist-info/METADATA +120 -0
  111. ser2netweb-3.0.0.dist-info/RECORD +121 -0
  112. ser2netweb-3.0.0.dist-info/WHEEL +5 -0
  113. ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
  114. ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
  115. ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
  116. ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
  117. ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
  118. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
  119. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
  120. ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
  121. ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
ser2netweb/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """ser2netweb — serial-to-network bridge with a web config UI."""
2
+
3
+ # Single source of truth for the version (ser2netweb.py and the web layer import this).
4
+ __version__ = "3.0.0"
5
+
6
+ # The product's name: up to 2.9.x it was ser2net (no relation to the C ser2net it can
7
+ # import the configuration of; NOTICE)
8
+ NAME = "ser2netweb"
9
+
10
+
11
+ def user_agent() -> str:
12
+ """The User-Agent of the HTTP requests it makes: webhooks, OpenID Connect, cluster peers
13
+ (no node reads a peer's: the version comes in the JSON answers)."""
14
+ return f"{NAME}/{__version__}"
15
+
16
+
17
+ # Where this program's source code is offered: the web UI's footer links it (AGPL-3.0
18
+ # section 13). A modified copy that others use over a network names its own source in
19
+ # SER2NETWEB_SOURCE_URL (an http:// or https:// address), see ser2netweb/licences.py.
20
+ SOURCE_URL = "https://github.com/haliskilic/ser2netweb"
ser2netweb/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m ser2netweb: the same as the ser2netweb command a pip / pipx install has (and
2
+ as python3 ser2netweb.py in a source checkout)."""
3
+ from ser2netweb.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
ser2netweb/accounts.py ADDED
@@ -0,0 +1,503 @@
1
+ """Account recovery and the first admin account, without the web UI.
2
+
3
+ ser2netweb --set-password NAME a new password for local account NAME, whose sessions
4
+ end and whose two-factor sign-in is turned off (NAME
5
+ is created as an admin if it doesn't exist)
6
+ ser2netweb --sign-out-all a new session key: every web UI session ends
7
+ ser2netweb --reload the running instance reads config.json again
8
+ (ser2netweb/reload.py); only while one runs
9
+ SER2NETWEB_ADMIN_PASSWORD_FILE (+ SER2NETWEB_ADMIN_USER, default "admin"; or their
10
+ 2.9.x names SER2NET_ADMIN_PASSWORD_FILE / _USER) the
11
+ first admin of an install without accounts, e.g. a
12
+ Docker secret
13
+
14
+ The commands work whether ser2netweb is running or not. The data directory's lock
15
+ (ser2netweb/datalock.py, <data>/ser2net.lock) tells which: while an instance of the same data
16
+ directory holds it, the change is handed to that instance: a request file in
17
+ <data>/admin-requests/ that it applies under its config lock, so its next save can't
18
+ undo the change and sessions end at once (this also reaches an instance in another
19
+ container or network namespace that shares the data directory). Otherwise the command
20
+ takes the lock itself — a ser2netweb that starts meanwhile waits for it — and changes
21
+ config.json here, through ConfigStore, which keeps the version it replaces in backups/.
22
+ Where the lock can't be used (a read-only data directory, a network share without
23
+ locks), and for a ser2netweb from before the lock, the web UI's /healthz tells whether one
24
+ runs. Either way the change is written to audit.log. The password is asked twice on
25
+ the terminal, or read from the first line of stdin when that is not a terminal; only
26
+ its scrypt hash leaves this process, and nothing secret is printed or logged. Run as
27
+ root on a data directory that belongs to another user (docker exec, sudo), the command
28
+ first becomes that user, so the files it writes keep the owner the running ser2netweb
29
+ needs.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import getpass
35
+ import json
36
+ import os
37
+ import re
38
+ import secrets
39
+ import sys
40
+ import tempfile
41
+ import time
42
+ from dataclasses import dataclass
43
+ from typing import Callable, Optional
44
+
45
+ from . import datalock, envvars, healthcheck
46
+ from .config import ConfigStore, User, host_port, new_pwd_version
47
+ from .web import auth
48
+
49
+ REQUEST_DIR = "admin-requests" # in the data directory
50
+ POLL_S = 1.0 # how often a running instance looks for requests
51
+ REQUEST_MAX_AGE_S = 120.0 # an older request is answered "expired", not applied
52
+ WAIT_RUNNING_S = 20.0 # an instance runs: wait this long for it to take it
53
+ WAIT_STOPPED_S = 3.0 # no lock, no /healthz: still wait a moment (another container?)
54
+ WAIT_ANSWER_S = 30.0 # taken: wait this long for its answer
55
+ ACTOR = "(command line)" # the audit log's user column
56
+ _REQUEST_ID = re.compile(r"^[0-9a-f]{16}$")
57
+
58
+
59
+ class AccountError(Exception):
60
+ """A problem for the person at the command line (printed; exit code 1)."""
61
+
62
+
63
+ @dataclass
64
+ class Change:
65
+ """What apply() changed: the reply, the audit record, and how to undo it in memory
66
+ if saving fails."""
67
+ message: str
68
+ action: str
69
+ detail: str
70
+ undo: Callable[[], None]
71
+ first_admin: bool = False
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # the changes (shared by the command line and a running instance)
76
+ # --------------------------------------------------------------------------
77
+ def apply(config, request: dict) -> Change:
78
+ """Make one requested change to `config`, in memory. Raises AccountError."""
79
+ action = request.get("action")
80
+ if action == "set_password":
81
+ return _set_password(config, str(request.get("username") or ""),
82
+ str(request.get("password_hash") or ""))
83
+ if action == "sign_out_all":
84
+ old = config.secret_key
85
+ config.secret_key = secrets.token_hex(32)
86
+
87
+ def undo() -> None:
88
+ config.secret_key = old
89
+ return Change("every web UI session was signed out (new session key)",
90
+ "sessions_revoke_all", "", undo)
91
+ raise AccountError(f"unknown request {action!r}")
92
+
93
+
94
+ def _set_password(config, username: str, password_hash: str) -> Change:
95
+ from .web.routes.forms import _username_problem
96
+
97
+ if problem := _username_problem(username):
98
+ raise AccountError(problem)
99
+ if not auth.is_password_hash(password_hash):
100
+ raise AccountError("the request carries no valid password hash")
101
+ user = config.get_user(username)
102
+ if user is None and (taken := config.named(username)):
103
+ # names that differ only in letter case are one name for LDAP, and easily mistaken
104
+ other = taken[0]
105
+ if other.source == "local":
106
+ raise AccountError(f"there is a local account '{other.username}' (another letter "
107
+ f"case); run the command with that name")
108
+ raise AccountError(f"'{other.username}' is an {other.source.upper()} account (letter "
109
+ "case aside); give a new local admin account another name")
110
+ if user is None:
111
+ first = not config.users
112
+ new = User(username=username, password_hash=password_hash, role="admin",
113
+ pwd_version=new_pwd_version())
114
+ config.users.append(new)
115
+
116
+ def remove() -> None:
117
+ config.users.remove(new)
118
+ return Change(f"local admin account '{username}' created",
119
+ "first_run_setup" if first else "user_create",
120
+ username if first else f"{username}:admin", remove, first_admin=first)
121
+ if user.source != "local":
122
+ raise AccountError(f"'{username}' is an {user.source.upper()} account, whose password "
123
+ "is managed there; give a new local admin account another name")
124
+ old = (user.password_hash, user.pwd_version, user.totp_secret)
125
+ # this is also the way back in for someone who lost their authenticator app: the new
126
+ # password alone signs in, and they can set two-factor sign-in up again
127
+ user.password_hash, user.pwd_version, user.totp_secret = (password_hash,
128
+ user.pwd_version + 1, "")
129
+
130
+ def restore() -> None:
131
+ user.password_hash, user.pwd_version, user.totp_secret = old
132
+ two_factor = "; two-factor sign-in was turned off (set it up again in Settings)" if old[2] else ""
133
+ return Change(f"new password set for '{username}' ({user.role}); its sessions were "
134
+ f"signed out{two_factor}", "user_password_set",
135
+ username + (" (two-factor sign-in off)" if old[2] else ""), restore)
136
+
137
+
138
+ PASSWORD_FILE_ENV = "SER2NETWEB_ADMIN_PASSWORD_FILE" # noqa: S105 (a variable name)
139
+ ADMIN_USER_ENV = "SER2NETWEB_ADMIN_USER"
140
+
141
+
142
+ def provision_requested(environ) -> bool:
143
+ """SER2NETWEB_ADMIN_PASSWORD_FILE / SER2NETWEB_ADMIN_USER (or their 2.9.x names) is
144
+ set: the first admin comes from the environment, never from the setup page."""
145
+ return bool(envvars.text(PASSWORD_FILE_ENV, environ)
146
+ or envvars.text(ADMIN_USER_ENV, environ))
147
+
148
+
149
+ def provision_from_env(config, environ) -> tuple[Optional[str], Optional[str]]:
150
+ """SER2NETWEB_ADMIN_PASSWORD_FILE (+ SER2NETWEB_ADMIN_USER; or their 2.9.x names): add
151
+ the first admin to an install that has no account yet. Returns (the name created, a
152
+ line for the log, which names the variables as they are set); (None, None) when nothing
153
+ was asked for."""
154
+ path, name = envvars.text(PASSWORD_FILE_ENV, environ), envvars.text(ADMIN_USER_ENV, environ)
155
+ file_var = envvars.source(PASSWORD_FILE_ENV, environ)
156
+ user_var = envvars.source(ADMIN_USER_ENV, environ)
157
+ if config.users:
158
+ if path:
159
+ return None, (f"{file_var} is ignored: accounts exist already "
160
+ "(ser2netweb --set-password changes a password)")
161
+ return None, None
162
+ if not path:
163
+ if name:
164
+ return None, (f"ERROR: {user_var} is set but {file_var} is "
165
+ "not: no admin account was created")
166
+ return None, None
167
+ from .web.routes.forms import _password_problem, _username_problem
168
+
169
+ name = name or "admin"
170
+ if problem := _username_problem(name):
171
+ return None, f"ERROR: {user_var}: {problem}"
172
+ try:
173
+ with open(path, encoding="utf-8") as fh:
174
+ password = fh.read(4096)
175
+ except (OSError, UnicodeDecodeError) as e:
176
+ return None, f"ERROR: {file_var}: cannot read {path}: {e}"
177
+ password = password.rstrip("\r\n") # a file written with echo / a secret ends in one
178
+ if problem := _password_problem(password, password):
179
+ return None, f"ERROR: {file_var}: {problem}"
180
+ config.users.append(User(username=name, password_hash=auth.hash_password(password),
181
+ role="admin", pwd_version=new_pwd_version()))
182
+ return name, f"admin account '{name}' created from {file_var}"
183
+
184
+
185
+ # --------------------------------------------------------------------------
186
+ # hand-over to a running instance: request files in <data>/admin-requests/
187
+ # <id>.req written by the command (atomically)
188
+ # <id>.work the instance took it (rename: either it or the command's withdrawal wins)
189
+ # <id>.res the instance's answer {"ok": bool, "message": str}
190
+ # --------------------------------------------------------------------------
191
+ def _request_dir(data_dir: str) -> str:
192
+ return os.path.join(data_dir, REQUEST_DIR)
193
+
194
+
195
+ def _write_atomic(path: str, text: str) -> None:
196
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), prefix=".", suffix=".tmp")
197
+ try:
198
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
199
+ fh.write(text)
200
+ os.replace(tmp, path)
201
+ finally:
202
+ with contextlib.suppress(OSError):
203
+ os.unlink(tmp)
204
+
205
+
206
+ def post_request(data_dir: str, request: dict) -> str:
207
+ """Leave a request for a running instance; returns its id."""
208
+ request_id = secrets.token_hex(8)
209
+ d = _request_dir(data_dir)
210
+ os.makedirs(d, mode=0o700, exist_ok=True)
211
+ _write_atomic(os.path.join(d, request_id + ".req"), json.dumps({**request, "time": time.time()}))
212
+ return request_id
213
+
214
+
215
+ def take_requests(data_dir: str) -> list[tuple[str, Optional[dict]]]:
216
+ """The running instance's side: take every waiting request — (id, request), or
217
+ (id, None) for one that is unreadable or too old — and tidy up leftovers."""
218
+ d = _request_dir(data_dir)
219
+ try:
220
+ names = sorted(os.listdir(d))
221
+ except OSError:
222
+ return []
223
+ now, taken = time.time(), []
224
+ for name in names:
225
+ path = os.path.join(d, name)
226
+ request_id, ext = os.path.splitext(name)
227
+ if ext != ".req" or not _REQUEST_ID.match(request_id):
228
+ with contextlib.suppress(OSError): # an answer nobody read, a stray temp file
229
+ if now - os.path.getmtime(path) > 600:
230
+ os.remove(path)
231
+ continue
232
+ work = os.path.join(d, request_id + ".work")
233
+ try:
234
+ os.replace(path, work)
235
+ except OSError:
236
+ continue # withdrawn by its command in the meantime
237
+ try:
238
+ with open(work, encoding="utf-8") as fh:
239
+ request = json.load(fh)
240
+ age = now - float(request["time"])
241
+ except (OSError, ValueError, TypeError, KeyError):
242
+ request, age = None, 0.0
243
+ with contextlib.suppress(OSError):
244
+ os.remove(work)
245
+ if not isinstance(request, dict) or not 0 <= age <= REQUEST_MAX_AGE_S:
246
+ request = None
247
+ taken.append((request_id, request))
248
+ return taken
249
+
250
+
251
+ def answer(data_dir: str, request_id: str, ok: bool, message: str) -> None:
252
+ _write_atomic(os.path.join(_request_dir(data_dir), request_id + ".res"),
253
+ json.dumps({"ok": ok, "message": message}))
254
+
255
+
256
+ def _read_answer(path: str) -> Optional[tuple[bool, str]]:
257
+ try:
258
+ with open(path, encoding="utf-8") as fh:
259
+ doc = json.load(fh)
260
+ except (OSError, ValueError):
261
+ return None # not there (yet)
262
+ with contextlib.suppress(OSError):
263
+ os.remove(path)
264
+ return bool(doc.get("ok")), str(doc.get("message", ""))
265
+
266
+
267
+ def wait_answer(data_dir: str, request_id: str, take_s: float, answer_s: float = WAIT_ANSWER_S,
268
+ sleep=time.sleep, gone: Optional[Callable[[], bool]] = None
269
+ ) -> Optional[tuple[bool, str]]:
270
+ """The command's side: a running instance's (ok, message), or None when no instance
271
+ took the request within take_s seconds, or before gone() said it has stopped (it is
272
+ withdrawn then)."""
273
+ d = _request_dir(data_dir)
274
+ request_path, answer_path = (os.path.join(d, request_id + ext) for ext in (".req", ".res"))
275
+ end = time.monotonic() + take_s
276
+ while (result := _read_answer(answer_path)) is None and time.monotonic() < end:
277
+ if gone is not None and gone():
278
+ break
279
+ sleep(0.2)
280
+ if result is not None:
281
+ return result
282
+ try:
283
+ os.remove(request_path)
284
+ return None # nobody took it
285
+ except FileNotFoundError:
286
+ pass # taken just now: its answer is on the way
287
+ end = time.monotonic() + answer_s
288
+ while (result := _read_answer(answer_path)) is None and time.monotonic() < end:
289
+ sleep(0.2)
290
+ if result is None:
291
+ raise AccountError("the running ser2netweb took the request but did not answer; "
292
+ "see its log (all.log)")
293
+ return result
294
+
295
+
296
+ # --------------------------------------------------------------------------
297
+ # the command line
298
+ # --------------------------------------------------------------------------
299
+ def read_password(stdin) -> str:
300
+ from .web.routes.forms import _password_problem
301
+
302
+ if stdin is not None and stdin.isatty():
303
+ password = getpass.getpass("New password (at least 8 characters): ")
304
+ again = getpass.getpass("The same again: ")
305
+ else:
306
+ password = again = (stdin.readline() if stdin is not None else "").rstrip("\r\n")
307
+ if problem := _password_problem(password, again):
308
+ raise AccountError(problem)
309
+ return password
310
+
311
+
312
+ def become_owner(data_dir: str, platform: str = sys.platform, os_=os) -> str:
313
+ """Run as root on a data directory that belongs to another account (docker exec,
314
+ sudo): become that account before touching anything, so whatever is written keeps
315
+ the owner the running ser2netweb needs. Returns a note for the output. (`platform`
316
+ and `os_` are injection points for tests.)"""
317
+ if platform == "win32" or os_.geteuid() != 0:
318
+ return ""
319
+ try:
320
+ st = os_.stat(data_dir)
321
+ except FileNotFoundError:
322
+ raise AccountError(f"{data_dir} does not exist; run this as the user ser2netweb runs as, "
323
+ "so the new data directory belongs to it") from None
324
+ if st.st_uid == 0:
325
+ return ""
326
+ try:
327
+ os_.setgroups([])
328
+ os_.setgid(st.st_gid)
329
+ os_.setuid(st.st_uid)
330
+ except OSError as e:
331
+ raise AccountError(f"cannot switch to uid {st.st_uid}, the data directory's owner: "
332
+ f"{e}") from None
333
+ return f" (as uid {st.st_uid}, the data directory's owner)"
334
+
335
+
336
+ def apply_here(request: dict, config_path: str) -> str:
337
+ """No instance runs: change config.json directly (ConfigStore keeps a backup)."""
338
+ from .state import write_audit_record
339
+
340
+ store = ConfigStore(config_path)
341
+ created = not store.exists()
342
+ try:
343
+ config = store.load()
344
+ except (OSError, ValueError) as e:
345
+ raise AccountError(f"cannot read {config_path}: {e}") from None
346
+ change = apply(config, request)
347
+ try:
348
+ store.save(config, validate=False) # an unrelated bad setting must not block this
349
+ except OSError as e:
350
+ raise AccountError(f"cannot write {config_path}: {e}") from None
351
+ write_audit_record(os.path.join(os.path.dirname(store.path), "audit.log"), "local",
352
+ change.action, change.detail, ACTOR,
353
+ max_age_s=config.log_max_age_days * 86400)
354
+ if created:
355
+ admin = config.admin_ui
356
+ return (f"{change.message}; {config_path} was created — the web UI will listen on "
357
+ f"{host_port(admin.bind_ip, admin.port)} (change it with --reconfigure or "
358
+ "SER2NETWEB_BIND_IP)")
359
+ return change.message
360
+
361
+
362
+ def submit(request: dict, config_path: str) -> str:
363
+ """Hand the change to the running instance of this data directory, or make it here
364
+ while holding the data directory's lock."""
365
+ data_dir = os.path.dirname(os.path.abspath(config_path))
366
+ with contextlib.suppress(OSError): # if it can't be created, the change itself says why
367
+ os.makedirs(data_dir, exist_ok=True)
368
+ try:
369
+ return _submit(request, config_path, data_dir)
370
+ finally:
371
+ datalock.release(data_dir) # if this command took it
372
+
373
+
374
+ def _done(result: tuple[bool, str]) -> str:
375
+ ok, message = result
376
+ if not ok:
377
+ raise AccountError(message)
378
+ return f"{message} (done by the running ser2netweb)"
379
+
380
+
381
+ def _submit(request: dict, config_path: str, data_dir: str) -> str:
382
+ while True:
383
+ try:
384
+ note = datalock.acquire(data_dir, brief=True, wait=datalock.BRIEF_WAIT_S)
385
+ except datalock.Held as e:
386
+ if e.brief: # another of these commands, still busy
387
+ raise AccountError(f"{e}; run this again in a moment") from None
388
+ if (message := _hand_over(request, data_dir, e.pid)) is not None:
389
+ return message
390
+ continue # it stopped before taking the request: the lock is this command's now
391
+ break
392
+ if not os.path.isfile(config_path):
393
+ return apply_here(request, config_path) # nothing can be running on it
394
+ # The lock is this command's, or can't be used here. A ser2netweb that runs without it
395
+ # (one from before the lock, or on a data directory that can't be locked) still
396
+ # answers /healthz; where the lock can't be used, also give one the healthcheck can't
397
+ # reach (another container) a moment to take the request.
398
+ running = healthcheck.check(config_path, timeout=3)[0]
399
+ if running or note is not None:
400
+ request_id = post_request(data_dir, request)
401
+ result = wait_answer(data_dir, request_id, WAIT_RUNNING_S if running else WAIT_STOPPED_S)
402
+ if result is not None:
403
+ return _done(result)
404
+ if running:
405
+ raise AccountError("ser2netweb is running on this data directory but did not take the "
406
+ "request (an older version?). Stop it, run this command again, "
407
+ "then start it.")
408
+ return apply_here(request, config_path)
409
+
410
+
411
+ def _hand_over(request: dict, data_dir: str, pid: Optional[int]) -> Optional[str]:
412
+ """The instance holding the lock applies the request. None when it stopped before
413
+ taking it: then this command holds the lock and makes the change itself."""
414
+ def stopped() -> bool:
415
+ try:
416
+ return datalock.acquire(data_dir, brief=True) is None
417
+ except datalock.Held:
418
+ return False
419
+
420
+ request_id = post_request(data_dir, request)
421
+ result = wait_answer(data_dir, request_id, WAIT_RUNNING_S, gone=stopped)
422
+ if result is not None:
423
+ return _done(result)
424
+ if datalock.holds(data_dir):
425
+ return None
426
+ process = f" (process {pid})" if pid else ""
427
+ raise AccountError(f"the ser2netweb running with {data_dir}{process} did not take the request "
428
+ f"within {WAIT_RUNNING_S:.0f} s. If it is still starting, run this "
429
+ "command again once its web UI answers; otherwise see its log (all.log)")
430
+
431
+
432
+ def reload_running(config_path: str) -> str:
433
+ """`ser2netweb --reload`: the instance running with this data directory reads config.json
434
+ again (the same hand-over as the account commands; it also works for the Windows
435
+ service and in a container). Raises AccountError when none runs, or it didn't reload."""
436
+ data_dir = os.path.dirname(os.path.abspath(config_path))
437
+ request = {"action": "reload"}
438
+ try:
439
+ try:
440
+ note = datalock.acquire(data_dir, brief=True, wait=datalock.BRIEF_WAIT_S)
441
+ except datalock.Held as e:
442
+ if e.brief: # one of the account commands, still busy
443
+ raise AccountError(f"{e}; run this again in a moment") from None
444
+ message = _reloaded(_hand_over, request, data_dir, e.pid)
445
+ if message is not None:
446
+ return message
447
+ else: # no instance holds the lock; one may run where it can't be used
448
+ running = os.path.isfile(config_path) and healthcheck.check(config_path, timeout=3)[0]
449
+ if running or note is not None:
450
+ request_id = post_request(data_dir, request)
451
+ result = wait_answer(data_dir, request_id,
452
+ WAIT_RUNNING_S if running else WAIT_STOPPED_S)
453
+ if result is not None:
454
+ return _reloaded(_done, result)
455
+ raise AccountError(f"ser2netweb is not running with {data_dir}: nothing to reload (it "
456
+ "reads config.json when it starts)")
457
+ finally:
458
+ datalock.release(data_dir) # if this command took it
459
+
460
+
461
+ def _reloaded(how, *args):
462
+ """A reload's answer; one from an instance before 2.9.0 says that it can't."""
463
+ try:
464
+ return how(*args)
465
+ except AccountError as e:
466
+ if str(e).startswith("unknown request"):
467
+ raise AccountError("the running ser2netweb is older than 2.9.0 and can't reload "
468
+ "config.json: restart it instead") from None
469
+ raise
470
+
471
+
472
+ def main(args, config_path: str, stdin=None) -> int:
473
+ """`ser2netweb --set-password NAME` / `ser2netweb --sign-out-all` / `ser2netweb --reload`."""
474
+ stdin = sys.stdin if stdin is None else stdin
475
+ try:
476
+ if getattr(args, "reload", False):
477
+ data_dir = os.path.dirname(os.path.abspath(config_path))
478
+ note = become_owner(data_dir) # (the request files must be the server's to read)
479
+ print(f"ser2netweb: {reload_running(config_path)}{note}")
480
+ return 0
481
+ if args.set_password is not None:
482
+ from .web.routes.forms import _username_problem
483
+
484
+ username = args.set_password.strip()
485
+ if problem := _username_problem(username):
486
+ raise AccountError(problem)
487
+ request = {"action": "set_password", "username": username,
488
+ "password_hash": auth.hash_password(read_password(stdin))}
489
+ else:
490
+ request = {"action": "sign_out_all"}
491
+ data_dir = os.path.dirname(os.path.abspath(config_path))
492
+ note = become_owner(data_dir)
493
+ if os.path.isdir(data_dir) and not os.access(data_dir, os.R_OK | os.W_OK | os.X_OK):
494
+ raise AccountError(f"no access to {data_dir}: run this as the user ser2netweb runs as "
495
+ "(or as root)")
496
+ print(f"ser2netweb: {submit(request, config_path)}{note}")
497
+ except AccountError as e:
498
+ print(f"ser2netweb: {e}", file=sys.stderr)
499
+ return 1
500
+ except OSError as e:
501
+ print(f"ser2netweb: {e}", file=sys.stderr)
502
+ return 1
503
+ return 0