mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: various bug fixes
This commit is contained in:
parent
27bebd6083
commit
e91ff458c7
32 changed files with 825 additions and 219 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -83,9 +83,14 @@ class BotHandler:
|
|||
def _load_state(self):
|
||||
try:
|
||||
with open(self.state_file, encoding="utf-8") as f:
|
||||
self.bots_state = json.load(f)
|
||||
# Ensure all storage paths are absolute
|
||||
for entry in self.bots_state:
|
||||
loaded = json.load(f)
|
||||
if not isinstance(loaded, list):
|
||||
self.bots_state = []
|
||||
return
|
||||
kept = []
|
||||
for entry in loaded:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if "storage_dir" in entry:
|
||||
entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
|
||||
if entry.get("bot_config_dir"):
|
||||
|
|
@ -96,6 +101,14 @@ class BotHandler:
|
|||
entry["reticulum_config_dir"] = os.path.abspath(
|
||||
os.path.expanduser(entry["reticulum_config_dir"]),
|
||||
)
|
||||
if self._jailed_bot_dirs(entry) is None:
|
||||
logger.warning(
|
||||
"Dropping bot %s: storage path is outside the identity bots directory",
|
||||
entry.get("id"),
|
||||
)
|
||||
continue
|
||||
kept.append(entry)
|
||||
self.bots_state = kept
|
||||
except FileNotFoundError:
|
||||
self.bots_state = []
|
||||
except Exception:
|
||||
|
|
@ -198,10 +211,21 @@ class BotHandler:
|
|||
break
|
||||
if entry is None:
|
||||
raise ValueError(f"Unknown bot: {bot_id}")
|
||||
storage_dir = entry.get("storage_dir")
|
||||
jailed = self._jailed_bot_dirs(entry)
|
||||
if jailed is None:
|
||||
raise ValueError("invalid bot storage directory")
|
||||
storage_dir, _bot_config_dir = jailed
|
||||
path = BotHandler._subprocess_log_path(storage_dir)
|
||||
if not path:
|
||||
return {"log": None, "truncated": False, "total_bytes": 0}
|
||||
if os.path.exists(path):
|
||||
jailed_path = self._jailed_file_under(
|
||||
storage_dir,
|
||||
"meshchatx_bot_subprocess.log",
|
||||
)
|
||||
if jailed_path is None:
|
||||
raise ValueError("invalid bot storage directory")
|
||||
path = jailed_path
|
||||
try:
|
||||
total = os.path.getsize(path)
|
||||
except OSError:
|
||||
|
|
@ -528,9 +552,10 @@ class BotHandler:
|
|||
pid = entry.get("pid")
|
||||
if not pid or not self._is_pid_alive(pid):
|
||||
raise RuntimeError("bot is not running")
|
||||
sd = entry.get("storage_dir")
|
||||
if not sd:
|
||||
raise RuntimeError("bot has no storage directory")
|
||||
jailed = self._jailed_bot_dirs(entry)
|
||||
if jailed is None:
|
||||
raise RuntimeError("invalid bot storage directory")
|
||||
sd, _bot_config_dir = jailed
|
||||
req = os.path.join(sd, "meshchatx_request_announce")
|
||||
try:
|
||||
with open(req, "w", encoding="utf-8") as f:
|
||||
|
|
@ -550,6 +575,78 @@ class BotHandler:
|
|||
return None
|
||||
return real
|
||||
|
||||
def _jailed_bot_dirs(self, entry):
|
||||
"""Return jailed (storage_dir, bot_config_dir) or None if either path escapes."""
|
||||
if not entry:
|
||||
return None
|
||||
storage_dir = self._jailed_bot_storage_dir(entry.get("storage_dir"))
|
||||
if not storage_dir:
|
||||
return None
|
||||
raw_cfg = entry.get("bot_config_dir")
|
||||
if raw_cfg:
|
||||
bot_config_dir = self._jailed_bot_storage_dir(raw_cfg)
|
||||
if not bot_config_dir:
|
||||
return None
|
||||
else:
|
||||
bot_config_dir = os.path.join(storage_dir, "config")
|
||||
return storage_dir, bot_config_dir
|
||||
|
||||
def _jailed_file_under(self, root, *parts):
|
||||
if not root:
|
||||
return None
|
||||
candidate = os.path.join(root, *parts)
|
||||
if not os.path.exists(candidate):
|
||||
return None
|
||||
real = os.path.realpath(candidate)
|
||||
root_real = os.path.realpath(root)
|
||||
if real != root_real and not real.startswith(root_real + os.sep):
|
||||
return None
|
||||
return real
|
||||
|
||||
def get_bot_identity_path(self, bot_id):
|
||||
entry = None
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
jailed = self._jailed_bot_dirs(entry)
|
||||
if jailed is None:
|
||||
return None
|
||||
storage_dir, bot_config_dir = jailed
|
||||
|
||||
for path in (
|
||||
self._jailed_file_under(bot_config_dir, "identity"),
|
||||
self._jailed_file_under(bot_config_dir, "lxmf", "identity"),
|
||||
):
|
||||
if path:
|
||||
return path
|
||||
|
||||
reticulum_config_dir = entry.get("reticulum_config_dir")
|
||||
if reticulum_config_dir:
|
||||
allowed_shared = os.path.realpath(
|
||||
os.path.join(self.bot_reticulum_config_dir, "identity"),
|
||||
)
|
||||
candidate = os.path.realpath(
|
||||
os.path.join(os.path.expanduser(reticulum_config_dir), "identity"),
|
||||
)
|
||||
if candidate == allowed_shared and os.path.isfile(candidate):
|
||||
return candidate
|
||||
|
||||
for path in (
|
||||
self._jailed_file_under(storage_dir, "config", "identity"),
|
||||
self._jailed_file_under(storage_dir, "identity"),
|
||||
self._jailed_file_under(storage_dir, "config", "lxmf", "identity"),
|
||||
self._jailed_file_under(storage_dir, "lxmf", "identity"),
|
||||
):
|
||||
if path:
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def delete_bot(self, bot_id):
|
||||
# Stop it first
|
||||
self.stop_bot(bot_id)
|
||||
|
|
@ -580,56 +677,6 @@ class BotHandler:
|
|||
return True
|
||||
return False
|
||||
|
||||
def get_bot_identity_path(self, bot_id):
|
||||
entry = None
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
storage_dir = entry.get("storage_dir")
|
||||
if not storage_dir:
|
||||
return None
|
||||
|
||||
bot_config_dir = entry.get("bot_config_dir")
|
||||
if bot_config_dir:
|
||||
id_path_bot_cfg = os.path.join(bot_config_dir, "identity")
|
||||
if os.path.exists(id_path_bot_cfg):
|
||||
return id_path_bot_cfg
|
||||
id_path_lxmf_cfg = os.path.join(bot_config_dir, "lxmf", "identity")
|
||||
if os.path.exists(id_path_lxmf_cfg):
|
||||
return id_path_lxmf_cfg
|
||||
|
||||
reticulum_config_dir = entry.get("reticulum_config_dir")
|
||||
if reticulum_config_dir:
|
||||
id_path_shared = os.path.join(reticulum_config_dir, "identity")
|
||||
if os.path.exists(id_path_shared):
|
||||
return id_path_shared
|
||||
|
||||
# LXMFy stores identity in the 'config' subdirectory by default
|
||||
id_path = os.path.join(storage_dir, "config", "identity")
|
||||
if os.path.exists(id_path):
|
||||
return id_path
|
||||
|
||||
# Fallback to direct identity file if it was moved or configured differently
|
||||
id_path_alt = os.path.join(storage_dir, "identity")
|
||||
if os.path.exists(id_path_alt):
|
||||
return id_path_alt
|
||||
|
||||
# LXMFy may nest inside config/lxmf
|
||||
id_path_lxmf = os.path.join(storage_dir, "config", "lxmf", "identity")
|
||||
if os.path.exists(id_path_lxmf):
|
||||
return id_path_lxmf
|
||||
|
||||
id_path_lxmf_root = os.path.join(storage_dir, "lxmf", "identity")
|
||||
if os.path.exists(id_path_lxmf_root):
|
||||
return id_path_lxmf_root
|
||||
|
||||
return None
|
||||
|
||||
def _load_identity_for_bot(self, bot_id):
|
||||
identity_path = self.get_bot_identity_path(bot_id)
|
||||
if not identity_path:
|
||||
|
|
|
|||
|
|
@ -346,9 +346,17 @@ def register_bots_routes(routes, app):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/bots/export")
|
||||
@routes.post("/api/v1/bots/export")
|
||||
async def bots_export(request):
|
||||
bot_id = request.query.get("bot_id")
|
||||
bot_id = None
|
||||
try:
|
||||
data = await request.json()
|
||||
if isinstance(data, dict):
|
||||
bot_id = data.get("bot_id")
|
||||
except Exception:
|
||||
bot_id = None
|
||||
if not bot_id:
|
||||
bot_id = request.query.get("bot_id")
|
||||
|
||||
if not bot_id:
|
||||
return web.json_response(
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
|
|||
|
||||
def register_identities_routes(routes, app):
|
||||
|
||||
@routes.get("/api/v1/identity/backup/download")
|
||||
@routes.post("/api/v1/identity/backup/download")
|
||||
async def identity_backup_download(request):
|
||||
try:
|
||||
info = app.backup_identity()
|
||||
|
|
@ -155,7 +155,7 @@ def register_identities_routes(routes, app):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/identity/backup/base32")
|
||||
@routes.post("/api/v1/identity/backup/base32")
|
||||
async def identity_backup_base32(request):
|
||||
try:
|
||||
return web.json_response(
|
||||
|
|
@ -266,7 +266,7 @@ def register_identities_routes(routes, app):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/identities/export-all")
|
||||
@routes.post("/api/v1/identities/export-all")
|
||||
async def identities_export_all(request):
|
||||
try:
|
||||
all_bytes = app.identity_manager.get_all_identity_backup_bytes()
|
||||
|
|
|
|||
|
|
@ -131,66 +131,93 @@ from meshchatx.src.backend.http.meshchat_names import ( # noqa: F401
|
|||
zipfile,
|
||||
)
|
||||
|
||||
# Same ceiling as RNProbeHandler.MAX_TIMEOUT_S
|
||||
PATH_PROBE_MIN_TIMEOUT_S = 1
|
||||
PATH_PROBE_MAX_TIMEOUT_S = 600
|
||||
PATH_WAIT_REQUIRES_POST_MESSAGE = (
|
||||
"Waiting for a path requires POST. GET /path is a snapshot only."
|
||||
)
|
||||
|
||||
|
||||
def parse_path_probe_timeout(raw, *, default=15):
|
||||
if raw is None or raw == "":
|
||||
raw = default
|
||||
try:
|
||||
timeout_seconds = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None, "Timeout must be an integer."
|
||||
if (
|
||||
timeout_seconds < PATH_PROBE_MIN_TIMEOUT_S
|
||||
or timeout_seconds > PATH_PROBE_MAX_TIMEOUT_S
|
||||
):
|
||||
return None, (
|
||||
f"Timeout must be between {PATH_PROBE_MIN_TIMEOUT_S} and "
|
||||
f"{PATH_PROBE_MAX_TIMEOUT_S} seconds."
|
||||
)
|
||||
return timeout_seconds, None
|
||||
|
||||
|
||||
async def read_path_probe_timeout_raw(request, default=15):
|
||||
query = getattr(request, "query", None) or {}
|
||||
if "timeout" in query:
|
||||
return query.get("timeout")
|
||||
method = str(getattr(request, "method", "GET") or "GET").upper()
|
||||
if method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = None
|
||||
if isinstance(body, dict) and body.get("timeout") is not None:
|
||||
return body.get("timeout")
|
||||
return default
|
||||
|
||||
|
||||
def local_destination_hashes(app):
|
||||
hashes: set[str] = set()
|
||||
with contextlib.suppress(Exception):
|
||||
if app.current_context and app.current_context.identity:
|
||||
hashes.add(app.current_context.identity.hash.hex())
|
||||
with contextlib.suppress(Exception):
|
||||
if app.local_lxmf_destination is not None:
|
||||
hashes.add(app.local_lxmf_destination.hash.hex())
|
||||
with contextlib.suppress(Exception):
|
||||
if app.current_context and app.current_context.message_router:
|
||||
pdest = app.current_context.message_router.propagation_destination
|
||||
if pdest is not None and getattr(pdest, "hash", None):
|
||||
hashes.add(pdest.hash.hex())
|
||||
return hashes
|
||||
|
||||
|
||||
def register_path_probe_routes(routes, app):
|
||||
|
||||
# get path to destination
|
||||
@routes.get("/api/v1/destination/{destination_hash}/path")
|
||||
async def destination_path(request):
|
||||
# get path params
|
||||
destination_hash = request.match_info.get("destination_hash", "")
|
||||
def maybe_resend_failed_for_current(destination_hash_str):
|
||||
ctx = app.current_context
|
||||
if (
|
||||
ctx is not None
|
||||
and ctx.running
|
||||
and ctx.config.auto_resend_failed_messages_when_announce_received.get()
|
||||
):
|
||||
AsyncUtils.run_async(
|
||||
app.resend_failed_messages_for_destination(
|
||||
destination_hash_str,
|
||||
context=ctx,
|
||||
),
|
||||
)
|
||||
|
||||
# convert destination hash to bytes
|
||||
destination_hash = bytes.fromhex(destination_hash)
|
||||
destination_hash_hex = destination_hash.hex()
|
||||
local_hashes: set[str] = set()
|
||||
with contextlib.suppress(Exception):
|
||||
if app.current_context and app.current_context.identity:
|
||||
local_hashes.add(app.current_context.identity.hash.hex())
|
||||
with contextlib.suppress(Exception):
|
||||
if app.local_lxmf_destination is not None:
|
||||
local_hashes.add(app.local_lxmf_destination.hash.hex())
|
||||
with contextlib.suppress(Exception):
|
||||
if app.current_context and app.current_context.message_router:
|
||||
pdest = app.current_context.message_router.propagation_destination
|
||||
if pdest is not None and getattr(pdest, "hash", None):
|
||||
local_hashes.add(pdest.hash.hex())
|
||||
|
||||
if destination_hash_hex in local_hashes:
|
||||
return web.json_response(
|
||||
{
|
||||
"path": {
|
||||
"hops": 0,
|
||||
"next_hop": destination_hash_hex,
|
||||
"next_hop_interface": "Local",
|
||||
},
|
||||
"path_stale": False,
|
||||
"path_unresponsive": False,
|
||||
def local_path_response(destination_hash_hex):
|
||||
return web.json_response(
|
||||
{
|
||||
"path": {
|
||||
"hops": 0,
|
||||
"next_hop": destination_hash_hex,
|
||||
"next_hop_interface": "Local",
|
||||
},
|
||||
)
|
||||
"path_stale": False,
|
||||
"path_unresponsive": False,
|
||||
},
|
||||
)
|
||||
|
||||
# check if user wants to request the path from the network right now
|
||||
request_query_param = request.query.get("request", "false")
|
||||
should_request_now = request_query_param in ("true", "1")
|
||||
if should_request_now:
|
||||
# determine how long we should wait for a path response
|
||||
timeout_seconds = int(request.query.get("timeout", 15))
|
||||
timeout_after_seconds = time.time() + timeout_seconds
|
||||
|
||||
reticulum = app.reticulum if hasattr(app, "reticulum") else None
|
||||
reticulum_pathfinding.prepare_fresh_path_request(
|
||||
reticulum,
|
||||
destination_hash,
|
||||
)
|
||||
|
||||
# wait until we have a path, or give up after the configured timeout
|
||||
while (
|
||||
not RNS.Transport.has_path(destination_hash)
|
||||
and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# ensure path is known
|
||||
def destination_path_snapshot(destination_hash):
|
||||
if not RNS.Transport.has_path(destination_hash):
|
||||
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
|
||||
return web.json_response(
|
||||
|
|
@ -200,7 +227,6 @@ def register_path_probe_routes(routes, app):
|
|||
},
|
||||
)
|
||||
|
||||
# determine next hop and hop count
|
||||
hops = RNS.Transport.hops_to(destination_hash)
|
||||
if not isinstance(hops, int):
|
||||
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
|
||||
|
|
@ -219,7 +245,6 @@ def register_path_probe_routes(routes, app):
|
|||
):
|
||||
next_hop_bytes = None
|
||||
|
||||
# ensure next hop provided
|
||||
if next_hop_bytes is None:
|
||||
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
|
||||
return web.json_response(
|
||||
|
|
@ -248,6 +273,62 @@ def register_path_probe_routes(routes, app):
|
|||
},
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/destination/{destination_hash}/path")
|
||||
async def destination_path(request):
|
||||
destination_hash = request.match_info.get("destination_hash", "")
|
||||
destination_hash = bytes.fromhex(destination_hash)
|
||||
destination_hash_hex = destination_hash.hex()
|
||||
|
||||
request_query_param = request.query.get("request", "false")
|
||||
if request_query_param in ("true", "1"):
|
||||
return web.json_response(
|
||||
{"message": PATH_WAIT_REQUIRES_POST_MESSAGE},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if destination_hash_hex in local_destination_hashes(app):
|
||||
return local_path_response(destination_hash_hex)
|
||||
|
||||
return destination_path_snapshot(destination_hash)
|
||||
|
||||
@routes.post("/api/v1/destination/{destination_hash}/path")
|
||||
async def destination_path_wait(request):
|
||||
destination_hash = request.match_info.get("destination_hash", "")
|
||||
try:
|
||||
destination_hash_bytes = bytes.fromhex(destination_hash)
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
{"message": "invalid destination hash"},
|
||||
status=400,
|
||||
)
|
||||
destination_hash_hex = destination_hash_bytes.hex()
|
||||
|
||||
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
|
||||
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
||||
if timeout_error:
|
||||
return web.json_response({"message": timeout_error}, status=400)
|
||||
|
||||
if destination_hash_hex in local_destination_hashes(app):
|
||||
return local_path_response(destination_hash_hex)
|
||||
|
||||
timeout_after_seconds = time.time() + timeout_seconds
|
||||
reticulum = app.reticulum if hasattr(app, "reticulum") else None
|
||||
reticulum_pathfinding.prepare_fresh_path_request(
|
||||
reticulum,
|
||||
destination_hash_bytes,
|
||||
)
|
||||
|
||||
while (
|
||||
not RNS.Transport.has_path(destination_hash_bytes)
|
||||
and time.time() < timeout_after_seconds
|
||||
):
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
if RNS.Transport.has_path(destination_hash_bytes):
|
||||
maybe_resend_failed_for_current(destination_hash_hex)
|
||||
|
||||
return destination_path_snapshot(destination_hash_bytes)
|
||||
|
||||
# drop path to destination
|
||||
|
||||
# drop path to destination
|
||||
|
|
@ -290,19 +371,8 @@ def register_path_probe_routes(routes, app):
|
|||
destination_hash_bytes,
|
||||
)
|
||||
|
||||
# if path is already available, resend failed messages for this destination
|
||||
if RNS.Transport.has_path(destination_hash_bytes):
|
||||
for _ctx in list(app.contexts.values()):
|
||||
if (
|
||||
_ctx.running
|
||||
and _ctx.config.auto_resend_failed_messages_when_announce_received.get()
|
||||
):
|
||||
AsyncUtils.run_async(
|
||||
app.resend_failed_messages_for_destination(
|
||||
destination_hash,
|
||||
context=_ctx,
|
||||
),
|
||||
)
|
||||
maybe_resend_failed_for_current(destination_hash)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
|
@ -400,7 +470,7 @@ def register_path_probe_routes(routes, app):
|
|||
# this allows us to ping/probe any active lxmf.delivery destination and get rtt/snr/rssi data on demand
|
||||
# https://github.com/markqvist/LXMF/blob/9ff76c0473e9d4107e079f266dd08144bb74c7c8/LXMF/LXMRouter.py#L234
|
||||
# https://github.com/markqvist/LXMF/blob/9ff76c0473e9d4107e079f266dd08144bb74c7c8/LXMF/LXMRouter.py#L1374
|
||||
@routes.get("/api/v1/ping/{destination_hash}/lxmf.delivery")
|
||||
@routes.post("/api/v1/ping/{destination_hash}/lxmf.delivery")
|
||||
async def ping_lxmf_delivery(request):
|
||||
# get path params
|
||||
destination_hash_str = request.match_info.get("destination_hash", "")
|
||||
|
|
@ -413,16 +483,11 @@ def register_path_probe_routes(routes, app):
|
|||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
timeout_seconds = int(request.query.get("timeout", 15))
|
||||
except (TypeError, ValueError):
|
||||
timeout_raw = await read_path_probe_timeout_raw(request, default=15)
|
||||
timeout_seconds, timeout_error = parse_path_probe_timeout(timeout_raw)
|
||||
if timeout_error:
|
||||
return web.json_response(
|
||||
{"message": "Ping failed. Timeout must be an integer."},
|
||||
status=400,
|
||||
)
|
||||
if timeout_seconds < 1:
|
||||
return web.json_response(
|
||||
{"message": "Ping failed. Timeout must be at least 1 second."},
|
||||
{"message": f"Ping failed. {timeout_error}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
|
|
@ -513,18 +578,7 @@ def register_path_probe_routes(routes, app):
|
|||
rtt_milliseconds = round(rtt * 1000, 3)
|
||||
rtt_duration_string = f"{rtt_milliseconds} ms"
|
||||
|
||||
# resend any previously failed messages to this destination now that path is available
|
||||
for _ctx in list(app.contexts.values()):
|
||||
if (
|
||||
_ctx.running
|
||||
and _ctx.config.auto_resend_failed_messages_when_announce_received.get()
|
||||
):
|
||||
AsyncUtils.run_async(
|
||||
app.resend_failed_messages_for_destination(
|
||||
destination_hash_str,
|
||||
context=_ctx,
|
||||
),
|
||||
)
|
||||
maybe_resend_failed_for_current(destination_hash_str)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -388,11 +388,15 @@ export default {
|
|||
const pingToastKey = "conversation-ping";
|
||||
ToastUtils.loading(this.$t("messages.ping_in_progress"), 0, pingToastKey);
|
||||
try {
|
||||
const response = await window.api.get(`/api/v1/ping/${destinationHash}/lxmf.delivery`, {
|
||||
params: {
|
||||
timeout: 30,
|
||||
},
|
||||
});
|
||||
const response = await window.api.post(
|
||||
`/api/v1/ping/${destinationHash}/lxmf.delivery`,
|
||||
{},
|
||||
{
|
||||
params: {
|
||||
timeout: 30,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pingResult = response.data.ping_result;
|
||||
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
<div>
|
||||
<label class="glass-label">{{ $t("ping.timeout_seconds") }}</label>
|
||||
<input v-model="timeout" type="number" min="1" class="input-field" />
|
||||
<input v-model="timeout" type="number" min="1" max="600" class="input-field" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -197,7 +197,7 @@ export default {
|
|||
}
|
||||
|
||||
// simple check to ensure destination hash is valid
|
||||
if (this.timeout == null || this.timeout < 1) {
|
||||
if (this.timeout == null || this.timeout < 1 || this.timeout > 600) {
|
||||
DialogUtils.alert(this.$t("ping.timeout_must_be_number"));
|
||||
return;
|
||||
}
|
||||
|
|
@ -238,12 +238,16 @@ export default {
|
|||
this.seq++;
|
||||
|
||||
// ping destination
|
||||
const response = await window.api.get(`/api/v1/ping/${this.destinationHash}/lxmf.delivery`, {
|
||||
signal: this.abortController.signal,
|
||||
params: {
|
||||
timeout: this.timeout,
|
||||
},
|
||||
});
|
||||
const response = await window.api.post(
|
||||
`/api/v1/ping/${this.destinationHash}/lxmf.delivery`,
|
||||
{},
|
||||
{
|
||||
signal: this.abortController.signal,
|
||||
params: {
|
||||
timeout: this.timeout,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const pingResult = response.data.ping_result;
|
||||
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
|
||||
|
|
|
|||
|
|
@ -524,7 +524,7 @@ import Utils from "../../js/Utils";
|
|||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import { copyTextToClipboard, readTextFromClipboard } from "../../js/clipboardUtils.js";
|
||||
import { getDestinationPath } from "../../js/reticulumPathfinding.js";
|
||||
import { postDestinationPath } from "../../js/reticulumPathfinding.js";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
|
||||
import {
|
||||
|
|
@ -954,8 +954,7 @@ export default {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
const response = await getDestinationPath(window.api, hash, {
|
||||
request: "1",
|
||||
const response = await postDestinationPath(window.api, hash, {
|
||||
timeout: 4,
|
||||
});
|
||||
this.nodePathsByHash = {
|
||||
|
|
|
|||
|
|
@ -516,9 +516,13 @@ export default {
|
|||
},
|
||||
async downloadIdentityFile() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/identity/backup/download", {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const response = await window.api.post(
|
||||
"/api/v1/identity/backup/download",
|
||||
{},
|
||||
{
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
);
|
||||
await DownloadUtils.downloadFromApiResponse(response, "identity");
|
||||
ToastUtils.success(this.$t("identities.identity_exported"));
|
||||
} catch {
|
||||
|
|
@ -527,7 +531,7 @@ export default {
|
|||
},
|
||||
async copyIdentityBase32() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/identity/backup/base32");
|
||||
const response = await window.api.post("/api/v1/identity/backup/base32");
|
||||
const base32 = response.data?.identity_base32 ?? "";
|
||||
if (!base32) {
|
||||
ToastUtils.error(this.$t("identities.no_identity_available"));
|
||||
|
|
@ -541,9 +545,13 @@ export default {
|
|||
},
|
||||
async downloadAllIdentities() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/identities/export-all", {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
const response = await window.api.post(
|
||||
"/api/v1/identities/export-all",
|
||||
{},
|
||||
{
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
);
|
||||
await DownloadUtils.downloadFromApiResponse(response, "identities_export.zip");
|
||||
ToastUtils.success(this.$t("identities.export_all_success"));
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@
|
|||
|
||||
<script>
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import DownloadUtils from "../../js/DownloadUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToolsPageHeader from "./ToolsPageHeader.vue";
|
||||
|
||||
|
|
@ -533,8 +534,17 @@ export default {
|
|||
ToastUtils.error(this.$t("bots.failed_to_delete"));
|
||||
}
|
||||
},
|
||||
exportIdentity(botId) {
|
||||
window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
|
||||
async exportIdentity(botId) {
|
||||
try {
|
||||
const response = await window.api.post(
|
||||
"/api/v1/bots/export",
|
||||
{ bot_id: botId },
|
||||
{ responseType: "arraybuffer" }
|
||||
);
|
||||
await DownloadUtils.downloadFromApiResponse(response, `bot_${botId}_identity`);
|
||||
} catch (e) {
|
||||
ToastUtils.error(e.response?.data?.message || this.$t("bots.export_failed"));
|
||||
}
|
||||
},
|
||||
startEditName(bot) {
|
||||
this.editingBotId = bot.id;
|
||||
|
|
|
|||
|
|
@ -74,20 +74,29 @@ export async function warmPathIfNeeded(api, hash, snapshot) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Snapshot-only. Waiting for a path uses postDestinationPath.
|
||||
*
|
||||
* @param {import("axios").AxiosInstance} api
|
||||
* @param {string} hash
|
||||
* @param {{ request?: "0" | "1" | boolean, timeout?: number } & Record<string, string | number | boolean | undefined>} [params]
|
||||
* @param {Record<string, string | number | boolean | undefined>} [params]
|
||||
*/
|
||||
export function getDestinationPath(api, hash, params) {
|
||||
const q = { ...params };
|
||||
if (q.request === true) {
|
||||
q.request = "1";
|
||||
} else if (q.request === false) {
|
||||
q.request = "0";
|
||||
}
|
||||
delete q.request;
|
||||
return api.get(destinationPath(hash), { params: q });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("axios").AxiosInstance} api
|
||||
* @param {string} hash
|
||||
* @param {{ timeout?: number }} [options]
|
||||
*/
|
||||
export function postDestinationPath(api, hash, options) {
|
||||
const timeout = options?.timeout;
|
||||
const params = timeout == null ? {} : { timeout };
|
||||
return api.post(destinationPath(hash), {}, { params });
|
||||
}
|
||||
|
||||
export function postRequestPath(api, hash) {
|
||||
return api.post(`/api/v1/destination/${hash}/request-path`);
|
||||
}
|
||||
|
|
@ -113,10 +122,7 @@ export async function runDestinationPathFinder(api, hash, mode, options) {
|
|||
return { ok: true, path: null };
|
||||
}
|
||||
if (mode === "force") {
|
||||
const res = await getDestinationPath(api, hash, {
|
||||
request: "1",
|
||||
timeout: forceTimeout,
|
||||
});
|
||||
const res = await postDestinationPath(api, hash, { timeout: forceTimeout });
|
||||
return { ok: true, path: res.data?.path ?? null };
|
||||
}
|
||||
if (mode === "drop_then_request") {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Bot konnte nicht gestoppt werden",
|
||||
"delete_bot": "Bot löschen",
|
||||
"export_identity": "Identität exportieren",
|
||||
"export_failed": "Bot-Identität konnte nicht exportiert werden",
|
||||
"bot_deleted": "Bot erfolgreich gelöscht",
|
||||
"failed_to_delete": "Bot konnte nicht gelöscht werden",
|
||||
"more_bots_coming": "Weitere Bots folgen in Kürze!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Failed to stop bot",
|
||||
"delete_bot": "Delete Bot",
|
||||
"export_identity": "Export Identity",
|
||||
"export_failed": "Could not export bot identity",
|
||||
"bot_deleted": "Bot deleted successfully",
|
||||
"failed_to_delete": "Failed to delete bot",
|
||||
"more_bots_coming": "More bots coming soon!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Error al detener bot",
|
||||
"delete_bot": "Eliminar bot",
|
||||
"export_identity": "Exportar identidad",
|
||||
"export_failed": "No se pudo exportar la identidad del bot",
|
||||
"bot_deleted": "Bot eliminado con éxito",
|
||||
"failed_to_delete": "Error al eliminar bot",
|
||||
"more_bots_coming": "¡Más bots pronto!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Botin pysäytys epäonnistui",
|
||||
"delete_bot": "Poista botti",
|
||||
"export_identity": "Vie identiteetti",
|
||||
"export_failed": "Botin identiteettiä ei voitu viedä",
|
||||
"bot_deleted": "Botin poisto onnistui",
|
||||
"failed_to_delete": "Botin poisto epäonnistui",
|
||||
"more_bots_coming": "Lisää botteja tulossa!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Échec de l'arrêt du robot",
|
||||
"delete_bot": "Supprimer Bot",
|
||||
"export_identity": "Identité d'exportation",
|
||||
"export_failed": "Impossible d'exporter l'identité du bot",
|
||||
"bot_deleted": "Bot supprimé avec succès",
|
||||
"failed_to_delete": "Impossible de supprimer le bot",
|
||||
"more_bots_coming": "D'autres robots arrivent bientôt !",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Impossibile fermare il bot",
|
||||
"delete_bot": "Elimina Bot",
|
||||
"export_identity": "Esporta Identità",
|
||||
"export_failed": "Impossibile esportare l'identità del bot",
|
||||
"bot_deleted": "Bot eliminato con successo",
|
||||
"failed_to_delete": "Impossibile eliminare il bot",
|
||||
"more_bots_coming": "Altri bot in arrivo!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Stoppen van bot is mislukt",
|
||||
"delete_bot": "Bot verwijderen",
|
||||
"export_identity": "Identiteit exporteren",
|
||||
"export_failed": "Botidentiteit kon niet worden geëxporteerd",
|
||||
"bot_deleted": "Bot is succesvol verwijderd",
|
||||
"failed_to_delete": "Verwijderen van bot is mislukt",
|
||||
"more_bots_coming": "Er komen nog meer bots aan.",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "Не удалось остановить бота",
|
||||
"delete_bot": "Удалить бота",
|
||||
"export_identity": "Экспорт личности",
|
||||
"export_failed": "Не удалось экспортировать личность бота",
|
||||
"bot_deleted": "Бот успешно удален",
|
||||
"failed_to_delete": "Не удалось удалить бота",
|
||||
"more_bots_coming": "Скоро появятся новые боты!",
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"failed_to_stop": "停止机器人失败",
|
||||
"delete_bot": "删除机器人",
|
||||
"export_identity": "导出身份",
|
||||
"export_failed": "无法导出机器人身份",
|
||||
"bot_deleted": "机器人删除成功",
|
||||
"failed_to_delete": "删除机器人失败",
|
||||
"more_bots_coming": "更多机器人即将推出!",
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@
|
|||
"path": "/api/v1/bots/delete"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/bots/export"
|
||||
},
|
||||
{
|
||||
|
|
@ -136,6 +136,10 @@
|
|||
"method": "PATCH",
|
||||
"path": "/api/v1/config"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/database/auto-recover"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/database/backup"
|
||||
|
|
@ -164,10 +168,6 @@
|
|||
"method": "POST",
|
||||
"path": "/api/v1/database/recover"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/database/auto-recover"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/database/restore"
|
||||
|
|
@ -220,6 +220,10 @@
|
|||
"method": "GET",
|
||||
"path": "/api/v1/destination/{destination_hash}/path"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/destination/{destination_hash}/path"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/v1/destination/{destination_hash}/request-path"
|
||||
|
|
@ -433,7 +437,7 @@
|
|||
"path": "/api/v1/identities/create"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/identities/export-all"
|
||||
},
|
||||
{
|
||||
|
|
@ -445,11 +449,11 @@
|
|||
"path": "/api/v1/identities/{identity_hash}"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/identity/backup/base32"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/identity/backup/download"
|
||||
},
|
||||
{
|
||||
|
|
@ -585,11 +589,11 @@
|
|||
"path": "/api/v1/lxmf/propagation-node/stop"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/lxmf/propagation-node/stop-sync"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/lxmf/propagation-node/sync"
|
||||
},
|
||||
{
|
||||
|
|
@ -885,7 +889,7 @@
|
|||
"path": "/api/v1/path-table"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/ping/{destination_hash}/lxmf.delivery"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
|
|||
),
|
||||
HttpJsonContract("GET", "/api/v1/identities", IDENTITIES_LIST_SCHEMA),
|
||||
HttpJsonContract(
|
||||
"GET",
|
||||
"POST",
|
||||
"/api/v1/identity/backup/base32",
|
||||
IDENTITY_BACKUP_BASE32_SCHEMA,
|
||||
),
|
||||
|
|
@ -661,7 +661,6 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
|
|||
"/api/v1/telephone/voicemails/{id}/audio",
|
||||
"/api/v1/lxmf/propagation-node/sync",
|
||||
"/api/v1/lxmf/propagation-node/stop-sync",
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
)
|
||||
|
||||
_HTTP_JSON_GET_EXCLUDED_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
|
|
|
|||
|
|
@ -69,14 +69,18 @@ def test_bot_handler_env_override_wins_over_app_reticulum_config_dir(
|
|||
|
||||
def test_bot_handler_load_save_state(temp_identity_dir):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
test_state = [{"id": "bot1", "enabled": True, "storage_dir": "some/path"}]
|
||||
storage = os.path.join(handler.bots_dir, "bot1")
|
||||
os.makedirs(storage, exist_ok=True)
|
||||
test_state = [{"id": "bot1", "enabled": True, "storage_dir": storage}]
|
||||
handler.bots_state = test_state
|
||||
handler._save_state()
|
||||
|
||||
# New handler instance to load state
|
||||
handler2 = BotHandler(temp_identity_dir)
|
||||
assert len(handler2.bots_state) == 1
|
||||
assert handler2.bots_state[0]["id"] == "bot1"
|
||||
assert os.path.realpath(handler2.bots_state[0]["storage_dir"]) == os.path.realpath(
|
||||
storage,
|
||||
)
|
||||
|
||||
|
||||
def test_get_available_templates(temp_identity_dir):
|
||||
|
|
@ -319,3 +323,99 @@ def test_read_subprocess_log_unknown_bot(temp_identity_dir):
|
|||
handler = BotHandler(temp_identity_dir)
|
||||
with pytest.raises(ValueError, match="Unknown bot"):
|
||||
handler.read_subprocess_log("nope")
|
||||
|
||||
|
||||
def _outside_bait_identity(tmp_path):
|
||||
outside = tmp_path / "outside-bot"
|
||||
outside.mkdir()
|
||||
bait = outside / "identity"
|
||||
bait.write_bytes(b"SECRET_BAIT_BYTES")
|
||||
log_path = outside / "meshchatx_bot_subprocess.log"
|
||||
log_path.write_text("OUTSIDE_LOG\n", encoding="utf-8")
|
||||
return outside, bait, log_path
|
||||
|
||||
|
||||
def test_get_bot_identity_path_rejects_escaped_storage(temp_identity_dir, tmp_path):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
outside, bait, _log = _outside_bait_identity(tmp_path)
|
||||
bot_id = "escaped"
|
||||
handler.bots_state = [
|
||||
{
|
||||
"id": bot_id,
|
||||
"storage_dir": str(outside),
|
||||
"bot_config_dir": str(outside),
|
||||
},
|
||||
]
|
||||
assert handler.get_bot_identity_path(bot_id) is None
|
||||
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
|
||||
|
||||
|
||||
def test_load_state_drops_escaped_storage(temp_identity_dir, tmp_path):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
outside, bait, _log = _outside_bait_identity(tmp_path)
|
||||
handler.bots_state = [
|
||||
{
|
||||
"id": "escaped",
|
||||
"enabled": True,
|
||||
"storage_dir": str(outside),
|
||||
"bot_config_dir": str(outside),
|
||||
},
|
||||
]
|
||||
handler._save_state()
|
||||
handler2 = BotHandler(temp_identity_dir)
|
||||
assert handler2.bots_state == []
|
||||
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
|
||||
|
||||
|
||||
def test_read_subprocess_log_rejects_escaped_storage(temp_identity_dir, tmp_path):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
outside, bait, log_path = _outside_bait_identity(tmp_path)
|
||||
handler.bots_state = [
|
||||
{
|
||||
"id": "escaped",
|
||||
"storage_dir": str(outside),
|
||||
"bot_config_dir": str(outside),
|
||||
},
|
||||
]
|
||||
with pytest.raises(ValueError, match="invalid bot storage directory"):
|
||||
handler.read_subprocess_log("escaped")
|
||||
assert log_path.read_text(encoding="utf-8") == "OUTSIDE_LOG\n"
|
||||
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
|
||||
|
||||
|
||||
@patch.object(BotHandler, "_is_pid_alive", return_value=True)
|
||||
def test_request_announce_rejects_escaped_storage(
|
||||
mock_alive,
|
||||
temp_identity_dir,
|
||||
tmp_path,
|
||||
):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
outside, bait, _log = _outside_bait_identity(tmp_path)
|
||||
handler.bots_state = [
|
||||
{
|
||||
"id": "escaped",
|
||||
"storage_dir": str(outside),
|
||||
"bot_config_dir": str(outside),
|
||||
"pid": 99999,
|
||||
},
|
||||
]
|
||||
with pytest.raises(RuntimeError, match="invalid bot storage directory"):
|
||||
handler.request_announce("escaped")
|
||||
assert not (outside / "meshchatx_request_announce").exists()
|
||||
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink jail oracle is POSIX")
|
||||
def test_get_bot_identity_path_rejects_symlink_out_bot_config_dir(
|
||||
temp_identity_dir,
|
||||
tmp_path,
|
||||
):
|
||||
handler = BotHandler(temp_identity_dir)
|
||||
bot_id = handler.start_bot("echo", "Echo")
|
||||
storage = os.path.join(handler.bots_dir, bot_id)
|
||||
outside, bait, _log = _outside_bait_identity(tmp_path)
|
||||
cfg_link = os.path.join(storage, "config_link")
|
||||
os.symlink(str(outside), cfg_link)
|
||||
handler.bots_state[0]["bot_config_dir"] = cfg_link
|
||||
assert handler.get_bot_identity_path(bot_id) is None
|
||||
assert bait.read_bytes() == b"SECRET_BAIT_BYTES"
|
||||
|
|
|
|||
22
tests/backend/test_key_export_csrf_methods.py
Normal file
22
tests/backend/test_key_export_csrf_methods.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Identity and bot key exports must be POST so CSRF middleware applies."""
|
||||
|
||||
|
||||
def _handler(app, method: str, path: str):
|
||||
for route in app.get_routes():
|
||||
if route.method == method and route.path == path:
|
||||
return route.handler
|
||||
return None
|
||||
|
||||
|
||||
def test_identity_and_bot_key_exports_are_post_not_get(mock_app):
|
||||
mutators = [
|
||||
"/api/v1/identities/export-all",
|
||||
"/api/v1/identity/backup/download",
|
||||
"/api/v1/identity/backup/base32",
|
||||
"/api/v1/bots/export",
|
||||
]
|
||||
for path in mutators:
|
||||
assert _handler(mock_app, "POST", path) is not None, path
|
||||
assert _handler(mock_app, "GET", path) is None, path
|
||||
|
|
@ -344,7 +344,7 @@ async def test_destination_path_returns_local_hop_zero_for_local_destinations(mo
|
|||
path_handler = next(
|
||||
r.handler
|
||||
for r in mock_app.get_routes()
|
||||
if r.path == "/api/v1/destination/{destination_hash}/path"
|
||||
if r.path == "/api/v1/destination/{destination_hash}/path" and r.method == "GET"
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
match_info={"destination_hash": local_hash},
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
"""Regression tests for the LXMF delivery ping HTTP endpoint."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend.http.routes.path_probe import PATH_WAIT_REQUIRES_POST_MESSAGE
|
||||
|
||||
|
||||
def _find_handler(app, path, method):
|
||||
for route in app.get_routes():
|
||||
|
|
@ -15,19 +17,44 @@ def _find_handler(app, path, method):
|
|||
return None
|
||||
|
||||
|
||||
def _make_request(match_info=None, query=None):
|
||||
def _make_request(match_info=None, query=None, method="POST"):
|
||||
request = MagicMock()
|
||||
request.match_info = match_info or {}
|
||||
request.query = query or {}
|
||||
request.method = method
|
||||
return request
|
||||
|
||||
|
||||
def _two_running_contexts(mock_app):
|
||||
current = MagicMock()
|
||||
current.running = True
|
||||
current.config.auto_resend_failed_messages_when_announce_received.get.return_value = True
|
||||
other = MagicMock()
|
||||
other.running = True
|
||||
other.config.auto_resend_failed_messages_when_announce_received.get.return_value = (
|
||||
True
|
||||
)
|
||||
mock_app.current_context = current
|
||||
mock_app.contexts = {"current": current, "other": other}
|
||||
mock_app.resend_failed_messages_for_destination = MagicMock(
|
||||
return_value=MagicMock(),
|
||||
)
|
||||
return current, other
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_is_post_not_get(mock_app):
|
||||
path = "/api/v1/ping/{destination_hash}/lxmf.delivery"
|
||||
assert _find_handler(mock_app, path, "POST") is not None
|
||||
assert _find_handler(mock_app, path, "GET") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_rejects_invalid_destination_hash(mock_app):
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
"GET",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
|
|
@ -46,7 +73,7 @@ async def test_ping_rejects_non_integer_timeout(mock_app):
|
|||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
"GET",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
|
|
@ -65,7 +92,7 @@ async def test_ping_rejects_zero_timeout(mock_app):
|
|||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
"GET",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
|
|
@ -75,3 +102,187 @@ async def test_ping_rejects_zero_timeout(mock_app):
|
|||
),
|
||||
)
|
||||
assert response.status == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_rejects_timeout_above_600(mock_app):
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
_make_request(
|
||||
match_info={"destination_hash": "ab" * 16},
|
||||
query={"timeout": "601"},
|
||||
),
|
||||
)
|
||||
assert response.status == 400
|
||||
data = json.loads(response.body)
|
||||
assert "600" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_path_request_true_is_400_and_does_not_prepare(mock_app):
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/destination/{destination_hash}/path",
|
||||
"GET",
|
||||
)
|
||||
assert handler is not None
|
||||
dest = "a" * 32
|
||||
req = _make_request(
|
||||
match_info={"destination_hash": dest},
|
||||
query={"request": "true", "timeout": "1"},
|
||||
method="GET",
|
||||
)
|
||||
with patch(
|
||||
"meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
|
||||
) as pfp:
|
||||
response = await handler(req)
|
||||
assert response.status == 400
|
||||
data = json.loads(response.body)
|
||||
assert data["message"] == PATH_WAIT_REQUIRES_POST_MESSAGE
|
||||
pfp.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_path_rejects_timeout_above_600(mock_app):
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/destination/{destination_hash}/path",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
_make_request(
|
||||
match_info={"destination_hash": "ab" * 16},
|
||||
query={"timeout": "601"},
|
||||
),
|
||||
)
|
||||
assert response.status == 400
|
||||
data = json.loads(response.body)
|
||||
assert "600" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_path_rejects_non_integer_timeout(mock_app):
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/destination/{destination_hash}/path",
|
||||
"POST",
|
||||
)
|
||||
assert handler is not None
|
||||
response = await handler(
|
||||
_make_request(
|
||||
match_info={"destination_hash": "ab" * 16},
|
||||
query={"timeout": "nope"},
|
||||
),
|
||||
)
|
||||
assert response.status == 400
|
||||
data = json.loads(response.body)
|
||||
assert "integer" in data["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_path_resend_uses_current_context_only(mock_app):
|
||||
current, other = _two_running_contexts(mock_app)
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/destination/{destination_hash}/request-path",
|
||||
"POST",
|
||||
)
|
||||
dest = "b" * 32
|
||||
req = _make_request(match_info={"destination_hash": dest})
|
||||
with (
|
||||
patch("meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request"),
|
||||
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
|
||||
patch("meshchatx.meshchat.AsyncUtils.run_async"),
|
||||
):
|
||||
response = await handler(req)
|
||||
assert response.status == 200
|
||||
mock_app.resend_failed_messages_for_destination.assert_called_once()
|
||||
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
|
||||
assert kwargs["context"] is current
|
||||
assert kwargs["context"] is not other
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_path_resend_uses_current_context_only(mock_app):
|
||||
current, other = _two_running_contexts(mock_app)
|
||||
mock_app.reticulum = MagicMock()
|
||||
mock_app.reticulum.get_next_hop.return_value = bytes(16)
|
||||
mock_app.reticulum.get_next_hop_if_name.return_value = "if0"
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/destination/{destination_hash}/path",
|
||||
"POST",
|
||||
)
|
||||
dest = "c" * 32
|
||||
req = _make_request(
|
||||
match_info={"destination_hash": dest},
|
||||
query={"timeout": "1"},
|
||||
)
|
||||
with (
|
||||
patch("meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request"),
|
||||
patch(
|
||||
"meshchatx.meshchat.reticulum_pathfinding.path_metadata_for_api",
|
||||
return_value={"path_stale": False, "path_unresponsive": False},
|
||||
),
|
||||
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
|
||||
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2),
|
||||
patch("meshchatx.meshchat.AsyncUtils.run_async"),
|
||||
):
|
||||
response = await handler(req)
|
||||
assert response.status == 200
|
||||
mock_app.resend_failed_messages_for_destination.assert_called_once()
|
||||
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
|
||||
assert kwargs["context"] is current
|
||||
assert kwargs["context"] is not other
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_resend_uses_current_context_only(mock_app):
|
||||
import RNS
|
||||
|
||||
current, other = _two_running_contexts(mock_app)
|
||||
mock_app.reticulum = MagicMock()
|
||||
mock_app.recall_identity = MagicMock(return_value=MagicMock())
|
||||
handler = _find_handler(
|
||||
mock_app,
|
||||
"/api/v1/ping/{destination_hash}/lxmf.delivery",
|
||||
"POST",
|
||||
)
|
||||
dest = "ab" * 16
|
||||
receipt = MagicMock()
|
||||
receipt.status = RNS.PacketReceipt.DELIVERED
|
||||
receipt.proof_packet.hops = 1
|
||||
receipt.proof_packet.rssi = -50
|
||||
receipt.proof_packet.snr = 5
|
||||
receipt.proof_packet.q = 100
|
||||
receipt.proof_packet.receiving_interface = "UDP"
|
||||
receipt.proof_packet.packet_hash = b"\x00" * 16
|
||||
receipt.get_rtt.return_value = 0.1
|
||||
receipt.destination.hash.hex.return_value = dest
|
||||
packet = MagicMock()
|
||||
packet.send.return_value = receipt
|
||||
|
||||
with (
|
||||
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
|
||||
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=1),
|
||||
patch("meshchatx.meshchat.RNS.Destination"),
|
||||
patch("meshchatx.meshchat.RNS.Packet", return_value=packet),
|
||||
patch("meshchatx.meshchat.AsyncUtils.run_async"),
|
||||
):
|
||||
response = await handler(
|
||||
_make_request(
|
||||
match_info={"destination_hash": dest},
|
||||
query={"timeout": "5"},
|
||||
),
|
||||
)
|
||||
assert response.status == 200
|
||||
mock_app.resend_failed_messages_for_destination.assert_called_once()
|
||||
kwargs = mock_app.resend_failed_messages_for_destination.call_args.kwargs
|
||||
assert kwargs["context"] is current
|
||||
assert kwargs["context"] is not other
|
||||
|
|
|
|||
|
|
@ -8,7 +8,30 @@ import pytest
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
|
||||
async def test_get_destination_path_with_request_is_400(mock_app):
|
||||
h = next(
|
||||
r.handler
|
||||
for r in mock_app.get_routes()
|
||||
if r.path == "/api/v1/destination/{destination_hash}/path" and r.method == "GET"
|
||||
)
|
||||
dest = "a" * 32
|
||||
req = SimpleNamespace(
|
||||
match_info={"destination_hash": dest},
|
||||
query={"request": "1", "timeout": "1"},
|
||||
method="GET",
|
||||
)
|
||||
with patch(
|
||||
"meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request",
|
||||
) as pfp:
|
||||
response = await h(req)
|
||||
pfp.assert_not_called()
|
||||
assert response.status == 400
|
||||
data = json.loads(response.body)
|
||||
assert "POST" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_destination_path_calls_prepare_fresh(mock_app):
|
||||
mock_app.reticulum = MagicMock()
|
||||
mock_app.reticulum.get_next_hop.return_value = bytes(16)
|
||||
mock_app.reticulum.get_next_hop_if_name.return_value = "if0"
|
||||
|
|
@ -16,11 +39,13 @@ async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
|
|||
r.handler
|
||||
for r in mock_app.get_routes()
|
||||
if r.path == "/api/v1/destination/{destination_hash}/path"
|
||||
and r.method == "POST"
|
||||
)
|
||||
dest = "a" * 32
|
||||
req = SimpleNamespace(
|
||||
match_info={"destination_hash": dest},
|
||||
query={"request": "1", "timeout": "1"},
|
||||
query={"timeout": "1"},
|
||||
method="POST",
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
|
|
@ -33,6 +58,10 @@ async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app):
|
|||
patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True),
|
||||
patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2),
|
||||
patch("meshchatx.meshchat.asyncio.sleep", new_callable=AsyncMock),
|
||||
patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: coro.close() if hasattr(coro, "close") else None,
|
||||
),
|
||||
):
|
||||
response = await h(req)
|
||||
pfp.assert_called_once()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import BotsPage from "@/components/tools/BotsPage.vue";
|
||||
import DownloadUtils from "@/js/DownloadUtils";
|
||||
import ToastUtils from "@/js/ToastUtils";
|
||||
|
||||
vi.mock("@/js/DownloadUtils", () => ({
|
||||
default: {
|
||||
downloadFromApiResponse: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("BotsPage.vue", () => {
|
||||
let axiosMock;
|
||||
|
|
@ -126,4 +141,28 @@ describe("BotsPage.vue", () => {
|
|||
params: { destinationHash: "a".repeat(32) },
|
||||
});
|
||||
});
|
||||
|
||||
it("exports bot identity through window.api and DownloadUtils", async () => {
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: new ArrayBuffer(4),
|
||||
headers: { "content-disposition": 'attachment; filename="bot_bot1_identity"' },
|
||||
});
|
||||
const wrapper = mountBotsPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
|
||||
await wrapper.vm.exportIdentity("bot1");
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
"/api/v1/bots/export",
|
||||
{ bot_id: "bot1" },
|
||||
{ responseType: "arraybuffer" }
|
||||
);
|
||||
expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("toasts when bot identity export fails", async () => {
|
||||
axiosMock.post.mockRejectedValue({ response: { data: { message: "nope" } } });
|
||||
const wrapper = mountBotsPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.loading).toBe(false));
|
||||
await wrapper.vm.exportIdentity("bot1");
|
||||
expect(ToastUtils.error).toHaveBeenCalledWith("nope");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ describe("PingPage.vue", () => {
|
|||
});
|
||||
|
||||
it("pings and displays results", async () => {
|
||||
axiosMock.get.mockResolvedValue({
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
ping_result: {
|
||||
rtt: 0.1234,
|
||||
|
|
@ -89,7 +89,7 @@ describe("PingPage.vue", () => {
|
|||
});
|
||||
|
||||
it("terminates previous loop when stop and start are called sequentially", async () => {
|
||||
axiosMock.get.mockResolvedValue({
|
||||
axiosMock.post.mockResolvedValue({
|
||||
data: {
|
||||
ping_result: {
|
||||
rtt: 0.1,
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ describe("PropagationNodesPage", () => {
|
|||
});
|
||||
|
||||
it("fetches path for a destination hash", async () => {
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
axiosMock.post.mockResolvedValueOnce({
|
||||
data: {
|
||||
path: { hops: 2, next_hop_interface: "TCP Client" },
|
||||
},
|
||||
|
|
@ -216,9 +216,13 @@ describe("PropagationNodesPage", () => {
|
|||
nodePathsByHash: {},
|
||||
};
|
||||
await PropagationNodesPage.methods.requestPathForNode.call(ctx, "abcd");
|
||||
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", {
|
||||
params: { request: "1", timeout: 4 },
|
||||
});
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
"/api/v1/destination/abcd/path",
|
||||
{},
|
||||
{
|
||||
params: { timeout: 4 },
|
||||
}
|
||||
);
|
||||
expect(ctx.nodePathsByHash.abcd).toEqual({ hops: 2, next_hop_interface: "TCP Client" });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,15 +39,31 @@ describe("download wiring through DownloadUtils", () => {
|
|||
headers: {},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: {}, headers: {} });
|
||||
}),
|
||||
post: vi.fn().mockImplementation((url) => {
|
||||
if (String(url).includes("/identity/backup/download")) {
|
||||
return Promise.resolve({
|
||||
data: new ArrayBuffer(2),
|
||||
headers: {},
|
||||
});
|
||||
}
|
||||
if (String(url).includes("/identities/export-all")) {
|
||||
return Promise.resolve({
|
||||
data: new ArrayBuffer(3),
|
||||
headers: {},
|
||||
});
|
||||
}
|
||||
if (String(url).includes("/api/v1/bots/export")) {
|
||||
return Promise.resolve({
|
||||
data: new ArrayBuffer(5),
|
||||
headers: {
|
||||
"content-disposition": 'attachment; filename="bot_bot1_identity"',
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: {}, headers: {} });
|
||||
}),
|
||||
post: vi.fn().mockResolvedValue({ data: {} }),
|
||||
delete: vi.fn().mockResolvedValue({ data: {} }),
|
||||
};
|
||||
window.api = axiosMock;
|
||||
|
|
@ -116,6 +132,11 @@ describe("download wiring through DownloadUtils", () => {
|
|||
|
||||
await wrapper.vm.downloadIdentityFile();
|
||||
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
"/api/v1/identity/backup/download",
|
||||
{},
|
||||
expect.objectContaining({ responseType: "arraybuffer" })
|
||||
);
|
||||
expect(DownloadUtils.downloadFromApiResponse).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.any(ArrayBuffer) }),
|
||||
"identity"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
getDestinationPath,
|
||||
postDestinationPath,
|
||||
postRequestPath,
|
||||
postDropPath,
|
||||
runDestinationPathFinder,
|
||||
|
|
@ -13,11 +14,11 @@ import {
|
|||
} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js";
|
||||
|
||||
describe("reticulumPathfinding.js", () => {
|
||||
it("getDestinationPath builds expected URL and forwards params", () => {
|
||||
it("getDestinationPath builds expected URL without a request query", () => {
|
||||
const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) };
|
||||
getDestinationPath(api, "deadbeef", { request: true, timeout: 12 });
|
||||
expect(api.get).toHaveBeenCalledWith("/api/v1/destination/deadbeef/path", {
|
||||
params: { request: "1", timeout: 12 },
|
||||
params: { timeout: 12 },
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -35,6 +36,33 @@ describe("reticulumPathfinding.js", () => {
|
|||
expect(api.post).toHaveBeenCalledWith("/api/v1/destination/ab/drop-path");
|
||||
});
|
||||
|
||||
it("postDestinationPath posts the path URL with timeout query", () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({ data: { path: null } }) };
|
||||
postDestinationPath(api, "deadbeef", { timeout: 4 });
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/api/v1/destination/deadbeef/path",
|
||||
{},
|
||||
{
|
||||
params: { timeout: 4 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder force posts path wait", async () => {
|
||||
const api = {
|
||||
post: vi.fn().mockResolvedValue({ data: { path: { hops: 2 } } }),
|
||||
};
|
||||
const r = await runDestinationPathFinder(api, "h", "force", { forceTimeout: 9 });
|
||||
expect(r).toEqual({ ok: true, path: { hops: 2 } });
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/api/v1/destination/h/path",
|
||||
{},
|
||||
{
|
||||
params: { timeout: 9 },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("runDestinationPathFinder quick only posts request-path", async () => {
|
||||
const api = { post: vi.fn().mockResolvedValue({}) };
|
||||
const r = await runDestinationPathFinder(api, "h", "quick");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue