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