feat: integrate HTTPInterface for RNS-over-HTTP support, improve build metadata handling, and update dependencies

This commit is contained in:
Ivan 2026-07-24 05:40:54 -05:00
parent 4ac2701422
commit b960db2e8b
No known key found for this signature in database
68 changed files with 7623 additions and 262 deletions

View file

@ -183,6 +183,13 @@ jobs:
"chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
"httpx-0.28.1-py3-none-any.whl"
"httpcore-*-py3-none-any.whl"
"h2-*-py3-none-any.whl"
"h11-*-py3-none-any.whl"
"anyio-*-py3-none-any.whl"
"hpack-*-py3-none-any.whl"
"hyperframe-*-py3-none-any.whl"
)
missing=0
for pattern in "${required[@]}"; do

View file

@ -211,6 +211,13 @@ jobs:
"chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
"httpx-0.28.1-py3-none-any.whl"
"httpcore-*-py3-none-any.whl"
"h2-*-py3-none-any.whl"
"h11-*-py3-none-any.whl"
"anyio-*-py3-none-any.whl"
"hpack-*-py3-none-any.whl"
"hyperframe-*-py3-none-any.whl"
)
missing=0
for pattern in "${required[@]}"; do

3
.gitignore vendored
View file

@ -177,3 +177,6 @@ scripts/private/
*.rsm
*.rfe
!/meshchatx.rsm
# Generated at build time (scripts/bake_build_meta.js)
meshchatx/src/_build_meta_baked.py

View file

@ -28,17 +28,21 @@ All notable changes to this project will be documented in this file.
- Relay Chat room keys so hosts can require a key to join a room
- Desktop privacy: Windows screen security to omit MeshChatX from screenshots, recording, and Recall
- Android privacy options to block screenshots and clear the clipboard when backgrounded
- Tutorial connect: **Internet + local (recommended)** adds AutoInterface and pre-selects 3 random community TCP bootstraps
- Builds bake git commit and channel into runtime metadata for About (commit) and sidebar (dev label plus short SHA on nightly, preview, and local builds)
- Tutorial connect: Internet + local (recommended) adds AutoInterface and pre-selects 3 random community TCP bootstraps
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
- Post-install prompts for existing users after upgrades
- Coolify-oriented Docker Compose with resource limits for deployments
- LXST telephony half-duplex mode, live duplex switching, push-to-talk (packetizer squelch), and richer in-call stats (rates plus mute state on the active call)
- Optional Linux seccomp-BPF syscall denylist (libseccomp) alongside Landlock, with auto-detect and `MESHCHAT_SECCOMP` fallback
- Optional Linux seccomp-BPF syscall denylist (libseccomp) alongside Landlock, with auto-detect and MESHCHAT_SECCOMP fallback
- Bundled [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) HTTPInterface with Interfaces page client/server setup, auto-install into the Reticulum interface path, and httpx support (Android includes httpx with HTTP/2 as pure-Python wheels)
### Changed
- Tutorial: language/theme controls no longer overlay bootstrap titles. Connection and bootstrap actions stay locked while discovery or random pick is running
- Tutorial connect/bootstrap: recommended mode stays on the bootstrap step for next/back, Finish is only on the last step, and random-pick races no longer stick busy or mark auto-pick done without a selection
- Tutorial privacy/security step: shorter mobile copy and toggle layout so labels and switches fit
- Tutorial nav copy uses ASCII `->` instead of Unicode arrows
- Auto propagation finder: require usable RNS paths and a scarce LXMF sync probe (treat **PR_COMPLETE** as success), remember verified destination hashes per identity, prefer live announces over memory ghosts, cool down failures, and avoid disruptive re-probes of a working preferred peer
- Community interface presets refreshed from directory.rns.recipes (69 online listings)
- Bundled Reticulum manual refetch (reticulum_docs_bundle.json timestamp)
- Discovery map markers use dual-halo badge icons (peers, LXMF, discovered, tracking, stale) with banded cluster badges, zoom-gated labels, and cached styles for denser maps
@ -61,7 +65,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- Desktop AppImage: main-process logs always append to storage `logs/meshchatx.log`. Stdout is only used when a TTY is attached, with broken-pipe guards as a fallback, so background launches no longer raise **write EPIPE** dialogs
- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
- Startup check and disable unsupported interfaces
@ -83,7 +87,7 @@ All notable changes to this project will be documented in this file.
- Unread badges dismiss when navigating back to an already-open Messages or Relay Chat room
- 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
- RNStatus interface mode labels match Reticulum modes again, including Internal
## [4.7.2] - 2026-07-06

View file

@ -6,7 +6,7 @@ Native APK with embedded Python (`meshchatx/`) and a WebView UI.
- Android SDK (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) with `cmdline-tools` and a matching **NDK** (see `android/app/build.gradle` for the pinned NDK version used in CI).
- **JDK 17** (Temurin or compatible).
- Chaquopy vendor wheels under `android/vendor/` (build locally with `bash scripts/build-android-wheels-local.sh` from repo root, or use CI artifacts).
- Chaquopy vendor wheels under `android/vendor/` (build locally with `bash scripts/build-android-wheels-local.sh` from repo root, or use CI artifacts). That script builds native recipes under `android/chaquopy-recipes/` and also vendors pure-Python wheels such as bleak and `httpx[http2]` (for bundled RNS-over-HTTP / `HTTPInterface`).
## Lint and static analysis

View file

@ -129,7 +129,24 @@ tasks.register("verifyVendorWheels") {
"cbor2": "5.6.5",
"cryptography": "49.0.0",
]
def purePythonVendorPrefixes = [
"httpx-0.28.1-",
"httpcore-",
"h2-",
"h11-",
"anyio-",
"hpack-",
"hyperframe-",
]
def wheels = vendorWheelDir.list()?.toList() ?: []
purePythonVendorPrefixes.each { prefix ->
if (!wheels.any { it.startsWith(prefix) && it.endsWith(".whl") }) {
throw new org.gradle.api.GradleException(
"Missing ${prefix}*.whl in ${vendorWheelDir} (httpx/RNS-over-HTTP). " +
"Run: bash scripts/build-android-wheels-local.sh"
)
}
}
selectedAndroidAbis.each { abi ->
def abiTag = abi.replace("-", "_")
vendorPackages.each { pkg, ver ->
@ -364,6 +381,9 @@ chaquopy {
install "usbserial4a==0.4.0"
install "ply>=3.11,<4.0"
install "cbor2==5.6.5"
// Pure-python; wheels are vendored by scripts/build-android-wheels-local.sh
// for offline/find-links installs (HTTPInterface / RNS-over-HTTP).
install "httpx[http2]==0.28.1"
}
}
}

View file

@ -24,6 +24,7 @@ The **Add interface** flow includes:
| KISSInterface | KISS TNC devices |
| I2PInterface | I2P-based Reticulum transport |
| AutoInterface | Automatic discovery on local networks |
| HTTPInterface | HTTP/S tunnel (bundled RNS-over-HTTP) |
| Custom external types | Advanced setups |
Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
@ -44,6 +45,10 @@ LoRa setups often need firmware management. **Tools → RNode Flasher** opens th
MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
## HTTP tunnel interface
MeshChatX vendors [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) and installs `HTTPInterface.py` into your Reticulum `interfacepath` on startup. Use **Add interface → HTTP Tunnel** for client or server mode when only HTTP/S egress is available. Default transport is HTTP/1.1. HTTP/2 and HTTP/3 need TLS and optional extra packages on the server side.
## Getting onto the mesh
A minimal path for a new node:

View file

@ -38,7 +38,7 @@ MeshChatX can:
- Run a **local propagation node** on your identity
- **Sync** with remote propagation nodes you trust
- **Auto-select** a preferred node via `AutoPropagationManager`
- **Auto-select** a preferred propagation peer via `AutoPropagationManager` from local `lxmf.propagation` announces and RNS paths (small sync probe, remembered destination hashes, no central directory)
- **Retry** failed direct deliveries through propagation when configured
Manage nodes from **Tools → Propagation nodes** or related settings entries.

Binary file not shown.

View file

@ -1590,6 +1590,18 @@ class ReticulumMeshChat:
guard_invalid_rnode_txpower_in_config(config_path)
i2p_support.guard_i2p_interfaces_in_config(config_path)
ensure_safe_reticulum_runtime_flags(config_path)
try:
from meshchatx.src.backend.interface_module_store import (
ensure_bundled_interface_modules,
)
ensure_bundled_interface_modules(config_dir)
except Exception as exc:
logger.warning(
"Failed to sync bundled interface modules into %s: %s",
config_dir,
exc,
)
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
previous = getattr(self, "_startup_stage", None)
@ -3003,6 +3015,22 @@ class ReticulumMeshChat:
def get_app_version() -> str:
return app_version
@staticmethod
def get_build_meta() -> dict:
"""Baked git commit / channel from meshchatx.src.build_meta."""
try:
from meshchatx.src import build_meta as _build_meta
return dict(_build_meta.as_dict(app_version))
except Exception:
return {
"git_commit": "",
"git_commit_short": "",
"build_channel": "local",
"is_dev_build": False,
"display_version": app_version,
}
def _api_reticulum_config_path(self) -> str | None:
r = getattr(self, "reticulum", None)
if r is not None:

View file

