feat: update interface management with new options for fast-flapping, location command, and recursive path requests

This commit is contained in:
Ivan 2026-07-19 05:46:28 -05:00
parent 7737194b3c
commit febce56ccb
No known key found for this signature in database
19 changed files with 1002 additions and 21 deletions

View file

@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file.
- Notification sound settings
- LXMFy 1.6.5 vendor refresh, wasmtime, mutation test tasks
- Network visualiser WebGL + WASM renderer (vis-network fallback) and Settings renderer preference
- Interfaces: internal mode, recursive path requests, announces-from-internal, discovery location command, and Backbone fast-flapping options (RNS 1.3.7 to 1.3.9)
### Changed
@ -61,6 +62,7 @@ All notable changes to this project will be documented in this file.
- Open conversations mark as read when a new message arrives without needing to reselect the thread
- Startup stage logs no longer print the same stage twice
- Ctrl+C shutdown no longer floods reentrant logging errors from RNS.exit containment
- RN Status interface mode labels match Reticulum modes again, including Internal
## [4.7.2] - 2026-07-06

Binary file not shown.

View file

@ -6793,6 +6793,17 @@ class ReticulumMeshChat:
data,
"prefer_ipv6",
)
flap_error = InterfaceEditor.apply_backbone_fast_flapping(
interface_details,
data,
)
if flap_error is not None:
return web.json_response(
{
"message": flap_error,
},
status=422,
)
else:
remote = data.get("remote") or data.get("target_host")
if remote is None or str(remote).strip() == "":
@ -7326,6 +7337,18 @@ class ReticulumMeshChat:
):
InterfaceEditor.update_value(interface_details, data, discovery_key)
location_cmd_error = InterfaceEditor.apply_location_cmd(
interface_details,
data,
)
if location_cmd_error is not None:
return web.json_response(
{
"message": location_cmd_error,
},
status=422,
)
if interface_type == "TCPClientInterface" or (
interface_type == "BackboneInterface"
and str(interface_details.get("remote") or "").strip() != ""
@ -7344,7 +7367,38 @@ class ReticulumMeshChat:
# set common interface options
InterfaceEditor.update_value(interface_details, data, "bitrate")
InterfaceEditor.update_value(interface_details, data, "mode")
mode_error = InterfaceEditor.apply_interface_mode(interface_details, data)
if mode_error is not None:
return web.json_response(
{
"message": mode_error,
},
status=422,
)
recursive_prs_error = InterfaceEditor.apply_yes_no_option(
interface_details,
data,
"recursive_prs",
)
if recursive_prs_error is not None:
return web.json_response(
{
"message": recursive_prs_error,
},
status=422,
)
announces_error = InterfaceEditor.apply_yes_no_option(
interface_details,
data,
"announces_from_internal",
)
if announces_error is not None:
return web.json_response(
{
"message": announces_error,
},
status=422,
)
InterfaceEditor.update_value(interface_details, data, "network_name")
InterfaceEditor.update_value(interface_details, data, "passphrase")
InterfaceEditor.update_value(interface_details, data, "ifac_size")
@ -7543,6 +7597,16 @@ class ReticulumMeshChat:
},
status=422,
)
import_option_error = (
InterfaceEditor.sanitize_imported_rns_options(iface_body)
)
if import_option_error is not None:
return web.json_response(
{
"message": import_option_error,
},
status=422,
)
if iface_type in ("RNodeInterface", "RNodeIPInterface"):
freq = iface_body.get("frequency")
if freq is not None and freq != "":

View file

