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/cli.py ADDED
@@ -0,0 +1,489 @@
1
+ """The command line of ser2netweb: what ser2netweb.py (and ser2net.py, its name up to 2.9.x)
2
+ runs once the Python version is known to be new enough.
3
+
4
+ Responsibilities kept deliberately thin:
5
+ 1. Make bundled dependencies importable (offline bootstrap into ./lib; a standalone
6
+ executable and a pip install have theirs already).
7
+ 2. Parse CLI args and locate the config/data directory (ser2netweb/layout.py: where it
8
+ is by default for each kind of installation).
9
+ 3. Hand off to ser2netweb.runtime, which owns the event loop and orchestration.
10
+
11
+ The start scripts stay outside the package (a script and a package of the same name side by
12
+ side: `import ser2netweb` finds the package), and this module only imports the standard
13
+ library until the dependencies are loaded.
14
+ """
15
+ import argparse
16
+ import contextlib
17
+ import os
18
+ import sys
19
+
20
+ from ser2netweb import __version__, envvars, layout
21
+ from ser2netweb.optional import LIB_OVERRIDE_ENV
22
+
23
+ # the source tree (the folder that holds ser2netweb/, bootstrap.py and ./lib), or a frozen
24
+ # build's unpacked files (a pip install's site-packages folder: nothing of ours but the
25
+ # package, layout.kind() == layout.PIP)
26
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
27
+ FROZEN = getattr(sys, "frozen", False) # True inside a PyInstaller build
28
+
29
+
30
+ def _app_dir() -> str:
31
+ """Where config/logs live: next to the executable in a frozen build (so they
32
+ survive and aren't written into the temp extraction dir), else the source tree
33
+ (layout.default_data_dir: not for the package's executable or a pip install)."""
34
+ return os.path.dirname(sys.executable) if FROZEN else ROOT
35
+
36
+
37
+ def default_data_dir() -> str:
38
+ """The data directory when neither --data-dir nor --config is given."""
39
+ return layout.default_data_dir(_app_dir())
40
+
41
+
42
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
43
+ p = argparse.ArgumentParser(
44
+ prog="ser2netweb",
45
+ description="Serial-to-network bridge with a web config UI.",
46
+ )
47
+ p.add_argument("--version", action="version", version=f"ser2netweb {__version__}")
48
+ p.add_argument(
49
+ "--data-dir",
50
+ default=None, # see below: whether it was given matters to --install-service
51
+ help="Directory for config.json, its backups, the logs and traces (default: data/ "
52
+ "next to ser2netweb.py or the executable; /var/lib/ser2netweb for the .deb / .rpm "
53
+ "package's; the user's application data folder for a pip / pipx install; here: "
54
+ f"{default_data_dir().replace('%', '%%')}. For --install-service: the data "
55
+ "directory the service already uses, or for a new service from the standalone "
56
+ "exe %%ProgramData%%\\ser2netweb). Give ser2netweb a folder of its own: it makes "
57
+ "it private to the account it runs as.",
58
+ )
59
+ p.add_argument(
60
+ "--config",
61
+ default=None,
62
+ help="Path to config.json (default: <data-dir>/config.json). Its folder is the data "
63
+ "directory then: the backups, logs and traces are kept next to it.",
64
+ )
65
+ p.add_argument(
66
+ "--reconfigure",
67
+ action="store_true",
68
+ help="Re-run the console picker for the admin UI bind IP/port, then start.",
69
+ )
70
+ p.add_argument(
71
+ "--online",
72
+ action="store_true",
73
+ help="Allow the dependency bootstrap to fall back to PyPI if a wheel is missing.",
74
+ )
75
+ p.add_argument(
76
+ "--no-bootstrap",
77
+ action="store_true",
78
+ help="Skip the dependency bootstrap (assume dependencies are already importable).",
79
+ )
80
+ p.add_argument(
81
+ "--about",
82
+ action="store_true",
83
+ help="Print the versions of Python, OpenSSL and the optional components, then exit.",
84
+ )
85
+ p.add_argument(
86
+ "--licenses",
87
+ action="store_true",
88
+ help="Print the licenses of ser2netweb and every bundled third-party component, then exit.",
89
+ )
90
+ svc = p.add_argument_group("Windows service")
91
+ svc.add_argument("--install-service", action="store_true",
92
+ help="Install ser2netweb as a Windows service (starts at boot, restarts after "
93
+ "a failure) with this --data-dir, and start it. Needs Administrator.")
94
+ svc.add_argument("--uninstall-service", action="store_true",
95
+ help="Stop and remove the Windows service. Its data directory is kept "
96
+ "(config.json, backups, logs, the audit log, traces): delete it to "
97
+ "remove those too.")
98
+ svc.add_argument("--service-name", default="ser2netweb",
99
+ help="Name of the Windows service (default: ser2netweb, which takes over "
100
+ "a 2.x service named ser2net); lets several instances with "
101
+ "different data directories run side by side.")
102
+ svc.add_argument("--allow-unsafe-location", action="store_true",
103
+ help="Install the service from a source checkout even if a "
104
+ "non-administrator can modify it (the standalone exe never needs "
105
+ "this: it installs itself to an admin-only folder).")
106
+ svc.add_argument("--service", action="store_true", help=argparse.SUPPRESS) # used by the SCM
107
+ p.add_argument(
108
+ "--healthcheck",
109
+ action="store_true",
110
+ help="Exit 0 if this data directory's running instance answers on /healthz, else 1 "
111
+ "(Docker HEALTHCHECK, monitoring).",
112
+ )
113
+ acc = p.add_argument_group("account recovery (works while ser2netweb runs, too)")
114
+ one = acc.add_mutually_exclusive_group()
115
+ one.add_argument("--set-password", metavar="USER",
116
+ help="Set a new password for the local account USER, sign its "
117
+ "sessions out and turn its two-factor sign-in off (USER is "
118
+ "created as an admin if it doesn't exist), then exit. The password "
119
+ "is asked on the terminal, or read from the first line of stdin.")
120
+ one.add_argument("--sign-out-all", action="store_true",
121
+ help="End every web UI session (new session key), then exit.")
122
+ run = p.add_argument_group("a running ser2netweb")
123
+ run.add_argument("--reload", action="store_true",
124
+ help="Have the ser2netweb running with this data directory read config.json "
125
+ "again and apply it without a restart (mappings that didn't change "
126
+ "keep running with their clients), then exit. On Linux/macOS "
127
+ "SIGHUP does the same (systemctl reload ser2netweb).")
128
+ args = p.parse_args(argv)
129
+ if args.reload and (args.set_password is not None or args.sign_out_all):
130
+ p.error("--reload can't be combined with --set-password or --sign-out-all")
131
+ args.data_dir_given = args.data_dir is not None
132
+ if args.data_dir is None:
133
+ args.data_dir = default_data_dir()
134
+ return args
135
+
136
+
137
+ def apply_lib_override() -> None:
138
+ """SER2NETWEB_LIB_OVERRIDE=<dir> (or SER2NET_LIB_OVERRIDE, its 2.9.x name): import Python
139
+ modules from <dir> before the bundled ones. The standalone executable keeps the LGPL-licensed ldap3 as plain source files
140
+ for this, so it can be replaced by another (e.g. modified) version without
141
+ rebuilding ser2netweb. Only an explicitly set variable is honoured — a directory is
142
+ never picked up just because it exists next to the executable."""
143
+ d = envvars.text(LIB_OVERRIDE_ENV)
144
+ if d:
145
+ if not os.path.isdir(d):
146
+ raise SystemExit(f"{envvars.source(LIB_OVERRIDE_ENV)}: not a directory: {d}")
147
+ sys.path.insert(0, os.path.abspath(d))
148
+
149
+
150
+ def _write_utf8(text: str) -> None:
151
+ # bytes, so a legacy console code page (Windows cp1252) can't fail on a character
152
+ sys.stdout.flush()
153
+ sys.stdout.buffer.write(text.encode("utf-8"))
154
+ sys.stdout.buffer.flush()
155
+
156
+
157
+ def print_licenses() -> int:
158
+ bundled = os.path.join(getattr(sys, "_MEIPASS", ""), "THIRD-PARTY-LICENSES.txt")
159
+ if FROZEN and os.path.isfile(bundled):
160
+ with open(bundled, encoding="utf-8") as fh:
161
+ _write_utf8(fh.read())
162
+ return 0
163
+ # an executable built without the license file has no THIRD-PARTY-NOTICES.md next to
164
+ # it either (ROOT is its temporary unpack folder): name this version's copy online; a
165
+ # pip install has it with the licence files of its .dist-info
166
+ online = f"https://github.com/haliskilic/ser2netweb/blob/v{__version__}/THIRD-PARTY-NOTICES.md"
167
+ if FROZEN:
168
+ notices = online
169
+ elif layout.kind() == layout.PIP:
170
+ folder = layout.licence_dir()
171
+ notices = os.path.join(folder, "THIRD-PARTY-NOTICES.md") if folder else online
172
+ else:
173
+ notices = os.path.join(ROOT, "THIRD-PARTY-NOTICES.md")
174
+ _write_utf8(
175
+ f"ser2netweb {__version__} is licensed under the GNU AGPL-3.0-only (LICENSE) or a "
176
+ f"commercial license (COMMERCIAL_LICENSE.md).\nThird-party components: {notices}\n")
177
+ return 0
178
+
179
+
180
+ # what --about writes for the folders it shortens (a bug report shows which copy of a
181
+ # package is loaded, not the account name in the path)
182
+ _ROOT_LABELS = {"<override>": LIB_OVERRIDE_ENV, "<bundle>": "the executable's files",
183
+ "<ser2netweb>": "ser2netweb's folder", "<python>": "the Python installation",
184
+ "~": "the home folder"}
185
+
186
+
187
+ def _known_roots(env=None) -> list[tuple[str, str]]:
188
+ """(label, folder) of the folders --about writes as a short label (_ROOT_LABELS)."""
189
+ # (a pip install's ROOT is the site-packages folder every package is in: there, only
190
+ # the package's own folder is ser2netweb's)
191
+ own = layout.PACKAGE_DIR if layout.kind() == layout.PIP else ROOT
192
+ roots = [("<override>", envvars.text(LIB_OVERRIDE_ENV, env)),
193
+ ("<bundle>", getattr(sys, "_MEIPASS", "") or ""),
194
+ ("<ser2netweb>", own), ("<python>", sys.prefix),
195
+ ("~", os.path.expanduser("~"))]
196
+ out = []
197
+ for label, folder in roots:
198
+ folder = os.path.abspath(folder) if folder and folder != "~" else ""
199
+ if folder and os.path.dirname(folder) != folder: # never a whole drive or "/"
200
+ out.append((label, folder))
201
+ return out
202
+
203
+
204
+ def _where(path: str, roots: list[tuple[str, str]]) -> str:
205
+ """`path` with the most specific of `roots` that holds it written as its label
206
+ (compared as the OS does: letter case aside on Windows), else as it is."""
207
+ full = os.path.abspath(path)
208
+ best: tuple[str, str] | None = None
209
+ for label, root in roots:
210
+ try:
211
+ inside = (os.path.commonpath([os.path.normcase(full), os.path.normcase(root)])
212
+ == os.path.normcase(root))
213
+ except ValueError: # another drive, or one relative and one absolute
214
+ continue
215
+ if inside and (best is None or len(root) > len(best[1])):
216
+ best = (label, root)
217
+ if best is None:
218
+ return full
219
+ rest = full[len(best[1]):].lstrip("\\/")
220
+ return best[0] + (os.sep + rest if rest else "")
221
+
222
+
223
+ def print_about() -> int:
224
+ import platform
225
+ import ssl
226
+ from importlib import metadata
227
+
228
+ from ser2netweb import optional
229
+
230
+ roots, used = _known_roots(), set()
231
+ lines = [f"ser2netweb {__version__}",
232
+ f"Python {platform.python_version()} ({platform.python_implementation()}), "
233
+ f"{ssl.OPENSSL_VERSION}",
234
+ f"Platform: {platform.platform()} ({platform.machine()})",
235
+ f"Build: {layout.LABELS[layout.kind()]}"]
236
+ components = (("pyserial", "serial"), ("pyserial-asyncio-fast", "serial_asyncio_fast"),
237
+ ("starlette", "starlette"), ("uvicorn", "uvicorn"), ("anyio", "anyio"),
238
+ ("psutil", "psutil"), ("paho-mqtt", "paho.mqtt"), ("ldap3", "ldap3"), ("pyasn1", "pyasn1"),
239
+ ("authlib", "authlib"), ("cryptography", "cryptography"))
240
+ width = max(len(dist) for dist, _module in components)
241
+ for dist, module in components:
242
+ mod, problem = optional.load(module, dist, module)
243
+ if problem is not None:
244
+ # "not installed" only when it really isn't; else the error importing it raised
245
+ lines.append(f" {dist:{width}} " + ("not installed" if problem.missing else
246
+ f"failed to import: {problem.error}"))
247
+ continue
248
+ version = getattr(mod, "__version__", None)
249
+ if not isinstance(version, str):
250
+ try:
251
+ version = metadata.version(dist)
252
+ except metadata.PackageNotFoundError:
253
+ version = "?"
254
+ where = os.path.dirname(getattr(mod, "__file__", "") or "")
255
+ if where:
256
+ where = _where(where, roots)
257
+ used.add(where.split(os.sep, 1)[0])
258
+ lines.append(f" {dist:{width}} {version} ({where})")
259
+ labels = dict(_ROOT_LABELS, **{"<override>": envvars.source(LIB_OVERRIDE_ENV)})
260
+ legend = [f"{label} = {text}" for label, text in labels.items() if label in used]
261
+ if legend:
262
+ lines.append(f" ({', '.join(legend)})")
263
+ _write_utf8("\n".join(lines) + "\n")
264
+ return 0
265
+
266
+
267
+ # what a server started with the old script name logs once (start-up notes)
268
+ OLD_SCRIPT_NOTE = ("{script} is the old name of ser2netweb.py (up to 2.9.x) and keeps working; "
269
+ "new set-ups use ser2netweb.py")
270
+
271
+
272
+ def main(argv: list[str] | None = None, started_as: str = "") -> int:
273
+ """The command line. ``started_as``: the name of the start script the user ran when it
274
+ is not ser2netweb.py (the old ser2net.py), for one line in the start-up log."""
275
+ args = parse_args(argv)
276
+ apply_lib_override()
277
+ if args.licenses:
278
+ return print_licenses()
279
+ if args.uninstall_service:
280
+ return _service_call(lambda ws: ws.uninstall(args.service_name))
281
+ if args.healthcheck: # stdlib only: no bootstrap needed
282
+ from ser2netweb import healthcheck
283
+
284
+ ok, msg = healthcheck.check(args.config or os.path.join(args.data_dir, "config.json"))
285
+ print(msg)
286
+ return 0 if ok else 1
287
+
288
+ config_path = args.config or os.path.join(args.data_dir, "config.json")
289
+ split = split_layout_note(args)
290
+ if split:
291
+ print(f"ser2netweb: {split}", file=sys.stderr)
292
+ from ser2netweb import standalone # (its notes are the lines all.log gets at start-up)
293
+
294
+ standalone.notes.append(split)
295
+ if started_as:
296
+ from ser2netweb import standalone
297
+
298
+ standalone.notes.append(OLD_SCRIPT_NOTE.format(script=started_as))
299
+ if args.service:
300
+ return _service(args, config_path)
301
+
302
+ # 1. dependencies. The one-off commands may run beside a running ser2netweb, so they
303
+ # leave an out-of-date ./lib as it is (with a warning); a server start refreshes it.
304
+ one_off = (args.about or args.set_password is not None or args.sign_out_all
305
+ or args.reload or args.install_service)
306
+ load_dependencies(args, refresh=not one_off)
307
+
308
+ if args.about:
309
+ return print_about()
310
+
311
+ if args.set_password is not None or args.sign_out_all or args.reload:
312
+ from ser2netweb import accounts
313
+
314
+ return accounts.main(args, config_path)
315
+
316
+ # 3. hand off
317
+ runtime = _import_runtime()
318
+
319
+ if args.install_service:
320
+ if _service_call(lambda ws: ws.require_windows()):
321
+ return 1
322
+ if layout.kind() == layout.PIP:
323
+ # the service would run a Python and a package any later pip command can
324
+ # change (a pipx venv is the user's own), as LocalSystem
325
+ print("ser2netweb: --install-service needs the standalone exe "
326
+ "(ser2netweb-windows-x64.exe) or a source checkout; a pip / pipx install "
327
+ "can't be a Windows service (docs/WINDOWS.md)", file=sys.stderr)
328
+ return 1
329
+ def prepare(service_config: str):
330
+ # runs once an existing service is stopped (the data directory then has no
331
+ # running instance). First run: asks for the UI address; warnings (e.g. a
332
+ # setting that was not saved) would otherwise only reach the service's log
333
+ _store, loaded = runtime.prepare_config(service_config, args.reconfigure)
334
+ for note in loaded.load_notes:
335
+ _write_utf8(note + "\n")
336
+ runtime.require_admin_tls(loaded.admin_ui) # the service could not start without it
337
+ return loaded
338
+
339
+ # an upgrade keeps the data directory the service has unless --data-dir is given
340
+ done: dict = {}
341
+ rc = _service_call(lambda ws: done.update(config=ws.install(
342
+ args.service_name, args.data_dir if args.data_dir_given else None, args.config,
343
+ prepare=prepare, default_data_dir=os.path.abspath(args.data_dir),
344
+ allow_unsafe_location=args.allow_unsafe_location)))
345
+ if rc == 0 and done.get("config") is not None:
346
+ _print_firewall_hint(done["config"], args.service_name)
347
+ return rc
348
+ if os.name == "posix":
349
+ # what the server creates (all.log, audit.log, traces, a mapping's log, tls/) is
350
+ # for this account only, also in a data directory it doesn't make private
351
+ os.umask(0o077)
352
+ return runtime.main(config_path=config_path, reconfigure=args.reconfigure)
353
+
354
+
355
+ def split_layout_note(args: argparse.Namespace) -> str:
356
+ """A warning when both --data-dir and --config are given and the config file is not in
357
+ that directory: ser2netweb keeps its state next to the config file, not in --data-dir."""
358
+ if not (args.config and args.data_dir_given):
359
+ return ""
360
+ folder = os.path.dirname(os.path.abspath(args.config))
361
+ if os.path.normcase(os.path.abspath(args.data_dir)) == os.path.normcase(folder):
362
+ return ""
363
+ return (f"WARNING: --config {args.config} is not in --data-dir {args.data_dir}: ser2netweb "
364
+ f"keeps the backups, logs and traces next to the config file, in {folder}")
365
+
366
+
367
+ def load_dependencies(args: argparse.Namespace, refresh: bool) -> None:
368
+ """Make the bundled dependencies importable: the offline bootstrap into ./lib (a
369
+ frozen build has everything inside). With ``refresh``, a ./lib installed for other
370
+ pins (an upgrade) is brought up to date too. Its warnings go to the start-up log. A pip
371
+ install's dependencies are pip's (and there is no bootstrap.py)."""
372
+ if FROZEN or layout.kind() == layout.PIP:
373
+ return
374
+ sys.path.insert(0, ROOT) # ensure local packages (ser2netweb/, bootstrap) import first
375
+ if not args.no_bootstrap:
376
+ import bootstrap
377
+
378
+ bootstrap.ensure(online=args.online, refresh_lib=refresh)
379
+ else:
380
+ lib = os.path.join(ROOT, "lib")
381
+ if os.path.isdir(lib):
382
+ sys.path.insert(0, lib)
383
+ try:
384
+ import bootstrap
385
+ except ImportError: # a package of ser2netweb without it: nothing to check
386
+ return
387
+ bootstrap.warn_if_stale()
388
+ if bootstrap.notes:
389
+ from ser2netweb import standalone # (its notes are the lines all.log gets at start-up)
390
+
391
+ standalone.notes.extend(bootstrap.notes)
392
+
393
+
394
+ def _import_runtime():
395
+ """ser2netweb.runtime, which imports the dependencies. When they come from a ./lib an older
396
+ release installed that could not be refreshed (the bootstrap said why) and lack what
397
+ this version needs, the start stops with that instead of a traceback."""
398
+ try:
399
+ from ser2netweb import runtime
400
+ except ImportError as e:
401
+ bootstrap = sys.modules.get("bootstrap")
402
+ if bootstrap is None or not bootstrap.lib_is_stale():
403
+ raise
404
+ raise SystemExit(f"ser2netweb: {e} — ./lib holds the dependencies of an older release, "
405
+ "and they could not be updated (see the warning before): run "
406
+ f"python3 bootstrap.py in {ROOT} as the account ser2netweb runs "
407
+ "as") from None
408
+ return runtime
409
+
410
+
411
+ def _service(args: argparse.Namespace, config_path: str) -> int:
412
+ """--service, as the Windows Service Control Manager starts it. The SCM waits about
413
+ 30 s for the process to connect (winservice.run), and refreshing ./lib after an
414
+ upgrade can take longer, so the dependencies and the server load in the service's
415
+ thread, once it is connected."""
416
+ import threading
417
+
418
+ from ser2netweb import winservice
419
+
420
+ stop_asked = threading.Event()
421
+ data_dir = os.path.dirname(os.path.abspath(config_path))
422
+ ready_hooks: list = [] # winservice.run's on_ready: runtime.when_ready, once imported
423
+
424
+ def serve() -> int:
425
+ try:
426
+ load_dependencies(args, refresh=True)
427
+ runtime = _import_runtime()
428
+ except SystemExit as e: # a service has no console: all.log and the event log
429
+ if isinstance(e.code, str):
430
+ from ser2netweb import clock
431
+
432
+ log = os.path.join(data_dir, "all.log")
433
+ with contextlib.suppress(OSError), open(log, "a", encoding="utf-8") as fh:
434
+ fh.write(f"{clock.stamp()} {e.code}\n")
435
+ winservice.report_event(args.service_name, (
436
+ f"The ser2netweb service '{args.service_name}' stopped: {e.code}\n\n"
437
+ f"Details: {log}"))
438
+ raise
439
+ for hook in ready_hooks: # RUNNING once the server is ready
440
+ runtime.when_ready(hook)
441
+ if stop_asked.is_set(): # the SCM asked to stop while they loaded
442
+ runtime.request_shutdown()
443
+ return runtime.service_main(config_path, args.service_name)
444
+
445
+ def stop() -> None:
446
+ stop_asked.set()
447
+ runtime = sys.modules.get("ser2netweb.runtime")
448
+ if runtime is not None and hasattr(runtime, "request_shutdown"):
449
+ runtime.request_shutdown()
450
+
451
+ return winservice.run(args.service_name, serve, stop,
452
+ log_path=os.path.join(data_dir, winservice.SERVICE_LOG),
453
+ on_ready=ready_hooks.append)
454
+
455
+
456
+ def _print_firewall_hint(config, service_name: str) -> None:
457
+ """A LocalSystem service never sees the Windows Firewall prompt, so other hosts can't
458
+ reach the admin UI, the mapping ports or the cluster until a rule is added. Print the
459
+ exact command when anything listens beyond loopback (the firewall is not opened for
460
+ the user: secure by default)."""
461
+ admin = config.admin_ui
462
+ beyond_loopback = admin.bind_ip not in ("127.0.0.1", "::1", "localhost") or any(
463
+ m.enabled and getattr(m.network, "listens", False)
464
+ and m.network.bind_ip not in ("127.0.0.1", "::1") for m in config.mappings)
465
+ if not beyond_loopback:
466
+ return
467
+ from ser2netweb import winservice
468
+
469
+ _write_utf8(
470
+ "\nWindows Firewall: this service listens for other hosts, which the firewall "
471
+ "blocks until you allow it (a service gets no prompt). From an elevated prompt:\n "
472
+ + winservice.firewall_command(service_name) + "\n (remove it later with: "
473
+ f'netsh advfirewall firewall delete rule name="{winservice.display_name(service_name)}")'
474
+ "\nSee docs/WINDOWS.md.\n")
475
+
476
+
477
+ def _service_call(fn) -> int:
478
+ from ser2netweb import winservice
479
+
480
+ try:
481
+ fn(winservice)
482
+ except winservice.ServiceError as e:
483
+ print(f"ser2netweb: {e}", file=sys.stderr)
484
+ return 1
485
+ return 0
486
+
487
+
488
+ if __name__ == "__main__":
489
+ raise SystemExit(main())
ser2netweb/clock.py ADDED
@@ -0,0 +1,128 @@
1
+ """Times as ser2netweb writes them into its files, and what it can tell about the system
2
+ clock (it never sets the clock: that is the operating system's time sync, see the README).
3
+
4
+ ``stamp()`` is the server's local time with its offset from UTC, as all.log, stdout, the
5
+ mapping logs (logs/<id>.log) and audit.log write it since 2.9:
6
+ ``2026-11-06 12:00:00+03:00``. Without the offset, the same text could stand for two
7
+ instants (the hour repeated when daylight saving time ends) and a container's UTC lines
8
+ could not be told from the operator's local time. The first 19 characters are the
9
+ ``YYYY-MM-DD HH:MM:SS`` that older versions wrote, and that is all they read of it (the
10
+ log trim, the sign-ins in the audit log): a file with old and new lines reads as one, in
11
+ this version and in an older one. ``parse_stamp()`` reads both kinds. The offset comes
12
+ from Python (datetime.astimezone), so it is the same on Linux and Windows.
13
+
14
+ Trace files keep their own format (``YYYY-MM-DD HH:MM:SS`` local time, the
15
+ classic ser2net layout tools parse); the trace panel names the server's zone instead.
16
+
17
+ ``behind()`` tells when the clock reads earlier than this installation's files show it
18
+ read already (a board without an RTC that started before NTP set its clock, a flat RTC
19
+ battery): the start-up log and Settings warn then. It only reads; how long something has
20
+ been going on is measured with time.monotonic() everywhere, which no clock step changes.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import calendar
25
+ import datetime
26
+ import os
27
+ import re
28
+ import time
29
+
30
+ _FORMAT = "%Y-%m-%d %H:%M:%S"
31
+ _OFFSET = re.compile(r"[+-](\d\d):(\d\d)")
32
+
33
+ # No ser2netweb with this code ran before this day, so a clock that reads earlier is wrong.
34
+ # It never has to move with a release: on an installation that ran for a while, the
35
+ # newest backup and config.json's time are later anyway.
36
+ FLOOR = calendar.timegm((2026, 11, 1, 0, 0, 0, 0, 0, 0))
37
+ # How far the clock may read before those times without a warning: time sync stepping
38
+ # a fast clock back, an RTC that gained a little while the machine was off.
39
+ SLACK_S = 300.0
40
+
41
+
42
+ def stamp(t: float | None = None) -> str:
43
+ """*t* (default: now) as the server's local time with its UTC offset."""
44
+ when = datetime.datetime.fromtimestamp(time.time() if t is None else t, datetime.timezone.utc)
45
+ return when.astimezone().isoformat(sep=" ", timespec="seconds")
46
+
47
+
48
+ def parse_stamp(text: str) -> float | None:
49
+ """The epoch time of the stamp a line starts with: ``YYYY-MM-DD HH:MM:SS`` followed by
50
+ its UTC offset (stamp()), or without one (a line an older version wrote: local time).
51
+ None when the line doesn't start with one."""
52
+ try:
53
+ fields = time.strptime(text[:19], _FORMAT)
54
+ except ValueError:
55
+ return None
56
+ offset = _OFFSET.match(text, 19)
57
+ if offset is not None:
58
+ sign = -1 if text[19] == "-" else 1
59
+ return calendar.timegm(fields) - sign * (int(offset[1]) * 3600 + int(offset[2]) * 60)
60
+ try:
61
+ return time.mktime(fields)
62
+ except (OverflowError, ValueError):
63
+ return None
64
+
65
+
66
+ def zone(t: float | None = None) -> str:
67
+ """The server's time zone at *t* (default: now), as the UI names it: 'UTC+03:00',
68
+ 'UTC+01:00 (CET)', 'UTC'."""
69
+ local = datetime.datetime.fromtimestamp(time.time() if t is None else t,
70
+ datetime.timezone.utc).astimezone()
71
+ offset = local.isoformat(timespec="seconds")[19:]
72
+ name = local.tzname() or ""
73
+ text = "UTC" if offset in ("+00:00", "-00:00") else f"UTC{offset}"
74
+ if name and name.upper() != "UTC" and not name.startswith(("+", "-")):
75
+ text += f" ({name})"
76
+ return text
77
+
78
+
79
+ def utc_text(t: float) -> str:
80
+ """*t* as 'YYYY-MM-DD HH:MM:SS' in UTC."""
81
+ return time.strftime(_FORMAT, time.gmtime(t))
82
+
83
+
84
+ def behind(store, now: float | None = None) -> tuple[str, float] | None:
85
+ """Whether the system clock reads earlier than the latest time this installation's
86
+ files show it read already: (what, when) for that time - "backup" (the newest
87
+ configuration backup, named in UTC), "config" (config.json's modification time) or
88
+ "release" (FLOOR) - when *now* is more than SLACK_S before it, else None. *store*:
89
+ the config.ConfigStore. Only reads (blocking file I/O: a directory listing and a
90
+ stat)."""
91
+ from .config import backup_time
92
+
93
+ now = time.time() if now is None else now
94
+ known = [("release", float(FLOOR))]
95
+ names = store.backup_names()
96
+ if names:
97
+ try:
98
+ known.append(("backup", float(calendar.timegm(
99
+ time.strptime(backup_time(names[0]), _FORMAT)))))
100
+ except ValueError:
101
+ pass
102
+ try:
103
+ known.append(("config", os.path.getmtime(store.path)))
104
+ except OSError:
105
+ pass
106
+ what, when = max(known, key=lambda k: k[1])
107
+ return (what, when) if now < when - SLACK_S else None
108
+
109
+
110
+ def warning(store, now: float | None = None) -> str:
111
+ """The start-up log's line when the clock reads earlier than it can (behind()), else ""."""
112
+ now = time.time() if now is None else now
113
+ found = behind(store, now)
114
+ if found is None:
115
+ return ""
116
+ what, when = found
117
+ reads = f"WARNING: the system clock reads {utc_text(now)} UTC"
118
+ if what == "release":
119
+ head = f"{reads}, before this version of ser2netweb was made ({utc_text(when)[:10]}): it is wrong"
120
+ elif what == "backup":
121
+ head = (f"{reads}, earlier than the newest configuration backup ({utc_text(when)} "
122
+ "UTC): it is probably wrong (or it was ahead when that backup was made)")
123
+ else:
124
+ head = (f"{reads}, earlier than config.json was last written ({utc_text(when)} UTC): "
125
+ "it is probably wrong")
126
+ return (f"{head}. Logs, the audit log and backups get wrong times, and sign-ins and "
127
+ "certificate checks can fail. ser2netweb never sets the clock: set it, or let the "
128
+ "system's time sync (NTP) do it (README: Time synchronisation)")