@ -1,15 +1,37 @@
# SPDX-License-Identifier: 0BSD
"""Auto-select a usable LXMF propagation peer from local announces and paths.
Zen of Reticulum notes for this manager:
- There is no privileged propagation center. Candidates are destination hashes
heard via lxmf.propagation announces, plus local memory of peers that already
proved reachable for this identity.
- Trust is experience: a usable RNS path and a successful LXMF sync probe, not a
hostname, directory, or fixed server role.
- Airtime is scarce: prefer sticky verified peers, cool down failures, probe at
most a few peers per cycle, and request at most one message during a probe.
- Delay is normal: missing paths request rediscovery and keep recoverable state
instead of clearing the preferred hash on the first blip.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
from typing import Any
import RNS
from LXMF.LXMRouter import LXMRouter
from meshchatx.src.backend import reticulum_pathfinding
from meshchatx.src.backend.async_utils import AsyncUtils
from meshchatx.src.backend.meshchat_utils import parse_lxmf_propagation_node_app_data
from meshchatx.src.backend.meshchat_utils import (
parse_lxmf_propagation_node_app_data,
propagation_sync_idle_like,
)
_PROP_FAILURE_STATES = frozenset(
{
@ -22,10 +44,39 @@ _PROP_FAILURE_STATES = frozenset(
LXMRouter.PR_PATH_TIMEOUT,
},
)
_PROP_SUCCESS_STATES = frozenset(
{
LXMRouter.PR_IDLE,
LXMRouter.PR_COMPLETE,
},
)
PATH_WAIT_SECONDS = 40.0
SYNC_PROBE_TIMEOUT_SECONDS = 120.0
POLL_INTERVAL_SECONDS = 0.2
CHECK_INTERVAL_SECONDS = 300
# Keep a working preferred peer without full sync probes this often.
REVERIFY_SECONDS = 1800
# Avoid hammering peers that just failed a sync probe.
FAILURE_COOLDOWN_SECONDS = 600
# Do not switch away from a working preferred peer for a tiny hop gain.
HOP_SWITCH_HYSTERESIS = 2
# Clear the active outbound peer only after repeated verify failures.
MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR = 3
MAX_MEMORY_NODES = 12
# Scarcity: do not walk the whole announce table in one cycle.
MAX_PROBES_PER_CYCLE = 2
# Scarcity: prove sync/access without pulling a bulk mailbox.
PROBE_MAX_MESSAGES = 1
UNKNOWN_HOPS = 10**9
MEMORY_CONFIG_KEY = "lxmf_preferred_propagation_node_memory"
def _pretty_dest(node_hex: str) -> str:
try:
return RNS.prettyhexrep(bytes.fromhex(node_hex))
except Exception:
return "<invalid>"
class AutoPropagationManager:
@ -35,14 +86,16 @@ class AutoPropagationManager:
self.config = context.config
self.database = context.database
self.running = False
self._last_check = 0
self._check_interval = 300 # 5 minutes
self._check_interval = CHECK_INTERVAL_SECONDS
# In-process cache mirrors identity-scoped config memory.
self._memory: dict[str, dict[str, Any]] = {}
self._load_memory()
def stop(self):
self.running = False
async def _run(self):
# Wait a bit after startup to allow discovers to come in
# Wait after startup so announces and paths can settle.
await asyncio.sleep(10)
self.running = True
@ -54,20 +107,221 @@ class AutoPropagationManager:
break
except Exception as e:
print(
f"Error in AutoPropagationManager for {self.context.identity_hash}: {e}",
f"Error in AutoPropagationManager: {type(e).__name__}: {e}",
)
await asyncio.sleep(self._check_interval)
async def _wait_for_path(self, dest_hash: bytes, timeout: float) -> bool:
r = self.app.reticulum if self.app and hasattr(self.app, "reticulum") else None
return await reticulum_pathfinding.wait_for_path(
r,
dest_hash,
timeout,
poll_interval=POLL_INTERVAL_SECONDS,
def _load_memory(self) -> None:
raw = None
with contextlib.suppress(Exception):
raw = self.config.get(MEMORY_CONFIG_KEY, default_value=None)
self._memory = self._parse_memory(raw)
def _save_memory(self) -> None:
trimmed = self._trim_memory(self._memory)
self._memory = trimmed
payload = json.dumps(trimmed, separators=(",", ":"), sort_keys=True)
with contextlib.suppress(Exception):
self.config.set(MEMORY_CONFIG_KEY, payload)
@staticmethod
def _parse_memory(raw: Any) -> dict[str, dict[str, Any]]:
if not raw:
return {}
try:
data = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, json.JSONDecodeError, ValueError):
return {}
if not isinstance(data, dict):
return {}
out: dict[str, dict[str, Any]] = {}
for key, value in data.items():
if not isinstance(key, str) or not isinstance(value, dict):
continue
try:
bytes.fromhex(key)
except ValueError:
continue
if len(key) != (RNS.Identity.TRUNCATED_HASHLENGTH // 8) * 2:
continue
out[key.lower()] = {
"successes": int(value.get("successes") or 0),
"failures": int(value.get("failures") or 0),
"consecutive_failures": int(value.get("consecutive_failures") or 0),
"last_success_at": int(value.get("last_success_at") or 0),
"last_failure_at": int(value.get("last_failure_at") or 0),
"last_hops": int(value.get("last_hops") or UNKNOWN_HOPS),
}
return out
@staticmethod
def _trim_memory(memory: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
if len(memory) <= MAX_MEMORY_NODES:
return memory
def rank(item: tuple[str, dict[str, Any]]) -> tuple[int, int, int]:
entry = item[1]
return (
int(entry.get("last_success_at") or 0),
int(entry.get("successes") or 0),
-int(entry.get("consecutive_failures") or 0),
)
keep = sorted(memory.items(), key=rank, reverse=True)[:MAX_MEMORY_NODES]
return {key: value for key, value in keep}
def _memory_entry(self, node_hex: str) -> dict[str, Any]:
return self._memory.setdefault(
node_hex.lower(),
{
"successes": 0,
"failures": 0,
"consecutive_failures": 0,
"last_success_at": 0,
"last_failure_at": 0,
"last_hops": UNKNOWN_HOPS,
},
)
def _record_success(self, node_hex: str, hops: int) -> None:
entry = self._memory_entry(node_hex)
entry["successes"] = int(entry.get("successes") or 0) + 1
entry["consecutive_failures"] = 0
entry["last_success_at"] = int(time.time())
entry["last_hops"] = (
hops if hops < UNKNOWN_HOPS else entry.get("last_hops", UNKNOWN_HOPS)
)
self._save_memory()
def _record_failure(self, node_hex: str) -> None:
entry = self._memory_entry(node_hex)
entry["failures"] = int(entry.get("failures") or 0) + 1
entry["consecutive_failures"] = int(entry.get("consecutive_failures") or 0) + 1
entry["last_failure_at"] = int(time.time())
self._save_memory()
def _in_failure_cooldown(self, node_hex: str, now: float | None = None) -> bool:
entry = self._memory.get(node_hex.lower())
if not entry:
return False
if int(entry.get("consecutive_failures") or 0) <= 0:
return False
last_failure = int(entry.get("last_failure_at") or 0)
if last_failure <= 0:
return False
clock = time.time() if now is None else now
return (clock - last_failure) < FAILURE_COOLDOWN_SECONDS
def _recently_verified(self, node_hex: str, now: float | None = None) -> bool:
entry = self._memory.get(node_hex.lower())
if not entry:
return False
if int(entry.get("consecutive_failures") or 0) > 0:
return False
last_success = int(entry.get("last_success_at") or 0)
if last_success <= 0:
return False
clock = time.time() if now is None else now
return (clock - last_success) < REVERIFY_SECONDS
@staticmethod
def _path_quality_ok(dest_hash: bytes) -> bool:
try:
if not RNS.Transport.has_path(dest_hash):
return False
if RNS.Transport.path_is_unresponsive(dest_hash):
return False
if reticulum_pathfinding.transport_path_table_entry_is_expired(dest_hash):
return False
return True
except Exception:
return False
def _hops_to(self, dest_hash: bytes) -> int:
try:
if RNS.Transport.has_path(dest_hash):
hops = RNS.Transport.hops_to(dest_hash)
if isinstance(hops, int) and hops >= 0:
return hops
except Exception:
pass
return UNKNOWN_HOPS
def _candidate_score(
self,
node_hex: str,
hops: int,
path_ok: bool,
from_announce: bool,
now: float,
) -> float:
"""Lower is better. Announced presence beats memory-only ghosts."""
score = float(hops if hops < UNKNOWN_HOPS else 64)
if not path_ok:
score += 80.0
if not from_announce:
# Announces are presence. Memory is only a recovery hint.
score += 12.0
entry = self._memory.get(node_hex.lower())
if entry:
successes = int(entry.get("successes") or 0)
consecutive = int(entry.get("consecutive_failures") or 0)
last_success = int(entry.get("last_success_at") or 0)
score -= min(successes, 8) * 0.75
score += consecutive * 6.0
if last_success > 0:
age = max(0.0, now - last_success)
score -= max(0.0, 4.0 - (age / 900.0))
if self._in_failure_cooldown(node_hex, now=now):
score += 1000.0
return score
async def _wait_for_usable_path(self, dest_hash: bytes, timeout: float) -> bool:
r = self.app.reticulum if self.app and hasattr(self.app, "reticulum") else None
reticulum_pathfinding.prepare_fresh_path_request(r, dest_hash)
deadline = time.monotonic() + timeout
nudged = False
while time.monotonic() < deadline:
if self._path_quality_ok(dest_hash):
return True
try:
has_path = RNS.Transport.has_path(dest_hash)
except Exception:
has_path = False
if has_path and not nudged:
# Stale or unresponsive path: drop and request again once.
if r is not None:
with contextlib.suppress(Exception):
r.drop_path(dest_hash)
else:
with contextlib.suppress(Exception):
RNS.Transport.expire_path(dest_hash)
reticulum_pathfinding.nudge_path_request(dest_hash)
nudged = True
elif (
not has_path
and not nudged
and (deadline - time.monotonic()) < (timeout * 0.45)
):
reticulum_pathfinding.nudge_path_request(dest_hash)
nudged = True
await asyncio.sleep(POLL_INTERVAL_SECONDS)
return self._path_quality_ok(dest_hash)
async def _settle_router_idle(self, router, seconds: float = 5.0) -> None:
settle_deadline = time.monotonic() + seconds
while time.monotonic() < settle_deadline:
if propagation_sync_idle_like(router.propagation_transfer_state):
with contextlib.suppress(Exception):
if router.propagation_transfer_state == LXMRouter.PR_COMPLETE:
router.propagation_transfer_state = LXMRouter.PR_IDLE
return
await asyncio.sleep(POLL_INTERVAL_SECONDS)
with contextlib.suppress(Exception):
router.propagation_transfer_state = LXMRouter.PR_IDLE
async def _probe_propagation_sync(self, node_hex: str) -> bool:
ctx = self.context
router = ctx.message_router
@ -80,48 +334,51 @@ class AutoPropagationManager:
except Exception:
return False
# Ensure any previous sync is fully cancelled and state is idle
# Cancel any previous sync and wait for idle so stale non-idle state
# cannot look like a successful probe.
self.app.stop_propagation_node_sync(context=ctx)
settle_deadline = time.monotonic() + 5.0
while time.monotonic() < settle_deadline:
if router.propagation_transfer_state == LXMRouter.PR_IDLE:
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
with contextlib.suppress(Exception):
router.propagation_transfer_state = LXMRouter.PR_IDLE
await self._settle_router_idle(router)
try:
router.set_outbound_propagation_node(dest)
except Exception:
return False
router.request_messages_from_propagation_node(ctx.identity)
# LXMF needs a recalled identity to open the propagation link.
with contextlib.suppress(Exception):
if RNS.Identity.recall(dest) is None:
RNS.Transport.request_path(dest)
# Prove access with a tiny transfer budget. Full mailbox sync belongs
# to the operator-triggered or interval sync paths, not auto-select.
router.request_messages_from_propagation_node(
ctx.identity,
max_messages=PROBE_MAX_MESSAGES,
)
deadline = time.monotonic() + SYNC_PROBE_TIMEOUT_SECONDS
left_idle = False
# Wait for the sync to actually start (leave idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
if state != LXMRouter.PR_IDLE:
if state not in _PROP_SUCCESS_STATES:
left_idle = True
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
# Never left idle -> failed to start
self.app.stop_propagation_node_sync(context=ctx)
return False
# Wait for the sync to finish (return to idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
if state == LXMRouter.PR_IDLE:
# Success: we left idle and now we're back
# LXMF ends a successful sync at PR_COMPLETE (not always PR_IDLE).
if left_idle and state in _PROP_SUCCESS_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return True
await asyncio.sleep(POLL_INTERVAL_SECONDS)
@ -129,6 +386,127 @@ class AutoPropagationManager:
self.app.stop_propagation_node_sync(context=ctx)
return False
def _collect_candidates(self) -> tuple[dict[str, int], set[str]]:
"""Return destination_hex -> hops, plus the set heard via announces."""
announces = self.database.announces.get_announces(aspect="lxmf.propagation")
best_by_hex: dict[str, int] = {}
announced: set[str] = set()
for announce in announces or []:
dest_hex = announce.get("destination_hash")
if not isinstance(dest_hex, str):
continue
dest_hex = dest_hex.lower()
node_data = parse_lxmf_propagation_node_app_data(announce.get("app_data"))
if not node_data or not node_data.get("enabled", False):
continue
try:
dest_hash = bytes.fromhex(dest_hex)
except ValueError:
continue
hops = self._hops_to(dest_hash)
prev = best_by_hex.get(dest_hex)
if prev is None or hops < prev:
best_by_hex[dest_hex] = hops
announced.add(dest_hex)
# Remembered peers stay candidates when announces aged out, but they are
# recovery hints only, never a global registry.
now = time.time()
for node_hex, entry in self._memory.items():
if node_hex in best_by_hex:
continue
if int(entry.get("successes") or 0) <= 0:
continue
if int(entry.get("consecutive_failures") or 0) >= (
MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR
):
continue
last_success = int(entry.get("last_success_at") or 0)
if last_success <= 0 or (now - last_success) > (REVERIFY_SECONDS * 8):
continue
try:
dest_hash = bytes.fromhex(node_hex)
except ValueError:
continue
best_by_hex[node_hex] = self._hops_to(dest_hash)
return best_by_hex, announced
def _ordered_candidates(
self,
best_by_hex: dict[str, int],
announced: set[str],
previous_hex: str | None,
) -> list[tuple[float, int, str]]:
now = time.time()
scored: list[tuple[float, int, str]] = []
for node_hex, hops in best_by_hex.items():
try:
dest_hash = bytes.fromhex(node_hex)
except ValueError:
continue
path_ok = self._path_quality_ok(dest_hash)
score = self._candidate_score(
node_hex,
hops,
path_ok,
node_hex in announced,
now,
)
scored.append((score, hops, node_hex))
scored.sort(key=lambda item: (item[0], item[1], item[2]))
ordered: list[tuple[float, int, str]] = []
seen: set[str] = set()
if previous_hex and previous_hex in best_by_hex:
for item in scored:
if item[2] == previous_hex:
ordered.append(item)
seen.add(previous_hex)
break
for item in scored:
if item[2] not in seen:
ordered.append(item)
seen.add(item[2])
return ordered
def _should_keep_previous_without_probe(
self,
previous_hex: str | None,
best_by_hex: dict[str, int],
ordered: list[tuple[float, int, str]],
) -> bool:
if not previous_hex or previous_hex not in best_by_hex:
return False
try:
dest_hash = bytes.fromhex(previous_hex)
except ValueError:
return False
if not self._path_quality_ok(dest_hash):
return False
if not self._recently_verified(previous_hex):
return False
previous_hops = best_by_hex[previous_hex]
# Stay sticky unless another candidate is clearly closer and path-usable.
for _score, hops, node_hex in ordered:
if node_hex == previous_hex:
continue
if hops >= UNKNOWN_HOPS:
continue
try:
other_hash = bytes.fromhex(node_hex)
except ValueError:
continue
if not self._path_quality_ok(other_hash):
continue
if hops + HOP_SWITCH_HYSTERESIS < previous_hops:
return False
break
return True
async def check_and_update_propagation_node(self):
ctx = self.context
router = ctx.message_router
@ -138,103 +516,98 @@ class AutoPropagationManager:
previous_hex = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
)
if isinstance(previous_hex, str):
previous_hex = previous_hex.lower()
else:
previous_hex = None
# If a sync is in progress, only interrupt it when the current node
# appears unreachable or the path is stale/unresponsive. This prevents
# getting stuck on a node we cannot actually reach.
if router.propagation_transfer_state != LXMRouter.PR_IDLE:
# If a sync is in progress, only interrupt when the current peer path
# looks unreachable or stale. PR_COMPLETE is idle-like (finished).
if not propagation_sync_idle_like(router.propagation_transfer_state):
current_path_ok = False
if previous_hex:
try:
current_dest = bytes.fromhex(previous_hex)
current_has_path = RNS.Transport.has_path(current_dest)
current_path_ok = (
current_has_path
and not RNS.Transport.path_is_unresponsive(current_dest)
and not reticulum_pathfinding.transport_path_table_entry_is_expired(
current_dest,
)
)
current_path_ok = self._path_quality_ok(current_dest)
except Exception:
pass
current_path_ok = False
if current_path_ok:
# Sync is likely making progress. Let it finish.
return
# Current node is unreachable. Stop the stuck sync so we can
# look for a working alternative.
self.app.stop_propagation_node_sync(context=ctx)
# Wait briefly for the router to settle back to idle before we
# start probing other nodes.
settle_deadline = time.monotonic() + 5.0
while time.monotonic() < settle_deadline:
if router.propagation_transfer_state == LXMRouter.PR_IDLE:
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
with contextlib.suppress(Exception):
router.propagation_transfer_state = LXMRouter.PR_IDLE
await self._settle_router_idle(router)
announces = self.database.announces.get_announces(aspect="lxmf.propagation")
best_by_hex: dict[str, tuple[int, str]] = {}
for announce in announces:
dest_hex = announce["destination_hash"]
node_data = parse_lxmf_propagation_node_app_data(announce["app_data"])
if not node_data or not node_data.get("enabled", False):
continue
try:
dest_hash = bytes.fromhex(dest_hex)
except ValueError:
continue
if RNS.Transport.has_path(dest_hash):
hops = RNS.Transport.hops_to(dest_hash)
else:
hops = 10**9
prev = best_by_hex.get(dest_hex)
if prev is None or hops < prev[0]:
best_by_hex[dest_hex] = (hops, dest_hex)
sorted_candidates = sorted(best_by_hex.values(), key=lambda x: x[0])
if not sorted_candidates:
best_by_hex, announced = self._collect_candidates()
if not best_by_hex:
return
ordered: list[tuple[int, str]] = []
seen_hex: set[str] = set()
if previous_hex and previous_hex in best_by_hex:
ordered.append(best_by_hex[previous_hex])
seen_hex.add(previous_hex)
for item in sorted_candidates:
if item[1] not in seen_hex:
ordered.append(item)
seen_hex.add(item[1])
ordered = self._ordered_candidates(best_by_hex, announced, previous_hex)
for _hops, node_hex in ordered:
if self._should_keep_previous_without_probe(previous_hex, best_by_hex, ordered):
# Keep the verified preferred peer without a disruptive sync probe.
outbound = None
with contextlib.suppress(Exception):
outbound = router.get_outbound_propagation_node()
if outbound is None or (
isinstance(outbound, (bytes, bytearray))
and outbound.hex() != previous_hex
):
self.app.set_active_propagation_node(previous_hex, context=self.context)
else:
with contextlib.suppress(Exception):
RNS.Transport.request_path(bytes.fromhex(previous_hex))
return
probed_any = False
probes = 0
for _score, hops, node_hex in ordered:
if probes >= MAX_PROBES_PER_CYCLE:
break
if self._in_failure_cooldown(node_hex) and node_hex != previous_hex:
continue
try:
dest_hash = bytes.fromhex(node_hex)
except ValueError:
continue
if not await self._wait_for_path(dest_hash, PATH_WAIT_SECONDS):
if not await self._wait_for_usable_path(dest_hash, PATH_WAIT_SECONDS):
self._record_failure(node_hex)
continue
probes += 1
probed_any = True
if not await self._probe_propagation_sync(node_hex):
self._record_failure(node_hex)
continue
hops = self._hops_to(dest_hash)
self._record_success(node_hex, hops)
if node_hex != previous_hex:
print(
f"Auto-propagation: Switching to verified node {node_hex} "
f"for {self.context.identity_hash}",
)
self.app.set_active_propagation_node(node_hex, context=self.context)
self.config.lxmf_preferred_propagation_node_destination_hash.set(
node_hex,
"Auto-propagation: switching to verified peer "
f"{_pretty_dest(node_hex)}",
)
self.app.set_active_propagation_node(node_hex, context=self.context)
self.config.lxmf_preferred_propagation_node_destination_hash.set(node_hex)
AsyncUtils.run_async(
self.app.send_config_to_websocket_clients(context=ctx),
)
return
# Nothing verified this cycle. Keep a recently-good preferred hash when
# possible so the next check can retry without wiping operator state.
if previous_hex:
entry = self._memory.get(previous_hex, {})
consecutive = int(entry.get("consecutive_failures") or 0)
if consecutive < MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR:
with contextlib.suppress(Exception):
RNS.Transport.request_path(bytes.fromhex(previous_hex))
self.app.set_active_propagation_node(previous_hex, context=self.context)
return
if probed_any or previous_hex:
self.app.remove_active_propagation_node(context=self.context)
if previous_hex:
self.config.lxmf_preferred_propagation_node_destination_hash.set(None)
AsyncUtils.run_async(
self.app.send_config_to_websocket_clients(context=ctx),
)
return
# None of the candidates worked (including the previous node if it was
# probed). Clear the active node rather than restoring a sync-broken one
# just because a transport path still exists.
self.app.remove_active_propagation_node(context=self.context)

View file

@ -52,6 +52,10 @@ lxmf 1.1.0
lxmfy 2.0.1
License: BSD-0-Clause
Author: Quad4 <team@quad4.io>
RNS-over-HTTP (vendored HTTPInterface)
License: MIT
Upstream: https://github.com/Quad4-Software/RNS-over-HTTP
Bundled revision: 530aca499fb7442910f691629670f6f9f4b67221
lxst 0.5.0
License: Other/Proprietary License
Author: Mark Qvist

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ivan / Quad4.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,897 @@
import asyncio
import os
import socket
import ssl
import threading
from collections import deque
from http.server import BaseHTTPRequestHandler, HTTPServer
from queue import Empty, Queue
from socketserver import ThreadingMixIn
from urllib.parse import urlparse
import httpx
import RNS
from RNS.Interfaces.Interface import Interface
class HDLC:
"""Pipe-compatible simplified HDLC framing for HTTP bodies.
Same FLAG/ESC scheme as RNS PipeInterface so multiple packets can
share one HTTP request or response without losing boundaries.
"""
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
@staticmethod
def escape(data):
data = data.replace(
bytes([HDLC.ESC]),
bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]),
)
data = data.replace(
bytes([HDLC.FLAG]),
bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]),
)
return data
@staticmethod
def frame(packet):
return bytes([HDLC.FLAG]) + HDLC.escape(packet) + bytes([HDLC.FLAG])
@staticmethod
def deframe(buffer, max_frame_len):
"""Return (packets, remainder) from an HDLC byte buffer."""
packets = []
in_frame = False
escape = False
data_buffer = bytearray()
i = 0
last_complete = 0
while i < len(buffer):
byte = buffer[i]
i += 1
if in_frame and byte == HDLC.FLAG:
packets.append(bytes(data_buffer))
in_frame = False
escape = False
data_buffer = bytearray()
last_complete = i
elif byte == HDLC.FLAG:
in_frame = True
escape = False
data_buffer = bytearray()
elif in_frame and len(data_buffer) < max_frame_len:
if byte == HDLC.ESC:
escape = True
else:
if escape:
if byte == HDLC.FLAG ^ HDLC.ESC_MASK:
byte = HDLC.FLAG
elif byte == HDLC.ESC ^ HDLC.ESC_MASK:
byte = HDLC.ESC
escape = False
data_buffer.append(byte)
remainder = buffer[last_complete:] if in_frame else b""
return packets, remainder
class _H3Session:
"""Persistent HTTP/3 client over one QUIC connection."""
def __init__(self, server_url, user_agent, verify=True, ca_certs=None):
from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import DataReceived, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import QuicEvent
parsed = urlparse(server_url)
if parsed.scheme != "https":
raise ValueError("HTTP/3 requires an https:// server_url")
self._host = parsed.hostname
self._port = parsed.port or 443
self._path = parsed.path or "/"
if parsed.query:
self._path += "?" + parsed.query
self._authority = parsed.netloc
self._user_agent = user_agent
self._closed = False
self._request_count = 0
configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
if not verify:
configuration.verify_mode = ssl.CERT_NONE
elif ca_certs:
configuration.load_verify_locations(ca_certs)
class _Client(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http = H3Connection(self._quic)
self._request_events = {}
self._request_waiter = {}
def http_event_received(self, event):
if isinstance(event, (HeadersReceived, DataReceived)):
stream_id = event.stream_id
if stream_id in self._request_events:
self._request_events[stream_id].append(event)
if event.stream_ended:
waiter = self._request_waiter.pop(stream_id)
waiter.set_result(self._request_events.pop(stream_id))
def quic_event_received(self, event: QuicEvent):
for http_event in self._http.handle_event(event):
self.http_event_received(http_event)
async def post_bytes(self, path, authority, user_agent, data):
stream_id = self._quic.get_next_available_stream_id()
headers = [
(b":method", b"POST"),
(b":scheme", b"https"),
(b":authority", authority.encode()),
(b":path", path.encode()),
(b"user-agent", user_agent.encode()),
(b"content-type", b"application/octet-stream"),
(b"content-length", str(len(data)).encode()),
]
self._http.send_headers(
stream_id=stream_id,
headers=headers,
end_stream=not data,
)
if data:
self._http.send_data(
stream_id=stream_id, data=data, end_stream=True
)
waiter = self._loop.create_future()
self._request_events[stream_id] = deque()
self._request_waiter[stream_id] = waiter
self.transmit()
events = await asyncio.shield(waiter)
status = 0
body = b""
for event in events:
if isinstance(event, HeadersReceived):
for key, value in event.headers:
if key == b":status":
status = int(value.decode())
elif isinstance(event, DataReceived):
body += event.data
return status, body
self._Client = _Client
self._connect = connect
self._configuration = configuration
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._run_loop,
name="h3-client-loop",
daemon=True,
)
self._ready = threading.Event()
self._error = None
self._client = None
self._cm = None
self._loop_thread.start()
if not self._ready.wait(timeout=30):
raise TimeoutError("HTTP/3 client failed to connect")
if self._error is not None:
raise self._error
def _run_loop(self):
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._open())
self._ready.set()
self._loop.run_forever()
except Exception as exc:
self._error = exc
self._ready.set()
async def _open(self):
self._cm = self._connect(
self._host,
self._port,
configuration=self._configuration,
create_protocol=self._Client,
)
self._client = await self._cm.__aenter__()
def post(self, data, timeout=10.0):
if self._closed:
raise RuntimeError("HTTP/3 session is closed")
async def _do_post():
status, body = await self._client.post_bytes(
self._path,
self._authority,
self._user_agent,
data,
)
self._request_count += 1
if status >= 400:
raise RuntimeError(f"HTTP/3 status {status}")
return body
fut = asyncio.run_coroutine_threadsafe(_do_post(), self._loop)
return fut.result(timeout=timeout)
def close(self):
if self._closed:
return
self._closed = True
async def _close():
if self._cm is not None:
await self._cm.__aexit__(None, None, None)
try:
fut = asyncio.run_coroutine_threadsafe(_close(), self._loop)
fut.result(timeout=5)
except Exception:
pass
self._loop.call_soon_threadsafe(self._loop.stop)
self._loop_thread.join(timeout=5)
class HTTPTunnelInterface(Interface):
"""HTTP Tunnel Interface for Reticulum.
Bidirectional RNS transport over HTTP POST with pipe-compatible HDLC
framing. Supports HTTP/1.1 (default), HTTP/2, and HTTP/3.
Engineering defaults:
- http_version=1 uses a stdlib HTTP/1.1 server and httpx client.
Works over cleartext http:// and is ideal behind Caddy/nginx.
- http_version=2 requires TLS and uses Hypercorn (h2) + httpx(http2).
- http_version=3 requires TLS and uses Hypercorn QUIC + a persistent
aioquic HTTP/3 client session.
Configuration highlights:
http_version: 1, 2, or 3 (default: 1)
tls_certfile / tls_keyfile: required for versions 2 and 3 on server
tls_verify: verify TLS certs on client (default: true)
tls_ca_certs: optional CA bundle path for client verify
server_url: use https:// for versions 2 and 3
"""
DEFAULT_IFAC_SIZE = 16
BITRATE_GUESS = 10_000_000
AUTOCONFIGURE_MTU = True
DEFAULT_MTU = 4096
TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0"
DEFAULT_POLL_INTERVAL = 0.1
DEFAULT_POOL_CONNECTIONS = 1
DEFAULT_POOL_MAXSIZE = 1
DEFAULT_KEEPALIVE_TIMEOUT = 60
DEFAULT_HTTP_VERSION = 1
def __init__(self, owner, configuration):
super().__init__()
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf["name"]
mode = str(ifconf["mode"]).lower() if "mode" in ifconf else "client"
listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0"
listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080
server_url = ifconf["server_url"] if "server_url" in ifconf else None
poll_interval = (
float(ifconf["poll_interval"])
if "poll_interval" in ifconf
else self.DEFAULT_POLL_INTERVAL
)
check_user_agent = (
ifconf.as_bool("check_user_agent") if "check_user_agent" in ifconf else True
)
user_agent = (
str(ifconf["user_agent"])
if "user_agent" in ifconf
else self.TUNNEL_USER_AGENT
)
mtu = int(ifconf["mtu"]) if "mtu" in ifconf else self.DEFAULT_MTU
serve_html_page = (
ifconf.as_bool("serve_html_page") if "serve_html_page" in ifconf else False
)
html_file_path = (
ifconf["html_file_path"] if "html_file_path" in ifconf else None
)
pool_connections = (
int(ifconf["pool_connections"])
if "pool_connections" in ifconf
else self.DEFAULT_POOL_CONNECTIONS
)
pool_maxsize = (
int(ifconf["pool_maxsize"])
if "pool_maxsize" in ifconf
else self.DEFAULT_POOL_MAXSIZE
)
keepalive_timeout = (
int(ifconf["keepalive_timeout"])
if "keepalive_timeout" in ifconf
else self.DEFAULT_KEEPALIVE_TIMEOUT
)
http_version = (
int(ifconf["http_version"])
if "http_version" in ifconf
else self.DEFAULT_HTTP_VERSION
)
tls_certfile = ifconf["tls_certfile"] if "tls_certfile" in ifconf else None
tls_keyfile = ifconf["tls_keyfile"] if "tls_keyfile" in ifconf else None
tls_verify = ifconf.as_bool("tls_verify") if "tls_verify" in ifconf else True
tls_ca_certs = ifconf["tls_ca_certs"] if "tls_ca_certs" in ifconf else None
self.mode = mode
self.http_version = http_version
if mode not in ["client", "server"]:
raise ValueError(
f"Invalid mode '{mode}' for {self}. Must be 'client' or 'server'",
)
if http_version not in (1, 2, 3):
raise ValueError(
f"Invalid http_version '{http_version}' for {self}. Must be 1, 2, or 3",
)
if mode == "client" and server_url is None:
raise ValueError(f"No server_url specified for client mode in {self}")
if pool_connections < 1 or pool_maxsize < 1:
raise ValueError(
f"pool_connections and pool_maxsize must be >= 1 for {self}",
)
if http_version >= 2:
if mode == "server" and (not tls_certfile or not tls_keyfile):
raise ValueError(
f"tls_certfile and tls_keyfile are required for "
f"HTTP/{http_version} server mode in {self}",
)
if mode == "client":
parsed = urlparse(server_url)
if parsed.scheme != "https":
raise ValueError(
f"HTTP/{http_version} client requires https:// server_url in {self}",
)
self.owner = owner
self.IN = True
self.mtu = mtu
self.check_user_agent = check_user_agent
self.user_agent = user_agent
self.serve_html_page = serve_html_page
self.html_file_path = html_file_path
self.html_content = None
self.pool_connections = pool_connections
self.pool_maxsize = pool_maxsize
self.keepalive_timeout = keepalive_timeout
self.tls_certfile = tls_certfile
self.tls_keyfile = tls_keyfile
self.tls_verify = tls_verify
self.tls_ca_certs = tls_ca_certs
self._tcp_accepts = 0
self._http_requests = 0
self._stats_lock = threading.Lock()
self._last_http_version = None
self._client_requests = 0
self.session = None
self._h3_session = None
self._asgi_shutdown = None
self._asgi_loop = None
if self.serve_html_page and self.html_file_path:
self._load_html_content()
self._recv_queue = Queue()
self._send_queue = Queue()
self._stop_event = threading.Event()
self._frame_remainder = b""
self._frame_lock = threading.Lock()
self.HW_MTU = mtu
self.online = False
self.bitrate = HTTPTunnelInterface.BITRATE_GUESS
if mode == "server":
self.listen_host = listen_host
self.listen_port = listen_port
else:
self.server_url = server_url
self.poll_interval = poll_interval
self.optimise_mtu()
if mode == "server":
if http_version == 1:
self.setup_server_http1()
else:
self.setup_server_asgi()
else:
self.setup_client()
def _load_html_content(self):
try:
if os.path.isfile(self.html_file_path):
with open(self.html_file_path, encoding="utf-8") as f:
self.html_content = f.read()
RNS.log(f"Loaded HTML content from {self.html_file_path}", RNS.LOG_INFO)
else:
RNS.log(f"HTML file not found: {self.html_file_path}", RNS.LOG_WARNING)
self.html_content = None
except Exception as e:
RNS.log(
f"Error loading HTML file {self.html_file_path}: {e}",
RNS.LOG_ERROR,
)
self.html_content = None
def _drain_send_frames(self):
parts = []
while not self._send_queue.empty():
try:
packet = self._send_queue.get_nowait()
except Empty:
break
if packet:
parts.append(HDLC.frame(packet))
return b"".join(parts)
def _ingest_wire_bytes(self, wire_data):
if not wire_data:
return
with self._frame_lock:
buffer = self._frame_remainder + wire_data
packets, self._frame_remainder = HDLC.deframe(buffer, self.HW_MTU)
for packet in packets:
if packet:
self._recv_queue.put(packet)
def _record_http_request(self):
with self._stats_lock:
self._http_requests += 1
def _handle_tunnel_exchange(self, method, path, headers, body):
"""Shared request handler for HTTP/1.1 and ASGI paths.
Returns (status, content_type, response_body).
"""
self._record_http_request()
headers_l = {str(k).lower(): str(v) for k, v in headers.items()}
if method == "GET" and path == "/":
if self.serve_html_page and self.html_content:
return (
200,
"text/html; charset=utf-8",
self.html_content.encode("utf-8"),
)
return 404, "text/plain", b""
if method == "POST" and path == "/":
if self.check_user_agent:
user_agent = headers_l.get("user-agent", "")
if user_agent != self.user_agent:
RNS.log(
f"Rejected request with invalid User-Agent: {user_agent}",
RNS.LOG_WARNING,
)
return 403, "text/plain", b"Forbidden"
if body:
RNS.log(f"Received {len(body)} bytes from client", RNS.LOG_EXTREME)
self._ingest_wire_bytes(body)
server_data = self._drain_send_frames()
if server_data:
RNS.log(
f"Sending {len(server_data)} framed bytes to client",
RNS.LOG_EXTREME,
)
return 200, "application/octet-stream", server_data
return 404, "text/plain", b""
def connection_stats(self):
with self._stats_lock:
stats = {
"tcp_accepts": self._tcp_accepts,
"http_requests": self._http_requests,
"client_requests": self._client_requests,
"last_http_version": self._last_http_version,
"configured_http_version": self.http_version,
}
if self._h3_session is not None:
stats["pool_num_connections"] = 0 if self._h3_session._closed else 1
stats["pool_num_requests"] = self._h3_session._request_count
return stats
def _build_asgi_app(self):
interface = self
async def app(scope, receive, send):
if scope["type"] != "http":
return
headers = {
key.decode("latin1"): value.decode("latin1")
for key, value in scope.get("headers", [])
}
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
status, content_type, response_body = interface._handle_tunnel_exchange(
scope.get("method", "GET"),
scope.get("path", "/"),
headers,
body,
)
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", content_type.encode()),
(b"content-length", str(len(response_body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": response_body})
return app
def setup_server_http1(self):
interface_instance = self
class TunnelRequestHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send_common_headers(self, content_type, content_length):
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(content_length))
self.send_header("Connection", "keep-alive")
self.send_header(
"Keep-Alive",
f"timeout={interface_instance.keepalive_timeout}, max=1000",
)
def do_GET(self):
status, content_type, body = interface_instance._handle_tunnel_exchange(
"GET",
self.path,
self.headers,
b"",
)
self.send_response(status)
self._send_common_headers(content_type, len(body))
self.end_headers()
if body:
self.wfile.write(body)
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
status, content_type, response = (
interface_instance._handle_tunnel_exchange(
"POST",
self.path,
self.headers,
body,
)
)
self.send_response(status)
self._send_common_headers(content_type, len(response))
self.end_headers()
if response:
self.wfile.write(response)
def log_message(self, fmt, *args):
pass
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def get_request(self):
request, client_address = super().get_request()
with interface_instance._stats_lock:
interface_instance._tcp_accepts += 1
try:
request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
pass
return request, client_address
def run_server():
try:
self._http_server = ThreadedHTTPServer(
(self.listen_host, self.listen_port),
TunnelRequestHandler,
)
self._http_server.daemon_threads = True
self._http_server.serve_forever()
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"HTTP server error for {self}: {e}", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
self._server_thread = threading.Thread(target=run_server, daemon=True)
self._server_thread.start()
self._start_receive_loop()
self.online = True
RNS.log(
f"HTTP/1.1 server started on http://{self.listen_host}:{self.listen_port}",
RNS.LOG_NOTICE,
)
def setup_server_asgi(self):
from hypercorn.asyncio import serve
from hypercorn.config import Config
config = Config()
bind = f"{self.listen_host}:{self.listen_port}"
config.bind = [bind]
config.certfile = self.tls_certfile
config.keyfile = self.tls_keyfile
config.alpn_protocols = ["h2", "http/1.1"]
if self.http_version == 3:
config.quic_bind = [bind]
config.alpn_protocols = ["h3", "h2", "http/1.1"]
app = self._build_asgi_app()
self._asgi_shutdown = asyncio.Event()
def run_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._asgi_loop = loop
try:
loop.run_until_complete(
serve(app, config, shutdown_trigger=self._asgi_shutdown.wait),
)
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"ASGI HTTP server error for {self}: {e}", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
finally:
loop.close()
self._server_thread = threading.Thread(target=run_server, daemon=True)
self._server_thread.start()
self._start_receive_loop()
self.online = True
proto = f"HTTP/{self.http_version}"
RNS.log(
f"{proto} server started on https://{self.listen_host}:{self.listen_port}",
RNS.LOG_NOTICE,
)
def _start_receive_loop(self):
thread = threading.Thread(target=self.receive_loop, daemon=True)
thread.start()
def setup_client(self):
self._consecutive_failures = 0
self._max_backoff = 30.0
if self.http_version == 3:
self._h3_session = _H3Session(
self.server_url,
self.user_agent,
verify=self.tls_verify,
ca_certs=self.tls_ca_certs,
)
self._last_http_version = "HTTP/3"
else:
verify = self.tls_ca_certs if self.tls_ca_certs else self.tls_verify
limits = httpx.Limits(
max_connections=max(self.pool_maxsize, 1),
max_keepalive_connections=max(self.pool_maxsize, 1),
keepalive_expiry=float(self.keepalive_timeout),
)
self.session = httpx.Client(
http2=(self.http_version == 2),
verify=verify,
headers={
"User-Agent": self.user_agent,
"Accept-Encoding": "identity",
},
limits=limits,
timeout=httpx.Timeout(5.0),
)
thread = threading.Thread(target=self.client_loop, daemon=True)
thread.start()
self.online = True
RNS.log(
f"HTTP/{self.http_version} client started, connecting to {self.server_url}",
RNS.LOG_NOTICE,
)
def receive_loop(self):
while not self._stop_event.is_set():
try:
received_data = self._recv_queue.get(timeout=1)
if received_data:
self.process_incoming(received_data)
except Empty:
continue
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"Error in receive loop for {self}: {e}", RNS.LOG_ERROR)
def _client_exchange(self, data_to_send):
if self.http_version == 3:
body = self._h3_session.post(data_to_send, timeout=5.0)
self._client_requests += 1
self._last_http_version = "HTTP/3"
return body
response = self.session.post(self.server_url, content=data_to_send)
response.raise_for_status()
self._client_requests += 1
self._last_http_version = response.http_version
return response.content
def client_loop(self):
while not self._stop_event.is_set():
data_to_send = self._drain_send_frames()
try:
RNS.log(
f"Sending {len(data_to_send)} framed bytes to server",
RNS.LOG_EXTREME,
)
content = self._client_exchange(data_to_send)
if content:
RNS.log(
f"Received {len(content)} bytes from server",
RNS.LOG_EXTREME,
)
self._ingest_wire_bytes(content)
while not self._recv_queue.empty():
try:
packet = self._recv_queue.get_nowait()
except Empty:
break
if packet:
self.process_incoming(packet)
if self._consecutive_failures > 0:
RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO)
self._consecutive_failures = 0
except Exception as e:
if self._stop_event.is_set():
break
self._consecutive_failures += 1
if self._consecutive_failures % 10 == 1:
RNS.log(
f"Error communicating with server for {self} "
f"(attempt {self._consecutive_failures}): {e}",
RNS.LOG_WARNING,
)
if self._stop_event.is_set():
break
if self._consecutive_failures > 0:
delay = min(
self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)),
self._max_backoff,
)
else:
delay = self.poll_interval
self._stop_event.wait(delay)
def process_incoming(self, data):
if len(data) > 0 and self.online:
self.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online:
if len(data) > self.mtu:
RNS.log(
f"Payload too large ({len(data)} > {self.mtu}) for {self}",
RNS.LOG_ERROR,
)
return
self._send_queue.put(data)
self.txb += len(data)
def detach(self):
RNS.log(f"Detaching {self}", RNS.LOG_DEBUG)
self._stop_event.set()
self.online = False
if self.mode == "client":
if self.session is not None:
try:
self.session.close()
except Exception as e:
RNS.log(
f"Error closing HTTP session for {self}: {e}",
RNS.LOG_DEBUG,
)
if self._h3_session is not None:
try:
self._h3_session.close()
except Exception as e:
RNS.log(
f"Error closing HTTP/3 session for {self}: {e}",
RNS.LOG_DEBUG,
)
if self.mode == "server":
if self.http_version == 1:
httpd = getattr(self, "_http_server", None)
if httpd is not None:
def _shutdown():
try:
httpd.shutdown()
except Exception:
pass
try:
httpd.server_close()
except Exception:
pass
threading.Thread(target=_shutdown, daemon=True).start()
if hasattr(self, "_server_thread") and self._server_thread:
self._server_thread.join(timeout=2)
if self._server_thread.is_alive() and httpd is not None:
try:
httpd.socket.close()
except Exception:
pass
self._server_thread.join(timeout=1)
else:
if self._asgi_loop is not None and self._asgi_shutdown is not None:
self._asgi_loop.call_soon_threadsafe(self._asgi_shutdown.set)
if hasattr(self, "_server_thread") and self._server_thread:
self._server_thread.join(timeout=5)
def should_ingress_limit(self):
return False
def __str__(self):
name = getattr(self, "name", "?")
ver = getattr(self, "http_version", "?")
if self.mode == "server":
lh = getattr(self, "listen_host", "?")
lp = getattr(self, "listen_port", "?")
return f"HTTPTunnelInterface[{name}/HTTP{ver}/server/{lh}:{lp}]"
if self.mode == "client" and getattr(self, "server_url", None) is not None:
return f"HTTPTunnelInterface[{name}/HTTP{ver}/client/{self.server_url}]"
return f"HTTPTunnelInterface[{name}/HTTP{ver}/{self.mode}]"
interface_class = HTTPTunnelInterface

View file

@ -391,6 +391,7 @@ def register_app_info_routes(routes, app):
{
"app_info": {
"version": app.get_app_version(),
**(app.get_build_meta() if hasattr(app, "get_build_meta") else {}),
"lxmf_version": LXMF.__version__,
"rns_version": RNS.__version__,
"lxst_version": app.get_lxst_version(),

View file

@ -1517,6 +1517,90 @@ def register_interfaces_routes(routes, app):
interface_details["command"] = interface_command
interface_details["respawn_delay"] = interface_respawn_delay
# HTTP tunnel (vendored RNS-over-HTTP). Config mode is client|server,
# which is distinct from Reticulum interface modes (full/gateway/...).
if interface_type == "HTTPInterface":
tunnel_mode = str(data.get("mode") or "").strip().lower()
if tunnel_mode not in {"client", "server"}:
return web.json_response(
{
"message": "HTTPInterface mode must be client or server",
},
status=422,
)
interface_details["mode"] = tunnel_mode
http_version_raw = data.get("http_version")
if http_version_raw not in (None, ""):
try:
http_version = int(http_version_raw)
except (TypeError, ValueError):
return web.json_response(
{"message": "http_version must be 1, 2, or 3"},
status=422,
)
if http_version not in (1, 2, 3):
return web.json_response(
{"message": "http_version must be 1, 2, or 3"},
status=422,
)
interface_details["http_version"] = http_version
else:
interface_details.pop("http_version", None)
if tunnel_mode == "client":
server_url = data.get("server_url")
if server_url is None or str(server_url).strip() == "":
return web.json_response(
{
"message": "server_url is required for HTTPInterface client mode"
},
status=422,
)
interface_details["server_url"] = str(server_url).strip()
InterfaceEditor.update_value(interface_details, data, "poll_interval")
for key in (
"listen_host",
"listen_port",
"check_user_agent",
"serve_html_page",
"html_file_path",
"tls_certfile",
"tls_keyfile",
):
interface_details.pop(key, None)
else:
listen_host = data.get("listen_host")
if listen_host is None or str(listen_host).strip() == "":
listen_host = "0.0.0.0"
listen_port = data.get("listen_port")
if listen_port is None or listen_port == "":
return web.json_response(
{
"message": "listen_port is required for HTTPInterface server mode"
},
status=422,
)
interface_details["listen_host"] = str(listen_host).strip()
interface_details["listen_port"] = listen_port
InterfaceEditor.update_value(
interface_details, data, "check_user_agent"
)
InterfaceEditor.update_value(interface_details, data, "serve_html_page")
InterfaceEditor.update_value(interface_details, data, "html_file_path")
InterfaceEditor.update_value(interface_details, data, "tls_certfile")
InterfaceEditor.update_value(interface_details, data, "tls_keyfile")
for key in ("server_url", "poll_interval"):
interface_details.pop(key, None)
InterfaceEditor.update_value(interface_details, data, "mtu")
InterfaceEditor.update_value(interface_details, data, "user_agent")
InterfaceEditor.update_value(interface_details, data, "pool_connections")
InterfaceEditor.update_value(interface_details, data, "pool_maxsize")
InterfaceEditor.update_value(interface_details, data, "keepalive_timeout")
InterfaceEditor.update_value(interface_details, data, "tls_verify")
InterfaceEditor.update_value(interface_details, data, "tls_ca_certs")
_builtin_interface_types = frozenset(
{
"AutoInterface",
@ -1532,6 +1616,7 @@ def register_interfaces_routes(routes, app):
"KISSInterface",
"AX25KISSInterface",
"PipeInterface",
"HTTPInterface",
},
)
if interface_type not in _builtin_interface_types:
@ -1601,14 +1686,15 @@ def register_interfaces_routes(routes, app):
# set common interface options
InterfaceEditor.update_value(interface_details, data, "bitrate")
mode_error = InterfaceEditor.apply_interface_mode(interface_details, data)
if mode_error is not None:
return web.json_response(
{
"message": mode_error,
},
status=422,
)
if interface_type != "HTTPInterface":
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,

View file

@ -11,6 +11,7 @@ import tempfile
_MODULE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_MAX_MODULE_BYTES = 512 * 1024
_BUNDLED_INTERFACE_MODULES = ("HTTPInterface.py",)
def interface_modules_dir(reticulum_config_dir: str | None) -> str:
@ -21,6 +22,82 @@ def interface_modules_dir(reticulum_config_dir: str | None) -> str:
return os.path.join(root, "interfaces")
def bundled_interface_modules_dir() -> str:
"""Return the package-data directory of MeshChatX-shipped interface modules."""
return os.path.join(os.path.dirname(__file__), "data", "interfaces")
def resolve_bundled_interface_module_path(filename: str) -> str | None:
"""Return an absolute path to a bundled TypeName.py, or None when missing."""
stem_name = os.path.basename(str(filename or ""))
if not stem_name.endswith(".py"):
return None
packaged = os.path.join(bundled_interface_modules_dir(), stem_name)
if os.path.isfile(packaged):
return packaged
# Dev checkout: vendor/rns_over_http next to the repo root.
repo_vendor = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
"..",
"..",
"..",
"..",
"vendor",
"rns_over_http",
stem_name,
),
)
if os.path.isfile(repo_vendor):
return repo_vendor
return None
def ensure_bundled_interface_modules(reticulum_config_dir: str | None) -> list[dict]:
"""Install or refresh MeshChatX-bundled interface modules into interfacepath.
Returns a list of install result dicts for modules that were written.
Missing bundled sources are skipped. Identical files are left untouched.
"""
written: list[dict] = []
if not reticulum_config_dir:
return written
for filename in _BUNDLED_INTERFACE_MODULES:
source_path = resolve_bundled_interface_module_path(filename)
if source_path is None:
continue
try:
with open(source_path, "rb") as handle:
data = handle.read()
except OSError:
continue
stem = sanitize_interface_module_stem(filename)
if stem is None:
continue
target_dir = interface_modules_dir(reticulum_config_dir)
target_path = os.path.join(target_dir, f"{stem}.py")
if os.path.isfile(target_path):
try:
with open(target_path, "rb") as handle:
existing = handle.read()
except OSError:
existing = None
if existing == data:
continue
try:
written.append(
install_interface_module(
reticulum_config_dir,
filename=filename,
data=data,
overwrite=True,
),
)
except ValueError:
continue
return written
def sanitize_interface_module_stem(name: str | None) -> str | None:
"""Return a safe TypeName stem, or None when the name is invalid."""
if not name or not isinstance(name, str):

View file

@ -0,0 +1,52 @@
# SPDX-License-Identifier: 0BSD
"""Baked build metadata (git commit and channel).
Defaults keep source checkouts and tests working without a bake step.
Builds run scripts/bake_build_meta.js which writes _build_meta_baked.py
(gitignored). Packaging includes that overlay when present.
"""
from __future__ import annotations
GIT_COMMIT = ""
GIT_COMMIT_SHORT = ""
BUILD_CHANNEL = "local"
IS_DEV_BUILD = False
try:
from meshchatx.src._build_meta_baked import ( # type: ignore[import-not-found]
BUILD_CHANNEL as _BAKED_CHANNEL,
GIT_COMMIT as _BAKED_COMMIT,
GIT_COMMIT_SHORT as _BAKED_SHORT,
IS_DEV_BUILD as _BAKED_IS_DEV,
)
GIT_COMMIT = str(_BAKED_COMMIT or "")
GIT_COMMIT_SHORT = str(_BAKED_SHORT or "")
BUILD_CHANNEL = str(_BAKED_CHANNEL or "local")
IS_DEV_BUILD = bool(_BAKED_IS_DEV)
except Exception:
pass
def display_version(base_version: str) -> str:
"""Version string for UI labels (adds -dev for nightly/local builds)."""
version = str(base_version or "").strip() or "unknown"
if IS_DEV_BUILD and not version.endswith("-dev"):
return f"{version}-dev"
return version
def as_dict(base_version: str) -> dict[str, object]:
"""Fields merged into /api/v1/app/info."""
short = (GIT_COMMIT_SHORT or "").strip()
full = (GIT_COMMIT or "").strip()
if not short and full:
short = full[:7]
return {
"git_commit": full or short,
"git_commit_short": short,
"build_channel": BUILD_CHANNEL or "local",
"is_dev_build": bool(IS_DEV_BUILD),
"display_version": display_version(base_version),
}

View file

@ -466,14 +466,14 @@
class="flex items-center py-2 text-[10px] font-mono text-gray-500 transition-colors hover:text-gray-700 dark:text-zinc-500 dark:hover:text-zinc-300"
:class="isSidebarCollapsed ? 'justify-center px-0' : 'justify-start px-3'"
data-testid="sidebar-app-version"
:title="$t('about.version', { version: appInfo.version })"
:title="sidebarVersionTitle"
>
<MaterialDesignIcon
v-if="isSidebarCollapsed"
icon-name="information-outline"
class="size-4"
/>
<span v-else>{{ $t("about.version", { version: appInfo.version }) }}</span>
<span v-else>{{ sidebarVersionLabel }}</span>
</RouterLink>
</div>
</div>
@ -747,6 +747,34 @@ export default {
isPopoutMode() {
return this.currentPopoutType != null;
},
sidebarDisplayVersion() {
const info = this.appInfo || {};
if (info.display_version) {
return info.display_version;
}
const base = info.version || "";
if (info.is_dev_build && base && !String(base).endsWith("-dev")) {
return `${base}-dev`;
}
return base;
},
sidebarVersionLabel() {
const version = this.sidebarDisplayVersion;
if (!version) {
return "";
}
const label = this.$t("about.version", { version });
const short =
this.appInfo?.git_commit_short ||
(this.appInfo?.git_commit ? String(this.appInfo.git_commit).slice(0, 7) : "");
if (this.appInfo?.is_dev_build && short) {
return `${label} ${short}`;
}
return label;
},
sidebarVersionTitle() {
return this.sidebarVersionLabel;
},
unreadConversationsCount() {
return GlobalState.unreadConversationsCount;
},

View file

@ -15,22 +15,6 @@
<v-card
class="flex min-h-0 flex-1 flex-col bg-white dark:bg-zinc-950 border-0 overflow-hidden relative h-full max-h-dvh"
>
<!-- Settings Controls -->
<div class="absolute top-4 left-4 z-50 flex items-center gap-1">
<LanguageSelector @language-change="onLanguageChange" />
<button
type="button"
class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
</div>
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@ -45,9 +29,25 @@
></div>
</div>
<!-- Language / theme (in-flow so titles are not covered) -->
<div class="shrink-0 flex items-center justify-end gap-1 px-3 py-1.5 sm:px-4">
<LanguageSelector @language-change="onLanguageChange" />
<button
type="button"
class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
</div>
<!-- Content Area -->
<v-card-text
class="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 md:px-12 md:py-10"
class="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6 md:px-12 md:py-10"
>
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
@ -388,16 +388,19 @@
</p>
</div>
<div class="grid grid-cols-1 gap-4">
<div
class="grid grid-cols-1 gap-4"
:class="{ 'pointer-events-none opacity-70': connectionSetupBusy }"
>
<button
type="button"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'recommended'
? 'border-indigo-500 ring-2 ring-indigo-500/30'
: 'border-indigo-500/20 hover:border-indigo-500',
]"
:disabled="addingRecommended || savingDiscovery || addingLocal || reloadingReticulum"
:disabled="connectionSetupBusy"
@click="useRecommendedMode"
>
<v-icon icon="mdi-access-point-network" color="indigo" size="40"></v-icon>
@ -419,13 +422,13 @@
<button
type="button"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'discovery'
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-blue-500/20 hover:border-blue-500',
]"
:disabled="savingDiscovery || addingRecommended"
:disabled="connectionSetupBusy"
@click="useDiscoveryMode"
>
<v-icon icon="mdi-radar" color="blue" size="40"></v-icon>
@ -447,13 +450,13 @@
<button
type="button"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'local'
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: 'border-emerald-500/20 hover:border-emerald-500',
]"
:disabled="addingLocal || addingRecommended || reloadingReticulum"
:disabled="connectionSetupBusy"
@click="useLocalMode"
>
<v-icon icon="mdi-lan" color="emerald" size="40"></v-icon>
@ -475,12 +478,13 @@
<button
type="button"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all"
class="text-left flex items-start gap-4 p-5 rounded-2xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'manual'
? 'border-gray-500 ring-2 ring-gray-500/30'
: 'border-gray-300 dark:border-zinc-700 hover:border-gray-500',
]"
:disabled="connectionSetupBusy"
@click="useManualMode"
>
<v-icon icon="mdi-cog-outline" color="gray" size="40"></v-icon>
@ -513,7 +517,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
:disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
:disabled="bootstrapPickBusy"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
@ -774,6 +778,7 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="bootstrapActionBusy"
@click="skipBootstraps"
>
{{ $t("tutorial.bootstrap_skip") }}
@ -781,13 +786,11 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
:disabled="
addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0
"
:disabled="bootstrapActionBusy || selectedBootstrapCount === 0"
@click="confirmBootstraps"
>
<v-progress-circular
v-if="addingBootstraps || reloadingReticulum"
v-if="bootstrapActionBusy"
indeterminate
size="14"
width="2"
@ -1111,6 +1114,7 @@
v-if="currentStep > 1 && currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="tutorialNavBusy"
@click="previousStep"
>
{{ $t("tutorial.back") }}
@ -1122,6 +1126,7 @@
v-if="currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="tutorialNavBusy"
@click="skipTutorial"
>
{{ $t("tutorial.skip") }}
@ -1132,7 +1137,7 @@
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="
(currentStep === 2 && identityImportInProgress) ||
tutorialNavBusy ||
(currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
"
@click="handlePrimaryAction"
@ -1141,10 +1146,10 @@
</button>
<button
v-else
v-else-if="currentStep === totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
:disabled="finishingTutorial"
:disabled="finishingTutorial || tutorialNavBusy"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@ -1155,22 +1160,6 @@
</v-dialog>
<div v-else class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden relative">
<!-- Settings Controls -->
<div class="absolute top-4 left-4 z-50 flex items-center gap-1">
<LanguageSelector @language-change="onLanguageChange" />
<button
type="button"
class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
</div>
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@ -1185,7 +1174,23 @@
></div>
</div>
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
<!-- Language / theme (in-flow so titles are not covered) -->
<div class="shrink-0 flex items-center justify-end gap-1 px-3 py-1.5 sm:px-4">
<LanguageSelector @language-change="onLanguageChange" />
<button
type="button"
class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
</button>
</div>
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-6 md:py-10">
<div class="w-full h-full flex flex-col justify-between">
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
@ -1536,16 +1541,19 @@
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-6xl mx-auto">
<div
class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-6xl mx-auto"
:class="{ 'pointer-events-none opacity-70': connectionSetupBusy }"
>
<button
type="button"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all hover:scale-[1.02]"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'recommended'
? 'border-indigo-500 ring-2 ring-indigo-500/30'
: 'border-indigo-500/20 hover:border-indigo-500',
]"
:disabled="addingRecommended || savingDiscovery || addingLocal || reloadingReticulum"
:disabled="connectionSetupBusy"
@click="useRecommendedMode"
>
<v-icon icon="mdi-access-point-network" color="indigo" size="56"></v-icon>
@ -1565,13 +1573,13 @@
<button
type="button"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all hover:scale-[1.02]"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'discovery'
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-blue-500/20 hover:border-blue-500',
]"
:disabled="savingDiscovery || addingRecommended"
:disabled="connectionSetupBusy"
@click="useDiscoveryMode"
>
<v-icon icon="mdi-radar" color="blue" size="56"></v-icon>
@ -1591,13 +1599,13 @@
<button
type="button"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all hover:scale-[1.02]"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'local'
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: 'border-emerald-500/20 hover:border-emerald-500',
]"
:disabled="addingLocal || addingRecommended || reloadingReticulum"
:disabled="connectionSetupBusy"
@click="useLocalMode"
>
<v-icon icon="mdi-lan" color="emerald" size="56"></v-icon>
@ -1617,12 +1625,13 @@
<button
type="button"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all hover:scale-[1.02]"
class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'manual'
? 'border-gray-500 ring-2 ring-gray-500/30'
: 'border-gray-300 dark:border-zinc-700 hover:border-gray-500',
]"
:disabled="connectionSetupBusy"
@click="useManualMode"
>
<v-icon icon="mdi-cog-outline" color="gray" size="56"></v-icon>
@ -1653,7 +1662,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border-2 border-blue-500/30 bg-blue-500/10 px-5 py-2.5 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
:disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
:disabled="bootstrapPickBusy"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
@ -1922,6 +1931,7 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="bootstrapActionBusy"
@click="skipBootstraps"
>
{{ $t("tutorial.bootstrap_skip") }}
@ -1929,11 +1939,11 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
:disabled="addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0"
:disabled="bootstrapActionBusy || selectedBootstrapCount === 0"
@click="confirmBootstraps"
>
<v-progress-circular
v-if="addingBootstraps || reloadingReticulum"
v-if="bootstrapActionBusy"
indeterminate
size="16"
width="2"
@ -2300,6 +2310,7 @@
v-if="currentStep > 1 && currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="tutorialNavBusy"
@click="previousStep"
>
{{ $t("tutorial.back") }}
@ -2311,6 +2322,7 @@
v-if="currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
:disabled="tutorialNavBusy"
@click="skipTutorial"
>
{{ $t("tutorial.skip_setup") }}
@ -2321,7 +2333,7 @@
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="
(currentStep === 2 && identityImportInProgress) ||
tutorialNavBusy ||
(currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
"
@click="handlePrimaryAction"
@ -2330,10 +2342,10 @@
</button>
<button
v-else
v-else-if="currentStep === totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
:disabled="finishingTutorial"
:disabled="finishingTutorial || tutorialNavBusy"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@ -2490,6 +2502,24 @@ export default {
}
return this.currentStep < this.totalSteps;
},
connectionSetupBusy() {
return this.addingRecommended || this.savingDiscovery || this.addingLocal || this.reloadingReticulum;
},
bootstrapPickBusy() {
return this.pickingRandomBootstraps || this.loadingInterfaces || this.loadingDiscovered;
},
bootstrapActionBusy() {
return this.pickingRandomBootstraps || this.addingBootstraps || this.reloadingReticulum;
},
tutorialNavBusy() {
return (
this.connectionSetupBusy ||
this.bootstrapActionBusy ||
this.savingPropagation ||
this.finishingTutorial ||
this.identityImportInProgress
);
},
},
watch: {
communityInterfaces() {
@ -2841,7 +2871,7 @@ export default {
}
},
async useRecommendedMode() {
if (this.addingRecommended) {
if (this.connectionSetupBusy) {
return;
}
this.addingRecommended = true;
@ -2851,7 +2881,6 @@ export default {
type: "AutoInterface",
enabled: true,
});
this.interfaceAddedViaTutorial = true;
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add("Local Network");
@ -2874,6 +2903,7 @@ export default {
await this.loadDiscoveredInterfaces();
await this.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
this.bootstrapAutoPickDone = true;
this.interfaceAddedViaTutorial = true;
} catch (e) {
console.error("Failed to apply recommended connection mode:", e);
ToastUtils.error(
@ -2886,6 +2916,9 @@ export default {
}
},
async useDiscoveryMode() {
if (this.connectionSetupBusy) {
return;
}
this.savingDiscovery = true;
try {
const payload = {
@ -2901,6 +2934,7 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
this.bootstrapAutoPickDone = false;
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
await this.maybeAutoPickBootstrapTcp();
@ -2912,7 +2946,9 @@ export default {
}
},
async useLocalMode() {
if (this.addingLocal) return;
if (this.connectionSetupBusy) {
return;
}
this.addingLocal = true;
try {
await window.api.post("/api/v1/reticulum/interfaces/add", {
@ -2938,6 +2974,9 @@ export default {
}
},
useManualMode() {
if (this.connectionSetupBusy) {
return;
}
this.connectionMode = "manual";
this.currentStep = 5;
},
@ -2945,6 +2984,9 @@ export default {
return this.selectedBootstrapKeys.includes(key);
},
toggleBootstrap(key) {
if (this.bootstrapActionBusy) {
return;
}
const idx = this.selectedBootstrapKeys.indexOf(key);
if (idx >= 0) {
this.selectedBootstrapKeys.splice(idx, 1);
@ -3067,17 +3109,12 @@ export default {
async pickRandomTcpBootstraps(options = {}) {
const silent = options.silent === true;
const auto = options.auto === true;
if (!silent && !auto) {
this.pickingRandomBootstraps = true;
if (this.pickingRandomBootstraps) {
return;
}
this.pickingRandomBootstraps = true;
// Yield once so the busy spinner can paint before selection work.
await Promise.resolve();
await new Promise((resolve) => {
if (typeof requestAnimationFrame !== "undefined") {
requestAnimationFrame(() => resolve());
} else {
setTimeout(resolve, 0);
}
});
try {
let entries = this.pickEligibleCommunityTcpBootstrapForRandom();
entries = this.dedupeBootstrapEntries(entries);
@ -3101,9 +3138,7 @@ export default {
);
}
} finally {
if (!silent && !auto) {
this.pickingRandomBootstraps = false;
}
this.pickingRandomBootstraps = false;
}
},
async maybeAutoPickBootstrapTcp() {
@ -3116,12 +3151,17 @@ export default {
if (this.selectedBootstrapKeys.length > 0) {
return;
}
if (this.pickingRandomBootstraps) {
return;
}
const entries = this.dedupeBootstrapEntries(this.pickEligibleCommunityTcpBootstrapForRandom());
if (entries.length === 0) {
return;
}
await this.pickRandomTcpBootstraps({ silent: true, auto: true });
this.bootstrapAutoPickDone = true;
if (this.selectedBootstrapKeys.length > 0) {
this.bootstrapAutoPickDone = true;
}
},
buildBootstrapPayload(item) {
if (item.kind === "discovered") {
@ -3182,7 +3222,9 @@ export default {
}
},
async confirmBootstraps() {
if (this.addingBootstraps) return;
if (this.bootstrapActionBusy) {
return;
}
if (this.selectedBootstrapKeys.length === 0) {
ToastUtils.warning(this.$t("tutorial.bootstrap_pick_at_least_one"));
return;
@ -3228,6 +3270,9 @@ export default {
}
},
skipBootstraps() {
if (this.bootstrapActionBusy) {
return;
}
this.currentStep = 5;
},
async enableAutoPropagation() {
@ -3348,7 +3393,7 @@ export default {
ToastUtils.warning(this.$t("tutorial.connect_mode_required"));
return;
}
if (this.connectionMode !== "discovery") {
if (this.connectionMode !== "discovery" && this.connectionMode !== "recommended") {
this.currentStep = 5;
return;
}
@ -3365,14 +3410,24 @@ export default {
}
},
previousStep() {
if (this.tutorialNavBusy) {
return;
}
if (this.currentStep <= 1) return;
if (this.currentStep === 5 && this.connectionMode !== "discovery") {
if (
this.currentStep === 5 &&
this.connectionMode !== "discovery" &&
this.connectionMode !== "recommended"
) {
this.currentStep = 3;
return;
}
this.currentStep--;
},
async skipTutorial() {
if (this.tutorialNavBusy) {
return;
}
if (!(await DialogUtils.confirm(this.$t("tutorial.skip_confirm")))) {
return;
}
@ -3434,7 +3489,7 @@ export default {
}
},
async finishTutorial() {
if (this.finishingTutorial) {
if (this.finishingTutorial || this.tutorialNavBusy) {
return;
}
this.finishingTutorial = true;

View file

@ -25,7 +25,18 @@
class="flex flex-col gap-0.5 sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3 sm:gap-y-0"
>
<div class="text-sm font-black uppercase tracking-[0.2em] text-blue-500 opacity-80">
{{ $t("about.version", { version: appInfo.version }) }}
{{ $t("about.version", { version: aboutDisplayVersion }) }}
</div>
<div
v-if="appInfo.git_commit_short || appInfo.git_commit"
class="text-xs font-medium normal-case tracking-normal font-mono text-gray-500 dark:text-zinc-500"
:title="appInfo.git_commit || appInfo.git_commit_short"
>
{{
$t("about.git_commit", {
commit: appInfo.git_commit_short || appInfo.git_commit,
})
}}
</div>
<div
v-if="formattedUiBuildDate"
@ -663,7 +674,7 @@
{{ $t("about.app_name") }}
</div>
<div class="text-xs font-mono font-bold text-gray-400">
v{{ appInfo.version }}
v{{ aboutDisplayVersion }}
</div>
</div>
</div>
@ -1274,6 +1285,17 @@ export default {
isElectron() {
return ElectronUtils.isElectron();
},
aboutDisplayVersion() {
const info = this.appInfo || {};
if (info.display_version) {
return info.display_version;
}
const base = info.version || "unknown";
if (info.is_dev_build && !String(base).endsWith("-dev")) {
return `${base}-dev`;
}
return base;
},
formattedUiBuildDate() {
try {
const raw = typeof __APP_BUILD_TIME__ !== "undefined" ? __APP_BUILD_TIME__ : "";

View file

@ -120,6 +120,12 @@
icon: 'auto-fix',
color: 'text-pink-500',
},
{
id: 'HTTPInterface',
name: 'HTTP Tunnel',
icon: 'web',
color: 'text-teal-500',
},
]"
:key="type.id"
type="button"
@ -1333,6 +1339,192 @@
</div>
</div>
<!-- HTTP tunnel (bundled RNS-over-HTTP) -->
<div v-if="newInterfaceType === 'HTTPInterface'" class="space-y-4">
<p class="text-xs text-gray-600 dark:text-zinc-400 leading-relaxed">
{{ $t("interfaces.http_tunnel_intro") }}
</p>
<div class="flex items-center gap-2">
<button
type="button"
class="flex-1 py-2 rounded-2xl border text-xs font-bold uppercase tracking-tight transition"
:class="
newInterfaceHttpTunnelMode === 'client'
? 'bg-teal-500/10 border-teal-500 text-teal-700 dark:text-teal-300'
: 'bg-gray-50/50 dark:bg-zinc-800/30 border-gray-100 dark:border-zinc-800 text-gray-600 dark:text-zinc-400'
"
@click="newInterfaceHttpTunnelMode = 'client'"
>
{{ $t("interfaces.http_tunnel_mode_client") }}
</button>
<button
type="button"
class="flex-1 py-2 rounded-2xl border text-xs font-bold uppercase tracking-tight transition"
:class="
newInterfaceHttpTunnelMode === 'server'
? 'bg-teal-500/10 border-teal-500 text-teal-700 dark:text-teal-300'
: 'bg-gray-50/50 dark:bg-zinc-800/30 border-gray-100 dark:border-zinc-800 text-gray-600 dark:text-zinc-400'
"
@click="newInterfaceHttpTunnelMode = 'server'"
>
{{ $t("interfaces.http_tunnel_mode_server") }}
</button>
</div>
<div v-if="newInterfaceHttpTunnelMode === 'client'" class="space-y-4">
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_server_url")
}}</FormLabel>
<input
v-model="newInterfaceHttpServerUrl"
type="url"
placeholder="https://example.com:8080/"
class="input-field"
autocomplete="off"
spellcheck="false"
/>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_poll_interval")
}}</FormLabel>
<input
v-model="newInterfaceHttpPollInterval"
type="number"
min="0.01"
step="0.01"
placeholder="0.1"
class="input-field"
/>
</div>
</div>
<div v-else class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_listen_host")
}}</FormLabel>
<input
v-model="newInterfaceHttpListenHost"
type="text"
placeholder="0.0.0.0"
class="input-field"
/>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_listen_port")
}}</FormLabel>
<input
v-model="newInterfaceHttpListenPort"
type="number"
min="1"
max="65535"
placeholder="8080"
class="input-field"
/>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<FormLabel class="glass-label mb-0!">{{
$t("interfaces.http_tunnel_check_user_agent")
}}</FormLabel>
<p class="text-xs text-gray-400 mt-1">
{{ $t("interfaces.http_tunnel_check_user_agent_hint") }}
</p>
</div>
<Toggle v-model="newInterfaceHttpCheckUserAgent" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_http_version")
}}</FormLabel>
<select v-model="newInterfaceHttpVersion" class="input-field">
<option :value="1">HTTP/1.1</option>
<option :value="2">HTTP/2</option>
<option :value="3">HTTP/3</option>
</select>
<p class="mt-1 text-xs text-gray-500 dark:text-zinc-400">
{{ $t("interfaces.http_tunnel_http_version_hint") }}
</p>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_mtu")
}}</FormLabel>
<input
v-model="newInterfaceHttpMtu"
type="number"
min="128"
placeholder="4096"
class="input-field"
/>
</div>
</div>
<div>
<FormLabel class="glass-label">{{
$t("interfaces.http_tunnel_user_agent")
}}</FormLabel>
<input
v-model="newInterfaceHttpUserAgent"
type="text"
placeholder="RNS-HTTP-Tunnel/1.0"
class="input-field"
autocomplete="off"
spellcheck="false"
/>
</div>
<div
v-if="newInterfaceHttpVersion >= 2"
class="space-y-4 rounded-2xl border border-gray-100 dark:border-zinc-800 p-3"
>
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("interfaces.http_tunnel_tls_hint") }}
</p>
<div
v-if="newInterfaceHttpTunnelMode === 'server'"
class="grid grid-cols-1 gap-3"
>
<div>
<FormLabel class="glass-label">tls_certfile</FormLabel>
<input
v-model="newInterfaceHttpTlsCertfile"
type="text"
class="input-field font-mono text-xs"
autocomplete="off"
/>
</div>
<div>
<FormLabel class="glass-label">tls_keyfile</FormLabel>
<input
v-model="newInterfaceHttpTlsKeyfile"
type="text"
class="input-field font-mono text-xs"
autocomplete="off"
/>
</div>
</div>
<div
v-if="newInterfaceHttpTunnelMode === 'client'"
class="flex items-center justify-between gap-4"
>
<FormLabel class="glass-label mb-0!">{{
$t("interfaces.http_tunnel_tls_verify")
}}</FormLabel>
<Toggle v-model="newInterfaceHttpTlsVerify" />
</div>
</div>
<BundledDocsHint
hint-i18n-key="interfaces.http_tunnel_docs_hint"
link-i18n-key="interfaces.http_tunnel_docs_link"
:docs-rel-path="docsReticulumInterfacesOverview"
paragraph-class="text-xs text-gray-500 dark:text-gray-400"
/>
</div>
<!-- LocalInterface: IPC path used internally by RNS. Optional external module -->
<div v-if="newInterfaceType === 'LocalInterface'" class="space-y-4">
<div class="text-sm text-gray-800 dark:text-zinc-200 leading-snug">
@ -1594,7 +1786,7 @@
<template #content>
<div class="p-6 space-y-6">
<div class="grid grid-cols-2 gap-4">
<div>
<div v-if="newInterfaceType !== 'HTTPInterface'">
<FormLabel class="glass-label">Interface Mode</FormLabel>
<select v-model="sharedInterfaceSettings.mode" class="input-field">
<option :value="undefined">
@ -1618,6 +1810,11 @@
</option>
</select>
</div>
<div v-else class="col-span-2">
<p class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("interfaces.http_tunnel_mode_note") }}
</p>
</div>
<div>
<FormLabel class="glass-label">Forced Bitrate</FormLabel>
<input
@ -1990,6 +2187,18 @@ export default {
newInterfaceFastFlappingBlockTime: 720,
newInterfaceFastFlappingThreshold: 20,
newInterfaceFastFlappingGrace: 5,
newInterfaceHttpTunnelMode: "client",
newInterfaceHttpServerUrl: null,
newInterfaceHttpPollInterval: 0.1,
newInterfaceHttpListenHost: "0.0.0.0",
newInterfaceHttpListenPort: 8080,
newInterfaceHttpMtu: 4096,
newInterfaceHttpVersion: 1,
newInterfaceHttpUserAgent: "RNS-HTTP-Tunnel/1.0",
newInterfaceHttpCheckUserAgent: true,
newInterfaceHttpTlsVerify: true,
newInterfaceHttpTlsCertfile: null,
newInterfaceHttpTlsKeyfile: null,
reticulumInstance: {
share_instance: true,
local_hops_delta: false,
@ -2615,6 +2824,30 @@ export default {
this.newInterfaceDataPort = iface.data_port ?? null;
this.newInterfaceConfiguredBitrate = iface.configured_bitrate ?? null;
if (iface.type === "HTTPInterface") {
const tunnelMode = String(iface.mode || "client").toLowerCase();
this.newInterfaceHttpTunnelMode = tunnelMode === "server" ? "server" : "client";
this.newInterfaceHttpServerUrl = iface.server_url ?? null;
this.newInterfaceHttpPollInterval =
iface.poll_interval != null && iface.poll_interval !== "" ? Number(iface.poll_interval) : 0.1;
this.newInterfaceHttpListenHost = iface.listen_host ?? "0.0.0.0";
this.newInterfaceHttpListenPort = iface.listen_port ?? 8080;
this.newInterfaceHttpMtu = iface.mtu != null && iface.mtu !== "" ? Number(iface.mtu) : 4096;
this.newInterfaceHttpVersion =
iface.http_version != null && iface.http_version !== "" ? Number(iface.http_version) : 1;
this.newInterfaceHttpUserAgent = iface.user_agent ?? "RNS-HTTP-Tunnel/1.0";
this.newInterfaceHttpCheckUserAgent =
iface.check_user_agent === undefined || iface.check_user_agent === null
? true
: this.parseBool(iface.check_user_agent);
this.newInterfaceHttpTlsVerify =
iface.tls_verify === undefined || iface.tls_verify === null
? true
: this.parseBool(iface.tls_verify);
this.newInterfaceHttpTlsCertfile = iface.tls_certfile ?? null;
this.newInterfaceHttpTlsKeyfile = iface.tls_keyfile ?? null;
}
this.newInterfaceFlowControl = this.parseBool(iface.flow_control);
this.newInterfaceCallsign = iface.callsign ?? null;
this.newInterfaceIDCallsign = iface.id_callsign ?? null;
@ -2649,7 +2882,7 @@ export default {
this.newInterfaceCodingRate = iface.codingrate;
this.newInterfaceCommand = iface.command;
this.newInterfaceRespawnDelay = iface.respawn_delay;
this.sharedInterfaceSettings.mode = iface.mode;
this.sharedInterfaceSettings.mode = iface.type === "HTTPInterface" ? null : iface.mode;
this.sharedInterfaceSettings.bitrate = iface.bitrate;
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
@ -3180,6 +3413,18 @@ export default {
}
}
if (this.newInterfaceType === "HTTPInterface") {
if (this.newInterfaceHttpTunnelMode === "client") {
if (!(this.newInterfaceHttpServerUrl || "").trim()) {
ToastUtils.error(this.$t("interfaces.http_tunnel_server_url_required"));
return;
}
} else if (this.newInterfaceHttpListenPort == null || this.newInterfaceHttpListenPort === "") {
ToastUtils.error(this.$t("interfaces.http_tunnel_listen_port_required"));
return;
}
}
if (this.newInterfaceType === "__external__") {
const typeStr = (this.customExternalTypeName || "").trim();
if (!typeStr) {
@ -3264,7 +3509,9 @@ export default {
: this.newInterfaceListenIp,
listen_port: isBackboneListener
? this.newInterfaceBackboneListenPort || null
: this.newInterfaceListenPort,
: this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
? this.newInterfaceHttpListenPort
: this.newInterfaceListenPort,
forward_ip: this.newInterfaceForwardIp,
forward_port: this.newInterfaceForwardPort,
device: isBackboneListener
@ -3327,6 +3574,39 @@ export default {
configured_bitrate: this.numOrNull(this.newInterfaceConfiguredBitrate),
command: this.newInterfaceCommand,
respawn_delay: this.newInterfaceRespawnDelay,
server_url:
this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "client"
? (this.newInterfaceHttpServerUrl || "").trim() || null
: null,
poll_interval:
this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "client"
? this.numOrNull(this.newInterfaceHttpPollInterval)
: null,
listen_host:
this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
? (this.newInterfaceHttpListenHost || "").trim() || "0.0.0.0"
: null,
mtu: this.newInterfaceType === "HTTPInterface" ? this.numOrNull(this.newInterfaceHttpMtu) : null,
http_version:
this.newInterfaceType === "HTTPInterface" ? this.numOrNull(this.newInterfaceHttpVersion) : null,
user_agent:
this.newInterfaceType === "HTTPInterface"
? (this.newInterfaceHttpUserAgent || "").trim() || null
: null,
check_user_agent:
this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
? this.newInterfaceHttpCheckUserAgent === true
: null,
tls_verify:
this.newInterfaceType === "HTTPInterface" ? this.newInterfaceHttpTlsVerify === true : null,
tls_certfile:
this.newInterfaceType === "HTTPInterface"
? (this.newInterfaceHttpTlsCertfile || "").trim() || null
: null,
tls_keyfile:
this.newInterfaceType === "HTTPInterface"
? (this.newInterfaceHttpTlsKeyfile || "").trim() || null
: null,
discoverable: discoveryEnabled ? "yes" : null,
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
announce_interval: discoveryEnabled
@ -3345,7 +3625,10 @@ export default {
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,
mode:
this.newInterfaceType === "HTTPInterface"
? this.newInterfaceHttpTunnelMode || "client"
: this.sharedInterfaceSettings.mode || null,
recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
announces_from_internal: this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
@ -3437,6 +3720,7 @@ export default {
"PipeInterface",
"AutoInterface",
"LocalInterface",
"HTTPInterface",
]);
return builtin.has(t);
},

View file

@ -241,6 +241,8 @@ export default {
return "eye";
case "PipeInterface":
return "pipe";
case "HTTPInterface":
return "web";
default:
return "server-network";
}
@ -249,6 +251,13 @@ export default {
if (this.iface.type === "TCPClientInterface") {
return `${this.iface.target_host}:${this.iface.target_port}`;
}
if (this.iface.type === "HTTPInterface") {
const tunnelMode = String(this.iface.mode || "").toLowerCase();
if (tunnelMode === "server") {
return `HTTP ${this.iface.listen_host || "0.0.0.0"}:${this.iface.listen_port}`;
}
return this.iface.server_url || "HTTP tunnel client";
}
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
}

View file

@ -33,6 +33,8 @@ export function getDiscoveredIconName(node) {
return "eye";
case "PipeInterface":
return "pipe";
case "HTTPInterface":
return "web";
default:
return "server-network";
}

View file

@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "Commit {commit}"
},
"interfaces": {
"title": "Schnittstellen",
@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Ignoriert Clients, die sich zu schnell verbinden und trennen (RNS 1.4.0 BackboneInterface-Standard).",
"fast_flapping_block_time_label": "Sperrzeit (Minuten)",
"fast_flapping_threshold_label": "Schwelle (Sekunden)",
"fast_flapping_grace_label": "Kulanz-Flaps"
"fast_flapping_grace_label": "Kulanz-Flaps",
"http_tunnel_intro": "Tunneln Sie Reticulum über HTTP-POST (RNS-over-HTTP). Nützlich, wenn nur Webverkehr erlaubt ist. Das gebündelte HTTPInterface-Modul wird automatisch in Ihren Reticulum-interfacepath installiert.",
"http_tunnel_mode_client": "Client",
"http_tunnel_mode_server": "Server",
"http_tunnel_mode_note": "Bei HTTP-Tunneln bedeutet Modus Client oder Server (nicht Reticulum full/gateway/roaming).",
"http_tunnel_server_url": "Server-URL",
"http_tunnel_server_url_required": "Server-URL ist für den HTTP-Tunnel-Clientmodus erforderlich.",
"http_tunnel_poll_interval": "Abfrageintervall (Sekunden)",
"http_tunnel_listen_host": "Listen-Host",
"http_tunnel_listen_port": "Listen-Port",
"http_tunnel_listen_port_required": "Listen-Port ist für den HTTP-Tunnel-Servermodus erforderlich.",
"http_tunnel_check_user_agent": "Passenden User-Agent verlangen",
"http_tunnel_check_user_agent_hint": "POST-Anfragen ohne den konfigurierten User-Agent ablehnen.",
"http_tunnel_http_version": "HTTP-Version",
"http_tunnel_http_version_hint": "HTTP/1.1 ist Standard. HTTP/2 und HTTP/3 benötigen TLS und zusätzliche Python-Pakete (hypercorn/aioquic) auf dem Server.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 und HTTP/3 erfordern https:// auf dem Client und TLS-Zertifikatpfade auf dem Server.",
"http_tunnel_tls_verify": "TLS-Zertifikate prüfen",
"http_tunnel_docs_hint": "Siehe das Kapitel Interfaces für allgemeine Reticulum-Interface-Optionen.",
"http_tunnel_docs_link": "Gebündeltes Reticulum-Handbuch öffnen"
},
"map": {
"title": "Karte",

View file

@ -266,7 +266,7 @@
"preferred_node_placeholder": "Destination hash, e.g. a39610c89d18bb48c73e429582423c24",
"fallback_node_description": "Messages fallback to this node whenever direct delivery fails.",
"auto_select_node": "Auto-select best node",
"auto_select_node_description": "Automatically find and switch to the best available propagation node based on network hops.",
"auto_select_node_description": "Find a usable propagation peer from local announces and Reticulum paths, verify with a small sync probe, and remember good destination hashes for this identity.",
"auto_select_using_label": "Using propagation node",
"auto_select_pending": "No propagation node selected yet. The app will choose one when available.",
"incoming_message_size": "Incoming message size",
@ -1041,6 +1041,7 @@
"title": "About",
"third_party_licenses": "Third-party licenses",
"version": "v{version}",
"git_commit": "commit {commit}",
"ui_build": "UI build {date}",
"rns_version": "RNS {version}",
"lxmf_version": "LXMF {version}",
@ -1316,6 +1317,26 @@
"custom_external_docs_link": "Open bundled Reticulum manual",
"custom_external_type_required": "Enter the Reticulum interface type name.",
"custom_external_json_invalid": "Invalid JSON for extra options.",
"http_tunnel_intro": "Tunnel Reticulum over HTTP POST (RNS-over-HTTP). Useful when only web traffic is allowed. The bundled HTTPInterface module is installed into your Reticulum interfacepath automatically.",
"http_tunnel_mode_client": "Client",
"http_tunnel_mode_server": "Server",
"http_tunnel_mode_note": "For HTTP tunnels, mode means client or server (not Reticulum full/gateway/roaming).",
"http_tunnel_server_url": "Server URL",
"http_tunnel_server_url_required": "Server URL is required for HTTP tunnel client mode.",
"http_tunnel_poll_interval": "Poll interval (seconds)",
"http_tunnel_listen_host": "Listen host",
"http_tunnel_listen_port": "Listen port",
"http_tunnel_listen_port_required": "Listen port is required for HTTP tunnel server mode.",
"http_tunnel_check_user_agent": "Require matching User-Agent",
"http_tunnel_check_user_agent_hint": "Reject POSTs that do not use the configured User-Agent string.",
"http_tunnel_http_version": "HTTP version",
"http_tunnel_http_version_hint": "HTTP/1.1 is the default. HTTP/2 and HTTP/3 need TLS and extra Python packages (hypercorn/aioquic) on the server.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 and HTTP/3 require https:// on the client and TLS certificate paths on the server.",
"http_tunnel_tls_verify": "Verify TLS certificates",
"http_tunnel_docs_hint": "See the Interfaces chapter for general Reticulum interface options.",
"http_tunnel_docs_link": "Open bundled Reticulum manual",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No interfaces were found in the selected configuration file",
"failed_parse_config": "Failed to parse configuration file",

View file

@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ignora clientes que se conectan y desconectan demasiado rápido (valores por defecto de BackboneInterface en RNS 1.4.0).",
"fast_flapping_block_time_label": "Tiempo de bloqueo (minutos)",
"fast_flapping_threshold_label": "Umbral (segundos)",
"fast_flapping_grace_label": "Flaps de gracia"
"fast_flapping_grace_label": "Flaps de gracia",
"http_tunnel_intro": "Tuneliza Reticulum sobre HTTP POST (RNS-over-HTTP). Útil cuando solo se permite tráfico web. El módulo HTTPInterface incluido se instala automáticamente en el interfacepath de Reticulum.",
"http_tunnel_mode_client": "Cliente",
"http_tunnel_mode_server": "Servidor",
"http_tunnel_mode_note": "En túneles HTTP, modo significa cliente o servidor (no full/gateway/roaming de Reticulum).",
"http_tunnel_server_url": "URL del servidor",
"http_tunnel_server_url_required": "La URL del servidor es obligatoria en el modo cliente del túnel HTTP.",
"http_tunnel_poll_interval": "Intervalo de sondeo (segundos)",
"http_tunnel_listen_host": "Host de escucha",
"http_tunnel_listen_port": "Puerto de escucha",
"http_tunnel_listen_port_required": "El puerto de escucha es obligatorio en el modo servidor del túnel HTTP.",
"http_tunnel_check_user_agent": "Exigir User-Agent coincidente",
"http_tunnel_check_user_agent_hint": "Rechazar POSTs que no usen el User-Agent configurado.",
"http_tunnel_http_version": "Versión HTTP",
"http_tunnel_http_version_hint": "HTTP/1.1 es el valor predeterminado. HTTP/2 y HTTP/3 necesitan TLS y paquetes Python adicionales (hypercorn/aioquic) en el servidor.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 y HTTP/3 requieren https:// en el cliente y rutas de certificado TLS en el servidor.",
"http_tunnel_tls_verify": "Verificar certificados TLS",
"http_tunnel_docs_hint": "Consulta el capítulo Interfaces para opciones generales de interfaz de Reticulum.",
"http_tunnel_docs_link": "Abrir el manual de Reticulum incluido"
},
"map": {
"title": "Mapa",

View file

@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "commit {commit}"
},
"interfaces": {
"title": "Sovittimet",
@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ohittaa asiakkaat, jotka yhdistavat ja katkaisevat liian nopeasti (RNS 1.4.0 BackboneInterface-oletukset).",
"fast_flapping_block_time_label": "Estoaika (minuuttia)",
"fast_flapping_threshold_label": "Kynnys (sekuntia)",
"fast_flapping_grace_label": "Armo-flapit"
"fast_flapping_grace_label": "Armo-flapit",
"http_tunnel_intro": "Tunneloi Reticulum HTTP POST -yhteyden yli (RNS-over-HTTP). Hyödyllinen kun vain web-liikenne on sallittu. Mukana tuleva HTTPInterface-moduuli asennetaan automaattisesti Reticulum-interfacepathiin.",
"http_tunnel_mode_client": "Asiakas",
"http_tunnel_mode_server": "Palvelin",
"http_tunnel_mode_note": "HTTP-tunneleissa tila tarkoittaa asiakasta tai palvelinta (ei Reticulum full/gateway/roaming).",
"http_tunnel_server_url": "Palvelimen URL",
"http_tunnel_server_url_required": "Palvelimen URL vaaditaan HTTP-tunnelin asiakastilassa.",
"http_tunnel_poll_interval": "Kyselyväli (sekuntia)",
"http_tunnel_listen_host": "Kuunteluosoite",
"http_tunnel_listen_port": "Kuunteluportti",
"http_tunnel_listen_port_required": "Kuunteluportti vaaditaan HTTP-tunnelin palvelintilassa.",
"http_tunnel_check_user_agent": "Vaadi vastaava User-Agent",
"http_tunnel_check_user_agent_hint": "Hylkää POSTit jotka eivät käytä määritettyä User-Agent-merkkijonoa.",
"http_tunnel_http_version": "HTTP-versio",
"http_tunnel_http_version_hint": "HTTP/1.1 on oletus. HTTP/2 ja HTTP/3 tarvitsevat TLS:n ja lisäpaketit (hypercorn/aioquic) palvelimella.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 ja HTTP/3 edellyttävät https:// asiakkaalla ja TLS-varmenteiden polkuja palvelimella.",
"http_tunnel_tls_verify": "Tarkista TLS-varmenteet",
"http_tunnel_docs_hint": "Katso Interfaces-luku yleisistä Reticulum-rajapinta-asetuksista.",
"http_tunnel_docs_link": "Avaa mukana tuleva Reticulum-ohje"
},
"map": {
"title": "Kartta",

View file

@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ignore les clients qui se connectent et se déconnectent trop rapidement (défauts BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Durée de blocage (minutes)",
"fast_flapping_threshold_label": "Seuil (secondes)",
"fast_flapping_grace_label": "Flaps de grace"
"fast_flapping_grace_label": "Flaps de grace",
"http_tunnel_intro": "Tunnelisez Reticulum via HTTP POST (RNS-over-HTTP). Utile quand seul le trafic web est autorisé. Le module HTTPInterface inclus est installé automatiquement dans votre interfacepath Reticulum.",
"http_tunnel_mode_client": "Client",
"http_tunnel_mode_server": "Serveur",
"http_tunnel_mode_note": "Pour les tunnels HTTP, mode signifie client ou serveur (pas full/gateway/roaming Reticulum).",
"http_tunnel_server_url": "URL du serveur",
"http_tunnel_server_url_required": "L'URL du serveur est requise en mode client du tunnel HTTP.",
"http_tunnel_poll_interval": "Intervalle de sondage (secondes)",
"http_tunnel_listen_host": "Hôte d'écoute",
"http_tunnel_listen_port": "Port d'écoute",
"http_tunnel_listen_port_required": "Le port d'écoute est requis en mode serveur du tunnel HTTP.",
"http_tunnel_check_user_agent": "Exiger un User-Agent correspondant",
"http_tunnel_check_user_agent_hint": "Rejeter les POST qui n'utilisent pas le User-Agent configuré.",
"http_tunnel_http_version": "Version HTTP",
"http_tunnel_http_version_hint": "HTTP/1.1 est la valeur par défaut. HTTP/2 et HTTP/3 nécessitent TLS et des paquets Python supplémentaires (hypercorn/aioquic) sur le serveur.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 et HTTP/3 exigent https:// côté client et des chemins de certificats TLS côté serveur.",
"http_tunnel_tls_verify": "Vérifier les certificats TLS",
"http_tunnel_docs_hint": "Voir le chapitre Interfaces pour les options générales d'interface Reticulum.",
"http_tunnel_docs_link": "Ouvrir le manuel Reticulum inclus"
},
"map": {
"title": "Carte",

View file

@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfacce",
@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Ignora client che si connettono e disconnettono troppo in fretta (default BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Tempo di blocco (minuti)",
"fast_flapping_threshold_label": "Soglia (secondi)",
"fast_flapping_grace_label": "Flap di grazia"
"fast_flapping_grace_label": "Flap di grazia",
"http_tunnel_intro": "Tunnelizza Reticulum su HTTP POST (RNS-over-HTTP). Utile quando è consentito solo il traffico web. Il modulo HTTPInterface incluso viene installato automaticamente nell'interfacepath di Reticulum.",
"http_tunnel_mode_client": "Client",
"http_tunnel_mode_server": "Server",
"http_tunnel_mode_note": "Per i tunnel HTTP, mode significa client o server (non full/gateway/roaming di Reticulum).",
"http_tunnel_server_url": "URL del server",
"http_tunnel_server_url_required": "L'URL del server è obbligatorio in modalità client del tunnel HTTP.",
"http_tunnel_poll_interval": "Intervallo di polling (secondi)",
"http_tunnel_listen_host": "Host in ascolto",
"http_tunnel_listen_port": "Porta in ascolto",
"http_tunnel_listen_port_required": "La porta in ascolto è obbligatoria in modalità server del tunnel HTTP.",
"http_tunnel_check_user_agent": "Richiedi User-Agent corrispondente",
"http_tunnel_check_user_agent_hint": "Rifiuta i POST che non usano lo User-Agent configurato.",
"http_tunnel_http_version": "Versione HTTP",
"http_tunnel_http_version_hint": "HTTP/1.1 è l'impostazione predefinita. HTTP/2 e HTTP/3 richiedono TLS e pacchetti Python aggiuntivi (hypercorn/aioquic) sul server.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 e HTTP/3 richiedono https:// sul client e percorsi dei certificati TLS sul server.",
"http_tunnel_tls_verify": "Verifica certificati TLS",
"http_tunnel_docs_hint": "Vedi il capitolo Interfaces per le opzioni generali delle interfacce Reticulum.",
"http_tunnel_docs_link": "Apri il manuale Reticulum incluso"
},
"map": {
"title": "Mappa",

View file

@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Negeert clients die te snel verbinden en verbreken (RNS 1.4.0 BackboneInterface-standaarden).",
"fast_flapping_block_time_label": "Blokkeertijd (minuten)",
"fast_flapping_threshold_label": "Drempel (seconden)",
"fast_flapping_grace_label": "Grace-flaps"
"fast_flapping_grace_label": "Grace-flaps",
"http_tunnel_intro": "Tunnel Reticulum over HTTP POST (RNS-over-HTTP). Handig wanneer alleen webverkeer is toegestaan. De meegeleverde HTTPInterface-module wordt automatisch in uw Reticulum-interfacepath geïnstalleerd.",
"http_tunnel_mode_client": "Client",
"http_tunnel_mode_server": "Server",
"http_tunnel_mode_note": "Voor HTTP-tunnels betekent mode client of server (niet Reticulum full/gateway/roaming).",
"http_tunnel_server_url": "Server-URL",
"http_tunnel_server_url_required": "Server-URL is vereist voor HTTP-tunnel clientmodus.",
"http_tunnel_poll_interval": "Poll-interval (seconden)",
"http_tunnel_listen_host": "Luisterhost",
"http_tunnel_listen_port": "Luisterpoort",
"http_tunnel_listen_port_required": "Luisterpoort is vereist voor HTTP-tunnel servermodus.",
"http_tunnel_check_user_agent": "Bijpassende User-Agent vereisen",
"http_tunnel_check_user_agent_hint": "Wijs POSTs af die niet de geconfigureerde User-Agent gebruiken.",
"http_tunnel_http_version": "HTTP-versie",
"http_tunnel_http_version_hint": "HTTP/1.1 is de standaard. HTTP/2 en HTTP/3 vereisen TLS en extra Python-pakketten (hypercorn/aioquic) op de server.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 en HTTP/3 vereisen https:// op de client en TLS-certificaatpaden op de server.",
"http_tunnel_tls_verify": "TLS-certificaten verifiëren",
"http_tunnel_docs_hint": "Zie het Interfaces-hoofdstuk voor algemene Reticulum-interfaceopties.",
"http_tunnel_docs_link": "Open de meegeleverde Reticulum-handleiding"
},
"map": {
"title": "Kaart",

View file

@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "коммит {commit}"
},
"interfaces": {
"title": "Интерфейсы",
@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Игнорирует клиентов, которые слишком быстро подключаются и отключаются (стандарты BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Время блокировки (минуты)",
"fast_flapping_threshold_label": "Порог (секунды)",
"fast_flapping_grace_label": "Льготные flaps"
"fast_flapping_grace_label": "Льготные flaps",
"http_tunnel_intro": "Туннелируйте Reticulum через HTTP POST (RNS-over-HTTP). Полезно, когда разрешён только веб-трафик. Встроенный модуль HTTPInterface автоматически устанавливается в interfacepath Reticulum.",
"http_tunnel_mode_client": "Клиент",
"http_tunnel_mode_server": "Сервер",
"http_tunnel_mode_note": "Для HTTP-туннелей mode означает client или server (не full/gateway/roaming Reticulum).",
"http_tunnel_server_url": "URL сервера",
"http_tunnel_server_url_required": "URL сервера обязателен для клиентского режима HTTP-туннеля.",
"http_tunnel_poll_interval": "Интервал опроса (секунды)",
"http_tunnel_listen_host": "Адрес прослушивания",
"http_tunnel_listen_port": "Порт прослушивания",
"http_tunnel_listen_port_required": "Порт прослушивания обязателен для серверного режима HTTP-туннеля.",
"http_tunnel_check_user_agent": "Требовать совпадающий User-Agent",
"http_tunnel_check_user_agent_hint": "Отклонять POST без настроенной строки User-Agent.",
"http_tunnel_http_version": "Версия HTTP",
"http_tunnel_http_version_hint": "HTTP/1.1 по умолчанию. HTTP/2 и HTTP/3 требуют TLS и доп. пакеты Python (hypercorn/aioquic) на сервере.",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 и HTTP/3 требуют https:// на клиенте и пути TLS-сертификатов на сервере.",
"http_tunnel_tls_verify": "Проверять TLS-сертификаты",
"http_tunnel_docs_hint": "См. главу Interfaces для общих параметров интерфейсов Reticulum.",
"http_tunnel_docs_link": "Открыть встроенное руководство Reticulum"
},
"map": {
"title": "Карта",

View file

@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
"path_table_count": "{count} paths"
"path_table_count": "{count} paths",
"git_commit": "提交 {commit}"
},
"interfaces": {
"title": "接口",
@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "忽略连接与断开过快的客户端RNS 1.4.0 BackboneInterface 默认值)。",
"fast_flapping_block_time_label": "封锁时间(分钟)",
"fast_flapping_threshold_label": "阈值(秒)",
"fast_flapping_grace_label": "宽限抖动次数"
"fast_flapping_grace_label": "宽限抖动次数",
"http_tunnel_intro": "通过 HTTP POST 隧道传输 ReticulumRNS-over-HTTP。在仅允许 Web 流量时很有用。捆绑的 HTTPInterface 模块会自动安装到 Reticulum 的 interfacepath。",
"http_tunnel_mode_client": "客户端",
"http_tunnel_mode_server": "服务器",
"http_tunnel_mode_note": "对于 HTTP 隧道mode 表示客户端或服务器(不是 Reticulum 的 full/gateway/roaming。",
"http_tunnel_server_url": "服务器 URL",
"http_tunnel_server_url_required": "HTTP 隧道客户端模式需要服务器 URL。",
"http_tunnel_poll_interval": "轮询间隔(秒)",
"http_tunnel_listen_host": "监听主机",
"http_tunnel_listen_port": "监听端口",
"http_tunnel_listen_port_required": "HTTP 隧道服务器模式需要监听端口。",
"http_tunnel_check_user_agent": "要求匹配的 User-Agent",
"http_tunnel_check_user_agent_hint": "拒绝未使用已配置 User-Agent 字符串的 POST。",
"http_tunnel_http_version": "HTTP 版本",
"http_tunnel_http_version_hint": "默认 HTTP/1.1。HTTP/2 和 HTTP/3 需要 TLS并在服务器上安装额外 Python 包hypercorn/aioquic。",
"http_tunnel_mtu": "MTU",
"http_tunnel_user_agent": "User-Agent",
"http_tunnel_tls_hint": "HTTP/2 和 HTTP/3 要求客户端使用 https://,服务器提供 TLS 证书路径。",
"http_tunnel_tls_verify": "验证 TLS 证书",
"http_tunnel_docs_hint": "有关常规 Reticulum 接口选项,请参阅 Interfaces 章节。",
"http_tunnel_docs_link": "打开捆绑的 Reticulum 手册"
},
"map": {
"title": "地图",

View file

@ -24,6 +24,7 @@ The **Add interface** flow includes:
| KISSInterface | KISS TNC devices |
| I2PInterface | I2P-based Reticulum transport |
| AutoInterface | Automatic discovery on local networks |
| HTTPInterface | HTTP/S tunnel (bundled RNS-over-HTTP) |
| Custom external types | Advanced setups |
Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
@ -44,6 +45,10 @@ LoRa setups often need firmware management. **Tools → RNode Flasher** opens th
MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
## HTTP tunnel interface
MeshChatX vendors [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) and installs `HTTPInterface.py` into your Reticulum `interfacepath` on startup. Use **Add interface → HTTP Tunnel** for client or server mode when only HTTP/S egress is available. Default transport is HTTP/1.1. HTTP/2 and HTTP/3 need TLS and optional extra packages on the server side.
## Getting onto the mesh
A minimal path for a new node:

View file

@ -38,7 +38,7 @@ MeshChatX can:
- Run a **local propagation node** on your identity
- **Sync** with remote propagation nodes you trust
- **Auto-select** a preferred node via `AutoPropagationManager`
- **Auto-select** a preferred propagation peer via `AutoPropagationManager` from local `lxmf.propagation` announces and RNS paths (small sync probe, remembered destination hashes, no central directory)
- **Retry** failed direct deliveries through propagation when configured
Manage nodes from **Tools → Propagation nodes** or related settings entries.

View file

@ -19,6 +19,7 @@
"build-docs:rns": "python3 scripts/build/fetch_reticulum_manual.py --force --via-rns",
"build-repository-wheels": "python3 scripts/build/fetch_repository_wheels.py",
"version:sync": "node scripts/sync_version.js",
"build-meta:bake": "node scripts/bake_build_meta.js",
"build": "pnpm run version:sync && pnpm run build-frontend && pnpm run build-docs && pnpm run build-repository-wheels && pnpm run build-backend",
"build:offline": "cross-env MESHCHATX_OFFLINE_BUILD=1 pnpm run build",
"bundle:offline": "bash scripts/create-offline-bundle.sh",

View file

@ -37,6 +37,7 @@ dependencies = [
"cbor2>=6.1.1",
"wasmtime>=28.0.0",
"bleak>=3.0.2,<4.0",
"httpx[http2]>=0.28.1,<1.0",
]
[project.scripts]
@ -56,7 +57,7 @@ exclude = ["tests*"]
namespaces = false
[tool.setuptools.package-data]
meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/backend/data/plugins/**/*", "src/frontend/public/repository-server-index.html"]
meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/backend/data/plugins/**/*", "src/backend/data/interfaces/**/*", "src/frontend/public/repository-server-index.html"]
[tool.setuptools.exclude-package-data]
meshchatx = ["public/repository-server-bundled/**"]

123
scripts/bake_build_meta.js Normal file
View file

@ -0,0 +1,123 @@
/**
* Bake git commit and build channel into meshchatx/src/_build_meta_baked.py
* (gitignored). meshchatx.src.build_meta imports that overlay when present.
*
* Invoked from scripts/sync_version.js (pnpm run version:sync / build).
*
* Channel detection (first match wins):
* MESHCHATX_BUILD_CHANNEL env
* GITHUB_REF_NAME / GITHUB_REF (nightly-*, preview-dev-*, preview-*, vX.Y.Z)
* default: local (is_dev)
*
* Dev channels (nightly, preview-dev, preview, local) set IS_DEV_BUILD.
* Release tags (vX.Y.Z) clear IS_DEV_BUILD.
*/
const fs = require("fs");
const path = require("path");
const { execFileSync } = require("child_process");
const root = path.resolve(__dirname, "..");
const metaPath = path.join(root, "meshchatx", "src", "_build_meta_baked.py");
function envTrim(name) {
const v = process.env[name];
return typeof v === "string" ? v.trim() : "";
}
function git(args) {
try {
return execFileSync("git", args, {
cwd: root,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
return "";
}
}
function resolveCommit() {
const fromEnv = envTrim("MESHCHATX_GIT_COMMIT") || envTrim("GIT_COMMIT") || envTrim("GITHUB_SHA");
if (fromEnv) {
return fromEnv;
}
return git(["rev-parse", "HEAD"]);
}
function shortCommit(full) {
if (!full) {
return "";
}
if (/^[0-9a-f]{7,40}$/i.test(full)) {
return full.slice(0, 7).toLowerCase();
}
const fromGit = git(["rev-parse", "--short=7", "HEAD"]);
return fromGit || full.slice(0, 7);
}
function refName() {
return (
envTrim("MESHCHATX_BUILD_REF") ||
envTrim("GITHUB_REF_NAME") ||
envTrim("GITHUB_REF").replace(/^refs\/(heads|tags)\//, "")
);
}
function resolveChannel(ref) {
const forced = envTrim("MESHCHATX_BUILD_CHANNEL").toLowerCase();
if (forced) {
return forced;
}
if (!ref) {
return "local";
}
if (ref.startsWith("nightly-")) {
return "nightly";
}
if (ref.startsWith("preview-dev-")) {
return "preview-dev";
}
if (ref.startsWith("preview-")) {
return "preview";
}
if (/^v\d+\.\d+\.\d+/.test(ref)) {
return "release";
}
return "local";
}
function isDevChannel(channel) {
return channel !== "release";
}
function pyString(value) {
return JSON.stringify(String(value ?? ""));
}
function bake() {
const commit = resolveCommit();
const short = shortCommit(commit);
const ref = refName();
const channel = resolveChannel(ref);
const isDev = isDevChannel(channel);
const content = `# SPDX-License-Identifier: 0BSD
"""Generated by scripts/bake_build_meta.js. Do not commit."""
GIT_COMMIT = ${pyString(commit)}
GIT_COMMIT_SHORT = ${pyString(short)}
BUILD_CHANNEL = ${pyString(channel)}
IS_DEV_BUILD = ${isDev ? "True" : "False"}
`;
const prev = fs.existsSync(metaPath) ? fs.readFileSync(metaPath, "utf8") : null;
if (prev !== content) {
fs.writeFileSync(metaPath, content, "utf8");
console.log(`Baked build_meta channel=${channel} is_dev=${isDev} commit=${short || "unknown"}`);
} else {
console.log(`build_meta unchanged channel=${channel} is_dev=${isDev} commit=${short || "unknown"}`);
}
}
bake();

View file

@ -11,11 +11,13 @@ This script:
3) Builds pycodec2 Android wheels with Chaquopy's build-wheel tool
4) Optionally patches LXST wheel metadata for local Android constraints
5) Vendors the bleak pure-python wheel from PyPI
6) Patches the rns wheel so its Android RNodeInterface never calls
6) Vendors httpx[http2] and its pure-python dependency wheels from PyPI
(RNS-over-HTTP / HTTPInterface client support)
7) Patches the rns wheel so its Android RNodeInterface never calls
RNS.panic() (os._exit) when usbserial4a/jnius are missing
7) Builds every recipe under android/chaquopy-recipes/ for each requested
ABI (currently: cryptography, miniaudio)
8) Copies outputs to android/vendor
8) Builds every recipe under android/chaquopy-recipes/ for each requested
ABI (currently: cryptography, miniaudio, aiohttp, cbor2, ...)
9) Copies outputs to android/vendor
Usage:
scripts/build-android-wheels-local.sh [options]
@ -30,6 +32,7 @@ Options:
--numpy-version V NumPy version used during pycodec2 build (default: 1.26.2)
--lxst-version V LXST wheel version for metadata patch (default: 0.5.0)
--bleak-version V bleak pure-python wheel version to vendor (default: 3.0.2)
--httpx-version V httpx pure-python wheel version to vendor (default: 0.28.1)
--rns-version V rns wheel version to patch (default: 1.4.0)
--no-lxst-patch Skip LXST metadata patch
--no-rns-patch Skip RNS Android RNodeInterface patch
@ -60,6 +63,7 @@ LIBCODEC2_VERSION="1.2.0"
NUMPY_VERSION="1.26.2"
LXST_VERSION="0.5.0"
BLEAK_VERSION="3.0.2"
HTTPX_VERSION="0.28.1"
RNS_VERSION="1.4.0"
PATCH_LXST="1"
PATCH_RNS="1"
@ -105,6 +109,10 @@ while [[ $# -gt 0 ]]; do
BLEAK_VERSION="${2:?missing value for --bleak-version}"
shift 2
;;
--httpx-version)
HTTPX_VERSION="${2:?missing value for --httpx-version}"
shift 2
;;
--rns-version)
RNS_VERSION="${2:?missing value for --rns-version}"
shift 2
@ -838,6 +846,37 @@ PY
echo "Expected bleak-${BLEAK_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2
exit 1
fi
echo "Fetching httpx[http2] ${HTTPX_VERSION} pure-python wheels (and deps)"
HTTPX_TMP_DIR="$(mktemp -d)"
"${VENV_DIR}/bin/pip" download \
--only-binary=:all: \
"httpx[http2]==${HTTPX_VERSION}" \
--dest "${HTTPX_TMP_DIR}" \
--index-url https://pypi.org/simple
HTTPX_WHEEL="$(ls "${HTTPX_TMP_DIR}"/httpx-"${HTTPX_VERSION}"-py3-none-any.whl)"
cp -f "${HTTPX_WHEEL}" "${OUT_DIR}/"
# Vendor transitive pure-python deps so offline/find-links builds resolve.
for dep_wheel in "${HTTPX_TMP_DIR}"/*.whl; do
base="$(basename "${dep_wheel}")"
if [[ "${base}" == httpx-* ]]; then
continue
fi
cp -f "${dep_wheel}" "${OUT_DIR}/"
done
rm -rf "${HTTPX_TMP_DIR}"
if ! ls "${OUT_DIR}/httpx-${HTTPX_VERSION}-py3-none-any.whl" >/dev/null 2>&1; then
echo "Expected httpx-${HTTPX_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2
exit 1
fi
for required_prefix in httpcore- h2- h11- anyio- hpack- hyperframe-; do
if ! ls "${OUT_DIR}/${required_prefix}"*.whl >/dev/null 2>&1; then
echo "Expected ${required_prefix}*.whl in ${OUT_DIR} (httpx[http2] dependency)" >&2
exit 1
fi
done
fi
if [[ "${PATCH_RNS}" == "1" && -z "${ONLY_RECIPES}" ]]; then

View file

@ -0,0 +1,195 @@
# SPDX-License-Identifier: 0BSD
"""Live observation of AutoPropagationManager against a shared rnsd instance.
Usage (from repo root, with rnsd already running as shared instance)::
MESHCHAT_LANDLOCK=0 MESHCHAT_SECCOMP=0 uv run python scripts/live_auto_propagation_observe.py
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import sys
import tempfile
import time
from pathlib import Path
import RNS
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from meshchatx.meshchat import ReticulumMeshChat # noqa: E402
from meshchatx.src.backend.auto_propagation_manager import ( # noqa: E402
MEMORY_CONFIG_KEY,
AutoPropagationManager,
)
def _pretty(node_hex: str | None) -> str:
if not node_hex:
return "(none)"
try:
return RNS.prettyhexrep(bytes.fromhex(node_hex))
except Exception:
return node_hex[:12] + "..."
async def main() -> int:
reticulum_config_dir = os.path.expanduser("~/.reticulum")
if not Path(reticulum_config_dir, "config").is_file():
print("FAIL: missing ~/.reticulum/config (need shared rnsd)")
return 2
tmp = tempfile.mkdtemp(prefix="mcx-live-autoprops-")
log_path = Path(tmp) / "observe.json"
print(f"temp storage: {tmp}")
print(f"reticulum config: {reticulum_config_dir}")
identity = RNS.Identity()
app = ReticulumMeshChat(
identity,
storage_dir=tmp,
reticulum_config_dir=reticulum_config_dir,
defer_network_setup=False,
headless=True,
plugins_enabled=False,
)
report: dict = {
"started_at": time.time(),
"network_ready": False,
"announce_wait_s": 45,
"propagation_announces": 0,
"candidates": {},
"before_preferred": None,
"after_preferred": None,
"memory": {},
"path_ok": None,
"hops": None,
"error": None,
}
try:
ready = app.wait_until_network_ready(timeout=90)
report["network_ready"] = bool(ready)
print(f"network_ready={ready}")
if not ready:
report["error"] = "network_not_ready"
log_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
return 1
ctx = app.current_context
if ctx is None or ctx.auto_propagation_manager is None:
report["error"] = "no_auto_propagation_manager"
log_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
return 1
manager: AutoPropagationManager = ctx.auto_propagation_manager
ctx.config.lxmf_preferred_propagation_node_auto_select.set(True)
print(f"waiting {report['announce_wait_s']}s for lxmf.propagation announces...")
await asyncio.sleep(report["announce_wait_s"])
announces = (
ctx.database.announces.get_announces(aspect="lxmf.propagation") or []
)
report["propagation_announces"] = len(announces)
print(f"propagation announces stored: {len(announces)}")
best, announced = manager._collect_candidates()
report["candidates"] = {
"count": len(best),
"announced_count": len(announced),
"sample": [
{
"dest": _pretty(h),
"hops": hops if hops < 10**8 else None,
"announced": h in announced,
"path_ok": manager._path_quality_ok(bytes.fromhex(h)),
}
for h, hops in list(best.items())[:8]
],
}
print(
f"candidates={len(best)} announced={len(announced)} "
f"sample={report['candidates']['sample']}",
)
before = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
report["before_preferred"] = before
print(f"before preferred: {_pretty(before)}")
t0 = time.monotonic()
await manager.check_and_update_propagation_node()
elapsed = time.monotonic() - t0
report["check_elapsed_s"] = round(elapsed, 2)
after = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
report["after_preferred"] = after
raw_mem = ctx.config.get(MEMORY_CONFIG_KEY, default_value=None)
report["memory"] = json.loads(raw_mem) if raw_mem else {}
print(f"after preferred: {_pretty(after)} ({elapsed:.1f}s)")
if after:
dest = bytes.fromhex(after)
report["path_ok"] = manager._path_quality_ok(dest)
report["hops"] = manager._hops_to(dest)
outbound = ctx.message_router.get_outbound_propagation_node()
report["outbound_matches"] = (
outbound is not None and outbound.hex() == after.lower()
)
print(
f"path_ok={report['path_ok']} hops={report['hops']} "
f"outbound_matches={report['outbound_matches']}",
)
# Second pass should stay sticky without thrashing when recently verified.
if after:
await manager.check_and_update_propagation_node()
sticky = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
report["sticky_preferred"] = sticky
report["sticky_ok"] = sticky == after
print(f"sticky preferred: {_pretty(sticky)} ok={report['sticky_ok']}")
log_path.write_text(json.dumps(report, indent=2) + "\n")
print("--- report ---")
print(json.dumps(report, indent=2))
if not best:
print(
"NOTE: no enabled lxmf.propagation candidates heard yet. "
"Logic ran, but live selection could not be proven this cycle.",
)
return 0
if after and report.get("path_ok") and report.get("outbound_matches"):
print("PASS: selected verified preferred peer with usable path")
return 0
if after:
print("PARTIAL: preferred set but path/outbound not fully confirmed")
return 0
print("NOTE: check finished without selecting a preferred peer")
return 0
except Exception as exc:
report["error"] = f"{type(exc).__name__}: {exc}"
log_path.write_text(json.dumps(report, indent=2) + "\n")
print(json.dumps(report, indent=2))
raise
finally:
with contextlib.suppress(Exception):
if getattr(app, "current_context", None) is not None:
app.current_context.stop()
print(f"report file: {log_path}")
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))

View file

@ -8,7 +8,8 @@
* meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_raspberry_pi.md,
* meshchatx/src/backend/data/licenses_backend.json (reticulum-meshchatx entry),
* android/app/build.gradle,
* pipx example, packaging/arch/PKGBUILD pkgver / printf fallback.
* pipx example, packaging/arch/PKGBUILD pkgver / printf fallback,
* then runs scripts/bake_build_meta.js (git commit / channel overlay).
*
* __version__ lives in meshchatx/__init__.py so Chaquopy/Android (which may not ship loose .py
* data files next to bytecode) always has a resolvable version. src/version.py stays for packaging and tools.
@ -135,3 +136,9 @@ patchFile("packaging/arch/.SRCINFO", (c) =>
);
console.log(`Synced version ${version} from package.json`);
try {
require("./bake_build_meta.js");
} catch (err) {
console.warn(`bake_build_meta skipped: ${err && err.message ? err.message : err}`);
}

View file

@ -86,6 +86,11 @@ async def test_app_info_extended(mock_rns_minimal, temp_dir):
assert "lxst_version" in data["app_info"]
assert data["app_info"]["lxst_version"] == "1.2.3"
assert "display_version" in data["app_info"]
assert "git_commit" in data["app_info"]
assert "git_commit_short" in data["app_info"]
assert "is_dev_build" in data["app_info"]
assert "build_channel" in data["app_info"]
@pytest.mark.asyncio

View file

@ -6,7 +6,12 @@ import pytest
import RNS
from LXMF.LXMRouter import LXMRouter
from meshchatx.src.backend.auto_propagation_manager import AutoPropagationManager
from meshchatx.src.backend.auto_propagation_manager import (
FAILURE_COOLDOWN_SECONDS,
MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR,
MEMORY_CONFIG_KEY,
AutoPropagationManager,
)
_VALID_HASH_A = "01" * 16
_VALID_HASH_B = "02" * 16
@ -15,11 +20,30 @@ _VALID_HASH_C = "03" * 16
_APP_DATA_ENABLED = b"\x94\x00\x00\x01\x00"
def _make_manager():
def _make_manager(memory_raw=None):
app = MagicMock()
context = MagicMock()
config = MagicMock()
database = MagicMock()
manager_store = {}
if memory_raw is not None:
manager_store[MEMORY_CONFIG_KEY] = memory_raw
config_manager = MagicMock()
config_manager.get.side_effect = lambda key, default_value=None: manager_store.get(
key,
default_value,
)
def _set(key, value):
manager_store[key] = value
config_manager.set.side_effect = _set
# Real ConfigManager exposes get/set on itself (not .manager).
config.get = config_manager.get
config.set = config_manager.set
config.manager = config_manager
context.config = config
context.database = database
@ -27,14 +51,15 @@ def _make_manager():
context.running = True
context.message_router = MagicMock()
context.message_router.propagation_transfer_state = LXMRouter.PR_IDLE
context.message_router.get_outbound_propagation_node.return_value = None
manager = AutoPropagationManager(app, context)
return manager, app, context, config, database
return manager, app, context, config, database, manager_store
@pytest.mark.asyncio
async def test_auto_propagation_logic():
manager, app, context, config, database = _make_manager()
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = False
with patch.object(manager, "check_and_update_propagation_node") as mock_check:
@ -58,8 +83,13 @@ async def test_auto_propagation_logic():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
@ -74,6 +104,8 @@ async def test_auto_propagation_logic():
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
_VALID_HASH_A,
)
assert _VALID_HASH_A in manager._memory
assert manager._memory[_VALID_HASH_A]["successes"] >= 1
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_B
@ -83,8 +115,13 @@ async def test_auto_propagation_logic():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", side_effect=[False, True]),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
@ -106,12 +143,18 @@ async def test_auto_propagation_logic():
}
database.announces.get_announces.return_value = [announce1, announce3]
app.set_active_propagation_node.reset_mock()
config.lxmf_preferred_propagation_node_destination_hash.set.reset_mock()
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
@ -119,7 +162,14 @@ async def test_auto_propagation_logic():
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
# Preferred C still works. Refresh binding but keep the same hash.
app.set_active_propagation_node.assert_called_with(
_VALID_HASH_C,
context=context,
)
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
_VALID_HASH_C,
)
@pytest.mark.asyncio
@ -129,7 +179,7 @@ async def test_auto_propagation_skips_when_sync_active_and_path_exists():
When a sync is active and the current node still has a path, the manager
should leave it alone so the transfer can finish.
"""
manager, app, context, config, database = _make_manager()
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@ -142,7 +192,7 @@ async def test_auto_propagation_skips_when_sync_active_and_path_exists():
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(
manager,
"_wait_for_path",
"_wait_for_usable_path",
return_value=True,
),
patch(
@ -163,7 +213,7 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
The manager should stop the stuck sync and look for a working node.
"""
manager, app, context, config, database = _make_manager()
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@ -180,9 +230,14 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
with (
patch.object(RNS.Transport, "has_path") as mock_has_path,
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
# Current node A has no path, candidate B has a path.
mock_has_path.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_B)
@ -200,13 +255,9 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
@pytest.mark.asyncio
async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
"""Remove the active propagation node when no candidate works.
When no candidate works and the previous node is unreachable, the active
propagation node should be removed instead of restoring a broken one.
"""
manager, app, context, config, database = _make_manager()
async def test_auto_propagation_keeps_preferred_until_repeated_failures():
"""Transient probe failures should not wipe a preferred node immediately."""
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@ -221,18 +272,36 @@ async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
with (
patch.object(RNS.Transport, "has_path", return_value=False),
patch.object(manager, "_wait_for_path", return_value=False),
patch.object(manager, "_wait_for_usable_path", return_value=False),
patch.object(RNS.Transport, "request_path") as mock_request_path,
):
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_called_once_with(context=context)
app.remove_active_propagation_node.assert_not_called()
app.set_active_propagation_node.assert_called_with(_VALID_HASH_A, context=context)
mock_request_path.assert_called()
assert manager._memory.get(_VALID_HASH_B, {}).get("consecutive_failures", 0) >= 1
@pytest.mark.asyncio
async def test_auto_propagation_clears_previous_even_when_path_still_exists():
"""Do not restore a sync-broken previous node just because has_path is true."""
manager, app, context, config, database = _make_manager()
async def test_auto_propagation_clears_after_repeated_failures():
"""Clear the preferred node after repeated verify failures."""
import json
import time
memory = {
_VALID_HASH_A: {
"successes": 2,
"failures": MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR - 1,
"consecutive_failures": MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR - 1,
"last_success_at": int(time.time()) - 60,
"last_failure_at": int(time.time()) - 30,
"last_hops": 2,
},
}
manager, app, context, config, database, _store = _make_manager(
memory_raw=json.dumps(memory),
)
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@ -240,31 +309,38 @@ async def test_auto_propagation_clears_previous_even_when_path_still_exists():
)
announce1 = {
"destination_hash": _VALID_HASH_A,
"app_data": _APP_DATA_ENABLED,
}
announce2 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
database.announces.get_announces.return_value = [announce1]
database.announces.get_announces.return_value = [announce1, announce2]
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=False),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
# Avoid cooldown short-circuit for A so both nodes are probed.
patch.object(manager, "_in_failure_cooldown", return_value=False),
):
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_called_once_with(context=context)
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(None)
@pytest.mark.asyncio
async def test_check_and_update_propagation_node_noops_without_message_router():
manager, app, context, config, _database = _make_manager()
manager, app, context, config, _database, _store = _make_manager()
context.message_router = None
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
@ -290,7 +366,7 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
Even if RNS reports has_path=True, a stale or unresponsive path should be
treated as broken so the manager can look for a working alternative.
"""
manager, app, context, config, database = _make_manager()
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@ -306,9 +382,9 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive") as mock_unresponsive,
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
patch(
@ -316,6 +392,8 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
return_value=False,
),
):
mock_unresponsive.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_A)
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_called_once_with(context=context)
@ -325,6 +403,106 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
)
@pytest.mark.asyncio
async def test_probe_propagation_sync_treats_complete_as_success():
"""LXMF finishes successful sync at PR_COMPLETE, not only PR_IDLE."""
manager, app, context, config, database, _store = _make_manager()
router = context.message_router
router.propagation_transfer_state = LXMRouter.PR_IDLE
call_count = [0]
async def fake_sleep(_):
call_count[0] += 1
if call_count[0] == 1:
router.propagation_transfer_state = LXMRouter.PR_LINK_ESTABLISHING
elif call_count[0] == 2:
router.propagation_transfer_state = LXMRouter.PR_COMPLETE
with (
patch(
"meshchatx.src.backend.auto_propagation_manager.asyncio.sleep",
fake_sleep,
),
patch.object(RNS.Identity, "recall", return_value=object()),
):
result = await manager._probe_propagation_sync(_VALID_HASH_A)
assert result is True
@pytest.mark.asyncio
async def test_probe_propagation_sync_uses_scarce_message_budget():
"""Auto-select probes must not pull a full mailbox (Zen scarcity)."""
manager, app, context, config, database, _store = _make_manager()
router = context.message_router
router.propagation_transfer_state = LXMRouter.PR_IDLE
call_count = [0]
async def fake_sleep(_):
call_count[0] += 1
if call_count[0] == 1:
router.propagation_transfer_state = LXMRouter.PR_REQUEST_SENT
elif call_count[0] == 2:
router.propagation_transfer_state = LXMRouter.PR_COMPLETE
with (
patch(
"meshchatx.src.backend.auto_propagation_manager.asyncio.sleep",
fake_sleep,
),
patch.object(RNS.Identity, "recall", return_value=object()),
):
result = await manager._probe_propagation_sync(_VALID_HASH_A)
assert result is True
router.request_messages_from_propagation_node.assert_called_once()
_args, kwargs = router.request_messages_from_propagation_node.call_args
# Prefer kwargs if present, else positional max_messages.
if "max_messages" in kwargs:
assert kwargs["max_messages"] == 1
else:
assert _args[1] == 1
@pytest.mark.asyncio
async def test_auto_propagation_caps_probes_per_cycle():
"""Do not sync-probe every announced peer in one cycle."""
from meshchatx.src.backend.auto_propagation_manager import MAX_PROBES_PER_CYCLE
manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
announces = []
for i in range(MAX_PROBES_PER_CYCLE + 3):
announces.append(
{
"destination_hash": f"{i:02x}" * 16,
"app_data": _APP_DATA_ENABLED,
},
)
database.announces.get_announces.return_value = announces
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(
manager, "_probe_propagation_sync", return_value=False
) as mock_probe,
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
await manager.check_and_update_propagation_node()
assert mock_probe.call_count == MAX_PROBES_PER_CYCLE
@pytest.mark.asyncio
async def test_probe_propagation_sync_ignores_stale_state():
"""A stale non-idle state from a previous sync must not cause a false success.
@ -334,7 +512,7 @@ async def test_probe_propagation_sync_ignores_stale_state():
"""
import time
manager, app, context, config, database = _make_manager()
manager, app, context, config, database, _store = _make_manager()
router = context.message_router
router.propagation_transfer_state = LXMRouter.PR_RECEIVING
@ -357,6 +535,8 @@ async def test_probe_propagation_sync_ignores_stale_state():
fake_sleep,
),
patch.object(time, "monotonic", fake_monotonic),
patch.object(RNS.Identity, "recall", return_value=None),
patch.object(RNS.Transport, "request_path"),
):
result = await manager._probe_propagation_sync(_VALID_HASH_A)
@ -384,3 +564,147 @@ async def test_sync_propagation_nodes_skips_when_active_and_not_forced():
router.request_messages_from_propagation_node.assert_not_called()
mock_stop.assert_not_called()
@pytest.mark.asyncio
async def test_auto_propagation_keeps_recently_verified_without_probe():
"""A recently verified preferred node with a good path should not be re-probed."""
import json
import time
memory = {
_VALID_HASH_A: {
"successes": 3,
"failures": 0,
"consecutive_failures": 0,
"last_success_at": int(time.time()),
"last_failure_at": 0,
"last_hops": 2,
},
}
manager, app, context, config, database, store = _make_manager(
memory_raw=json.dumps(memory),
)
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_A
)
context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
_VALID_HASH_A,
)
announce1 = {
"destination_hash": _VALID_HASH_A,
"app_data": _APP_DATA_ENABLED,
}
announce2 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
database.announces.get_announces.return_value = [announce1, announce2]
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(RNS.Transport, "hops_to") as mock_hops,
patch.object(manager, "_wait_for_usable_path") as mock_wait,
patch.object(manager, "_probe_propagation_sync") as mock_probe,
patch.object(RNS.Transport, "request_path") as mock_request_path,
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
# B is only one hop better, inside hysteresis, so keep A.
mock_hops.side_effect = lambda dh: (
2 if dh == bytes.fromhex(_VALID_HASH_A) else 1
)
await manager.check_and_update_propagation_node()
mock_wait.assert_not_called()
mock_probe.assert_not_called()
app.set_active_propagation_node.assert_not_called()
mock_request_path.assert_called()
assert MEMORY_CONFIG_KEY in store or _VALID_HASH_A in manager._memory
@pytest.mark.asyncio
async def test_auto_propagation_remembers_good_node_without_announce():
"""Remembered successful nodes remain candidates when announces age out."""
import json
import time
memory = {
_VALID_HASH_A: {
"successes": 4,
"failures": 0,
"consecutive_failures": 0,
"last_success_at": int(time.time()),
"last_failure_at": 0,
"last_hops": 1,
},
}
manager, app, context, config, database, _store = _make_manager(
memory_raw=json.dumps(memory),
)
# Force re-verify so the sticky short-circuit does not skip the probe.
manager._memory[_VALID_HASH_A]["last_success_at"] = int(time.time()) - (
FAILURE_COOLDOWN_SECONDS * 10
)
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
database.announces.get_announces.return_value = []
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
):
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_called_with(
_VALID_HASH_A,
context=context,
)
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
_VALID_HASH_A,
)
@pytest.mark.asyncio
async def test_wait_for_usable_path_rejects_unresponsive_paths():
manager, _app, _context, _config, _database, _store = _make_manager()
dest = bytes.fromhex(_VALID_HASH_A)
with (
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.prepare_fresh_path_request",
return_value="new_path_requested",
),
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.nudge_path_request",
) as mock_nudge,
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
patch(
"meshchatx.src.backend.auto_propagation_manager.time.monotonic",
) as mock_mono,
):
mock_mono.side_effect = [0.0, 0.1, 50.0, 50.0]
ok = await manager._wait_for_usable_path(dest, timeout=1.0)
assert ok is False
mock_nudge.assert_called()

View file

@ -0,0 +1,27 @@
# SPDX-License-Identifier: 0BSD
from meshchatx.src import build_meta
def test_display_version_adds_dev_suffix(monkeypatch):
monkeypatch.setattr(build_meta, "IS_DEV_BUILD", True)
assert build_meta.display_version("4.8.0") == "4.8.0-dev"
assert build_meta.display_version("4.8.0-dev") == "4.8.0-dev"
def test_display_version_release(monkeypatch):
monkeypatch.setattr(build_meta, "IS_DEV_BUILD", False)
assert build_meta.display_version("4.8.0") == "4.8.0"
def test_as_dict_includes_commit_fields(monkeypatch):
monkeypatch.setattr(build_meta, "GIT_COMMIT", "abcdef0123456789")
monkeypatch.setattr(build_meta, "GIT_COMMIT_SHORT", "abcdef0")
monkeypatch.setattr(build_meta, "BUILD_CHANNEL", "nightly")
monkeypatch.setattr(build_meta, "IS_DEV_BUILD", True)
data = build_meta.as_dict("4.8.0")
assert data["git_commit"] == "abcdef0123456789"
assert data["git_commit_short"] == "abcdef0"
assert data["build_channel"] == "nightly"
assert data["is_dev_build"] is True
assert data["display_version"] == "4.8.0-dev"

View file

@ -66,3 +66,28 @@ def test_install_list_delete_interface_module(tmp_path):
assert not os.path.exists(
os.path.join(str(config_dir), "interfaces", "ExampleInterface.py")
)
def test_ensure_bundled_interface_modules_installs_http_interface(tmp_path):
from meshchatx.src.backend.interface_module_store import (
ensure_bundled_interface_modules,
resolve_bundled_interface_module_path,
)
source = resolve_bundled_interface_module_path("HTTPInterface.py")
assert source is not None
assert os.path.isfile(source)
config_dir = tmp_path / "reticulum"
config_dir.mkdir()
written = ensure_bundled_interface_modules(str(config_dir))
assert written
assert written[0]["type"] == "HTTPInterface"
target = config_dir / "interfaces" / "HTTPInterface.py"
assert target.is_file()
with open(source, "rb") as handle:
expected = handle.read()
assert target.read_bytes() == expected
again = ensure_bundled_interface_modules(str(config_dir))
assert again == []

View file

@ -828,3 +828,78 @@ async def test_backbone_listener_fast_flapping_options(temp_dir):
assert saved["fast_flapping_block_time"] == 60
assert saved["fast_flapping_threshold"] == 15
assert saved["fast_flapping_grace"] == 3
@pytest.mark.asyncio
async def test_http_interface_client_persists_tunnel_options(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
async with make_app(temp_dir, config) as handler:
payload = {
"name": "HttpClient",
"type": "HTTPInterface",
"mode": "client",
"server_url": "https://example.com:8080/",
"poll_interval": 0.2,
"mtu": 2048,
"http_version": 1,
"user_agent": "RNS-HTTP-Tunnel/1.0",
"tls_verify": True,
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 200, body
saved = config["interfaces"]["HttpClient"]
assert saved["type"] == "HTTPInterface"
assert saved["mode"] == "client"
assert saved["server_url"] == "https://example.com:8080/"
assert saved["poll_interval"] == 0.2
assert saved["mtu"] == 2048
assert saved["http_version"] == 1
assert saved["user_agent"] == "RNS-HTTP-Tunnel/1.0"
assert "listen_port" not in saved
@pytest.mark.asyncio
async def test_http_interface_server_persists_listen_options(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
free_port = _free_port("tcp")
async with make_app(temp_dir, config) as handler:
payload = {
"name": "HttpServer",
"type": "HTTPInterface",
"mode": "server",
"listen_host": "127.0.0.1",
"listen_port": free_port,
"check_user_agent": True,
"mtu": 4096,
"http_version": 1,
"user_agent": "RNS-HTTP-Tunnel/1.0",
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 200, body
saved = config["interfaces"]["HttpServer"]
assert saved["mode"] == "server"
assert saved["listen_host"] == "127.0.0.1"
assert saved["listen_port"] == free_port
assert saved["check_user_agent"] is True
assert "server_url" not in saved
@pytest.mark.asyncio
async def test_http_interface_rejects_reticulum_mode_values(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
async with make_app(temp_dir, config) as handler:
payload = {
"name": "HttpBad",
"type": "HTTPInterface",
"mode": "gateway",
"server_url": "https://example.com/",
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 422, body
assert "client or server" in body["message"]

View file

@ -398,4 +398,45 @@ describe("AddInterfacePage.vue interface options", () => {
})
);
});
it("sends HTTPInterface client tunnel payload", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "HttpClient";
wrapper.vm.newInterfaceType = "HTTPInterface";
wrapper.vm.newInterfaceHttpTunnelMode = "client";
wrapper.vm.newInterfaceHttpServerUrl = "https://example.com:8080/";
wrapper.vm.newInterfaceHttpPollInterval = 0.2;
wrapper.vm.newInterfaceHttpMtu = 2048;
wrapper.vm.newInterfaceHttpVersion = 1;
wrapper.vm.newInterfaceHttpUserAgent = "RNS-HTTP-Tunnel/1.0";
await wrapper.vm.saveInterface();
expect(mockAxios.post).toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({
type: "HTTPInterface",
mode: "client",
server_url: "https://example.com:8080/",
poll_interval: 0.2,
mtu: 2048,
http_version: 1,
user_agent: "RNS-HTTP-Tunnel/1.0",
})
);
});
it("blocks HTTPInterface client save without server_url", async () => {
const wrapper = mountPage();
wrapper.vm.newInterfaceName = "HttpClient";
wrapper.vm.newInterfaceType = "HTTPInterface";
wrapper.vm.newInterfaceHttpTunnelMode = "client";
wrapper.vm.newInterfaceHttpServerUrl = " ";
await wrapper.vm.saveInterface();
expect(mockAxios.post).not.toHaveBeenCalled();
});
});

View file

@ -179,6 +179,33 @@ describe("App.vue sidebar identity label and announce control", () => {
expect(versionLink.attributes("title")).toBe(`v${appPackageVersion}`);
});
it("shows -dev and short commit for nightly-style app info", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info") {
return Promise.resolve({
data: {
app_info: {
version: appPackageVersion,
display_version: `${appPackageVersion}-dev`,
is_dev_build: true,
git_commit: "abcdef0123456789",
git_commit_short: "abcdef0",
build_channel: "nightly",
tutorial_seen: true,
changelog_seen_version: appPackageVersion,
},
},
});
}
return defaultAxiosImplementation(url);
});
wrapper = makeMountedApp();
await readyShell(wrapper.vm.$router);
const versionLink = wrapper.find('[data-testid="sidebar-app-version"]');
expect(versionLink.text()).toContain(`v${appPackageVersion}-dev`);
expect(versionLink.text()).toContain("abcdef0");
});
it("falls back to My Identity when display name is empty", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config") {

View file

@ -0,0 +1,400 @@
/* SPDX-License-Identifier: 0BSD */
/**
* Adversarial / race oracles for tutorial connect + bootstrap flows.
* Each case states an invariant and asserts an exact postcondition.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { createRouter, createWebHashHistory } from "vue-router";
import { createI18n } from "vue-i18n";
import { createVuetify } from "vuetify";
import TutorialModal from "../../meshchatx/src/frontend/components/TutorialModal.vue";
import en from "../../meshchatx/src/frontend/locales/en.json";
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
default: {
config: { theme: "light", language: "en" },
hasPendingInterfaceChanges: false,
modifiedInterfaceNames: new Set(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
default: {
confirm: vi.fn().mockResolvedValue(true),
},
}));
const axiosMock = { get: vi.fn(), post: vi.fn(), patch: vi.fn() };
const vuetify = createVuetify();
const i18n = createI18n({
legacy: false,
locale: "en",
messages: { en },
});
const dialogStubs = {
LanguageSelector: true,
MaterialDesignIcon: true,
Toggle: true,
TutorialPrivacyStep: true,
VIcon: { template: '<span class="v-icon-stub"/>' },
VProgressCircular: { template: '<span class="progress-stub"/>' },
};
function tcpCommunity(n) {
return Array.from({ length: n }, (_, i) => ({
name: `Node ${i + 1}`,
type: "TCPClientInterface",
target_host: `10.0.0.${i + 1}`,
target_port: 4242 + i,
description: "community tcp",
}));
}
function apiHandlers(communityInterfaces = []) {
return (url) => {
if (url === "/api/v1/app/info") {
return Promise.resolve({ data: { app_info: { migration: { show_choice: false } } } });
}
if (url === "/api/v1/reticulum/discovery") {
return Promise.resolve({ data: { discovery: { default_bootstrap_only: false } } });
}
if (url === "/api/v1/community-interfaces") {
return Promise.resolve({ data: { interfaces: communityInterfaces } });
}
if (url === "/api/v1/reticulum/discovered-interfaces") {
return Promise.resolve({ data: { interfaces: [], active: [] } });
}
if (url === "/api/v1/config") {
return Promise.resolve({ data: { config: { display_name: "Anonymous Peer" } } });
}
if (url === "/api/v1/identities") {
return Promise.resolve({
data: {
identities: [{ hash: "default_identity", display_name: "Anonymous Peer", is_current: true }],
},
});
}
return Promise.resolve({ data: {} });
};
}
async function mountTutorial(communityInterfaces = tcpCommunity(6)) {
axiosMock.get.mockImplementation(apiHandlers(communityInterfaces));
axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
axiosMock.patch.mockResolvedValue({ data: { message: "ok" } });
const router = createRouter({
history: createWebHashHistory(),
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
});
await router.push("/");
await router.isReady();
const wrapper = mount(TutorialModal, {
attachTo: document.body,
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
});
await wrapper.vm.show();
await flushPromises();
return wrapper;
}
describe("TutorialModal connect/bootstrap adversarial oracles", () => {
beforeEach(() => {
window.api = axiosMock;
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it("H1: footer must not expose Finish Setup on connect or bootstrap steps", async () => {
const wrapper = await mountTutorial();
for (const step of [3, 4]) {
wrapper.vm.currentStep = step;
await wrapper.vm.$nextTick();
expect(wrapper.vm.showFooterContinue).toBe(false);
expect(wrapper.vm.currentStep === wrapper.vm.totalSteps).toBe(false);
const finishButtons = wrapper.findAll("button").filter((b) => b.text().includes(en.tutorial.finish_setup));
expect(finishButtons.length, `finish visible on step ${step}`).toBe(0);
}
wrapper.unmount();
});
it("H2: nextStep with recommended mode must enter bootstrap step 4 not skip to 5", async () => {
const wrapper = await mountTutorial();
wrapper.vm.currentStep = 3;
wrapper.vm.connectionMode = "recommended";
wrapper.vm.nextStep();
expect(wrapper.vm.currentStep).toBe(4);
wrapper.unmount();
});
it("H3: previousStep from propagation after recommended returns to bootstrap step 4", async () => {
const wrapper = await mountTutorial();
wrapper.vm.connectionMode = "recommended";
wrapper.vm.currentStep = 5;
wrapper.vm.previousStep();
expect(wrapper.vm.currentStep).toBe(4);
wrapper.unmount();
});
it("H4: concurrent auto pickRandom must not interleave (second call no-ops while busy)", async () => {
const wrapper = await mountTutorial(tcpCommunity(8));
wrapper.vm.currentStep = 4;
wrapper.vm.connectionMode = "discovery";
wrapper.vm.communityInterfaces = tcpCommunity(8);
const shuffleSpy = vi.spyOn(wrapper.vm, "shuffleArrayInPlace");
const p1 = wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
expect(wrapper.vm.pickingRandomBootstraps).toBe(true);
const p2 = wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
await Promise.all([p1, p2]);
await flushPromises();
expect(shuffleSpy).toHaveBeenCalledTimes(1);
expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
expect(wrapper.vm.pickingRandomBootstraps).toBe(false);
wrapper.unmount();
});
it("H5: useDiscoveryMode resets auto-pick latch so a second visit can pick again", async () => {
const wrapper = await mountTutorial(tcpCommunity(5));
wrapper.vm.currentStep = 3;
wrapper.vm.bootstrapAutoPickDone = true;
wrapper.vm.selectedBootstrapKeys = [];
wrapper.vm.communityInterfaces = tcpCommunity(5);
await wrapper.vm.useDiscoveryMode();
await flushPromises();
await wrapper.vm.$nextTick();
await flushPromises();
expect(wrapper.vm.currentStep).toBe(4);
expect(wrapper.vm.connectionMode).toBe("discovery");
expect(wrapper.vm.pickingRandomBootstraps).toBe(false);
expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
expect(wrapper.vm.bootstrapAutoPickDone).toBe(true);
wrapper.unmount();
});
it("H5b: watcher must not mark auto-pick done when pick is already in flight", async () => {
const wrapper = await mountTutorial(tcpCommunity(5));
wrapper.vm.currentStep = 4;
wrapper.vm.connectionMode = "discovery";
wrapper.vm.bootstrapAutoPickDone = false;
wrapper.vm.selectedBootstrapKeys = [];
wrapper.vm.communityInterfaces = tcpCommunity(5);
wrapper.vm.pickingRandomBootstraps = true;
await wrapper.vm.maybeAutoPickBootstrapTcp();
expect(wrapper.vm.bootstrapAutoPickDone).toBe(false);
expect(wrapper.vm.selectedBootstrapKeys.length).toBe(0);
wrapper.unmount();
});
it("H6: useRecommendedMode adds AutoInterface, enables discovery, picks exactly 3 TCP", async () => {
const wrapper = await mountTutorial(tcpCommunity(7));
wrapper.vm.currentStep = 3;
await wrapper.vm.useRecommendedMode();
await flushPromises();
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/add", {
name: "Local Network",
type: "AutoInterface",
enabled: true,
});
expect(axiosMock.patch).toHaveBeenCalledWith(
"/api/v1/reticulum/discovery",
expect.objectContaining({
discover_interfaces: true,
autoconnect_discovered_interfaces: 3,
})
);
expect(wrapper.vm.currentStep).toBe(4);
expect(wrapper.vm.connectionMode).toBe("recommended");
expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
expect(wrapper.vm.selectedBootstrapKeys.every((k) => k.startsWith("comm:"))).toBe(true);
expect(wrapper.vm.addingRecommended).toBe(false);
wrapper.unmount();
});
it("H7: connection mode buttons stay locked while discovery is in flight", async () => {
let resolvePatch;
axiosMock.get.mockImplementation(apiHandlers(tcpCommunity(4)));
axiosMock.patch.mockImplementation(
() =>
new Promise((resolve) => {
resolvePatch = resolve;
})
);
axiosMock.post.mockResolvedValue({ data: {} });
const router = createRouter({
history: createWebHashHistory(),
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
});
await router.push("/");
await router.isReady();
const wrapper = mount(TutorialModal, {
attachTo: document.body,
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
});
await wrapper.vm.show();
await flushPromises();
wrapper.vm.currentStep = 3;
const pending = wrapper.vm.useDiscoveryMode();
await wrapper.vm.$nextTick();
expect(wrapper.vm.connectionSetupBusy).toBe(true);
expect(wrapper.vm.tutorialNavBusy).toBe(true);
// Adversarial second click must not start another discovery patch
const patchCallsBefore = axiosMock.patch.mock.calls.length;
await wrapper.vm.useDiscoveryMode();
await wrapper.vm.useRecommendedMode();
await wrapper.vm.useLocalMode();
expect(axiosMock.patch.mock.calls.length).toBe(patchCallsBefore);
resolvePatch({ data: { message: "ok" } });
await pending;
await flushPromises();
expect(wrapper.vm.connectionSetupBusy).toBe(false);
wrapper.unmount();
});
it("H8: skip/confirm bootstraps are ignored while random pick is busy", async () => {
const wrapper = await mountTutorial(tcpCommunity(5));
wrapper.vm.currentStep = 4;
wrapper.vm.connectionMode = "discovery";
wrapper.vm.pickingRandomBootstraps = true;
wrapper.vm.selectedBootstrapKeys = ["comm:Node 1"];
wrapper.vm.skipBootstraps();
expect(wrapper.vm.currentStep).toBe(4);
await wrapper.vm.confirmBootstraps();
expect(wrapper.vm.currentStep).toBe(4);
expect(axiosMock.post).not.toHaveBeenCalledWith(
"/api/v1/reticulum/interfaces/add",
expect.objectContaining({ name: "Node 1" })
);
wrapper.unmount();
});
it("H9: yggdrasil / ygg-labelled community presets are excluded from random pick", async () => {
const mixed = [
{
name: "Yggdrasil Bridge",
type: "TCPClientInterface",
target_host: "1.1.1.1",
target_port: 4242,
description: "ygg only",
},
{
name: "Normal A",
type: "TCPClientInterface",
target_host: "2.2.2.2",
target_port: 4242,
},
{
name: "Normal B",
type: "TCPClientInterface",
target_host: "3.3.3.3",
target_port: 4242,
},
{
name: "Normal C",
type: "TCPClientInterface",
target_host: "4.4.4.4",
target_port: 4242,
},
{
name: "Normal D",
type: "TCPClientInterface",
target_host: "5.5.5.5",
target_port: 4242,
},
];
const wrapper = await mountTutorial(mixed);
wrapper.vm.communityInterfaces = mixed;
await wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
expect(wrapper.vm.selectedBootstrapKeys).not.toContain("comm:Yggdrasil Bridge");
expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
wrapper.unmount();
});
it("H10: random pick prefers at most available eligible nodes and never selects hostless entries", async () => {
const sparse = [
{ name: "Good1", type: "TCPClientInterface", target_host: "8.8.8.8", target_port: 4242 },
{ name: "NoHost", type: "TCPClientInterface", target_host: "", target_port: 4242 },
{ name: "UDP", type: "UDPInterface", target_host: "9.9.9.9", target_port: 4242 },
{ name: "Good2", type: "TCPClientInterface", target_host: "8.8.4.4", target_port: 4242 },
];
const wrapper = await mountTutorial(sparse);
wrapper.vm.communityInterfaces = sparse;
await wrapper.vm.pickRandomTcpBootstraps({ count: 99 });
expect(wrapper.vm.selectedBootstrapKeys.sort()).toEqual(["comm:Good1", "comm:Good2"].sort());
wrapper.unmount();
});
it("H11: useRecommendedMode failure after AutoInterface add does not advance step", async () => {
axiosMock.get.mockImplementation(apiHandlers(tcpCommunity(3)));
axiosMock.post.mockResolvedValue({ data: { message: "added" } });
axiosMock.patch.mockRejectedValue({ response: { data: { message: "discovery failed" } } });
const router = createRouter({
history: createWebHashHistory(),
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
});
await router.push("/");
await router.isReady();
const wrapper = mount(TutorialModal, {
attachTo: document.body,
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
});
await wrapper.vm.show();
await flushPromises();
wrapper.vm.currentStep = 3;
await wrapper.vm.useRecommendedMode();
await flushPromises();
expect(wrapper.vm.currentStep).toBe(3);
expect(wrapper.vm.connectionMode).toBeNull();
expect(wrapper.vm.addingRecommended).toBe(false);
expect(ToastUtils.error).toHaveBeenCalled();
wrapper.unmount();
});
it("H12: theme/language controls are in-flow (not absolute overlay) so titles are not covered", async () => {
const wrapper = await mountTutorial();
expect(wrapper.find(".absolute.top-4.left-4").exists()).toBe(false);
expect(wrapper.find(".absolute.top-4.right-4").exists()).toBe(false);
wrapper.unmount();
});
it("H13: double finishTutorial is race-safe while connect busy", async () => {
const wrapper = await mountTutorial();
wrapper.vm.currentStep = 3;
wrapper.vm.savingDiscovery = true;
await wrapper.vm.finishTutorial();
expect(wrapper.vm.visible).toBe(true);
expect(wrapper.vm.finishingTutorial).toBe(false);
wrapper.unmount();
});
});

View file

@ -0,0 +1,111 @@
/* SPDX-License-Identifier: 0BSD */
/**
* Channel / commit bake oracles for scripts/bake_build_meta.js
*/
import { describe, it, expect, afterEach } from "vitest";
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const bakeScript = path.join(repoRoot, "scripts", "bake_build_meta.js");
const bakedPath = path.join(repoRoot, "meshchatx", "src", "_build_meta_baked.py");
function readBaked() {
const text = fs.readFileSync(bakedPath, "utf8");
const commit = (text.match(/^GIT_COMMIT = "(.*)"$/m) || [])[1] ?? "";
const short = (text.match(/^GIT_COMMIT_SHORT = "(.*)"$/m) || [])[1] ?? "";
const channel = (text.match(/^BUILD_CHANNEL = "(.*)"$/m) || [])[1] ?? "";
const isDev = /^IS_DEV_BUILD = True$/m.test(text);
return { commit, short, channel, isDev };
}
function runBake(envOverrides) {
const env = { ...process.env, ...envOverrides };
for (const key of Object.keys(envOverrides)) {
if (envOverrides[key] === "" || envOverrides[key] == null) {
delete env[key];
}
}
const result = spawnSync(process.execPath, [bakeScript], {
cwd: repoRoot,
env,
encoding: "utf8",
});
expect(result.status, result.stderr || result.stdout).toBe(0);
return readBaked();
}
describe("bake_build_meta channel oracles", () => {
let previous = null;
afterEach(() => {
if (previous != null) {
fs.writeFileSync(bakedPath, previous, "utf8");
previous = null;
} else if (fs.existsSync(bakedPath)) {
// leave whatever last bake wrote; file is gitignored
}
});
function snapshot() {
if (fs.existsSync(bakedPath)) {
previous = fs.readFileSync(bakedPath, "utf8");
}
}
it("nightly ref bakes is_dev with short sha", () => {
snapshot();
const meta = runBake({
MESHCHATX_BUILD_CHANNEL: "",
GITHUB_REF_NAME: "nightly-2026.07.24-abcdef0",
GITHUB_SHA: "abcdef0123456789aaaa",
MESHCHATX_GIT_COMMIT: "",
GIT_COMMIT: "",
});
expect(meta.channel).toBe("nightly");
expect(meta.isDev).toBe(true);
expect(meta.short).toBe("abcdef0");
expect(meta.commit.startsWith("abcdef0")).toBe(true);
});
it("release tag clears is_dev", () => {
snapshot();
const meta = runBake({
MESHCHATX_BUILD_CHANNEL: "",
GITHUB_REF_NAME: "v4.8.0",
GITHUB_SHA: "deadbeefcafebabe",
MESHCHATX_GIT_COMMIT: "",
GIT_COMMIT: "",
});
expect(meta.channel).toBe("release");
expect(meta.isDev).toBe(false);
expect(meta.short).toBe("deadbee");
});
it("explicit MESHCHATX_BUILD_CHANNEL=release wins over nightly ref name", () => {
snapshot();
const meta = runBake({
MESHCHATX_BUILD_CHANNEL: "release",
GITHUB_REF_NAME: "nightly-2026.07.24-abcdef0",
MESHCHATX_GIT_COMMIT: "1234567890abcdef",
});
expect(meta.channel).toBe("release");
expect(meta.isDev).toBe(false);
expect(meta.short).toBe("1234567");
});
it("local/default channel is treated as dev", () => {
snapshot();
const meta = runBake({
MESHCHATX_BUILD_CHANNEL: "local",
GITHUB_REF_NAME: "",
GITHUB_REF: "",
MESHCHATX_GIT_COMMIT: "feedface00",
});
expect(meta.channel).toBe("local");
expect(meta.isDev).toBe(true);
expect(meta.short).toBe("feedfac");
});
});

View file

@ -180,10 +180,19 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(gradle).toMatch(/syncMeshchatPython/);
expect(gradle).toMatch(/syncLxmfyPython/);
expect(gradle).toMatch(/syncRnsFilesyncPython/);
expect(gradle).toContain("httpx[http2]==0.28.1");
expect(gradle).toContain("httpx-0.28.1-");
const wheelScript = readSource("scripts/build-android-wheels-local.sh");
expect(wheelScript).toContain("httpx[http2]");
expect(wheelScript).toContain("HTTPX_VERSION");
const lxmfyInit = readSource("vendor/lxmfy/lxmfy/__init__.py");
expect(lxmfyInit.length).toBeGreaterThan(0);
const filesyncInit = readSource("vendor/rns_filesync/rns_filesync/__init__.py");
expect(filesyncInit.length).toBeGreaterThan(0);
const httpIface = readSource("vendor/rns_over_http/HTTPInterface.py");
expect(httpIface).toContain("interface_class");
const packagedHttp = readSource("meshchatx/src/backend/data/interfaces/HTTPInterface.py");
expect(packagedHttp).toContain("interface_class");
});
it("Android wrapper clears stale storage lock before main()", () => {

97
uv.lock generated
View file

@ -177,6 +177,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
@ -406,6 +419,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/29/e257a381d494615348c7266fc173a36edce142533a5befe3c0967fd45ab4/cbor2-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f0bc04543c562bd0b35fc79a29528017fe63104757c1b421d8c1ddfbe6761eca", size = 290270, upload-time = "2026-05-14T10:57:40.597Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
@ -930,6 +952,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" },
]
[[package]]
name = "hpack"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "hypothesis"
version = "6.152.4"
@ -1915,6 +2010,7 @@ dependencies = [
{ name = "bleak" },
{ name = "cbor2" },
{ name = "cryptography" },
{ name = "httpx", extra = ["http2"] },
{ name = "lxmf" },
{ name = "lxst" },
{ name = "miniaudio" },
@ -1951,6 +2047,7 @@ requires-dist = [
{ name = "bleak", specifier = ">=3.0.2,<4.0" },
{ name = "cbor2", specifier = ">=6.1.1" },
{ name = "cryptography", specifier = ">=49.0.0,<50.0.0" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<1.0" },
{ name = "lxmf", specifier = ">=1.1.0" },
{ name = "lxst", specifier = ">=0.5.0" },
{ name = "miniaudio", specifier = ">=1.70,<2.0" },

10
vendor/README.txt vendored
View file

@ -15,3 +15,13 @@ rns_filesync/
Declared version (pyproject): see vendor/rns_filesync/pyproject.toml
Update: clone default branch, replace vendor/rns_filesync (omit .git), align vendor/README
commit above, regenerate THIRD_PARTY_NOTICES if needed.
rns_over_http/
Upstream: https://github.com/Quad4-Software/RNS-over-HTTP
Bundled revision: 530aca499fb7442910f691629670f6f9f4b67221
Declared version (pyproject): see vendor/rns_over_http/pyproject.toml
Update: clone default branch, replace vendor/rns_over_http (omit .git), copy
HTTPInterface.py and LICENSE into meshchatx/src/backend/data/interfaces/
(as HTTPInterface.py and HTTPInterface.LICENSE), align vendor/README commit above,
regenerate THIRD_PARTY_NOTICES if needed.
Runtime: MeshChatX installs HTTPInterface.py into Reticulum interfacepath on startup.

7
vendor/rns_over_http/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
__pycache__
.venv
.pytest_cache
.hypothesis
.ruff_cache
dist/
*.egg-info/

894
vendor/rns_over_http/HTTPInterface.py vendored Executable file
View file

@ -0,0 +1,894 @@
import asyncio
import os
import socket
import ssl
import threading
from collections import deque
from http.server import BaseHTTPRequestHandler, HTTPServer
from queue import Empty, Queue
from socketserver import ThreadingMixIn
from urllib.parse import urlparse
import httpx
import RNS
from RNS.Interfaces.Interface import Interface
class HDLC:
"""Pipe-compatible simplified HDLC framing for HTTP bodies.
Same FLAG/ESC scheme as RNS PipeInterface so multiple packets can
share one HTTP request or response without losing boundaries.
"""
FLAG = 0x7E
ESC = 0x7D
ESC_MASK = 0x20
@staticmethod
def escape(data):
data = data.replace(
bytes([HDLC.ESC]),
bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]),
)
data = data.replace(
bytes([HDLC.FLAG]),
bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]),
)
return data
@staticmethod
def frame(packet):
return bytes([HDLC.FLAG]) + HDLC.escape(packet) + bytes([HDLC.FLAG])
@staticmethod
def deframe(buffer, max_frame_len):
"""Return (packets, remainder) from an HDLC byte buffer."""
packets = []
in_frame = False
escape = False
data_buffer = bytearray()
i = 0
last_complete = 0
while i < len(buffer):
byte = buffer[i]
i += 1
if in_frame and byte == HDLC.FLAG:
packets.append(bytes(data_buffer))
in_frame = False
escape = False
data_buffer = bytearray()
last_complete = i
elif byte == HDLC.FLAG:
in_frame = True
escape = False
data_buffer = bytearray()
elif in_frame and len(data_buffer) < max_frame_len:
if byte == HDLC.ESC:
escape = True
else:
if escape:
if byte == HDLC.FLAG ^ HDLC.ESC_MASK:
byte = HDLC.FLAG
elif byte == HDLC.ESC ^ HDLC.ESC_MASK:
byte = HDLC.ESC
escape = False
data_buffer.append(byte)
remainder = buffer[last_complete:] if in_frame else b""
return packets, remainder
class _H3Session:
"""Persistent HTTP/3 client over one QUIC connection."""
def __init__(self, server_url, user_agent, verify=True, ca_certs=None):
from aioquic.asyncio import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import DataReceived, HeadersReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import QuicEvent
parsed = urlparse(server_url)
if parsed.scheme != "https":
raise ValueError("HTTP/3 requires an https:// server_url")
self._host = parsed.hostname
self._port = parsed.port or 443
self._path = parsed.path or "/"
if parsed.query:
self._path += "?" + parsed.query
self._authority = parsed.netloc
self._user_agent = user_agent
self._closed = False
self._request_count = 0
configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
if not verify:
configuration.verify_mode = ssl.CERT_NONE
elif ca_certs:
configuration.load_verify_locations(ca_certs)
class _Client(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http = H3Connection(self._quic)
self._request_events = {}
self._request_waiter = {}
def http_event_received(self, event):
if isinstance(event, (HeadersReceived, DataReceived)):
stream_id = event.stream_id
if stream_id in self._request_events:
self._request_events[stream_id].append(event)
if event.stream_ended:
waiter = self._request_waiter.pop(stream_id)
waiter.set_result(self._request_events.pop(stream_id))
def quic_event_received(self, event: QuicEvent):
for http_event in self._http.handle_event(event):
self.http_event_received(http_event)
async def post_bytes(self, path, authority, user_agent, data):
stream_id = self._quic.get_next_available_stream_id()
headers = [
(b":method", b"POST"),
(b":scheme", b"https"),
(b":authority", authority.encode()),
(b":path", path.encode()),
(b"user-agent", user_agent.encode()),
(b"content-type", b"application/octet-stream"),
(b"content-length", str(len(data)).encode()),
]
self._http.send_headers(
stream_id=stream_id,
headers=headers,
end_stream=not data,
)
if data:
self._http.send_data(stream_id=stream_id, data=data, end_stream=True)
waiter = self._loop.create_future()
self._request_events[stream_id] = deque()
self._request_waiter[stream_id] = waiter
self.transmit()
events = await asyncio.shield(waiter)
status = 0
body = b""
for event in events:
if isinstance(event, HeadersReceived):
for key, value in event.headers:
if key == b":status":
status = int(value.decode())
elif isinstance(event, DataReceived):
body += event.data
return status, body
self._Client = _Client
self._connect = connect
self._configuration = configuration
self._loop = asyncio.new_event_loop()
self._loop_thread = threading.Thread(
target=self._run_loop,
name="h3-client-loop",
daemon=True,
)
self._ready = threading.Event()
self._error = None
self._client = None
self._cm = None
self._loop_thread.start()
if not self._ready.wait(timeout=30):
raise TimeoutError("HTTP/3 client failed to connect")
if self._error is not None:
raise self._error
def _run_loop(self):
asyncio.set_event_loop(self._loop)
try:
self._loop.run_until_complete(self._open())
self._ready.set()
self._loop.run_forever()
except Exception as exc:
self._error = exc
self._ready.set()
async def _open(self):
self._cm = self._connect(
self._host,
self._port,
configuration=self._configuration,
create_protocol=self._Client,
)
self._client = await self._cm.__aenter__()
def post(self, data, timeout=10.0):
if self._closed:
raise RuntimeError("HTTP/3 session is closed")
async def _do_post():
status, body = await self._client.post_bytes(
self._path,
self._authority,
self._user_agent,
data,
)
self._request_count += 1
if status >= 400:
raise RuntimeError(f"HTTP/3 status {status}")
return body
fut = asyncio.run_coroutine_threadsafe(_do_post(), self._loop)
return fut.result(timeout=timeout)
def close(self):
if self._closed:
return
self._closed = True
async def _close():
if self._cm is not None:
await self._cm.__aexit__(None, None, None)
try:
fut = asyncio.run_coroutine_threadsafe(_close(), self._loop)
fut.result(timeout=5)
except Exception:
pass
self._loop.call_soon_threadsafe(self._loop.stop)
self._loop_thread.join(timeout=5)
class HTTPTunnelInterface(Interface):
"""HTTP Tunnel Interface for Reticulum.
Bidirectional RNS transport over HTTP POST with pipe-compatible HDLC
framing. Supports HTTP/1.1 (default), HTTP/2, and HTTP/3.
Engineering defaults:
- http_version=1 uses a stdlib HTTP/1.1 server and httpx client.
Works over cleartext http:// and is ideal behind Caddy/nginx.
- http_version=2 requires TLS and uses Hypercorn (h2) + httpx(http2).
- http_version=3 requires TLS and uses Hypercorn QUIC + a persistent
aioquic HTTP/3 client session.
Configuration highlights:
http_version: 1, 2, or 3 (default: 1)
tls_certfile / tls_keyfile: required for versions 2 and 3 on server
tls_verify: verify TLS certs on client (default: true)
tls_ca_certs: optional CA bundle path for client verify
server_url: use https:// for versions 2 and 3
"""
DEFAULT_IFAC_SIZE = 16
BITRATE_GUESS = 10_000_000
AUTOCONFIGURE_MTU = True
DEFAULT_MTU = 4096
TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0"
DEFAULT_POLL_INTERVAL = 0.1
DEFAULT_POOL_CONNECTIONS = 1
DEFAULT_POOL_MAXSIZE = 1
DEFAULT_KEEPALIVE_TIMEOUT = 60
DEFAULT_HTTP_VERSION = 1
def __init__(self, owner, configuration):
super().__init__()
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf["name"]
mode = str(ifconf["mode"]).lower() if "mode" in ifconf else "client"
listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0"
listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080
server_url = ifconf["server_url"] if "server_url" in ifconf else None
poll_interval = (
float(ifconf["poll_interval"])
if "poll_interval" in ifconf
else self.DEFAULT_POLL_INTERVAL
)
check_user_agent = (
ifconf.as_bool("check_user_agent") if "check_user_agent" in ifconf else True
)
user_agent = (
str(ifconf["user_agent"])
if "user_agent" in ifconf
else self.TUNNEL_USER_AGENT
)
mtu = int(ifconf["mtu"]) if "mtu" in ifconf else self.DEFAULT_MTU
serve_html_page = (
ifconf.as_bool("serve_html_page") if "serve_html_page" in ifconf else False
)
html_file_path = (
ifconf["html_file_path"] if "html_file_path" in ifconf else None
)
pool_connections = (
int(ifconf["pool_connections"])
if "pool_connections" in ifconf
else self.DEFAULT_POOL_CONNECTIONS
)
pool_maxsize = (
int(ifconf["pool_maxsize"])
if "pool_maxsize" in ifconf
else self.DEFAULT_POOL_MAXSIZE
)
keepalive_timeout = (
int(ifconf["keepalive_timeout"])
if "keepalive_timeout" in ifconf
else self.DEFAULT_KEEPALIVE_TIMEOUT
)
http_version = (
int(ifconf["http_version"])
if "http_version" in ifconf
else self.DEFAULT_HTTP_VERSION
)
tls_certfile = ifconf["tls_certfile"] if "tls_certfile" in ifconf else None
tls_keyfile = ifconf["tls_keyfile"] if "tls_keyfile" in ifconf else None
tls_verify = (
ifconf.as_bool("tls_verify") if "tls_verify" in ifconf else True
)
tls_ca_certs = ifconf["tls_ca_certs"] if "tls_ca_certs" in ifconf else None
self.mode = mode
self.http_version = http_version
if mode not in ["client", "server"]:
raise ValueError(
f"Invalid mode '{mode}' for {self}. Must be 'client' or 'server'",
)
if http_version not in (1, 2, 3):
raise ValueError(
f"Invalid http_version '{http_version}' for {self}. Must be 1, 2, or 3",
)
if mode == "client" and server_url is None:
raise ValueError(f"No server_url specified for client mode in {self}")
if pool_connections < 1 or pool_maxsize < 1:
raise ValueError(
f"pool_connections and pool_maxsize must be >= 1 for {self}",
)
if http_version >= 2:
if mode == "server" and (not tls_certfile or not tls_keyfile):
raise ValueError(
f"tls_certfile and tls_keyfile are required for "
f"HTTP/{http_version} server mode in {self}",
)
if mode == "client":
parsed = urlparse(server_url)
if parsed.scheme != "https":
raise ValueError(
f"HTTP/{http_version} client requires https:// server_url in {self}",
)
self.owner = owner
self.IN = True
self.mtu = mtu
self.check_user_agent = check_user_agent
self.user_agent = user_agent
self.serve_html_page = serve_html_page
self.html_file_path = html_file_path
self.html_content = None
self.pool_connections = pool_connections
self.pool_maxsize = pool_maxsize
self.keepalive_timeout = keepalive_timeout
self.tls_certfile = tls_certfile
self.tls_keyfile = tls_keyfile
self.tls_verify = tls_verify
self.tls_ca_certs = tls_ca_certs
self._tcp_accepts = 0
self._http_requests = 0
self._stats_lock = threading.Lock()
self._last_http_version = None
self._client_requests = 0
self.session = None
self._h3_session = None
self._asgi_shutdown = None
self._asgi_loop = None
if self.serve_html_page and self.html_file_path:
self._load_html_content()
self._recv_queue = Queue()
self._send_queue = Queue()
self._stop_event = threading.Event()
self._frame_remainder = b""
self._frame_lock = threading.Lock()
self.HW_MTU = mtu
self.online = False
self.bitrate = HTTPTunnelInterface.BITRATE_GUESS
if mode == "server":
self.listen_host = listen_host
self.listen_port = listen_port
else:
self.server_url = server_url
self.poll_interval = poll_interval
self.optimise_mtu()
if mode == "server":
if http_version == 1:
self.setup_server_http1()
else:
self.setup_server_asgi()
else:
self.setup_client()
def _load_html_content(self):
try:
if os.path.isfile(self.html_file_path):
with open(self.html_file_path, encoding="utf-8") as f:
self.html_content = f.read()
RNS.log(f"Loaded HTML content from {self.html_file_path}", RNS.LOG_INFO)
else:
RNS.log(f"HTML file not found: {self.html_file_path}", RNS.LOG_WARNING)
self.html_content = None
except Exception as e:
RNS.log(
f"Error loading HTML file {self.html_file_path}: {e}",
RNS.LOG_ERROR,
)
self.html_content = None
def _drain_send_frames(self):
parts = []
while not self._send_queue.empty():
try:
packet = self._send_queue.get_nowait()
except Empty:
break
if packet:
parts.append(HDLC.frame(packet))
return b"".join(parts)
def _ingest_wire_bytes(self, wire_data):
if not wire_data:
return
with self._frame_lock:
buffer = self._frame_remainder + wire_data
packets, self._frame_remainder = HDLC.deframe(buffer, self.HW_MTU)
for packet in packets:
if packet:
self._recv_queue.put(packet)
def _record_http_request(self):
with self._stats_lock:
self._http_requests += 1
def _handle_tunnel_exchange(self, method, path, headers, body):
"""Shared request handler for HTTP/1.1 and ASGI paths.
Returns (status, content_type, response_body).
"""
self._record_http_request()
headers_l = {str(k).lower(): str(v) for k, v in headers.items()}
if method == "GET" and path == "/":
if self.serve_html_page and self.html_content:
return (
200,
"text/html; charset=utf-8",
self.html_content.encode("utf-8"),
)
return 404, "text/plain", b""
if method == "POST" and path == "/":
if self.check_user_agent:
user_agent = headers_l.get("user-agent", "")
if user_agent != self.user_agent:
RNS.log(
f"Rejected request with invalid User-Agent: {user_agent}",
RNS.LOG_WARNING,
)
return 403, "text/plain", b"Forbidden"
if body:
RNS.log(f"Received {len(body)} bytes from client", RNS.LOG_EXTREME)
self._ingest_wire_bytes(body)
server_data = self._drain_send_frames()
if server_data:
RNS.log(
f"Sending {len(server_data)} framed bytes to client",
RNS.LOG_EXTREME,
)
return 200, "application/octet-stream", server_data
return 404, "text/plain", b""
def connection_stats(self):
with self._stats_lock:
stats = {
"tcp_accepts": self._tcp_accepts,
"http_requests": self._http_requests,
"client_requests": self._client_requests,
"last_http_version": self._last_http_version,
"configured_http_version": self.http_version,
}
if self._h3_session is not None:
stats["pool_num_connections"] = 0 if self._h3_session._closed else 1
stats["pool_num_requests"] = self._h3_session._request_count
return stats
def _build_asgi_app(self):
interface = self
async def app(scope, receive, send):
if scope["type"] != "http":
return
headers = {
key.decode("latin1"): value.decode("latin1")
for key, value in scope.get("headers", [])
}
body = b""
while True:
message = await receive()
body += message.get("body", b"")
if not message.get("more_body", False):
break
status, content_type, response_body = interface._handle_tunnel_exchange(
scope.get("method", "GET"),
scope.get("path", "/"),
headers,
body,
)
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", content_type.encode()),
(b"content-length", str(len(response_body)).encode()),
],
}
)
await send({"type": "http.response.body", "body": response_body})
return app
def setup_server_http1(self):
interface_instance = self
class TunnelRequestHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def _send_common_headers(self, content_type, content_length):
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(content_length))
self.send_header("Connection", "keep-alive")
self.send_header(
"Keep-Alive",
f"timeout={interface_instance.keepalive_timeout}, max=1000",
)
def do_GET(self):
status, content_type, body = interface_instance._handle_tunnel_exchange(
"GET",
self.path,
self.headers,
b"",
)
self.send_response(status)
self._send_common_headers(content_type, len(body))
self.end_headers()
if body:
self.wfile.write(body)
def do_POST(self):
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length) if content_length > 0 else b""
status, content_type, response = interface_instance._handle_tunnel_exchange(
"POST",
self.path,
self.headers,
body,
)
self.send_response(status)
self._send_common_headers(content_type, len(response))
self.end_headers()
if response:
self.wfile.write(response)
def log_message(self, fmt, *args):
pass
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
allow_reuse_address = True
def get_request(self):
request, client_address = super().get_request()
with interface_instance._stats_lock:
interface_instance._tcp_accepts += 1
try:
request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
except OSError:
pass
return request, client_address
def run_server():
try:
self._http_server = ThreadedHTTPServer(
(self.listen_host, self.listen_port),
TunnelRequestHandler,
)
self._http_server.daemon_threads = True
self._http_server.serve_forever()
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"HTTP server error for {self}: {e}", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
self._server_thread = threading.Thread(target=run_server, daemon=True)
self._server_thread.start()
self._start_receive_loop()
self.online = True
RNS.log(
f"HTTP/1.1 server started on http://{self.listen_host}:{self.listen_port}",
RNS.LOG_NOTICE,
)
def setup_server_asgi(self):
from hypercorn.asyncio import serve
from hypercorn.config import Config
config = Config()
bind = f"{self.listen_host}:{self.listen_port}"
config.bind = [bind]
config.certfile = self.tls_certfile
config.keyfile = self.tls_keyfile
config.alpn_protocols = ["h2", "http/1.1"]
if self.http_version == 3:
config.quic_bind = [bind]
config.alpn_protocols = ["h3", "h2", "http/1.1"]
app = self._build_asgi_app()
self._asgi_shutdown = asyncio.Event()
def run_server():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._asgi_loop = loop
try:
loop.run_until_complete(
serve(app, config, shutdown_trigger=self._asgi_shutdown.wait),
)
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"ASGI HTTP server error for {self}: {e}", RNS.LOG_ERROR)
if RNS.Reticulum.panic_on_interface_error:
RNS.panic()
finally:
loop.close()
self._server_thread = threading.Thread(target=run_server, daemon=True)
self._server_thread.start()
self._start_receive_loop()
self.online = True
proto = f"HTTP/{self.http_version}"
RNS.log(
f"{proto} server started on https://{self.listen_host}:{self.listen_port}",
RNS.LOG_NOTICE,
)
def _start_receive_loop(self):
thread = threading.Thread(target=self.receive_loop, daemon=True)
thread.start()
def setup_client(self):
self._consecutive_failures = 0
self._max_backoff = 30.0
if self.http_version == 3:
self._h3_session = _H3Session(
self.server_url,
self.user_agent,
verify=self.tls_verify,
ca_certs=self.tls_ca_certs,
)
self._last_http_version = "HTTP/3"
else:
verify = self.tls_ca_certs if self.tls_ca_certs else self.tls_verify
limits = httpx.Limits(
max_connections=max(self.pool_maxsize, 1),
max_keepalive_connections=max(self.pool_maxsize, 1),
keepalive_expiry=float(self.keepalive_timeout),
)
self.session = httpx.Client(
http2=(self.http_version == 2),
verify=verify,
headers={
"User-Agent": self.user_agent,
"Accept-Encoding": "identity",
},
limits=limits,
timeout=httpx.Timeout(5.0),
)
thread = threading.Thread(target=self.client_loop, daemon=True)
thread.start()
self.online = True
RNS.log(
f"HTTP/{self.http_version} client started, connecting to {self.server_url}",
RNS.LOG_NOTICE,
)
def receive_loop(self):
while not self._stop_event.is_set():
try:
received_data = self._recv_queue.get(timeout=1)
if received_data:
self.process_incoming(received_data)
except Empty:
continue
except Exception as e:
if not self._stop_event.is_set():
RNS.log(f"Error in receive loop for {self}: {e}", RNS.LOG_ERROR)
def _client_exchange(self, data_to_send):
if self.http_version == 3:
body = self._h3_session.post(data_to_send, timeout=5.0)
self._client_requests += 1
self._last_http_version = "HTTP/3"
return body
response = self.session.post(self.server_url, content=data_to_send)
response.raise_for_status()
self._client_requests += 1
self._last_http_version = response.http_version
return response.content
def client_loop(self):
while not self._stop_event.is_set():
data_to_send = self._drain_send_frames()
try:
RNS.log(
f"Sending {len(data_to_send)} framed bytes to server",
RNS.LOG_EXTREME,
)
content = self._client_exchange(data_to_send)
if content:
RNS.log(
f"Received {len(content)} bytes from server",
RNS.LOG_EXTREME,
)
self._ingest_wire_bytes(content)
while not self._recv_queue.empty():
try:
packet = self._recv_queue.get_nowait()
except Empty:
break
if packet:
self.process_incoming(packet)
if self._consecutive_failures > 0:
RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO)
self._consecutive_failures = 0
except Exception as e:
if self._stop_event.is_set():
break
self._consecutive_failures += 1
if self._consecutive_failures % 10 == 1:
RNS.log(
f"Error communicating with server for {self} "
f"(attempt {self._consecutive_failures}): {e}",
RNS.LOG_WARNING,
)
if self._stop_event.is_set():
break
if self._consecutive_failures > 0:
delay = min(
self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)),
self._max_backoff,
)
else:
delay = self.poll_interval
self._stop_event.wait(delay)
def process_incoming(self, data):
if len(data) > 0 and self.online:
self.rxb += len(data)
self.owner.inbound(data, self)
def process_outgoing(self, data):
if self.online:
if len(data) > self.mtu:
RNS.log(
f"Payload too large ({len(data)} > {self.mtu}) for {self}",
RNS.LOG_ERROR,
)
return
self._send_queue.put(data)
self.txb += len(data)
def detach(self):
RNS.log(f"Detaching {self}", RNS.LOG_DEBUG)
self._stop_event.set()
self.online = False
if self.mode == "client":
if self.session is not None:
try:
self.session.close()
except Exception as e:
RNS.log(
f"Error closing HTTP session for {self}: {e}",
RNS.LOG_DEBUG,
)
if self._h3_session is not None:
try:
self._h3_session.close()
except Exception as e:
RNS.log(
f"Error closing HTTP/3 session for {self}: {e}",
RNS.LOG_DEBUG,
)
if self.mode == "server":
if self.http_version == 1:
httpd = getattr(self, "_http_server", None)
if httpd is not None:
def _shutdown():
try:
httpd.shutdown()
except Exception:
pass
try:
httpd.server_close()
except Exception:
pass
threading.Thread(target=_shutdown, daemon=True).start()
if hasattr(self, "_server_thread") and self._server_thread:
self._server_thread.join(timeout=2)
if self._server_thread.is_alive() and httpd is not None:
try:
httpd.socket.close()
except Exception:
pass
self._server_thread.join(timeout=1)
else:
if self._asgi_loop is not None and self._asgi_shutdown is not None:
self._asgi_loop.call_soon_threadsafe(self._asgi_shutdown.set)
if hasattr(self, "_server_thread") and self._server_thread:
self._server_thread.join(timeout=5)
def should_ingress_limit(self):
return False
def __str__(self):
name = getattr(self, "name", "?")
ver = getattr(self, "http_version", "?")
if self.mode == "server":
lh = getattr(self, "listen_host", "?")
lp = getattr(self, "listen_port", "?")
return f"HTTPTunnelInterface[{name}/HTTP{ver}/server/{lh}:{lp}]"
if self.mode == "client" and getattr(self, "server_url", None) is not None:
return f"HTTPTunnelInterface[{name}/HTTP{ver}/client/{self.server_url}]"
return f"HTTPTunnelInterface[{name}/HTTP{ver}/{self.mode}]"
interface_class = HTTPTunnelInterface

21
vendor/rns_over_http/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ivan / Quad4.io
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

138
vendor/rns_over_http/README-RU.md vendored Normal file
View file

@ -0,0 +1,138 @@
# **RNS-over-HTTP**
Интерфейс Reticulum, который туннелирует трафик через стандартные HTTP/S POST-запросы. Это позволяет Reticulum работать в сетях, где разрешён только веб-трафик, эффективно обходя файрволы, DPI и другие ограничения.
Границы пакетов на HTTP-теле используют то же упрощённое HDLC-фреймирование, что и `PipeInterface` Reticulum, поэтому несколько пакетов могут передаваться в одном теле запроса или ответа без слияния.
[Не-GitHub-зеркало](https://lavaforge.org/Ivan/RNS-over-HTTP). Кроме того, файлы проекта доступны в сети Reticulum по адресу `RNS-over-HTTP`.
## **Обзор**
RNS-over-HTTP создаёт двунаправленный транспортный уровень, используя простую клиент-серверную модель:
* **Сервер**: Запускается на машине с публичным IP-адресом и прослушивает HTTP-запросы.
* **Клиент**: Может находиться за файрволом или NAT, ему требуется только исходящий доступ в интернет.
Клиент опрашивает сервер с помощью HTTP POST-запросов, отправляя исходящие данные в теле запроса и получая входящие данные в теле ответа. Это делает трафик похожим на обычную веб-активность.
## **Как это работает**
Интерфейс имитирует постоянное соединение, используя механизм, подобный long-polling:
1. Клиент отправляет HTTP POST на сервер с ожидающими HDLC-фреймами в теле запроса.
2. Сервер декодирует входящие пакеты для Reticulum и сразу возвращает исходящую очередь (тоже в HDLC) в теле ответа.
3. Клиент принимает ответ, декодирует фреймы и передаёт пакеты в Reticulum.
4. После настраиваемого интервала опроса клиент повторяет цикл.
## **Особенности**
* **Обход файрволов и DPI**: Туннелирует трафик через стандартные порты HTTP/S (80/443).
* **Двунаправленная связь**: Полнодуплексная передача данных.
* **Совместимое с Pipe фреймирование**: HDLC FLAG/ESC как у `PipeInterface`.
* **Простая настройка**: Python, `httpx`, Hypercorn/aioquic для HTTP/23 и Reticulum (`rns`).
* **Надёжность**: Повторное подключение с экспоненциальной задержкой.
* **Гибкость**: Настраиваемые MTU и интервал опроса.
* **Совместимость с прокси**: Caddy, Nginx и аналоги.
* **Повторное использование соединений**: keep-alive / мультиплексирование.
* **HTTP/1.1, HTTP/2 и HTTP/3**: `http_version = 1|2|3` (по умолчанию `1`).
## **Начало работы**
### **Требования**
* Python 3.10 или новее
* [Poetry](https://python-poetry.org/docs/#installation)
### **Установка**
1. **Установите зависимости через Poetry** (из корня репозитория):
```bash
poetry install
```
2. **Установите пользовательский интерфейс:** скопируйте `HTTPInterface.py` в каталог интерфейсов Reticulum, например `~/.reticulum/interfaces/`.
### **Тесты**
```bash
poetry run pytest -m "not live"
poetry run pytest -m live
```
## **Конфигурация**
Добавьте блок интерфейса в `~/.reticulum/config` на сервере и на клиенте. Поле `type` должно совпадать с именем модуля (`HTTPInterface`).
### **Конфигурация сервера**
```ini
[[HTTP Server Interface]]
type = HTTPInterface
enabled = true
mode = server
listen_host = 0.0.0.0
listen_port = 8080
mtu = 4096
check_user_agent = true
user_agent = RNS-HTTP-Tunnel/1.0
```
### **Конфигурация клиента**
```ini
[[HTTP Client Interface]]
type = HTTPInterface
enabled = true
mode = client
server_url = http://your-server-ip-or-domain:8080/
poll_interval = 0.1
mtu = 4096
user_agent = RNS-HTTP-Tunnel/1.0
```
## **Параметры конфигурации**
### Общие
* `mtu`: максимальный размер пакета в байтах (по умолчанию `4096`)
* `user_agent`: строка User-Agent (по умолчанию `RNS-HTTP-Tunnel/1.0`)
### Режим сервера
* `mode = server`
* `listen_host` (по умолчанию `0.0.0.0`)
* `listen_port` (по умолчанию `8080`)
* `check_user_agent` (по умолчанию `true`)
* `serve_html_page` / `html_file_path`: необязательная HTML-страница на GET `/`
### Режим клиента
* `mode = client`
* `server_url` (обязателен)
* `poll_interval` (по умолчанию `0.1`)
* `http_version = 1|2|3` (по умолчанию `1`)
* для `2` и `3`: `https://`, на сервере `tls_certfile` / `tls_keyfile`
* `tls_verify` / `tls_ca_certs` на клиенте
* `pool_connections` / `pool_maxsize` / `keepalive_timeout`
## **Обратный прокси (пример Caddy)**
```caddy
example.yourdomain.com {
reverse_proxy 127.0.0.1:8080
header {
-Server
X-Content-Type-Options nosniff
}
}
```
## **Безопасность**
* Используйте HTTPS там, где возможно.
* По умолчанию сервер проверяет User-Agent. Задайте одинаковый `user_agent` на обеих сторонах или отключите проверку через `check_user_agent = false`.
## **Лицензия**
Проект распространяется под [лицензией MIT](LICENSE).

201
vendor/rns_over_http/README.md vendored Normal file
View file

@ -0,0 +1,201 @@
# RNS-over-HTTP
[Русский](README-RU.md)
A custom Reticulum interface that tunnels traffic over standard HTTP/S POST requests. This allows Reticulum to operate on networks where only web traffic is permitted, effectively bypassing firewalls, DPI, and other restrictions.
Packet boundaries on the wire use the same simplified HDLC framing as Reticulum's `PipeInterface`, so multiple packets can share one HTTP body without merging.
[Non-GitHub Mirror](https://lavaforge.org/Ivan/RNS-over-HTTP). Also available on the network `RNS-over-HTTP` node.
## Overview
RNS-over-HTTP creates a bidirectional transport layer using a simple client-server model:
- **Server**: Runs on a machine with a public IP, listening for HTTP requests.
- **Client**: Can be behind a firewall or NAT, only needing outbound internet access.
The client polls the server with HTTP POST requests, sending any outbound data in the request body and receiving inbound data in the response body. This makes the traffic appear as normal web activity.
## How It Works
The interface mimics a persistent connection using a long-polling-like mechanism:
1. The client sends an HTTP POST request to the server, with any pending HDLC-framed packets in the request body.
2. The server receives the request. It deframes inbound packets for Reticulum and immediately sends back any queued outbound packets (also HDLC-framed) in the HTTP response body.
3. The client receives the response, deframes packets, and hands them to Reticulum.
4. After a short, configurable polling interval, the client repeats the process.
This continuous cycle creates a reliable, albeit higher-latency, communication channel.
## Features
- **Firewall & DPI Evasion**: Tunnels any traffic through standard HTTP/S ports (80/443).
- **Bidirectional Communication**: Full-duplex data transfer.
- **Pipe-compatible framing**: HDLC FLAG/ESC framing identical to `PipeInterface`.
- **Simple setup**: Python, `httpx`, Hypercorn/aioquic for HTTP/23, and Reticulum (`rns`). Use Poetry in this repo for deps and tests.
- **Reliable**: Automatic connection retry with exponential backoff.
- **Flexible**: Supports custom MTU sizes and configurable polling intervals.
- **Proxy-Friendly**: Works seamlessly behind reverse proxies like Caddy or Nginx.
- **Connection reuse**: Keep-alive / multiplexed sessions by default, reducing handshake noise visible to DPI.
- **HTTP/1.1, HTTP/2, and HTTP/3**: Choose the version that matches your path. Default stays HTTP/1.1 for cleartext and reverse-proxy backends.
## Getting Started
### Requirements
- Python 3.10 or later
- [Poetry](https://python-poetry.org/docs/#installation) (for a reproducible dev environment and tests)
### Installation
1. **Install dependencies with Poetry** (from this repository root):
```bash
poetry install
```
2. **Install the custom interface for Reticulum:**
Copy `HTTPInterface.py` into your Reticulum interfaces directory: `~/.reticulum/interfaces/`.
### Tests
```bash
# Unit, integration, and fuzz tests
poetry run pytest -m "not live"
# Full live Reticulum tests (two instances over HTTP + local shared-instance client)
poetry run pytest -m live
```
## Configuration
Add an interface entry to your Reticulum configuration file (`~/.reticulum/config`) on both the server and client machines. The config `type` must match the module basename (`HTTPInterface`).
### Server Configuration
The server listens for incoming connections from clients.
```ini
[[HTTP Server Interface]]
type = HTTPInterface
enabled = true
mode = server
listen_host = 0.0.0.0
listen_port = 8080
mtu = 4096
check_user_agent = true
user_agent = RNS-HTTP-Tunnel/1.0
```
### Client Configuration
The client connects to the server's public URL.
```ini
[[HTTP Client Interface]]
type = HTTPInterface
enabled = true
mode = client
server_url = http://your-server-ip-or-domain:8080/
poll_interval = 0.1
mtu = 4096
user_agent = RNS-HTTP-Tunnel/1.0
```
## Configuration Options
### Common Options
- `mtu`: Maximum Transmission Unit in bytes (default: `4096`).
- `name`: Interface name for logging and identification.
- `user_agent`: User-Agent string for HTTP requests and server checks (default: `RNS-HTTP-Tunnel/1.0`).
### Server Mode Options
- `mode`: Must be set to `server`.
- `listen_host`: Host to bind the HTTP server to (default: `0.0.0.0`).
- `listen_port`: Port to listen on (default: `8080`).
- `check_user_agent`: Whether to validate User-Agent headers (default: `true`).
- `serve_html_page`: Serve an HTML page on GET `/` (default: `false`).
- `html_file_path`: Path to the HTML file used when `serve_html_page` is enabled.
### Client Mode Options
- `mode`: Must be set to `client`.
- `server_url`: Full URL of the server to connect to (required for client mode).
- `poll_interval`: Polling interval in seconds (default: `0.1`).
- `pool_connections` / `pool_maxsize`: Keepalive pool sizing for HTTP/1.1 and HTTP/2 (default: `1`).
### HTTP Version and TLS
- `http_version`: `1` (default), `2`, or `3`.
- **HTTP/1.1**: cleartext `http://` or TLS. Best behind Caddy/nginx that already terminate HTTP/2 or HTTP/3.
- **HTTP/2**: requires `https://` and server `tls_certfile` / `tls_keyfile`. Client uses `httpx` with ALPN `h2`.
- **HTTP/3**: requires `https://` and the same TLS files. Server enables Hypercorn QUIC bind. Client keeps one aioquic QUIC session across polls.
- `tls_verify`: client certificate verification (default: `true`). Set `false` only for lab/self-signed setups.
- `tls_ca_certs`: optional CA bundle path for client verification.
- `keepalive_timeout`: idle keepalive budget in seconds (default: `60`).
Example HTTP/2 server/client:
```ini
[[HTTP Server Interface]]
type = HTTPInterface
enabled = true
mode = server
http_version = 2
listen_host = 0.0.0.0
listen_port = 443
tls_certfile = /path/to/fullchain.pem
tls_keyfile = /path/to/privkey.pem
[[HTTP Client Interface]]
type = HTTPInterface
enabled = true
mode = client
http_version = 2
server_url = https://your-server.example/
tls_verify = true
```
## Reverse Proxy Setup (Caddy Example)
### Subdomain
```caddy
# Caddyfile for example.yourdomain.com
example.yourdomain.com {
reverse_proxy 127.0.0.1:8080
header {
# Hide the server software version
-Server
# Prevent MIME-type sniffing
X-Content-Type-Options nosniff
}
}
```
### Main Domain
```caddy
yourdomain.com {
reverse_proxy 127.0.0.1:8080
header {
# Hide the server software version
-Server
# Prevent MIME-type sniffing
X-Content-Type-Options nosniff
}
}
```
## Security Considerations
- **Use HTTPS**: Helps bypass some firewalls and DPI that could potentially see reticulum data.
- **User-Agent Check**: By default, the server validates the `User-Agent` header (`RNS-HTTP-Tunnel/1.0`). This provides basic protection against web crawlers and casual scanning. To mimic a common browser under sophisticated DPI, set a matching `user_agent` on both sides and keep `check_user_agent = true`, or set `check_user_agent = false` on the server.
## License
This project is licensed under the [MIT License](LICENSE).

24
vendor/rns_over_http/index.html vendored Normal file
View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Status</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.status { color: #28a745; font-weight: bold; }
.header { text-align: center; margin-bottom: 30px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Service Status</h1>
<p class="status">✓ All systems operational</p>
</div>
<p>This service is currently running normally. If you're seeing this page, the service is functioning as expected.</p>
<p>For technical inquiries, please contact the system administrator.</p>
</div>
</body>
</html>

851
vendor/rns_over_http/poetry.lock generated vendored Normal file
View file

@ -0,0 +1,851 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aioquic"
version = "1.3.0"
description = "An implementation of QUIC and HTTP/3"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "aioquic-1.3.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59da070ff0f55a54f5623c9190dbc86638daa0bcf84bbdb11ebe507abc641435"},
{file = "aioquic-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:48590fa38ec13f01a3d4e44fb3cfd373661094c9c7248f3c54d2d9512b6c3469"},
{file = "aioquic-1.3.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:019b16580d53541b5d77b4a44a61966921156554fad2536d74895713c800caa5"},
{file = "aioquic-1.3.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:396e5f53f6ddb27713d9b5bb11d8f0f842e42857b7e671c5ae203bf618528550"},
{file = "aioquic-1.3.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:4098afc6337adf19bdb54474f6c37983988e7bfa407892a277259c32eb664b00"},
{file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:48292279a248422b6289fffd82159eba8d8b35ff4b1f660b9f74ff85e10ca265"},
{file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0538acdfbf839d87b175676664737c248cd51f1a2295c5fef8e131ddde478a86"},
{file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8881239801279188e33ced6f9849cedf033325a48a6f44d7e55e583abc555a3"},
{file = "aioquic-1.3.0-cp310-abi3-win32.whl", hash = "sha256:ba30016244e45d9222fdd1fbd4e8b0e5f6811e81a5d0643475ad7024a537274a"},
{file = "aioquic-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:2d7957ba14a6c5efcc14fdc685ccda7ecf0ad048c410a2bdcad1b63bf9527e8e"},
{file = "aioquic-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9d15a89213d38cbc4679990fa5151af8ea02655a1d6ce5ec972b0a6af74d5f1c"},
{file = "aioquic-1.3.0.tar.gz", hash = "sha256:28d070b2183e3e79afa9d4e7bd558960d0d53aeb98bc0cf0a358b279ba797c92"},
]
[package.dependencies]
certifi = "*"
cryptography = ">=42.0.0"
pylsqpack = ">=0.3.3,<0.4.0"
pyopenssl = ">=24"
service-identity = ">=24.1.0"
[package.extras]
dev = ["coverage[toml] (>=7.2.2)"]
[[package]]
name = "anyio"
version = "4.14.2"
description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
{file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
idna = ">=2.8"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras]
trio = ["trio (>=0.32.0)"]
[[package]]
name = "attrs"
version = "26.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
]
[[package]]
name = "certifi"
version = "2026.6.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
{file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
]
[[package]]
name = "cffi"
version = "2.1.0"
description = "Foreign Function Interface for Python calling C code."
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\""
files = [
{file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"},
{file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"},
{file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"},
{file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"},
{file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"},
{file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"},
{file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"},
{file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"},
{file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"},
{file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"},
{file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"},
{file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"},
{file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"},
{file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"},
{file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"},
{file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"},
{file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"},
{file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"},
{file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"},
{file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"},
{file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"},
{file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"},
{file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"},
{file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"},
{file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"},
{file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"},
{file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"},
{file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"},
{file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"},
{file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"},
{file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"},
{file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"},
{file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"},
{file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"},
{file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"},
{file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"},
{file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"},
{file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"},
{file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"},
{file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"},
{file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"},
{file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"},
{file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"},
{file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"},
{file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"},
{file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"},
{file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"},
{file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"},
{file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"},
{file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"},
{file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"},
{file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"},
{file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"},
{file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"},
{file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"},
{file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"},
{file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"},
{file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"},
{file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"},
{file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"},
{file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"},
{file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"},
{file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"},
{file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"},
{file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"},
{file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"},
{file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"},
{file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"},
{file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"},
{file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"},
{file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"},
{file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"},
{file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"},
{file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"},
{file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"},
{file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"},
{file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"},
{file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"},
{file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"},
{file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"},
{file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"},
{file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"},
{file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"},
{file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"},
{file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"},
{file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"},
{file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"},
{file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"},
{file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"},
{file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"},
{file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"},
{file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"},
{file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"},
{file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"},
{file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"},
{file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"},
{file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"},
{file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"},
{file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"},
{file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"},
]
[package.dependencies]
pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "cryptography"
version = "49.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.9"
groups = ["main"]
files = [
{file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"},
{file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"},
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"},
{file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"},
{file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"},
{file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"},
{file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"},
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"},
{file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"},
{file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"},
{file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"},
{file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"},
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"},
{file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"},
{file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"},
{file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"},
{file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"},
]
[package.dependencies]
cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
[package.extras]
ssh = ["bcrypt (>=3.1.5)"]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
groups = ["main", "dev"]
markers = "python_version == \"3.10\""
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "h2"
version = "4.3.0"
description = "Pure-Python HTTP/2 protocol implementation"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"},
{file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"},
]
[package.dependencies]
hpack = ">=4.1,<5"
hyperframe = ">=6.1,<7"
[[package]]
name = "hpack"
version = "4.2.0"
description = "Pure-Python HPACK header encoding"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986"},
{file = "hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0"},
]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""}
httpcore = "==1.*"
idna = "*"
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "hypercorn"
version = "0.18.0"
description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd"},
{file = "hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da"},
]
[package.dependencies]
aioquic = {version = ">=0.9.0", optional = true, markers = "extra == \"h3\""}
exceptiongroup = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
h11 = "*"
h2 = ">=4.3.0"
priority = "*"
taskgroup = {version = "*", markers = "python_version < \"3.11\""}
tomli = {version = "*", markers = "python_version < \"3.11\""}
typing_extensions = {version = "*", markers = "python_version < \"3.11\""}
wsproto = ">=0.14.0"
[package.extras]
docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"]
h3 = ["aioquic (>=0.9.0)"]
trio = ["trio"]
uvloop = ["uvloop"]
[[package]]
name = "hyperframe"
version = "6.1.0"
description = "Pure-Python HTTP/2 framing"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"},
{file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"},
]
[[package]]
name = "hypothesis"
version = "6.157.0"
description = "The property-based testing library for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "hypothesis-6.157.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c958d78cfe93e00fc410d8dadfafac379bff3633348e87a4c5a5af47a0ef0873"},
{file = "hypothesis-6.157.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6ffb3948acffacd2fe9cd56b5b259f48002ed97d8addc2087db55d2e76ab8183"},
{file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764ea1cb63e8d75b0b3889da30640466425b5a2fcaec803952b3fb13e56515a"},
{file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:116734981c3c59e06e6ed1446b054fa92db999cbdbc354c3335c593ce917ebb9"},
{file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b93a357d1ff0dae972e8accbfe15cd91c1eeb8fa766a2a1c361b56bd0414f80"},
{file = "hypothesis-6.157.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8778a10749cfd3cd236224dc78b354b14970a07cd5630345269ffbf8830db9a"},
{file = "hypothesis-6.157.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b718c800bfd1fbec5a11ac1e56c28a5115a31af499ebb6acfc6e13922a6dd0d4"},
{file = "hypothesis-6.157.0-cp310-abi3-win32.whl", hash = "sha256:17e9264974ffdeaa95e48caa36db418d49bb1f0fa164be616adea64f5547d900"},
{file = "hypothesis-6.157.0-cp310-abi3-win_amd64.whl", hash = "sha256:c8ac434ead091077b1fc9ca69ed7162cd644e42bf75e59a11ae35258e8922225"},
{file = "hypothesis-6.157.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5dd5b530a847cfecd7dc7ec97d0776e2744e0abab16bb8ed94a100a463209003"},
{file = "hypothesis-6.157.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37953dfffdf4c5147d92ce2269aed9f66123edc09e9de3356b6e68cc2ae5d70f"},
{file = "hypothesis-6.157.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96abb36b598b1b9a427716e28ddccb4b88fd4be462e92d23c3c12e1f91ee3efd"},
{file = "hypothesis-6.157.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db9124511b4bde227e927d2e3eb43a2df4416930c47874126a8d71ea13e3fe2b"},
{file = "hypothesis-6.157.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b7b53bf761fac490c758db65062072b2239b70e4f3ac772439aa73186171ffd7"},
{file = "hypothesis-6.157.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4cfa9feb2cb7d3ff327b2298711e5b61fbdf1f95617344ae6de8ef25563c88"},
{file = "hypothesis-6.157.0-cp310-cp310-win_amd64.whl", hash = "sha256:89c0c6e3426eb4ebe721c80edf19285e8979a119e2b77971c5d8e959efeddbd9"},
{file = "hypothesis-6.157.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d73b30b5d5904493cf6c2fd22ed202e9f05537c9da8e8f1c985c52abe118d35d"},
{file = "hypothesis-6.157.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:856ee6862ba535820117d3f042ccdc078eee30915d5a79af94fb97ac604cf7aa"},
{file = "hypothesis-6.157.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc9c695dab6573d27c0f1482937b2df963c24e08a75f6cdc9eb3aa32efb183bf"},
{file = "hypothesis-6.157.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89d41263f048b05e3723c66d10ce7e9ff7e73931dadf5e7cfab0dade18c1abd1"},
{file = "hypothesis-6.157.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26d7f7f95b3d93b7930f9e0a13ae93bc75832c97eea7b76c966aa8799ba0f2ab"},
{file = "hypothesis-6.157.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:00701c60761391eb4b11ed469a265ea2957fe49bcdc3b4920ce527589cef11e9"},
{file = "hypothesis-6.157.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2e2e18aa5c8bf8e8046e4bef5cc6c58bf72a0a57e15d75abf519060ea37cdec"},
{file = "hypothesis-6.157.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:cc840c8878e80f3a2a3bc273a2a16027925585a19ce8ef0e21d43900c855b72f"},
{file = "hypothesis-6.157.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c13eeeb7ac9c493e3985d5190ffda8779d9f50cdd7f5ca2cb13bb55c4f885cef"},
{file = "hypothesis-6.157.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99e5c77e29000033412ef56ffe6900d1cb396c60860b69794d35d535350d3151"},
{file = "hypothesis-6.157.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b84a473dc19e5bdd26fff44c08b33ef9c342a59da90466cdf31400e812e2feae"},
{file = "hypothesis-6.157.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:78efc2441fded9c12de7db6d38b64af2173af49c0f09d09acdb0ad37028f6044"},
{file = "hypothesis-6.157.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f961c45fb9908966bef523d42b5c018896cfcb1dc4faee70cdcb03c55de0f14"},
{file = "hypothesis-6.157.0-cp312-cp312-win_amd64.whl", hash = "sha256:848f5c0349a2629f13c40493f0c1ad380e92a31edb7b76646ba58b1268f29d7c"},
{file = "hypothesis-6.157.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84a93415a8d0c69ef7d37548c653cd1f4cbc861457f29ca5ecfeb9ab21955195"},
{file = "hypothesis-6.157.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cea91fe827de004963e611697c52ee2f766cc4366183a822d9ed1470c2bdc0a9"},
{file = "hypothesis-6.157.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feb9af5c6ea644b136a871bab2a43930836445cf1feae29c9e05ae0173439668"},
{file = "hypothesis-6.157.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce0e0bfe1e9c849d96850d23eb5cf02d80d278d2a5130cabe7815c13c8aea15e"},
{file = "hypothesis-6.157.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae9064b021a0571514db40f2beb99a31c8d18a1fd12f0ee30cb7e72c102483e4"},
{file = "hypothesis-6.157.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84b664f0500911713f564ec826fe29ba883a635921babc2683a4fd12cb567988"},
{file = "hypothesis-6.157.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fabcd93730584a28b0324da308122f9cb6f35d2d540d223d5561649d71b5392"},
{file = "hypothesis-6.157.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c3084b75511d50a912eb597ab5d1d19a12734a38155c93b061a6beead84a55cd"},
{file = "hypothesis-6.157.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:46519de255107a12213470aebea23fb457acec1157ca9aafc2ce4278d073d61e"},
{file = "hypothesis-6.157.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:702c39588097cdaacbbd9b3c7c26abc2321a91f023163af364698d79d57adcd2"},
{file = "hypothesis-6.157.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f1f48231ba294c0eff490403ff1580c5ba8534679ee5462171f7c23a7096592"},
{file = "hypothesis-6.157.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcc1cf1d961e9fa4043e5938ebee8b81de559c5253253ef42a523bb73f9a6be4"},
{file = "hypothesis-6.157.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:48a75902f2a5b6fbf8b3e22e5d1c9c099ecb1accb1b32860dbc07ef8b66d5cc5"},
{file = "hypothesis-6.157.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a48197661d22f0a6d6fad9ae43fc630901c996e7914e8a22eb0fa3c041295b0c"},
{file = "hypothesis-6.157.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8d7c38f06cd43f68eca7532fc55171b265c3b55cf29317e7d330fbdfdb19ab"},
{file = "hypothesis-6.157.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a32ae18769c095331eedd1921e999075660631d532370497116dc5dc1975024a"},
{file = "hypothesis-6.157.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6947a4978496aeafc94815b16643a3aa753e12da090ccee65bf2f72c29b0b96"},
{file = "hypothesis-6.157.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c32d9da2d7f52d6cd04cfaaab6e32793c99525f07c5cb44a04c3895df73506b"},
{file = "hypothesis-6.157.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9404b73097ef8deb2f0a72d7a8c38c15c86f4aa3d2fcad89540e17c33eb6259"},
{file = "hypothesis-6.157.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b97ebad2018f39287b5c8fdfd66d4372c005bc914fe2db6c283facbac7ddb81"},
{file = "hypothesis-6.157.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb4670b6f9a2c863025d9c4a8e7307bf083bce391e37dcb8857d9ec1a422ae5e"},
{file = "hypothesis-6.157.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42861f32c1ca6fb999528a5feb553a967fc6b18c39411a224002cdf0d6e91364"},
{file = "hypothesis-6.157.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aabe6c3f74bac72be3908581be071a90c21fb2a6d99a264363710783cfd072c5"},
{file = "hypothesis-6.157.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5ffb93da23a43edd894b4cbec3af5f8975cc53def3df345d4ca9f5549939e2d7"},
{file = "hypothesis-6.157.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bff02bff2b8cb0d464cfbe62aacca26838155de3ac8f8c6afd697d1866722e8"},
{file = "hypothesis-6.157.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19d9bc11c4e56ac0b3709e78589d9d94156c1649fe371849e02940d474a71755"},
{file = "hypothesis-6.157.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c0f75a017cc914f8aa4ae11c210a6de8592501e34a0697a24c349069c16b33dd"},
{file = "hypothesis-6.157.0.tar.gz", hash = "sha256:5e4cd0af9261b06fc79e8aabe7d840384b3c24eaceae7e7e25ee3800a6d6ac58"},
]
[package.dependencies]
exceptiongroup = {version = ">=1.0.0", markers = "python_full_version < \"3.11.0\""}
sortedcontainers = ">=2.1.0,<3.0.0"
[package.extras]
all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.108)", "django (>=5.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.28)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.3) ; sys_platform == \"emscripten\" or sys_platform == \"win32\"", "watchdog (>=4.0.0)"]
cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"]
codemods = ["libcst (>=0.3.16)"]
crosshair = ["crosshair-tool (>=0.0.108)", "hypothesis-crosshair (>=0.0.28)"]
dateutil = ["python-dateutil (>=1.4)"]
django = ["django (>=5.2)"]
dpcontracts = ["dpcontracts (>=0.4)"]
ghostwriter = ["black (>=20.8b0)"]
lark = ["lark (>=0.10.1)"]
numpy = ["numpy (>=1.21.6)"]
pandas = ["pandas (>=1.1)"]
pytest = ["pytest (>=4.6)"]
pytz = ["pytz (>=2014.1)"]
redis = ["redis (>=3.0.0)"]
watchdog = ["watchdog (>=4.0.0)"]
zoneinfo = ["tzdata (>=2026.3) ; sys_platform == \"emscripten\" or sys_platform == \"win32\""]
[[package]]
name = "idna"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "packaging"
version = "26.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "priority"
version = "2.0.0"
description = "A pure-Python implementation of the HTTP/2 priority tree"
optional = false
python-versions = ">=3.6.1"
groups = ["main"]
files = [
{file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"},
{file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"},
]
[[package]]
name = "pycparser"
version = "3.0"
description = "C parser in Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
files = [
{file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
{file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
]
[[package]]
name = "pygments"
version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pylsqpack"
version = "0.3.24"
description = "Python wrapper for the ls-qpack QPACK library"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d"},
{file = "pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc"},
{file = "pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a"},
{file = "pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c"},
{file = "pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c"},
{file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9"},
{file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b"},
{file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0"},
{file = "pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767"},
{file = "pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff"},
{file = "pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9"},
{file = "pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890"},
]
[[package]]
name = "pyopenssl"
version = "26.3.0"
description = "Python wrapper module around the OpenSSL library"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pyopenssl-26.3.0-py3-none-any.whl", hash = "sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3"},
{file = "pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341"},
]
[package.dependencies]
cryptography = ">=49.0.0,<50"
typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
[[package]]
name = "pyserial"
version = "3.5"
description = "Python Serial Port Extension"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"},
{file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"},
]
[package.extras]
cp2110 = ["hidapi"]
[[package]]
name = "pytest"
version = "9.1.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-timeout"
version = "2.4.0"
description = "pytest plugin to abort hanging tests"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
{file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
]
[package.dependencies]
pytest = ">=7.0.0"
[[package]]
name = "rns"
version = "1.3.9"
description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
optional = false
python-versions = ">=3.7"
groups = ["main"]
files = [
{file = "rns-1.3.9-py3-none-any.whl", hash = "sha256:ceeac4799dda72b48a34b1e36de538e64009cba5b7b28d086918c0120ca8bfa4"},
{file = "rns-1.3.9.tar.gz", hash = "sha256:fd3745c1aba4dcbf8833fa87a3157ec9900745a32d6d96ba330d623a98932a15"},
]
[package.dependencies]
cryptography = ">=3.4.7"
pyserial = ">=3.5"
[[package]]
name = "service-identity"
version = "26.1.0"
description = "Service identity verification for pyOpenSSL & cryptography."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118"},
{file = "service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b"},
]
[package.dependencies]
attrs = ">=19.1.0"
cryptography = ">=47"
[package.extras]
idna = ["idna"]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
{file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
]
[[package]]
name = "taskgroup"
version = "0.2.2"
description = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout"
optional = false
python-versions = "*"
groups = ["main"]
markers = "python_version == \"3.10\""
files = [
{file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"},
{file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"},
]
[package.dependencies]
exceptiongroup = "*"
typing_extensions = ">=4.12.2,<5"
[[package]]
name = "tomli"
version = "2.4.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["main", "dev"]
markers = "python_version == \"3.10\""
files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
groups = ["main", "dev"]
files = [
{file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
{file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
]
markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""}
[[package]]
name = "wsproto"
version = "1.3.2"
description = "Pure-Python WebSocket protocol implementation"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"},
{file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"},
]
[package.dependencies]
h11 = ">=0.16.0,<1"
[metadata]
lock-version = "2.1"
python-versions = "^3.10"
content-hash = "40f2b9d0b11cbd12d7033234c9fe18b08045ffec7741140a9b65dbe2e95bf877"

33
vendor/rns_over_http/pyproject.toml vendored Normal file
View file

@ -0,0 +1,33 @@
[tool.poetry]
name = "rns-over-http"
version = "0.1.0"
description = "Reticulum interface that tunnels RNS traffic over HTTP POST"
authors = ["Ivan / Quad4.io"]
license = "MIT"
readme = "README.md"
package-mode = false
[tool.poetry.dependencies]
python = "^3.10"
rns = "^1.3.9"
httpx = {extras = ["http2"], version = "^0.28.1"}
hypercorn = {extras = ["h3"], version = "^0.18.0"}
aioquic = "^1.3.0"
[tool.poetry.group.dev.dependencies]
pytest = "^9.0.0"
hypothesis = "^6.0.0"
pytest-timeout = "^2.0.0"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
timeout = 120
timeout_method = "thread"
markers = [
"live: full Reticulum live tests (slower, starts local rns instances)",
]

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Subprocess helper for live Reticulum HTTP tunnel tests."""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def write_json(path: Path, obj):
path.write_text(json.dumps(obj) + "\n", encoding="utf-8")
def cmd_serve(args):
import RNS
RNS.Reticulum(
configdir=args.configdir,
loglevel=RNS.LOG_NOTICE,
)
identity = RNS.Identity()
destination = RNS.Destination(
identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
"http_live",
"echo",
)
received = {"payload": None}
def packet_callback(data, packet):
try:
text = data.decode("utf-8")
except Exception:
text = None
received["payload"] = text
write_json(Path(args.recv_file), {"payload": text})
destination.set_packet_callback(packet_callback)
destination.announce()
Path(args.announce_file).write_text(destination.hash.hex() + "\n", encoding="utf-8")
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
if received["payload"] is not None:
time.sleep(1.0)
return 0
time.sleep(0.1)
write_json(Path(args.recv_file), {"payload": None, "error": "timeout"})
return 1
def cmd_client(args):
import RNS
reticulum = RNS.Reticulum(
configdir=args.configdir,
loglevel=RNS.LOG_NOTICE,
)
RNS.Identity()
target_hash = bytes.fromhex(args.target_hash)
if not RNS.Transport.has_path(target_hash):
RNS.Transport.request_path(target_hash)
deadline = time.monotonic() + args.timeout
while time.monotonic() < deadline:
if RNS.Transport.has_path(target_hash):
break
RNS.Transport.request_path(target_hash)
time.sleep(0.25)
else:
write_json(
Path(args.done_file),
{"ok": False, "error": "path_timeout"},
)
return 1
recalled = RNS.Identity.recall(target_hash)
if recalled is None:
write_json(
Path(args.done_file),
{"ok": False, "error": "identity_recall_failed"},
)
return 1
dest = RNS.Destination(
recalled,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"http_live",
"echo",
)
packet = RNS.Packet(dest, args.payload.encode("utf-8"))
packet.send()
write_json(
Path(args.done_file),
{
"ok": True,
"path": True,
"shared": reticulum.is_shared_instance,
},
)
time.sleep(3.0)
return 0
def cmd_local_client(args):
import RNS
reticulum = RNS.Reticulum(
configdir=args.configdir,
loglevel=RNS.LOG_NOTICE,
require_shared_instance=True,
)
connected = reticulum.is_connected_to_shared_instance
expect = bytes.fromhex(args.expect_hash)
deadline = time.monotonic() + min(args.timeout, 20.0)
has_path = False
while time.monotonic() < deadline:
if RNS.Transport.has_path(expect):
has_path = True
break
RNS.Transport.request_path(expect)
time.sleep(0.3)
ok = bool(connected)
write_json(
Path(args.done_file),
{
"ok": ok,
"connected_to_shared": connected,
"has_path": has_path,
"is_shared_instance": reticulum.is_shared_instance,
},
)
return 0 if ok else 1
def main():
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="cmd", required=True)
p_serve = sub.add_parser("serve")
p_serve.add_argument("--configdir", required=True)
p_serve.add_argument("--announce-file", required=True)
p_serve.add_argument("--recv-file", required=True)
p_serve.add_argument("--timeout", type=float, default=120.0)
p_serve.set_defaults(func=cmd_serve)
p_client = sub.add_parser("client")
p_client.add_argument("--configdir", required=True)
p_client.add_argument("--target-hash", required=True)
p_client.add_argument("--done-file", required=True)
p_client.add_argument("--payload", required=True)
p_client.add_argument("--timeout", type=float, default=90.0)
p_client.set_defaults(func=cmd_client)
p_local = sub.add_parser("local_client")
p_local.add_argument("--configdir", required=True)
p_local.add_argument("--done-file", required=True)
p_local.add_argument("--expect-hash", required=True)
p_local.add_argument("--timeout", type=float, default=45.0)
p_local.set_defaults(func=cmd_local_client)
args = parser.parse_args()
try:
return args.func(args)
except Exception as exc:
print(f"peer error: {exc}", file=sys.stderr)
import traceback
traceback.print_exc()
return 2
if __name__ == "__main__":
sys.exit(main())

45
vendor/rns_over_http/tests/conftest.py vendored Normal file
View file

@ -0,0 +1,45 @@
import socket
import textwrap
import pytest
def _free_tcp_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="session", autouse=True)
def reticulum_bootstrap(tmp_path_factory):
"""RNS 1.3+ Interface.__init__ requires an active Reticulum instance."""
import RNS
configdir = tmp_path_factory.mktemp("rns_bootstrap")
shared_port = _free_tcp_port()
control_port = _free_tcp_port()
(configdir / "interfaces").mkdir()
(configdir / "config").write_text(
textwrap.dedent(
f"""
[reticulum]
enable_transport = No
share_instance = No
instance_name = http-tunnel-pytest
shared_instance_port = {shared_port}
instance_control_port = {control_port}
shared_instance_type = tcp
[logging]
loglevel = 0
[interfaces]
[[Default Interface]]
type = AutoInterface
enabled = No
"""
),
encoding="utf-8",
)
reticulum = RNS.Reticulum(configdir=str(configdir), loglevel=RNS.LOG_CRITICAL)
yield reticulum

View file

@ -0,0 +1,111 @@
import random
from hypothesis import given, settings, strategies as st
from HTTPInterface import HDLC
def test_frame_wraps_with_flags():
framed = HDLC.frame(b"abc")
assert framed[0] == HDLC.FLAG
assert framed[-1] == HDLC.FLAG
assert bytes([HDLC.FLAG]) not in framed[1:-1]
def test_escape_flag_and_esc_inside_payload():
payload = bytes([0x01, HDLC.FLAG, 0x02, HDLC.ESC, 0x03])
framed = HDLC.frame(payload)
assert bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]) in framed
assert bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]) in framed
packets, rem = HDLC.deframe(framed, 4096)
assert rem == b""
assert packets == [payload]
def test_deframe_multiple_packets():
wire = HDLC.frame(b"one") + HDLC.frame(b"two") + HDLC.frame(b"three")
packets, rem = HDLC.deframe(wire, 4096)
assert rem == b""
assert packets == [b"one", b"two", b"three"]
def test_deframe_incomplete_returns_remainder():
partial = HDLC.frame(b"complete") + bytes([HDLC.FLAG]) + b"incomplete"
packets, rem = HDLC.deframe(partial, 4096)
assert packets == [b"complete"]
assert rem.startswith(bytes([HDLC.FLAG]))
assert b"incomplete" in rem
def test_deframe_split_across_chunks():
wire = HDLC.frame(b"split-me")
mid = len(wire) // 2
first, rem1 = HDLC.deframe(wire[:mid], 4096)
assert first == []
assert rem1 != b""
second, rem2 = HDLC.deframe(rem1 + wire[mid:], 4096)
assert second == [b"split-me"]
assert rem2 == b""
def test_empty_frame_yields_empty_packet():
packets, rem = HDLC.deframe(bytes([HDLC.FLAG, HDLC.FLAG]), 4096)
assert packets == [b""]
assert rem == b""
@given(st.binary(min_size=0, max_size=512))
@settings(max_examples=200, deadline=None)
def test_fuzz_roundtrip_single_packet(data):
framed = HDLC.frame(data)
packets, rem = HDLC.deframe(framed, max(len(data) + 16, 64))
assert rem == b""
assert packets == [data]
@given(st.lists(st.binary(min_size=0, max_size=128), min_size=1, max_size=20))
@settings(max_examples=100, deadline=None)
def test_fuzz_roundtrip_batch(packets_in):
wire = b"".join(HDLC.frame(p) for p in packets_in)
packets, rem = HDLC.deframe(wire, 4096)
assert rem == b""
assert packets == packets_in
@given(st.binary(min_size=1, max_size=256), st.integers(min_value=1, max_value=8))
@settings(max_examples=100, deadline=None)
def test_fuzz_chunked_reassembly(data, chunks):
wire = HDLC.frame(data)
remainder = b""
out = []
step = max(1, len(wire) // chunks)
for i in range(0, len(wire), step):
piece = remainder + wire[i : i + step]
pkts, remainder = HDLC.deframe(piece, 4096)
out.extend(pkts)
if remainder:
pkts, remainder = HDLC.deframe(remainder, 4096)
out.extend(pkts)
assert remainder == b""
assert out == [data]
def test_random_noise_without_flags_still_recovers_frame():
rng = random.Random(42)
for _ in range(50):
noise = bytearray()
while len(noise) < rng.randint(0, 64):
b = rng.getrandbits(8)
if b not in (HDLC.FLAG, HDLC.ESC):
noise.append(b)
payload = bytes(rng.getrandbits(8) for _ in range(rng.randint(1, 80)))
wire = bytes(noise) + HDLC.frame(payload)
packets, rem = HDLC.deframe(wire, 4096)
assert rem == b""
assert packets == [payload]
def test_pipe_compatible_escape_constants():
assert HDLC.FLAG == 0x7E
assert HDLC.ESC == 0x7D
assert HDLC.ESC_MASK == 0x20

View file

@ -0,0 +1,281 @@
"""HTTP/2 and HTTP/3 tunnel tests over TLS."""
from __future__ import annotations
import io
import socket
import time
import httpx
import pytest
from RNS.vendor.configobj import ConfigObj
import HTTPInterface as http_mod
from HTTPInterface import HDLC, HTTPTunnelInterface
from tests.tls_certs import write_self_signed_cert
def free_tcp_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_tcp_open(host, port, timeout=15.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.3):
return
except OSError:
time.sleep(0.05)
pytest.fail(f"port {host}:{port} did not open within {timeout}s")
def wait_udp_bound(host, port, timeout=15.0):
"""Best-effort wait until something is listening on UDP port."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
# Hypercorn QUIC bind is harder to probe; give the ASGI thread time.
time.sleep(0.1)
# Also accept TCP (Hypercorn binds TCP even for HTTP/3 mode).
try:
with socket.create_connection((host, port), timeout=0.2):
return
except OSError:
continue
pytest.fail(f"HTTP/3 server did not become ready on {host}:{port}")
def configobj_from_lines(lines):
return ConfigObj(io.StringIO("\n".join(lines) + "\n"))
class RecordingOwner:
def __init__(self):
self.packets = []
def inbound(self, data, iface):
self.packets.append(bytes(data))
def last_payload(self, timeout=10.0, poll=0.05):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.packets:
return self.packets[-1]
time.sleep(poll)
return None
def wait_count(self, n, timeout=15.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if len(self.packets) >= n:
return list(self.packets)
time.sleep(0.05)
return None
def make_tls_pair(tmp_path, free_port, http_version, poll_interval=0.05):
cert, key = write_self_signed_cert(tmp_path / f"certs-h{http_version}")
owner_s = RecordingOwner()
owner_c = RecordingOwner()
server = HTTPTunnelInterface(
owner_s,
configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
f"http_version = {http_version}",
f"tls_certfile = {cert}",
f"tls_keyfile = {key}",
f"poll_interval = {poll_interval}",
]
),
)
if http_version == 3:
wait_udp_bound("127.0.0.1", free_port)
else:
wait_tcp_open("127.0.0.1", free_port)
# Extra settle time for Hypercorn TLS/QUIC startup
time.sleep(0.3)
url = f"https://127.0.0.1:{free_port}/"
client = HTTPTunnelInterface(
owner_c,
configobj_from_lines(
[
"name = cli",
"mode = client",
f"server_url = {url}",
f"http_version = {http_version}",
"tls_verify = false",
f"poll_interval = {poll_interval}",
]
),
)
return owner_s, owner_c, server, client
@pytest.fixture
def free_port():
return free_tcp_port()
def test_http2_requires_tls_on_server(free_port):
with pytest.raises(ValueError, match="tls_certfile"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"http_version = 2",
]
),
)
def test_http3_client_requires_https_url():
with pytest.raises(ValueError, match="https://"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = cli",
"mode = client",
"server_url = http://127.0.0.1:9/",
"http_version = 3",
]
),
)
def test_invalid_http_version_rejected(free_port):
with pytest.raises(ValueError, match="http_version"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"http_version = 4",
]
),
)
def test_http2_bidirectional_tunnel(tmp_path, free_port):
owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 2)
try:
client.process_outgoing(b"h2-client")
assert owner_s.last_payload(timeout=15.0) == b"h2-client"
server.process_outgoing(b"h2-server")
assert owner_c.last_payload(timeout=15.0) == b"h2-server"
# Confirm httpx negotiated HTTP/2
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if client.connection_stats().get("last_http_version") == "HTTP/2":
break
time.sleep(0.05)
assert client.connection_stats()["last_http_version"] == "HTTP/2"
assert server.connection_stats()["http_requests"] >= 1
finally:
client.detach()
server.detach()
def test_http2_reuses_connection_across_polls(tmp_path, free_port):
owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 2)
try:
for i in range(6):
client.process_outgoing(f"h2p-{i}".encode())
payloads = owner_s.wait_count(6, timeout=20.0)
assert payloads is not None
assert payloads[-6:] == [f"h2p-{i}".encode() for i in range(6)]
stats = client.connection_stats()
# Packets may be batched into fewer HTTP exchanges on one h2 connection
assert stats["client_requests"] >= 1
assert stats["client_requests"] <= 6
assert stats["last_http_version"] == "HTTP/2"
finally:
client.detach()
server.detach()
def test_http2_external_client_sees_h2(tmp_path, free_port):
cert, key = write_self_signed_cert(tmp_path / "certs-ext")
server = HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"http_version = 2",
f"tls_certfile = {cert}",
f"tls_keyfile = {key}",
]
),
)
try:
wait_tcp_open("127.0.0.1", free_port)
time.sleep(0.3)
with httpx.Client(http2=True, verify=False, timeout=5.0) as c:
r = c.post(
f"https://127.0.0.1:{free_port}/",
content=HDLC.frame(b"ext-h2"),
headers={"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT},
)
assert r.status_code == 200
assert r.http_version == "HTTP/2"
finally:
server.detach()
def test_http3_bidirectional_tunnel(tmp_path, free_port):
owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 3)
try:
client.process_outgoing(b"h3-client")
assert owner_s.last_payload(timeout=20.0) == b"h3-client"
server.process_outgoing(b"h3-server")
assert owner_c.last_payload(timeout=20.0) == b"h3-server"
assert client.connection_stats()["last_http_version"] == "HTTP/3"
assert client.connection_stats()["client_requests"] >= 1
assert server.connection_stats()["http_requests"] >= 1
finally:
client.detach()
server.detach()
def test_http3_persistent_session_multiple_posts(tmp_path, free_port):
owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 3)
try:
for i in range(5):
client.process_outgoing(f"h3p-{i}".encode())
payloads = owner_s.wait_count(5, timeout=25.0)
assert payloads is not None
assert payloads[-5:] == [f"h3p-{i}".encode() for i in range(5)]
stats = client.connection_stats()
assert stats["pool_num_connections"] == 1
assert stats["pool_num_requests"] >= 1
assert stats["pool_num_requests"] <= 5
assert stats["last_http_version"] == "HTTP/3"
finally:
client.detach()
server.detach()
def test_module_still_exports_interface_class():
assert http_mod.interface_class is HTTPTunnelInterface

View file

@ -0,0 +1,556 @@
import io
import socket
import threading
import time
import httpx
import pytest
from RNS.vendor.configobj import ConfigObj
import HTTPInterface as http_mod
from HTTPInterface import HDLC
HTTPTunnelInterface = http_mod.HTTPTunnelInterface
def free_tcp_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def wait_tcp_open(host, port, timeout=8.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection((host, port), timeout=0.3):
return
except OSError:
time.sleep(0.05)
pytest.fail(f"port {host}:{port} did not open within {timeout}s")
def configobj_from_lines(lines):
return ConfigObj(io.StringIO("\n".join(lines) + "\n"))
def ping_client_to_server(owner_s, client, token=b"\xfe\xd2tunnel-pingpytest"):
client.process_outgoing(token)
assert owner_s.last_payload(timeout=10.0) == token
@pytest.fixture
def free_port():
return free_tcp_port()
class RecordingOwner:
def __init__(self):
self._lock = threading.Lock()
self.packets = []
def inbound(self, data, iface):
with self._lock:
self.packets.append((bytes(data), iface))
def last_payload(self, timeout=6.0, poll=0.05):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._lock:
if self.packets:
return self.packets[-1][0]
time.sleep(poll)
return None
def payloads(self):
with self._lock:
return [bytes(p) for p, _ in self.packets]
def packet_count(self):
with self._lock:
return len(self.packets)
def wait_packet_count_at_least(self, n, timeout=8.0, poll=0.05):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
with self._lock:
if len(self.packets) >= n:
return [bytes(p) for p, _ in self.packets]
time.sleep(poll)
return None
def make_pair(free_port, poll_interval=0.05, **extra_server):
owner_s = RecordingOwner()
owner_c = RecordingOwner()
srv_lines = [
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
f"poll_interval = {poll_interval}",
]
for k, v in extra_server.items():
srv_lines.append(f"{k} = {v}")
server = HTTPTunnelInterface(owner_s, configobj_from_lines(srv_lines))
wait_tcp_open("127.0.0.1", free_port)
url = f"http://127.0.0.1:{free_port}/"
client = HTTPTunnelInterface(
owner_c,
configobj_from_lines(
[
"name = cli",
"mode = client",
f"server_url = {url}",
f"poll_interval = {poll_interval}",
]
),
)
return owner_s, owner_c, server, client
def test_client_mode_requires_server_url():
with pytest.raises(ValueError, match="server_url"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = c",
"mode = client",
]
),
)
def test_invalid_tunnel_mode():
with pytest.raises(ValueError, match="Invalid mode"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = x",
"mode = bridge",
"listen_host = 127.0.0.1",
"listen_port = 9",
]
),
)
def test_process_outgoing_rejects_over_mtu(free_port):
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"mtu = 32",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
payload = b"x" * 64
iface.process_outgoing(payload)
assert iface._send_queue.empty()
finally:
iface.detach()
def test_client_payload_reaches_server_owner(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
payload = b"rnstest-bytes-1"
client.process_outgoing(payload)
got = owner_s.last_payload(timeout=8.0)
assert got == payload
finally:
client.detach()
server.detach()
def test_server_payload_reaches_client_owner(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
ping_client_to_server(owner_s, client)
reply = b"rnstest-reply-2"
server.process_outgoing(reply)
got = owner_c.last_payload(timeout=8.0)
assert got == reply
finally:
client.detach()
server.detach()
def test_server_rejects_wrong_user_agent(free_port):
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"check_user_agent = true",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
r = httpx.post(
f"http://127.0.0.1:{free_port}/",
content=HDLC.frame(b"probe"),
headers={"User-Agent": "Mozilla/5.0"},
timeout=5,
)
assert r.status_code == 403
time.sleep(0.2)
assert owner.packets == []
finally:
iface.detach()
def test_client_default_mode_requires_server_url_when_mode_omitted():
with pytest.raises(ValueError, match="server_url"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = c",
]
),
)
def test_tunnel_mode_case_insensitive_SERVER(free_port):
cfg = configobj_from_lines(
[
"name = srv",
"mode = SERVER",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
]
)
iface = HTTPTunnelInterface(RecordingOwner(), cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
assert iface.mode == "server"
finally:
iface.detach()
def test_process_outgoing_exact_mtu_accepted(free_port):
owner = RecordingOwner()
mtu = 80
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
f"mtu = {mtu}",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
payload = b"p" * mtu
tx_before = iface.txb
iface.process_outgoing(payload)
assert not iface._send_queue.empty()
assert iface._send_queue.get_nowait() == payload
assert iface.txb == tx_before + mtu
finally:
iface.detach()
def test_process_outgoing_when_offline_does_not_queue(free_port):
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
]
)
iface = HTTPTunnelInterface(RecordingOwner(), cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
iface.detach()
iface.process_outgoing(b"ignored")
assert iface._send_queue.empty()
assert iface.txb == 0
assert iface.online is False
finally:
if iface.online:
iface.detach()
def test_process_incoming_empty_not_forwarded(free_port):
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
prev = iface.rxb
iface.process_incoming(b"")
iface.process_incoming(b"")
assert iface.rxb == prev
assert owner.packets == []
finally:
iface.detach()
def test_server_queued_packets_arrive_as_separate_frames(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
ping_client_to_server(owner_s, client)
server.process_outgoing(b"A")
server.process_outgoing(b"B")
payloads = owner_c.wait_packet_count_at_least(2, timeout=10.0)
assert payloads is not None
assert payloads == [b"A", b"B"]
finally:
client.detach()
server.detach()
def test_sequential_client_out_two_server_packets(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
ping_client_to_server(owner_s, client)
client.process_outgoing(b"\x01first")
client.process_outgoing(b"\x02second")
payloads = owner_s.wait_packet_count_at_least(3, timeout=15.0)
assert payloads is not None
assert payloads[-2:] == [b"\x01first", b"\x02second"]
finally:
client.detach()
server.detach()
def test_check_user_agent_false_accepts_foreign_ua(free_port):
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"check_user_agent = false",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
r = httpx.post(
f"http://127.0.0.1:{free_port}/",
content=HDLC.frame(b"foreign"),
headers={"User-Agent": "ForeignAgent/9"},
timeout=5,
)
assert r.status_code == 200
assert owner.last_payload(timeout=6.0) == b"foreign"
finally:
iface.detach()
def test_wrong_post_path_404_never_queues(free_port):
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
r = httpx.post(
f"http://127.0.0.1:{free_port}/bogus",
content=HDLC.frame(b"x"),
headers={"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT},
timeout=5,
)
assert r.status_code == 404
time.sleep(0.25)
assert owner.packets == []
finally:
iface.detach()
def test_serve_html_on_get_when_configured(tmp_path, free_port):
html_path = tmp_path / "stub.html"
html_path.write_text("<title>t</title><p>h</p>", encoding="utf-8")
owner = RecordingOwner()
cfg = configobj_from_lines(
[
"name = srv",
"mode = server",
"listen_host = 127.0.0.1",
f"listen_port = {free_port}",
"serve_html_page = true",
f"html_file_path = {html_path}",
]
)
iface = HTTPTunnelInterface(owner, cfg)
try:
wait_tcp_open("127.0.0.1", free_port)
r = httpx.get(f"http://127.0.0.1:{free_port}/", timeout=5)
assert r.status_code == 200
assert b"<title>t</title>" in r.content
finally:
iface.detach()
def test_client_requests_use_configured_user_agent(free_port):
custom_ua = "CustomTunnelAgent/2.0"
owner_s, owner_c, server, client = make_pair(
free_port,
user_agent=custom_ua,
)
# rebuild client with matching UA
client.detach()
url = f"http://127.0.0.1:{free_port}/"
client = HTTPTunnelInterface(
owner_c,
configobj_from_lines(
[
"name = cli",
"mode = client",
f"server_url = {url}",
"poll_interval = 0.05",
f"user_agent = {custom_ua}",
]
),
)
try:
ua = client.session.headers["User-Agent"]
assert ua == custom_ua
client.process_outgoing(b"ua-ok")
assert owner_s.last_payload(timeout=8.0) == b"ua-ok"
finally:
client.detach()
server.detach()
def test_counters_after_tunnel_roundtrip_stay_consistent(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
p = b"dcounter-test"
client.process_outgoing(p)
assert owner_s.last_payload(timeout=8.0) == p
assert client.txb >= len(p)
assert server.rxb >= len(p)
reply = b"back-at-you"
time.sleep(0.05)
server.process_outgoing(reply)
assert owner_c.last_payload(timeout=8.0) == reply
assert server.txb >= len(reply)
assert client.rxb >= len(reply)
finally:
client.detach()
server.detach()
def test_subclass_matches_rns_interface():
from RNS.Interfaces.Interface import Interface
assert issubclass(HTTPTunnelInterface, Interface)
assert http_mod.interface_class is HTTPTunnelInterface
def test_payloads_with_flag_and_esc_bytes_survive_roundtrip(free_port):
owner_s, owner_c, server, client = make_pair(free_port)
try:
payload = bytes([HDLC.FLAG, 0x00, HDLC.ESC, 0xFF, HDLC.FLAG, HDLC.ESC])
client.process_outgoing(payload)
assert owner_s.last_payload(timeout=8.0) == payload
finally:
client.detach()
server.detach()
def test_client_reuses_single_pooled_tcp_connection(free_port):
owner_s, owner_c, server, client = make_pair(free_port, poll_interval=0.05)
try:
expected = []
for i in range(8):
payload = f"pool-{i}".encode()
expected.append(payload)
client.process_outgoing(payload)
payloads = owner_s.wait_packet_count_at_least(8, timeout=15.0)
assert payloads is not None
assert payloads[-8:] == expected
deadline = time.monotonic() + 5.0
stats = {}
while time.monotonic() < deadline:
stats = server.connection_stats()
if stats.get("http_requests", 0) >= 8 and stats.get("tcp_accepts", 0) >= 1:
break
time.sleep(0.05)
assert stats["http_requests"] >= 8
# wait_tcp_open probes once, then the client should reuse a single TCP session
assert stats["tcp_accepts"] <= 2
assert stats["http_requests"] > stats["tcp_accepts"]
cstats = client.connection_stats()
assert cstats.get("client_requests", 0) >= 8
assert cstats.get("last_http_version") == "HTTP/1.1"
assert client.http_version == 1
finally:
client.detach()
server.detach()
def test_server_advertises_http11_keepalive_headers(free_port):
owner_s, owner_c, server, client = make_pair(
free_port,
poll_interval=0.05,
keepalive_timeout=45,
)
try:
ping_client_to_server(owner_s, client)
r = httpx.post(
f"http://127.0.0.1:{free_port}/",
content=HDLC.frame(b"hdr"),
headers={
"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT,
"Connection": "keep-alive",
},
timeout=5,
)
assert r.status_code == 200
assert r.headers.get("Connection", "").lower() == "keep-alive"
assert "timeout=45" in r.headers.get("Keep-Alive", "")
finally:
client.detach()
server.detach()
def test_invalid_pool_size_rejected():
with pytest.raises(ValueError, match="pool_connections"):
HTTPTunnelInterface(
RecordingOwner(),
configobj_from_lines(
[
"name = c",
"mode = client",
"server_url = http://127.0.0.1:9/",
"pool_maxsize = 0",
]
),
)

View file

@ -0,0 +1,266 @@
"""Live tests with full Reticulum instances over HTTPTunnelInterface.
Spawns isolated server and client shared instances in subprocesses, exchanges
traffic over the HTTP tunnel, and verifies a local program can attach to the
server shared instance via LocalInterface.
"""
from __future__ import annotations
import json
import os
import shutil
import socket
import subprocess
import sys
import textwrap
import time
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
INTERFACE_SRC = REPO_ROOT / "HTTPInterface.py"
PEER_HELPER = Path(__file__).resolve().parent / "_live_rns_peer.py"
def free_tcp_port():
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def write_rns_config(
configdir: Path,
*,
instance_name: str,
shared_port: int,
control_port: int,
http_mode: str,
listen_port: int | None = None,
server_url: str | None = None,
):
configdir.mkdir(parents=True, exist_ok=True)
interfaces_dir = configdir / "interfaces"
interfaces_dir.mkdir(exist_ok=True)
shutil.copy2(INTERFACE_SRC, interfaces_dir / "HTTPInterface.py")
if http_mode == "server":
http_block = textwrap.dedent(
f"""
[[HTTP Tunnel]]
type = HTTPInterface
enabled = yes
mode = server
listen_host = 127.0.0.1
listen_port = {listen_port}
mtu = 4096
poll_interval = 0.05
check_user_agent = yes
"""
)
else:
http_block = textwrap.dedent(
f"""
[[HTTP Tunnel]]
type = HTTPInterface
enabled = yes
mode = client
server_url = {server_url}
mtu = 4096
poll_interval = 0.05
"""
)
config = textwrap.dedent(
f"""
[reticulum]
enable_transport = Yes
share_instance = Yes
instance_name = {instance_name}
shared_instance_port = {shared_port}
instance_control_port = {control_port}
shared_instance_type = tcp
[logging]
loglevel = 3
[interfaces]
[[Default Interface]]
type = AutoInterface
enabled = No
{http_block}
"""
)
(configdir / "config").write_text(config, encoding="utf-8")
def wait_file(path: Path, timeout=30.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if path.is_file() and path.stat().st_size > 0:
return path.read_text(encoding="utf-8").strip()
time.sleep(0.1)
pytest.fail(f"timed out waiting for {path}")
def start_peer(args, env=None):
full_env = os.environ.copy()
if env:
full_env.update(env)
# Ensure repo root is importable if needed
full_env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + full_env.get("PYTHONPATH", "")
return subprocess.Popen(
[sys.executable, str(PEER_HELPER), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=full_env,
cwd=str(REPO_ROOT),
)
def terminate(proc: subprocess.Popen, timeout=8.0):
if proc.poll() is not None:
return
proc.terminate()
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
@pytest.fixture
def live_dirs(tmp_path):
server_dir = tmp_path / "rns_server"
client_dir = tmp_path / "rns_client"
status_dir = tmp_path / "status"
status_dir.mkdir()
return server_dir, client_dir, status_dir
@pytest.mark.live
def test_live_rns_http_tunnel_link_and_local_client(live_dirs):
server_dir, client_dir, status_dir = live_dirs
http_port = free_tcp_port()
server_shared = free_tcp_port()
server_control = free_tcp_port()
client_shared = free_tcp_port()
client_control = free_tcp_port()
write_rns_config(
server_dir,
instance_name="http-live-server",
shared_port=server_shared,
control_port=server_control,
http_mode="server",
listen_port=http_port,
)
write_rns_config(
client_dir,
instance_name="http-live-client",
shared_port=client_shared,
control_port=client_control,
http_mode="client",
server_url=f"http://127.0.0.1:{http_port}/",
)
server_announce = status_dir / "server_announce.hash"
server_recv = status_dir / "server_recv.json"
client_done = status_dir / "client_done.json"
local_done = status_dir / "local_done.json"
server_proc = start_peer(
[
"serve",
"--configdir",
str(server_dir),
"--announce-file",
str(server_announce),
"--recv-file",
str(server_recv),
]
)
try:
dest_hash = wait_file(server_announce, timeout=45.0)
# Allow HTTP listener to come up
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", http_port), timeout=0.3):
break
except OSError:
time.sleep(0.1)
else:
err = server_proc.stderr.read().decode("utf-8", errors="replace") if server_proc.stderr else ""
pytest.fail(f"HTTP server port did not open\n{err}")
client_proc = start_peer(
[
"client",
"--configdir",
str(client_dir),
"--target-hash",
dest_hash,
"--done-file",
str(client_done),
"--payload",
"live-http-rns-ok",
]
)
try:
done_raw = wait_file(client_done, timeout=90.0)
done = json.loads(done_raw)
assert done.get("ok") is True, done
recv_raw = wait_file(server_recv, timeout=30.0)
recv = json.loads(recv_raw)
assert recv.get("payload") == "live-http-rns-ok"
# Local program attaches to the server shared instance
local_proc = start_peer(
[
"local_client",
"--configdir",
str(server_dir),
"--done-file",
str(local_done),
"--expect-hash",
dest_hash,
]
)
local = {}
try:
local_raw = wait_file(local_done, timeout=45.0)
local = json.loads(local_raw)
assert local.get("ok") is True, local
assert local.get("connected_to_shared") is True
finally:
terminate(local_proc)
out, err = local_proc.communicate(timeout=5)
if not local.get("ok"):
pytest.fail(
"local client failed\n"
+ out.decode("utf-8", errors="replace")
+ err.decode("utf-8", errors="replace")
)
finally:
terminate(client_proc)
cout, cerr = client_proc.communicate(timeout=5)
if not client_done.is_file():
pytest.fail(
"client peer failed\n"
+ cout.decode("utf-8", errors="replace")
+ cerr.decode("utf-8", errors="replace")
)
finally:
terminate(server_proc)
sout, serr = server_proc.communicate(timeout=5)
if not server_announce.is_file():
pytest.fail(
"server peer failed\n"
+ sout.decode("utf-8", errors="replace")
+ serr.decode("utf-8", errors="replace")
)

51
vendor/rns_over_http/tests/tls_certs.py vendored Normal file
View file

@ -0,0 +1,51 @@
"""Helpers for generating short-lived self-signed TLS material in tests."""
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
def write_self_signed_cert(directory, common_name="localhost"):
"""Write cert.pem and key.pem into directory. Return (cert_path, key_path)."""
directory = Path(directory)
directory.mkdir(parents=True, exist_ok=True)
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
subject = issuer = x509.Name(
[x509.NameAttribute(NameOID.COMMON_NAME, common_name)]
)
now = datetime.now(timezone.utc)
cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.add_extension(
x509.SubjectAlternativeName(
[
x509.DNSName("localhost"),
x509.IPAddress(__import__("ipaddress").IPv4Address("127.0.0.1")),
]
),
critical=False,
)
.sign(key, hashes.SHA256())
)
cert_path = directory / "cert.pem"
key_path = directory / "key.pem"
cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
key_path.write_bytes(
key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
return cert_path, key_path