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
@@ -0,0 +1,2001 @@
1
+ """Import mappings from a classic ser2net configuration (the C program by Corey Minyard).
2
+
3
+ Users migrating from it bring /etc/ser2net/ser2net.yaml (ser2net 4.x) or
4
+ /etc/ser2net.conf (ser2net 3.x). Neither can be read with a stock parser: a 4.x
5
+ "version 1" file repeats top-level keys (one `connection:` per port) and substitutes
6
+ `*(name)` references inside strings, which is not standard YAML (and PyYAML is not a
7
+ dependency anyway); 3.x has its own colon-separated line format. So this module
8
+ carries a small reader for the YAML subset ser2net configs use plus a 3.x line
9
+ parser, and turns every ser2net connection into a dict for MappingConfig.from_dict
10
+ (each warning about one is a Note that knows its mapping: the preview shows it there).
11
+
12
+ What has no equivalent here (LEDs, rotators, mDNS, ...) is reported as a warning that
13
+ names the connection; chardelay (how ser2net batches what it reads) is dropped without
14
+ one: data is forwarded as it arrives here. A connection that would be more open here than in
15
+ ser2net is imported disabled, with a warning that says why: its gtlssh login
16
+ (certauth) cannot be carried over, its TLS client certificates (ssl clientauth) only with
17
+ the CA file they are checked against (CA=) on this host, its TLS certificate is not on
18
+ this host (with the files there it keeps TLS), the device only
19
+ received data (wronly, rawlp, no-con-to-acc), its bind address or remaddr cannot be
20
+ used here. A connection that cannot be represented at all (a network or IPMI
21
+ connector, a unix-socket accepter) is skipped with a warning, so every returned dict
22
+ passes MappingConfig.validate(). ser2net only opens a serial device while a client is
23
+ connected, so several of its ports may share one device; a mapping here keeps its
24
+ device open, so later connections on the same device are imported disabled, and a
25
+ repeated listening port is skipped: the import can then be applied as a whole. As the
26
+ device stays open, an openstr is sent once when it opens, not for each client (a note says
27
+ so), and hangup-when-done / nobreak are kept but do nothing (a note too). A listening port
28
+ this process can't use (below 1024 on Linux without the capability) gets a note. ser2net's
29
+ locking carries over: 4.x flock (flock plus TIOCEXCL) and uucplock, on by default, become
30
+ exclusive + tiocexcl and respect_uucp_locks (UUCP lock files are honoured, not made), 3.x
31
+ UUCP locking respect_uucp_locks; one warning says what still differs (the port is held
32
+ open, no lock files are made).
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import ipaddress
37
+ import os
38
+ import re
39
+ import socket
40
+ import sys
41
+ from collections.abc import Callable
42
+ from dataclasses import dataclass, field
43
+ from typing import Any, Optional
44
+
45
+ from .config import (
46
+ MAX_TIMER_S, RFC2217_SIGNATURE_MAX, ConfigError, MappingConfig, _ip_overlaps,
47
+ bind_ip_problem, canonical_device, canonical_ip, trace_file_name, url_port,
48
+ )
49
+ from .engine import bindhint
50
+ from .i18n import _
51
+
52
+ # What one file may cost. A ser2net configuration is a few KB, but aliases, defines and
53
+ # string escapes can repeat text without limit, so each kind of growth has a bound and
54
+ # a file past one is refused (ConfigError) instead of tying up the service.
55
+ MAX_TEXT = 1024 * 1024 # characters in the file
56
+ MAX_CONNECTIONS = 1000 # connections (and ser2net.conf ports) in the file
57
+ _MAX_VALUE = 64 * 1024 # characters in one value, defines and escapes expanded
58
+ _MAX_WORK = 16 * 1024 * 1024 # characters of values and messages produced in all
59
+ _MAX_NODES = 100_000 # YAML nodes, each alias counted as all it repeats
60
+ _MAX_DEPTH = 64 # YAML collections nested in each other
61
+ _MAX_WARNINGS = 1000 # warnings listed; the rest are only counted
62
+
63
+
64
+ class _Budget:
65
+ """The characters (_MAX_WORK) one file may still produce."""
66
+
67
+ def __init__(self) -> None:
68
+ self.left = _MAX_WORK
69
+
70
+ def spend(self, n: int) -> None:
71
+ self.left -= n
72
+ if self.left < 0:
73
+ raise ConfigError(_("The file expands to more than {0} MB of text with its "
74
+ "aliases, defines and references, so it is not imported.")
75
+ .format(_MAX_WORK // 2**20))
76
+
77
+
78
+ def _too_long(what: str) -> ConfigError:
79
+ return ConfigError(_("{0}: the value is longer than {1} KB with its defines and escapes "
80
+ "expanded, so the file is not imported.").format(what, _MAX_VALUE // 1024))
81
+
82
+
83
+ def _too_many_connections() -> ConfigError:
84
+ return ConfigError(_("The file has more than {0} connections; import it in parts.")
85
+ .format(MAX_CONNECTIONS))
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # A reader for the YAML subset ser2net configs use: block mappings/sequences by
90
+ # indentation, plain / 'single' / "double" quoted scalars (multi-line too), flow
91
+ # [..] and {..}, |/> block scalars, &anchors and *aliases, comments, %YAML and
92
+ # ---/... markers. Repeated keys are kept in order (ser2net reads them all).
93
+ # ---------------------------------------------------------------------------
94
+ class _YamlError(Exception):
95
+ def __init__(self, line: int, msg: str) -> None:
96
+ super().__init__(_("line {0}: {1}").format(line, msg))
97
+
98
+
99
+ @dataclass
100
+ class _YMap:
101
+ entries: list[tuple[str, Any, str]] = field(default_factory=list) # key, value, anchor
102
+
103
+ def get(self, key: str, default: Any = None) -> Any:
104
+ """The last value of `key` (case-insensitive, as ser2net matches keys)."""
105
+ for k, v, _anchor in reversed(self.entries):
106
+ if k.lower() == key:
107
+ return v
108
+ return default
109
+
110
+
111
+ _ALIAS_NAME = re.compile(r"[^\s,\[\]{}]+")
112
+ _DQ_ESCAPES = {"0": "\0", "a": "\a", "b": "\b", "t": "\t", "\t": "\t", "n": "\n", "v": "\v",
113
+ "f": "\f", "r": "\r", "e": "\x1b", " ": " ", '"': '"', "/": "/", "\\": "\\",
114
+ "N": "\x85", "_": "\xa0", "L": "
", "P": "
"}
115
+
116
+
117
+ def _indent(line: str) -> tuple[int, str]:
118
+ body = line.lstrip(" \t")
119
+ return len(line[: len(line) - len(body)].expandtabs(8)), body
120
+
121
+
122
+ def _marker(body: str, mark: str) -> bool:
123
+ return body == mark or body.startswith((mark + " ", mark + "\t"))
124
+
125
+
126
+ def _boundary(ind: int, body: str) -> bool:
127
+ """A %directive or a ---/... document marker."""
128
+ return ind == 0 and (body.startswith("%") or _marker(body, "---") or _marker(body, "..."))
129
+
130
+
131
+ def _is_item(body: str) -> bool:
132
+ return body == "-" or body.startswith(("- ", "-\t"))
133
+
134
+
135
+ def _cut_comment(s: str) -> str:
136
+ """A plain scalar ends where a comment starts (a # after whitespace)."""
137
+ m = re.search(r"\s#", s)
138
+ return (s[: m.start()] if m else s).rstrip()
139
+
140
+
141
+ def _key(body: str) -> Optional[tuple[str, str]]:
142
+ """(key, text after the colon) for a 'key: value' line, else None."""
143
+ if body[0] in "\"'":
144
+ end = body.find(body[0], 1)
145
+ rest = body[end + 1:].lstrip(" \t") if end > 0 else ""
146
+ if rest.startswith(":") and (len(rest) == 1 or rest[1] in " \t"):
147
+ return body[1:end], rest[1:]
148
+ return None
149
+ if body[0] in "[]{},&*!|>%@`#" or _is_item(body):
150
+ return None
151
+ m = re.search(r":(?:[ \t]|$)", body)
152
+ if m is None:
153
+ return None
154
+ key = body[: m.start()].rstrip()
155
+ if not key or re.search(r"\s#", key):
156
+ return None
157
+ return key, body[m.start() + 1:]
158
+
159
+
160
+ def _close_quote(seg: str, quote: str, start: int = 0) -> int:
161
+ """Index of the closing quote in seg (looking from `start`), or -1 (the string goes on)."""
162
+ j = start
163
+ while j < len(seg):
164
+ c = seg[j]
165
+ if quote == '"' and c == "\\":
166
+ j += 2
167
+ continue
168
+ if c == quote:
169
+ if quote == "'" and seg[j + 1:j + 2] == "'":
170
+ j += 2
171
+ continue
172
+ return j
173
+ j += 1
174
+ return -1
175
+
176
+
177
+ def _dq_unescape(s: str) -> str:
178
+ def one(m: re.Match[str]) -> str:
179
+ e = m.group(1)
180
+ if e in _DQ_ESCAPES:
181
+ return _DQ_ESCAPES[e]
182
+ if e[0] in "xuU" and len(e) > 1:
183
+ return chr(int(e[1:], 16))
184
+ return "\\" + e # not a YAML escape: keep it, it may be a ser2net one (\p, \d)
185
+ return re.sub(r"\\(x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|U[0-9a-fA-F]{8}|.)", one, s, flags=re.S)
186
+
187
+
188
+ def _fold_quoted(parts: list[str], quote: str) -> str:
189
+ """Join the lines of a multi-line quoted scalar (a line break folds to a space,
190
+ an empty line to a newline, a trailing backslash in "..." joins directly)."""
191
+ out, blank = [parts[0]], 0
192
+ run = -1 # backslashes the text ends with (-1: the first line is not trimmed yet)
193
+ for n, raw in enumerate(parts[1:], 1):
194
+ last = n == len(parts) - 1
195
+ seg = raw.lstrip(" \t") if last else raw.strip(" \t")
196
+ if not seg and not last:
197
+ blank += 1
198
+ continue
199
+ if run < 0: # only the first line can end in blanks: the others are stripped
200
+ out[0] = out[0].rstrip(" \t")
201
+ run = len(out[0]) - len(out[0].rstrip("\\"))
202
+ if quote == '"' and run % 2 == 1: # an escaped line break: join directly
203
+ out[-1] = out[-1][:-1]
204
+ if not out[-1]:
205
+ out.pop()
206
+ run, sep = run - 1, ""
207
+ else:
208
+ sep = "\n" * blank if blank else " "
209
+ tail = len(seg) - len(seg.rstrip("\\"))
210
+ run = run + tail if seg and tail == len(seg) and not sep else tail
211
+ out += [sep, seg]
212
+ blank = 0
213
+ text = "".join(out)
214
+ return _dq_unescape(text) if quote == '"' else text.replace("''", "'")
215
+
216
+
217
+ class _FlowEnd:
218
+ """Finds the bracket that closes a flow collection ([ or {), one line at a time, so
219
+ a collection over many lines is scanned once."""
220
+
221
+ def __init__(self) -> None:
222
+ self.depth, self.quote = 0, ""
223
+
224
+ def scan(self, line: str) -> int:
225
+ """Index just past the closing bracket in `line`, or -1 if it comes later."""
226
+ depth, quote, j = self.depth, self.quote, 0
227
+ while j < len(line):
228
+ c = line[j]
229
+ if quote:
230
+ if c == "\\" and quote == '"':
231
+ j += 1 # (at the end of a line: the line break is escaped)
232
+ elif c == quote:
233
+ quote = ""
234
+ elif c in "\"'" and (j == 0 or line[j - 1] in " \t,[{:"):
235
+ quote = c
236
+ elif c == "#" and (j == 0 or line[j - 1] in " \t"):
237
+ break # a comment: the rest of the line
238
+ elif c in "[{":
239
+ depth += 1
240
+ elif c in "]}":
241
+ depth -= 1
242
+ if depth == 0:
243
+ return j + 1
244
+ j += 1
245
+ self.depth, self.quote = depth, quote
246
+ return -1
247
+
248
+
249
+ class _Flow:
250
+ """Parser for one flow collection ([a, *b] / {name: speed, value: 115200})."""
251
+
252
+ def __init__(self, text: str, reader: "_YamlReader") -> None:
253
+ self.s, self.j, self.r = text, 0, reader
254
+
255
+ def _ws(self) -> None:
256
+ while self.j < len(self.s):
257
+ c = self.s[self.j]
258
+ if c in " \t\n":
259
+ self.j += 1
260
+ elif c == "#" and (self.j == 0 or self.s[self.j - 1] in " \t\n"):
261
+ nl = self.s.find("\n", self.j)
262
+ self.j = len(self.s) if nl < 0 else nl
263
+ else:
264
+ return
265
+
266
+ def value(self) -> Any:
267
+ self._ws()
268
+ self.r.count()
269
+ c = self.s[self.j:self.j + 1]
270
+ if c in ("[", "{"):
271
+ return self._collection(c)
272
+ if c in ("'", '"'):
273
+ end = _close_quote(self.s, c, self.j + 1)
274
+ if end < 0:
275
+ raise ValueError(_("unterminated quoted string"))
276
+ raw = self.s[self.j + 1:end]
277
+ self.j = end + 1
278
+ return _fold_quoted(raw.split("\n"), c)
279
+ if c == "*":
280
+ m = _ALIAS_NAME.match(self.s, self.j + 1)
281
+ name = m.group(0) if m else ""
282
+ if name not in self.r.anchors:
283
+ raise ValueError(_("unknown alias *{0}").format(name))
284
+ self.j += 1 + len(name)
285
+ return self.r.alias(name)
286
+ start = self.j
287
+ while self.j < len(self.s):
288
+ ch = self.s[self.j]
289
+ nxt = self.s[self.j + 1:self.j + 2]
290
+ if ch in ",[]{}" or (ch == ":" and nxt in ("", " ", "\t", "\n", ",", "]", "}")):
291
+ break
292
+ if ch == "#" and self.s[self.j - 1:self.j] in (" ", "\t", "\n"):
293
+ break
294
+ self.j += 1
295
+ return " ".join(ln.strip() for ln in self.s[start:self.j].split("\n") if ln.strip())
296
+
297
+ def _collection(self, opening: str) -> Any:
298
+ close = "]" if opening == "[" else "}"
299
+ self.j += 1
300
+ self.r.nest(1)
301
+ items: list[Any] = []
302
+ mapping = _YMap()
303
+ while True:
304
+ self._ws()
305
+ if self.s.startswith(close, self.j):
306
+ self.j += 1
307
+ self.r.nest(-1)
308
+ return items if opening == "[" else mapping
309
+ item = self.value()
310
+ self._ws()
311
+ if opening == "{":
312
+ if not isinstance(item, str):
313
+ raise ValueError(_("a [...] or {...} cannot be a key"))
314
+ val: Any = ""
315
+ if self.s.startswith(":", self.j):
316
+ self.j += 1
317
+ val = self.value()
318
+ self._ws()
319
+ mapping.entries.append((item, val, ""))
320
+ else:
321
+ items.append(item)
322
+ if self.s.startswith(",", self.j):
323
+ self.j += 1
324
+ elif not self.s.startswith(close, self.j):
325
+ raise ValueError(_("expected ',' or '{0}'").format(close))
326
+
327
+
328
+ class _YamlReader:
329
+ def __init__(self, text: str) -> None:
330
+ self.lines = text.split("\n")
331
+ self.i = 0 # next line to read
332
+ self.last = 0 # last line consumed (for error messages)
333
+ self.anchors: dict[str, Any] = {}
334
+ self.sizes: dict[str, int] = {} # anchor -> the nodes it stands for
335
+ self.nodes = 0 # nodes read, aliases counted in full (count())
336
+ self.depth = 0 # collections around the one being read
337
+
338
+ def _err(self, msg: str, index: Optional[int] = None) -> _YamlError:
339
+ return _YamlError((self.last if index is None else index) + 1, msg)
340
+
341
+ def _take(self) -> str:
342
+ self.last = self.i
343
+ self.i += 1
344
+ return self.lines[self.last]
345
+
346
+ def _peek(self) -> Optional[tuple[int, str]]:
347
+ """(indent, text) of the next significant line; None at a document end."""
348
+ while self.i < len(self.lines):
349
+ ind, body = _indent(self.lines[self.i])
350
+ if body and not body.startswith("#"):
351
+ return None if _boundary(ind, body) else (ind, body)
352
+ self.i += 1
353
+ return None
354
+
355
+ def documents(self) -> list[Any]:
356
+ docs: list[Any] = []
357
+ while self.i < len(self.lines):
358
+ ind, body = _indent(self.lines[self.i])
359
+ if not body or body.startswith("#") or _boundary(ind, body):
360
+ self.i += 1
361
+ continue
362
+ docs.append(self._node(-1))
363
+ if self._peek() is not None: # dedented below a nested block: not YAML
364
+ raise self._err(_("unexpected indentation"), self.i)
365
+ return docs
366
+
367
+ def count(self, n: int = 1) -> None:
368
+ """Count nodes read. An alias shares the node it names (nothing changes a node once
369
+ it is read), so it costs nothing to copy, but it stands for every node in it: the
370
+ file is refused past _MAX_NODES of them rather than repeating them without end."""
371
+ self.nodes += n
372
+ if self.nodes > _MAX_NODES:
373
+ raise self._err(_("the file stands for more than {0} YAML nodes (an alias counts "
374
+ "as all it repeats)").format(_MAX_NODES))
375
+
376
+ def alias(self, name: str) -> Any:
377
+ self.count(self.sizes[name])
378
+ return self.anchors[name]
379
+
380
+ def nest(self, step: int) -> None:
381
+ self.depth += step
382
+ if self.depth > _MAX_DEPTH:
383
+ raise self._err(_("collections are nested more than {0} levels deep")
384
+ .format(_MAX_DEPTH), self.i)
385
+
386
+ def _node(self, parent: int) -> Any:
387
+ """The block node on the next lines, indented deeper than `parent`."""
388
+ nxt = self._peek()
389
+ if nxt is None or nxt[0] <= parent:
390
+ return ""
391
+ ind, body = nxt
392
+ self.nest(1)
393
+ node: Any
394
+ if _is_item(body):
395
+ node = self._seq(ind)
396
+ elif _key(body) is not None:
397
+ node = self._map(ind, root=parent < 0)
398
+ else:
399
+ self._take()
400
+ node = self._value(body, parent)[0]
401
+ self.nest(-1)
402
+ return node
403
+
404
+ def _map(self, ind: int, root: bool = False) -> _YMap:
405
+ m = _YMap()
406
+ while True:
407
+ nxt = self._peek()
408
+ if nxt is None or nxt[0] < ind:
409
+ return m
410
+ kv = _key(nxt[1]) if nxt[0] == ind else None
411
+ if kv is None:
412
+ if nxt[0] == ind and _is_item(nxt[1]):
413
+ return m # a list at its key's indentation ended; the caller goes on
414
+ raise self._err(_("expected 'key: value' here"), self.i)
415
+ self._take()
416
+ value, anchor = self._value(kv[1], ind, in_map=True)
417
+ if root and anchor and isinstance(value, (_YMap, list)):
418
+ # ser2net: *con0 / *led1 stand for the name of a main-level entry
419
+ self.anchors[anchor], self.sizes[anchor] = anchor, 1
420
+ m.entries.append((kv[0], value, anchor))
421
+
422
+ def _seq(self, ind: int) -> list[Any]:
423
+ items: list[Any] = []
424
+ while True:
425
+ nxt = self._peek()
426
+ if nxt is None or nxt[0] < ind or (nxt[0] == ind and not _is_item(nxt[1])):
427
+ return items
428
+ if nxt[0] > ind:
429
+ raise self._err(_("unexpected indentation"), self.i)
430
+ rest = nxt[1][1:]
431
+ inner = rest.lstrip(" \t")
432
+ if inner and not inner.startswith("#") and (_is_item(inner) or _key(inner)):
433
+ # "- key: value": a nested block that starts on this line
434
+ self.lines[self.i] = " " * (ind + 1 + len(rest) - len(inner)) + inner
435
+ items.append(self._node(ind))
436
+ else:
437
+ self._take()
438
+ items.append(self._value(rest, ind)[0])
439
+
440
+ def _value(self, text: str, parent: int, in_map: bool = False) -> tuple[Any, str]:
441
+ """The value after 'key:' / '- ' (that line is consumed already)."""
442
+ s, anchor = text.strip(), ""
443
+ while s[:1] in ("&", "!"):
444
+ m = re.match(r"([&!])(\S*)\s*", s)
445
+ if m is None:
446
+ break
447
+ if m.group(1) == "&":
448
+ anchor = m.group(2)
449
+ s = s[m.end():]
450
+ start = self.nodes
451
+ self.count()
452
+ node: Any
453
+ if not s or s.startswith("#"):
454
+ nxt = self._peek()
455
+ if in_map and nxt is not None and nxt[0] == parent and _is_item(nxt[1]):
456
+ node = self._seq(parent) # "key:\n- a": a list may sit at the key's indentation
457
+ else:
458
+ node = self._node(parent)
459
+ elif s[0] == "*" and s[1:2] not in ("(", "{"): # *(name) is ser2net text, not an alias
460
+ m2 = _ALIAS_NAME.match(s, 1)
461
+ name = m2.group(0) if m2 else ""
462
+ if name not in self.anchors:
463
+ raise self._err(_("unknown alias *{0}").format(name))
464
+ node = self.alias(name)
465
+ elif s[0] in "\"'":
466
+ node = self._quoted(s)
467
+ elif s[0] in "[{":
468
+ node = self._flow(s)
469
+ elif s[0] in "|>":
470
+ node = self._block_scalar(s, parent)
471
+ else:
472
+ node = self._plain(s, parent)
473
+ if anchor:
474
+ self.anchors[anchor], self.sizes[anchor] = node, self.nodes - start
475
+ return node, anchor
476
+
477
+ def _plain(self, first: str, parent: int) -> str:
478
+ """A plain scalar; lines indented deeper than its key continue it, folded with
479
+ one space (ser2net configs split long connector strings this way)."""
480
+ out, breaks = [_cut_comment(first)], 0
481
+ while self.i < len(self.lines):
482
+ ind, body = _indent(self.lines[self.i])
483
+ if not body or body.startswith("#"):
484
+ breaks += 0 if body else 1
485
+ self.i += 1
486
+ continue
487
+ if ind <= parent or _boundary(ind, body):
488
+ break
489
+ self._take()
490
+ out.append(("\n" * breaks if breaks else " ") + _cut_comment(body))
491
+ breaks = 0
492
+ return "".join(out)
493
+
494
+ def _quoted(self, s: str) -> str:
495
+ quote, parts = s[0], [s[1:]]
496
+ while (end := _close_quote(parts[-1], quote)) < 0:
497
+ if self.i >= len(self.lines):
498
+ raise self._err(_("unterminated quoted string"))
499
+ parts.append(self._take())
500
+ tail = parts[-1][end + 1:].strip()
501
+ if tail and not tail.startswith("#"):
502
+ raise self._err(_("unexpected text after a quoted string"))
503
+ parts[-1] = parts[-1][:end]
504
+ return _fold_quoted(parts, quote)
505
+
506
+ def _flow(self, s: str) -> Any:
507
+ scan, lines = _FlowEnd(), [s]
508
+ while (end := scan.scan(lines[-1])) < 0:
509
+ if self.i >= len(self.lines):
510
+ raise self._err(_("unterminated [...] or {...}"))
511
+ lines.append(self._take())
512
+ text = "\n".join(lines)
513
+ end += len(text) - len(lines[-1])
514
+ tail = text[end:].strip()
515
+ if tail and not tail.startswith("#"):
516
+ raise self._err(_("unexpected text after ] or }"))
517
+ try:
518
+ return _Flow(text[:end], self).value()
519
+ except ValueError as e:
520
+ raise self._err(str(e)) from None
521
+
522
+ def _block_scalar(self, header: str, parent: int) -> str:
523
+ m = re.match(r"([|>])([+-]?)([1-9]?)([+-]?)\s*(?:#.*)?$", header)
524
+ if m is None:
525
+ raise self._err(_("bad | or > block header"))
526
+ chomp = m.group(2) or m.group(4)
527
+ indent = max(parent, 0) + int(m.group(3)) if m.group(3) else -1
528
+ lines: list[str] = []
529
+ while self.i < len(self.lines):
530
+ ind, body = _indent(self.lines[self.i])
531
+ if not body:
532
+ lines.append("")
533
+ self.i += 1
534
+ continue
535
+ if indent < 0:
536
+ indent = ind
537
+ if ind < indent or ind <= parent:
538
+ break
539
+ self._take()
540
+ lines.append(" " * (ind - indent) + body)
541
+ trailing = 0
542
+ while lines and not lines[-1]:
543
+ lines.pop()
544
+ trailing += 1
545
+ if m.group(1) == "|":
546
+ text = "\n".join(lines)
547
+ else: # folded: lines join with a space, an empty line is a newline
548
+ parts: list[str] = []
549
+ blank = 0
550
+ for ln in lines:
551
+ if not ln:
552
+ blank += 1
553
+ continue
554
+ if parts:
555
+ parts.append("\n" * blank if blank else " ")
556
+ parts.append(ln)
557
+ blank = 0
558
+ text = "".join(parts)
559
+ if chomp == "-" or not text:
560
+ return text
561
+ return text + "\n" * (1 + trailing if chomp == "+" else 1)
562
+
563
+
564
+ # ---------------------------------------------------------------------------
565
+ # One ser2net connection on its way to a mapping
566
+ # ---------------------------------------------------------------------------
567
+ @dataclass
568
+ class _Conn:
569
+ name: str
570
+ label: str # how warnings name it
571
+ budget: _Budget # the file's (shared by its connections)
572
+ v3: bool = False # ser2net 3.x (escapes differ slightly)
573
+ enabled: bool = True
574
+ device: str = ""
575
+ serial: dict[str, Any] = field(default_factory=lambda: { # ser2net's default: 9600N81
576
+ "baudrate": 9600, "parity": "N", "bytesize": 8, "stopbits": 1})
577
+ advanced: dict[str, Any] = field(default_factory=dict)
578
+ network: dict[str, Any] = field(default_factory=lambda: {
579
+ "mode": "server", "protocol": "raw", "bind_ip": "0.0.0.0", "port": 0})
580
+ options: dict[str, Any] = field(default_factory=dict)
581
+ xonxoff: bool = False
582
+ rtscts: bool = False
583
+ strings: dict[str, str] = field(default_factory=dict) # banner/openstr/... (raw)
584
+ traces: dict[str, str] = field(default_factory=dict) # both/read/write -> file
585
+ trace_mods: dict[tuple[str, str], bool] = field(default_factory=dict)
586
+ access_given: bool = False # remaddr gave an address (a client limit)
587
+ remaddr_set: bool = False # 3.x remaddr= (even empty): no default then
588
+ any_family: bool = False # no family or address: ser2net listened on IPv4 and IPv6
589
+ holds: list[str] = field(default_factory=list) # why it comes in disabled (else more open)
590
+ ssl: Optional[dict[str, Any]] = None # the accepter's ssl layer (_ssl_layer)
591
+ remctl: bool = False # 3.x RFC2217 flag
592
+ flock: bool = True # gensio serialdev flock (flock + TIOCEXCL), on by default
593
+ uucplock: bool = True # gensio serialdev uucplock (UUCP lock files), on by default
594
+ devname: str = "" # 3.x: the device as the port line names it (\o)
595
+ dropped: dict[str, None] = field(default_factory=dict) # options with no equivalent
596
+ notes: list[str] = field(default_factory=list)
597
+ skipped: list[str] = field(default_factory=list) # why it is not imported
598
+ noted: set[str] = field(default_factory=set) # = set(notes)
599
+ flagged: set[str] = field(default_factory=set) # notes about its protection
600
+
601
+ def note(self, msg: str, security: bool = False) -> None:
602
+ """`security`: the note says the mapping is held disabled or has lost a limit on
603
+ its clients (Note.security)."""
604
+ if msg not in self.noted:
605
+ self.budget.spend(len(self.label) + len(msg)) # the warning it becomes
606
+ self.noted.add(msg)
607
+ self.notes.append(msg)
608
+ if security:
609
+ self.flagged.add(msg)
610
+
611
+ def hold(self, why: str) -> None:
612
+ """Import the connection disabled: as it is here it would be more open than in
613
+ ser2net (every reason is reported)."""
614
+ if why not in self.holds:
615
+ self.holds.append(why)
616
+
617
+ def skip(self, why: str) -> None:
618
+ """Note why the connection is not imported (then the only notes shown for it)."""
619
+ msg = _("skipped: {0}").format(why)
620
+ if msg not in self.skipped:
621
+ self.skipped.append(msg)
622
+ self.note(msg)
623
+
624
+ def drop(self, option: str) -> None:
625
+ self.dropped[option] = None
626
+
627
+ def serial_text(self) -> str:
628
+ """ser2net's \\B: the serial parameters, e.g. 9600N81."""
629
+ s = self.serial
630
+ return (f"{s.get('baudrate', 9600)}{s.get('parity', 'N')}"
631
+ f"{s.get('bytesize', 8)}{int(s.get('stopbits', 1))}")
632
+
633
+ def to_dict(self) -> dict[str, Any]:
634
+ serial: dict[str, Any] = {"port": self.device, **self.serial}
635
+ if self.advanced:
636
+ serial["advanced"] = dict(self.advanced)
637
+ return {"name": self.name, "enabled": self.enabled, "kind": "net", "serial": serial,
638
+ "network": dict(self.network), "options": dict(self.options)}
639
+
640
+
641
+ _TRUE = ("true", "yes", "on", "1")
642
+ _FALSE = ("false", "no", "off", "0")
643
+
644
+
645
+ def _bool(value: str) -> Optional[bool]:
646
+ v = value.strip().lower()
647
+ return True if v in _TRUE else False if v in _FALSE else None
648
+
649
+
650
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
651
+
652
+
653
+ def _shown(value: str) -> str:
654
+ """A value as a warning shows it: control characters (a CR/LF in a banner) escaped."""
655
+ return _CONTROL.sub(lambda m: {"\r": "\\r", "\n": "\\n", "\t": "\\t"}.get(
656
+ m.group(), f"\\x{ord(m.group()):02x}"), value)
657
+
658
+
659
+ def _split_gensio(spec: str) -> list[str]:
660
+ """Split a gensio string at the commas outside (...) and quotes."""
661
+ parts: list[str] = []
662
+ buf, depth, quote, i = "", 0, "", 0
663
+ while i < len(spec):
664
+ ch = spec[i]
665
+ if quote:
666
+ quote = "" if ch == quote else quote
667
+ elif ch == "\\" and i + 1 < len(spec):
668
+ buf += spec[i:i + 2]
669
+ i += 2
670
+ continue
671
+ elif ch in "\"'":
672
+ quote = ch
673
+ elif ch == "(":
674
+ depth += 1
675
+ elif ch == ")":
676
+ depth = max(0, depth - 1)
677
+ elif ch == "," and depth == 0:
678
+ parts.append(buf.strip())
679
+ buf, i = "", i + 1
680
+ continue
681
+ buf += ch
682
+ i += 1
683
+ parts.append(buf.strip())
684
+ return [p for p in parts if p]
685
+
686
+
687
+ def _layer(part: str) -> tuple[str, list[str]]:
688
+ """'telnet(rfc2217)' -> ('telnet', ['rfc2217'])."""
689
+ m = re.match(r"([A-Za-z0-9_-]+)\s*\((.*)\)$", part, re.S)
690
+ if m is None:
691
+ return part, []
692
+ return m.group(1), _split_gensio(m.group(2))
693
+
694
+
695
+ def _port_number(text: str) -> Optional[int]:
696
+ text = text.strip()
697
+ if text.isdigit():
698
+ n = int(text)
699
+ return n if 1 <= n <= 65535 else None
700
+ try:
701
+ return socket.getservbyname(text)
702
+ except (OSError, UnicodeError):
703
+ return None
704
+
705
+
706
+ def _listen_address(c: _Conn, fields: list[str]) -> bool:
707
+ """[ipv4|ipv6,][host,]port -> network bind_ip/port; False if it has no usable port."""
708
+ fields = [f.strip() for f in fields if f.strip()]
709
+ family = ""
710
+ while fields and fields[0].lower() in ("ipv4", "ipv6", "ipv6n4"):
711
+ family = fields.pop(0).lower()
712
+ if not fields:
713
+ c.skip(_("the accepter has no port"))
714
+ return False
715
+ host, port_s = ("", fields[0]) if len(fields) == 1 else (fields[0], fields[1])
716
+ if len(fields) > 2:
717
+ c.note(_("only the first listen address is imported: {0}").format(",".join(fields[:2])))
718
+ port = _port_number(port_s)
719
+ if port is None:
720
+ c.skip(_("listen port '{0}' is not a port number between 1 and 65535").format(port_s))
721
+ return False
722
+ c.network["port"] = port
723
+ c.network["bind_ip"] = _bind_ip(c, host, family)
724
+ if c.network["bind_ip"] == "::":
725
+ # ipv6n4: an IPv6 socket that takes IPv4 connections too (gensio.5), so IPv4 and
726
+ # IPv6 here; else IPv6 only, also for UDP where IPv6 sockets take IPv4 by default
727
+ # (MappingConfig.from_dict reads a UDP mapping on :: without the key as an older
728
+ # release ran it)
729
+ c.network["dual_stack"] = family == "ipv6n4"
730
+ c.any_family = not family and not host.strip("[]")
731
+ return True
732
+
733
+
734
+ def _bind_ip(c: _Conn, host: str, family: str) -> str:
735
+ """The bind IP for a listen host. Never wider than ser2net listened: a loopback
736
+ stays loopback (::1 too), and an address that cannot be used here (a host name) gives
737
+ 0.0.0.0 with the mapping imported disabled until its bind IP is set."""
738
+ h = host.strip("[]")
739
+ if not h: # (ipv6n4: IPv6 that takes IPv4 too, see _listen_address)
740
+ return "::" if family in ("ipv6", "ipv6n4") else "0.0.0.0"
741
+ if h.lower() == "localhost":
742
+ return "127.0.0.1"
743
+ if not bind_ip_problem(h):
744
+ return canonical_ip(h)
745
+ c.hold(_("listen address '{0}' cannot be used as a bind IP here: the mapping listens on "
746
+ "0.0.0.0 and is imported disabled; set its bind IP, then enable it").format(host))
747
+ return "0.0.0.0"
748
+
749
+
750
+ # gensio types that end a stack (the rest of the string is their address/device)
751
+ _NET_TERMINALS = ("tcp", "udp", "sctp")
752
+ _OTHER_TERMINALS = ("unix", "unixdgram", "stdio", "pty", "file", "echo", "dummy", "conacc",
753
+ "ipmisol", "serialdev", "sdev", "dev", "sound", "mdns", "net", "telnet",
754
+ "ssl", "certauth", "mux")
755
+
756
+
757
+ def _accepter(c: _Conn, spec: str, ssl_defaults: Optional[dict[str, str]] = None,
758
+ rfc2217: Optional[bool] = None) -> bool:
759
+ """The listening side: telnet / telnet(rfc2217) over tcp|udp,[host,]port. `rfc2217`:
760
+ the file's telnet default for it (None: not set), for a telnet layer that doesn't
761
+ say."""
762
+ layers = _split_gensio(spec)
763
+ for idx, part in enumerate(layers):
764
+ name, opts = _layer(part)
765
+ low = name.lower()
766
+ if low in _NET_TERMINALS:
767
+ if low == "udp":
768
+ c.network["mode"] = "udp"
769
+ elif low == "sctp":
770
+ c.note(_("SCTP is not supported here: the mapping listens on TCP instead"))
771
+ return _listen_address(c, layers[idx + 1:])
772
+ if low == "telnet":
773
+ rfc = rfc2217
774
+ for o in opts:
775
+ key, eq, value = o.partition("=")
776
+ if key.strip().lower() == "rfc2217":
777
+ rfc = bool(_bool(value)) if eq else True
778
+ c.network["protocol"] = "rfc2217" if rfc else "telnet"
779
+ elif low == "ssl":
780
+ c.ssl = _ssl_layer(opts, ssl_defaults or {}) # resolved in _tls()
781
+ elif low == "dtls":
782
+ c.hold(_("{0} (encryption) is not available here for this connection: the mapping "
783
+ "is imported disabled rather than unencrypted").format(name))
784
+ elif low == "certauth":
785
+ c.hold(_("certauth (gtlssh login) is not available here: the mapping is imported "
786
+ "disabled rather than open to clients without a login; allow only known "
787
+ "client IPs on it before you enable it"))
788
+ elif low == "mux":
789
+ pass # gtlssh's channel layer, comes with certauth
790
+ elif low in _OTHER_TERMINALS:
791
+ break
792
+ else:
793
+ c.note(_("accepter layer '{0}' is not imported").format(name))
794
+ c.skip(_("accepter '{0}' is not a TCP/UDP port").format(spec))
795
+ return False
796
+
797
+
798
+ # the files ser2net's ssl layer uses unless told otherwise (<sysconfdir>/ser2net/)
799
+ _SSL_FILES = {"key": "/etc/ser2net/ser2net.key", "cert": "/etc/ser2net/ser2net.crt"}
800
+
801
+
802
+ def _ssl_layer(opts: list[str], defaults: dict[str, str]) -> dict[str, Any]:
803
+ """ssl(key=..,cert=..,CA=..,clientauth) -> {key, cert, ca, clientauth}; what the layer
804
+ leaves out comes from the file's ssl defaults, then from ser2net's own files (no CA
805
+ file of its own)."""
806
+ found = {**_SSL_FILES, "ca": "", **defaults}
807
+ auth = found.pop("clientauth", "").strip()
808
+ clientauth = bool(auth) and _bool(auth) is not False # a default without a value is off
809
+ for o in opts:
810
+ name, eq, value = o.partition("=")
811
+ name = name.strip().lower()
812
+ if name in ("key", "cert", "ca"):
813
+ found[name] = value.strip()
814
+ elif name == "clientauth":
815
+ clientauth = not eq or _bool(value) is not False
816
+ # an empty cert= means the certificate is in the key file
817
+ return {"key": found["key"], "cert": found["cert"] or found["key"], "ca": found["ca"],
818
+ "clientauth": clientauth}
819
+
820
+
821
+ def _tls(c: _Conn) -> None:
822
+ """The accepter's ssl layer: TLS with the same certificate when its files are on this
823
+ host, else the mapping comes in disabled rather than unencrypted. Client certificates
824
+ (clientauth) are checked against the CA file it names (CA=, also an ssl default) when
825
+ that file is on this host, else the mapping comes in disabled rather than open to
826
+ clients without a certificate."""
827
+ s = c.ssl
828
+ if s is None:
829
+ return
830
+ if s["clientauth"]:
831
+ ca = s["ca"]
832
+ if ca and os.path.isabs(ca) and os.path.isfile(ca):
833
+ c.network["tls_client_ca"] = ca
834
+ c.note(_("TLS client certificates (ssl clientauth) are kept: clients need a "
835
+ "certificate from the CA file '{0}'").format(ca))
836
+ else:
837
+ c.hold(_("TLS client certificates (ssl clientauth) are checked here only against "
838
+ "a CA file on this host, and {0}: the mapping is imported disabled rather "
839
+ "than open to clients without a certificate; set its client CA file "
840
+ "before you enable it").format(
841
+ _("the ssl layer names none (CA=)") if not ca else
842
+ _("'{0}' is not a file on this host").format(ca)))
843
+ if c.network["mode"] == "udp":
844
+ c.hold(_("{0} (encryption) is not available here for this connection: the mapping "
845
+ "is imported disabled rather than unencrypted").format("ssl"))
846
+ return
847
+ cert, key = s["cert"], s["key"]
848
+ if all(os.path.isabs(p) and os.path.isfile(p) for p in (cert, key)):
849
+ c.network.update(tls=True, tls_cert=cert, tls_key=key)
850
+ c.note(_("TLS (ssl) is kept: the mapping uses the certificate '{0}' and the key '{1}'")
851
+ .format(cert, key))
852
+ else:
853
+ c.hold(_("TLS (ssl): the certificate '{0}' or the key '{1}' is not on this host: the "
854
+ "mapping is imported disabled rather than unencrypted; set TLS on it with a "
855
+ "certificate and key, then enable it").format(cert, key))
856
+
857
+
858
+ _SPEED = re.compile(r"(?:speed=)?(\d+)([NEOMS])?([5-8])?([12])?", re.I)
859
+ _PARITY_WORDS = {"NONE": "N", "EVEN": "E", "ODD": "O", "MARK": "M", "SPACE": "S"}
860
+
861
+
862
+ def _serial_word(c: _Conn, tok: str) -> bool:
863
+ """The serial framing written as words (gensio still takes them, ser2net 3.x wrote
864
+ them so): 1STOPBIT / 2STOPBITS / 5DATABITS..8DATABITS in any case, the parity words
865
+ NONE EVEN ODD MARK SPACE in capitals (gensio compares those exactly). False if `tok`
866
+ is none of them."""
867
+ word = tok.upper()
868
+ if word in ("1STOPBIT", "2STOPBITS"):
869
+ c.serial["stopbits"] = int(word[0])
870
+ elif re.fullmatch(r"[5-8]DATABITS", word):
871
+ c.serial["bytesize"] = int(word[0])
872
+ elif tok in _PARITY_WORDS:
873
+ c.serial["parity"] = _PARITY_WORDS[tok]
874
+ else:
875
+ return False
876
+ return True
877
+
878
+
879
+ def _windows_device(device: str) -> str:
880
+ m = re.fullmatch(r"(?:\\\\\.\\|//\./)(COM\d+)", device, re.I)
881
+ return m.group(1).upper() if m else device
882
+
883
+
884
+ def _connector(c: _Conn, spec: str, defaults: list[str]) -> bool:
885
+ """The device side: serialdev,<device>[,<serial options>]."""
886
+ layers = _split_gensio(spec)
887
+ for idx, part in enumerate(layers):
888
+ name, opts = _layer(part)
889
+ low = name.lower()
890
+ if low in ("serialdev", "sdev", "dev"):
891
+ tokens = [t for f in layers[idx + 1:] for t in f.split()]
892
+ if not tokens:
893
+ c.skip(_("the connector names no device"))
894
+ return False
895
+ device = _windows_device(tokens[0])
896
+ if url_port(device): # a pyserial URL, not a device: never opened here
897
+ break
898
+ c.device = device
899
+ if low == "dev":
900
+ c.note(_("'dev' (a device without serial settings) is imported as a serial "
901
+ "port; it may not open if it is not a tty"))
902
+ for tok in defaults + opts + tokens[1:]:
903
+ _serial_option(c, tok)
904
+ return True
905
+ if low in _NET_TERMINALS or low in _OTHER_TERMINALS:
906
+ break
907
+ c.note(_("connector layer '{0}' is not imported").format(name))
908
+ c.skip(_("connector '{0}' is not a local serial port").format(spec))
909
+ return False
910
+
911
+
912
+ def _serial_option(c: _Conn, tok: str) -> None:
913
+ """One gensio serialdev option: 9600n81, rtscts, xonxoff=false, -local, dtr=off, ..."""
914
+ m = _SPEED.fullmatch(tok)
915
+ if m is not None:
916
+ c.serial["baudrate"] = int(m.group(1))
917
+ if m.group(2):
918
+ c.serial["parity"] = m.group(2).upper()
919
+ if m.group(3):
920
+ c.serial["bytesize"] = int(m.group(3))
921
+ if m.group(4):
922
+ c.serial["stopbits"] = int(m.group(4))
923
+ return
924
+ if _serial_word(c, tok):
925
+ return
926
+ if tok.upper() in _PARITY_WORDS:
927
+ c.note(_("serial option '{0}': ser2net 4.x takes the parity words in capitals only "
928
+ "({1}), so it did not accept this one; not imported").format(tok, tok.upper()))
929
+ return
930
+ name, eq, value = tok.partition("=")
931
+ low = name.lower()
932
+ flag: Optional[bool] = True
933
+ if low.startswith("-"):
934
+ low, flag = low[1:], False
935
+ if low == "wronly":
936
+ if flag and (not eq or _bool(value) is not False):
937
+ _write_only(c, tok)
938
+ return
939
+ if low not in _SERIAL_FLAGS + ("dtr", "rts", "rs485"):
940
+ if low not in ("custspeed", "readbuf"): # nothing to do here
941
+ c.drop(tok)
942
+ return
943
+ if eq and flag and low in _SERIAL_FLAGS:
944
+ flag = _bool(value)
945
+ if flag is None:
946
+ c.note(_("serial option '{0}' has no true/false value, ignored").format(tok))
947
+ return
948
+ if low in ("xonxoff", "rtscts", "flock", "uucplock"):
949
+ setattr(c, low, bool(flag))
950
+ elif low in ("hangup-when-done", "hangup_when_done", "hupcl"):
951
+ c.advanced["hangup_when_done"] = bool(flag)
952
+ elif low == "nobreak":
953
+ c.advanced["nobreak"] = bool(flag)
954
+ elif low in ("dtr", "rts"):
955
+ on = _bool(value or "on") if flag else False # gensio reads it as a boolean
956
+ if on is None:
957
+ c.note(_("serial option '{0}' is not on/off, ignored").format(tok))
958
+ else:
959
+ c.serial[f"{low}_on_open"] = "on" if on else "off"
960
+ elif low == "rs485":
961
+ if value.strip().lower() not in ("", "off"):
962
+ c.note(_("{0} is not imported: set RS-485 in the mapping's serial settings")
963
+ .format(tok))
964
+ elif low == "rdonly":
965
+ if flag:
966
+ c.network["read_only"] = True
967
+ elif low == "local" and not flag:
968
+ c.note(_("local=false (watching the modem control lines) is not imported"))
969
+
970
+
971
+ _SERIAL_FLAGS = ("xonxoff", "rtscts", "hangup-when-done", "hangup_when_done", "hupcl",
972
+ "nobreak", "rdonly", "local", "flock", "uucplock")
973
+
974
+
975
+ def _write_only(c: _Conn, what: str) -> None:
976
+ """ser2net only wrote to the device (wronly, 3.x rawlp): its output never reached the
977
+ clients. A mapping here always passes it on, so it comes in disabled."""
978
+ c.hold(_("{0} (a write-only device) is not available here: the device's output would be "
979
+ "sent to clients, so the mapping is imported disabled").format(what))
980
+
981
+
982
+ # ---------------------------------------------------------------------------
983
+ # ser2net string escapes
984
+ # ---------------------------------------------------------------------------
985
+ _C_ESCAPES = {"a": "\a", "b": "\b", "f": "\f", "t": "\t", "v": "\v", "\\": "\\",
986
+ "?": "?", "'": "'", '"': '"'}
987
+ _TIME_ESCAPES = "YyMmADeUHhiSqPT"
988
+ _ESCAPE = re.compile(r"\\(x[0-9a-fA-F]{1,2}|[0-7]{1,3}|.)", re.S)
989
+ _GONE = "" # marks a dropped escape until it is removed
990
+
991
+
992
+ def _convert(c: _Conn, text: str, kind: str, what: str,
993
+ high: Optional[list[str]] = None) -> str:
994
+ """ser2net escapes -> this program. kind 'banner' keeps the escapes the banner
995
+ renderer knows (\\d \\p \\N \\I \\r \\n); 'bytes' (openstr/closestr/closeon, sent
996
+ as they are) and 'file' (a trace file name) get plain text. `high` gets the escapes
997
+ that stand for a byte of 0x80 or more: ser2net sent that byte, a string here is sent
998
+ as UTF-8 (two bytes for it)."""
999
+ dropped: list[str] = []
1000
+ fixed: list[str] = []
1001
+
1002
+ def byte(n: int, e: str) -> str:
1003
+ if n >= 0x80 and high is not None:
1004
+ high.append("\\" + e)
1005
+ return chr(n)
1006
+
1007
+ def escape(m: re.Match[str]) -> str:
1008
+ e = m.group(1)
1009
+ ch = e[0]
1010
+ if ch == "x" and len(e) > 1:
1011
+ return byte(int(e[1:], 16), e)
1012
+ if ch in "01234567":
1013
+ return byte(int(e, 8) & 0xFF, e)
1014
+ if ch == "\\" and kind == "banner":
1015
+ return "\\\\" # a backslash, written so the banner renderer keeps it
1016
+ if ch in _C_ESCAPES:
1017
+ return _C_ESCAPES[ch]
1018
+ if ch in "rn":
1019
+ return "\\" + ch if kind == "banner" else ("\r" if ch == "r" else "\n")
1020
+ if ch == "o" and not c.v3: # ser2net 4.3 and later: the connection's name
1021
+ return "\\N" if kind == "banner" else c.name
1022
+ if ch == "o" and c.devname != c.device: # 3.x: the DEVICE alias as the port names it
1023
+ name = c.devname.rsplit("/", 1)[-1] if kind == "file" else c.devname
1024
+ return name.replace("\\", "\\\\") if kind == "banner" else name
1025
+ if ch in "do":
1026
+ if kind == "banner":
1027
+ return "\\d"
1028
+ return c.device.rsplit("/", 1)[-1] if kind == "file" else c.device
1029
+ if ch == "p":
1030
+ return "\\p" if kind == "banner" else str(c.network.get("port", ""))
1031
+ if ch == "N" and not c.v3:
1032
+ return "\\N" if kind == "banner" else c.name
1033
+ if ch == "I" and kind == "banner":
1034
+ return "\\I"
1035
+ if ch == "B" or (ch == "s" and kind != "file"):
1036
+ fixed.append("\\" + ch)
1037
+ return c.serial_text()
1038
+ if ch in _TIME_ESCAPES or ch in "Is":
1039
+ dropped.append("\\" + ch)
1040
+ return _GONE
1041
+ return ch # ser2net prints an unknown escape's character
1042
+
1043
+ grown = 0 # what the escapes add (\d is the whole device path, ...)
1044
+
1045
+ def one(m: re.Match[str]) -> str:
1046
+ nonlocal grown
1047
+ rep = escape(m)
1048
+ grown += len(rep) - len(m.group(0))
1049
+ if len(text) + grown > _MAX_VALUE:
1050
+ raise _too_long(f"{c.label}: {what}")
1051
+ return rep
1052
+ if kind == "banner": # real line breaks (from "..." YAML) become the banner's escapes
1053
+ text = text.replace("\r", "\\r").replace("\n", "\\n")
1054
+ c.budget.spend(len(text))
1055
+ out = _ESCAPE.sub(one, text)
1056
+ if len(out) > _MAX_VALUE:
1057
+ raise _too_long(f"{c.label}: {what}")
1058
+ c.budget.spend(len(out))
1059
+ # a dropped part of a file name takes its separator along: tr-\p-\Y-\M -> tr-2000
1060
+ out = re.sub(f"[-_.: ]?{_GONE}" if kind == "file" else _GONE, "", out)
1061
+ if fixed:
1062
+ c.note(_("{0}: {1} (serial parameters) is written as the fixed text {2}")
1063
+ .format(what, "/".join(dict.fromkeys(fixed)), c.serial_text()))
1064
+ if "\\s" in fixed and not c.v3:
1065
+ c.note(_("{0}: \\s stood for the serial parameters in ser2net 3.x and 4.0; ser2net "
1066
+ "4.1 and later print a plain 's' instead").format(what))
1067
+ if dropped:
1068
+ parts = " ".join(dict.fromkeys(dropped))
1069
+ c.note(_("{0}: {1} in the file name left out (one trace file per mapping here, rotated "
1070
+ "by size)").format(what, parts) if kind == "file" else
1071
+ _("{0}: {1} not supported here, left out").format(what, parts))
1072
+ return out
1073
+
1074
+
1075
+ # ---------------------------------------------------------------------------
1076
+ # Connection options (4.x `options:` keys; 3.x keywords map onto the same names)
1077
+ # ---------------------------------------------------------------------------
1078
+ _TRACE_MOD = re.compile(r"trace-(?:(both|read|write)-)?(hexdump|timestamp)")
1079
+
1080
+
1081
+ def _flag(c: _Conn, key: str, value: str) -> Optional[bool]:
1082
+ b = _bool(value)
1083
+ if b is None:
1084
+ c.note(_("{0}: '{1}' is not true/false, ignored").format(key, value))
1085
+ return b
1086
+
1087
+
1088
+ def _option(c: _Conn, key: str, value: str) -> None:
1089
+ k = key.strip().lower()
1090
+ if k in ("timeout", "connection timeout"):
1091
+ _idle_timeout(c, value)
1092
+ elif k == "kickolduser":
1093
+ b = _flag(c, key, value)
1094
+ if b is not None:
1095
+ c.network["kick_old_user"] = b
1096
+ elif k == "max-connections":
1097
+ n = value.strip()
1098
+ if n.isdigit() and int(n) >= 1:
1099
+ c.network["max_connections"] = int(n)
1100
+ else:
1101
+ c.note(_("max-connections '{0}' is not a number >= 1, ignored").format(value))
1102
+ elif k in ("banner", "openstr", "closestr", "closeon", "signature"):
1103
+ c.strings[k] = value
1104
+ elif k in ("trace-both", "trace-read", "trace-write"):
1105
+ if value.strip():
1106
+ c.traces[k[6:]] = value.strip()
1107
+ elif (m := _TRACE_MOD.fullmatch(k)) is not None:
1108
+ b = _flag(c, key, value)
1109
+ if b is not None:
1110
+ _trace_mod(c, m.group(1) or "all", m.group(2), b)
1111
+ elif k == "remaddr":
1112
+ _remaddr(c, value)
1113
+ elif k == "no-acc-to-con": # clients' data is not passed to the device = read-only
1114
+ if _flag(c, key, value):
1115
+ c.network["read_only"] = True
1116
+ elif k == "no-con-to-acc": # the device's data is not passed to the clients
1117
+ if _bool(value) is not False:
1118
+ c.hold(_("no-con-to-acc (the device's output is not sent to clients) is not "
1119
+ "available here: the mapping is imported disabled rather than sending it "
1120
+ "to them"))
1121
+ elif k == "telnet-brk-on-sync": # only matters on telnet ports
1122
+ if _bool(value) and c.network["protocol"] != "raw":
1123
+ c.drop(key)
1124
+ elif k == "chardelay":
1125
+ pass # batching of serial reads; data is forwarded as it arrives here
1126
+ else:
1127
+ c.drop(key)
1128
+
1129
+
1130
+ def _trace_mod(c: _Conn, direction: str, mod: str, on: bool) -> None:
1131
+ """trace-hexdump / trace-timestamp (direction 'all') or one trace file's own, read in
1132
+ the order of the file as ser2net reads them: the general one sets every trace file
1133
+ (over a file's own one before it), a file's own one only that file."""
1134
+ for d in ("both", "read", "write") if direction == "all" else (direction,):
1135
+ c.trace_mods[(d, mod)] = on
1136
+
1137
+
1138
+ def _idle_timeout(c: _Conn, value: str) -> None:
1139
+ v = value.strip()
1140
+ if v.isdigit():
1141
+ c.options["idle_timeout_s"] = min(int(v), MAX_TIMER_S)
1142
+ if int(v) > MAX_TIMER_S:
1143
+ c.note(_("timeout {0} s is longer than the maximum here: set to {1} s")
1144
+ .format(v, MAX_TIMER_S))
1145
+ elif v:
1146
+ c.note(_("timeout '{0}' is not a number of seconds, ignored").format(value))
1147
+
1148
+
1149
+ def _remaddr(c: _Conn, value: str) -> None:
1150
+ """remaddr=<addr>[;<addr>...] (allowed clients) -> allowed_client_ips. Only IP
1151
+ addresses and subnets can be represented; source ports are not checked here. An
1152
+ address that can't be imported still limits the clients in ser2net (access_given):
1153
+ without any other the mapping comes in disabled. No address = no limit, as there."""
1154
+ allowed = c.network.setdefault("allowed_client_ips", [])
1155
+ for entry in re.split(r"[;\s]+", value.strip()):
1156
+ if not entry:
1157
+ continue
1158
+ c.access_given = True
1159
+ if entry.startswith("!"):
1160
+ c.note(_("remaddr {0}: connect-back addresses are not imported").format(entry))
1161
+ continue
1162
+ fields = [f.strip() for f in entry.split(",") if f.strip()]
1163
+ while fields and fields[0].lower() in ("ipv4", "ipv6", "ipv6n4", "tcp", "udp", "sctp"):
1164
+ fields.pop(0)
1165
+ host = fields[0] if fields else ""
1166
+ try:
1167
+ ipaddress.ip_network(host, strict=False)
1168
+ except ValueError:
1169
+ c.note(_("remaddr {0}: only IP addresses and subnets can be imported").format(entry))
1170
+ continue
1171
+ if len(fields) > 1 and fields[1] != "0":
1172
+ c.note(_("remaddr {0}: only the client address is checked here, not its port")
1173
+ .format(entry), security=True)
1174
+ if host not in allowed:
1175
+ allowed.append(host)
1176
+
1177
+
1178
+ def _default_remaddr(c: _Conn, value: str) -> None:
1179
+ """The default remaddr, for a connection that gives no client address itself."""
1180
+ if value.strip() and not c.access_given:
1181
+ c.budget.spend(len(value))
1182
+ _remaddr(c, value)
1183
+ if c.access_given:
1184
+ c.note(_("remaddr {0} comes from the defaults (the connection sets none)")
1185
+ .format(value.strip()))
1186
+
1187
+
1188
+ def _finish(c: _Conn) -> None:
1189
+ """Resolve what depends on the whole connection; collect the warnings."""
1190
+ net = c.network
1191
+ if c.rtscts and c.xonxoff:
1192
+ c.note(_("both RTS/CTS and XON/XOFF are on: only RTS/CTS is imported (one flow control "
1193
+ "per port here)"))
1194
+ c.serial["flowcontrol"] = "rtscts" if c.rtscts else "xonxoff" if c.xonxoff else "none"
1195
+ # keeping other programs off the port, as ser2net did: 4.x (gensio) flock = flock plus
1196
+ # TIOCEXCL, uucplock = UUCP lock files, both on unless turned off; 3.x used UUCP lock
1197
+ # files only (unless started with -u, which the file doesn't say)
1198
+ if c.v3:
1199
+ c.advanced["respect_uucp_locks"] = True
1200
+ else:
1201
+ c.serial["exclusive"] = c.flock
1202
+ c.advanced["tiocexcl"] = c.flock
1203
+ c.advanced["respect_uucp_locks"] = c.uucplock
1204
+ if net["mode"] == "udp" and net["protocol"] != "raw":
1205
+ c.note(_("{0} over UDP is not supported here: imported as raw UDP").format(net['protocol']))
1206
+ net["protocol"] = "raw"
1207
+ if net["protocol"] == "rfc2217" and net.get("max_connections", 1) > 1:
1208
+ c.note(_("RFC2217 allows a single client here: max-connections set to 1"))
1209
+ net["max_connections"] = 1
1210
+ for key in ("banner", "openstr", "closestr", "closeon"):
1211
+ if key in c.strings:
1212
+ high: list[str] = []
1213
+ c.options[key] = _convert(c, c.strings[key], "banner" if key == "banner" else "bytes",
1214
+ key, high)
1215
+ if high:
1216
+ _high_bytes(c, key, " ".join(dict.fromkeys(high)))
1217
+ if "signature" in c.strings and net["protocol"] == "rfc2217": # (only RFC 2217 sends it)
1218
+ sig = _convert(c, c.strings["signature"], "bytes", "signature")
1219
+ if len(sig) > RFC2217_SIGNATURE_MAX or re.search(r"[\x00-\x1f\x7f]", sig):
1220
+ c.note(_("the RFC 2217 signature is not imported: here it is text of at most {0} "
1221
+ "characters, without control characters").format(RFC2217_SIGNATURE_MAX))
1222
+ else:
1223
+ c.options["rfc2217_signature"] = sig
1224
+ for flag, name in (("hangup_when_done", "hangup-when-done"), ("nobreak", "nobreak")):
1225
+ if c.advanced.get(flag):
1226
+ # kept (the form keeps it too) but not acted on: the port stays open between
1227
+ # connections, so it is never closed (DTR dropped) or opened (BREAK cleared)
1228
+ # for one
1229
+ c.note(_("{0} is kept in the mapping but has no effect here: the serial port "
1230
+ "stays open between connections").format(name.upper().replace("-", "_")
1231
+ if c.v3 else name))
1232
+ if c.options.get("openstr"):
1233
+ c.note(_("openstr is sent once, when the serial port opens (at the mapping's start, "
1234
+ "and when it is opened again after the device was lost), not each time a "
1235
+ "client connects"))
1236
+ if (start := _privileged_below(net)) is not None:
1237
+ c.note(_("port {0} is below {1}: as ser2netweb runs now, it can't listen there (Linux "
1238
+ "needs the CAP_NET_BIND_SERVICE capability: README, Ports below 1024); give "
1239
+ "it the capability or change the port before enabling the mapping")
1240
+ .format(net.get("port"), start))
1241
+ labels = ({"both": "tb", "read": "tr", "write": "tw"} if c.v3 else
1242
+ {k: f"trace-{k}" for k in ("both", "read", "write")})
1243
+ kind = next((k for k in ("both", "read", "write") if k in c.traces), "")
1244
+ if kind == "read" and "write" not in c.traces:
1245
+ # only the device's output was traced; a trace here records the clients' input too
1246
+ c.note(_("{0} is not imported: it traced only the device's output, and a trace here "
1247
+ "also records what clients send (passwords too); set a trace file on the "
1248
+ "mapping if you want that").format(labels["read"]))
1249
+ kind = ""
1250
+ if kind:
1251
+ path = _convert(c, c.traces[kind], "file", labels[kind])
1252
+ name = trace_file_name(path)
1253
+ if name:
1254
+ c.options["trace_both"] = path
1255
+ if kind != "both":
1256
+ c.note(_("{0}: both directions are traced into one file here").format(labels[kind]))
1257
+ if name != path:
1258
+ c.note(_("trace file: written as traces/<mapping id>-{0} in the data directory "
1259
+ "({1} is not used)").format(name, path))
1260
+ for other in c.traces:
1261
+ if other != kind:
1262
+ c.note(_("{0} is not imported: one trace file per mapping").format(labels[other]))
1263
+ for mod in ("hexdump", "timestamp"):
1264
+ b = c.trace_mods.get((kind, mod))
1265
+ if b is not None:
1266
+ c.options[f"trace_{mod}"] = b
1267
+ _tls(c)
1268
+ if c.access_given and not net.get("allowed_client_ips"):
1269
+ c.hold(_("remaddr could not be imported: the mapping is imported disabled rather "
1270
+ "than open to every client"))
1271
+ if c.holds:
1272
+ c.enabled = False
1273
+ for why in c.holds:
1274
+ c.note(why, security=True)
1275
+ if c.dropped:
1276
+ c.note(_("not imported (no equivalent here): {0}").format(", ".join(c.dropped)))
1277
+
1278
+
1279
+ def _privileged_below(net: dict, platform: Optional[str] = None) -> Optional[int]:
1280
+ """The first port anyone may listen on (bindhint.unprivileged_port_start), when the
1281
+ listening mapping `net` wants a port below it that this process may not use (Linux,
1282
+ not root, without CAP_NET_BIND_SERVICE: the systemd unit's account); else None."""
1283
+ platform = sys.platform if platform is None else platform
1284
+ port = net.get("port")
1285
+ if (not platform.startswith("linux") or net.get("mode") not in ("server", "udp")
1286
+ or not isinstance(port, int)):
1287
+ return None
1288
+ start = bindhint.unprivileged_port_start()
1289
+ if not 0 < port < start or bindhint.may_bind_low_ports():
1290
+ return None
1291
+ return start
1292
+
1293
+
1294
+ def _high_bytes(c: _Conn, key: str, escapes: str) -> None:
1295
+ """An escape for a byte of 0x80 or more: a string here is sent as UTF-8, so that byte
1296
+ goes out as two. The device would get other bytes from an open or close string: the
1297
+ mapping comes in disabled; a close-on string then never matches (or a banner shows
1298
+ otherwise): a note."""
1299
+ if key in ("openstr", "closestr"):
1300
+ c.hold(_("{0}: {1} is a byte of 0x80 or more, which would be sent here as two bytes "
1301
+ "(strings are sent as UTF-8): the mapping is imported disabled rather than "
1302
+ "sending the device other bytes; change the string, then enable it")
1303
+ .format(key, escapes))
1304
+ elif key == "closeon":
1305
+ c.note(_("closeon: {0} is a byte of 0x80 or more, which is matched here as two bytes "
1306
+ "(strings are sent as UTF-8), so this close-on string won't match what the "
1307
+ "device sends; change it on the mapping").format(escapes))
1308
+ else:
1309
+ c.note(_("banner: {0} is a byte of 0x80 or more, which is sent here as two bytes "
1310
+ "(strings are sent as UTF-8)").format(escapes))
1311
+
1312
+
1313
+ class Note(str):
1314
+ """A warning of the import, as parse_ser2net_config returns them (a str). `mapping`: the
1315
+ index of the mapping it is about in the list returned with it (None: about the file,
1316
+ or about a connection that is not imported); `detail`: the text without the name of
1317
+ the connection in front; `security`: it says why the mapping comes in disabled (it
1318
+ would be more open here than in ser2net) or which limit on its clients is lost."""
1319
+ mapping: Optional[int] = None
1320
+ detail: str = ""
1321
+ security: bool = False
1322
+ conn: Optional[_Conn] = None # until the mappings are known (_point_notes)
1323
+
1324
+
1325
+ def _note(c: _Conn, detail: str, security: bool = False) -> Note:
1326
+ n = Note(f"{c.label}: {detail}")
1327
+ n.detail, n.security, n.conn = detail, security, c
1328
+ return n
1329
+
1330
+
1331
+ def _point_notes(warnings: list[str], kept: list[_Conn]) -> None:
1332
+ """Point each connection's note at its mapping (Note.mapping), by position in `kept`."""
1333
+ index = {id(c): i for i, c in enumerate(kept)}
1334
+ for w in warnings:
1335
+ if isinstance(w, Note) and w.conn is not None:
1336
+ w.mapping, w.conn = index.get(id(w.conn)), None
1337
+
1338
+
1339
+ def _notes(c: _Conn, imported: bool) -> list[str]:
1340
+ """A connection's warnings; for a skipped one only why it was skipped."""
1341
+ if not imported:
1342
+ return [f"{c.label}: {n}" for n in c.skipped]
1343
+ return [_note(c, n, n in c.flagged) for n in c.notes]
1344
+
1345
+
1346
+ # ---------------------------------------------------------------------------
1347
+ # ser2net 4.x (ser2net.yaml)
1348
+ # ---------------------------------------------------------------------------
1349
+ _V1_KEYS = {"connection", "define", "default", "delete_default", "rotator", "led", "admin"}
1350
+ _V2_KEYS = {"connections", "defines", "defaults", "delete_defaults", "rotators", "leds",
1351
+ "admins"}
1352
+ _TOP_KEY = re.compile(r"^(?:connections?|defines?|defaults?|delete_defaults?|rotators?|leds?|"
1353
+ r"admins?|include)[ \t]*:(?:[ \t]|$)", re.I | re.M)
1354
+ _SERIAL_DEFAULTS = {"speed", "nobreak", "rtscts", "xonxoff", "local", "hangup-when-done",
1355
+ "dtr", "rts", "custspeed", "rs485", "uucplock", "flock", "wronly"}
1356
+ _OPTION_DEFAULTS = {"kickolduser", "max-connections", "remaddr", "telnet-brk-on-sync",
1357
+ "chardelay", "trace-hexdump", "trace-timestamp", "trace-both-hexdump",
1358
+ "trace-both-timestamp", "signature", "banner", "openstr", "closestr",
1359
+ "closeon"}
1360
+ # (sent as they are: their spaces and line breaks too; the RFC 2217 signature is stripped)
1361
+ _STRING_OPTIONS = ("banner", "openstr", "closestr", "closeon")
1362
+ _SSL_DEFAULTS = {"key", "cert", "ca", "clientauth"}
1363
+ # the gensio class that reads each default used here (it reads the base value, set
1364
+ # without a class, too)
1365
+ _DEFAULT_CLASS = {**dict.fromkeys(_SERIAL_DEFAULTS, "serialdev"),
1366
+ **dict.fromkeys(_OPTION_DEFAULTS, "ser2net"),
1367
+ **dict.fromkeys(_SSL_DEFAULTS, "ssl"), "rfc2217": "telnet"}
1368
+
1369
+
1370
+ def _gensio_class(cls: str) -> str:
1371
+ c = cls.strip().lower()
1372
+ return "serialdev" if c == "sdev" else c
1373
+
1374
+
1375
+ _REFERENCE = {act: re.compile("[" + re.escape(act) + "]") for act in ("*", "=", "*=")}
1376
+
1377
+
1378
+ def _substitute(s: str, aliases: dict[str, str], act: str, note: Callable[[str], None],
1379
+ what: str) -> str:
1380
+ """ser2net's references inside strings: *(name) in version 1 files, =(name) in
1381
+ version 2 ('**' / '*(*' for the literal characters); *{file} / ={file} (a file on
1382
+ the ser2net host) cannot be read here. Nothing else is replaced ($(name) neither, as
1383
+ in ser2net). One pass over s (each bracket is searched for once); a value that grows
1384
+ past _MAX_VALUE refuses the file."""
1385
+ out: list[str] = []
1386
+ grown, i = 0, 0
1387
+ found = {")": -1, "}": -1} # where that bracket was found last (len(s): none is left)
1388
+ special = _REFERENCE[act]
1389
+ while i < len(s):
1390
+ m = special.search(s, i)
1391
+ if m is None:
1392
+ out.append(s[i:])
1393
+ break
1394
+ if m.start() > i:
1395
+ out.append(s[i:m.start()])
1396
+ i = m.start()
1397
+ ch, nxt = s[i], s[i + 1:i + 2]
1398
+ if nxt == ch:
1399
+ out.append(ch)
1400
+ i += 2
1401
+ continue
1402
+ if nxt in ("(", "{"):
1403
+ if s[i + 2:i + 3] == ch:
1404
+ out.append(ch + nxt)
1405
+ i += 3
1406
+ continue
1407
+ closing = ")" if nxt == "(" else "}"
1408
+ if found[closing] < i + 2:
1409
+ at = s.find(closing, i + 2)
1410
+ found[closing] = len(s) if at < 0 else at
1411
+ close = found[closing]
1412
+ if close < len(s):
1413
+ ref = s[i + 2:close]
1414
+ if nxt == "{":
1415
+ note(_("{0}{{{1}}} (a file on the ser2net host) is left out")
1416
+ .format(ch, ref))
1417
+ i = close + 1
1418
+ continue
1419
+ if ref in aliases:
1420
+ grown += len(aliases[ref])
1421
+ if grown > _MAX_VALUE:
1422
+ raise _too_long(what)
1423
+ out.append(aliases[ref])
1424
+ i = close + 1
1425
+ continue
1426
+ # the name runs to the first ')', as ser2net reads it
1427
+ note(_("unknown reference {0}({1}) left as it is").format(ch, ref))
1428
+ out.append(s[i:close + 1])
1429
+ i = close + 1
1430
+ continue
1431
+ out.append(ch)
1432
+ i += 1
1433
+ return "".join(out)
1434
+
1435
+
1436
+ class _Yaml:
1437
+ def __init__(self, act: str, budget: _Budget) -> None:
1438
+ self.act = act # reference character(s): * (v1), = (v2)
1439
+ self.budget = budget
1440
+ self.aliases: dict[str, str] = {}
1441
+ self.warnings: list[str] = []
1442
+ self.conns: list[_Conn] = []
1443
+ self.connections = 0 # connection entries read (MAX_CONNECTIONS)
1444
+ self.defaults: dict[tuple[str, str], str] = {} # (name, class) -> value, so far
1445
+ # what each gensio class gets from them for the connections from here on
1446
+ self.serial_defaults: dict[str, str] = {}
1447
+ self.option_defaults: dict[str, str] = {}
1448
+ self.ssl_defaults: dict[str, str] = {} # key / cert / ca / clientauth of the ssl layer
1449
+ self.telnet_defaults: dict[str, str] = {} # rfc2217 of the telnet layer
1450
+ self._views = {"serialdev": self.serial_defaults, "ser2net": self.option_defaults,
1451
+ "ssl": self.ssl_defaults, "telnet": self.telnet_defaults}
1452
+
1453
+ def text(self, node: Any, note: Callable[[str], None], what: str) -> str:
1454
+ if isinstance(node, (_YMap, list)):
1455
+ note(_("{0} is not a single value, ignored").format(what))
1456
+ return ""
1457
+ s = str(node)
1458
+ self.budget.spend(len(s))
1459
+ out = _substitute(s, self.aliases, self.act, note, what)
1460
+ if len(out) > _MAX_VALUE:
1461
+ raise _too_long(what)
1462
+ self.budget.spend(len(out))
1463
+ return out
1464
+
1465
+ def gtext(self, node: Any, what: str) -> str:
1466
+ """text() outside a connection: notes become warnings about `what`."""
1467
+ def note(msg: str) -> None:
1468
+ self.budget.spend(len(what) + len(msg))
1469
+ self.warnings.append(f"{what}: {msg}")
1470
+ return self.text(node, note, what)
1471
+
1472
+ def entry(self, key: str, value: Any, anchor: str) -> None:
1473
+ k = key.lower()
1474
+ if k == "define":
1475
+ if anchor: # ser2net ignores a define without an &anchor
1476
+ self.aliases[anchor] = self.gtext(value, f"define {anchor}")
1477
+ elif k == "defines" and isinstance(value, _YMap):
1478
+ for name, v, _a in value.entries:
1479
+ self.aliases[name] = self.gtext(v, f"define {name}")
1480
+ elif k == "connection":
1481
+ self.connection(anchor, value)
1482
+ elif k == "connections" and isinstance(value, _YMap):
1483
+ for name, body, _a in value.entries:
1484
+ self.connection(name, body)
1485
+ elif k == "default" and isinstance(value, _YMap):
1486
+ self.default(*(self.gtext(value.get(f, ""), "default")
1487
+ for f in ("name", "value", "class")))
1488
+ elif k == "defaults" and isinstance(value, _YMap):
1489
+ for name, body, _a in value.entries:
1490
+ if isinstance(body, _YMap):
1491
+ self.default(name, self.gtext(body.get("value", ""), f"default {name}"),
1492
+ self.gtext(body.get("class", ""), f"default {name}"))
1493
+ elif k == "delete_default" and isinstance(value, _YMap):
1494
+ self.delete_default(*(self.gtext(value.get(f, ""), "delete_default")
1495
+ for f in ("name", "class")))
1496
+ elif k == "delete_defaults" and isinstance(value, _YMap):
1497
+ for name, classes, _a in value.entries: # <name>: [<class>, ...]
1498
+ for cls in classes if isinstance(classes, list) else [classes]:
1499
+ self.delete_default(name, self.gtext(cls, f"delete_defaults {name}"))
1500
+ elif k in ("rotator", "rotators"):
1501
+ names = ([anchor] if k == "rotator" else
1502
+ [n for n, _v, _a in value.entries] if isinstance(value, _YMap) else [])
1503
+ for name in names or ["(unnamed)"]:
1504
+ self.warnings.append(_("rotator {0}: not imported (rotators are not supported "
1505
+ "here)").format(name))
1506
+ elif k in ("admin", "admins"):
1507
+ self.warnings.append(_("the ser2net admin interface is not imported (the web UI "
1508
+ "and REST API take its place)"))
1509
+ elif k == "include":
1510
+ self.warnings.append(_("include {0}: included files are not read; import each of "
1511
+ "them as well").format(self.gtext(value, 'include')))
1512
+ elif k not in ("led", "leds", "defines", "connections", "defaults", "delete_default",
1513
+ "delete_defaults", "default"):
1514
+ self.warnings.append(_("unknown top-level entry '{0}' ignored").format(key))
1515
+
1516
+ def default(self, name: str, value: str, cls: str) -> None:
1517
+ """A default, for the connections after it (ser2net sets each connection up as it
1518
+ reads it). gensio keeps a value per class besides the base value (set without a
1519
+ class), and a class's own value wins over the base value whatever their order."""
1520
+ n, cls = name.strip().lower(), _gensio_class(cls)
1521
+ if not n:
1522
+ return
1523
+ reader = _DEFAULT_CLASS.get(n)
1524
+ if reader is None or cls not in ("", reader):
1525
+ if cls in ("", "serialdev", "ser2net"): # (another gensio's: does not apply here)
1526
+ self.warnings.append(_("default {0}: {1} is not imported (no equivalent here)")
1527
+ .format(name, _shown(value)))
1528
+ self.defaults[(n, cls)] = value.strip() # (for a delete_default of it)
1529
+ return
1530
+ if reader == "telnet" and _bool(value) is None:
1531
+ self.warnings.append(_("{0}: '{1}' is not true/false, ignored")
1532
+ .format(f"default {name}", _shown(value)))
1533
+ return
1534
+ # (a banner or open string as it is: its spaces and line breaks are sent)
1535
+ self.defaults[(n, cls)] = value if n in _STRING_OPTIONS else value.strip()
1536
+ self._resolve(n)
1537
+
1538
+ def _resolve(self, n: str) -> None:
1539
+ """What the connections from here on get for default `n`: the value of the class
1540
+ that reads it, else the base value."""
1541
+ cls = _DEFAULT_CLASS[n]
1542
+ value = self.defaults.get((n, cls), self.defaults.get((n, "")))
1543
+ if value is None:
1544
+ self._views[cls].pop(n, None)
1545
+ else:
1546
+ self._views[cls][n] = value
1547
+
1548
+ def delete_default(self, name: str, cls: str) -> None:
1549
+ """The value set for a class goes, so that class gets the base value again. ser2net
1550
+ refuses a file whose delete_default gives no class (a base value can't be
1551
+ deleted) or names a value that is not set: reported here, nothing deleted."""
1552
+ n, cls = name.strip().lower(), _gensio_class(cls)
1553
+ if not n:
1554
+ return
1555
+ if not cls:
1556
+ self.warnings.append(_("delete_default {0}: no class is given, and ser2net refuses "
1557
+ "such a file (a default set without a class can't be "
1558
+ "deleted); nothing is deleted").format(name))
1559
+ elif self.defaults.pop((n, cls), None) is None:
1560
+ self.warnings.append(_("delete_default {0}: no default of class {1} is set, and "
1561
+ "ser2net refuses such a file; nothing is deleted")
1562
+ .format(name, cls))
1563
+ elif n in _DEFAULT_CLASS:
1564
+ self._resolve(n)
1565
+
1566
+ def connection(self, name: str, body: Any) -> None:
1567
+ self.connections += 1
1568
+ if self.connections > MAX_CONNECTIONS:
1569
+ raise _too_many_connections()
1570
+ if name:
1571
+ self.aliases[name] = name # ser2net: *(con0) is the connection's name
1572
+ c = _Conn(name=name, label=name or _("a connection without a name"), budget=self.budget)
1573
+ if not isinstance(body, _YMap):
1574
+ c.skip(_("not a mapping"))
1575
+ else:
1576
+ accepter = self.text(body.get("accepter", ""), c.note, "accepter").strip()
1577
+ connector = self.text(body.get("connector", ""), c.note, "connector").strip()
1578
+ if not accepter or not connector:
1579
+ c.skip(_("it needs both an accepter and a connector"))
1580
+ elif _accepter(c, accepter, self.ssl_defaults,
1581
+ _bool(self.telnet_defaults.get("rfc2217", ""))):
1582
+ if not name:
1583
+ c.name = c.label = f"ser2net-{c.network['port']}"
1584
+ defaults = [f"{k}={v}" for k, v in self.serial_defaults.items()]
1585
+ self.budget.spend(sum(map(len, defaults)))
1586
+ if _connector(c, connector, defaults):
1587
+ self.settings(c, body)
1588
+ _finish(c)
1589
+ self.conns.append(c)
1590
+ self.warnings.extend(_notes(c, imported=bool(self.conns) and self.conns[-1] is c))
1591
+
1592
+ def settings(self, c: _Conn, body: _YMap) -> None:
1593
+ enable = self.text(body.get("enable", "on"), c.note, "enable")
1594
+ b = _bool(enable)
1595
+ if b is None:
1596
+ c.note(_("enable '{0}' is not on/off: imported disabled").format(enable))
1597
+ elif not b:
1598
+ c.note(_("imported disabled: the file turns it off (enable: off)"))
1599
+ c.enabled = bool(b)
1600
+ for k, v in self.option_defaults.items():
1601
+ if k != "remaddr": # see below
1602
+ self.budget.spend(len(v))
1603
+ _option(c, k, v)
1604
+ for key, value, _a in body.entries:
1605
+ k = key.lower()
1606
+ if k == "options":
1607
+ if isinstance(value, _YMap):
1608
+ for okey, oval, _oa in value.entries:
1609
+ _option(c, okey, self.text(oval, c.note, okey))
1610
+ elif isinstance(value, list) or str(value).strip():
1611
+ c.note(_("options is not a mapping, ignored"))
1612
+ elif k not in ("accepter", "connector", "enable"):
1613
+ _option(c, key, self.text(value, c.note, key)) # timeout (and stray options)
1614
+ # ser2net takes the default remaddr only for a connection that gives no address
1615
+ # itself (an empty remaddr gives none); it never adds the two up
1616
+ _default_remaddr(c, self.option_defaults.get("remaddr", ""))
1617
+
1618
+
1619
+ def _from_yaml(text: str, label: str, budget: _Budget) -> tuple[list[_Conn], list[str]]:
1620
+ try:
1621
+ docs = _YamlReader(text).documents()
1622
+ except _YamlError as e:
1623
+ raise ConfigError(f"{label}: {e}") from None
1624
+ except RecursionError: # the reader limits the nesting (_MAX_DEPTH); this only backs it up
1625
+ raise ConfigError(_("{0}: nested too deeply to import").format(label)) from None
1626
+ entries = [e for d in docs if isinstance(d, _YMap) for e in d.entries]
1627
+ keys = {k.lower() for k, _v, _a in entries}
1628
+ v1, v2 = bool(keys & _V1_KEYS), bool(keys & _V2_KEYS)
1629
+ if not (v1 or v2):
1630
+ raise ConfigError(_("{0} is not a ser2net configuration: it has no connection, define "
1631
+ "or default entries.").format(label))
1632
+ y = _Yaml("=" if v2 and not v1 else "*=" if v2 else "*", budget)
1633
+ if v1 and v2:
1634
+ y.warnings.append(_("the file mixes ser2net version 1 and version 2 YAML entries"))
1635
+ for key, value, anchor in entries:
1636
+ y.entry(key, value, anchor)
1637
+ return y.conns, y.warnings
1638
+
1639
+
1640
+ # ---------------------------------------------------------------------------
1641
+ # ser2net 3.x (ser2net.conf)
1642
+ # ---------------------------------------------------------------------------
1643
+ _V3_STATE = re.compile(r":(rawlp|raw|telnet|off):")
1644
+ _V3_DIRECTIVE = re.compile(r"(BANNER|SIGNATURE|OPENSTR|CLOSESTR|CLOSEON|DEVICE|TRACEFILE|"
1645
+ r"CONTROLPORT|RS485CONF|DEFAULT|ROTATOR|LED):")
1646
+ _V3_PARITY = {**_PARITY_WORDS, **{k.lower(): v for k, v in _PARITY_WORDS.items()}}
1647
+ _V3_TRACE = {"tr": "read", "tw": "write", "tb": "both"}
1648
+ _V3_IGNORED_DEFAULTS = ("local", "chardelay", "telnet_brk_on_sync")
1649
+
1650
+
1651
+ def _v3_lines(text: str) -> list[tuple[int, str]]:
1652
+ """Logical lines (line number, text): blank and '#' lines dropped, a trailing
1653
+ backslash continues the line on the next one (as ser2net 3.x reads them). A line
1654
+ longer than _MAX_VALUE refuses the file."""
1655
+ out: list[tuple[int, str]] = []
1656
+ buf: list[str] = []
1657
+ size, start = 0, 0 # size: characters in buf
1658
+ for n, line in enumerate(text.split("\n"), 1):
1659
+ if not size:
1660
+ if not line.strip() or line.lstrip().startswith("#"):
1661
+ continue
1662
+ start, line, buf = n, line.lstrip(), []
1663
+ if size + len(line) > _MAX_VALUE:
1664
+ raise ConfigError(_("line {0} is longer than {1} KB, so the file is not imported.")
1665
+ .format(start, _MAX_VALUE // 1024))
1666
+ if line.endswith("\\"):
1667
+ buf.append(line[:-1])
1668
+ size += len(line) - 1
1669
+ continue
1670
+ out.append((start, "".join(buf) + line))
1671
+ buf, size = [], 0
1672
+ if size:
1673
+ out.append((start, "".join(buf)))
1674
+ return out
1675
+
1676
+
1677
+ class _V3:
1678
+ def __init__(self, budget: _Budget) -> None:
1679
+ self.budget = budget
1680
+ self.connections = 0 # port lines read (MAX_CONNECTIONS)
1681
+ self.strings: dict[str, tuple[str, str]] = {} # name -> (BANNER/OPENSTR/..., text)
1682
+ self.devices: dict[str, str] = {}
1683
+ self.tracefiles: dict[str, str] = {}
1684
+ self.defaults: dict[str, str] = {}
1685
+ self.warnings: list[str] = []
1686
+ self.conns: list[_Conn] = []
1687
+
1688
+ def directive(self, n: int, kind: str, rest: str) -> None:
1689
+ name, _sep, value = rest.partition(":")
1690
+ if kind == "CONTROLPORT":
1691
+ self.warnings.append(_("line {0}: CONTROLPORT {1} is not imported (the web UI and "
1692
+ "REST API take its place)").format(n, rest.strip()))
1693
+ elif kind == "DEFAULT":
1694
+ if name in _V3_IGNORED_DEFAULTS or name in (
1695
+ "speed", "databits", "stopbits", "parity", "xonxoff", "rtscts",
1696
+ "hangup_when_done", "nobreak", "remctl", "kickolduser", "max-connections",
1697
+ "remaddr"):
1698
+ self.defaults[name] = value.strip()
1699
+ else:
1700
+ self.warnings.append(_("line {0}: DEFAULT {1} is not imported").format(n, name))
1701
+ elif kind == "DEVICE":
1702
+ self.devices[name] = value.strip()
1703
+ elif kind == "TRACEFILE":
1704
+ self.tracefiles[name] = value.strip()
1705
+ elif kind in ("LED", "ROTATOR"):
1706
+ what = {"LED": _("LEDs"), "ROTATOR": _("rotators")}
1707
+ self.warnings.append(_("line {0}: {1} {2} is not imported ({3} are not supported "
1708
+ "here)").format(n, kind, name, what[kind]))
1709
+ self.strings[name] = (kind, value)
1710
+ else: # BANNER OPENSTR CLOSESTR CLOSEON SIGNATURE RS485CONF
1711
+ self.strings[name] = (kind, value)
1712
+
1713
+ def port(self, n: int, line: str, m: re.Match[str]) -> None:
1714
+ self.connections += 1
1715
+ if self.connections > MAX_CONNECTIONS:
1716
+ raise _too_many_connections()
1717
+ state = m.group(1)
1718
+ timeout, _sep, rest = line[m.end():].partition(":")
1719
+ device, _sep, options = rest.partition(":")
1720
+ written = device.strip()
1721
+ device = self.devices.get(written, written)
1722
+ self.budget.spend(len(device)) # a DEVICE: name repeats its path
1723
+ c = _Conn(name="", label=_("line {0}").format(n), budget=self.budget, v3=True,
1724
+ devname=written)
1725
+ fields = []
1726
+ for f in line[: m.start()].split(","):
1727
+ if f.strip().lower() == "udp":
1728
+ c.network["mode"] = "udp"
1729
+ elif f.strip().lower() != "tcp":
1730
+ fields.append(f)
1731
+ if _listen_address(c, fields):
1732
+ port = c.network["port"]
1733
+ c.name = f"ser2net-udp-{port}" if c.network["mode"] == "udp" else f"ser2net-{port}"
1734
+ c.label = _("{0} (line {1})").format(c.name, n)
1735
+ if not device:
1736
+ c.skip(_("no device"))
1737
+ elif url_port(device): # e.g. a DEVICE alias; a pyserial URL is never opened here
1738
+ c.skip(_("device {0} is a URL, not a local serial port").format(device))
1739
+ elif device.startswith("sol.") or " " in device:
1740
+ c.skip(_("IPMI serial-over-LAN connections are not imported"))
1741
+ else:
1742
+ self.settings(c, state, timeout, device, options)
1743
+ _finish(c)
1744
+ self.conns.append(c)
1745
+ self.warnings.extend(_notes(c, imported=bool(self.conns) and self.conns[-1] is c))
1746
+
1747
+ def settings(self, c: _Conn, state: str, timeout: str, device: str, options: str) -> None:
1748
+ c.device = device
1749
+ c.enabled = state != "off"
1750
+ if not c.enabled:
1751
+ c.note(_("imported disabled: the file turns it off (state off)"))
1752
+ c.network["protocol"] = "telnet" if state == "telnet" else "raw"
1753
+ if state == "rawlp": # a printer port: ser2net opens it write-only
1754
+ _write_only(c, "rawlp")
1755
+ _idle_timeout(c, timeout)
1756
+ for name, value in self.defaults.items():
1757
+ if name != "remaddr": # see below
1758
+ self.budget.spend(len(value))
1759
+ self.default(c, name, value)
1760
+ for tok in options.split():
1761
+ self.token(c, tok)
1762
+ if not c.remaddr_set: # ser2net 3.x: the default only for a port without remaddr=
1763
+ _default_remaddr(c, self.defaults.get("remaddr", ""))
1764
+ if c.remctl:
1765
+ if state == "telnet":
1766
+ c.network["protocol"] = "rfc2217"
1767
+ else:
1768
+ c.note(_("remctl (RFC2217) needs a telnet port: ignored"))
1769
+
1770
+ def default(self, c: _Conn, name: str, value: str) -> None:
1771
+ if name in _V3_IGNORED_DEFAULTS:
1772
+ if name == "telnet_brk_on_sync" and _bool(value):
1773
+ _option(c, "telnet-brk-on-sync", "true")
1774
+ elif name in ("speed", "databits", "stopbits"):
1775
+ if value.isdigit():
1776
+ c.serial[{"speed": "baudrate", "databits": "bytesize"}.get(name, name)] = int(value)
1777
+ elif name == "parity":
1778
+ if value.upper() in _V3_PARITY:
1779
+ c.serial["parity"] = _V3_PARITY[value.upper()]
1780
+ elif name in ("max-connections", "kickolduser"):
1781
+ _option(c, name, value)
1782
+ elif _bool(value) is not None: # xonxoff rtscts hangup_when_done nobreak remctl
1783
+ flag = bool(_bool(value))
1784
+ if name in ("xonxoff", "rtscts", "remctl"):
1785
+ setattr(c, name, flag)
1786
+ else:
1787
+ c.advanced[name] = flag
1788
+
1789
+ def token(self, c: _Conn, tok: str) -> None:
1790
+ if self.serial(c, tok) or self.option(c, tok):
1791
+ return
1792
+ named = self.strings.get(tok)
1793
+ if named is not None:
1794
+ kind, value = named
1795
+ if kind in ("BANNER", "OPENSTR", "CLOSESTR", "CLOSEON", "SIGNATURE"):
1796
+ c.strings[kind.lower()] = value
1797
+ elif kind == "RS485CONF":
1798
+ c.note(_("RS485CONF {0} is not imported: set RS-485 in the mapping's serial "
1799
+ "settings").format(tok))
1800
+ return # LED: reported where it is defined
1801
+ if not self.serial(c, tok.upper()):
1802
+ c.note(_("unknown option '{0}' ignored").format(tok))
1803
+
1804
+ @staticmethod
1805
+ def serial(c: _Conn, tok: str) -> bool:
1806
+ """The serial keywords (case as ser2net 3.x spells them)."""
1807
+ base, on = tok.lstrip("-"), not tok.startswith("-")
1808
+ if tok.isdigit():
1809
+ c.serial["baudrate"] = int(tok)
1810
+ elif _serial_word(c, tok):
1811
+ pass
1812
+ elif tok in _V3_PARITY: # (in small letters too)
1813
+ c.serial["parity"] = _V3_PARITY[tok]
1814
+ elif base in ("XONXOFF", "RTSCTS"):
1815
+ setattr(c, base.lower(), on)
1816
+ elif base in ("HANGUP_WHEN_DONE", "NOBREAK"):
1817
+ c.advanced[base.lower()] = on
1818
+ elif base == "LOCAL":
1819
+ if not on:
1820
+ c.note(_("-LOCAL (watching the modem control lines) is not imported"))
1821
+ else:
1822
+ return False
1823
+ return True
1824
+
1825
+ def option(self, c: _Conn, tok: str) -> bool:
1826
+ """The connection keywords of ser2net 3.x."""
1827
+ name, eq, value = tok.partition("=")
1828
+ base, on = name.lstrip("-"), not name.startswith("-")
1829
+ if not eq:
1830
+ mod = re.fullmatch(r"(?:(tr|tw|tb)-)?(hexdump|timestamp)", base)
1831
+ if base == "remctl":
1832
+ c.remctl = on
1833
+ elif base == "kickolduser":
1834
+ c.network["kick_old_user"] = on
1835
+ elif mod is not None:
1836
+ _trace_mod(c, _V3_TRACE.get(mod.group(1) or "", "all"), mod.group(2), on)
1837
+ elif base == "telnet_brk_on_sync":
1838
+ if on:
1839
+ _option(c, "telnet-brk-on-sync", "true")
1840
+ elif base != "chardelay":
1841
+ return False
1842
+ return True
1843
+ if name in _V3_TRACE:
1844
+ if value in self.tracefiles:
1845
+ c.traces[_V3_TRACE[name]] = self.tracefiles[value]
1846
+ else:
1847
+ c.note(_("{0}: there is no TRACEFILE named {1}").format(tok, value))
1848
+ elif name == "rs485":
1849
+ c.note(_("{0} is not imported: set RS-485 in the mapping's serial settings")
1850
+ .format(tok))
1851
+ elif name == "max-connections":
1852
+ _option(c, name, value)
1853
+ elif name == "remaddr":
1854
+ c.remaddr_set = True
1855
+ _remaddr(c, value)
1856
+ elif name in ("chardelay-scale", "chardelay-min", "chardelay-max", "dev-to-net-bufsize",
1857
+ "net-to-dev-bufsize", "dev-to-tcp-bufsize", "tcp-to-dev-bufsize"):
1858
+ c.drop(name)
1859
+ elif name not in ("led-rx", "led-tx"): # LEDs: reported where they are defined
1860
+ return False
1861
+ return True
1862
+
1863
+
1864
+ def _from_v3(lines: list[tuple[int, str]], budget: _Budget) -> tuple[list[_Conn], list[str]]:
1865
+ p = _V3(budget)
1866
+ for n, line in lines:
1867
+ d = _V3_DIRECTIVE.match(line)
1868
+ m = _V3_STATE.search(line)
1869
+ if d is not None:
1870
+ p.directive(n, d.group(1), line[d.end():])
1871
+ elif m is not None:
1872
+ p.port(n, line, m)
1873
+ else:
1874
+ p.warnings.append(_("line {0}: not a ser2net.conf line, ignored").format(n))
1875
+ return p.conns, p.warnings
1876
+
1877
+
1878
+ # ---------------------------------------------------------------------------
1879
+ # Public entry point
1880
+ # ---------------------------------------------------------------------------
1881
+ def _assemble(conns: list[_Conn], warnings: list[str], budget: _Budget) -> list[_Conn]:
1882
+ """Drop what does not validate or listens where an earlier one does, make the names
1883
+ unique and keep one active mapping per serial device: the connections to import."""
1884
+ def warn(msg: str) -> None:
1885
+ budget.spend(len(msg))
1886
+ warnings.append(msg)
1887
+
1888
+ def warn_for(c: _Conn, msg: str) -> None: # "<label>: ..." about one that is imported
1889
+ warn(_note(c, msg.removeprefix(f"{c.label}: ")))
1890
+ kept: list[_Conn] = []
1891
+ listening: dict[tuple[str, int], dict[str, str]] = {} # (tcp|udp, port) -> bind IP -> name
1892
+ for c in conns:
1893
+ try:
1894
+ MappingConfig.from_dict(c.to_dict()).validate()
1895
+ except (TypeError, ValueError) as e: # ConfigError is a ValueError
1896
+ warn(_("{0}: skipped: {1}").format(c.label, e))
1897
+ continue
1898
+ net = c.network
1899
+ proto = "udp" if net["mode"] == "udp" else "tcp"
1900
+ on_port = listening.setdefault((proto, net["port"]), {})
1901
+ other = next((name for ip, name in on_port.items()
1902
+ if _ip_overlaps(ip, net["bind_ip"])), None)
1903
+ if other is not None:
1904
+ warn(_("{0}: skipped: {1} port {2} is already used by '{3}'")
1905
+ .format(c.label, proto.upper(), net["port"], other))
1906
+ continue
1907
+ on_port[net["bind_ip"]] = c.name
1908
+ kept.append(c)
1909
+ used: set[str] = set()
1910
+ tried: dict[str, int] = {} # name -> the last (k) tried for it: those before are taken
1911
+ owner: dict[str, str] = {}
1912
+ for c in kept:
1913
+ base = c.name
1914
+ k = tried.get(base, 1)
1915
+ while c.name in used:
1916
+ k += 1
1917
+ c.name = f"{base} ({k})"
1918
+ tried[base] = k
1919
+ used.add(c.name)
1920
+ if c.name != base:
1921
+ warn_for(c, _("{0}: renamed to '{1}' (the name is used twice)").format(c.label, c.name))
1922
+ key = canonical_device(c.device) # (com3 and COM3 are one port)
1923
+ if c.enabled and key in owner:
1924
+ c.enabled = False
1925
+ warn_for(c, _("{0}: imported disabled: {1} is also used by '{2}', and a serial port can be "
1926
+ "open in only one active mapping here")
1927
+ .format(c.label, c.device, owner[key]))
1928
+ elif c.enabled:
1929
+ owner[key] = c.name
1930
+ return kept
1931
+
1932
+
1933
+ def looks_yaml(text: str) -> bool:
1934
+ """True for a ser2net 4.x ser2net.yaml, False for anything else (a 3.x ser2net.conf)."""
1935
+ for line in text.split("\n"):
1936
+ s = line.strip()
1937
+ if s and not s.startswith("#"):
1938
+ if s.startswith("%YAML") or _marker(s, "---"):
1939
+ return True
1940
+ break
1941
+ return bool(_TOP_KEY.search(text))
1942
+
1943
+
1944
+ def parse_ser2net_config(text: str, filename: str = "") -> tuple[list[dict], list[str]]:
1945
+ """Mappings (dicts for MappingConfig.from_dict) + human-readable warnings.
1946
+
1947
+ `text` is a ser2net 4.x ser2net.yaml or a 3.x ser2net.conf (detected from the
1948
+ content; `filename` only names the file in messages). Raises ConfigError when it
1949
+ is neither, when the YAML is broken, or when the file is past one of the limits
1950
+ at the top of this module."""
1951
+ if len(text) > MAX_TEXT:
1952
+ raise ConfigError(_("The file is larger than {0} MB, too large for a ser2net "
1953
+ "configuration.").format(MAX_TEXT // 2**20))
1954
+ budget = _Budget()
1955
+ text = text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
1956
+ label = re.split(r"[\\/]", filename)[-1] if filename else ""
1957
+ if not text.strip():
1958
+ raise ConfigError(_("The file is empty."))
1959
+ if looks_yaml(text):
1960
+ conns, warnings = _from_yaml(text, label or "ser2net.yaml", budget)
1961
+ else:
1962
+ lines = _v3_lines(text)
1963
+ if not any(_V3_DIRECTIVE.match(ln) or _V3_STATE.search(ln) for _n, ln in lines):
1964
+ raise ConfigError(_("{0} is not a ser2net configuration: expected a ser2net.yaml "
1965
+ "(ser2net 4.x) or ser2net.conf (ser2net 3.x) file.").format(
1966
+ label or _("The file")))
1967
+ conns, warnings = _from_v3(lines, budget)
1968
+ kept = _assemble(conns, warnings, budget)
1969
+ mappings = [c.to_dict() for c in kept]
1970
+ _point_notes(warnings, kept)
1971
+ if len(warnings) > _MAX_WARNINGS:
1972
+ more = len(warnings) - _MAX_WARNINGS
1973
+ del warnings[_MAX_WARNINGS:]
1974
+ warnings.append(_("… and {0} more warnings").format(more))
1975
+ if not mappings:
1976
+ warnings.append(_("No connection could be imported.") if conns else
1977
+ _("The file has no ser2net connections to import."))
1978
+ if mappings:
1979
+ warnings.append(_("A mapping here opens its serial port when it starts and keeps it "
1980
+ "open (ser2net opened it only while a client was connected), and "
1981
+ "makes no UUCP lock files: programs that look for them, such as "
1982
+ "minicom, don't see the port as in use"))
1983
+ wide = [c.name for c in kept if c.any_family]
1984
+ if wide:
1985
+ warnings.append(_("ser2net listened on IPv4 and IPv6 for an accepter that names no "
1986
+ "address family or address ({0}): imported on 0.0.0.0, which takes "
1987
+ "IPv4 clients only; pick “All interfaces (IPv4 + IPv6)” as the bind "
1988
+ "IP to take IPv6 clients too").format(
1989
+ ", ".join(wide[:5]) + (", …" if len(wide) > 5 else "")))
1990
+ if any(_open_to_all(m) for m in mappings):
1991
+ warnings.append(_("ser2net may also have limited its clients with TCP wrappers "
1992
+ "(/etc/hosts.allow, /etc/hosts.deny); those rules are not imported: a "
1993
+ "mapping without allowed client IPs accepts any client"))
1994
+ return mappings, warnings
1995
+
1996
+
1997
+ def _open_to_all(m: dict) -> bool:
1998
+ """An enabled mapping that listens beyond the loopback and accepts any client."""
1999
+ net = m["network"]
2000
+ return bool(m["enabled"] and not net.get("allowed_client_ips")
2001
+ and not ipaddress.ip_address(net["bind_ip"]).is_loopback)