@ -1,11 +1,45 @@
# SPDX-License-Identifier: 0BSD AND MIT
import os
import re
import RNS
_IPV4_HOST_PORT = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$")
# Canonical Reticulum interface mode strings (RNS 1.3.7+ includes internal).
ALLOWED_INTERFACE_MODES = frozenset(
{
"full",
"gateway",
"gw",
"access_point",
"accesspoint",
"ap",
"pointtopoint",
"ptp",
"roaming",
"boundary",
"internal",
}
)
# Prefer writing the long form when aliases are supplied via the API.
_INTERFACE_MODE_CANONICAL = {
"gw": "gateway",
"accesspoint": "access_point",
"ap": "access_point",
"pointtopoint": "pointtopoint",
"ptp": "pointtopoint",
}
_YES_NO_TRUE = frozenset({"true", "yes", "1", "y", "on"})
_YES_NO_FALSE = frozenset({"false", "no", "0", "n", "off"})
# location_cmd is executed by RNS Discovery via subprocess.run([path]).
# Reject shell metacharacters and relative traversal before persisting.
_LOCATION_CMD_FORBIDDEN = re.compile(r"[\x00-\x1f\x7f;&|`$<>\\\"'*?\[\]{}()!#]")
def normalize_rnode_tcp_port(port: str) -> str:
"""Normalize RNodeInterface port when using tcp://.
@ -162,3 +196,214 @@ class InterfaceEditor:
# otherwise remove existing value
interface_details.pop(key, None)
@staticmethod
def normalize_interface_mode(value) -> str | None:
"""Return a canonical Reticulum mode string, or None when unset."""
if value is None or value == "":
return None
mode = str(value).strip().lower()
if mode not in ALLOWED_INTERFACE_MODES:
return None
return _INTERFACE_MODE_CANONICAL.get(mode, mode)
@staticmethod
def apply_interface_mode(interface_details: dict, data: dict) -> str | None:
"""Persist mode when valid. Return an API error message otherwise."""
if "mode" not in data:
return None
value = data.get("mode")
if value is None or value == "":
interface_details.pop("mode", None)
return None
mode = InterfaceEditor.normalize_interface_mode(value)
if mode is None:
return (
"mode must be one of: full, gateway, access_point, "
"pointtopoint, roaming, boundary, internal"
)
interface_details["mode"] = mode
return None
@staticmethod
def request_yes_no(value) -> str | None:
"""Map common truthy/falsey request values to Reticulum yes/no."""
if value is None or value == "":
return None
if isinstance(value, bool):
return "yes" if value else "no"
s = str(value).strip().lower()
if s in _YES_NO_TRUE:
return "yes"
if s in _YES_NO_FALSE:
return "no"
return None
@staticmethod
def apply_yes_no_option(
interface_details: dict,
data: dict,
key: str,
*,
default_when_missing: str | None = None,
) -> str | None:
"""Persist a Reticulum yes/no option. Return error text on bad input.
When the key is absent from data, leave existing config alone unless
default_when_missing is set (then write that yes/no or pop when None).
"""
if key not in data:
if default_when_missing is None:
return None
if default_when_missing in ("yes", "no"):
interface_details[key] = default_when_missing
else:
interface_details.pop(key, None)
return None
yn = InterfaceEditor.request_yes_no(data.get(key))
if yn is None:
interface_details.pop(key, None)
raw = data.get(key)
if raw is None or raw == "":
return None
return f"{key} must be a boolean or yes/no value"
interface_details[key] = yn
return None
@staticmethod
def validate_location_cmd(value) -> str | None:
"""Return an error when location_cmd is unsafe for RNS Discovery exec."""
if value is None or value == "":
return None
raw = str(value).strip()
if not raw:
return None
if _LOCATION_CMD_FORBIDDEN.search(raw):
return (
"location_cmd must be an absolute executable path without "
"shell metacharacters or control characters"
)
if ".." in raw.replace("\\", "/").split("/"):
return "location_cmd must not contain parent-directory segments"
expanded = os.path.expanduser(raw)
if not os.path.isabs(expanded):
return "location_cmd must be an absolute path or start with ~/"
return None
@staticmethod
def apply_location_cmd(interface_details: dict, data: dict) -> str | None:
"""Persist discovery location_cmd when valid."""
if "location_cmd" not in data:
return None
value = data.get("location_cmd")
if value is None or value == "":
interface_details.pop("location_cmd", None)
return None
error = InterfaceEditor.validate_location_cmd(value)
if error is not None:
return error
interface_details["location_cmd"] = os.path.normpath(
os.path.expanduser(str(value).strip()),
)
return None
@staticmethod
def apply_positive_number(
interface_details: dict,
data: dict,
key: str,
*,
as_int: bool = False,
minimum: float = 0,
maximum: float | None = None,
) -> str | None:
"""Persist a numeric option with bounds. Return error text if invalid."""
if key not in data:
return None
value = data.get(key)
if value is None or value == "":
interface_details.pop(key, None)
return None
try:
number = int(value) if as_int else float(value)
except (TypeError, ValueError):
return f"{key} must be a number"
if number < minimum:
return f"{key} must be at least {minimum}"
if maximum is not None and number > maximum:
return f"{key} must be at most {maximum}"
interface_details[key] = int(number) if as_int else number
return None
@staticmethod
def apply_backbone_fast_flapping(
interface_details: dict,
data: dict,
) -> str | None:
"""Persist BackboneInterface fast-flapping options (RNS 1.3.9)."""
err = InterfaceEditor.apply_yes_no_option(
interface_details,
data,
"block_fast_flapping",
)
if err:
return err
err = InterfaceEditor.apply_positive_number(
interface_details,
data,
"fast_flapping_block_time",
as_int=True,
minimum=1,
maximum=60 * 24 * 30,
)
if err:
return err
err = InterfaceEditor.apply_positive_number(
interface_details,
data,
"fast_flapping_threshold",
as_int=False,
minimum=0.1,
maximum=3600,
)
if err:
return err
return InterfaceEditor.apply_positive_number(
interface_details,
data,
"fast_flapping_grace",
as_int=True,
minimum=0,
maximum=10_000,
)
@staticmethod
def sanitize_imported_rns_options(iface_body: dict) -> str | None:
"""Normalize/validate RNS 1.3.7+ options on import. Return error or None."""
if "mode" in iface_body:
mode = InterfaceEditor.normalize_interface_mode(iface_body.get("mode"))
if mode is None:
return (
"Imported interface mode must be one of: full, gateway, "
"access_point, pointtopoint, roaming, boundary, internal"
)
iface_body["mode"] = mode
for key in ("recursive_prs", "announces_from_internal", "block_fast_flapping"):
if key not in iface_body:
continue
yn = InterfaceEditor.request_yes_no(iface_body.get(key))
if yn is None:
return f"Imported interface {key} must be a boolean or yes/no value"
iface_body[key] = yn
if "location_cmd" in iface_body:
loc = iface_body.get("location_cmd")
if loc is None or loc == "":
iface_body.pop("location_cmd", None)
else:
error = InterfaceEditor.validate_location_cmd(loc)
if error is not None:
return error
iface_body["location_cmd"] = os.path.normpath(
os.path.expanduser(str(loc).strip()),
)
return None

View file

@ -233,16 +233,19 @@ class RNStatusHandler:
}
mode = ifstat.get("mode")
if mode == 1:
iface_modes = RNS.Interfaces.Interface.Interface
if mode == iface_modes.MODE_ACCESS_POINT:
formatted_if["mode"] = "Access Point"
elif mode == 2:
elif mode == iface_modes.MODE_POINT_TO_POINT:
formatted_if["mode"] = "Point-to-Point"
elif mode == 3:
elif mode == iface_modes.MODE_ROAMING:
formatted_if["mode"] = "Roaming"
elif mode == 4:
elif mode == iface_modes.MODE_BOUNDARY:
formatted_if["mode"] = "Boundary"
elif mode == 5:
elif mode == iface_modes.MODE_GATEWAY:
formatted_if["mode"] = "Gateway"
elif mode == iface_modes.MODE_INTERNAL:
formatted_if["mode"] = "Internal"
else:
formatted_if["mode"] = "Full"

View file

@ -445,6 +445,57 @@
>Prefer IPv6</FormLabel
>
</div>
<div
class="rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30 p-3 space-y-3"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<FormLabel class="glass-label mb-0!">{{
$t("interfaces.block_fast_flapping_label")
}}</FormLabel>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ $t("interfaces.block_fast_flapping_hint") }}
</p>
</div>
<Toggle v-model="newInterfaceBlockFastFlapping" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<FormLabel class="glass-label">{{
$t("interfaces.fast_flapping_block_time_label")
}}</FormLabel>
<input
v-model.number="newInterfaceFastFlappingBlockTime"
type="number"
min="1"
class="input-field"
/>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.fast_flapping_threshold_label")
}}</FormLabel>
<input
v-model.number="newInterfaceFastFlappingThreshold"
type="number"
min="0.1"
step="0.1"
class="input-field"
/>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.fast_flapping_grace_label")
}}</FormLabel>
<input
v-model.number="newInterfaceFastFlappingGrace"
type="number"
min="0"
class="input-field"
/>
</div>
</div>
</div>
</div>
</div>
@ -1557,6 +1608,21 @@
/>
</div>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.location_cmd_label")
}}</FormLabel>
<input
v-model="discovery.location_cmd"
type="text"
:placeholder="$t('interfaces.location_cmd_placeholder')"
class="input-field font-mono text-xs"
autocomplete="off"
/>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ $t("interfaces.location_cmd_hint") }}
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<FormLabel class="glass-label">Discovery stamp value</FormLabel>
@ -1663,12 +1729,25 @@
<div>
<FormLabel class="glass-label">Interface Mode</FormLabel>
<select v-model="sharedInterfaceSettings.mode" class="input-field">
<option :value="undefined">Default (Full)</option>
<option value="full">Full</option>
<option value="gateway">Gateway</option>
<option value="access_point">Access Point</option>
<option value="roaming">Roaming</option>
<option value="boundary">Boundary</option>
<option :value="undefined">{{
$t("interfaces.mode_default_full")
}}</option>
<option value="full">{{ $t("interfaces.mode_full") }}</option>
<option value="gateway">{{
$t("interfaces.mode_gateway")
}}</option>
<option value="access_point">{{
$t("interfaces.mode_access_point")
}}</option>
<option value="roaming">{{
$t("interfaces.mode_roaming")
}}</option>
<option value="boundary">{{
$t("interfaces.mode_boundary")
}}</option>
<option value="internal">{{
$t("interfaces.mode_internal")
}}</option>
</select>
</div>
<div>
@ -1681,6 +1760,32 @@
/>
</div>
</div>
<div class="space-y-4 pt-2">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 max-w-md">
<FormLabel class="glass-label mb-0!">{{
$t("interfaces.recursive_prs_label")
}}</FormLabel>
<p class="text-xs text-gray-400 mt-1">
{{ $t("interfaces.recursive_prs_hint") }}
</p>
</div>
<Toggle v-model="sharedInterfaceSettings.recursive_prs" />
</div>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 max-w-md">
<FormLabel class="glass-label mb-0!">{{
$t("interfaces.announces_from_internal_label")
}}</FormLabel>
<p class="text-xs text-gray-400 mt-1">
{{ $t("interfaces.announces_from_internal_hint") }}
</p>
</div>
<Toggle
v-model="sharedInterfaceSettings.announces_from_internal"
/>
</div>
</div>
<div class="space-y-4 pt-4 border-t border-gray-100 dark:border-zinc-800">
<FormLabel class="glass-label">Interface Access Code (IFAC)</FormLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
@ -2013,6 +2118,10 @@ export default {
newInterfaceBackboneListenPort: null,
newInterfaceBackboneListenIp: null,
newInterfaceBackboneListenDevice: null,
newInterfaceBlockFastFlapping: true,
newInterfaceFastFlappingBlockTime: 720,
newInterfaceFastFlappingThreshold: 20,
newInterfaceFastFlappingGrace: 5,
reticulumInstance: {
share_instance: true,
local_hops_delta: false,
@ -2033,6 +2142,8 @@ export default {
passphrase: null,
ifac_size: null,
bitrate: null,
recursive_prs: false,
announces_from_internal: true,
},
discovery: {
@ -2046,6 +2157,7 @@ export default {
latitude: null,
longitude: null,
height: null,
location_cmd: "",
discovery_frequency: null,
discovery_bandwidth: null,
discovery_modulation: null,
@ -2590,6 +2702,22 @@ export default {
this.newInterfaceBackboneListenIp = iface.listen_ip ?? null;
this.newInterfaceBackboneListenPort = iface.listen_port ?? null;
this.newInterfaceBackboneListenDevice = iface.device ?? null;
this.newInterfaceBlockFastFlapping =
iface.block_fast_flapping === undefined || iface.block_fast_flapping === null
? true
: this.parseBool(iface.block_fast_flapping);
this.newInterfaceFastFlappingBlockTime =
iface.fast_flapping_block_time != null && iface.fast_flapping_block_time !== ""
? Number(iface.fast_flapping_block_time)
: 720;
this.newInterfaceFastFlappingThreshold =
iface.fast_flapping_threshold != null && iface.fast_flapping_threshold !== ""
? Number(iface.fast_flapping_threshold)
: 20;
this.newInterfaceFastFlappingGrace =
iface.fast_flapping_grace != null && iface.fast_flapping_grace !== ""
? Number(iface.fast_flapping_grace)
: 5;
}
if (
@ -2651,6 +2779,11 @@ export default {
this.sharedInterfaceSettings.bitrate = iface.bitrate;
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
this.sharedInterfaceSettings.recursive_prs = this.parseBool(iface.recursive_prs);
this.sharedInterfaceSettings.announces_from_internal =
iface.announces_from_internal === undefined || iface.announces_from_internal === null
? true
: this.parseBool(iface.announces_from_internal);
if (iface.frequency) {
this.RNodeGHzValue = Math.floor(iface.frequency / 1e9);
@ -2676,6 +2809,7 @@ export default {
this.discovery.longitude =
iface.longitude != null && iface.longitude !== "" ? Number(iface.longitude) : null;
this.discovery.height = iface.height != null && iface.height !== "" ? Number(iface.height) : null;
this.discovery.location_cmd = iface.location_cmd ?? "";
this.discovery.discovery_frequency =
iface.discovery_frequency != null && iface.discovery_frequency !== ""
? Number(iface.discovery_frequency)
@ -2828,6 +2962,31 @@ export default {
if (config.bitrate) this.sharedInterfaceSettings.bitrate = Number(config.bitrate);
if (config.network_name) this.sharedInterfaceSettings.network_name = config.network_name;
if (config.passphrase) this.sharedInterfaceSettings.passphrase = config.passphrase;
if (config.recursive_prs !== undefined && config.recursive_prs !== null && config.recursive_prs !== "") {
this.sharedInterfaceSettings.recursive_prs = this.parseBool(config.recursive_prs);
}
if (
config.announces_from_internal !== undefined &&
config.announces_from_internal !== null &&
config.announces_from_internal !== ""
) {
this.sharedInterfaceSettings.announces_from_internal = this.parseBool(
config.announces_from_internal
);
}
if (
config.block_fast_flapping !== undefined &&
config.block_fast_flapping !== null &&
config.block_fast_flapping !== ""
) {
this.newInterfaceBlockFastFlapping = this.parseBool(config.block_fast_flapping);
}
if (config.fast_flapping_block_time)
this.newInterfaceFastFlappingBlockTime = Number(config.fast_flapping_block_time);
if (config.fast_flapping_threshold)
this.newInterfaceFastFlappingThreshold = Number(config.fast_flapping_threshold);
if (config.fast_flapping_grace)
this.newInterfaceFastFlappingGrace = Number(config.fast_flapping_grace);
if (config.discoverable !== undefined && config.discoverable !== null && config.discoverable !== "") {
this.discovery.discoverable = this.parseBool(config.discoverable);
@ -2843,6 +3002,7 @@ export default {
if (config.latitude) this.discovery.latitude = Number(config.latitude);
if (config.longitude) this.discovery.longitude = Number(config.longitude);
if (config.height) this.discovery.height = Number(config.height);
if (config.location_cmd) this.discovery.location_cmd = String(config.location_cmd);
ToastUtils.success(`Imported configuration for "${config.name}"`);
@ -2913,10 +3073,25 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(config.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(config.longitude) : null,
height: discoveryEnabled ? this.numOrNull(config.height) : null,
location_cmd: discoveryEnabled
? config.location_cmd
? String(config.location_cmd).trim() || null
: null
: null,
discovery_frequency: discoveryEnabled ? this.numOrNull(config.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(config.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(config.discovery_modulation) : null,
mode: config.mode || null,
recursive_prs:
config.recursive_prs !== undefined && config.recursive_prs !== null && config.recursive_prs !== ""
? this.parseBool(config.recursive_prs)
: false,
announces_from_internal:
config.announces_from_internal !== undefined &&
config.announces_from_internal !== null &&
config.announces_from_internal !== ""
? this.parseBool(config.announces_from_internal)
: true,
bitrate: this.numOrNull(config.bitrate),
network_name: config.network_name || null,
passphrase: config.passphrase || null,
@ -2927,6 +3102,34 @@ export default {
config.prefer_ipv6 !== undefined && config.prefer_ipv6 !== null && config.prefer_ipv6 !== ""
? this.parseBool(config.prefer_ipv6)
: null,
block_fast_flapping:
config.type === "BackboneInterface" &&
config.listen_port != null &&
String(config.listen_port).trim() !== ""
? config.block_fast_flapping !== undefined &&
config.block_fast_flapping !== null &&
config.block_fast_flapping !== ""
? this.parseBool(config.block_fast_flapping)
: true
: null,
fast_flapping_block_time:
config.type === "BackboneInterface" &&
config.listen_port != null &&
String(config.listen_port).trim() !== ""
? this.numOrNull(config.fast_flapping_block_time)
: null,
fast_flapping_threshold:
config.type === "BackboneInterface" &&
config.listen_port != null &&
String(config.listen_port).trim() !== ""
? this.numOrNull(config.fast_flapping_threshold)
: null,
fast_flapping_grace:
config.type === "BackboneInterface" &&
config.listen_port != null &&
String(config.listen_port).trim() !== ""
? this.numOrNull(config.fast_flapping_grace)
: null,
kiss_framing:
config.kiss_framing !== undefined && config.kiss_framing !== null && config.kiss_framing !== ""
? this.parseBool(config.kiss_framing)
@ -3142,6 +3345,9 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
location_cmd: discoveryEnabled
? (this.discovery.location_cmd || "").trim() || null
: null,
discovery_frequency: discoveryEnabled
? this.numOrNull(this.discovery.discovery_frequency)
: null,
@ -3152,6 +3358,9 @@ export default {
? this.numOrNull(this.discovery.discovery_modulation)
: null,
mode: this.sharedInterfaceSettings.mode || null,
recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
announces_from_internal:
this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
network_name: this.sharedInterfaceSettings.network_name,
passphrase: this.sharedInterfaceSettings.passphrase,
@ -3194,6 +3403,16 @@ export default {
? (this.newInterfaceBackboneListenDevice || "").trim() || null
: (this.newInterfaceNetworkDevice || "").trim() || null,
prefer_ipv6: this.newInterfacePreferIPV6 === true,
block_fast_flapping: isBackboneListener ? this.newInterfaceBlockFastFlapping === true : null,
fast_flapping_block_time: isBackboneListener
? this.numOrNull(this.newInterfaceFastFlappingBlockTime)
: null,
fast_flapping_threshold: isBackboneListener
? this.numOrNull(this.newInterfaceFastFlappingThreshold)
: null,
fast_flapping_grace: isBackboneListener
? this.numOrNull(this.newInterfaceFastFlappingGrace)
: null,
kiss_framing: this.newInterfaceKISSFramingEnabled === true,
i2p_tunneled: this.newInterfaceI2PTunnelingEnabled === true,
connect_timeout: this.numOrNull(this.newInterfaceConnectTimeout),
@ -3256,10 +3475,13 @@ export default {
latitude: discoveryEnabled ? this.numOrNull(this.discovery.latitude) : null,
longitude: discoveryEnabled ? this.numOrNull(this.discovery.longitude) : null,
height: discoveryEnabled ? this.numOrNull(this.discovery.height) : null,
location_cmd: discoveryEnabled ? (this.discovery.location_cmd || "").trim() || null : null,
discovery_frequency: discoveryEnabled ? this.numOrNull(this.discovery.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(this.discovery.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(this.discovery.discovery_modulation) : null,
mode: this.sharedInterfaceSettings.mode || null,
recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
announces_from_internal: this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
network_name: this.sharedInterfaceSettings.network_name,
passphrase: this.sharedInterfaceSettings.passphrase,

View file

@ -1348,7 +1348,26 @@
"i2p_requirements_body": "Nur eine I2P-Schnittstelle ist erlaubt. Aktivieren Sie zuerst den Transportmodus in den Einstellungen und fügen Sie I2P zuletzt hinzu. I2P nicht aus einer Konfigurationsdatei importieren und nicht im Roh-Editor bearbeiten. Änderungen, die I2P in der Mitte lassen, werden beim Speichern und Start automatisch repariert.",
"i2p_transport_required": "Aktivieren Sie den Transportmodus in den Einstellungen, bevor Sie eine I2P-Schnittstelle hinzufügen.",
"i2p_already_exists": "Es existiert bereits eine I2P-Schnittstelle. Entfernen Sie sie, bevor Sie eine weitere hinzufügen.",
"i2p_import_forbidden": "I2P-Schnittstellen können nicht aus einer Datei importiert werden. Fügen Sie I2P nur über Schnittstelle hinzufügen hinzu."
"i2p_import_forbidden": "I2P-Schnittstellen können nicht aus einer Datei importiert werden. Fügen Sie I2P nur über Schnittstelle hinzufügen hinzu.",
"mode_default_full": "Standard (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Rekursive Pfadanfragen",
"recursive_prs_hint": "Erlaubt rekursive Pfadfindung auf dieser Schnittstelle unabhängig vom Modus (RNS recursive_prs).",
"announces_from_internal_label": "Announces von internen Schnittstellen weiterleiten",
"announces_from_internal_hint": "Wenn aus, werden Announces von Internal-Mode-Schnittstellen hier nicht weitergesendet (RNS announces_from_internal).",
"location_cmd_label": "Standortbefehl (optional)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Absoluter Pfad zu einem Programm, das latitude,longitude,height ausgibt. Reticulum startet es für Discovery-Announces (RNS location_cmd).",
"block_fast_flapping_label": "Schnell wechselnde Clients blockieren",
"block_fast_flapping_hint": "Ignoriert Clients, die sich zu schnell verbinden und trennen (RNS 1.3.9 BackboneInterface-Standard).",
"fast_flapping_block_time_label": "Sperrzeit (Minuten)",
"fast_flapping_threshold_label": "Schwelle (Sekunden)",
"fast_flapping_grace_label": "Kulanz-Flaps"
},
"map": {
"title": "Karte",

View file

@ -1224,6 +1224,25 @@
"backbone_transport_identity_label": "Transport identity (optional)",
"backbone_transport_identity_placeholder": "e53433e51cde34c42a3245ba3fe1ad69",
"backbone_transport_identity_hint": "Matches Reticulum backbone remote examples that only set remote and target_port. Use the hash when an operator or directory specifies it.",
"mode_default_full": "Default (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Recursive path requests",
"recursive_prs_hint": "Allow recursive path discovery on this interface regardless of mode (RNS recursive_prs).",
"announces_from_internal_label": "Propagate announces from internal interfaces",
"announces_from_internal_hint": "When off, announces received on internal-mode interfaces are not rebroadcast on this interface (RNS announces_from_internal).",
"location_cmd_label": "Location command (optional)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Absolute path to an executable that prints latitude,longitude,height. Reticulum runs it for discovery announces (RNS location_cmd).",
"block_fast_flapping_label": "Block fast-flapping clients",
"block_fast_flapping_hint": "Ignore clients that connect and disconnect too quickly (RNS 1.3.9 BackboneInterface defaults).",
"fast_flapping_block_time_label": "Block time (minutes)",
"fast_flapping_threshold_label": "Threshold (seconds)",
"fast_flapping_grace_label": "Grace flaps",
"loopback_local_title": "Local / loopback",
"loopback_local_body": "Reticulum uses a shared instance internally. To talk to another RNS process on this host, use a TCP Client to 127.0.0.1 with the instance port, or use an external interface module in the interface path.",
"loopback_local_docs_hint": "See the Interfaces chapter for supported types.",

View file

@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Solo se permite una interfaz I2P. Active primero el modo de transporte en Ajustes y añada I2P al final. No importe I2P desde un archivo de configuración ni la edite en el editor en bruto. Los cambios que dejen I2P en medio se reparan automáticamente al guardar y al iniciar.",
"i2p_transport_required": "Active el modo de transporte en Ajustes antes de añadir una interfaz I2P.",
"i2p_already_exists": "Ya existe una interfaz I2P. Elimínela antes de añadir otra.",
"i2p_import_forbidden": "Las interfaces I2P no se pueden importar desde un archivo. Añada I2P solo desde Añadir interfaz."
"i2p_import_forbidden": "Las interfaces I2P no se pueden importar desde un archivo. Añada I2P solo desde Añadir interfaz.",
"mode_default_full": "Predeterminado (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Solicitudes de ruta recursivas",
"recursive_prs_hint": "Permite descubrimiento recursivo de rutas en esta interfaz sin importar el modo (RNS recursive_prs).",
"announces_from_internal_label": "Propagar announces desde interfaces internas",
"announces_from_internal_hint": "Si está desactivado, los announces recibidos en interfaces en modo internal no se reemiten aquí (RNS announces_from_internal).",
"location_cmd_label": "Comando de ubicación (opcional)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Ruta absoluta a un ejecutable que imprime latitude,longitude,height. Reticulum lo ejecuta para announces de discovery (RNS location_cmd).",
"block_fast_flapping_label": "Bloquear clientes de flapping rápido",
"block_fast_flapping_hint": "Ignora clientes que se conectan y desconectan demasiado rápido (valores por defecto de BackboneInterface en RNS 1.3.9).",
"fast_flapping_block_time_label": "Tiempo de bloqueo (minutos)",
"fast_flapping_threshold_label": "Umbral (segundos)",
"fast_flapping_grace_label": "Flaps de gracia"
},
"map": {
"title": "Mapa",

View file

@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Vain yksi I2P-liittymä on sallittu. Ota ensin välitystila käyttöön asetuksissa ja lisää I2P viimeiseksi. Älä tuo I2P:tä asetustiedostosta äläkä muokkaa sitä raakaeditorissa. Muutokset, jotka jättävät I2P:n keskelle, korjataan automaattisesti tallennuksessa ja käynnistyksessä.",
"i2p_transport_required": "Ota välitystila käyttöön asetuksissa ennen I2P-liittymän lisäämistä.",
"i2p_already_exists": "I2P-liittymä on jo olemassa. Poista se ennen uuden lisäämistä.",
"i2p_import_forbidden": "I2P-liittymiä ei voi tuoda tiedostosta. Lisää I2P vain Lisää liittymä -sivulta."
"i2p_import_forbidden": "I2P-liittymiä ei voi tuoda tiedostosta. Lisää I2P vain Lisää liittymä -sivulta.",
"mode_default_full": "Oletus (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Rekursiiviset polkupyynnöt",
"recursive_prs_hint": "Sallii rekursiivisen polunlöydön tällä liittymällä tilasta riippumatta (RNS recursive_prs).",
"announces_from_internal_label": "Välitä announceja sisäisiltä liittymiltä",
"announces_from_internal_hint": "Kun pois päältä, internal-tilan liittymiltä saatuja announceja ei lähetetä uudelleen tällä liittymällä (RNS announces_from_internal).",
"location_cmd_label": "Sijaintikomento (valinnainen)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Absoluuttinen polku ohjelmaan, joka tulostaa latitude,longitude,height. Reticulum ajaa sen discovery-announceja varten (RNS location_cmd).",
"block_fast_flapping_label": "Estaa nopea flapping",
"block_fast_flapping_hint": "Ohittaa asiakkaat, jotka yhdistavat ja katkaisevat liian nopeasti (RNS 1.3.9 BackboneInterface-oletukset).",
"fast_flapping_block_time_label": "Estoaika (minuuttia)",
"fast_flapping_threshold_label": "Kynnys (sekuntia)",
"fast_flapping_grace_label": "Armo-flapit"
},
"map": {
"title": "Kartta",

View file

@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Une seule interface I2P est autorisée. Activez d'abord le mode transport dans les paramètres, puis ajoutez I2P en dernier. N'importez pas I2P depuis un fichier de configuration et ne la modifiez pas dans l'éditeur brut. Les changements qui laissent I2P au milieu sont réparés automatiquement à l'enregistrement et au démarrage.",
"i2p_transport_required": "Activez le mode transport dans les paramètres avant d'ajouter une interface I2P.",
"i2p_already_exists": "Une interface I2P existe déjà. Supprimez-la avant d'en ajouter une autre.",
"i2p_import_forbidden": "Les interfaces I2P ne peuvent pas être importées depuis un fichier. Ajoutez I2P uniquement via Ajouter une interface."
"i2p_import_forbidden": "Les interfaces I2P ne peuvent pas être importées depuis un fichier. Ajoutez I2P uniquement via Ajouter une interface.",
"mode_default_full": "Par défaut (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Demandes de chemin récursives",
"recursive_prs_hint": "Autorise la discovery récursive de chemins sur cette interface quel que soit le mode (RNS recursive_prs).",
"announces_from_internal_label": "Propager les announces depuis les interfaces internes",
"announces_from_internal_hint": "Si désactivé, les announces reçues sur des interfaces en mode internal ne sont pas réémises ici (RNS announces_from_internal).",
"location_cmd_label": "Commande de localisation (optionnel)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Chemin absolu vers un exécutable qui affiche latitude,longitude,height. Reticulum l'exécute pour les announces de discovery (RNS location_cmd).",
"block_fast_flapping_label": "Bloquer le flapping rapide",
"block_fast_flapping_hint": "Ignore les clients qui se connectent et se déconnectent trop rapidement (défauts BackboneInterface RNS 1.3.9).",
"fast_flapping_block_time_label": "Durée de blocage (minutes)",
"fast_flapping_threshold_label": "Seuil (secondes)",
"fast_flapping_grace_label": "Flaps de grace"
},
"map": {
"title": "Carte",

View file

@ -1348,7 +1348,26 @@
"i2p_requirements_body": "È consentita una sola interfaccia I2P. Abilita prima la modalità trasporto nelle Impostazioni, poi aggiungi I2P per ultima. Non importare I2P da un file di configurazione e non modificarla nell'editor grezzo. Le modifiche che lasciano I2P a metà elenco vengono riparate automaticamente al salvataggio e all'avvio.",
"i2p_transport_required": "Abilita la modalità trasporto nelle Impostazioni prima di aggiungere un'interfaccia I2P.",
"i2p_already_exists": "Esiste già un'interfaccia I2P. Rimuovila prima di aggiungerne un'altra.",
"i2p_import_forbidden": "Le interfacce I2P non possono essere importate da un file. Aggiungi I2P solo tramite Aggiungi interfaccia."
"i2p_import_forbidden": "Le interfacce I2P non possono essere importate da un file. Aggiungi I2P solo tramite Aggiungi interfaccia.",
"mode_default_full": "Predefinito (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Richieste di percorso ricorsive",
"recursive_prs_hint": "Consente la discovery ricorsiva dei percorsi su questa interfaccia indipendentemente dalla modalita (RNS recursive_prs).",
"announces_from_internal_label": "Propagare gli announce dalle interfacce interne",
"announces_from_internal_hint": "Se disattivato, gli announce ricevuti su interfacce in modalita internal non vengono ritrasmessi qui (RNS announces_from_internal).",
"location_cmd_label": "Comando posizione (opzionale)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Percorso assoluto a un eseguibile che stampa latitude,longitude,height. Reticulum lo esegue per gli announce di discovery (RNS location_cmd).",
"block_fast_flapping_label": "Blocca client con flapping rapido",
"block_fast_flapping_hint": "Ignora client che si connettono e disconnettono troppo in fretta (default BackboneInterface RNS 1.3.9).",
"fast_flapping_block_time_label": "Tempo di blocco (minuti)",
"fast_flapping_threshold_label": "Soglia (secondi)",
"fast_flapping_grace_label": "Flap di grazia"
},
"map": {
"title": "Mappa",

View file

@ -1296,7 +1296,26 @@
"i2p_requirements_body": "Er is slechts één I2P-interface toegestaan. Schakel eerst transportmodus in bij Instellingen en voeg I2P als laatste toe. Importeer I2P niet uit een configbestand en bewerk het niet in de ruwe editor. Wijzigingen die I2P middenin laten staan worden automatisch hersteld bij opslaan en opstarten.",
"i2p_transport_required": "Schakel transportmodus in bij Instellingen voordat je een I2P-interface toevoegt.",
"i2p_already_exists": "Er bestaat al een I2P-interface. Verwijder die voordat je er nog een toevoegt.",
"i2p_import_forbidden": "I2P-interfaces kunnen niet uit een bestand worden geïmporteerd. Voeg I2P alleen toe via Interface toevoegen."
"i2p_import_forbidden": "I2P-interfaces kunnen niet uit een bestand worden geïmporteerd. Voeg I2P alleen toe via Interface toevoegen.",
"mode_default_full": "Standaard (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Recursieve padverzoeken",
"recursive_prs_hint": "Staat recursieve padontdekking toe op deze interface ongeacht de modus (RNS recursive_prs).",
"announces_from_internal_label": "Announces van interne interfaces doorgeven",
"announces_from_internal_hint": "Indien uit, worden announces ontvangen op internal-mode interfaces hier niet opnieuw uitgezonden (RNS announces_from_internal).",
"location_cmd_label": "Locatiecommando (optioneel)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Absoluut pad naar een uitvoerbaar bestand dat latitude,longitude,height print. Reticulum voert dit uit voor discovery-announces (RNS location_cmd).",
"block_fast_flapping_label": "Snelle flapping-clients blokkeren",
"block_fast_flapping_hint": "Negeert clients die te snel verbinden en verbreken (RNS 1.3.9 BackboneInterface-standaarden).",
"fast_flapping_block_time_label": "Blokkeertijd (minuten)",
"fast_flapping_threshold_label": "Drempel (seconden)",
"fast_flapping_grace_label": "Grace-flaps"
},
"map": {
"title": "Kaart",

View file

@ -1348,7 +1348,26 @@
"i2p_requirements_body": "Допускается только один интерфейс I2P. Сначала включите режим транспорта в настройках, затем добавьте I2P последним. Не импортируйте I2P из файла конфигурации и не редактируйте его в сыром редакторе. Изменения, оставляющие I2P не последним, автоматически исправляются при сохранении и запуске.",
"i2p_transport_required": "Включите режим транспорта в настройках перед добавлением интерфейса I2P.",
"i2p_already_exists": "Интерфейс I2P уже существует. Удалите его перед добавлением другого.",
"i2p_import_forbidden": "Интерфейсы I2P нельзя импортировать из файла. Добавляйте I2P только через страницу добавления интерфейса."
"i2p_import_forbidden": "Интерфейсы I2P нельзя импортировать из файла. Добавляйте I2P только через страницу добавления интерфейса.",
"mode_default_full": "По умолчанию (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "Рекурсивные запросы путей",
"recursive_prs_hint": "Разрешает рекурсивный поиск путей на этом интерфейсе вне зависимости от режима (RNS recursive_prs).",
"announces_from_internal_label": "Распространять announces с внутренних интерфейсов",
"announces_from_internal_hint": "Если выключено, announces с интерфейсов в режиме internal не ретранслируются здесь (RNS announces_from_internal).",
"location_cmd_label": "Команда местоположения (необязательно)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "Абсолютный путь к исполняемому файлу, печатающему latitude,longitude,height. Reticulum запускает его для discovery-announces (RNS location_cmd).",
"block_fast_flapping_label": "Блокировать быстрый flapping",
"block_fast_flapping_hint": "Игнорирует клиентов, которые слишком быстро подключаются и отключаются (стандарты BackboneInterface RNS 1.3.9).",
"fast_flapping_block_time_label": "Время блокировки (минуты)",
"fast_flapping_threshold_label": "Порог (секунды)",
"fast_flapping_grace_label": "Льготные flaps"
},
"map": {
"title": "Карта",

View file

@ -1296,7 +1296,26 @@
"i2p_requirements_body": "只允许一个 I2P 接口。请先在设置中启用传输模式,再将 I2P 添加为最后一个接口。不要从配置文件导入 I2P也不要在原始配置编辑器中修改它。若 I2P 不在末尾,保存和启动时会自动修复。",
"i2p_transport_required": "添加 I2P 接口前请先在设置中启用传输模式。",
"i2p_already_exists": "已存在 I2P 接口。请先删除后再添加另一个。",
"i2p_import_forbidden": "不能从文件导入 I2P 接口。请仅通过“添加接口”页面添加 I2P。"
"i2p_import_forbidden": "不能从文件导入 I2P 接口。请仅通过“添加接口”页面添加 I2P。",
"mode_default_full": "默认 (Full)",
"mode_full": "Full",
"mode_gateway": "Gateway",
"mode_access_point": "Access Point",
"mode_roaming": "Roaming",
"mode_boundary": "Boundary",
"mode_internal": "Internal",
"recursive_prs_label": "递归路径请求",
"recursive_prs_hint": "无论接口模式如何都允许在此接口上进行递归路径发现RNS recursive_prs。",
"announces_from_internal_label": "传播来自内部接口的通告",
"announces_from_internal_hint": "关闭后,在 internal 模式接口上收到的通告不会在此接口上重播RNS announces_from_internal。",
"location_cmd_label": "位置命令(可选)",
"location_cmd_placeholder": "/path/to/gps-script",
"location_cmd_hint": "可执行文件的绝对路径,输出 latitude,longitude,height。Reticulum 会在发现通告时运行它RNS location_cmd。",
"block_fast_flapping_label": "阻止快速抖动客户端",
"block_fast_flapping_hint": "忽略连接与断开过快的客户端RNS 1.3.9 BackboneInterface 默认值)。",
"fast_flapping_block_time_label": "封锁时间(分钟)",
"fast_flapping_threshold_label": "阈值(秒)",
"fast_flapping_grace_label": "宽限抖动次数"
},
"map": {
"title": "地图",

View file

@ -93,3 +93,80 @@ def test_normalize_rnode_tcp_port_bracket_ipv6_with_port():
def test_normalize_rnode_tcp_port_non_tcp_unchanged():
assert InterfaceEditor.normalize_rnode_tcp_port("/dev/ttyUSB0") == "/dev/ttyUSB0"
def test_normalize_interface_mode_aliases():
assert InterfaceEditor.normalize_interface_mode("internal") == "internal"
assert InterfaceEditor.normalize_interface_mode("GW") == "gateway"
assert InterfaceEditor.normalize_interface_mode("ap") == "access_point"
assert InterfaceEditor.normalize_interface_mode("evil") is None
assert InterfaceEditor.normalize_interface_mode("") is None
def test_apply_interface_mode_rejects_unknown():
details = {}
err = InterfaceEditor.apply_interface_mode(details, {"mode": "not-a-mode"})
assert err is not None
assert "mode" not in details
def test_apply_interface_mode_canonicalizes():
details = {}
assert InterfaceEditor.apply_interface_mode(details, {"mode": "internal"}) is None
assert details["mode"] == "internal"
assert InterfaceEditor.apply_interface_mode(details, {"mode": "gw"}) is None
assert details["mode"] == "gateway"
def test_apply_yes_no_option_recursive_prs():
details = {}
assert (
InterfaceEditor.apply_yes_no_option(
details, {"recursive_prs": True}, "recursive_prs"
)
is None
)
assert details["recursive_prs"] == "yes"
assert (
InterfaceEditor.apply_yes_no_option(
details,
{"announces_from_internal": False},
"announces_from_internal",
)
is None
)
assert details["announces_from_internal"] == "no"
def test_validate_location_cmd_rejects_shell_metacharacters():
assert InterfaceEditor.validate_location_cmd("/usr/bin/true") is None
assert InterfaceEditor.validate_location_cmd("~/bin/gps.sh") is None
assert InterfaceEditor.validate_location_cmd("relative/path") is not None
assert InterfaceEditor.validate_location_cmd("/tmp/evil;rm -rf /") is not None
assert InterfaceEditor.validate_location_cmd("/tmp/$(id)") is not None
assert InterfaceEditor.validate_location_cmd("/tmp/../etc/passwd") is not None
def test_apply_location_cmd_normalizes_home():
details = {}
err = InterfaceEditor.apply_location_cmd(details, {"location_cmd": "~/gps-loc"})
assert err is None
assert details["location_cmd"].endswith("gps-loc")
assert details["location_cmd"].startswith("/")
def test_sanitize_imported_rns_options_rejects_bad_mode():
body = {"mode": "warehouse"}
assert InterfaceEditor.sanitize_imported_rns_options(body) is not None
def test_sanitize_imported_rns_options_accepts_internal():
body = {
"mode": "internal",
"recursive_prs": "yes",
"announces_from_internal": "no",
}
assert InterfaceEditor.sanitize_imported_rns_options(body) is None
assert body["mode"] == "internal"
assert body["recursive_prs"] == "yes"
assert body["announces_from_internal"] == "no"

View file

@ -737,4 +737,94 @@ async def test_i2p_connectable_can_be_disabled(temp_dir):
saved = config["interfaces"]["I2POut"]
assert saved["connectable"] == "False"
assert saved["peers"] == ["abcdef.b32.i2p"]
assert list(config["interfaces"].keys())[-1] == "I2POut"
@pytest.mark.asyncio
async def test_internal_mode_and_rns_bool_options_persist(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
free_port = _free_port("tcp")
async with make_app(temp_dir, config) as handler:
payload = {
"name": "InternalTCP",
"type": "TCPServerInterface",
"listen_ip": "127.0.0.1",
"listen_port": free_port,
"mode": "internal",
"recursive_prs": True,
"announces_from_internal": False,
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 200, body
saved = config["interfaces"]["InternalTCP"]
assert saved["mode"] == "internal"
assert saved["recursive_prs"] == "yes"
assert saved["announces_from_internal"] == "no"
@pytest.mark.asyncio
async def test_rejects_unknown_interface_mode(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
free_port = _free_port("tcp")
async with make_app(temp_dir, config) as handler:
payload = {
"name": "BadMode",
"type": "TCPServerInterface",
"listen_ip": "127.0.0.1",
"listen_port": free_port,
"mode": "warehouse",
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 422, body
assert "mode" in body["message"].lower()
assert "BadMode" not in config["interfaces"]
@pytest.mark.asyncio
async def test_rejects_unsafe_location_cmd(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
free_port = _free_port("tcp")
async with make_app(temp_dir, config) as handler:
payload = {
"name": "Disco",
"type": "TCPServerInterface",
"listen_ip": "127.0.0.1",
"listen_port": free_port,
"discoverable": "yes",
"mode": "gateway",
"location_cmd": "/tmp/gps; rm -rf /",
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 422, body
assert "location_cmd" in body["message"]
@pytest.mark.asyncio
async def test_backbone_listener_fast_flapping_options(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
free_port = _free_port("tcp")
async with make_app(temp_dir, config) as handler:
payload = {
"name": "BackboneFlap",
"type": "BackboneInterface",
"listen_ip": "127.0.0.1",
"listen_port": free_port,
"block_fast_flapping": True,
"fast_flapping_block_time": 60,
"fast_flapping_threshold": 15,
"fast_flapping_grace": 3,
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 200, body
saved = config["interfaces"]["BackboneFlap"]
assert saved["block_fast_flapping"] == "yes"
assert saved["fast_flapping_block_time"] == 60
assert saved["fast_flapping_threshold"] == 15
assert saved["fast_flapping_grace"] == 3

View file

@ -30,3 +30,37 @@ def test_speed_str_bitrate_not_scaled():
assert speed_str(100) == "100.00 bps"
assert speed_str(5_000_000) == "5.00 Mbps"
assert speed_str(8_000_000) == "8.00 Mbps"
def test_rnstatus_mode_labels_match_rns_constants():
from unittest.mock import MagicMock
import RNS
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
iface = RNS.Interfaces.Interface.Interface
cases = {
iface.MODE_FULL: "Full",
iface.MODE_POINT_TO_POINT: "Point-to-Point",
iface.MODE_ACCESS_POINT: "Access Point",
iface.MODE_ROAMING: "Roaming",
iface.MODE_BOUNDARY: "Boundary",
iface.MODE_GATEWAY: "Gateway",
iface.MODE_INTERNAL: "Internal",
}
handler = RNStatusHandler(MagicMock())
for mode_value, label in cases.items():
status = handler.get_status(
stats={
"interfaces": [
{
"name": "TestInterface",
"status": True,
"mode": mode_value,
},
],
},
include_local_blackhole=False,
)
assert status["interfaces"][0]["mode"] == label, mode_value

View file

@ -325,4 +325,77 @@ describe("AddInterfacePage.vue interface options", () => {
expect(ToastUtils.error).toHaveBeenCalledWith(expect.stringContaining("already in use"));
});
it("sends internal mode and recursive_prs / announces_from_internal", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "InternalLAN";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "127.0.0.1";
wrapper.vm.newInterfaceListenPort = 4242;
wrapper.vm.sharedInterfaceSettings.mode = "internal";
wrapper.vm.sharedInterfaceSettings.recursive_prs = true;
wrapper.vm.sharedInterfaceSettings.announces_from_internal = false;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
mode: "internal",
recursive_prs: true,
announces_from_internal: false,
})
);
});
it("sends BackboneInterface fast-flapping options in listener mode", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "Backbone";
wrapper.vm.newInterfaceType = "BackboneInterface";
wrapper.vm.newInterfaceBackboneListenMode = true;
wrapper.vm.newInterfaceBackboneListenIp = "0.0.0.0";
wrapper.vm.newInterfaceBackboneListenPort = 5151;
wrapper.vm.newInterfaceBlockFastFlapping = false;
wrapper.vm.newInterfaceFastFlappingBlockTime = 30;
wrapper.vm.newInterfaceFastFlappingThreshold = 10;
wrapper.vm.newInterfaceFastFlappingGrace = 2;
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
block_fast_flapping: false,
fast_flapping_block_time: 30,
fast_flapping_threshold: 10,
fast_flapping_grace: 2,
})
);
});
it("sends discovery location_cmd when discoverable", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "TCPS";
wrapper.vm.newInterfaceType = "TCPServerInterface";
wrapper.vm.newInterfaceListenIp = "0.0.0.0";
wrapper.vm.newInterfaceListenPort = 4242;
wrapper.vm.discovery.discoverable = true;
wrapper.vm.discovery.discovery_name = "Public";
wrapper.vm.discovery.location_cmd = "/usr/local/bin/gps-loc";
wrapper.vm.sharedInterfaceSettings.mode = "gateway";
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
discoverable: "yes",
location_cmd: "/usr/local/bin/gps-loc",
mode: "gateway",
})
);
});
});