ser2netweb 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ser2netweb/__init__.py +20 -0
- ser2netweb/__main__.py +6 -0
- ser2netweb/accounts.py +503 -0
- ser2netweb/cli.py +489 -0
- ser2netweb/clock.py +128 -0
- ser2netweb/config.py +4491 -0
- ser2netweb/console.py +181 -0
- ser2netweb/datalock.py +181 -0
- ser2netweb/engine/__init__.py +0 -0
- ser2netweb/engine/access.py +173 -0
- ser2netweb/engine/bindhint.py +115 -0
- ser2netweb/engine/bridge.py +1928 -0
- ser2netweb/engine/cluster.py +1140 -0
- ser2netweb/engine/modbus.py +530 -0
- ser2netweb/engine/modbus_gateway.py +1465 -0
- ser2netweb/engine/mqtt_pub.py +632 -0
- ser2netweb/engine/netguard.py +805 -0
- ser2netweb/engine/netinfo.py +199 -0
- ser2netweb/engine/portdiag.py +414 -0
- ser2netweb/engine/portlist.py +471 -0
- ser2netweb/engine/protocols/__init__.py +28 -0
- ser2netweb/engine/protocols/base.py +117 -0
- ser2netweb/engine/protocols/raw.py +12 -0
- ser2netweb/engine/protocols/rfc2217.py +586 -0
- ser2netweb/engine/protocols/telnet.py +172 -0
- ser2netweb/engine/serial_compat.py +598 -0
- ser2netweb/engine/serial_io.py +210 -0
- ser2netweb/engine/serial_link.py +414 -0
- ser2netweb/engine/sockopts.py +103 -0
- ser2netweb/engine/sparkplug.py +538 -0
- ser2netweb/engine/sparkplug_node.py +550 -0
- ser2netweb/engine/supervisor.py +533 -0
- ser2netweb/engine/tls.py +232 -0
- ser2netweb/envvars.py +57 -0
- ser2netweb/explain.py +117 -0
- ser2netweb/healthcheck.py +78 -0
- ser2netweb/i18n.py +110 -0
- ser2netweb/importer.py +550 -0
- ser2netweb/layout.py +118 -0
- ser2netweb/licences.py +86 -0
- ser2netweb/locale/tr.json +1569 -0
- ser2netweb/logsetup.py +253 -0
- ser2netweb/logtext.py +23 -0
- ser2netweb/notify.py +761 -0
- ser2netweb/optional.py +76 -0
- ser2netweb/reload.py +295 -0
- ser2netweb/runtime.py +762 -0
- ser2netweb/sdnotify.py +85 -0
- ser2netweb/ser2net_import.py +2001 -0
- ser2netweb/signins.py +98 -0
- ser2netweb/standalone.py +154 -0
- ser2netweb/state.py +1457 -0
- ser2netweb/storage.py +130 -0
- ser2netweb/textfold.py +51 -0
- ser2netweb/tlsreload.py +37 -0
- ser2netweb/watchdog.py +201 -0
- ser2netweb/web/__init__.py +0 -0
- ser2netweb/web/api.py +793 -0
- ser2netweb/web/auth.py +699 -0
- ser2netweb/web/certinfo.py +144 -0
- ser2netweb/web/httpguard.py +413 -0
- ser2netweb/web/ldap_auth.py +556 -0
- ser2netweb/web/oidc_auth.py +330 -0
- ser2netweb/web/proxy.py +398 -0
- ser2netweb/web/routes/__init__.py +78 -0
- ser2netweb/web/routes/cluster.py +669 -0
- ser2netweb/web/routes/common.py +359 -0
- ser2netweb/web/routes/console.py +451 -0
- ser2netweb/web/routes/forms.py +853 -0
- ser2netweb/web/routes/login.py +573 -0
- ser2netweb/web/routes/mappings.py +470 -0
- ser2netweb/web/routes/ops.py +374 -0
- ser2netweb/web/routes/settings.py +2135 -0
- ser2netweb/web/routes/system.py +308 -0
- ser2netweb/web/routes/totp.py +282 -0
- ser2netweb/web/selfsigned.py +129 -0
- ser2netweb/web/server.py +483 -0
- ser2netweb/web/static/app.css +327 -0
- ser2netweb/web/static/app.js +1102 -0
- ser2netweb/web/static/htmx.min.js +1 -0
- ser2netweb/web/static/points.js +218 -0
- ser2netweb/web/static/theme.js +7 -0
- ser2netweb/web/static/xterm-addon-fit.js +2 -0
- ser2netweb/web/static/xterm.css +218 -0
- ser2netweb/web/static/xterm.js +2 -0
- ser2netweb/web/templates/_cluster_body.html +109 -0
- ser2netweb/web/templates/_console.html +21 -0
- ser2netweb/web/templates/_footer.html +13 -0
- ser2netweb/web/templates/_login_notice.html +8 -0
- ser2netweb/web/templates/_macros.html +21 -0
- ser2netweb/web/templates/_mapping_form.html +477 -0
- ser2netweb/web/templates/_mapping_log.html +28 -0
- ser2netweb/web/templates/_mapping_traces.html +44 -0
- ser2netweb/web/templates/_mappings_body.html +149 -0
- ser2netweb/web/templates/_modbus_read.html +18 -0
- ser2netweb/web/templates/_ports_table.html +37 -0
- ser2netweb/web/templates/_serial_port_select.html +48 -0
- ser2netweb/web/templates/_shell.html +41 -0
- ser2netweb/web/templates/audit.html +55 -0
- ser2netweb/web/templates/base.html +77 -0
- ser2netweb/web/templates/dashboard.html +72 -0
- ser2netweb/web/templates/licenses.html +18 -0
- ser2netweb/web/templates/login.html +32 -0
- ser2netweb/web/templates/login_totp.html +23 -0
- ser2netweb/web/templates/settings.html +830 -0
- ser2netweb/web/templates/setup.html +43 -0
- ser2netweb/web/templates/totp.html +41 -0
- ser2netweb/web/totp.py +94 -0
- ser2netweb/winservice.py +1793 -0
- ser2netweb-3.0.0.dist-info/METADATA +120 -0
- ser2netweb-3.0.0.dist-info/RECORD +121 -0
- ser2netweb-3.0.0.dist-info/WHEEL +5 -0
- ser2netweb-3.0.0.dist-info/entry_points.txt +2 -0
- ser2netweb-3.0.0.dist-info/licenses/COMMERCIAL_LICENSE.md +94 -0
- ser2netweb-3.0.0.dist-info/licenses/LICENSE +662 -0
- ser2netweb-3.0.0.dist-info/licenses/NOTICE +20 -0
- ser2netweb-3.0.0.dist-info/licenses/THIRD-PARTY-NOTICES.md +105 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/htmx.org.txt +13 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm-addon-fit.txt +19 -0
- ser2netweb-3.0.0.dist-info/licenses/tools/licenses/npm/xterm.txt +21 -0
- ser2netweb-3.0.0.dist-info/top_level.txt +1 -0
ser2netweb/web/api.py
ADDED
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
"""JSON REST API (``/api/v1``) for automation: Ansible, scripts, CI, fleet tools.
|
|
2
|
+
|
|
3
|
+
Separate from the HTMX endpoints under ``/api`` (which return HTML fragments for
|
|
4
|
+
the browser UI). Authentication is a bearer token enforced in GuardMiddleware
|
|
5
|
+
(``Authorization: Bearer <token>``); these handlers assume the request is already
|
|
6
|
+
authenticated. Every response is JSON, except ``/api/v1/metrics`` (the Prometheus text
|
|
7
|
+
format). The OpenAPI 3.0 description is served at ``/api/v1/openapi.json`` and
|
|
8
|
+
``/api/v1/health`` is unauthenticated for probes.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import dataclasses
|
|
14
|
+
import typing
|
|
15
|
+
from typing import TYPE_CHECKING, Optional
|
|
16
|
+
|
|
17
|
+
from starlette.exceptions import HTTPException
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
20
|
+
from starlette.routing import Route
|
|
21
|
+
|
|
22
|
+
from .. import __version__, config, importer
|
|
23
|
+
from ..config import (REDACTED, ROLE_RANK, ConfigError, ConfigWriteError, MappingConfig,
|
|
24
|
+
mapping_revision, valid_mapping_id)
|
|
25
|
+
from . import proxy
|
|
26
|
+
from .routes import ops
|
|
27
|
+
from .routes.common import client_ip as _client_ip
|
|
28
|
+
from .routes.system import METRICS_TYPE, metrics_text
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from ..state import AppState
|
|
32
|
+
|
|
33
|
+
API_PREFIX = "/api/v1"
|
|
34
|
+
# the values a true/false query parameter may have
|
|
35
|
+
_BOOLEANS = {"true": True, "1": True, "yes": True, "false": False, "0": False, "no": False}
|
|
36
|
+
|
|
37
|
+
# The REST API's own version (OpenAPI info.version, GET /info) and what it offers beyond
|
|
38
|
+
# 1.0.0, so a client can tell what it may use without parsing the software version.
|
|
39
|
+
API_VERSION = "1.1.0"
|
|
40
|
+
API_FEATURES = ("warnings", "changed", "if-match")
|
|
41
|
+
|
|
42
|
+
def _caller(request) -> str:
|
|
43
|
+
"""Who calls, for the audit log: the token's name (api-token:<name>), 'api-token' for
|
|
44
|
+
the token of api_token_hash."""
|
|
45
|
+
token = getattr(request.state, "api_token", None)
|
|
46
|
+
return token.audit_label if token is not None else "api-token"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _mapping_view(mapping: MappingConfig, state: AppState,
|
|
50
|
+
request: Optional[Request] = None) -> dict:
|
|
51
|
+
"""A mapping's full config plus its live runtime status. A read-only (viewer)
|
|
52
|
+
token doesn't get stored secrets (the MQTT password and client key password, the
|
|
53
|
+
TLS key password) — the same rule as the web UI, where viewers can't open the edit
|
|
54
|
+
form or the export. Operator/admin tokens see the real value, so a GET -> PUT round
|
|
55
|
+
trip keeps working for them (a PUT that sends the mask back keeps the stored TLS key
|
|
56
|
+
password). The role is the calling token's (GuardMiddleware puts it on the request)."""
|
|
57
|
+
view = mapping.to_dict()
|
|
58
|
+
token = getattr(request.state, "api_token", None) if request is not None else None
|
|
59
|
+
role = token.role if token is not None else state.config.api_token_role
|
|
60
|
+
if ROLE_RANK.get(role, 0) < ROLE_RANK["operator"]:
|
|
61
|
+
for section, secret in (("mqtt", "password"), ("mqtt", "client_key_password"),
|
|
62
|
+
("network", "tls_key_password")):
|
|
63
|
+
if view[section].get(secret):
|
|
64
|
+
view[section][secret] = REDACTED
|
|
65
|
+
view["status"] = state.supervisor.status(mapping.id)
|
|
66
|
+
return view
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _check_tls_files(mapping) -> None:
|
|
70
|
+
"""MappingConfig.check_tls_files and check_mqtt_tls_files, off the event loop (they
|
|
71
|
+
read the files)."""
|
|
72
|
+
await asyncio.to_thread(mapping.check_tls_files)
|
|
73
|
+
await asyncio.to_thread(mapping.check_mqtt_tls_files)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def merge_patch(target, patch):
|
|
77
|
+
"""RFC 7396 JSON merge patch: `patch` applied to `target` (neither is changed). An
|
|
78
|
+
object in the patch is merged key by key into the target's (a missing or non-object
|
|
79
|
+
target counts as empty), null removes a key, anything else replaces the value."""
|
|
80
|
+
if not isinstance(patch, dict):
|
|
81
|
+
return patch
|
|
82
|
+
out = dict(target) if isinstance(target, dict) else {}
|
|
83
|
+
for key, value in patch.items():
|
|
84
|
+
if value is None:
|
|
85
|
+
out.pop(key, None)
|
|
86
|
+
else:
|
|
87
|
+
out[key] = merge_patch(out.get(key), value)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _edit_view(mapping, state, request=None) -> dict:
|
|
92
|
+
"""The response to a create / update: the mapping as stored (_mapping_view), and when
|
|
93
|
+
there are any, the `warnings` about what was sent (keys that are no setting, a setting
|
|
94
|
+
replaced by a supported one) and about settings it has that do nothing
|
|
95
|
+
(MappingConfig.ignored_settings)."""
|
|
96
|
+
view = _mapping_view(mapping, state, request)
|
|
97
|
+
warnings = mapping.input_warnings() + mapping.ignored_settings()
|
|
98
|
+
if warnings:
|
|
99
|
+
view["warnings"] = warnings
|
|
100
|
+
return view
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# what a mapping in a request can make from_dict / validate raise besides ConfigError:
|
|
104
|
+
# values of the wrong type are read as the right one (config._read_fields), so these only
|
|
105
|
+
# guard against a check that still trips over one
|
|
106
|
+
_BAD_INPUT = (ValueError, TypeError, AttributeError, OverflowError)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _etag(state, mapping) -> str:
|
|
110
|
+
"""A mapping's ETag: its revision (config.mapping_revision), as a strong entity tag."""
|
|
111
|
+
return f'"{mapping_revision(state.config.secret_key, mapping)}"'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _if_match(request, state, mapping) -> bool:
|
|
115
|
+
"""True unless the request has an If-Match header that names neither the mapping's
|
|
116
|
+
current ETag nor "*" (RFC 9110: strong comparison, so a weak tag never matches). A
|
|
117
|
+
client that sends no If-Match changes the mapping whatever its revision, as before."""
|
|
118
|
+
header = request.headers.get("if-match")
|
|
119
|
+
if header is None:
|
|
120
|
+
return True
|
|
121
|
+
tags = [t.strip() for t in header.split(",")]
|
|
122
|
+
return "*" in tags or _etag(state, mapping) in tags
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_STALE = {"error": "precondition_failed",
|
|
126
|
+
"detail": "the mapping changed since it was read: GET it again (If-Match)"}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _not_saved(e: ConfigError) -> JSONResponse:
|
|
130
|
+
"""A change that was refused when saved, and so not made: 400 when the config would
|
|
131
|
+
not validate (it shares a port or a serial device with another mapping, ...), 500 when
|
|
132
|
+
config.json can't be written."""
|
|
133
|
+
if isinstance(e, ConfigWriteError):
|
|
134
|
+
return JSONResponse({"error": "save_failed", "detail": str(e)}, status_code=500)
|
|
135
|
+
return JSONResponse({"error": "validation", "detail": str(e)}, status_code=400)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def build_api_routes(state: AppState) -> list[Route]:
|
|
139
|
+
async def list_mappings(request):
|
|
140
|
+
return JSONResponse({"mappings": [_mapping_view(m, state, request) for m in state.config.mappings]})
|
|
141
|
+
|
|
142
|
+
async def get_mapping(request):
|
|
143
|
+
m = state.config.get_mapping(request.path_params["mid"])
|
|
144
|
+
if not m:
|
|
145
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
146
|
+
return JSONResponse(_mapping_view(m, state, request), headers={"ETag": _etag(state, m)})
|
|
147
|
+
|
|
148
|
+
async def create_mapping(request):
|
|
149
|
+
try:
|
|
150
|
+
body = await request.json()
|
|
151
|
+
except HTTPException: # a body over the size limit: 413
|
|
152
|
+
raise
|
|
153
|
+
except Exception:
|
|
154
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
155
|
+
if not isinstance(body, dict):
|
|
156
|
+
return JSONResponse({"error": "invalid_json", "detail": "object expected"}, status_code=400)
|
|
157
|
+
body.pop("id", None) # the server assigns the id
|
|
158
|
+
try:
|
|
159
|
+
mapping = MappingConfig.from_dict(body)
|
|
160
|
+
mapping.validate()
|
|
161
|
+
mapping.validate_input()
|
|
162
|
+
await _check_tls_files(mapping)
|
|
163
|
+
except _BAD_INPUT as e: # ConfigError is a ValueError
|
|
164
|
+
return JSONResponse({"error": "validation", "detail": str(e)}, status_code=400)
|
|
165
|
+
try:
|
|
166
|
+
async with state.edit_config():
|
|
167
|
+
state.config.mappings.append(mapping)
|
|
168
|
+
# next to the config as it is saved: ser2netweb's own ports, a serial port
|
|
169
|
+
# another mapping uses, the admin UI's TLS key
|
|
170
|
+
if conflict := ops.edit_conflict(state.config, mapping):
|
|
171
|
+
raise ConfigError(conflict)
|
|
172
|
+
except ConfigError as e: # not saved: not created either
|
|
173
|
+
return _not_saved(e)
|
|
174
|
+
await state.supervisor.apply_mapping(mapping)
|
|
175
|
+
state.log(f"api: mapping created: {mapping.name}")
|
|
176
|
+
state.audit(_client_ip(request), "api_mapping_create", mapping.name, user=_caller(request))
|
|
177
|
+
return JSONResponse(_edit_view(mapping, state, request), status_code=201,
|
|
178
|
+
headers={"Location": proxy.url(request,
|
|
179
|
+
f"{API_PREFIX}/mappings/{mapping.id}"),
|
|
180
|
+
"ETag": _etag(state, mapping)})
|
|
181
|
+
|
|
182
|
+
async def update_mapping(request):
|
|
183
|
+
mid = request.path_params["mid"]
|
|
184
|
+
if not valid_mapping_id(mid): # no mapping can have it (ids are checked on load)
|
|
185
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
186
|
+
try:
|
|
187
|
+
body = await request.json()
|
|
188
|
+
except HTTPException: # a body over the size limit: 413
|
|
189
|
+
raise
|
|
190
|
+
except Exception:
|
|
191
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
192
|
+
if not isinstance(body, dict):
|
|
193
|
+
return JSONResponse({"error": "invalid_json", "detail": "object expected"}, status_code=400)
|
|
194
|
+
body["id"] = mid # PUT replaces the mapping with this id
|
|
195
|
+
try:
|
|
196
|
+
mapping = MappingConfig.from_dict(body)
|
|
197
|
+
stored = state.config.get_mapping(mid)
|
|
198
|
+
if mapping.network.tls_key_password == REDACTED and stored is not None:
|
|
199
|
+
mapping.network.tls_key_password = stored.network.tls_key_password
|
|
200
|
+
if mapping.mqtt.client_key_password == REDACTED and stored is not None:
|
|
201
|
+
mapping.mqtt.client_key_password = stored.mqtt.client_key_password
|
|
202
|
+
mapping.validate()
|
|
203
|
+
mapping.validate_input()
|
|
204
|
+
await _check_tls_files(mapping)
|
|
205
|
+
except _BAD_INPUT as e: # ConfigError is a ValueError
|
|
206
|
+
return JSONResponse({"error": "validation", "detail": str(e)}, status_code=400)
|
|
207
|
+
existing = None
|
|
208
|
+
try:
|
|
209
|
+
async with state.edit_config():
|
|
210
|
+
existing = state.config.get_mapping(mid)
|
|
211
|
+
if existing and not _if_match(request, state, existing):
|
|
212
|
+
raise ops.StaleRevision(mid)
|
|
213
|
+
if existing and mapping.mqtt.password == REDACTED: # what a viewer token reads
|
|
214
|
+
mapping.mqtt.password = existing.mqtt.password
|
|
215
|
+
# the same settings again: nothing to save, its runner stays as it is
|
|
216
|
+
if existing and existing.to_dict() != mapping.to_dict():
|
|
217
|
+
state.config.mappings[state.config.mappings.index(existing)] = mapping
|
|
218
|
+
if conflict := ops.edit_conflict(state.config, mapping):
|
|
219
|
+
raise ConfigError(conflict)
|
|
220
|
+
except ConfigError as e: # not saved: not changed either
|
|
221
|
+
return _not_saved(e)
|
|
222
|
+
except ops.StaleRevision:
|
|
223
|
+
return JSONResponse(_STALE, status_code=412)
|
|
224
|
+
if not existing:
|
|
225
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
226
|
+
same = existing.to_dict() == mapping.to_dict()
|
|
227
|
+
_ok, _msg, restarted = await state.supervisor.ensure_running(existing if same else mapping)
|
|
228
|
+
state.log(f"api: mapping updated: {mapping.name}")
|
|
229
|
+
state.audit(_client_ip(request), "api_mapping_update", mapping.name, user=_caller(request))
|
|
230
|
+
view = _edit_view(mapping, state, request)
|
|
231
|
+
view["changed"] = not same or restarted # False: the same settings, still running
|
|
232
|
+
return JSONResponse(view, headers={"ETag": _etag(state, mapping)})
|
|
233
|
+
|
|
234
|
+
async def patch_mapping(request):
|
|
235
|
+
"""PATCH: an RFC 7396 JSON merge patch on the stored mapping. Only what the body
|
|
236
|
+
names changes (an object is merged into the one it replaces, a list or a value
|
|
237
|
+
replaces it, null resets a field to its default), unlike PUT, which replaces the
|
|
238
|
+
whole mapping with the body. Merged and checked under the config lock, so a
|
|
239
|
+
concurrent change to another field is not lost. A patch that changes nothing
|
|
240
|
+
leaves the mapping running as it is (`changed`: false), as a PUT of its settings."""
|
|
241
|
+
mid = request.path_params["mid"]
|
|
242
|
+
if not valid_mapping_id(mid): # no mapping can have it (ids are checked on load)
|
|
243
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
244
|
+
try:
|
|
245
|
+
body = await request.json()
|
|
246
|
+
except HTTPException: # a body over the size limit: 413
|
|
247
|
+
raise
|
|
248
|
+
except Exception:
|
|
249
|
+
return JSONResponse({"error": "invalid_json"}, status_code=400)
|
|
250
|
+
if not isinstance(body, dict):
|
|
251
|
+
return JSONResponse({"error": "invalid_json", "detail": "object expected"}, status_code=400)
|
|
252
|
+
body.pop("id", None) # the path names the mapping
|
|
253
|
+
body.pop("status", None) # read-only field
|
|
254
|
+
try:
|
|
255
|
+
async with state.edit_config():
|
|
256
|
+
existing = state.config.get_mapping(mid)
|
|
257
|
+
if not existing:
|
|
258
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
259
|
+
merged = merge_patch(existing.to_dict(), body)
|
|
260
|
+
merged["id"] = mid
|
|
261
|
+
try:
|
|
262
|
+
mapping = MappingConfig.from_dict(merged)
|
|
263
|
+
# the mask a viewer token reads, sent back: the stored secret stays
|
|
264
|
+
if mapping.mqtt.password == REDACTED:
|
|
265
|
+
mapping.mqtt.password = existing.mqtt.password
|
|
266
|
+
if mapping.network.tls_key_password == REDACTED:
|
|
267
|
+
mapping.network.tls_key_password = existing.network.tls_key_password
|
|
268
|
+
if mapping.mqtt.client_key_password == REDACTED:
|
|
269
|
+
mapping.mqtt.client_key_password = existing.mqtt.client_key_password
|
|
270
|
+
mapping.validate()
|
|
271
|
+
mapping.validate_input()
|
|
272
|
+
await _check_tls_files(mapping)
|
|
273
|
+
except _BAD_INPUT as e: # ConfigError is a ValueError
|
|
274
|
+
return JSONResponse({"error": "validation", "detail": str(e)}, status_code=400)
|
|
275
|
+
same = existing.to_dict() == mapping.to_dict()
|
|
276
|
+
if not same:
|
|
277
|
+
state.config.mappings[state.config.mappings.index(existing)] = mapping
|
|
278
|
+
# next to the config as it is saved: ser2netweb's own ports, a serial port
|
|
279
|
+
# another mapping uses, the admin UI's TLS key
|
|
280
|
+
if conflict := ops.edit_conflict(state.config, mapping):
|
|
281
|
+
raise ConfigError(conflict)
|
|
282
|
+
except ConfigError as e: # not saved: not changed either
|
|
283
|
+
return _not_saved(e)
|
|
284
|
+
_ok, _msg, restarted = await state.supervisor.ensure_running(existing if same else mapping)
|
|
285
|
+
state.log(f"api: mapping patched: {mapping.name}")
|
|
286
|
+
state.audit(_client_ip(request), "api_mapping_patch", mapping.name, user=_caller(request))
|
|
287
|
+
view = _edit_view(mapping, state, request)
|
|
288
|
+
view["changed"] = not same or restarted
|
|
289
|
+
return JSONResponse(view, headers={"ETag": _etag(state, mapping)})
|
|
290
|
+
|
|
291
|
+
async def delete_mapping(request):
|
|
292
|
+
mid = request.path_params["mid"]
|
|
293
|
+
try:
|
|
294
|
+
removed = await ops.delete_mapping(
|
|
295
|
+
state, mid, expected=lambda m: _if_match(request, state, m))
|
|
296
|
+
except ConfigError as e: # not deleted
|
|
297
|
+
return _not_saved(e)
|
|
298
|
+
except ops.StaleRevision:
|
|
299
|
+
return JSONResponse(_STALE, status_code=412)
|
|
300
|
+
if removed is None:
|
|
301
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
302
|
+
state.log(f"api: mapping deleted: {removed.name}")
|
|
303
|
+
state.audit(_client_ip(request), "api_mapping_delete", removed.name, user=_caller(request))
|
|
304
|
+
return JSONResponse({"deleted": mid})
|
|
305
|
+
|
|
306
|
+
async def mapping_action(request):
|
|
307
|
+
mid = request.path_params["mid"]
|
|
308
|
+
action = request.path_params["action"]
|
|
309
|
+
if action not in ("start", "stop", "restart"):
|
|
310
|
+
return JSONResponse({"error": "unknown_action"}, status_code=400)
|
|
311
|
+
res = await ops.apply_action(state, mid, action)
|
|
312
|
+
if not res.found:
|
|
313
|
+
return JSONResponse({"error": "not_found"}, status_code=404)
|
|
314
|
+
if res.error is not None: # nothing changed
|
|
315
|
+
return _not_saved(res.error)
|
|
316
|
+
state.log(f"api: mapping {action}: {res.name}")
|
|
317
|
+
state.audit(_client_ip(request), f"api_mapping_{action}", res.name, user=_caller(request))
|
|
318
|
+
out = {"id": mid, "action": action, "status": state.supervisor.status(mid),
|
|
319
|
+
"changed": res.changed}
|
|
320
|
+
if res.warning: # stopped, but config.json could not keep it
|
|
321
|
+
out["warning"] = res.warning
|
|
322
|
+
return JSONResponse(out)
|
|
323
|
+
|
|
324
|
+
async def import_mappings(request):
|
|
325
|
+
"""POST /import: many mappings in one change. The body is a mappings export
|
|
326
|
+
({"mappings": [...]}, which is also what GET /mappings answers), a whole
|
|
327
|
+
config.json (only its mappings are used) or a classic ser2net.yaml / ser2net.conf.
|
|
328
|
+
?mode=merge (the default: a mapping with the same id, else the same name, is
|
|
329
|
+
updated, the others added) or replace (the body's mappings replace all of them);
|
|
330
|
+
?dry_run=true answers what it would do, and why it would be refused, without
|
|
331
|
+
changing anything. A real run is one save: one backup, all or nothing."""
|
|
332
|
+
mode = request.query_params.get("mode", "merge")
|
|
333
|
+
if mode not in importer.MODES:
|
|
334
|
+
return JSONResponse({"error": "invalid_parameter",
|
|
335
|
+
"detail": "mode must be merge or replace"}, status_code=400)
|
|
336
|
+
dry = request.query_params.get("dry_run", "false").strip().lower()
|
|
337
|
+
if dry not in _BOOLEANS:
|
|
338
|
+
return JSONResponse({"error": "invalid_parameter",
|
|
339
|
+
"detail": "dry_run must be true or false"}, status_code=400)
|
|
340
|
+
raw = await request.body() # over the route's size limit: 413
|
|
341
|
+
try:
|
|
342
|
+
# parsing is bounded, but a big file takes a moment: not on the event loop
|
|
343
|
+
plan = await asyncio.to_thread(importer.parse_mappings_file, raw, "")
|
|
344
|
+
except ConfigError as e:
|
|
345
|
+
return JSONResponse({"error": "invalid_import", "detail": str(e)}, status_code=400)
|
|
346
|
+
answer = {"mode": mode, "dry_run": _BOOLEANS[dry], "source": plan.source,
|
|
347
|
+
"count": len(plan.mappings), "warnings": plan.warnings}
|
|
348
|
+
if _BOOLEANS[dry]:
|
|
349
|
+
rows, problem = ops.import_preview(state, plan, mode)
|
|
350
|
+
return JSONResponse({**answer, "rows": rows, "summary": ops.import_counts(rows),
|
|
351
|
+
"valid": problem is None, "problem": problem, "applied": False})
|
|
352
|
+
try:
|
|
353
|
+
rows, _new, _backed_up = await ops.import_mappings(state, plan, mode)
|
|
354
|
+
except ConfigError as e:
|
|
355
|
+
return JSONResponse({**answer, "error": "validation", "detail": str(e),
|
|
356
|
+
"applied": False}, status_code=400)
|
|
357
|
+
count = ops.import_counts(rows)
|
|
358
|
+
# as the import form logs it (the REST API is English): the warnings stay on record
|
|
359
|
+
warnings = importer.log_lines(plan)
|
|
360
|
+
state.log(f"api: mappings imported ({mode}) from {plan.source}: {count['add']} added, "
|
|
361
|
+
f"{count['update']} updated, {count['same']} unchanged, {count['remove']} "
|
|
362
|
+
f"removed, {len(warnings)} warnings")
|
|
363
|
+
for w in warnings:
|
|
364
|
+
state.log(f"api: import warning: {w}")
|
|
365
|
+
state.audit(_client_ip(request), f"api_config_import_{mode}", importer.audit_detail(plan),
|
|
366
|
+
user=_caller(request))
|
|
367
|
+
return JSONResponse({**answer, "rows": rows, "summary": count, "valid": True,
|
|
368
|
+
"problem": None, "applied": True})
|
|
369
|
+
|
|
370
|
+
async def metrics(request):
|
|
371
|
+
return PlainTextResponse(await metrics_text(state), media_type=METRICS_TYPE)
|
|
372
|
+
|
|
373
|
+
async def status(request):
|
|
374
|
+
return JSONResponse({"mappings": state.supervisor.all_status()})
|
|
375
|
+
|
|
376
|
+
async def ports(request):
|
|
377
|
+
return JSONResponse({"ports": await state.ports.current()})
|
|
378
|
+
|
|
379
|
+
async def health(request):
|
|
380
|
+
return JSONResponse({"status": "ok", "mappings": len(state.config.mappings)})
|
|
381
|
+
|
|
382
|
+
async def info(request):
|
|
383
|
+
return JSONResponse({"version": __version__, "api": API_VERSION,
|
|
384
|
+
"features": list(API_FEATURES)})
|
|
385
|
+
|
|
386
|
+
async def openapi(request):
|
|
387
|
+
return JSONResponse(openapi_spec(state.config.admin_ui, proxy.base(request)))
|
|
388
|
+
|
|
389
|
+
return [
|
|
390
|
+
Route(f"{API_PREFIX}/health", health, methods=["GET"]),
|
|
391
|
+
Route(f"{API_PREFIX}/openapi.json", openapi, methods=["GET"]),
|
|
392
|
+
Route(f"{API_PREFIX}/info", info, methods=["GET"]),
|
|
393
|
+
Route(f"{API_PREFIX}/status", status, methods=["GET"]),
|
|
394
|
+
Route(f"{API_PREFIX}/metrics", metrics, methods=["GET"]),
|
|
395
|
+
Route(f"{API_PREFIX}/import", import_mappings, methods=["POST"],
|
|
396
|
+
max_body_size=importer.MAX_FILE_BYTES),
|
|
397
|
+
Route(f"{API_PREFIX}/ports", ports, methods=["GET"]),
|
|
398
|
+
Route(f"{API_PREFIX}/mappings", list_mappings, methods=["GET"]),
|
|
399
|
+
Route(f"{API_PREFIX}/mappings", create_mapping, methods=["POST"]),
|
|
400
|
+
Route(f"{API_PREFIX}/mappings/{{mid}}", get_mapping, methods=["GET"]),
|
|
401
|
+
Route(f"{API_PREFIX}/mappings/{{mid}}", update_mapping, methods=["PUT"]),
|
|
402
|
+
Route(f"{API_PREFIX}/mappings/{{mid}}", patch_mapping, methods=["PATCH"]),
|
|
403
|
+
Route(f"{API_PREFIX}/mappings/{{mid}}", delete_mapping, methods=["DELETE"]),
|
|
404
|
+
Route(f"{API_PREFIX}/mappings/{{mid}}/{{action}}", mapping_action, methods=["POST"]),
|
|
405
|
+
]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
# OpenAPI description
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# The config classes a mapping is made of, by their schema name in the spec
|
|
412
|
+
_MAPPING_CLASSES = {
|
|
413
|
+
"Mapping": config.MappingConfig, "SerialSettings": config.SerialSettings,
|
|
414
|
+
"SerialAdvanced": config.SerialAdvanced, "NetworkSettings": config.NetworkSettings,
|
|
415
|
+
"MappingOptions": config.MappingOptions, "MqttSettings": config.MqttSettings,
|
|
416
|
+
"ModbusPoll": config.ModbusPoll, "ModbusPoint": config.ModbusPoint,
|
|
417
|
+
}
|
|
418
|
+
# the values a field may have (what validate() accepts)
|
|
419
|
+
_ENUMS = {
|
|
420
|
+
("MappingConfig", "kind"): config.MAPPING_KINDS,
|
|
421
|
+
("SerialSettings", "bytesize"): config.BYTESIZES,
|
|
422
|
+
("SerialSettings", "parity"): config.PARITIES,
|
|
423
|
+
("SerialSettings", "stopbits"): config.STOPBITS,
|
|
424
|
+
("SerialSettings", "flowcontrol"): config.FLOWCONTROLS,
|
|
425
|
+
("SerialSettings", "rts_on_open"): config.LINE_STATES,
|
|
426
|
+
("SerialSettings", "dtr_on_open"): config.LINE_STATES,
|
|
427
|
+
("NetworkSettings", "mode"): config.NET_MODES,
|
|
428
|
+
("NetworkSettings", "protocol"): config.PROTOCOLS,
|
|
429
|
+
("MqttSettings", "qos"): (0, 1, 2),
|
|
430
|
+
("MqttSettings", "payload_format"): config.MQTT_FORMATS,
|
|
431
|
+
("MqttSettings", "line_end"): config.MQTT_LINE_ENDS,
|
|
432
|
+
("MqttSettings", "value_format"): config.MQTT_VALUE_FORMATS,
|
|
433
|
+
("ModbusPoint", "fn"): (1, 2, 3, 4),
|
|
434
|
+
("ModbusPoint", "dtype"): config.MODBUS_DTYPES,
|
|
435
|
+
("ModbusPoint", "word_order"): config.MODBUS_WORD_ORDERS,
|
|
436
|
+
}
|
|
437
|
+
_NOTES = {
|
|
438
|
+
("MappingConfig", "id"): "Assigned by the server (1-64 letters, digits or _); "
|
|
439
|
+
"ignored in a request body.",
|
|
440
|
+
("MappingConfig", "enabled"): "Run the mapping. Left out of a POST or PUT: true.",
|
|
441
|
+
("MappingConfig", "serial_b"): "The second serial port of a serialbridge mapping.",
|
|
442
|
+
("MappingConfig", "network"): "The network side of a net mapping.",
|
|
443
|
+
("SerialSettings", "port"): "The serial device (COM3, /dev/ttyUSB0, ...); required. "
|
|
444
|
+
"URLs (socket://, rfc2217://, ...) are refused.",
|
|
445
|
+
("SerialSettings", "match"): "The USB identity (vid, pid, serial_number) the device is "
|
|
446
|
+
"found by when its name changes; {} for none.",
|
|
447
|
+
("NetworkSettings", "bind_ip"): "The address a server or udp mapping listens on; "
|
|
448
|
+
"0.0.0.0 = every interface.",
|
|
449
|
+
("NetworkSettings", "allowed_client_ips"): "The clients (IP or CIDR) that may connect; "
|
|
450
|
+
"empty = any client.",
|
|
451
|
+
("NetworkSettings", "read_only"): "Clients only read: nothing they send reaches the device.",
|
|
452
|
+
("MqttSettings", "password"): "Masked as ******** for a viewer token; sent back so in a PUT "
|
|
453
|
+
"or PATCH, the stored one stays.",
|
|
454
|
+
("MqttSettings", "client_key_password"): "The password of an encrypted client_key. "
|
|
455
|
+
"Masked as ******** for a viewer token; sent back "
|
|
456
|
+
"so in a PUT or PATCH, the stored one stays.",
|
|
457
|
+
("NetworkSettings", "tls_key_password"): "The password of an encrypted tls_key. Masked as "
|
|
458
|
+
"******** for a viewer token; sent back so in a "
|
|
459
|
+
"PUT or PATCH, the stored one stays.",
|
|
460
|
+
}
|
|
461
|
+
_JSON_TYPES = {str: "string", int: "integer", float: "number", bool: "boolean"}
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _field_schema(cls, f, hint) -> dict:
|
|
465
|
+
"""The schema of one config dataclass field: a $ref for a nested config class, else
|
|
466
|
+
its JSON type with the default, the allowed values and a note where there are any."""
|
|
467
|
+
if dataclasses.is_dataclass(hint):
|
|
468
|
+
return {"$ref": f"#/components/schemas/{_schema_name(hint)}"}
|
|
469
|
+
origin = typing.get_origin(hint)
|
|
470
|
+
if origin is list:
|
|
471
|
+
(item,) = typing.get_args(hint)
|
|
472
|
+
items = ({"$ref": f"#/components/schemas/{_schema_name(item)}"}
|
|
473
|
+
if dataclasses.is_dataclass(item) else {"type": _JSON_TYPES.get(item, "string")})
|
|
474
|
+
out: dict = {"type": "array", "items": items}
|
|
475
|
+
elif origin is dict or hint is dict:
|
|
476
|
+
out = {"type": "object", "additionalProperties": True}
|
|
477
|
+
else:
|
|
478
|
+
out = {"type": _JSON_TYPES.get(hint, "string")}
|
|
479
|
+
if f.default is not dataclasses.MISSING:
|
|
480
|
+
out["default"] = f.default
|
|
481
|
+
elif f.default_factory is not dataclasses.MISSING:
|
|
482
|
+
out["default"] = f.default_factory()
|
|
483
|
+
if (cls.__name__, f.name) in _ENUMS:
|
|
484
|
+
out["enum"] = list(_ENUMS[cls.__name__, f.name])
|
|
485
|
+
if (cls.__name__, f.name) in _NOTES:
|
|
486
|
+
out["description"] = _NOTES[cls.__name__, f.name]
|
|
487
|
+
return out
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _schema_name(cls) -> str:
|
|
491
|
+
return next(name for name, c in _MAPPING_CLASSES.items() if c is cls)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def mapping_schemas() -> dict:
|
|
495
|
+
"""The schemas of a mapping and its parts, made from the config dataclasses, so the
|
|
496
|
+
spec names every field with its type and default. Open (additionalProperties): a
|
|
497
|
+
field added later must not break a client generated from an older spec."""
|
|
498
|
+
out = {}
|
|
499
|
+
for name, cls in _MAPPING_CLASSES.items():
|
|
500
|
+
hints = typing.get_type_hints(cls)
|
|
501
|
+
# id: below; input_notes: what reading it noted, never part of the JSON (to_dict)
|
|
502
|
+
props = {f.name: _field_schema(cls, f, hints[f.name]) for f in dataclasses.fields(cls)
|
|
503
|
+
if not (cls is config.MappingConfig and f.name in ("id", "input_notes"))}
|
|
504
|
+
if cls is config.MappingConfig:
|
|
505
|
+
props["id"] = {"type": "string", "readOnly": True,
|
|
506
|
+
"description": _NOTES["MappingConfig", "id"]}
|
|
507
|
+
props["warnings"] = {"type": "array", "items": {"type": "string"}, "readOnly": True,
|
|
508
|
+
"description": "In the answer to a create / update, when "
|
|
509
|
+
"there are any: keys that are no setting "
|
|
510
|
+
"(ignored) and settings that do nothing."}
|
|
511
|
+
props["status"] = {"allOf": [{"$ref": "#/components/schemas/MappingStatus"}],
|
|
512
|
+
"readOnly": True, "nullable": True,
|
|
513
|
+
"description": "Live state (null: the mapping has no runner)."}
|
|
514
|
+
out[name] = {"type": "object", "properties": props, "additionalProperties": True}
|
|
515
|
+
out["Mapping"]["description"] = (
|
|
516
|
+
"A serial<->network (kind net) or serial<->serial (kind serialbridge) mapping. In a "
|
|
517
|
+
"POST or PUT, every field left out gets the default shown here (PUT replaces the "
|
|
518
|
+
"whole mapping); PATCH changes only the fields it names. A value may be sent as text "
|
|
519
|
+
"(\"9600\", \"false\"); one that is not of the setting's type is refused (400). "
|
|
520
|
+
"The MQTT topics and payloads, the Modbus point keys and the Sparkplug B model: "
|
|
521
|
+
"docs/MODBUS-MQTT.md in the source tree.")
|
|
522
|
+
out["MqttSettings"]["description"] = (
|
|
523
|
+
"A mapping's MQTT connection: what it publishes and takes on which topic, and the "
|
|
524
|
+
"Sparkplug B model, in docs/MODBUS-MQTT.md.")
|
|
525
|
+
out["ModbusPoint"]["description"] = (
|
|
526
|
+
"A Modbus point polled (and written) by a gateway mapping: address 0-based, the "
|
|
527
|
+
"types, word orders and limits in docs/MODBUS-MQTT.md.")
|
|
528
|
+
return out
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _json(schema_ref: str, description: str) -> dict:
|
|
532
|
+
return {"description": description, "content": {"application/json": {
|
|
533
|
+
"schema": {"$ref": f"#/components/schemas/{schema_ref}"}}}}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def openapi_spec(admin, base: str = "") -> dict:
|
|
537
|
+
"""The OpenAPI 3.0 description of the REST API, written by hand (Starlette has no
|
|
538
|
+
auto-generation) except for the mapping schemas (mapping_schemas). Every route in
|
|
539
|
+
build_api_routes is in it (tests/test_rest_patch.py checks). `base`: the path prefix
|
|
540
|
+
the request came under (ser2netweb/web/proxy.py)."""
|
|
541
|
+
schemas = mapping_schemas()
|
|
542
|
+
schemas.update({
|
|
543
|
+
"MappingStatus": {"type": "object", "additionalProperties": True, "properties": {
|
|
544
|
+
"state": {"type": "string",
|
|
545
|
+
"description": "stopped, running, reconnecting, device-missing or error"},
|
|
546
|
+
"device": {"type": "string"},
|
|
547
|
+
"client_count": {"type": "integer"},
|
|
548
|
+
"clients": {"type": "array", "items": {"type": "object", "properties": {
|
|
549
|
+
"peer": {"type": "string"},
|
|
550
|
+
"duration": {"type": "string", "description": "connected for (text)"},
|
|
551
|
+
"connected_s": {"type": "integer", "description": "connected for (seconds)"},
|
|
552
|
+
"priority": {"type": "boolean"}}}},
|
|
553
|
+
"bytes_in": {"type": "integer", "description": "read from the serial device"},
|
|
554
|
+
"bytes_out": {"type": "integer", "description": "written to the serial device"},
|
|
555
|
+
"bytes_dropped": {"type": "integer"},
|
|
556
|
+
"reconnects": {"type": "integer"},
|
|
557
|
+
"dropped_clients": {"type": "integer"},
|
|
558
|
+
"queue_overflows": {"type": "integer"},
|
|
559
|
+
"last_error": {"type": "string"},
|
|
560
|
+
"trace_error": {"type": "string",
|
|
561
|
+
"description": "why the trace is not written now (\"\": it is)"},
|
|
562
|
+
"last_rx": {"type": "number", "nullable": True,
|
|
563
|
+
"description": "when the device last sent something (Unix time)"},
|
|
564
|
+
"rx_idle_s": {"type": "number", "nullable": True,
|
|
565
|
+
"description": "how long the device has sent nothing"},
|
|
566
|
+
"reply_wait_s": {"type": "number", "nullable": True,
|
|
567
|
+
"description": "how long what was sent to it has had no answer"}}},
|
|
568
|
+
"MappingList": {"type": "object", "properties": {
|
|
569
|
+
"mappings": {"type": "array", "items": {"$ref": "#/components/schemas/Mapping"}}}},
|
|
570
|
+
"StatusMap": {"type": "object", "properties": {"mappings": {
|
|
571
|
+
"type": "object", "description": "mapping id -> its live state",
|
|
572
|
+
"additionalProperties": {"$ref": "#/components/schemas/MappingStatus"}}}},
|
|
573
|
+
"PortList": {"type": "object", "properties": {"ports": {
|
|
574
|
+
"type": "array", "items": {"type": "object", "additionalProperties": True}}}},
|
|
575
|
+
"Health": {"type": "object", "properties": {"status": {"type": "string"},
|
|
576
|
+
"mappings": {"type": "integer"}}},
|
|
577
|
+
"ActionResult": {"type": "object", "properties": {
|
|
578
|
+
"id": {"type": "string"}, "action": {"type": "string"},
|
|
579
|
+
"status": {"allOf": [{"$ref": "#/components/schemas/MappingStatus"}],
|
|
580
|
+
"nullable": True},
|
|
581
|
+
"changed": {"type": "boolean",
|
|
582
|
+
"description": "false: it ran (or was stopped) already"},
|
|
583
|
+
"warning": {"type": "string",
|
|
584
|
+
"description": "a stop that config.json could not keep"}}},
|
|
585
|
+
"Info": {"type": "object", "properties": {
|
|
586
|
+
"version": {"type": "string", "description": "ser2netweb's version"},
|
|
587
|
+
"api": {"type": "string", "description": "this API's version (info.version)"},
|
|
588
|
+
"features": {"type": "array", "items": {"type": "string"},
|
|
589
|
+
"description": "what it offers beyond 1.0.0"}}},
|
|
590
|
+
"Deleted": {"type": "object", "properties": {"deleted": {"type": "string"}}},
|
|
591
|
+
"ImportResult": {"type": "object", "additionalProperties": True, "properties": {
|
|
592
|
+
"mode": {"type": "string", "enum": list(importer.MODES)},
|
|
593
|
+
"dry_run": {"type": "boolean"},
|
|
594
|
+
"source": {"type": "string", "description": "what the body was"},
|
|
595
|
+
"count": {"type": "integer", "description": "mappings in the body"},
|
|
596
|
+
"warnings": {"type": "array", "items": {"type": "string"}},
|
|
597
|
+
"rows": {"type": "array", "items": {"type": "object", "properties": {
|
|
598
|
+
"name": {"type": "string"}, "kind": {"type": "string"},
|
|
599
|
+
"action": {"type": "string", "enum": ["add", "update", "same", "remove"]},
|
|
600
|
+
"target": {"type": "string", "description": "the mapping it updates"}}}},
|
|
601
|
+
"summary": {"type": "object", "additionalProperties": {"type": "integer"}},
|
|
602
|
+
"valid": {"type": "boolean", "description": "the result can be saved"},
|
|
603
|
+
"problem": {"type": "string", "nullable": True,
|
|
604
|
+
"description": "why the result can't be saved"},
|
|
605
|
+
"applied": {"type": "boolean"}}},
|
|
606
|
+
"MergePatch": {"type": "object", "additionalProperties": True, "description": (
|
|
607
|
+
"An RFC 7396 JSON merge patch on a Mapping: an object merges into the stored "
|
|
608
|
+
"one, a list or a value replaces it, null resets the field to its default.")},
|
|
609
|
+
"Error": {"type": "object", "required": ["error"], "properties": {
|
|
610
|
+
"error": {"type": "string", "description": "a machine-readable code"},
|
|
611
|
+
"detail": {"type": "string", "description": "what is wrong (English)"}}},
|
|
612
|
+
})
|
|
613
|
+
responses = {
|
|
614
|
+
"BadRequest": _json("Error", "The request is not valid: a body that is not a JSON "
|
|
615
|
+
"object (invalid_json), a mapping or an import result "
|
|
616
|
+
"that is not valid or a mapping that can't be started, "
|
|
617
|
+
"e.g. on a serial port another one uses (validation), "
|
|
618
|
+
"an unknown action "
|
|
619
|
+
"(unknown_action) or query value (invalid_parameter), a "
|
|
620
|
+
"body that is nothing importable (invalid_import)."),
|
|
621
|
+
"Unauthorized": _json("Error", "No token, a wrong or expired one (unauthorized), or no API "
|
|
622
|
+
"token configured (api_token_not_configured)."),
|
|
623
|
+
"Forbidden": _json("Error", "The token's role may only read (forbidden)."),
|
|
624
|
+
"NotFound": _json("Error", "No mapping has this id (not_found)."),
|
|
625
|
+
"TooLarge": {"description": "The request body is larger than the server accepts.",
|
|
626
|
+
"content": {"text/plain": {"schema": {"type": "string"}}}},
|
|
627
|
+
"SetupIncomplete": _json("Error", "The first-run setup is not done yet "
|
|
628
|
+
"(setup_incomplete)."),
|
|
629
|
+
"PreconditionFailed": _json("Error", "If-Match names another revision of the mapping "
|
|
630
|
+
"(precondition_failed): GET it again."),
|
|
631
|
+
"SaveFailed": _json("Error", "config.json could not be written (save_failed): "
|
|
632
|
+
"nothing changed."),
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
def ref(name: str) -> dict:
|
|
636
|
+
return {"$ref": f"#/components/responses/{name}"}
|
|
637
|
+
|
|
638
|
+
def op(summary: str, ok: tuple, *errors: str, write: bool = False, body=None,
|
|
639
|
+
description: str = "", parameters=None) -> dict:
|
|
640
|
+
"""One operation: its 2xx answer, the errors it can give, and the token errors
|
|
641
|
+
every authenticated call can (403 for a call that changes something)."""
|
|
642
|
+
resp = {ok[0]: _json(ok[1], ok[2])}
|
|
643
|
+
for name in errors:
|
|
644
|
+
code = {"BadRequest": "400", "NotFound": "404", "PreconditionFailed": "412",
|
|
645
|
+
"TooLarge": "413", "SaveFailed": "500"}[name]
|
|
646
|
+
resp[code] = ref(name)
|
|
647
|
+
resp["401"] = ref("Unauthorized")
|
|
648
|
+
if write:
|
|
649
|
+
resp["403"] = ref("Forbidden")
|
|
650
|
+
resp["503"] = ref("SetupIncomplete")
|
|
651
|
+
out = {"summary": summary, "security": bearer, "responses": resp}
|
|
652
|
+
if description:
|
|
653
|
+
out["description"] = description
|
|
654
|
+
if body is not None:
|
|
655
|
+
out["requestBody"] = body
|
|
656
|
+
if parameters:
|
|
657
|
+
out["parameters"] = parameters
|
|
658
|
+
return out
|
|
659
|
+
|
|
660
|
+
def mapping_body(schema: str = "Mapping", types=("application/json",)) -> dict:
|
|
661
|
+
return {"required": True, "content": {
|
|
662
|
+
t: {"schema": {"$ref": f"#/components/schemas/{schema}"}} for t in types}}
|
|
663
|
+
|
|
664
|
+
bearer: list[dict[str, list[str]]] = [{"bearerAuth": []}]
|
|
665
|
+
mid = {"name": "mid", "in": "path", "required": True, "schema": {"type": "string"}}
|
|
666
|
+
if_match = {"name": "If-Match", "in": "header", "required": False,
|
|
667
|
+
"schema": {"type": "string"},
|
|
668
|
+
"description": "The ETag a GET returned (or *): change the mapping only if it "
|
|
669
|
+
"is still at that revision, else 412."}
|
|
670
|
+
return {
|
|
671
|
+
"openapi": "3.0.3",
|
|
672
|
+
"info": {
|
|
673
|
+
"title": "ser2netweb REST API",
|
|
674
|
+
"version": API_VERSION,
|
|
675
|
+
"description": "Manage serial<->network mappings, status and ports. "
|
|
676
|
+
"Authenticate with `Authorization: Bearer <token>` "
|
|
677
|
+
"(generate the token in Settings). A token with the viewer role "
|
|
678
|
+
"may only read; operator and admin tokens may also change. "
|
|
679
|
+
"Messages are in English.",
|
|
680
|
+
},
|
|
681
|
+
"servers": [{"url": base + API_PREFIX}],
|
|
682
|
+
"components": {
|
|
683
|
+
"securitySchemes": {
|
|
684
|
+
"bearerAuth": {"type": "http", "scheme": "bearer"},
|
|
685
|
+
},
|
|
686
|
+
"schemas": schemas,
|
|
687
|
+
"responses": responses,
|
|
688
|
+
},
|
|
689
|
+
"security": bearer,
|
|
690
|
+
"paths": {
|
|
691
|
+
"/health": {"get": {"summary": "Liveness probe (no auth)", "security": [],
|
|
692
|
+
"responses": {"200": _json("Health", "ok")}}},
|
|
693
|
+
"/openapi.json": {"get": {"summary": "This description (no auth)", "security": [],
|
|
694
|
+
"responses": {"200": {
|
|
695
|
+
"description": "the OpenAPI 3.0 document",
|
|
696
|
+
"content": {"application/json": {
|
|
697
|
+
"schema": {"type": "object"}}}}}}},
|
|
698
|
+
"/info": {"get": op("Software and API version, API features",
|
|
699
|
+
("200", "Info", "version, api, features"),
|
|
700
|
+
description="`version`: ser2netweb's version; `api`: this API's "
|
|
701
|
+
"version (info.version here); `features`: what it "
|
|
702
|
+
"offers beyond 1.0.0 (`warnings` and `changed` in "
|
|
703
|
+
"create / update / action answers, `if-match` on "
|
|
704
|
+
"PUT / DELETE).")},
|
|
705
|
+
"/status": {"get": op("Live status of all mappings", ("200", "StatusMap", "status map"))},
|
|
706
|
+
"/ports": {"get": op("Detected serial ports", ("200", "PortList", "port list"))},
|
|
707
|
+
"/metrics": {"get": {
|
|
708
|
+
"summary": "Prometheus metrics (the same as /metrics in a web UI session)",
|
|
709
|
+
"security": bearer,
|
|
710
|
+
"responses": {"200": {"description": "text exposition format 0.0.4",
|
|
711
|
+
"content": {"text/plain": {"schema": {"type": "string"}}}},
|
|
712
|
+
"401": ref("Unauthorized"), "503": ref("SetupIncomplete")}}},
|
|
713
|
+
"/import": {"post": op(
|
|
714
|
+
"Import many mappings in one change", ("200", "ImportResult", "imported, or "
|
|
715
|
+
"what a dry run would do"),
|
|
716
|
+
"BadRequest", "TooLarge", write=True,
|
|
717
|
+
description="The body: a mappings export ({\"mappings\": [...]}, also what "
|
|
718
|
+
"GET /mappings answers), a whole config.json (only its mappings) "
|
|
719
|
+
"or a classic ser2net.yaml / ser2net.conf. One save and one "
|
|
720
|
+
"backup; refused as a whole (400, nothing changed) when the "
|
|
721
|
+
"result is not valid. In merge mode, a mapping updated keeps the "
|
|
722
|
+
"settings the body doesn't have.",
|
|
723
|
+
body={"required": True, "content": {
|
|
724
|
+
"application/json": {"schema": {"type": "object"}},
|
|
725
|
+
"text/plain": {"schema": {"type": "string"}}}},
|
|
726
|
+
parameters=[
|
|
727
|
+
{"name": "mode", "in": "query", "schema": {
|
|
728
|
+
"type": "string", "enum": list(importer.MODES), "default": "merge"},
|
|
729
|
+
"description": "merge: a mapping with the same id, else the same name, is "
|
|
730
|
+
"updated, the others are added; replace: the body's "
|
|
731
|
+
"mappings replace all of them"},
|
|
732
|
+
{"name": "dry_run", "in": "query",
|
|
733
|
+
"schema": {"type": "boolean", "default": False},
|
|
734
|
+
"description": "true: answer what it would do; change nothing"}])},
|
|
735
|
+
"/mappings": {
|
|
736
|
+
"get": op("List mappings (config + status)", ("200", "MappingList", "mappings")),
|
|
737
|
+
"post": op("Create a mapping", ("201", "Mapping", "created (Location: its URL; "
|
|
738
|
+
"see `warnings`; ETag header)"),
|
|
739
|
+
"BadRequest", "TooLarge", "SaveFailed", write=True, body=mapping_body(),
|
|
740
|
+
description="Fields left out get their defaults; the server "
|
|
741
|
+
"assigns the id."),
|
|
742
|
+
},
|
|
743
|
+
"/mappings/{mid}": {
|
|
744
|
+
"parameters": [mid],
|
|
745
|
+
"get": op("Get a mapping", ("200", "Mapping", "mapping (ETag header)"), "NotFound",
|
|
746
|
+
description="The `ETag` header is the mapping's revision: send it "
|
|
747
|
+
"back in `If-Match` with a PUT or DELETE to change it "
|
|
748
|
+
"only if nobody changed it since."),
|
|
749
|
+
"put": op("Replace a mapping", ("200", "Mapping", "updated (see `warnings`; "
|
|
750
|
+
"ETag header)"), "BadRequest",
|
|
751
|
+
"NotFound", "PreconditionFailed", "TooLarge", "SaveFailed", write=True,
|
|
752
|
+
body=mapping_body(), parameters=[if_match],
|
|
753
|
+
description="Replaces the whole mapping: every field the body "
|
|
754
|
+
"leaves out gets its default (listen on 0.0.0.0, no "
|
|
755
|
+
"allow-list, not read-only, enabled, TLS/MQTT/trace "
|
|
756
|
+
"off, no USB match, kind net). GET it first and send "
|
|
757
|
+
"it all back changed, or use PATCH. The same settings "
|
|
758
|
+
"again change nothing: the mapping keeps running as it "
|
|
759
|
+
"is (`changed`: false). Without `If-Match` the mapping "
|
|
760
|
+
"is replaced whatever its revision."),
|
|
761
|
+
"patch": op("Change some fields of a mapping (JSON merge patch, RFC 7396)",
|
|
762
|
+
("200", "Mapping", "updated (see `warnings`; ETag header)"),
|
|
763
|
+
"BadRequest", "NotFound", "TooLarge", "SaveFailed", write=True,
|
|
764
|
+
body=mapping_body("MergePatch", ("application/merge-patch+json",
|
|
765
|
+
"application/json")),
|
|
766
|
+
description="Only the fields the body names change; null resets "
|
|
767
|
+
"one to its default. A patch that makes the mapping "
|
|
768
|
+
"invalid changes nothing."),
|
|
769
|
+
"delete": op("Delete a mapping", ("200", "Deleted", "deleted"), "NotFound",
|
|
770
|
+
"PreconditionFailed", "SaveFailed", write=True,
|
|
771
|
+
parameters=[if_match]),
|
|
772
|
+
},
|
|
773
|
+
"/mappings/{mid}/{action}": {
|
|
774
|
+
"parameters": [
|
|
775
|
+
mid,
|
|
776
|
+
{"name": "action", "in": "path", "required": True,
|
|
777
|
+
"schema": {"type": "string", "enum": ["start", "stop", "restart"]}},
|
|
778
|
+
],
|
|
779
|
+
"post": op("Start/stop/restart a mapping", ("200", "ActionResult", "action applied"),
|
|
780
|
+
"BadRequest", "NotFound", "SaveFailed", write=True,
|
|
781
|
+
description="start and stop are idempotent: a start of a mapping "
|
|
782
|
+
"that runs with its settings, or a stop of a stopped "
|
|
783
|
+
"one, changes nothing (`changed`: false); restart "
|
|
784
|
+
"always restarts. A start or restart that can't be "
|
|
785
|
+
"saved changes nothing (400 validation: e.g. its "
|
|
786
|
+
"serial port is in use by another mapping; 500 "
|
|
787
|
+
"save_failed: config.json can't be written). A stop "
|
|
788
|
+
"always stops; when config.json can't be written, "
|
|
789
|
+
"`warning` says it starts again at the next start of "
|
|
790
|
+
"ser2netweb."),
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
}
|