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
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)")
|