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/console.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""First-launch console picker for the admin UI bind IP/port.
|
|
2
|
+
|
|
3
|
+
On first run (or with --reconfigure) we ask, on the terminal, which local IP the
|
|
4
|
+
configuration web UI should listen on — chosen from the machine's own addresses
|
|
5
|
+
(every interface for IPv4, IPv6 or both, loopback, each NIC address) or a custom one.
|
|
6
|
+
Every interface of both families is stored as "" (asyncio's every interface). In a
|
|
7
|
+
non-interactive context (service/headless) we keep the address configured, else
|
|
8
|
+
127.0.0.1 (see choose_admin_bind).
|
|
9
|
+
|
|
10
|
+
The port offered is tried first (port_in_use): when another program has it — the
|
|
11
|
+
usual case is a second instance on the same machine — the picker says so and offers
|
|
12
|
+
the next free port, but takes a port typed in (it never refuses one: that program may
|
|
13
|
+
be gone by the time this one starts). Without a terminal the port stays as it is, with
|
|
14
|
+
a warning for the log.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import errno
|
|
19
|
+
import os
|
|
20
|
+
import shlex
|
|
21
|
+
import socket
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
from . import envvars, layout
|
|
25
|
+
from .config import AdminUI, host_port, shown_host
|
|
26
|
+
from .engine import bindhint, netinfo
|
|
27
|
+
|
|
28
|
+
# how far past a taken port the picker looks for a free one
|
|
29
|
+
_FREE_PORT_TRIES = 50
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def port_in_use(ip: str, port: int) -> bool:
|
|
33
|
+
"""Does another socket listen on ip:port now, so that the admin UI couldn't bind it?
|
|
34
|
+
Tried as asyncio's create_server binds (uvicorn's listener): SO_REUSEADDR on POSIX,
|
|
35
|
+
IPV6_V6ONLY for an IPv6 address; the socket is closed at once. Only "address in use"
|
|
36
|
+
counts: an address this machine doesn't have is not this check's business. "" (every
|
|
37
|
+
interface of both families) is tried as 0.0.0.0 and as ::, as httpguard.listen binds it."""
|
|
38
|
+
if not ip:
|
|
39
|
+
return port_in_use("0.0.0.0", port) or port_in_use("::", port)
|
|
40
|
+
try:
|
|
41
|
+
family = socket.AF_INET6 if ":" in ip else socket.AF_INET
|
|
42
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
43
|
+
except OSError:
|
|
44
|
+
return False
|
|
45
|
+
with sock:
|
|
46
|
+
try:
|
|
47
|
+
if os.name == "posix" and sys.platform != "cygwin":
|
|
48
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
49
|
+
if family == socket.AF_INET6 and hasattr(socket, "IPPROTO_IPV6"):
|
|
50
|
+
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
|
51
|
+
sock.bind((ip, port))
|
|
52
|
+
except OSError as e:
|
|
53
|
+
# Windows: WSAEADDRINUSE, or WSAEACCES for a port held exclusively / reserved
|
|
54
|
+
return e.errno == errno.EADDRINUSE or getattr(e, "winerror", None) in (10048, 10013)
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def next_free_port(ip: str, port: int) -> int | None:
|
|
59
|
+
"""The first port after `port` that port_in_use() finds free on `ip`, or None."""
|
|
60
|
+
for candidate in range(port + 1, min(port + _FREE_PORT_TRIES, 65535) + 1):
|
|
61
|
+
if not port_in_use(ip, candidate):
|
|
62
|
+
return candidate
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def in_use_warning(ip: str, port: int) -> str:
|
|
67
|
+
return (f"WARNING: port {port} on {shown_host(ip)} is in use (another ser2netweb instance or another "
|
|
68
|
+
"program?) — the web UI can't listen there until it is free. Give this instance "
|
|
69
|
+
"its own port: --reconfigure, or SER2NETWEB_BIND_IP + SER2NETWEB_PORT")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _quoted(arg: str) -> str:
|
|
73
|
+
if os.name == "nt":
|
|
74
|
+
return f'"{arg}"' if " " in arg else arg
|
|
75
|
+
return shlex.quote(arg)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reconfigure_hint(data_dir: str, service_name: str = "", environ=None) -> tuple[str, str]:
|
|
79
|
+
"""How this installation changes the admin UI's address (for the Settings page):
|
|
80
|
+
("env", "") when SER2NETWEB_BIND_IP (or SER2NET_BIND_IP) sets it (a container: its
|
|
81
|
+
environment wins at every start); ("service", command) for the Windows service, whose
|
|
82
|
+
reinstall with --reconfigure asks for it again; else ("console", command), a start with
|
|
83
|
+
--reconfigure."""
|
|
84
|
+
if envvars.text("SER2NETWEB_BIND_IP", environ):
|
|
85
|
+
return "env", ""
|
|
86
|
+
if getattr(sys, "frozen", False):
|
|
87
|
+
prog = _quoted(sys.executable)
|
|
88
|
+
elif layout.kind() == layout.PIP:
|
|
89
|
+
prog = "ser2netweb" # the command pip / pipx installed
|
|
90
|
+
else:
|
|
91
|
+
script = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
92
|
+
"ser2netweb.py")
|
|
93
|
+
prog = f"{_quoted(sys.executable)} {_quoted(script)}"
|
|
94
|
+
if service_name:
|
|
95
|
+
# (ser2net: 2.x's default service, which --install-service of the default moves over)
|
|
96
|
+
name = ("" if service_name in ("ser2netweb", "ser2net")
|
|
97
|
+
else f" --service-name {_quoted(service_name)}")
|
|
98
|
+
return "service", f"{prog} --install-service --reconfigure{name}"
|
|
99
|
+
return "console", f"{prog} --reconfigure --data-dir {_quoted(data_dir)}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def choose_admin_bind(current: AdminUI | None = None,
|
|
103
|
+
notes: list[str] | None = None) -> tuple[str, int]:
|
|
104
|
+
"""(bind IP, port) for the admin UI, asked on the console. Without a terminal the
|
|
105
|
+
current (or default) address. A warning for the start-up log goes to `notes` when the
|
|
106
|
+
port kept is taken."""
|
|
107
|
+
candidates = netinfo.list_ip_candidates()
|
|
108
|
+
# (a configured "" is every interface, IPv4 and IPv6: a choice made here before)
|
|
109
|
+
default_ip = current.bind_ip if current is not None else "127.0.0.1"
|
|
110
|
+
default_port = (current.port if current else None) or 8080
|
|
111
|
+
|
|
112
|
+
if not sys.stdin or not sys.stdin.isatty():
|
|
113
|
+
# No terminal to prompt at (service/headless). Default to LOOPBACK so the
|
|
114
|
+
# admin UI is never network-exposed without an explicit choice + password.
|
|
115
|
+
where = "loopback" if default_ip in ("127.0.0.1", "::1", "localhost") else "as configured"
|
|
116
|
+
print(f"[ser2netweb] non-interactive console; admin UI bind defaults to "
|
|
117
|
+
f"{host_port(default_ip, default_port)} ({where}). Run with a terminal or "
|
|
118
|
+
f"--reconfigure to expose it on the network.", flush=True)
|
|
119
|
+
if port_in_use(default_ip, default_port):
|
|
120
|
+
warning = in_use_warning(default_ip, default_port)
|
|
121
|
+
print(f"[ser2netweb] {warning}", flush=True)
|
|
122
|
+
if notes is not None:
|
|
123
|
+
notes.append(warning)
|
|
124
|
+
return default_ip, default_port
|
|
125
|
+
|
|
126
|
+
print("\n=== ser2netweb — configuration interface setup ===")
|
|
127
|
+
print("Choose which local IP address the web config UI should listen on:\n")
|
|
128
|
+
for i, c in enumerate(candidates, 1):
|
|
129
|
+
print(f" {i}) {c['label']}")
|
|
130
|
+
print(" C) Custom IP address")
|
|
131
|
+
|
|
132
|
+
ip = default_ip
|
|
133
|
+
while True:
|
|
134
|
+
raw = input(f"\nSelection [1-{len(candidates)} or C] (default 1): ").strip() or "1"
|
|
135
|
+
if raw.lower() == "c":
|
|
136
|
+
custom = input(" Enter custom IP: ").strip()
|
|
137
|
+
try:
|
|
138
|
+
ip = netinfo.validate_ip(custom)
|
|
139
|
+
break
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
print(f" {e}")
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
idx = int(raw)
|
|
145
|
+
if 1 <= idx <= len(candidates):
|
|
146
|
+
ip = candidates[idx - 1]["value"]
|
|
147
|
+
if ip == netinfo.BOTH_FAMILIES:
|
|
148
|
+
ip = "" # every interface, IPv4 and IPv6 (as asyncio reads "")
|
|
149
|
+
break
|
|
150
|
+
except ValueError:
|
|
151
|
+
pass
|
|
152
|
+
print(" Invalid selection, try again.")
|
|
153
|
+
|
|
154
|
+
if port_in_use(ip, default_port):
|
|
155
|
+
free = next_free_port(ip, default_port)
|
|
156
|
+
print(f" Port {default_port} on {shown_host(ip)} is in use (another ser2netweb instance or another "
|
|
157
|
+
"program?)" + (f"; {free} is free." if free else "."))
|
|
158
|
+
default_port = free or default_port
|
|
159
|
+
while True:
|
|
160
|
+
raw = input(f"Web UI TCP port [{default_port}]: ").strip() or str(default_port)
|
|
161
|
+
try:
|
|
162
|
+
port = int(raw)
|
|
163
|
+
if 1 <= port <= 65535:
|
|
164
|
+
break
|
|
165
|
+
except ValueError:
|
|
166
|
+
pass
|
|
167
|
+
print(" Invalid port, try again.")
|
|
168
|
+
if port_in_use(ip, port):
|
|
169
|
+
print(f" NOTE: port {port} on {shown_host(ip)} is in use right now: the web UI can't listen "
|
|
170
|
+
"there until the program that has it stops.")
|
|
171
|
+
if notes is not None:
|
|
172
|
+
notes.append(in_use_warning(ip, port))
|
|
173
|
+
|
|
174
|
+
if note := bindhint.admin_port_note(port):
|
|
175
|
+
print(f" NOTE: {note.removeprefix('WARNING: ')}")
|
|
176
|
+
print(f"\n[ser2netweb] Configuration UI will listen on http://{host_port(ip, port)}")
|
|
177
|
+
if ip not in ("127.0.0.1", "localhost", "::1"):
|
|
178
|
+
print("[ser2netweb] NOTE: this is reachable from the network. You will set an "
|
|
179
|
+
"admin password on first access.")
|
|
180
|
+
print()
|
|
181
|
+
return ip, port
|
ser2netweb/datalock.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""One running ser2netweb per data directory.
|
|
2
|
+
|
|
3
|
+
Two instances on the same data directory — a second start while the Windows service or
|
|
4
|
+
the systemd unit runs, --reconfigure next to the running server — would each save their
|
|
5
|
+
own copy of config.json over the other's and lose each other's changes. runtime.main()
|
|
6
|
+
therefore takes an exclusive lock on <data>/ser2net.lock before it writes anything and
|
|
7
|
+
holds it until the server has stopped; a second start on that directory stops with one
|
|
8
|
+
line that names the process holding it. The operating system drops the lock when the
|
|
9
|
+
process ends, however it ends, so a crash leaves no stale lock behind (the file itself
|
|
10
|
+
stays; earlier releases ignore it).
|
|
11
|
+
|
|
12
|
+
Where the lock can't be taken — a read-only data directory, a network share without
|
|
13
|
+
lock support — the start goes on without it and the start-up log says so.
|
|
14
|
+
POSIX: flock(); Windows: a byte-range lock (msvcrt.locking).
|
|
15
|
+
|
|
16
|
+
`ser2netweb --set-password` / `--sign-out-all` (ser2netweb/accounts.py) hold the lock too, while
|
|
17
|
+
they change config.json of a stopped ser2netweb: they mark it as brief ("<pid> brief" in
|
|
18
|
+
the file), and a start that finds it held that way waits for them (BRIEF_WAIT_S)
|
|
19
|
+
instead of refusing.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import errno
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
LOCK_NAME = "ser2net.lock"
|
|
29
|
+
BRIEF = "brief" # the lock file's second word: held by a command that exits soon
|
|
30
|
+
BRIEF_WAIT_S = 15.0 # how long a start waits for such a command
|
|
31
|
+
SETTLE_S = 0.5 # ... and for a holder that may not have written its line yet
|
|
32
|
+
# A Windows byte-range lock also keeps other processes from reading that range: lock a
|
|
33
|
+
# byte far past the holder's process id (written at the start of the file), so a start
|
|
34
|
+
# that is refused can still name the holder.
|
|
35
|
+
_WIN_OFFSET = 1 << 30
|
|
36
|
+
_held: dict[str, int] = {} # data directory -> fd that holds its lock
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Held(Exception):
|
|
40
|
+
"""Another process holds the data directory's lock (brief: a --set-password /
|
|
41
|
+
--sign-out-all that is changing config.json)."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, data_dir: str, pid: int | None, brief: bool = False):
|
|
44
|
+
self.data_dir, self.pid, self.brief = data_dir, pid, brief
|
|
45
|
+
process = f" (process {pid})" if pid else ""
|
|
46
|
+
if brief:
|
|
47
|
+
super().__init__(f"ser2netweb --set-password / --sign-out-all{process} is changing "
|
|
48
|
+
f"the data directory {data_dir}")
|
|
49
|
+
else:
|
|
50
|
+
super().__init__(f"another ser2netweb{process} is already running with the data "
|
|
51
|
+
f"directory {data_dir}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _lock(fd: int) -> bool:
|
|
55
|
+
"""Lock fd without waiting: True when locked, False when another process holds it.
|
|
56
|
+
Any other OSError means this file system can't lock."""
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
import msvcrt
|
|
59
|
+
|
|
60
|
+
os.lseek(fd, _WIN_OFFSET, os.SEEK_SET)
|
|
61
|
+
try:
|
|
62
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
63
|
+
except OSError as e:
|
|
64
|
+
if e.errno in (errno.EACCES, errno.EDEADLK):
|
|
65
|
+
return False
|
|
66
|
+
raise
|
|
67
|
+
return True
|
|
68
|
+
import fcntl
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
72
|
+
except BlockingIOError:
|
|
73
|
+
return False
|
|
74
|
+
return True
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unlock(fd: int) -> None:
|
|
78
|
+
if sys.platform == "win32":
|
|
79
|
+
import msvcrt
|
|
80
|
+
|
|
81
|
+
os.lseek(fd, _WIN_OFFSET, os.SEEK_SET)
|
|
82
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
83
|
+
else:
|
|
84
|
+
import fcntl
|
|
85
|
+
|
|
86
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _holder(fd: int) -> tuple[int | None, bool]:
|
|
90
|
+
"""(process id, brief) from the holder's line: "<pid>" or "<pid> brief"."""
|
|
91
|
+
try:
|
|
92
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
93
|
+
words = os.read(fd, 32).decode("ascii", "replace").split()
|
|
94
|
+
except OSError:
|
|
95
|
+
return None, False
|
|
96
|
+
pid = int(words[0]) if words and words[0].isdigit() else None
|
|
97
|
+
return pid, words[1:2] == [BRIEF]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _open(path: str) -> tuple[int, bool]:
|
|
101
|
+
"""(fd, writable). Read-only is enough for the lock itself (the process id isn't
|
|
102
|
+
written then); raises OSError when the file can't be opened at all."""
|
|
103
|
+
try:
|
|
104
|
+
return os.open(path, os.O_RDWR | os.O_CREAT, 0o600), True
|
|
105
|
+
except OSError:
|
|
106
|
+
if not os.path.isfile(path):
|
|
107
|
+
raise
|
|
108
|
+
return os.open(path, os.O_RDONLY), False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def acquire(data_dir: str, *, brief: bool = False, wait: float = 0.0) -> str | None:
|
|
112
|
+
"""Hold data_dir's lock until release() or the end of the process. Raises Held when
|
|
113
|
+
another process holds it; a brief holder is waited for up to `wait` seconds first
|
|
114
|
+
(one that has not written its line yet: SETTLE_S of them). brief=True marks this
|
|
115
|
+
holder as brief. Returns a warning for the start-up log when it can't be taken here
|
|
116
|
+
(the start goes on without it), else None."""
|
|
117
|
+
data_dir = os.path.abspath(data_dir)
|
|
118
|
+
if data_dir in _held:
|
|
119
|
+
return None
|
|
120
|
+
path = os.path.join(data_dir, LOCK_NAME)
|
|
121
|
+
start = time.monotonic()
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
fd, writable = _open(path)
|
|
125
|
+
except OSError as e:
|
|
126
|
+
return (f"WARNING: can't lock the data directory ({path}: {e.strerror or e}) — "
|
|
127
|
+
"nothing keeps a second ser2netweb from using it at the same time")
|
|
128
|
+
try:
|
|
129
|
+
locked = _lock(fd)
|
|
130
|
+
except OSError as e:
|
|
131
|
+
os.close(fd)
|
|
132
|
+
return (f"WARNING: can't lock the data directory ({path}: locking is not "
|
|
133
|
+
f"supported there: {e.strerror or e}) — nothing keeps a second ser2netweb "
|
|
134
|
+
"from using it at the same time")
|
|
135
|
+
if locked:
|
|
136
|
+
break
|
|
137
|
+
pid, by_brief = _holder(fd)
|
|
138
|
+
os.close(fd)
|
|
139
|
+
if time.monotonic() - start >= (wait if by_brief else min(wait, SETTLE_S)):
|
|
140
|
+
raise Held(data_dir, pid, by_brief)
|
|
141
|
+
time.sleep(0.1)
|
|
142
|
+
if writable:
|
|
143
|
+
try:
|
|
144
|
+
os.ftruncate(fd, 0)
|
|
145
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
146
|
+
os.write(fd, f"{os.getpid()}{' ' + BRIEF if brief else ''}\n".encode("ascii"))
|
|
147
|
+
except OSError:
|
|
148
|
+
pass # the lock holds; only the name in a refusal is lost
|
|
149
|
+
_held[data_dir] = fd
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def release(data_dir: str) -> None:
|
|
154
|
+
fd = _held.pop(os.path.abspath(data_dir), None)
|
|
155
|
+
if fd is not None:
|
|
156
|
+
os.close(fd) # closing the file drops the lock
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def holds(data_dir: str) -> bool:
|
|
160
|
+
"""This process holds data_dir's lock."""
|
|
161
|
+
return os.path.abspath(data_dir) in _held
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def holder(data_dir: str) -> int | None:
|
|
165
|
+
"""Without keeping it: the process id holding data_dir's lock (0 when it can't be
|
|
166
|
+
read), or None when nobody holds it. For --install-service, which must leave the
|
|
167
|
+
lock to the service it starts."""
|
|
168
|
+
path = os.path.join(os.path.abspath(data_dir), LOCK_NAME)
|
|
169
|
+
try:
|
|
170
|
+
fd = os.open(path, os.O_RDONLY)
|
|
171
|
+
except OSError:
|
|
172
|
+
return None # no lock file: no ser2netweb that locks has run here
|
|
173
|
+
try:
|
|
174
|
+
if _lock(fd):
|
|
175
|
+
_unlock(fd)
|
|
176
|
+
return None
|
|
177
|
+
return _holder(fd)[0] or 0
|
|
178
|
+
except OSError:
|
|
179
|
+
return None
|
|
180
|
+
finally:
|
|
181
|
+
os.close(fd)
|
|
File without changes
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Who may use a listening mapping: the per-client policy of the transparent bridge
|
|
2
|
+
(bridge.MappingRunner) and the Modbus gateway (modbus_gateway.ModbusGatewayRunner).
|
|
3
|
+
|
|
4
|
+
- ``allowed_client_ips``: blank admits every source, otherwise only the listed IPs/CIDRs
|
|
5
|
+
(an IPv4-mapped IPv6 peer counts as its IPv4 address, an IPv4-mapped entry as the IPv4
|
|
6
|
+
addresses it maps, and a bare 0.0.0.0 or :: as every source, IPv4 and IPv6); entries
|
|
7
|
+
that are no address or network (a hand-edited config.json) are left out, and a list
|
|
8
|
+
with no usable entry at all admits nobody, never everyone;
|
|
9
|
+
- at ``max_connections`` a source in ``priority_client_ips`` always gets in by kicking a
|
|
10
|
+
client (the oldest non-priority one, else the oldest); any other source gets in only
|
|
11
|
+
with ``kick_old_user``, by kicking the oldest non-priority client: it never takes a
|
|
12
|
+
priority client's place, and is refused while only priority clients are connected.
|
|
13
|
+
Only clients still connected count: one kicked (or otherwise on its way out) is neither
|
|
14
|
+
counted nor kicked again, so connections that arrive together can't all take the same
|
|
15
|
+
slot;
|
|
16
|
+
- ``read_only``: a client may watch the device but not change it. Each runner enforces it
|
|
17
|
+
for its protocol: the bridge never writes a client's bytes to the port (and its RFC2217
|
|
18
|
+
session refuses line changes), the Modbus gateway answers write requests with an
|
|
19
|
+
exception instead of putting them on the bus and refuses MQTT/Sparkplug writes.
|
|
20
|
+
|
|
21
|
+
Also the TLS context of a listener (``network.tls``), the same for both runners.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import ipaddress
|
|
27
|
+
import itertools
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Callable, Collection, Iterable, Optional, Protocol
|
|
30
|
+
|
|
31
|
+
from ..config import ClientNetwork as Network
|
|
32
|
+
from ..config import canonical_ip, client_networks
|
|
33
|
+
from . import tls
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
import ssl
|
|
37
|
+
|
|
38
|
+
from ..config import NetworkSettings
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Client(Protocol):
|
|
42
|
+
"""A connected client as the policy sees it."""
|
|
43
|
+
peer: str # "ip:port", for the log
|
|
44
|
+
priority: bool # admitted as a priority client
|
|
45
|
+
seq: int # its place in the order clients connected in (connection_order)
|
|
46
|
+
connected_at: float # time.monotonic() when it connected: for how long it has been
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def closing(self) -> bool: ... # kicked, or ending: still listed until it is gone
|
|
50
|
+
|
|
51
|
+
def kick(self, reason: str = ...) -> None: ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# a bare wildcard in an IP/CIDR list: every client, of either family
|
|
55
|
+
_ANY: tuple[Network, ...] = (ipaddress.IPv4Network("0.0.0.0/0"), ipaddress.IPv6Network("::/0"))
|
|
56
|
+
_MAPPED = ipaddress.IPv6Network("::ffff:0:0/96")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _unmapped(net: Network) -> Network:
|
|
60
|
+
"""An IPv4-mapped IPv6 network (::ffff:10.0.0.0/120) as the IPv4 network it maps
|
|
61
|
+
(10.0.0.0/24): a client's IPv4-mapped address is compared as its IPv4 address."""
|
|
62
|
+
if isinstance(net, ipaddress.IPv6Network) and net.prefixlen >= 96 and net.subnet_of(_MAPPED):
|
|
63
|
+
return ipaddress.IPv4Network((int(net.network_address) & 0xFFFFFFFF, net.prefixlen - 96))
|
|
64
|
+
return net
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_ORDER = itertools.count(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def connection_order() -> int:
|
|
71
|
+
"""The next number in the order clients connect in (one count for every mapping): which
|
|
72
|
+
of two clients came first is told by it, never by a clock - a clock set back or
|
|
73
|
+
forward can't make the newest client the oldest, and two clients that connect within
|
|
74
|
+
one tick of a coarse clock (about 15.6 ms on Windows) still have an order."""
|
|
75
|
+
return next(_ORDER)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_nets(cidrs: Iterable[Any]) -> list[Network]:
|
|
79
|
+
"""The networks of an IP/CIDR list. A bare wildcard address (0.0.0.0, ::) stands for
|
|
80
|
+
every client, IPv4 and IPv6 alike (0.0.0.0/0 and ::/0 are each one family); an
|
|
81
|
+
IPv4-mapped entry for the IPv4 addresses it maps. An entry that is no address or
|
|
82
|
+
network is left out (config.client_networks: the edits refuse it, the runner names it
|
|
83
|
+
at start)."""
|
|
84
|
+
nets: list[Network] = []
|
|
85
|
+
for c in cidrs or []:
|
|
86
|
+
text = c if isinstance(c, str) else str(c)
|
|
87
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
88
|
+
if "/" not in text and ipaddress.ip_address(canonical_ip(text.strip())).is_unspecified:
|
|
89
|
+
nets += _ANY
|
|
90
|
+
continue
|
|
91
|
+
found, _ignored = client_networks([text])
|
|
92
|
+
nets += [_unmapped(n) for n in found]
|
|
93
|
+
return nets
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def ip_in_nets(ip: str, nets: Collection[Network]) -> bool:
|
|
97
|
+
if not nets:
|
|
98
|
+
return False
|
|
99
|
+
with contextlib.suppress(ValueError):
|
|
100
|
+
addr = ipaddress.ip_address(ip)
|
|
101
|
+
# treat IPv4-mapped IPv6 (::ffff:1.2.3.4) as the underlying IPv4
|
|
102
|
+
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped:
|
|
103
|
+
addr = addr.ipv4_mapped
|
|
104
|
+
return any(addr in net for net in nets)
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def pick_victim(clients: Collection[Client], priority: bool = True) -> Optional[Client]:
|
|
109
|
+
"""The client to evict to make room for a newcomer: the oldest (first connected)
|
|
110
|
+
non-priority one. Only a priority newcomer (``priority``) falls back to the oldest
|
|
111
|
+
client overall; for any other there is none to evict while only priority clients are
|
|
112
|
+
connected (None)."""
|
|
113
|
+
pool = [c for c in clients if not c.priority]
|
|
114
|
+
if not pool and priority:
|
|
115
|
+
pool = list(clients)
|
|
116
|
+
return min(pool, key=lambda c: c.seq) if pool else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Admission:
|
|
121
|
+
"""AccessPolicy.admit's verdict on a new connection."""
|
|
122
|
+
admitted: bool
|
|
123
|
+
priority: bool = False # the source is a priority client
|
|
124
|
+
at_capacity: bool = False # refused because the mapping already has max_connections
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class AccessPolicy:
|
|
128
|
+
def __init__(self, net: NetworkSettings) -> None:
|
|
129
|
+
self.net = net
|
|
130
|
+
self._allowed = parse_nets(net.allowed_client_ips)
|
|
131
|
+
self._priority = parse_nets(net.priority_client_ips)
|
|
132
|
+
# no allow-list = everyone; one whose entries are all unusable = nobody (the runner
|
|
133
|
+
# logs which entries it left out at start: MappingConfig.ignored_settings)
|
|
134
|
+
self._everyone = not net.allowed_client_ips
|
|
135
|
+
|
|
136
|
+
def allowed(self, ip: str) -> bool:
|
|
137
|
+
return self._everyone or ip_in_nets(ip, self._allowed)
|
|
138
|
+
|
|
139
|
+
def is_priority(self, ip: str) -> bool:
|
|
140
|
+
return ip_in_nets(ip, self._priority)
|
|
141
|
+
|
|
142
|
+
def admit(self, ip: str, label: str, clients: Collection[Client],
|
|
143
|
+
log: Callable[[str], None]) -> Admission:
|
|
144
|
+
"""Let a new connection from ``ip`` (``label``: ip:port, for the log) in or not,
|
|
145
|
+
given the ``clients`` listed now. Kicks one of them to make room when the policy
|
|
146
|
+
says so; logs each refusal and kick. The caller closes a refused connection, and
|
|
147
|
+
adds an admitted one to ``clients`` before it awaits anything (so the next
|
|
148
|
+
connection is decided with this one counted)."""
|
|
149
|
+
if not self.allowed(ip):
|
|
150
|
+
log(f"rejected {label}: not in allowed list")
|
|
151
|
+
return Admission(False)
|
|
152
|
+
priority = self.is_priority(ip)
|
|
153
|
+
live = [c for c in clients if not c.closing] # a kicked one is still listed a while
|
|
154
|
+
if len(live) >= self.net.max_connections:
|
|
155
|
+
# A priority client always gets in (kick to make room); a normal client
|
|
156
|
+
# gets in only if kick_old_user is set, and never in a priority client's place.
|
|
157
|
+
if not (priority or self.net.kick_old_user):
|
|
158
|
+
log(f"rejected {label}: max connections reached")
|
|
159
|
+
return Admission(False, priority, at_capacity=True)
|
|
160
|
+
victim = pick_victim(live, priority)
|
|
161
|
+
if victim is None:
|
|
162
|
+
log(f"rejected {label}: max connections reached (all priority clients)")
|
|
163
|
+
return Admission(False, priority, at_capacity=True)
|
|
164
|
+
why = "priority client" if priority else "new client"
|
|
165
|
+
log(f"kicking {victim.peer} for {why} {label}")
|
|
166
|
+
victim.kick(f"kicked for {why} {label}")
|
|
167
|
+
return Admission(True, priority)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def server_ssl(net: NetworkSettings) -> Optional[ssl.SSLContext]:
|
|
171
|
+
"""The TLS context of a mapping's TCP listener (None without TLS; engine/tls.py).
|
|
172
|
+
Raises tls.TlsFileError when its files can't be used."""
|
|
173
|
+
return tls.mapping_context(net)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Why a listener can't take its port when the system refuses it, and what to do.
|
|
2
|
+
|
|
3
|
+
On Linux a process that isn't root may listen on a port below
|
|
4
|
+
net.ipv4.ip_unprivileged_port_start (1024 unless changed) only with the
|
|
5
|
+
CAP_NET_BIND_SERVICE capability. The systemd unit runs ser2netweb as such a user, and so
|
|
6
|
+
does the Docker image (uid 10001) on the host network, so Modbus/TCP's 502 or a web UI on
|
|
7
|
+
80 fails with EACCES: "permission denied", with neither the cause nor the fix. On Windows
|
|
8
|
+
the same error (WSAEACCES, 10013) means another program holds the port exclusively, or
|
|
9
|
+
Windows keeps it in an excluded range.
|
|
10
|
+
|
|
11
|
+
hint() names the case; note() makes it the mapping status's hint (portdiag.Note: the
|
|
12
|
+
dashboard words it in the page's language, the REST API in English); explain() is the
|
|
13
|
+
English line for the logs; admin_port_note() is the start-up warning for an admin UI port
|
|
14
|
+
that this process can't listen on.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import errno
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from . import portdiag
|
|
24
|
+
|
|
25
|
+
PRIVILEGED_PORT = "privileged_port" # Linux: a port below 1024 without the capability
|
|
26
|
+
EXCLUDED_PORT = "excluded_port" # Windows: a port another program or Windows keeps
|
|
27
|
+
CAP_NET_BIND_SERVICE = 10 # its bit in the capability sets (linux/capability.h)
|
|
28
|
+
_WSAEACCES = 10013
|
|
29
|
+
|
|
30
|
+
_EXPLAIN = {
|
|
31
|
+
PRIVILEGED_PORT: (
|
|
32
|
+
"ports below {start} need the CAP_NET_BIND_SERVICE capability on Linux (systemd: "
|
|
33
|
+
"AmbientCapabilities=CAP_NET_BIND_SERVICE; Docker on the host network: "
|
|
34
|
+
"SER2NETWEB_LOW_PORTS=1), or use port {start} or above (README: Ports below 1024)"),
|
|
35
|
+
EXCLUDED_PORT: (
|
|
36
|
+
"Windows doesn't let ser2netweb use this port: another program holds it, or Windows "
|
|
37
|
+
"reserves it (netsh interface ipv4 show excludedportrange protocol=tcp); pick "
|
|
38
|
+
"another port"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def unprivileged_port_start() -> int:
|
|
43
|
+
"""The first port a process without the capability may listen on (Linux): 1024 when
|
|
44
|
+
the setting can't be read (the systemd unit's ProcSubset=pid hides /proc/sys)."""
|
|
45
|
+
try:
|
|
46
|
+
with open("/proc/sys/net/ipv4/ip_unprivileged_port_start", encoding="ascii") as fh:
|
|
47
|
+
return int(fh.read().strip())
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return 1024
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _euid() -> int:
|
|
53
|
+
if sys.platform == "win32":
|
|
54
|
+
return -1
|
|
55
|
+
return os.geteuid()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def may_bind_low_ports() -> bool:
|
|
59
|
+
"""Linux: whether this process may listen below unprivileged_port_start(): root, or
|
|
60
|
+
CAP_NET_BIND_SERVICE among its effective capabilities."""
|
|
61
|
+
if _euid() == 0:
|
|
62
|
+
return True
|
|
63
|
+
try:
|
|
64
|
+
with open("/proc/self/status", encoding="ascii", errors="replace") as fh:
|
|
65
|
+
for line in fh:
|
|
66
|
+
if line.startswith("CapEff:"):
|
|
67
|
+
return bool(int(line.split()[1], 16) >> CAP_NET_BIND_SERVICE & 1)
|
|
68
|
+
except (OSError, ValueError, IndexError):
|
|
69
|
+
pass
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def hint(error: BaseException, port: int, platform: str = sys.platform) -> str:
|
|
74
|
+
"""PRIVILEGED_PORT / EXCLUDED_PORT when the bind of ``port`` failed for that reason,
|
|
75
|
+
else "". (asyncio keeps the errno when it rewords the error.)"""
|
|
76
|
+
code = getattr(error, "errno", None)
|
|
77
|
+
if platform.startswith("linux"):
|
|
78
|
+
if code == errno.EACCES and 0 < port < unprivileged_port_start():
|
|
79
|
+
return PRIVILEGED_PORT
|
|
80
|
+
elif platform == "win32":
|
|
81
|
+
if code in (errno.EACCES, _WSAEACCES) or getattr(error, "winerror", None) == _WSAEACCES:
|
|
82
|
+
return EXCLUDED_PORT
|
|
83
|
+
return ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def for_mapping(error: BaseException, mapping: Any) -> str:
|
|
87
|
+
"""hint() for a mapping that failed to start: only a listener's port counts."""
|
|
88
|
+
net = getattr(mapping, "network", None)
|
|
89
|
+
if getattr(mapping, "kind", "") != "net" or not getattr(net, "listens", False):
|
|
90
|
+
return ""
|
|
91
|
+
return hint(error, int(getattr(net, "port", 0) or 0))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def note(name: str) -> Optional[portdiag.Note]:
|
|
95
|
+
"""The status hint (bridge.RunnerStatus.hint) for a name hint() gave; None for ""."""
|
|
96
|
+
return portdiag.Note(name) if name else None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def explain(name: str) -> str:
|
|
100
|
+
"""The English line for a hint (the logs)."""
|
|
101
|
+
text = _EXPLAIN.get(name, "")
|
|
102
|
+
return text.format(start=unprivileged_port_start()) if text else ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def admin_port_note(port: int, platform: str = sys.platform) -> str:
|
|
106
|
+
"""The start-up warning when the admin UI's port is one this process can't listen on
|
|
107
|
+
(Linux): the web UI would not start (and a service manager restart it again and
|
|
108
|
+
again). "" otherwise."""
|
|
109
|
+
if not platform.startswith("linux") or not 0 < port < unprivileged_port_start():
|
|
110
|
+
return ""
|
|
111
|
+
if may_bind_low_ports():
|
|
112
|
+
return ""
|
|
113
|
+
return (f"WARNING: the admin UI port {port} is below {unprivileged_port_start()}, which "
|
|
114
|
+
f"this account (uid {_euid()}) can't listen on, so the web UI can't start: "
|
|
115
|
+
+ explain(PRIVILEGED_PORT))
|