mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: integrate RNS FileSync functionality, including handler setup, API endpoints, and frontend components for file synchronization
This commit is contained in:
parent
a9cc4b996b
commit
9f71f3420c
130 changed files with 9412 additions and 112 deletions
|
|
@ -6,6 +6,13 @@ screenshots/
|
|||
vendor/lxmfy/tests/
|
||||
vendor/lxmfy/docs/
|
||||
vendor/lxmfy/.gitea/
|
||||
vendor/rns_filesync/tests/
|
||||
vendor/rns_filesync/docs/
|
||||
vendor/rns_filesync/docker/
|
||||
vendor/rns_filesync/packaging/
|
||||
vendor/rns_filesync/sideband/
|
||||
vendor/rns_filesync/man/
|
||||
vendor/rns_filesync/scripts/
|
||||
vendor/offline/
|
||||
|
||||
# Development files
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ recursive-exclude vendor/offline *
|
|||
recursive-exclude vendor/lxmfy/tests *
|
||||
recursive-exclude vendor/lxmfy/docs *
|
||||
recursive-exclude vendor/lxmfy/docker *
|
||||
recursive-exclude vendor/rns_filesync/tests *
|
||||
recursive-exclude vendor/rns_filesync/docs *
|
||||
recursive-exclude vendor/rns_filesync/docker *
|
||||
recursive-exclude vendor/rns_filesync/packaging *
|
||||
recursive-exclude vendor/rns_filesync/sideband *
|
||||
recursive-exclude vendor/rns_filesync/man *
|
||||
recursive-exclude vendor/rns_filesync/scripts *
|
||||
recursive-exclude * __pycache__
|
||||
recursive-exclude * *.py[co]
|
||||
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ tasks:
|
|||
tests/backend/test_rns_link_manager.py
|
||||
tests/backend/test_rns_link_fuzzing.py
|
||||
tests/backend/test_rns_link_plugin.py
|
||||
tests/backend/test_rns_filesync_security.py
|
||||
-q
|
||||
- task: test:eect
|
||||
|
||||
|
|
@ -283,6 +284,11 @@ tasks:
|
|||
cmds:
|
||||
- uv run pytest tests/backend/eect/packs -m eect -q --tb=short
|
||||
|
||||
test:filesync:security:
|
||||
desc: Adversarial and Hypothesis fuzz tests for RNS FileSync
|
||||
cmds:
|
||||
- uv run pytest tests/backend/test_rns_filesync_security.py tests/backend/test_rns_filesync_handler.py -q --tb=short
|
||||
|
||||
test:lv:
|
||||
desc: Live Validation ladder (set MESHCHAT_LIVE_VALIDATION=1 for L2-L3)
|
||||
cmds:
|
||||
|
|
|
|||
1
android/.gitignore
vendored
1
android/.gitignore
vendored
|
|
@ -17,6 +17,7 @@ local.properties
|
|||
# Gradle-synced Chaquopy Python tree (see app/build.gradle)
|
||||
/app/src/main/python/meshchatx/
|
||||
/app/src/main/python/lxmfy/
|
||||
/app/src/main/python/rns_filesync/
|
||||
|
||||
# libcodec2.so copied from vendor wheels (scripts/android/sync-codec2-jni-libs.sh)
|
||||
/app/src/main/jniLibs/*/libcodec2.so
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ cd android
|
|||
./gradlew --no-daemon :app:assembleDebug :app:assembleRelease
|
||||
```
|
||||
|
||||
There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
|
||||
There is a **single** application variant (no product flavors). Gradle syncs the **entire** `meshchatx/` tree into `app/src/main/python/meshchatx/` (including `public/repository-server-bundled` for the in-app repository server), and syncs vendored **`vendor/lxmfy/lxmfy`** into `app/src/main/python/lxmfy/` (required for bots; not installed via Chaquopy pip) plus **`vendor/rns_filesync/rns_filesync`** into `app/src/main/python/rns_filesync/` (required for FileSync). The `fetchRepositoryBundledWheels` task runs before sync when bundled wheels are missing; if repo root `dist/reticulum_meshchatx-*.whl` exists (e.g. from `python -m build --wheel -o dist .`), that wheel is preferred over PyPI for the bundled set.
|
||||
|
||||
### Native ABIs (universal APK)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ plugins {
|
|||
def repoRoot = rootProject.projectDir.parentFile
|
||||
def meshchatSourceDir = new File(repoRoot, "meshchatx")
|
||||
def lxmfySourceDir = new File(repoRoot, "vendor/lxmfy/lxmfy")
|
||||
def rnsFilesyncSourceDir = new File(repoRoot, "vendor/rns_filesync/rns_filesync")
|
||||
def repositoryBundledWheelsDir = new File(meshchatSourceDir, "public/repository-server-bundled/bundled")
|
||||
def chaquopyBuildPython = System.getenv("CHAQUOPY_BUILD_PYTHON")
|
||||
def pythonTempDir = new File(buildDir, "python-tmp")
|
||||
|
|
@ -268,9 +269,37 @@ tasks.register("syncLxmfyPython", Sync) {
|
|||
into(file("$projectDir/src/main/python/lxmfy"))
|
||||
}
|
||||
|
||||
/*
|
||||
* Desktop installs rns_filesync via setuptools (pyproject where includes vendor/rns_filesync).
|
||||
* Chaquopy only sees android/app/src/main/python plus pip installs, so the
|
||||
* vendored package must be synced here or FileSync imports fail at boot.
|
||||
*/
|
||||
tasks.register("syncRnsFilesyncPython", Sync) {
|
||||
if (!rnsFilesyncSourceDir.exists()) {
|
||||
throw new org.gradle.api.GradleException(
|
||||
"Missing vendored rns_filesync package at ${rnsFilesyncSourceDir}. " +
|
||||
"Expected vendor/rns_filesync/rns_filesync from the MeshChatX tree."
|
||||
)
|
||||
}
|
||||
def initPy = new File(rnsFilesyncSourceDir, "__init__.py")
|
||||
if (!initPy.exists()) {
|
||||
throw new org.gradle.api.GradleException("Missing ${initPy}; vendored rns_filesync tree looks incomplete.")
|
||||
}
|
||||
doFirst {
|
||||
delete(file("$projectDir/src/main/python/rns_filesync"))
|
||||
}
|
||||
from(rnsFilesyncSourceDir)
|
||||
includeEmptyDirs = false
|
||||
into(file("$projectDir/src/main/python/rns_filesync"))
|
||||
}
|
||||
|
||||
tasks.configureEach { task ->
|
||||
if (task.name.toLowerCase().contains("merge") && task.name.toLowerCase().contains("pythonsources")) {
|
||||
task.dependsOn(tasks.named("syncMeshchatPython"), tasks.named("syncLxmfyPython"))
|
||||
task.dependsOn(
|
||||
tasks.named("syncMeshchatPython"),
|
||||
tasks.named("syncLxmfyPython"),
|
||||
tasks.named("syncRnsFilesyncPython"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ ROOT = Path(__file__).resolve().parent
|
|||
_vendor_lxmfy = ROOT / "vendor" / "lxmfy"
|
||||
if _vendor_lxmfy.is_dir() and (_vendor_lxmfy / "lxmfy").is_dir():
|
||||
sys.path.insert(0, str(_vendor_lxmfy))
|
||||
_vendor_rns_filesync = ROOT / "vendor" / "rns_filesync"
|
||||
if _vendor_rns_filesync.is_dir() and (_vendor_rns_filesync / "rns_filesync").is_dir():
|
||||
sys.path.insert(0, str(_vendor_rns_filesync))
|
||||
|
||||
from cx_Freeze import Executable, setup # noqa: E402
|
||||
|
||||
|
|
@ -59,6 +62,7 @@ packages = [
|
|||
"LXMF",
|
||||
"LXST",
|
||||
"lxmfy",
|
||||
"rns_filesync",
|
||||
"websockets",
|
||||
"pycparser",
|
||||
"cffi",
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ Critical lifecycle facts:
|
|||
| `tests/frontend/` | vitest |
|
||||
| `tests/e2e/` | Playwright |
|
||||
| `docs/en/` | In-app / shipped English docs |
|
||||
| `vendor/` | Vendored deps (for example LXMFy) |
|
||||
| `vendor/` | Vendored deps (LXMFy, RNS FileSync) |
|
||||
| `Taskfile.yml` | Preferred command entrypoints |
|
||||
| `docs/agents/` | Agent guidance (this tree) |
|
||||
| `AGENTS.md` | Short pointer to `docs/agents/` |
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Keep Chaquopy backend boot, WebView file choosers, storage locks, and external n
|
|||
## Navigation and packaging
|
||||
|
||||
- External http(s) links open in the system browser. Do not navigate the WebView away from the app.
|
||||
- Vendored `lxmfy` is synced into Chaquopy `src/main/python/`. Android pip does not install it like desktop setuptools.
|
||||
- Vendored `lxmfy` and `rns_filesync` are synced into Chaquopy `src/main/python/`. Android pip does not install them like desktop setuptools.
|
||||
- RNS panic containment matters on Android (see `deferred-network-startup`).
|
||||
|
||||
## RNode on Android
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
|
|||
|
||||
## File transfer and shell
|
||||
|
||||
| Tool | Purpose |
|
||||
| ---- | ------------------------------------------ |
|
||||
| RNCP | Send or fetch files over Reticulum |
|
||||
| RNSH | Remote shell sessions with streamed output |
|
||||
| Tool | Purpose |
|
||||
| ------------ | ---------------------------------------------------- |
|
||||
| RNCP | Send or fetch files over Reticulum |
|
||||
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
|
||||
| RNSH | Remote shell sessions with streamed output |
|
||||
|
||||
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
|
||||
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
|
||||
FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
|
||||
|
||||
## Messaging helpers
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
|
|||
|
||||
## Coming soon
|
||||
|
||||
The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
|
||||
The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
|
||||
|
||||
## Relay chat server
|
||||
|
||||
|
|
|
|||
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -741,6 +741,17 @@ class ReticulumMeshChat:
|
|||
if self.current_context:
|
||||
self.current_context.rncp_handler = value
|
||||
|
||||
@property
|
||||
def rns_filesync_handler(self):
|
||||
return (
|
||||
self.current_context.rns_filesync_handler if self.current_context else None
|
||||
)
|
||||
|
||||
@rns_filesync_handler.setter
|
||||
def rns_filesync_handler(self, value):
|
||||
if self.current_context:
|
||||
self.current_context.rns_filesync_handler = value
|
||||
|
||||
@property
|
||||
def rnsh_manager(self):
|
||||
return self.current_context.rnsh_manager if self.current_context else None
|
||||
|
|
@ -2892,6 +2903,15 @@ class ReticulumMeshChat:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
if top_level == "rns_filesync":
|
||||
try:
|
||||
module = importlib.import_module("rns_filesync")
|
||||
ver = getattr(module, "__version__", None)
|
||||
if ver:
|
||||
return str(ver)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
embedded_specs: dict[str, tuple[str, str]] = {
|
||||
"aiohttp": ("aiohttp", "__version__"),
|
||||
"aiohttp-session": ("aiohttp_session", "__version__"),
|
||||
|
|
@ -2901,6 +2921,8 @@ class ReticulumMeshChat:
|
|||
"bcrypt": ("bcrypt", "__version__"),
|
||||
"ply": ("ply", "__version__"),
|
||||
"lxmfy": ("lxmfy", "__version__"),
|
||||
"rns-filesync": ("rns_filesync", "__version__"),
|
||||
"rns_filesync": ("rns_filesync", "__version__"),
|
||||
}
|
||||
if package_name in embedded_specs:
|
||||
mod_name, attr = embedded_specs[package_name]
|
||||
|
|
@ -8082,6 +8104,7 @@ class ReticulumMeshChat:
|
|||
"ply": self.get_package_version("ply"),
|
||||
"bcrypt": self.get_package_version("bcrypt"),
|
||||
"lxmfy": self.get_package_version("lxmfy"),
|
||||
"rns_filesync": self.get_package_version("rns-filesync"),
|
||||
},
|
||||
"storage_path": self.storage_path,
|
||||
"database_path": _safe_database_path(),
|
||||
|
|
@ -13541,6 +13564,250 @@ class ReticulumMeshChat:
|
|||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
|
||||
# --- RNS FileSync ---
|
||||
|
||||
def _filesync_require_handler():
|
||||
return self._require_rns_tool_handler(
|
||||
self.rns_filesync_handler,
|
||||
"RNS FileSync",
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/filesync/status")
|
||||
async def filesync_status(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
return web.json_response(self.rns_filesync_handler.get_status())
|
||||
|
||||
@routes.post("/api/v1/filesync/start")
|
||||
async def filesync_start(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = {}
|
||||
with contextlib.suppress(Exception):
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.start,
|
||||
sync_directory=data.get("sync_directory"),
|
||||
monitor=data.get("monitor"),
|
||||
announce_interval=data.get("announce_interval"),
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "failed to start")},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/api/v1/filesync/stop")
|
||||
async def filesync_stop(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
try:
|
||||
result = await asyncio.to_thread(self.rns_filesync_handler.stop)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.get("/api/v1/filesync/peers")
|
||||
async def filesync_peers(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
return web.json_response({"peers": self.rns_filesync_handler.list_peers()})
|
||||
|
||||
@routes.get("/api/v1/filesync/files")
|
||||
async def filesync_files(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
return web.json_response({"files": self.rns_filesync_handler.list_files()})
|
||||
|
||||
@routes.post("/api/v1/filesync/connect")
|
||||
async def filesync_connect(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
identity_hash = data.get("identity_hash", "")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.connect_peer,
|
||||
identity_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "connect failed"), **result},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/api/v1/filesync/disconnect")
|
||||
async def filesync_disconnect(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
peer_id = data.get("peer_id", "")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.disconnect_peer,
|
||||
peer_id,
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "disconnect failed")},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/api/v1/filesync/announce")
|
||||
async def filesync_announce(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
try:
|
||||
result = await asyncio.to_thread(self.rns_filesync_handler.announce_now)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "announce failed")},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/api/v1/filesync/browse")
|
||||
async def filesync_browse(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
peer_id = data.get("peer_id", "")
|
||||
timeout = data.get("timeout", 10.0)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.browse_peer,
|
||||
peer_id,
|
||||
timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{
|
||||
"message": result.get("error", "browse failed"),
|
||||
"files": result.get("files", []),
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.post("/api/v1/filesync/download")
|
||||
async def filesync_download(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
peer_id = data.get("peer_id", "")
|
||||
path = data.get("path", "")
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.download_file,
|
||||
peer_id,
|
||||
path,
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "download failed"), **result},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.get("/api/v1/filesync/acl")
|
||||
async def filesync_acl_get(_request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
return web.json_response(self.rns_filesync_handler.get_acl())
|
||||
|
||||
@routes.post("/api/v1/filesync/acl")
|
||||
async def filesync_acl_post(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
perms = data.get("perms")
|
||||
if perms is not None and not isinstance(perms, list):
|
||||
return web.json_response(
|
||||
{"message": "perms must be a list"},
|
||||
status=400,
|
||||
)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.update_acl,
|
||||
identity_hash=data.get("identity_hash"),
|
||||
perms=perms,
|
||||
enforce=data.get("enforce"),
|
||||
rules_text=data.get("rules_text"),
|
||||
replace=bool(data.get("replace", False)),
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "acl update failed")},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
@routes.patch("/api/v1/filesync/settings")
|
||||
async def filesync_settings(request):
|
||||
not_ready = _filesync_require_handler()
|
||||
if not_ready is not None:
|
||||
return not_ready
|
||||
data = await request.json()
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response({"message": "Invalid JSON body"}, status=400)
|
||||
try:
|
||||
result = await asyncio.to_thread(
|
||||
self.rns_filesync_handler.update_settings,
|
||||
sync_directory=data.get("sync_directory"),
|
||||
monitor=data.get("monitor"),
|
||||
announce_interval=data.get("announce_interval"),
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
if not result.get("ok"):
|
||||
return web.json_response(
|
||||
{"message": result.get("error", "settings update failed")},
|
||||
status=400,
|
||||
)
|
||||
return web.json_response(result)
|
||||
|
||||
# --- Plugin API ---
|
||||
|
||||
@routes.get("/api/v1/plugins")
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ lxmf 1.0.1
|
|||
lxmfy 1.6.5
|
||||
License: BSD-0-Clause
|
||||
Author: Quad4 <team@quad4.io>
|
||||
rns-filesync 1.0.0
|
||||
License: BSD-2-Clause
|
||||
Author: Sudo-Ivan / Quad4.io
|
||||
lxst 0.5.0
|
||||
License: Other/Proprietary License
|
||||
Author: Mark Qvist
|
||||
|
|
|
|||
|
|
@ -95,6 +95,12 @@
|
|||
"author": "Quad4 <team@quad4.io>",
|
||||
"license": "BSD-0-Clause"
|
||||
},
|
||||
{
|
||||
"name": "rns-filesync",
|
||||
"version": "1.0.0",
|
||||
"author": "Sudo-Ivan / Quad4.io",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
{
|
||||
"name": "lxst",
|
||||
"version": "0.5.0",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ from meshchatx.src.backend.rnpath_handler import RNPathHandler
|
|||
from meshchatx.src.backend.rnpath_trace_handler import RNPathTraceHandler
|
||||
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
|
||||
from meshchatx.src.backend.rnsh_manager import RNSHManager
|
||||
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
|
||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||
from meshchatx.src.backend.rnx_manager import RNXManager
|
||||
from meshchatx.src.backend.rrc import RRCManager, RRCServerManager
|
||||
|
|
@ -93,6 +94,7 @@ class IdentityContext:
|
|||
self.notification_sound_manager = None
|
||||
self.auto_propagation_manager = None
|
||||
self.rncp_handler = None
|
||||
self.rns_filesync_handler = None
|
||||
self.rnsh_manager = None
|
||||
self.rnx_manager = None
|
||||
self.rnstatus_handler = None
|
||||
|
|
@ -135,6 +137,14 @@ class IdentityContext:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _filesync_emit(self, message):
|
||||
try:
|
||||
from meshchatx.src.backend.async_utils import AsyncUtils
|
||||
|
||||
AsyncUtils.run_async(self.app._broadcast_websocket_message(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def setup(self):
|
||||
print(f"Setting up Identity Context for {self.identity_hash}...")
|
||||
|
||||
|
|
@ -306,6 +316,12 @@ class IdentityContext:
|
|||
storage_dir=self.app.storage_dir,
|
||||
)
|
||||
self.rncp_handler.on_receive_completed = self._rncp_emit_receive_completed
|
||||
self.rns_filesync_handler = RnsFilesyncHandler(
|
||||
reticulum_instance=getattr(self.app, "reticulum", None),
|
||||
identity=self.identity,
|
||||
storage_dir=self.storage_path,
|
||||
emit_callback=self._filesync_emit,
|
||||
)
|
||||
self.rnsh_manager = RNSHManager(
|
||||
storage_dir=self.storage_path,
|
||||
reticulum_config_dir=getattr(self.app, "reticulum_config_dir", None),
|
||||
|
|
@ -733,6 +749,11 @@ class IdentityContext:
|
|||
self.rncp_handler.teardown_receive_destination()
|
||||
self.rncp_handler = None
|
||||
|
||||
if self.rns_filesync_handler:
|
||||
with contextlib.suppress(Exception):
|
||||
self.rns_filesync_handler.teardown()
|
||||
self.rns_filesync_handler = None
|
||||
|
||||
self.rnstatus_handler = None
|
||||
self.rnpath_handler = None
|
||||
self.rnpath_trace_handler = None
|
||||
|
|
|
|||
|
|
@ -134,10 +134,19 @@ def _python_roots_from_pyproject(repo_root: Path) -> tuple[str, ...]:
|
|||
return tuple(sorted(set(names), key=lambda n: n.lower()))
|
||||
|
||||
|
||||
def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
|
||||
if _dist_for_requirement_name("lxmfy") is not None:
|
||||
def _bundled_vendor_license_row(
|
||||
repo_root: Path,
|
||||
*,
|
||||
vendor_dir: str,
|
||||
dist_name: str,
|
||||
package_name: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
lookup_name = package_name or dist_name
|
||||
if _dist_for_requirement_name(lookup_name) is not None:
|
||||
return None
|
||||
vp = repo_root / "vendor" / "lxmfy" / "pyproject.toml"
|
||||
if _dist_for_requirement_name(dist_name) is not None:
|
||||
return None
|
||||
vp = repo_root / "vendor" / vendor_dir / "pyproject.toml"
|
||||
if not vp.is_file():
|
||||
return None
|
||||
try:
|
||||
|
|
@ -149,7 +158,7 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
|
|||
if not isinstance(proj, dict):
|
||||
return None
|
||||
name = proj.get("name")
|
||||
if name != "lxmfy":
|
||||
if name != dist_name and name != lookup_name:
|
||||
return None
|
||||
version = proj.get("version")
|
||||
version_s = version.strip() if isinstance(version, str) and version.strip() else "—"
|
||||
|
|
@ -165,36 +174,72 @@ def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
|
|||
elif an or ae:
|
||||
author = an or ae
|
||||
lic = proj.get("license")
|
||||
license_s = lic.strip() if isinstance(lic, str) and lic.strip() else "—"
|
||||
if isinstance(lic, dict):
|
||||
license_s = str(lic.get("text") or lic.get("file") or "—").strip() or "—"
|
||||
elif isinstance(lic, str) and lic.strip():
|
||||
license_s = lic.strip()
|
||||
else:
|
||||
license_s = "—"
|
||||
return {
|
||||
"name": "lxmfy",
|
||||
"name": dist_name,
|
||||
"version": version_s,
|
||||
"author": author,
|
||||
"license": license_s,
|
||||
}
|
||||
|
||||
|
||||
def _bundled_lxmfy_license_row(repo_root: Path) -> dict[str, Any] | None:
|
||||
return _bundled_vendor_license_row(
|
||||
repo_root,
|
||||
vendor_dir="lxmfy",
|
||||
dist_name="lxmfy",
|
||||
)
|
||||
|
||||
|
||||
def _bundled_rns_filesync_license_row(repo_root: Path) -> dict[str, Any] | None:
|
||||
return _bundled_vendor_license_row(
|
||||
repo_root,
|
||||
vendor_dir="rns_filesync",
|
||||
dist_name="rns-filesync",
|
||||
package_name="rns_filesync",
|
||||
)
|
||||
|
||||
|
||||
def _merge_bundled_vendor_rows(
|
||||
repo_root: Path,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
merged = list(rows)
|
||||
existing = {str(r.get("name") or "").lower() for r in merged}
|
||||
for row_fn, names in (
|
||||
(_bundled_lxmfy_license_row, ("lxmfy",)),
|
||||
(_bundled_rns_filesync_license_row, ("rns-filesync", "rns_filesync")),
|
||||
):
|
||||
if any(n in existing for n in names):
|
||||
continue
|
||||
row = row_fn(repo_root)
|
||||
if row is None:
|
||||
continue
|
||||
merged.append(row)
|
||||
existing.add(str(row.get("name") or "").lower())
|
||||
merged.sort(key=lambda r: str(r.get("name", "")).lower())
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_bundled_lxmfy(
|
||||
repo_root: Path,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
if any(str(r.get("name") or "").lower() == "lxmfy" for r in rows):
|
||||
return rows
|
||||
row = _bundled_lxmfy_license_row(repo_root)
|
||||
if row is None:
|
||||
return rows
|
||||
merged = [*rows, row]
|
||||
merged.sort(key=lambda r: str(r.get("name", "")).lower())
|
||||
return merged
|
||||
return _merge_bundled_vendor_rows(repo_root, rows)
|
||||
|
||||
|
||||
def _collect_backend_licenses_live() -> list[dict[str, Any]]:
|
||||
repo = _repo_root()
|
||||
for root in _ROOT_DIST_CANDIDATES:
|
||||
if _dist_for_requirement_name(root) is not None:
|
||||
return _merge_bundled_lxmfy(repo, _collect_python_transitive((root,)))
|
||||
return _merge_bundled_vendor_rows(repo, _collect_python_transitive((root,)))
|
||||
roots = _python_roots_from_pyproject(repo)
|
||||
return _merge_bundled_lxmfy(repo, _collect_python_transitive(roots))
|
||||
return _merge_bundled_vendor_rows(repo, _collect_python_transitive(roots))
|
||||
|
||||
|
||||
def _load_embedded_backend_licenses() -> list[dict[str, Any]] | None:
|
||||
|
|
|
|||
400
meshchatx/src/backend/rns_filesync_handler.py
Normal file
400
meshchatx/src/backend/rns_filesync_handler.py
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Identity-scoped wrapper around vendored rns_filesync.FileSyncService."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT
|
||||
from rns_filesync.permissions import PermissionStore
|
||||
from rns_filesync.service import FileSyncService
|
||||
|
||||
|
||||
class RnsFilesyncHandler:
|
||||
"""Host FileSync against the shared Reticulum stack for one identity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reticulum_instance,
|
||||
identity,
|
||||
storage_dir: str,
|
||||
emit_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
):
|
||||
self.reticulum = reticulum_instance
|
||||
self.identity = identity
|
||||
self.storage_dir = storage_dir
|
||||
self._emit_callback = emit_callback
|
||||
self._lock = threading.RLock()
|
||||
self.service: FileSyncService | None = None
|
||||
|
||||
self._root = os.path.join(storage_dir, "filesync")
|
||||
self._settings_path = os.path.join(self._root, "settings.json")
|
||||
self._acl_path = os.path.join(self._root, "acl.txt")
|
||||
self._sync_directory = os.path.join(self._root, "sync")
|
||||
self._monitor = True
|
||||
self._announce_interval = ANNOUNCE_INTERVAL_DEFAULT
|
||||
self._load_settings()
|
||||
|
||||
def _emit(self, event_type: str, payload: dict[str, Any] | None = None) -> None:
|
||||
if not self._emit_callback:
|
||||
return
|
||||
message = {"type": event_type}
|
||||
if payload:
|
||||
message.update(payload)
|
||||
try:
|
||||
self._emit_callback(message)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _default_sync_directory(self) -> str:
|
||||
return os.path.join(self._root, "sync")
|
||||
|
||||
def _load_settings(self) -> None:
|
||||
os.makedirs(self._root, exist_ok=True)
|
||||
if not os.path.isfile(self._settings_path):
|
||||
return
|
||||
try:
|
||||
with open(self._settings_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
except Exception:
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
sync_dir = data.get("sync_directory")
|
||||
if isinstance(sync_dir, str) and sync_dir.strip():
|
||||
self._sync_directory = os.path.realpath(os.path.expanduser(sync_dir.strip()))
|
||||
monitor = data.get("monitor")
|
||||
if isinstance(monitor, bool):
|
||||
self._monitor = monitor
|
||||
interval = data.get("announce_interval")
|
||||
if isinstance(interval, int) and interval >= 10:
|
||||
self._announce_interval = interval
|
||||
|
||||
def _save_settings(self) -> None:
|
||||
os.makedirs(self._root, exist_ok=True)
|
||||
payload = {
|
||||
"sync_directory": self._sync_directory,
|
||||
"monitor": self._monitor,
|
||||
"announce_interval": self._announce_interval,
|
||||
}
|
||||
tmp = f"{self._settings_path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.replace(tmp, self._settings_path)
|
||||
|
||||
def _load_permissions(self) -> PermissionStore:
|
||||
permissions = PermissionStore()
|
||||
if os.path.isfile(self._acl_path):
|
||||
with contextlib.suppress(Exception):
|
||||
permissions.load_file(self._acl_path)
|
||||
return permissions
|
||||
|
||||
def _save_acl(self, permissions: PermissionStore) -> None:
|
||||
os.makedirs(self._root, exist_ok=True)
|
||||
lines: list[str] = []
|
||||
rules = permissions.as_dict()
|
||||
for perm in ("read", "write", "delete"):
|
||||
for target in rules.get(perm, []):
|
||||
short = {"read": "r", "write": "w", "delete": "d"}[perm]
|
||||
lines.append(f"{short}:{target}")
|
||||
if permissions.enabled and not lines:
|
||||
lines.append("# enforce=true")
|
||||
elif not permissions.enabled:
|
||||
lines.insert(0, "# enforce=false")
|
||||
else:
|
||||
lines.insert(0, "# enforce=true")
|
||||
tmp = f"{self._acl_path}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
os.replace(tmp, self._acl_path)
|
||||
|
||||
def _wire_callbacks(self, service: FileSyncService) -> None:
|
||||
service.on_peer_connected = lambda payload: self._emit(
|
||||
"filesync.peer.connected",
|
||||
payload if isinstance(payload, dict) else {"peer": payload},
|
||||
)
|
||||
service.on_peer_disconnected = lambda payload: self._emit(
|
||||
"filesync.peer.disconnected",
|
||||
payload if isinstance(payload, dict) else {"peer": payload},
|
||||
)
|
||||
service.on_sync_progress = lambda payload: self._emit(
|
||||
"filesync.sync.progress",
|
||||
payload if isinstance(payload, dict) else {"progress": payload},
|
||||
)
|
||||
service.on_file_updated = lambda payload: self._emit(
|
||||
"filesync.file.updated",
|
||||
payload if isinstance(payload, dict) else {"path": payload},
|
||||
)
|
||||
service.on_file_deleted = lambda payload: self._emit(
|
||||
"filesync.file.deleted",
|
||||
payload if isinstance(payload, dict) else {"path": payload},
|
||||
)
|
||||
service.on_error = lambda payload: self._emit(
|
||||
"filesync.error",
|
||||
payload if isinstance(payload, dict) else {"error": str(payload)},
|
||||
)
|
||||
|
||||
def _permissions(self) -> PermissionStore:
|
||||
if self.service is not None:
|
||||
return self.service.permissions
|
||||
return self._load_permissions()
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is not None and self.service.get_status().get("running"):
|
||||
status = self.service.get_status()
|
||||
status["monitor"] = self._monitor
|
||||
status["announce_interval"] = self._announce_interval
|
||||
status["config_directory"] = self._root
|
||||
return status
|
||||
return {
|
||||
"running": False,
|
||||
"sync_directory": self._sync_directory,
|
||||
"identity_hash": (
|
||||
self.identity.hash.hex()
|
||||
if getattr(self.identity, "hash", None) is not None
|
||||
else None
|
||||
),
|
||||
"destination_hash": None,
|
||||
"peers": 0,
|
||||
"files": 0,
|
||||
"whitelist": self._permissions().enabled,
|
||||
"monitor": self._monitor,
|
||||
"announce_interval": self._announce_interval,
|
||||
"config_directory": self._root,
|
||||
}
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
sync_directory: str | None = None,
|
||||
monitor: bool | None = None,
|
||||
announce_interval: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if sync_directory is not None:
|
||||
cleaned = str(sync_directory).strip()
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "sync_directory is required"}
|
||||
self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
|
||||
if monitor is not None:
|
||||
self._monitor = bool(monitor)
|
||||
if announce_interval is not None:
|
||||
try:
|
||||
interval = int(announce_interval)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "invalid announce_interval"}
|
||||
if interval < 10:
|
||||
return {"ok": False, "error": "announce_interval must be >= 10"}
|
||||
self._announce_interval = interval
|
||||
|
||||
if self.service is not None:
|
||||
status = self.service.get_status()
|
||||
if status.get("running"):
|
||||
return {"ok": True, "already_running": True, **status}
|
||||
|
||||
os.makedirs(self._sync_directory, exist_ok=True)
|
||||
permissions = self._load_permissions()
|
||||
if os.path.isfile(self._acl_path):
|
||||
try:
|
||||
with open(self._acl_path, encoding="utf-8") as handle:
|
||||
first = handle.readline().strip()
|
||||
if first == "# enforce=false":
|
||||
permissions._enforce = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
service = FileSyncService(
|
||||
identity=self.identity,
|
||||
sync_directory=self._sync_directory,
|
||||
storage_dir=self._root,
|
||||
reticulum=self.reticulum,
|
||||
permissions=permissions,
|
||||
own_reticulum=False,
|
||||
)
|
||||
self._wire_callbacks(service)
|
||||
dest = service.start(
|
||||
monitor=self._monitor,
|
||||
announce_interval=self._announce_interval,
|
||||
)
|
||||
self.service = service
|
||||
self._save_settings()
|
||||
status = service.get_status()
|
||||
return {
|
||||
"ok": True,
|
||||
"destination_hash": dest,
|
||||
**status,
|
||||
}
|
||||
|
||||
def stop(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": True, "running": False}
|
||||
with contextlib.suppress(Exception):
|
||||
self.service.stop()
|
||||
self.service = None
|
||||
return {"ok": True, "running": False}
|
||||
|
||||
def teardown(self) -> None:
|
||||
self.stop()
|
||||
|
||||
def list_peers(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return []
|
||||
return self.service.list_peers()
|
||||
|
||||
def list_files(self) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return []
|
||||
return self.service.list_files()
|
||||
|
||||
def connect_peer(self, identity_hash: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": False, "error": "filesync is not running"}
|
||||
cleaned = str(identity_hash or "").strip().lower().replace(":", "")
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "identity_hash is required"}
|
||||
return self.service.connect_peer(cleaned)
|
||||
|
||||
def disconnect_peer(self, peer_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": False, "error": "filesync is not running"}
|
||||
cleaned = str(peer_id or "").strip()
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "peer_id is required"}
|
||||
self.service.disconnect_peer(cleaned)
|
||||
return {"ok": True, "peer_id": cleaned}
|
||||
|
||||
def announce_now(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": False, "error": "filesync is not running"}
|
||||
self.service.announce_now()
|
||||
return {"ok": True}
|
||||
|
||||
def browse_peer(self, peer_id: str, timeout: float = 10.0) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": False, "error": "filesync is not running", "files": []}
|
||||
cleaned = str(peer_id or "").strip()
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "peer_id is required", "files": []}
|
||||
try:
|
||||
timeout_f = float(timeout)
|
||||
except (TypeError, ValueError):
|
||||
timeout_f = 10.0
|
||||
files = self.service.browse_peer(cleaned, timeout=timeout_f)
|
||||
return {"ok": True, "peer_id": cleaned, "files": files}
|
||||
|
||||
def download_file(self, peer_id: str, path: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if self.service is None:
|
||||
return {"ok": False, "error": "filesync is not running"}
|
||||
cleaned_peer = str(peer_id or "").strip()
|
||||
cleaned_path = str(path or "").strip()
|
||||
if not cleaned_peer:
|
||||
return {"ok": False, "error": "peer_id is required"}
|
||||
if not cleaned_path:
|
||||
return {"ok": False, "error": "path is required"}
|
||||
return self.service.download_file(cleaned_peer, cleaned_path)
|
||||
|
||||
def get_acl(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
permissions = self._permissions()
|
||||
return {
|
||||
"enforce": permissions.enabled,
|
||||
"rules": permissions.as_dict(),
|
||||
}
|
||||
|
||||
def update_acl(
|
||||
self,
|
||||
*,
|
||||
identity_hash: str | None = None,
|
||||
perms: list[str] | None = None,
|
||||
enforce: bool | None = None,
|
||||
rules_text: str | None = None,
|
||||
replace: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
if replace or rules_text is not None:
|
||||
permissions = PermissionStore()
|
||||
if rules_text:
|
||||
permissions.load_allowed_text(rules_text)
|
||||
else:
|
||||
permissions = self._permissions()
|
||||
if self.service is None:
|
||||
# Work on a fresh copy so we can persist even when stopped.
|
||||
permissions = self._load_permissions()
|
||||
|
||||
if identity_hash and perms is not None:
|
||||
granted = permissions.grant(identity_hash, perms)
|
||||
if not granted and perms:
|
||||
return {"ok": False, "error": "no valid permissions provided"}
|
||||
|
||||
if enforce is not None:
|
||||
permissions._enforce = bool(enforce)
|
||||
|
||||
if self.service is not None:
|
||||
self.service.permissions = permissions
|
||||
|
||||
self._save_acl(permissions)
|
||||
return {
|
||||
"ok": True,
|
||||
"enforce": permissions.enabled,
|
||||
"rules": permissions.as_dict(),
|
||||
}
|
||||
|
||||
def update_settings(
|
||||
self,
|
||||
*,
|
||||
sync_directory: str | None = None,
|
||||
monitor: bool | None = None,
|
||||
announce_interval: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
running = False
|
||||
if self.service is not None:
|
||||
running = bool(self.service.get_status().get("running"))
|
||||
|
||||
if sync_directory is not None:
|
||||
if running:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "stop filesync before changing sync directory",
|
||||
}
|
||||
cleaned = str(sync_directory).strip()
|
||||
if not cleaned:
|
||||
return {"ok": False, "error": "sync_directory is required"}
|
||||
self._sync_directory = os.path.realpath(os.path.expanduser(cleaned))
|
||||
|
||||
if monitor is not None:
|
||||
self._monitor = bool(monitor)
|
||||
|
||||
if announce_interval is not None:
|
||||
try:
|
||||
interval = int(announce_interval)
|
||||
except (TypeError, ValueError):
|
||||
return {"ok": False, "error": "invalid announce_interval"}
|
||||
if interval < 10:
|
||||
return {"ok": False, "error": "announce_interval must be >= 10"}
|
||||
self._announce_interval = interval
|
||||
|
||||
self._save_settings()
|
||||
return {
|
||||
"ok": True,
|
||||
"sync_directory": self._sync_directory,
|
||||
"monitor": self._monitor,
|
||||
"announce_interval": self._announce_interval,
|
||||
"running": running,
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ _CRITICAL_IMPORTS = (
|
|||
"RNS",
|
||||
"LXMF",
|
||||
"lxmfy",
|
||||
"rns_filesync",
|
||||
"aiohttp",
|
||||
"bcrypt",
|
||||
"cbor2",
|
||||
|
|
|
|||
620
meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
Normal file
620
meshchatx/src/frontend/components/filesync/RnsFilesyncPage.vue
Normal file
|
|
@ -0,0 +1,620 @@
|
|||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
|
||||
<ToolsPageHeader
|
||||
icon="folder-sync"
|
||||
:title="$t('rns_filesync.title')"
|
||||
:description="$t('rns_filesync.description')"
|
||||
:eyebrow="$t('rns_filesync.eyebrow')"
|
||||
accent="green"
|
||||
/>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div class="space-y-4 w-full max-w-4xl mx-auto">
|
||||
<div class="glass-card space-y-5">
|
||||
<div
|
||||
class="p-4 rounded-lg bg-emerald-50/50 dark:bg-emerald-900/10 border border-emerald-100 dark:border-emerald-900/20"
|
||||
>
|
||||
<div
|
||||
class="text-xs font-bold uppercase tracking-wider text-emerald-600 dark:text-emerald-400 mb-2"
|
||||
>
|
||||
{{ $t("rns_filesync.usage_steps") }}
|
||||
</div>
|
||||
<div class="space-y-1.5 text-xs text-emerald-800/80 dark:text-emerald-300/80 leading-relaxed">
|
||||
<p>{{ $t("rns_filesync.step_1") }}</p>
|
||||
<p>{{ $t("rns_filesync.step_2") }}</p>
|
||||
<p>{{ $t("rns_filesync.step_3") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-b border-gray-200 dark:border-zinc-700 overflow-x-auto overscroll-x-contain -mx-4 px-4 sm:mx-0 sm:px-0"
|
||||
>
|
||||
<div class="flex w-max min-w-full sm:w-auto gap-1 sm:gap-2">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'border-b-2 border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-gray-600 dark:text-gray-400',
|
||||
'shrink-0 px-3 sm:px-4 py-2 text-sm font-semibold transition',
|
||||
]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ $t(tab.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'status'" class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="glass-label">{{ $t("rns_filesync.sync_directory") }}</label>
|
||||
<input
|
||||
v-model="syncDirectory"
|
||||
type="text"
|
||||
class="glass-input w-full font-mono text-sm"
|
||||
:disabled="status.running"
|
||||
:placeholder="$t('rns_filesync.sync_directory_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="glass-label">{{ $t("rns_filesync.announce_interval") }}</label>
|
||||
<input
|
||||
v-model.number="announceInterval"
|
||||
type="number"
|
||||
min="10"
|
||||
class="glass-input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="monitor" type="checkbox" class="rounded" />
|
||||
{{ $t("rns_filesync.monitor") }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="!status.running"
|
||||
type="button"
|
||||
class="primary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy"
|
||||
@click="startService"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
|
||||
{{ $t("rns_filesync.start") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
|
||||
:disabled="busy"
|
||||
@click="stopService"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="stop" class="w-4 h-4" />
|
||||
{{ $t("rns_filesync.stop") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy || !status.running"
|
||||
@click="announceNow"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="bullhorn" class="w-4 h-4" />
|
||||
{{ $t("rns_filesync.announce") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy"
|
||||
@click="refreshStatus"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
|
||||
{{ $t("rns_filesync.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>
|
||||
{{ $t("rns_filesync.running") }}:
|
||||
<strong>{{ status.running ? $t("rns_filesync.yes") : $t("rns_filesync.no") }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{{ $t("rns_filesync.peers_count") }}:
|
||||
<strong>{{ status.peers || 0 }}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{{ $t("rns_filesync.files_count") }}:
|
||||
<strong>{{ status.files || 0 }}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="status.destination_hash" class="font-mono text-xs break-all">
|
||||
<div class="font-semibold mb-1">{{ $t("rns_filesync.destination_hash") }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-left hover:underline"
|
||||
@click="copyHash(status.destination_hash)"
|
||||
>
|
||||
{{ status.destination_hash }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="lastProgress" class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("rns_filesync.last_progress") }}: {{ lastProgress }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'peers'" class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
v-model="connectHash"
|
||||
type="text"
|
||||
class="glass-input flex-1 font-mono text-sm"
|
||||
:placeholder="$t('rns_filesync.peer_hash_placeholder')"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy || !status.running"
|
||||
@click="connectPeer"
|
||||
>
|
||||
{{ $t("rns_filesync.connect") }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-1.5 text-sm"
|
||||
:disabled="busy"
|
||||
@click="refreshPeers"
|
||||
>
|
||||
{{ $t("rns_filesync.refresh") }}
|
||||
</button>
|
||||
<div v-if="peers.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t("rns_filesync.no_peers") }}
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="peer in peers"
|
||||
:key="peer.peer_id"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
|
||||
>
|
||||
<div class="min-w-0 font-mono text-xs break-all">
|
||||
<div>{{ peer.peer_id }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">
|
||||
{{ peer.destination_hash }} · {{ peer.status }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-1.5 text-sm text-red-600 dark:text-red-300"
|
||||
:disabled="busy"
|
||||
@click="disconnectPeer(peer.peer_id)"
|
||||
>
|
||||
{{ $t("rns_filesync.disconnect") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'files'" class="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-1.5 text-sm"
|
||||
:disabled="busy"
|
||||
@click="refreshFiles"
|
||||
>
|
||||
{{ $t("rns_filesync.refresh") }}
|
||||
</button>
|
||||
<div v-if="files.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t("rns_filesync.no_files") }}
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="file in files"
|
||||
:key="file.path"
|
||||
class="p-3 rounded-lg border border-gray-200 dark:border-zinc-700 font-mono text-xs"
|
||||
>
|
||||
<div class="break-all">{{ file.path }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 mt-1">
|
||||
{{ file.size }} B · {{ file.hash }}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'browse'" class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<select v-model="browsePeerId" class="glass-input flex-1 font-mono text-sm">
|
||||
<option value="">{{ $t("rns_filesync.select_peer") }}</option>
|
||||
<option v-for="peer in peers" :key="peer.peer_id" :value="peer.peer_id">
|
||||
{{ peer.peer_id }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy || !status.running || !browsePeerId"
|
||||
@click="browsePeer"
|
||||
>
|
||||
{{ $t("rns_filesync.browse") }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="remoteFiles.length === 0" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ $t("rns_filesync.no_remote_files") }}
|
||||
</div>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="file in remoteFiles"
|
||||
:key="file.path || file"
|
||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-3 rounded-lg border border-gray-200 dark:border-zinc-700"
|
||||
>
|
||||
<div class="min-w-0 font-mono text-xs break-all">
|
||||
{{ file.path || file }}
|
||||
<span v-if="file.size != null" class="text-gray-500"> · {{ file.size }} B</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip px-3 py-1.5 text-sm"
|
||||
:disabled="busy || !browsePeerId"
|
||||
@click="downloadFile(file.path || file)"
|
||||
>
|
||||
{{ $t("rns_filesync.download") }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeTab === 'acl'" class="space-y-4">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="aclEnforce" type="checkbox" class="rounded" @change="saveEnforce" />
|
||||
{{ $t("rns_filesync.acl_enforce") }}
|
||||
</label>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
v-model="aclHash"
|
||||
type="text"
|
||||
class="glass-input flex-1 font-mono text-sm"
|
||||
:placeholder="$t('rns_filesync.peer_hash_placeholder')"
|
||||
/>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="aclRead" type="checkbox" />
|
||||
r
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="aclWrite" type="checkbox" />
|
||||
w
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="aclDelete" type="checkbox" />
|
||||
d
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip px-4 py-2 text-sm"
|
||||
:disabled="busy"
|
||||
@click="grantAcl"
|
||||
>
|
||||
{{ $t("rns_filesync.acl_grant") }}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
class="p-3 rounded-lg bg-zinc-100 dark:bg-zinc-900 text-xs overflow-x-auto"
|
||||
>{{ aclRulesText }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
|
||||
import { onWsEvent, offWsEvent } from "../../js/registries/wsEventRegistry.js";
|
||||
|
||||
export default {
|
||||
name: "RnsFilesyncPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
ToolsPageHeader,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeTab: "status",
|
||||
tabs: [
|
||||
{ id: "status", labelKey: "rns_filesync.tab_status" },
|
||||
{ id: "peers", labelKey: "rns_filesync.tab_peers" },
|
||||
{ id: "files", labelKey: "rns_filesync.tab_files" },
|
||||
{ id: "browse", labelKey: "rns_filesync.tab_browse" },
|
||||
{ id: "acl", labelKey: "rns_filesync.tab_acl" },
|
||||
],
|
||||
busy: false,
|
||||
status: {
|
||||
running: false,
|
||||
peers: 0,
|
||||
files: 0,
|
||||
destination_hash: null,
|
||||
sync_directory: "",
|
||||
},
|
||||
syncDirectory: "",
|
||||
announceInterval: 300,
|
||||
monitor: true,
|
||||
connectHash: "",
|
||||
peers: [],
|
||||
files: [],
|
||||
browsePeerId: "",
|
||||
remoteFiles: [],
|
||||
aclEnforce: false,
|
||||
aclHash: "",
|
||||
aclRead: true,
|
||||
aclWrite: false,
|
||||
aclDelete: false,
|
||||
aclRules: {},
|
||||
lastProgress: "",
|
||||
_wsHandlers: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
aclRulesText() {
|
||||
try {
|
||||
return JSON.stringify(this.aclRules || {}, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.bindWs();
|
||||
await this.refreshAll();
|
||||
},
|
||||
beforeUnmount() {
|
||||
for (const [type, handler] of this._wsHandlers) {
|
||||
offWsEvent(type, handler);
|
||||
}
|
||||
this._wsHandlers = [];
|
||||
},
|
||||
methods: {
|
||||
bindWs() {
|
||||
const bind = (type, handler) => {
|
||||
onWsEvent(type, handler);
|
||||
this._wsHandlers.push([type, handler]);
|
||||
};
|
||||
bind("filesync.sync.progress", (payload) => {
|
||||
this.lastProgress = JSON.stringify(payload);
|
||||
});
|
||||
bind("filesync.peer.connected", async () => {
|
||||
await this.refreshPeers();
|
||||
await this.refreshStatus();
|
||||
});
|
||||
bind("filesync.peer.disconnected", async () => {
|
||||
await this.refreshPeers();
|
||||
await this.refreshStatus();
|
||||
});
|
||||
bind("filesync.file.updated", async () => {
|
||||
await this.refreshFiles();
|
||||
ToastUtils.info(this.$t("rns_filesync.file_updated"));
|
||||
});
|
||||
bind("filesync.file.deleted", async () => {
|
||||
await this.refreshFiles();
|
||||
ToastUtils.info(this.$t("rns_filesync.file_deleted"));
|
||||
});
|
||||
bind("filesync.error", (payload) => {
|
||||
const detail = payload?.error || payload?.message || "";
|
||||
ToastUtils.error(
|
||||
`${this.$t("rns_filesync.error")}${detail ? ": " + detail : ""}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
async refreshAll() {
|
||||
await Promise.all([
|
||||
this.refreshStatus(),
|
||||
this.refreshPeers(),
|
||||
this.refreshFiles(),
|
||||
this.refreshAcl(),
|
||||
]);
|
||||
},
|
||||
async refreshStatus() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/filesync/status");
|
||||
const status = response?.data || {};
|
||||
this.status = status;
|
||||
if (status.sync_directory) {
|
||||
this.syncDirectory = status.sync_directory;
|
||||
}
|
||||
if (typeof status.announce_interval === "number") {
|
||||
this.announceInterval = status.announce_interval;
|
||||
}
|
||||
if (typeof status.monitor === "boolean") {
|
||||
this.monitor = status.monitor;
|
||||
}
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
}
|
||||
},
|
||||
async refreshPeers() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/filesync/peers");
|
||||
const data = response?.data || {};
|
||||
this.peers = Array.isArray(data.peers) ? data.peers : [];
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
}
|
||||
},
|
||||
async refreshFiles() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/filesync/files");
|
||||
const data = response?.data || {};
|
||||
this.files = Array.isArray(data.files) ? data.files : [];
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
}
|
||||
},
|
||||
async refreshAcl() {
|
||||
try {
|
||||
const response = await window.api.get("/api/v1/filesync/acl");
|
||||
const data = response?.data || {};
|
||||
this.aclEnforce = Boolean(data.enforce);
|
||||
this.aclRules = data.rules || {};
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
}
|
||||
},
|
||||
async startService() {
|
||||
this.busy = true;
|
||||
try {
|
||||
if (!this.status.running && this.syncDirectory) {
|
||||
await window.api.patch("/api/v1/filesync/settings", {
|
||||
sync_directory: this.syncDirectory,
|
||||
monitor: this.monitor,
|
||||
announce_interval: this.announceInterval,
|
||||
});
|
||||
}
|
||||
await window.api.post("/api/v1/filesync/start", {
|
||||
sync_directory: this.syncDirectory || undefined,
|
||||
monitor: this.monitor,
|
||||
announce_interval: this.announceInterval,
|
||||
});
|
||||
ToastUtils.success(this.$t("rns_filesync.started"));
|
||||
await this.refreshAll();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async stopService() {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/stop", {});
|
||||
ToastUtils.success(this.$t("rns_filesync.stopped"));
|
||||
await this.refreshAll();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async announceNow() {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/announce", {});
|
||||
ToastUtils.success(this.$t("rns_filesync.announced"));
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async connectPeer() {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/connect", {
|
||||
identity_hash: this.connectHash,
|
||||
});
|
||||
ToastUtils.success(this.$t("rns_filesync.connected"));
|
||||
this.connectHash = "";
|
||||
await this.refreshPeers();
|
||||
await this.refreshStatus();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async disconnectPeer(peerId) {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/disconnect", {
|
||||
peer_id: peerId,
|
||||
});
|
||||
ToastUtils.success(this.$t("rns_filesync.disconnected"));
|
||||
await this.refreshPeers();
|
||||
await this.refreshStatus();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async browsePeer() {
|
||||
this.busy = true;
|
||||
try {
|
||||
const response = await window.api.post("/api/v1/filesync/browse", {
|
||||
peer_id: this.browsePeerId,
|
||||
});
|
||||
const data = response?.data || {};
|
||||
this.remoteFiles = Array.isArray(data.files) ? data.files : [];
|
||||
ToastUtils.success(this.$t("rns_filesync.browse_done"));
|
||||
} catch (err) {
|
||||
this.remoteFiles = [];
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async downloadFile(path) {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/download", {
|
||||
peer_id: this.browsePeerId,
|
||||
path,
|
||||
});
|
||||
ToastUtils.info(this.$t("rns_filesync.download_started"));
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async grantAcl() {
|
||||
const perms = [];
|
||||
if (this.aclRead) perms.push("read");
|
||||
if (this.aclWrite) perms.push("write");
|
||||
if (this.aclDelete) perms.push("delete");
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/acl", {
|
||||
identity_hash: this.aclHash,
|
||||
perms,
|
||||
enforce: this.aclEnforce,
|
||||
});
|
||||
ToastUtils.success(this.$t("rns_filesync.acl_updated"));
|
||||
this.aclHash = "";
|
||||
await this.refreshAcl();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async saveEnforce() {
|
||||
this.busy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/filesync/acl", {
|
||||
enforce: this.aclEnforce,
|
||||
});
|
||||
ToastUtils.success(this.$t("rns_filesync.acl_updated"));
|
||||
await this.refreshAcl();
|
||||
} catch (err) {
|
||||
ToastUtils.error(err?.message || this.$t("rns_filesync.error"));
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
async copyHash(hash) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
ToastUtils.success(this.$t("rns_filesync.copied"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("rns_filesync.error"));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -40,6 +40,7 @@
|
|||
:online-interface-count="onlineInterfaces.length"
|
||||
:offline-interface-count="offlineInterfaces.length"
|
||||
:search-query="searchQuery"
|
||||
:preferred-renderer="preferredRenderer"
|
||||
:engine-mode="engineMode"
|
||||
:fps="fps"
|
||||
@update:is-showing-controls="isShowingControls = $event"
|
||||
|
|
@ -47,6 +48,7 @@
|
|||
@update:enable-physics="enablePhysics = $event"
|
||||
@update:hop-max-filter="onUserHopMaxFilterChange"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@update:preferred-renderer="onPreferredRendererChange"
|
||||
@manual-update="manualUpdate"
|
||||
/>
|
||||
<NetworkVisualiserLegend
|
||||
|
|
@ -93,6 +95,7 @@ import {
|
|||
loadVisualiserDisplayPrefs,
|
||||
persistVisualiserAutoReload,
|
||||
persistVisualiserLiveLayout,
|
||||
persistVisualiserRenderer,
|
||||
VISUALISER_DISPLAY_PREFS_CHANGED,
|
||||
} from "../../js/settings/settingsVisualiserPrefs.js";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
|
@ -821,6 +824,13 @@ export default {
|
|||
this.autoReload = p.autoReload;
|
||||
this.preferredRenderer = p.renderer || "auto";
|
||||
},
|
||||
async onPreferredRendererChange(next) {
|
||||
const normalized = next === "webgl" || next === "vis" || next === "auto" ? next : "auto";
|
||||
if (normalized === this.preferredRenderer) return;
|
||||
this.preferredRenderer = normalized;
|
||||
persistVisualiserRenderer(normalized, { emit: false });
|
||||
await this.reinitRenderer();
|
||||
},
|
||||
destroyActiveRenderer() {
|
||||
this.hoverTooltip = null;
|
||||
if (this.webglEngine) {
|
||||
|
|
@ -926,7 +936,7 @@ export default {
|
|||
const layoutEdges = this.edges.get().map((e) => ({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
length: e.width >= 2.5 ? 150 : 180,
|
||||
length: e.width >= 2.5 ? 260 : 300,
|
||||
}));
|
||||
const settled = settleLayout({ nodes: layoutNodes, edges: layoutEdges, iterations: 0 });
|
||||
const positions = settled?.positions || {};
|
||||
|
|
@ -1642,7 +1652,7 @@ export default {
|
|||
graph.layout_edges = graphEdges.map((e) => ({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
length: e.width >= 2.5 ? 150 : 180,
|
||||
length: e.width >= 2.5 ? 260 : 300,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,16 +53,26 @@
|
|||
<div class="grid grid-cols-2 gap-2">
|
||||
<div
|
||||
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
|
||||
:title="engineModeTitle"
|
||||
:title="engineSelectTitle"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5"
|
||||
<label
|
||||
for="visualiser-engine-select"
|
||||
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-0.5 block"
|
||||
>
|
||||
{{ $t("visualiser.engine") }}
|
||||
</div>
|
||||
<div class="text-xs font-bold truncate" :class="engineModeClass">
|
||||
{{ engineModeLabel }}
|
||||
</div>
|
||||
</label>
|
||||
<select
|
||||
id="visualiser-engine-select"
|
||||
class="w-full bg-transparent text-xs font-bold truncate border-0 p-0 pr-5 focus:outline-hidden focus:ring-0 cursor-pointer"
|
||||
:class="engineSelectClass"
|
||||
:value="preferredRenderer"
|
||||
:aria-label="$t('visualiser.engine')"
|
||||
@change="onEngineSelectChange"
|
||||
>
|
||||
<option value="auto">{{ $t("visualiser.renderer_option_auto") }}</option>
|
||||
<option value="webgl">{{ $t("visualiser.renderer_option_webgl") }}</option>
|
||||
<option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl px-3 py-2 border border-gray-100 dark:border-zinc-700/50 bg-gray-50/60 dark:bg-zinc-800/40"
|
||||
|
|
@ -239,11 +249,18 @@ export default {
|
|||
onlineInterfaceCount: { type: Number, default: 0 },
|
||||
offlineInterfaceCount: { type: Number, default: 0 },
|
||||
searchQuery: { type: String, default: "" },
|
||||
preferredRenderer: {
|
||||
type: String,
|
||||
default: "auto",
|
||||
validator(v) {
|
||||
return ["auto", "webgl", "vis"].includes(v);
|
||||
},
|
||||
},
|
||||
engineMode: {
|
||||
type: String,
|
||||
default: "checking",
|
||||
validator(v) {
|
||||
return ["checking", "wasm", "fallback"].includes(v);
|
||||
return ["checking", "wasm", "fallback", "webgl"].includes(v);
|
||||
},
|
||||
},
|
||||
fps: { type: Number, default: 0 },
|
||||
|
|
@ -254,6 +271,7 @@ export default {
|
|||
"update:enablePhysics",
|
||||
"update:hopMaxFilter",
|
||||
"update:searchQuery",
|
||||
"update:preferredRenderer",
|
||||
"manual-update",
|
||||
],
|
||||
data() {
|
||||
|
|
@ -277,23 +295,17 @@ export default {
|
|||
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
|
||||
return String(this.hopMaxFilter);
|
||||
},
|
||||
engineModeLabel() {
|
||||
if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl");
|
||||
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm");
|
||||
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback");
|
||||
return this.$t("visualiser.engine_checking");
|
||||
},
|
||||
engineModeTitle() {
|
||||
engineSelectTitle() {
|
||||
if (this.engineMode === "webgl") return this.$t("visualiser.engine_webgl_hint");
|
||||
if (this.engineMode === "wasm") return this.$t("visualiser.engine_wasm_hint");
|
||||
if (this.engineMode === "fallback") return this.$t("visualiser.engine_fallback_hint");
|
||||
return this.$t("visualiser.engine_checking_hint");
|
||||
return this.$t("visualiser.renderer_desc");
|
||||
},
|
||||
engineModeClass() {
|
||||
engineSelectClass() {
|
||||
if (this.engineMode === "webgl") return "text-sky-600 dark:text-sky-400";
|
||||
if (this.engineMode === "wasm") return "text-emerald-600 dark:text-emerald-400";
|
||||
if (this.engineMode === "fallback") return "text-amber-600 dark:text-amber-400";
|
||||
return "text-gray-500 dark:text-zinc-400";
|
||||
return "text-gray-800 dark:text-zinc-100";
|
||||
},
|
||||
fpsDisplay() {
|
||||
const n = Number(this.fps);
|
||||
|
|
@ -302,6 +314,12 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
onEngineSelectChange(e) {
|
||||
const next = e?.target?.value;
|
||||
if (next === "auto" || next === "webgl" || next === "vis") {
|
||||
this.$emit("update:preferredRenderer", next);
|
||||
}
|
||||
},
|
||||
onHopSliderInput(e) {
|
||||
const v = hopSliderPosToMaxHops(Number(e.target.value));
|
||||
this.$emit("update:hopMaxFilter", v);
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@ export function buildFullGraph(req) {
|
|||
layout_edges: (path.edges || []).map((e) => ({
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
length: e.width >= 2 ? 150 : 180,
|
||||
length: e.width >= 2 ? 260 : 300,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,14 @@ export const CORE_COMMAND_ENTRIES = [
|
|||
type: "navigation",
|
||||
route: { name: "rncp" },
|
||||
},
|
||||
{
|
||||
id: "nav-rns-filesync",
|
||||
title: "nav_rns_filesync",
|
||||
description: "nav_rns_filesync_desc",
|
||||
icon: "folder-sync",
|
||||
type: "navigation",
|
||||
route: { name: "rns-filesync" },
|
||||
},
|
||||
{
|
||||
id: "nav-rnsh",
|
||||
title: "nav_rnsh",
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ export const CORE_TOOLS_ENTRIES = [
|
|||
},
|
||||
{
|
||||
name: "rns-filesync",
|
||||
comingSoon: true,
|
||||
route: { name: "rns-filesync" },
|
||||
icon: "folder-sync",
|
||||
iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
|
||||
titleKey: "tools.rns_filesync.title",
|
||||
|
|
|
|||
|
|
@ -2601,8 +2601,8 @@
|
|||
"description": "IP-Tunneling über Reticulum-Verbindungen (in Entwicklung)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNSFilesync",
|
||||
"description": "Dateisync zwischen Mesh-Peers (in Entwicklung)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy-Bots",
|
||||
|
|
@ -3249,7 +3249,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Remote-Shell über Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Remote-Befehlsausführung über Reticulum"
|
||||
"nav_rnx_desc": "Remote-Befehlsausführung über Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"settings": {
|
||||
"shortcut_saved": "Verknüpfung gespeichert",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX kann den Windows-Inhaltsschutz nutzen (dasselbe DRM-ähnliche Display-Flag wie Medien-Apps), damit Screenshots, Recorder und Windows Recall das App-Fenster nicht erfassen. Später änderbar unter Einstellungen → Datenschutz.",
|
||||
"windows_screen_security_enable": "Bildschirmsicherheit aktivieren",
|
||||
"windows_screen_security_later": "Nicht jetzt"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2798,8 +2798,8 @@
|
|||
"description": "Carry IP traffic over Reticulum links (in development)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Sync files between mesh peers (in development)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Bots",
|
||||
|
|
@ -3025,6 +3025,7 @@
|
|||
"step_2": "2. To **send** a file, get the **Destination Hash** of a listening node, enter the **Local File Path**, and click **Send File**.",
|
||||
"step_3": "3. To **fetch** a file, get the **Destination Hash** of a node listening with 'Allow Fetch' enabled, enter the **Remote File Path**, and click **Fetch File**.",
|
||||
"send_file": "Send File",
|
||||
|
||||
"fetch_file": "Fetch File",
|
||||
"listen": "Listen",
|
||||
"destination_hash": "Destination Hash",
|
||||
|
|
@ -3067,6 +3068,58 @@
|
|||
"web_path_hint": "Browsers cannot expose full file paths. Use the desktop app to browse, or paste the full path to the file on this machine.",
|
||||
"web_save_path_prompt": "Enter the absolute folder path on the machine running MeshChat (server) where the fetched file should be saved:"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
},
|
||||
"rnsh": {
|
||||
"remote_shell": "Remote shell",
|
||||
"usage_title": "Usage",
|
||||
|
|
@ -3752,6 +3805,8 @@
|
|||
"nav_rnprobe_desc": "Probe reachability and delay",
|
||||
"nav_rncp": "RNCP",
|
||||
"nav_rncp_desc": "File copy over Reticulum (RNCP)",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum",
|
||||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Remote shell over Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
|
|
|
|||
|
|
@ -2794,8 +2794,8 @@
|
|||
"description": "Tunelización IP sobre los enlaces de Reticulum (en desarrollo)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Sincronización de archivos entre pares de malla (en desarrollo)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "Bots LXMFy",
|
||||
|
|
@ -3494,7 +3494,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Shell remoto sobre Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum"
|
||||
"nav_rnx_desc": "Ejecución remota de comandos sobre Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"relay_chat": {
|
||||
"title": "Chat de retransmision",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX puede usar la protección de contenido de Windows (la misma marca tipo DRM que usan las apps de medios) para que capturas, grabadoras y Windows Recall no capturen la ventana. Puede cambiarlo después en Ajustes → Privacidad.",
|
||||
"windows_screen_security_enable": "Activar seguridad de pantalla",
|
||||
"windows_screen_security_later": "Ahora no"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2794,8 +2794,8 @@
|
|||
"description": "Kuljeta IP-liikennettä Reticulum-linkkien yli (kehitysvaiheessa)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Synkronoi tiedostoja verkon vertaisten välillä (kehitysvaiheessa)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy-botit",
|
||||
|
|
@ -3701,7 +3701,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Etäkuori Reticulumin yli",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli"
|
||||
"nav_rnx_desc": "Etäkomentojen suoritus Reticulumin yli",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"rnx": {
|
||||
"title": "RNX - Reticulum Remote Execution",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX voi käyttää Windowsin sisällönsuojausta (samaa DRM-tyylistä näyttölippua kuin mediaohjelmat), jotta kuvakaappaukset, tallentimet ja Windows Recall eivät kaappaa sovellusikkunaa. Voit muuttaa tätä myöhemmin kohdassa Asetukset → Tietosuoja.",
|
||||
"windows_screen_security_enable": "Ota näytön suojaus käyttöön",
|
||||
"windows_screen_security_later": "Ei nyt"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2794,8 +2794,8 @@
|
|||
"description": "Tunnel IP sur les liaisons Reticulum (en développement)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "Fichiers RNSync",
|
||||
"description": "Synchronisation des fichiers entre les paires de mailles (en développement)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "Bots LXMFy",
|
||||
|
|
@ -3494,7 +3494,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Shell distant sur Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Exécution distante de commandes sur Reticulum"
|
||||
"nav_rnx_desc": "Exécution distante de commandes sur Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"relay_chat": {
|
||||
"title": "Chat relais",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX peut utiliser la protection de contenu Windows (le même indicateur d’affichage de type DRM que les apps multimédias) pour que les captures, enregistreurs et Windows Recall ne capturent pas la fenêtre. Modifiable plus tard dans Paramètres → Confidentialité.",
|
||||
"windows_screen_security_enable": "Activer la sécurité d’écran",
|
||||
"windows_screen_security_later": "Pas maintenant"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2846,8 +2846,8 @@
|
|||
"description": "Tunnel IP su collegamenti Reticulum (in sviluppo)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Sincronizzazione file tra peer mesh (in sviluppo)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Bot",
|
||||
|
|
@ -3494,7 +3494,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Shell remota su Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Esecuzione remota di comandi su Reticulum"
|
||||
"nav_rnx_desc": "Esecuzione remota di comandi su Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"relay_chat": {
|
||||
"title": "Chat relay",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX può usare la protezione contenuti di Windows (lo stesso flag display in stile DRM delle app multimediali) così screenshot, registratori e Windows Recall non catturano la finestra. Modificabile in seguito in Impostazioni → Privacy.",
|
||||
"windows_screen_security_enable": "Attiva sicurezza schermo",
|
||||
"windows_screen_security_later": "Non ora"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2794,8 +2794,8 @@
|
|||
"description": "IP tunneling over Reticulum links (in ontwikkeling)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Bestand synchroniseren tussen mesh peers (in ontwikkeling)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Bots",
|
||||
|
|
@ -3494,7 +3494,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Remote shell over Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum"
|
||||
"nav_rnx_desc": "Externe opdrachtuitvoering over Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"relay_chat": {
|
||||
"title": "Relaychat",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX kan Windows-inhoudsbescherming gebruiken (dezelfde DRM-achtige displayvlag als media-apps) zodat screenshots, recorders en Windows Recall het appvenster niet vastleggen. Later te wijzigen onder Instellingen → Privacy.",
|
||||
"windows_screen_security_enable": "Schermbeveiliging inschakelen",
|
||||
"windows_screen_security_later": "Niet nu"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2601,8 +2601,8 @@
|
|||
"description": "IP-туннелирование по каналам Reticulum (в разработке)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Синхронизация файлов между узлами mesh (в разработке)."
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Боты",
|
||||
|
|
@ -3249,7 +3249,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "Удалённая оболочка через Reticulum",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "Удалённое выполнение команд через Reticulum"
|
||||
"nav_rnx_desc": "Удалённое выполнение команд через Reticulum",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"settings": {
|
||||
"shortcut_saved": "Ярлык сохранён",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX может использовать защиту содержимого Windows (тот же DRM-подобный флаг отображения, что у медиаприложений), чтобы снимки, запись и Windows Recall не захватывали окно. Позже можно изменить в Настройки → Конфиденциальность.",
|
||||
"windows_screen_security_enable": "Включить защиту экрана",
|
||||
"windows_screen_security_later": "Не сейчас"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2794,8 +2794,8 @@
|
|||
"description": "IP地道通向Reticulum链接(正在开发)."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS 文件同步",
|
||||
"description": "网络端点之间的文件同步( 正在开发中) 。"
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a directory with mesh peers over Reticulum using destination hashes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy 波兹",
|
||||
|
|
@ -3494,7 +3494,9 @@
|
|||
"nav_rnsh": "RNSH",
|
||||
"nav_rnsh_desc": "通过 Reticulum 的远程 Shell",
|
||||
"nav_rnx": "RNX",
|
||||
"nav_rnx_desc": "通过 Reticulum 远程执行命令"
|
||||
"nav_rnx_desc": "通过 Reticulum 远程执行命令",
|
||||
"nav_rns_filesync": "RNS FileSync",
|
||||
"nav_rns_filesync_desc": "Directory sync over Reticulum"
|
||||
},
|
||||
"relay_chat": {
|
||||
"title": "中继聊天",
|
||||
|
|
@ -3792,5 +3794,57 @@
|
|||
"windows_screen_security_desc": "MeshChatX 可使用 Windows 内容保护(与媒体应用相同的 DRM 风格显示标志),使截图、录屏和 Windows Recall 无法捕获应用窗口。之后可在 设置 → 隐私 中更改。",
|
||||
"windows_screen_security_enable": "启用屏幕安全",
|
||||
"windows_screen_security_later": "暂不"
|
||||
},
|
||||
"rns_filesync": {
|
||||
"eyebrow": "Directory sync",
|
||||
"title": "RNS FileSync",
|
||||
"description": "Sync a local directory with mesh peers over Reticulum. Peers are identity hashes on aspect rns_filesync.filesync.",
|
||||
"usage_steps": "How to use FileSync:",
|
||||
"step_1": "1. Set a sync directory, start FileSync, and share your destination hash with peers.",
|
||||
"step_2": "2. Connect to a peer by identity hash, then browse or download files from the Browse tab.",
|
||||
"step_3": "3. Use the ACL tab to grant read, write, or delete permissions when enforcement is enabled.",
|
||||
"tab_status": "Status",
|
||||
"tab_peers": "Peers",
|
||||
"tab_files": "Files",
|
||||
"tab_browse": "Browse",
|
||||
"tab_acl": "ACL",
|
||||
"sync_directory": "Sync directory",
|
||||
"sync_directory_placeholder": "Path under this identity storage",
|
||||
"announce_interval": "Announce interval (seconds)",
|
||||
"monitor": "Watch directory for changes",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"announce": "Announce now",
|
||||
"refresh": "Refresh",
|
||||
"running": "Running",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"peers_count": "Peers",
|
||||
"files_count": "Files",
|
||||
"destination_hash": "Destination hash",
|
||||
"last_progress": "Last progress",
|
||||
"peer_hash_placeholder": "Identity hash (hex)",
|
||||
"connect": "Connect",
|
||||
"disconnect": "Disconnect",
|
||||
"no_peers": "No connected peers yet.",
|
||||
"no_files": "No local files in the sync inventory yet.",
|
||||
"select_peer": "Select a peer",
|
||||
"browse": "Browse",
|
||||
"no_remote_files": "No remote files listed. Connect and browse a peer.",
|
||||
"download": "Download",
|
||||
"acl_enforce": "Enforce ACL (deny by default)",
|
||||
"acl_grant": "Grant",
|
||||
"acl_updated": "ACL updated",
|
||||
"started": "FileSync started",
|
||||
"stopped": "FileSync stopped",
|
||||
"announced": "Announce sent",
|
||||
"connected": "Peer connect requested",
|
||||
"disconnected": "Peer disconnected",
|
||||
"browse_done": "Browse complete",
|
||||
"download_started": "Download requested",
|
||||
"file_updated": "File updated",
|
||||
"file_deleted": "File deleted",
|
||||
"copied": "Copied to clipboard",
|
||||
"error": "FileSync error"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,6 +171,11 @@ const router = createRouter({
|
|||
path: "/rncp",
|
||||
component: () => import("./components/rncp/RNCPPage.vue"),
|
||||
},
|
||||
{
|
||||
name: "rns-filesync",
|
||||
path: "/rns-filesync",
|
||||
component: () => import("./components/filesync/RnsFilesyncPage.vue"),
|
||||
},
|
||||
{
|
||||
name: "rnsh",
|
||||
path: "/rnsh",
|
||||
|
|
|
|||
|
|
@ -17,12 +17,15 @@ Use these when messages or pages fail despite interfaces showing as enabled.
|
|||
|
||||
## File transfer and shell
|
||||
|
||||
| Tool | Purpose |
|
||||
| ---- | ------------------------------------------ |
|
||||
| RNCP | Send or fetch files over Reticulum |
|
||||
| RNSH | Remote shell sessions with streamed output |
|
||||
| Tool | Purpose |
|
||||
| ------------ | ---------------------------------------------------- |
|
||||
| RNCP | Send or fetch files over Reticulum |
|
||||
| RNS FileSync | Sync a directory with peers over `rns_filesync.filesync` |
|
||||
| RNSH | Remote shell sessions with streamed output |
|
||||
|
||||
RNCP progress events arrive on the WebSocket as `rncp.transfer.progress`.
|
||||
FileSync progress and peer events use `filesync.sync.progress`, `filesync.peer.connected`, `filesync.peer.disconnected`, `filesync.file.updated`, `filesync.file.deleted`, and `filesync.error`.
|
||||
FileSync uses the bundled `rns_filesync` package and keeps sync state under the active identity storage directory.
|
||||
|
||||
## Messaging helpers
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ Translator calls respect **privacy mode**. When privacy mode blocks outbound HTT
|
|||
|
||||
## Coming soon
|
||||
|
||||
The registry marks **RNS Tunnel** and **RNS FileSync** as coming soon. They do not have routes in the current release.
|
||||
The registry marks **RNS Tunnel** as coming soon. It does not have a route in the current release.
|
||||
|
||||
## Relay chat server
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": "1.2.0",
|
||||
"wasm": "sha384-Z1L+iI6+ud0bH8JYg54AHdwlr+/43VFTU6s91BEQQpiTjEAfGCnH2CweclVMpX5x",
|
||||
"wasm": "sha384-u89AYwtv9w30MHieh7YmTmorpfsR5A8bh+9wRJR1rgvqaRc2xjgxEiM5mvJAsA9V",
|
||||
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
|
||||
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,13 +44,14 @@ meshchatx = "meshchatx.meshchat:main"
|
|||
meshchat = "meshchatx.meshchat:main"
|
||||
meshchatx-repository-http = "meshchatx.repository_http_standalone:main"
|
||||
lxmfy = "lxmfy.cli:main"
|
||||
rns-filesync = "rns_filesync.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/Quad4-Software/MeshChatX"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = [".", "vendor/lxmfy"]
|
||||
include = ["meshchatx*", "lxmfy*"]
|
||||
where = [".", "vendor/lxmfy", "vendor/rns_filesync"]
|
||||
include = ["meshchatx*", "lxmfy*", "rns_filesync*"]
|
||||
exclude = ["tests*"]
|
||||
namespaces = false
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"include": ["meshchatx/src/backend", "meshchatx/src/env_utils.py"],
|
||||
"exclude": ["**/vendor/**", "**/build/**", "**/node_modules/**", "**/__pycache__/**"],
|
||||
"extraPaths": [".", "vendor/lxmfy"],
|
||||
"extraPaths": [".", "vendor/lxmfy", "vendor/rns_filesync"],
|
||||
"stubPath": "typings",
|
||||
"pythonVersion": "3.11",
|
||||
"typeCheckingMode": "basic",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ record_hit() {
|
|||
# Do not use a bare /(^|/)android(/|$)/ pattern: LXST ships
|
||||
# LXST/Platforms/android for mobile hosts. That path must remain allowed in
|
||||
# APKs and must not false-fail Docker scans of site-packages.
|
||||
COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
|
||||
COMMON_DENY_RE='(^|/)\.git(/|$)|(^|/)node_modules(/|$)|(^|/)\.pnpm-store(/|$)|(^|/)\.venv(/|$)|(^|/)vendor/offline(/|$)|(^|/)vendor/lxmfy/tests(/|$)|(^|/)vendor/lxmfy/docs(/|$)|(^|/)vendor/lxmfy/docker(/|$)|(^|/)vendor/rns_filesync/tests(/|$)|(^|/)vendor/rns_filesync/docs(/|$)|(^|/)vendor/rns_filesync/docker(/|$)|(^|/)vendor/rns_filesync/packaging(/|$)|(^|/)vendor/rns_filesync/sideband(/|$)|(^|/)vendor/rns_filesync/man(/|$)|(^|/)vendor/rns_filesync/scripts(/|$)|(^|/)\.github(/|$)|(^|/)docs/agents(/|$)|(^|/)screenshots(/|$)|(^|/)\.pytest_cache(/|$)|(^|/)mutants(/|$)|(^|/)coverage(/|$)'
|
||||
|
||||
BYTECODE_DENY_RE='(^|/)__pycache__(/|$)'
|
||||
|
||||
|
|
|
|||
|
|
@ -183,6 +183,9 @@ def mock_app(db, tmp_path, temp_db):
|
|||
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||
)
|
||||
stack.enter_context(patch("meshchatx.src.backend.identity_context.RNCPHandler"))
|
||||
stack.enter_context(
|
||||
patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
|
||||
)
|
||||
stack.enter_context(
|
||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@ _MUTATING_SAMPLES = (
|
|||
("PATCH", "/api/v1/server/security", {"web_ui_ip_allowlist": ""}),
|
||||
("POST", "/api/v1/app/tutorial/seen", {}),
|
||||
("POST", "/api/v1/app/changelog/seen", {"version": "999.999.999"}),
|
||||
("POST", "/api/v1/filesync/start", {}),
|
||||
("POST", "/api/v1/filesync/stop", {}),
|
||||
("POST", "/api/v1/filesync/announce", {}),
|
||||
("POST", "/api/v1/filesync/connect", {"identity_hash": "aa" * 16}),
|
||||
("POST", "/api/v1/filesync/disconnect", {"peer_id": "bb" * 16}),
|
||||
("POST", "/api/v1/filesync/browse", {"peer_id": "bb" * 16}),
|
||||
("POST", "/api/v1/filesync/download", {"peer_id": "bb" * 16, "path": "a.txt"}),
|
||||
("POST", "/api/v1/filesync/acl", {"enforce": False}),
|
||||
("PATCH", "/api/v1/filesync/settings", {"monitor": True}),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -42,6 +51,28 @@ def _make_aio_app(mock_app, use_https: bool = False):
|
|||
return aio_app
|
||||
|
||||
|
||||
def _stub_filesync_handler(mock_app):
|
||||
handler = mock_app.rns_filesync_handler
|
||||
if handler is None:
|
||||
return
|
||||
handler.reticulum = object()
|
||||
handler.start.return_value = {"ok": True, "running": True}
|
||||
handler.stop.return_value = {"ok": True, "running": False}
|
||||
handler.announce_now.return_value = {"ok": True}
|
||||
handler.connect_peer.return_value = {"ok": True, "peer_id": "aa" * 16}
|
||||
handler.disconnect_peer.return_value = {"ok": True, "peer_id": "bb" * 16}
|
||||
handler.browse_peer.return_value = {"ok": True, "files": []}
|
||||
handler.download_file.return_value = {"ok": True, "path": "a.txt"}
|
||||
handler.update_acl.return_value = {"ok": True, "enforce": False, "rules": {}}
|
||||
handler.update_settings.return_value = {
|
||||
"ok": True,
|
||||
"sync_directory": "/tmp",
|
||||
"monitor": True,
|
||||
"announce_interval": 300,
|
||||
"running": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
|
||||
with eect_scenario("auth.csrf.mutating_without_token") as (_s, _seed, rng):
|
||||
|
|
@ -65,6 +96,7 @@ async def test_eect_mutating_without_csrf_rejected(mock_app, monkeypatch):
|
|||
async def test_eect_mutating_with_csrf_accepted(mock_app, monkeypatch):
|
||||
with eect_scenario("auth.csrf.mutating_with_token") as (_s, _seed, rng):
|
||||
monkeypatch.delenv("MESHCHAT_DISABLE_CSRF", raising=False)
|
||||
_stub_filesync_handler(mock_app)
|
||||
aio_app = _make_aio_app(mock_app)
|
||||
samples = list(_MUTATING_SAMPLES)
|
||||
rng.shuffle(samples)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def mock_rns(tmp_path):
|
|||
patch("meshchatx.src.backend.identity_context.VoicemailManager"),
|
||||
patch("meshchatx.src.backend.identity_context.RingtoneManager"),
|
||||
patch("meshchatx.src.backend.identity_context.RNCPHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.RnsFilesyncHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.RNStatusHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.RNProbeHandler"),
|
||||
patch("meshchatx.src.backend.identity_context.TranslatorHandler"),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ from tests.backend.http_api_response_schemas import (
|
|||
RETICULUM_INSTANCE_SCHEMA,
|
||||
RNCP_STATUS_SCHEMA,
|
||||
RNCP_TRANSFER_SCHEMA,
|
||||
FILESYNC_ACL_SCHEMA,
|
||||
FILESYNC_FILES_SCHEMA,
|
||||
FILESYNC_PEERS_SCHEMA,
|
||||
FILESYNC_STATUS_SCHEMA,
|
||||
RNPATH_RATES_SCHEMA,
|
||||
RNPATH_TABLE_SCHEMA,
|
||||
RNPATH_TRACE_SCHEMA,
|
||||
|
|
@ -449,6 +453,10 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
|
|||
allow_statuses=(200, 404),
|
||||
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
|
||||
),
|
||||
HttpJsonContract("GET", "/api/v1/filesync/status", FILESYNC_STATUS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/filesync/peers", FILESYNC_PEERS_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/filesync/files", FILESYNC_FILES_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/filesync/acl", FILESYNC_ACL_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
|
||||
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
|
||||
HttpJsonContract(
|
||||
|
|
|
|||
|
|
@ -528,6 +528,48 @@ RNCP_STATUS_SCHEMA: dict = {
|
|||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
FILESYNC_STATUS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["running", "sync_directory"],
|
||||
"properties": {
|
||||
"running": _BOOLEAN,
|
||||
"sync_directory": _STRING,
|
||||
"identity_hash": {"type": ["string", "null"]},
|
||||
"destination_hash": {"type": ["string", "null"]},
|
||||
"peers": _INTEGER,
|
||||
"files": _INTEGER,
|
||||
"whitelist": _BOOLEAN,
|
||||
"monitor": _BOOLEAN,
|
||||
"announce_interval": _INTEGER,
|
||||
"config_directory": _STRING,
|
||||
},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
FILESYNC_PEERS_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["peers"],
|
||||
"properties": {"peers": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
FILESYNC_FILES_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["files"],
|
||||
"properties": {"files": _ARRAY},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
FILESYNC_ACL_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["enforce", "rules"],
|
||||
"properties": {
|
||||
"enforce": _BOOLEAN,
|
||||
"rules": _OBJECT,
|
||||
},
|
||||
"additionalProperties": True,
|
||||
}
|
||||
|
||||
RNCP_TRANSFER_SCHEMA: dict = {
|
||||
"type": "object",
|
||||
"required": ["transfer"],
|
||||
|
|
|
|||
|
|
@ -65,6 +65,28 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
|
|||
assert v[0].isdigit()
|
||||
|
||||
|
||||
def test_get_package_version_resolves_rns_filesync_when_metadata_missing():
|
||||
def _missing(*_a, **_k):
|
||||
raise importlib.metadata.PackageNotFoundError
|
||||
|
||||
real_import = __import__
|
||||
|
||||
def _import_module(name, package=None):
|
||||
if name == "rns_filesync":
|
||||
return real_import(name)
|
||||
return real_import(name, package=package)
|
||||
|
||||
with (
|
||||
patch("importlib.metadata.version", side_effect=_missing),
|
||||
patch("importlib.metadata.distribution", side_effect=_missing),
|
||||
patch("importlib.metadata.packages_distributions", return_value={}),
|
||||
patch("importlib.import_module", side_effect=_import_module),
|
||||
):
|
||||
v = ReticulumMeshChat.get_package_version("rns-filesync")
|
||||
assert v != "unknown"
|
||||
assert v[0].isdigit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"package",
|
||||
(
|
||||
|
|
@ -76,6 +98,8 @@ def test_get_package_version_resolves_lxmfy_when_metadata_missing():
|
|||
"ply",
|
||||
"bcrypt",
|
||||
"lxmfy",
|
||||
"rns-filesync",
|
||||
"rns_filesync",
|
||||
),
|
||||
)
|
||||
def test_app_info_dependency_keys_resolve_in_dev_env(package: str):
|
||||
|
|
|
|||
132
tests/backend/test_rns_filesync_handler.py
Normal file
132
tests/backend/test_rns_filesync_handler.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_identity():
|
||||
return SimpleNamespace(hash=b"\xaa" * 16)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(mock_identity, tmp_path):
|
||||
storage = tmp_path / "identity"
|
||||
storage.mkdir()
|
||||
return RnsFilesyncHandler(
|
||||
reticulum_instance=MagicMock(name="reticulum"),
|
||||
identity=mock_identity,
|
||||
storage_dir=str(storage),
|
||||
)
|
||||
|
||||
|
||||
def test_default_status_not_running(handler, mock_identity):
|
||||
status = handler.get_status()
|
||||
assert status["running"] is False
|
||||
assert status["destination_hash"] is None
|
||||
assert status["identity_hash"] == mock_identity.hash.hex()
|
||||
assert status["sync_directory"].endswith("filesync/sync")
|
||||
assert "filesync" in status["config_directory"]
|
||||
|
||||
|
||||
def test_update_settings_persists_sync_directory(handler):
|
||||
result = handler.update_settings(sync_directory="/tmp/filesync-alt", monitor=False)
|
||||
assert result["ok"] is True
|
||||
assert result["monitor"] is False
|
||||
status = handler.get_status()
|
||||
assert status["sync_directory"] == "/tmp/filesync-alt"
|
||||
assert status["monitor"] is False
|
||||
|
||||
|
||||
def test_acl_grant_and_persist(handler, tmp_path):
|
||||
peer = "bb" * 16
|
||||
result = handler.update_acl(
|
||||
identity_hash=peer,
|
||||
perms=["read", "write"],
|
||||
enforce=True,
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["enforce"] is True
|
||||
assert peer in result["rules"]["read"]
|
||||
assert peer in result["rules"]["write"]
|
||||
|
||||
acl_path = tmp_path / "identity" / "filesync" / "acl.txt"
|
||||
assert acl_path.is_file()
|
||||
text = acl_path.read_text(encoding="utf-8")
|
||||
assert f"r:{peer}" in text
|
||||
assert f"w:{peer}" in text
|
||||
|
||||
again = handler.get_acl()
|
||||
assert again["enforce"] is True
|
||||
assert peer in again["rules"]["read"]
|
||||
|
||||
|
||||
def test_connect_requires_running(handler):
|
||||
result = handler.connect_peer("cc" * 16)
|
||||
assert result["ok"] is False
|
||||
assert "not running" in result["error"]
|
||||
|
||||
|
||||
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
|
||||
def test_start_stop_and_teardown(mock_service_cls, handler):
|
||||
service = MagicMock()
|
||||
service.start.return_value = "dd" * 16
|
||||
service.get_status.return_value = {
|
||||
"running": True,
|
||||
"sync_directory": handler._sync_directory,
|
||||
"identity_hash": "aa" * 16,
|
||||
"destination_hash": "dd" * 16,
|
||||
"peers": 0,
|
||||
"files": 0,
|
||||
"whitelist": False,
|
||||
"monitor": True,
|
||||
}
|
||||
service.list_peers.return_value = [{"peer_id": "ee" * 16, "status": 1}]
|
||||
service.list_files.return_value = [{"path": "a.txt", "size": 1}]
|
||||
service.connect_peer.return_value = {"ok": True, "peer_id": "ee" * 16}
|
||||
service.browse_peer.return_value = [{"path": "remote.txt", "size": 2}]
|
||||
service.download_file.return_value = {"ok": True, "path": "remote.txt"}
|
||||
mock_service_cls.return_value = service
|
||||
|
||||
started = handler.start(monitor=True, announce_interval=120)
|
||||
assert started["ok"] is True
|
||||
assert started["destination_hash"] == "dd" * 16
|
||||
mock_service_cls.assert_called_once()
|
||||
kwargs = mock_service_cls.call_args.kwargs
|
||||
assert kwargs["own_reticulum"] is False
|
||||
assert kwargs["reticulum"] is handler.reticulum
|
||||
service.start.assert_called_once()
|
||||
|
||||
assert handler.list_peers()[0]["peer_id"] == "ee" * 16
|
||||
assert handler.list_files()[0]["path"] == "a.txt"
|
||||
assert handler.connect_peer("ee" * 16)["ok"] is True
|
||||
assert handler.browse_peer("ee" * 16)["ok"] is True
|
||||
assert handler.download_file("ee" * 16, "remote.txt")["ok"] is True
|
||||
assert handler.announce_now()["ok"] is True
|
||||
assert handler.disconnect_peer("ee" * 16)["ok"] is True
|
||||
|
||||
stopped = handler.stop()
|
||||
assert stopped["ok"] is True
|
||||
assert stopped["running"] is False
|
||||
service.stop.assert_called()
|
||||
assert handler.service is None
|
||||
|
||||
service.stop.reset_mock()
|
||||
handler.service = service
|
||||
handler.teardown()
|
||||
service.stop.assert_called()
|
||||
assert handler.service is None
|
||||
|
||||
|
||||
def test_settings_reject_sync_dir_change_while_running(handler):
|
||||
handler.service = MagicMock()
|
||||
handler.service.get_status.return_value = {"running": True}
|
||||
result = handler.update_settings(sync_directory="/tmp/other")
|
||||
assert result["ok"] is False
|
||||
assert "stop filesync" in result["error"]
|
||||
336
tests/backend/test_rns_filesync_security.py
Normal file
336
tests/backend/test_rns_filesync_security.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# SPDX-License-Identifier: 0BSD
|
||||
|
||||
"""Adversarial and Hypothesis fuzz coverage for RNS FileSync handler."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from meshchatx.src.backend.rns_filesync_handler import RnsFilesyncHandler
|
||||
from rns_filesync.paths import PathJailError, normalize_relpath
|
||||
from rns_filesync.permissions import PermissionStore
|
||||
|
||||
_TRAVERSAL_PAYLOADS = (
|
||||
"../etc/passwd",
|
||||
"..\\windows\\system32",
|
||||
"a/../../b",
|
||||
"/etc/passwd",
|
||||
"C:\\Windows\\System32",
|
||||
"a\x00b",
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"//evil",
|
||||
"\\\\evil",
|
||||
"foo/../../../etc/shadow",
|
||||
".rns-filesync.db",
|
||||
"dir/.rns-xfer-temp",
|
||||
)
|
||||
|
||||
_BAD_HASHES = (
|
||||
"",
|
||||
" ",
|
||||
"zz",
|
||||
"not-hex",
|
||||
"../aabb",
|
||||
"a" * 15,
|
||||
"g" * 32,
|
||||
"\x00" * 16,
|
||||
"' OR '1'='1",
|
||||
"../../identity",
|
||||
"а" * 32,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(tmp_path):
|
||||
storage = tmp_path / "identity_a"
|
||||
storage.mkdir()
|
||||
identity = SimpleNamespace(hash=b"\xaa" * 16)
|
||||
return RnsFilesyncHandler(
|
||||
reticulum_instance=MagicMock(name="reticulum"),
|
||||
identity=identity,
|
||||
storage_dir=str(storage),
|
||||
)
|
||||
|
||||
|
||||
def test_path_traversal_payloads_rejected_by_normalize():
|
||||
for payload in _TRAVERSAL_PAYLOADS:
|
||||
with pytest.raises(PathJailError):
|
||||
normalize_relpath(payload)
|
||||
|
||||
|
||||
def test_download_rejects_path_traversal(handler):
|
||||
handler.service = MagicMock()
|
||||
handler.service.download_file.side_effect = lambda peer, path: {
|
||||
"ok": False,
|
||||
"error": f"path jail: {path}",
|
||||
}
|
||||
for payload in _TRAVERSAL_PAYLOADS:
|
||||
result = handler.download_file("bb" * 16, payload)
|
||||
assert result["ok"] is False
|
||||
assert "error" in result
|
||||
|
||||
|
||||
def test_download_and_browse_require_running(handler):
|
||||
assert handler.download_file("bb" * 16, "a.txt")["ok"] is False
|
||||
assert handler.browse_peer("bb" * 16)["ok"] is False
|
||||
assert handler.connect_peer("bb" * 16)["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
|
||||
def test_connect_rejects_or_handles_bad_hashes(handler, bad_hash):
|
||||
handler.service = MagicMock()
|
||||
handler.service.connect_peer.return_value = {"ok": False, "error": "bad peer"}
|
||||
result = handler.connect_peer(bad_hash)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
if not str(bad_hash).strip():
|
||||
assert result["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_hash", _BAD_HASHES)
|
||||
def test_acl_grant_handles_bad_hashes(handler, bad_hash):
|
||||
result = handler.update_acl(
|
||||
identity_hash=bad_hash,
|
||||
perms=["read"],
|
||||
enforce=True,
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
def test_acl_enforce_denies_stranger_by_default_rules(handler):
|
||||
peer = "cc" * 16
|
||||
stranger = "dd" * 16
|
||||
handler.update_acl(identity_hash=peer, perms=["read"], enforce=True)
|
||||
acl = handler.get_acl()
|
||||
assert acl["enforce"] is True
|
||||
perms = PermissionStore()
|
||||
for rule_perm, targets in acl["rules"].items():
|
||||
for target in targets:
|
||||
short = {"read": "r", "write": "w", "delete": "d"}[rule_perm]
|
||||
perms.add_rule(f"{short}:{target}")
|
||||
assert perms.check(peer, "read") is True
|
||||
assert perms.check(stranger, "read") is False
|
||||
assert perms.check(stranger, "write") is False
|
||||
|
||||
|
||||
def test_acl_rules_text_replace_does_not_leak_old_rules(handler):
|
||||
peer_a = "11" * 16
|
||||
peer_b = "22" * 16
|
||||
handler.update_acl(identity_hash=peer_a, perms=["read", "write"], enforce=True)
|
||||
handler.update_acl(
|
||||
rules_text=f"r:{peer_b}",
|
||||
replace=True,
|
||||
enforce=True,
|
||||
)
|
||||
acl = handler.get_acl()
|
||||
assert peer_a not in acl["rules"].get("read", [])
|
||||
assert peer_b in acl["rules"].get("read", [])
|
||||
assert peer_a not in acl["rules"].get("write", [])
|
||||
|
||||
|
||||
def test_oversized_rules_text_does_not_raise(handler):
|
||||
huge = ("r:" + ("ab" * 16) + "\n") * 5000
|
||||
result = handler.update_acl(rules_text=huge, replace=True, enforce=True)
|
||||
assert result["ok"] is True
|
||||
assert isinstance(result["rules"], dict)
|
||||
|
||||
|
||||
def test_teardown_clears_service_and_isolates_storage(tmp_path):
|
||||
storage_a = tmp_path / "a"
|
||||
storage_b = tmp_path / "b"
|
||||
storage_a.mkdir()
|
||||
storage_b.mkdir()
|
||||
identity_a = SimpleNamespace(hash=b"\xaa" * 16)
|
||||
identity_b = SimpleNamespace(hash=b"\xbb" * 16)
|
||||
ha = RnsFilesyncHandler(MagicMock(), identity_a, str(storage_a))
|
||||
hb = RnsFilesyncHandler(MagicMock(), identity_b, str(storage_b))
|
||||
peer = "ee" * 16
|
||||
ha.update_acl(identity_hash=peer, perms=["read"], enforce=True)
|
||||
hb.update_acl(identity_hash=peer, perms=["write"], enforce=True)
|
||||
|
||||
svc = MagicMock()
|
||||
svc.get_status.return_value = {"running": True}
|
||||
ha.service = svc
|
||||
ha.teardown()
|
||||
assert ha.service is None
|
||||
svc.stop.assert_called()
|
||||
|
||||
assert "read" in ha.get_acl()["rules"]
|
||||
assert peer in ha.get_acl()["rules"]["read"]
|
||||
assert "write" in hb.get_acl()["rules"]
|
||||
assert peer in hb.get_acl()["rules"]["write"]
|
||||
assert peer not in ha.get_acl()["rules"].get("write", [])
|
||||
assert (storage_a / "filesync" / "acl.txt").is_file()
|
||||
assert (storage_b / "filesync" / "acl.txt").is_file()
|
||||
assert storage_a.resolve() != storage_b.resolve()
|
||||
|
||||
|
||||
def test_concurrent_acl_mutations_stable(handler):
|
||||
peer = "ff" * 16
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def worker(idx: int):
|
||||
try:
|
||||
for i in range(40):
|
||||
handler.update_acl(
|
||||
identity_hash=peer,
|
||||
perms=["read"] if (idx + i) % 2 == 0 else ["write"],
|
||||
enforce=True,
|
||||
)
|
||||
acl = handler.get_acl()
|
||||
assert isinstance(acl["rules"], dict)
|
||||
assert acl["enforce"] is True
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=10)
|
||||
assert not errors
|
||||
final = handler.get_acl()
|
||||
assert final["enforce"] is True
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=40,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(
|
||||
path=st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "S", "Z", "Cc"),
|
||||
),
|
||||
min_size=0,
|
||||
max_size=400,
|
||||
),
|
||||
)
|
||||
def test_download_path_fuzz_never_raises(handler, path):
|
||||
handler.service = MagicMock()
|
||||
handler.service.download_file.side_effect = lambda peer, p: {
|
||||
"ok": False,
|
||||
"error": "denied",
|
||||
"path": p,
|
||||
}
|
||||
result = handler.download_file("aa" * 16, path)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=40,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(
|
||||
identity_hash=st.one_of(
|
||||
st.text(min_size=0, max_size=80),
|
||||
st.binary(min_size=0, max_size=40).map(lambda b: b.hex()),
|
||||
st.sampled_from(list(_BAD_HASHES)),
|
||||
),
|
||||
perms=st.lists(
|
||||
st.sampled_from(["read", "write", "delete", "admin", "nope", ""]),
|
||||
max_size=6,
|
||||
),
|
||||
enforce=st.booleans(),
|
||||
rules_text=st.one_of(st.none(), st.text(max_size=500)),
|
||||
replace=st.booleans(),
|
||||
)
|
||||
def test_acl_update_fuzz_never_raises(
|
||||
handler,
|
||||
identity_hash,
|
||||
perms,
|
||||
enforce,
|
||||
rules_text,
|
||||
replace,
|
||||
):
|
||||
result = handler.update_acl(
|
||||
identity_hash=identity_hash,
|
||||
perms=perms,
|
||||
enforce=enforce,
|
||||
rules_text=rules_text,
|
||||
replace=replace,
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=30,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(
|
||||
sync_directory=st.one_of(
|
||||
st.none(),
|
||||
st.text(min_size=0, max_size=200),
|
||||
st.sampled_from(["", " ", "../escape", "/tmp/x", "~/.ssh"]),
|
||||
),
|
||||
monitor=st.one_of(st.none(), st.booleans()),
|
||||
announce_interval=st.one_of(
|
||||
st.none(),
|
||||
st.integers(min_value=-10, max_value=10_000),
|
||||
st.text(max_size=20),
|
||||
),
|
||||
)
|
||||
def test_settings_fuzz_never_raises(handler, sync_directory, monitor, announce_interval):
|
||||
result = handler.update_settings(
|
||||
sync_directory=sync_directory,
|
||||
monitor=monitor,
|
||||
announce_interval=announce_interval,
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=25,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.too_slow, HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(peer_id=st.text(min_size=0, max_size=100))
|
||||
def test_disconnect_fuzz_never_raises(handler, peer_id):
|
||||
handler.service = MagicMock()
|
||||
result = handler.disconnect_peer(peer_id)
|
||||
assert isinstance(result, dict)
|
||||
assert "ok" in result
|
||||
|
||||
|
||||
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
|
||||
def test_start_with_malicious_interval_rejected(mock_service_cls, handler):
|
||||
result = handler.start(announce_interval=1)
|
||||
assert result["ok"] is False
|
||||
mock_service_cls.assert_not_called()
|
||||
|
||||
|
||||
@patch("meshchatx.src.backend.rns_filesync_handler.FileSyncService")
|
||||
def test_start_wires_callbacks_and_reuses_host_reticulum(mock_service_cls, handler):
|
||||
service = MagicMock()
|
||||
service.start.return_value = "ab" * 16
|
||||
service.get_status.return_value = {
|
||||
"running": True,
|
||||
"sync_directory": handler._sync_directory,
|
||||
"identity_hash": "aa" * 16,
|
||||
"destination_hash": "ab" * 16,
|
||||
"peers": 0,
|
||||
"files": 0,
|
||||
"whitelist": False,
|
||||
"monitor": True,
|
||||
}
|
||||
mock_service_cls.return_value = service
|
||||
result = handler.start()
|
||||
assert result["ok"] is True
|
||||
assert mock_service_cls.call_args.kwargs["own_reticulum"] is False
|
||||
assert service.on_error is not None
|
||||
assert service.on_sync_progress is not None
|
||||
|
|
@ -11,6 +11,7 @@ describe("NetworkVisualiserToolbar", () => {
|
|||
edgeCount: 8,
|
||||
onlineInterfaceCount: 2,
|
||||
offlineInterfaceCount: 1,
|
||||
preferredRenderer: "auto",
|
||||
engineMode: "wasm",
|
||||
fps: 58,
|
||||
...props,
|
||||
|
|
@ -27,23 +28,33 @@ describe("NetworkVisualiserToolbar", () => {
|
|||
},
|
||||
});
|
||||
|
||||
it("shows WASM engine label and FPS", () => {
|
||||
it("shows engine select and FPS", () => {
|
||||
const wrapper = mountToolbar();
|
||||
expect(wrapper.text()).toContain("visualiser.engine_wasm");
|
||||
const select = wrapper.find("#visualiser-engine-select");
|
||||
expect(select.exists()).toBe(true);
|
||||
expect(select.element.value).toBe("auto");
|
||||
expect(wrapper.text()).toContain("58");
|
||||
expect(wrapper.text()).toContain("visualiser.fps");
|
||||
expect(wrapper.text()).toContain("visualiser.renderer_option_webgl");
|
||||
expect(wrapper.text()).toContain("visualiser.renderer_option_vis");
|
||||
});
|
||||
|
||||
it("shows JS fallback engine label", () => {
|
||||
const wrapper = mountToolbar({ engineMode: "fallback", fps: 0 });
|
||||
expect(wrapper.text()).toContain("visualiser.engine_fallback");
|
||||
expect(wrapper.text()).toContain("--");
|
||||
it("emits preferred renderer when engine select changes", async () => {
|
||||
const wrapper = mountToolbar({ preferredRenderer: "auto", engineMode: "webgl" });
|
||||
const select = wrapper.find("#visualiser-engine-select");
|
||||
await select.setValue("webgl");
|
||||
expect(wrapper.emitted("update:preferredRenderer")?.[0]).toEqual(["webgl"]);
|
||||
await select.setValue("vis");
|
||||
expect(wrapper.emitted("update:preferredRenderer")?.[1]).toEqual(["vis"]);
|
||||
});
|
||||
|
||||
it("shows WebGL engine label", () => {
|
||||
const wrapper = mountToolbar({ engineMode: "webgl", fps: 60 });
|
||||
expect(wrapper.text()).toContain("visualiser.engine_webgl");
|
||||
expect(wrapper.text()).toContain("60");
|
||||
it("styles select from active engine mode", () => {
|
||||
const webgl = mountToolbar({ engineMode: "webgl", preferredRenderer: "webgl", fps: 60 });
|
||||
expect(webgl.find("#visualiser-engine-select").classes().join(" ")).toContain("text-sky-600");
|
||||
|
||||
const fallback = mountToolbar({ engineMode: "fallback", preferredRenderer: "vis", fps: 0 });
|
||||
expect(fallback.find("#visualiser-engine-select").classes().join(" ")).toContain("text-amber-600");
|
||||
expect(fallback.text()).toContain("--");
|
||||
});
|
||||
|
||||
it("uses MDI magnify for search and refresh for update button", () => {
|
||||
|
|
|
|||
136
tests/frontend/RnsFilesyncPage.test.js
Normal file
136
tests/frontend/RnsFilesyncPage.test.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import RnsFilesyncPage from "@/components/filesync/RnsFilesyncPage.vue";
|
||||
import ToastUtils from "@/js/ToastUtils";
|
||||
|
||||
vi.mock("@/js/ToastUtils", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/js/registries/wsEventRegistry.js", () => ({
|
||||
onWsEvent: vi.fn(),
|
||||
offWsEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("RnsFilesyncPage.vue", () => {
|
||||
let apiMock;
|
||||
|
||||
beforeEach(() => {
|
||||
apiMock = {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
};
|
||||
window.api = apiMock;
|
||||
|
||||
apiMock.get.mockImplementation((url) => {
|
||||
if (url === "/api/v1/filesync/status") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
running: false,
|
||||
sync_directory: "/tmp/sync",
|
||||
peers: 0,
|
||||
files: 0,
|
||||
monitor: true,
|
||||
announce_interval: 300,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/filesync/peers") {
|
||||
return Promise.resolve({ data: { peers: [] } });
|
||||
}
|
||||
if (url === "/api/v1/filesync/files") {
|
||||
return Promise.resolve({ data: { files: [] } });
|
||||
}
|
||||
if (url === "/api/v1/filesync/acl") {
|
||||
return Promise.resolve({ data: { enforce: false, rules: {} } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
apiMock.post.mockResolvedValue({ data: { ok: true } });
|
||||
apiMock.patch.mockResolvedValue({ data: { ok: true } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.api;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mountPage = () =>
|
||||
mount(RnsFilesyncPage, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: {
|
||||
template: '<div class="mdi-stub" :data-icon-name="iconName"></div>',
|
||||
props: ["iconName"],
|
||||
},
|
||||
ToolsPageHeader: {
|
||||
template: "<div class='header-stub'>{{ title }}</div>",
|
||||
props: ["title", "description", "eyebrow", "icon", "accent"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("renders and loads status", async () => {
|
||||
const wrapper = mountPage();
|
||||
await vi.waitFor(() => expect(apiMock.get).toHaveBeenCalledWith("/api/v1/filesync/status"));
|
||||
expect(wrapper.text()).toContain("rns_filesync.title");
|
||||
expect(wrapper.vm.syncDirectory).toBe("/tmp/sync");
|
||||
});
|
||||
|
||||
it("starts filesync and toasts success", async () => {
|
||||
const wrapper = mountPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
|
||||
await wrapper.vm.startService();
|
||||
expect(apiMock.post).toHaveBeenCalledWith(
|
||||
"/api/v1/filesync/start",
|
||||
expect.objectContaining({
|
||||
sync_directory: "/tmp/sync",
|
||||
monitor: true,
|
||||
}),
|
||||
);
|
||||
expect(ToastUtils.success).toHaveBeenCalledWith("rns_filesync.started");
|
||||
});
|
||||
|
||||
it("shows error toast when connect fails", async () => {
|
||||
apiMock.post.mockRejectedValueOnce(new Error("path timeout"));
|
||||
const wrapper = mountPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
|
||||
wrapper.vm.status.running = true;
|
||||
wrapper.vm.connectHash = "ab".repeat(16);
|
||||
await wrapper.vm.connectPeer();
|
||||
expect(ToastUtils.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("browses and downloads a remote file", async () => {
|
||||
apiMock.post.mockImplementation((url) => {
|
||||
if (url === "/api/v1/filesync/browse") {
|
||||
return Promise.resolve({
|
||||
data: { ok: true, files: [{ path: "notes.txt", size: 12 }] },
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: { ok: true } });
|
||||
});
|
||||
const wrapper = mountPage();
|
||||
await vi.waitFor(() => expect(wrapper.vm.syncDirectory).toBe("/tmp/sync"));
|
||||
wrapper.vm.status.running = true;
|
||||
wrapper.vm.browsePeerId = "cd".repeat(16);
|
||||
await wrapper.vm.browsePeer();
|
||||
expect(wrapper.vm.remoteFiles).toEqual([{ path: "notes.txt", size: 12 }]);
|
||||
await wrapper.vm.downloadFile("notes.txt");
|
||||
expect(apiMock.post).toHaveBeenCalledWith("/api/v1/filesync/download", {
|
||||
peer_id: "cd".repeat(16),
|
||||
path: "notes.txt",
|
||||
});
|
||||
expect(ToastUtils.info).toHaveBeenCalledWith("rns_filesync.download_started");
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ describe("ToolsPage.vue", () => {
|
|||
{ path: "/ping", name: "ping", component: { template: "div" } },
|
||||
{ path: "/rnprobe", name: "rnprobe", component: { template: "div" } },
|
||||
{ path: "/rncp", name: "rncp", component: { template: "div" } },
|
||||
{ path: "/rns-filesync", name: "rns-filesync", component: { template: "div" } },
|
||||
{ path: "/rnsh", name: "rnsh", component: { template: "div" } },
|
||||
{ path: "/rnstatus", name: "rnstatus", component: { template: "div" } },
|
||||
{ path: "/rnpath", name: "rnpath", component: { template: "div" } },
|
||||
|
|
|
|||
|
|
@ -174,11 +174,16 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
|
|||
expect(gradle).toContain("vendor/lxmfy/lxmfy");
|
||||
expect(gradle).toContain("syncLxmfyPython");
|
||||
expect(gradle).toContain("src/main/python/lxmfy");
|
||||
expect(gradle).toMatch(
|
||||
/dependsOn\(tasks\.named\("syncMeshchatPython"\),\s*tasks\.named\("syncLxmfyPython"\)\)/
|
||||
);
|
||||
const initPy = readSource("vendor/lxmfy/lxmfy/__init__.py");
|
||||
expect(initPy.length).toBeGreaterThan(0);
|
||||
expect(gradle).toContain("syncRnsFilesyncPython");
|
||||
expect(gradle).toContain("vendor/rns_filesync/rns_filesync");
|
||||
expect(gradle).toContain("src/main/python/rns_filesync");
|
||||
expect(gradle).toMatch(/syncMeshchatPython/);
|
||||
expect(gradle).toMatch(/syncLxmfyPython/);
|
||||
expect(gradle).toMatch(/syncRnsFilesyncPython/);
|
||||
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);
|
||||
});
|
||||
|
||||
it("Android wrapper clears stale storage lock before main()", () => {
|
||||
|
|
|
|||
34
tests/frontend/filesyncPackagingContracts.test.js
Normal file
34
tests/frontend/filesyncPackagingContracts.test.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const ROOT = resolve(__dirname, "../..");
|
||||
|
||||
function readSource(relativePath) {
|
||||
return readFileSync(resolve(ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("filesync packaging contracts", () => {
|
||||
it("docker ignore excludes rns_filesync non-runtime trees", () => {
|
||||
const dockerignore = readSource(".dockerignore");
|
||||
expect(dockerignore).toContain("vendor/rns_filesync/tests/");
|
||||
expect(dockerignore).toContain("vendor/rns_filesync/docker/");
|
||||
expect(dockerignore).toContain("vendor/rns_filesync/packaging/");
|
||||
expect(dockerignore).toContain("vendor/rns_filesync/sideband/");
|
||||
});
|
||||
|
||||
it("package verify denylist covers rns_filesync bloat paths", () => {
|
||||
const script = readSource("scripts/ci/verify-package-contents.sh");
|
||||
expect(script).toContain("vendor/rns_filesync/tests");
|
||||
expect(script).toContain("vendor/rns_filesync/docker");
|
||||
expect(script).toContain("vendor/rns_filesync/packaging");
|
||||
});
|
||||
|
||||
it("tools registry exposes filesync without comingSoon", () => {
|
||||
const tools = readSource("meshchatx/src/frontend/js/registries/coreToolsEntries.js");
|
||||
expect(tools).toContain('name: "rns-filesync"');
|
||||
expect(tools).toContain('route: { name: "rns-filesync" }');
|
||||
const block = tools.slice(tools.indexOf('name: "rns-filesync"'), tools.indexOf('name: "debug-logs"'));
|
||||
expect(block).not.toContain("comingSoon");
|
||||
});
|
||||
});
|
||||
7
vendor/README.txt
vendored
7
vendor/README.txt
vendored
|
|
@ -6,3 +6,10 @@ lxmfy/
|
|||
Declared version (pyproject): see vendor/lxmfy/pyproject.toml
|
||||
Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
|
||||
commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.
|
||||
|
||||
rns_filesync/
|
||||
Upstream: https://github.com/Quad4-Software/RNS-Filesync
|
||||
Bundled revision: 12161f3f47d4c7421990e5790aa3d41e08fd623a
|
||||
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.
|
||||
|
|
|
|||
13
vendor/rns_filesync/.gitignore
vendored
Normal file
13
vendor/rns_filesync/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.ruff_cache/
|
||||
.hypothesis/
|
||||
*.tmp
|
||||
9
vendor/rns_filesync/LICENSE
vendored
Normal file
9
vendor/rns_filesync/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
Copyright 2025-2026 Sudo-Ivan / Quad4.io
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
102
vendor/rns_filesync/Makefile
vendored
Normal file
102
vendor/rns_filesync/Makefile
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
EDITOR ?= nano
|
||||
PYTHON ?= python3
|
||||
PIP ?= pip
|
||||
PREFIX ?= /usr/local
|
||||
DESTDIR ?=
|
||||
RNID_ID ?= e46112d44649266d71fe2193e00a4710
|
||||
RNID_KEY ?= $(HOME)/.rngit/client_identity
|
||||
RNS_REMOTE ?= rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync
|
||||
TAG ?= v1.0.0
|
||||
|
||||
VERSION := $(shell $(PYTHON) -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])" 2>/dev/null || $(PYTHON) -c "from rns_filesync._meta import __version__; print(__version__)")
|
||||
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
GIT_DIRTY := $(shell git diff --quiet 2>/dev/null && echo 0 || echo 1)
|
||||
|
||||
.PHONY: all clean meta build wheel sdist sign upload release release-rns tag retag \
|
||||
test test-live test-services test-bwrap install install-user man uninstall version help
|
||||
|
||||
all: build
|
||||
|
||||
help:
|
||||
@echo "Targets: meta build wheel sdist test test-services install install-user man clean release"
|
||||
@echo "Version: $(VERSION) commit: $(GIT_COMMIT) built: $(BUILD_DATE)"
|
||||
|
||||
version: meta
|
||||
@$(PYTHON) -c "from rns_filesync._meta import version_string; print(version_string())"
|
||||
|
||||
meta:
|
||||
VERSION="$(VERSION)" BUILD_DATE="$(BUILD_DATE)" GIT_COMMIT="$(GIT_COMMIT)" GIT_DIRTY="$(GIT_DIRTY)" \
|
||||
$(PYTHON) scripts/bake_meta.py
|
||||
|
||||
clean:
|
||||
rm -rf dist/ build/ *.egg-info .pytest_cache .coverage htmlcov
|
||||
find . -type d -name __pycache__ -prune -exec rm -rf {} +
|
||||
|
||||
build: meta wheel sdist
|
||||
|
||||
wheel: meta
|
||||
$(PYTHON) -m build --wheel
|
||||
|
||||
sdist: meta
|
||||
$(PYTHON) -m build --sdist
|
||||
|
||||
sign: build
|
||||
for f in dist/*.tar.gz dist/*.whl; do \
|
||||
rnid -f -i $(RNID_KEY) -s "$$f" -w "$$f.rsg"; \
|
||||
done
|
||||
|
||||
upload: sign
|
||||
twine upload dist/*.whl dist/*.tar.gz
|
||||
|
||||
release: tag release-rns
|
||||
|
||||
release-rns: sign
|
||||
mkdir -p dist
|
||||
RELEASE_TAG=$(TAG) EDITOR="$(EDITOR)" \
|
||||
rngit release -i $(RNID_KEY) $(RNS_REMOTE) create $(TAG):./dist
|
||||
|
||||
tag:
|
||||
@if git rev-parse -q --verify "refs/tags/$(TAG)" >/dev/null 2>&1; then \
|
||||
echo "Tag $(TAG) already exists. Use: make retag TAG=$(TAG)"; \
|
||||
echo "Or republish rngit only: make release-rns TAG=$(TAG)"; \
|
||||
exit 1; \
|
||||
fi
|
||||
git tag -s $(TAG) -m "$(TAG)"
|
||||
git push origin $(TAG)
|
||||
|
||||
retag:
|
||||
git tag -d $(TAG) 2>/dev/null || true
|
||||
-git push origin :refs/tags/$(TAG) 2>/dev/null || true
|
||||
git tag -s $(TAG) -m "$(TAG)"
|
||||
git push origin $(TAG)
|
||||
|
||||
test:
|
||||
$(PYTHON) -m pytest -m "not exploratory and not live"
|
||||
|
||||
test-live:
|
||||
$(PYTHON) -m pytest -m live
|
||||
|
||||
test-services:
|
||||
./packaging/docker-test/run-all.sh
|
||||
|
||||
test-bwrap:
|
||||
./packaging/docker-test/test-bwrap.sh
|
||||
|
||||
install: meta
|
||||
$(PIP) install --break-system-packages .
|
||||
$(MAKE) man
|
||||
|
||||
install-user: meta
|
||||
$(PIP) install --user .
|
||||
mkdir -p $(HOME)/.local/share/man/man1
|
||||
cp man/man1/rns-filesync.1 $(HOME)/.local/share/man/man1/
|
||||
|
||||
man:
|
||||
mkdir -p $(DESTDIR)$(PREFIX)/share/man/man1
|
||||
cp man/man1/rns-filesync.1 $(DESTDIR)$(PREFIX)/share/man/man1/
|
||||
|
||||
uninstall:
|
||||
$(PIP) uninstall -y rns-filesync || true
|
||||
rm -f $(DESTDIR)$(PREFIX)/share/man/man1/rns-filesync.1
|
||||
rm -f $(HOME)/.local/share/man/man1/rns-filesync.1
|
||||
273
vendor/rns_filesync/README.md
vendored
Normal file
273
vendor/rns_filesync/README.md
vendored
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
# RNS FileSync
|
||||
|
||||
Decentralized peer-to-peer file synchronization over the Reticulum Network Stack.
|
||||
|
||||
This package is designed as an embeddable library for host programs such as
|
||||
MeshChatX and Sideband, with a thin CLI for standalone use. There is no TUI.
|
||||
|
||||
For usability and easy usage this follows rngit permissions and configuration.
|
||||
|
||||
## Features
|
||||
|
||||
- Peer-to-peer sync with no central server
|
||||
- Delta sync for changed blocks
|
||||
- Path-jailed writes and rngit-style ACL (permission:target)
|
||||
- Embed-safe: reuses an existing RNS.Reticulum instance when present
|
||||
- Works over any Reticulum medium (radio, LoRa, WiFi, internet)
|
||||
- Daemon mode via --no-repl for init systems and containers
|
||||
|
||||
## Install
|
||||
|
||||
Over Reticulum with pip-rns (no internet required once you have a path):
|
||||
|
||||
```bash
|
||||
pip-rns install rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --pipx
|
||||
```
|
||||
|
||||
From a release wheel over Reticulum (no source build):
|
||||
|
||||
```bash
|
||||
pip-rns install --from-release rns://06a54b505bb67b25ef3f8097e8001edc/public/rns-filesync --ref v1.0.0 --pipx
|
||||
```
|
||||
|
||||
From GitHub with pipx:
|
||||
|
||||
```bash
|
||||
pipx install git+https://github.com/Quad4-Software/RNS-Filesync
|
||||
```
|
||||
|
||||
From a local wheel:
|
||||
|
||||
```bash
|
||||
make wheel
|
||||
pipx install dist/rns_filesync-*.whl
|
||||
# or: pip install dist/rns_filesync-*.whl
|
||||
```
|
||||
|
||||
From a local checkout (development):
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
# or: make install-user
|
||||
```
|
||||
|
||||
Requires rns>=1.3.9. Current package version: **1.0.0**.
|
||||
|
||||
```bash
|
||||
rns-filesync -v
|
||||
# rns-filesync 1.0.0 | built 2026-07-19T12:00:00Z | commit abc1234
|
||||
```
|
||||
|
||||
## Config
|
||||
|
||||
FileSync uses an rngit-like config directory (default `~/.rns_filesync/`):
|
||||
|
||||
```bash
|
||||
rns-filesync -d ~/shared
|
||||
# creates ~/.rns_filesync/config on first run
|
||||
```
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `--config DIR` | FileSync config directory (default `~/.rns_filesync`) |
|
||||
| `--rnsconfig DIR` | Reticulum config directory (default `~/.reticulum`) |
|
||||
| `-d DIR` | Sync directory (overrides `[filesync] directory`) |
|
||||
|
||||
Example `~/.rns_filesync/config`:
|
||||
|
||||
```ini
|
||||
[filesync]
|
||||
announce_interval = 300
|
||||
# directory = ~/shared
|
||||
identity = rns_filesync
|
||||
# peers = 9710b86ba12c42d1d8f30f74fe509286
|
||||
# blocked_identities = d31aeea49873006f13b3415520666a4e
|
||||
|
||||
[aliases]
|
||||
# alice = d09285e660cfe27cee6d9a0beb58b7e0
|
||||
|
||||
[access]
|
||||
# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[logging]
|
||||
loglevel = 4
|
||||
```
|
||||
|
||||
Identities are stored under `~/.rns_filesync/identities/`.
|
||||
|
||||
## Permissions
|
||||
|
||||
Same rule form as rngit: `permission:target`.
|
||||
|
||||
| Perm | Meaning |
|
||||
|------|---------|
|
||||
| `r` | read |
|
||||
| `w` | write |
|
||||
| `d` | delete |
|
||||
| `rw` | read/write |
|
||||
| `rwd` | read/write/delete |
|
||||
| `adm` | admin (all) |
|
||||
|
||||
Targets: identity hash, alias, `all`, or `none`.
|
||||
|
||||
When any ACL rule is loaded, access is **deny by default**. With no rules configured, the peer stays open (library/embed default).
|
||||
|
||||
Sidecar `.allowed` files (first found wins alongside config `[access]`):
|
||||
|
||||
- `<sync_dir>.allowed`
|
||||
- `<parent>/<name>.allowed`
|
||||
- `<sync_dir>/.allowed`
|
||||
|
||||
See `permissions.example`.
|
||||
|
||||
```bash
|
||||
rns-filesync -d ~/shared --allow r:all --allow w:9710b86ba12c42d1d8f30f74fe509286
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
rns-filesync -d ~/shared
|
||||
rns-filesync -d ~/shared -p <peer_identity_hash>
|
||||
rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
|
||||
rns-filesync -v
|
||||
rns-filesync --version
|
||||
```
|
||||
|
||||
`-v` / `--version` prints version, bake build date, and git commit when available.
|
||||
|
||||
`-p` expects a peer **identity hash**. A destination hash is accepted as fallback
|
||||
after the peer has announced.
|
||||
|
||||
Interactive commands: `status`, `peers`, `files`, `connect`, `disconnect`,
|
||||
`browse`, `download`, `announce`, `quit`.
|
||||
|
||||
Logging verbosity uses `--verbose` (repeatable). Quiet mode is `-q`.
|
||||
|
||||
## Man page
|
||||
|
||||
```bash
|
||||
make install-user # installs man page to ~/.local/share/man/man1/
|
||||
man rns-filesync
|
||||
```
|
||||
|
||||
## Library embed (MeshChatX-style)
|
||||
|
||||
```python
|
||||
from rns_filesync import FileSyncService
|
||||
from rns_filesync.permissions import PermissionStore
|
||||
|
||||
perms = PermissionStore()
|
||||
perms.add_rule("r:all")
|
||||
perms.add_rule("w:9710b86ba12c42d1d8f30f74fe509286")
|
||||
|
||||
service = FileSyncService(
|
||||
identity=host_identity,
|
||||
sync_directory="/path/to/sync",
|
||||
reticulum=existing_reticulum,
|
||||
permissions=perms,
|
||||
)
|
||||
dest_hash = service.start(monitor=True)
|
||||
service.connect_peer(peer_identity_hash)
|
||||
service.stop()
|
||||
```
|
||||
|
||||
Keep aspect `rns_filesync.filesync` for wire compatibility.
|
||||
|
||||
## Sideband plugin
|
||||
|
||||
Install `rns-filesync` into the same Python environment Sideband uses, then copy
|
||||
the drop-in plugins from `sideband/` into Sideband's plugins directory and
|
||||
enable service/command plugins in Sideband settings.
|
||||
|
||||
```bash
|
||||
# After installing rns-filesync (pip / pipx / pip-rns):
|
||||
cp sideband/rns_filesync_service.py ~/.config/sideband/plugins/
|
||||
cp sideband/rns_filesync_command.py ~/.config/sideband/plugins/
|
||||
```
|
||||
|
||||
Set Sideband's plugin path to that directory, enable **service plugins** and
|
||||
**command plugins**, then restart Sideband.
|
||||
|
||||
The service plugin reuses Sideband's identity and Reticulum instance. Sync
|
||||
directory, peers, and ACL come from `~/.rns_filesync/config`. If no directory
|
||||
is set there, it defaults to `~/.config/sideband/filesync`.
|
||||
|
||||
Remote LXMF commands (from a peer allowed to run Sideband commands):
|
||||
|
||||
```text
|
||||
filesync status
|
||||
filesync peers
|
||||
filesync files
|
||||
filesync announce
|
||||
filesync connect <identity_hash>
|
||||
filesync disconnect <peer_id>
|
||||
```
|
||||
|
||||
## Daemon and packaging
|
||||
|
||||
Init units (non-root) live under packaging/ for systemd (system and user),
|
||||
OpenRC, dinit, and runit. See packaging/README.md.
|
||||
|
||||
```bash
|
||||
# system (dedicated rns-filesync user, hardened)
|
||||
sudo systemctl enable --now rns-filesync
|
||||
|
||||
# or per-user
|
||||
systemctl --user enable --now rns-filesync
|
||||
```
|
||||
|
||||
Daemon entrypoint:
|
||||
|
||||
```bash
|
||||
rns-filesync -d ~/shared --no-repl -q
|
||||
```
|
||||
|
||||
Bubblewrap sandbox example (full command in packaging/README.md):
|
||||
|
||||
```bash
|
||||
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
|
||||
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
|
||||
|
||||
exec bwrap \
|
||||
--die-with-parent \
|
||||
--new-session \
|
||||
--proc /proc \
|
||||
--dev /dev \
|
||||
--ro-bind / / \
|
||||
--tmpfs /tmp \
|
||||
--bind "$DATA" "$DATA" \
|
||||
--uid "$(id -u)" --gid "$(id -g)" \
|
||||
rns-filesync \
|
||||
--config "$DATA/config" \
|
||||
--rnsconfig "$DATA/reticulum" \
|
||||
-d "$DATA/sync" \
|
||||
--no-repl \
|
||||
-q
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Rootless multi-stage image and wheel builder under docker/. See docker/README.md.
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
make test
|
||||
pytest -m "not live and not exploratory"
|
||||
pytest -m e2e
|
||||
pytest -m live
|
||||
```
|
||||
|
||||
Markers: `unit`, `acceptance`, `smoke`, `e2e`, `fuzz`, `live`, `exploratory`.
|
||||
|
||||
Useful Make targets (aligned with pip-rns): `meta`, `wheel`, `sdist`, `test`,
|
||||
`install-user`, `man`, `sign`, `release-rns`, `clean`, `test-services`.
|
||||
|
||||
## License
|
||||
|
||||
BSD-2-Clause. See LICENSE.
|
||||
12
vendor/rns_filesync/README_RU.md
vendored
Normal file
12
vendor/rns_filesync/README_RU.md
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# RNS FileSync (RU)
|
||||
|
||||
English README is the source of truth: [README.md](README.md).
|
||||
|
||||
Кратко: библиотека и CLI для синхронизации каталогов через Reticulum (`rns>=1.3.9`).
|
||||
Конфиг и ACL как у rngit: `~/.rns_filesync/config`, правила `r:hash` / `w:all`, файлы `.allowed`.
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
rns-filesync -d ~/shared -p <identity_hash>
|
||||
rns-filesync --config ~/.rns_filesync --rnsconfig ~/.reticulum -d ~/shared
|
||||
```
|
||||
46
vendor/rns_filesync/docker/Dockerfile
vendored
Normal file
46
vendor/rns_filesync/docker/Dockerfile
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Multi-stage rootless runtime image for rns-filesync.
|
||||
# Build: docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
|
||||
# Run instructions: see docker/README.md
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
||||
|
||||
COPY pyproject.toml README.md LICENSE ./
|
||||
COPY rns_filesync ./rns_filesync
|
||||
COPY scripts ./scripts
|
||||
COPY man ./man
|
||||
|
||||
RUN python -m pip install --no-cache-dir --upgrade pip build \
|
||||
&& python scripts/bake_meta.py \
|
||||
&& python -m build --wheel \
|
||||
&& python -m venv /opt/venv \
|
||||
&& /opt/venv/bin/pip install --no-cache-dir dist/*.whl \
|
||||
&& /opt/venv/bin/pip uninstall -y pip setuptools wheel \
|
||||
&& find /opt/venv -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine AS runtime
|
||||
|
||||
RUN apk upgrade --no-cache \
|
||||
&& adduser -D -u 1000 -h /home/filesync filesync \
|
||||
&& mkdir -p /config /data /reticulum \
|
||||
&& chown -R filesync:filesync /config /data /reticulum /home/filesync
|
||||
|
||||
COPY --from=builder /opt/venv /opt/venv
|
||||
|
||||
ENV PATH=/opt/venv/bin:$PATH \
|
||||
HOME=/home/filesync \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
USER filesync
|
||||
WORKDIR /home/filesync
|
||||
|
||||
VOLUME ["/config", "/data", "/reticulum"]
|
||||
|
||||
ENTRYPOINT ["rns-filesync"]
|
||||
CMD ["--config", "/config", "--rnsconfig", "/reticulum", "-d", "/data", "--no-repl", "-q"]
|
||||
29
vendor/rns_filesync/docker/Dockerfile.build
vendored
Normal file
29
vendor/rns_filesync/docker/Dockerfile.build
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Wheel builder image. Writes .whl artifacts to /output.
|
||||
# Build and extract:
|
||||
# docker build -f docker/Dockerfile.build -t rns-filesync-build .
|
||||
# docker run --rm -v "$PWD/dist:/output" rns-filesync-build
|
||||
|
||||
ARG PYTHON_VERSION=3.12
|
||||
|
||||
FROM python:${PYTHON_VERSION}-alpine
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev git \
|
||||
&& adduser -D -u 1000 builder \
|
||||
&& mkdir -p /output \
|
||||
&& chown builder:builder /output /build
|
||||
|
||||
COPY --chown=builder:builder pyproject.toml README.md LICENSE ./
|
||||
COPY --chown=builder:builder rns_filesync ./rns_filesync
|
||||
COPY --chown=builder:builder scripts ./scripts
|
||||
COPY --chown=builder:builder man ./man
|
||||
|
||||
USER builder
|
||||
|
||||
RUN python -m pip install --user --no-cache-dir --upgrade pip build \
|
||||
&& python scripts/bake_meta.py \
|
||||
&& python -m build --wheel --outdir /output \
|
||||
&& ls -la /output
|
||||
|
||||
CMD ["sh", "-c", "cp -a /output/. /dist/ 2>/dev/null || true; ls -la /output"]
|
||||
84
vendor/rns_filesync/docker/README.md
vendored
Normal file
84
vendor/rns_filesync/docker/README.md
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Docker images for RNS FileSync
|
||||
|
||||
Rootless multi-stage runtime and a separate wheel builder.
|
||||
|
||||
## Runtime image (rootless)
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
docker build -f docker/Dockerfile -t rns-filesync:1.0.0 .
|
||||
```
|
||||
|
||||
Prepare host dirs owned by uid 1000:
|
||||
|
||||
```bash
|
||||
mkdir -p ./fs-config ./fs-data ./fs-reticulum
|
||||
```
|
||||
|
||||
Put FileSync config under ./fs-config (config file named config),
|
||||
Reticulum config under ./fs-reticulum, and the sync tree under ./fs-data.
|
||||
|
||||
Run (rootless-friendly, drop capabilities):
|
||||
|
||||
```bash
|
||||
docker run --rm -d \
|
||||
--name rns-filesync \
|
||||
--user=1000:1000 \
|
||||
--read-only \
|
||||
--tmpfs /tmp:rw,size=64m \
|
||||
--security-opt no-new-privileges \
|
||||
--cap-drop ALL \
|
||||
-v "$PWD/fs-config:/config:rw" \
|
||||
-v "$PWD/fs-data:/data:rw" \
|
||||
-v "$PWD/fs-reticulum:/reticulum:rw" \
|
||||
rns-filesync:1.0.0
|
||||
```
|
||||
|
||||
Override peers or flags:
|
||||
|
||||
```bash
|
||||
docker run --rm --user=1000:1000 \
|
||||
-v "$PWD/fs-config:/config" \
|
||||
-v "$PWD/fs-data:/data" \
|
||||
-v "$PWD/fs-reticulum:/reticulum" \
|
||||
rns-filesync:1.0.0 \
|
||||
--config /config --rnsconfig /reticulum -d /data --no-repl -p PEER_HASH
|
||||
```
|
||||
|
||||
Check version:
|
||||
|
||||
```bash
|
||||
docker run --rm rns-filesync:1.0.0 -v
|
||||
```
|
||||
|
||||
## Wheel builder
|
||||
|
||||
Build wheels inside the image and copy them out:
|
||||
|
||||
```bash
|
||||
mkdir -p dist
|
||||
docker build -f docker/Dockerfile.build -t rns-filesync-build .
|
||||
docker run --rm -v "$PWD/dist:/output" rns-filesync-build \
|
||||
sh -c 'cp -a /output/. /output/ 2>/dev/null; ls -la /output'
|
||||
```
|
||||
|
||||
If the image already wrote wheels under /output during build, extract with:
|
||||
|
||||
```bash
|
||||
cid=$(docker create rns-filesync-build)
|
||||
docker cp "$cid":/output/. ./dist/
|
||||
docker rm "$cid"
|
||||
```
|
||||
|
||||
Install a built wheel:
|
||||
|
||||
```bash
|
||||
pipx install dist/rns_filesync-*.whl
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The runtime user is filesync (uid 1000). Match host volume ownership.
|
||||
- Network is required for Reticulum interfaces you enable in /reticulum/config.
|
||||
- Prefer --no-repl for containers and service managers.
|
||||
128
vendor/rns_filesync/man/man1/rns-filesync.1
vendored
Normal file
128
vendor/rns_filesync/man/man1/rns-filesync.1
vendored
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
.\" Man page for rns-filesync
|
||||
.\" vim: ft=man
|
||||
.TH RNS-FILESYNC 1 "2026-07-19" "1.0.0" "User Commands"
|
||||
.SH NAME
|
||||
rns-filesync \- peer-to-peer directory sync over the Reticulum Network Stack
|
||||
.SH SYNOPSIS
|
||||
.B rns-filesync
|
||||
[\fB-v\fR|\fB--version\fR]
|
||||
[\fB--config\fR \fIDIR\fR]
|
||||
[\fB--rnsconfig\fR \fIDIR\fR]
|
||||
[\fB-d\fR \fIDIR\fR]
|
||||
[\fB-i\fR \fINAME\fR]
|
||||
[\fB-p\fR \fIHASH\fR]...
|
||||
[\fB-n\fR]
|
||||
[\fB-a\fR \fISECONDS\fR]
|
||||
[\fB--allowed\fR \fIFILE\fR]
|
||||
[\fB--allow\fR \fIRULE\fR]...
|
||||
[\fB--perms\fR \fIPERMS\fR]
|
||||
[\fB--verbose\fR]...
|
||||
[\fB-q\fR]
|
||||
[\fB--no-repl\fR]
|
||||
.SH DESCRIPTION
|
||||
.B rns-filesync
|
||||
synchronizes a directory with peers on the Reticulum network.
|
||||
It is an embeddable library (MeshChatX, Sideband) with a thin CLI.
|
||||
There is no TUI.
|
||||
.PP
|
||||
Configuration and ACL follow an rngit-like layout under
|
||||
.I ~/.rns_filesync/
|
||||
by default.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.BR \-v ", " \-\-version
|
||||
Print version, build date, and git commit (when baked), then exit.
|
||||
.TP
|
||||
.BI \-\-config " DIR"
|
||||
FileSync config directory (default:
|
||||
.IR ~/.rns_filesync ).
|
||||
.TP
|
||||
.BI \-\-rnsconfig " DIR"
|
||||
Reticulum config directory (default:
|
||||
.IR ~/.reticulum ).
|
||||
.TP
|
||||
.BR \-d ", " \-\-directory " " \fIDIR\fR
|
||||
Directory to synchronize (overrides
|
||||
.B [filesync] directory
|
||||
in config).
|
||||
.TP
|
||||
.BR \-i ", " \-\-identity " " \fINAME\fR
|
||||
Identity name stored under the FileSync config directory.
|
||||
.TP
|
||||
.BR \-p ", " \-\-peer " " \fIHASH\fR
|
||||
Peer identity hash to connect on start (repeatable).
|
||||
A destination hash is accepted as fallback after the peer has announced.
|
||||
.TP
|
||||
.BR \-n ", " \-\-no\-monitor
|
||||
Disable filesystem monitoring.
|
||||
.TP
|
||||
.BR \-a ", " \-\-announce\-interval " " \fISECONDS\fR
|
||||
Announce interval in seconds.
|
||||
.TP
|
||||
.BI \-\-allowed " FILE"
|
||||
Path to an
|
||||
.B .allowed
|
||||
ACL file (rngit-style
|
||||
.I permission:target
|
||||
lines).
|
||||
.TP
|
||||
.BI \-\-allow " RULE"
|
||||
ACL rule such as
|
||||
.I r:all
|
||||
or
|
||||
.IR w:HASH
|
||||
(repeatable).
|
||||
.TP
|
||||
.BI \-\-perms " PERMS"
|
||||
Legacy shorthand for bare
|
||||
.B \-\-allow
|
||||
hashes (
|
||||
.IR r ,
|
||||
.IR w ,
|
||||
.IR d ,
|
||||
.IR rw ,
|
||||
.IR rwd ).
|
||||
.TP
|
||||
.B \-\-verbose
|
||||
Increase Reticulum log verbosity (repeatable).
|
||||
.TP
|
||||
.BR \-q ", " \-\-quiet
|
||||
Reduce logging.
|
||||
.TP
|
||||
.B \-\-no\-repl
|
||||
Run without the interactive command prompt.
|
||||
.SH INTERACTIVE COMMANDS
|
||||
When the REPL is enabled:
|
||||
.BR status ,
|
||||
.BR peers ,
|
||||
.BR files ,
|
||||
.BR connect ,
|
||||
.BR disconnect ,
|
||||
.BR browse ,
|
||||
.BR download ,
|
||||
.BR announce ,
|
||||
.BR quit .
|
||||
.SH FILES
|
||||
.TP
|
||||
.I ~/.rns_filesync/config
|
||||
Main FileSync configuration (ConfigObj).
|
||||
.TP
|
||||
.I ~/.rns_filesync/identities/
|
||||
Stored identities.
|
||||
.TP
|
||||
.I <sync_dir>.allowed
|
||||
Optional sidecar ACL next to the sync directory.
|
||||
.SH EXAMPLES
|
||||
.nf
|
||||
rns-filesync -d ~/shared
|
||||
rns-filesync -d ~/shared -p 9710b86ba12c42d1d8f30f74fe509286
|
||||
rns-filesync -v
|
||||
.fi
|
||||
.SH SEE ALSO
|
||||
.BR rnsd (1),
|
||||
.BR rnstatus (1)
|
||||
.PP
|
||||
Documentation and Sideband plugins:
|
||||
.I https://github.com/Quad4-Software/RNS-Filesync
|
||||
.SH AUTHOR
|
||||
Quad4 Software
|
||||
126
vendor/rns_filesync/packaging/README.md
vendored
Normal file
126
vendor/rns_filesync/packaging/README.md
vendored
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Init and packaging files for RNS FileSync
|
||||
|
||||
All system units run as non-root user rns-filesync with state under
|
||||
/var/lib/rns-filesync. Prefer the systemd user unit when you do not want a
|
||||
system account.
|
||||
|
||||
## Layout
|
||||
|
||||
- systemd/rns-filesync.service - hardened system unit
|
||||
- systemd/rns-filesync.user.service - per-user unit (systemctl --user)
|
||||
- openrc/rns-filesync - OpenRC script
|
||||
- dinit/rns-filesync - dinit service
|
||||
- runit/rns-filesync/run - runit run script
|
||||
- sysusers.d/rns-filesync.conf - system user
|
||||
- tmpfiles.d/rns-filesync.conf - state directories
|
||||
- rns-filesync.env.example - optional env file
|
||||
|
||||
## systemd (system)
|
||||
|
||||
```bash
|
||||
sudo install -m 644 packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/
|
||||
sudo install -m 644 packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/
|
||||
sudo systemd-sysusers
|
||||
sudo systemd-tmpfiles --create
|
||||
sudo install -m 644 packaging/systemd/rns-filesync.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now rns-filesync
|
||||
```
|
||||
|
||||
Ensure /usr/bin/rns-filesync exists (package install or symlink).
|
||||
|
||||
## systemd (user)
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user ~/Filesync
|
||||
cp packaging/systemd/rns-filesync.user.service ~/.config/systemd/user/rns-filesync.service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now rns-filesync
|
||||
```
|
||||
|
||||
Adjust ExecStart paths if rns-filesync is not in ~/.local/bin.
|
||||
|
||||
## OpenRC
|
||||
|
||||
```bash
|
||||
sudo install -m 755 packaging/openrc/rns-filesync /etc/init.d/rns-filesync
|
||||
sudo rc-update add rns-filesync default
|
||||
sudo rc-service rns-filesync start
|
||||
```
|
||||
|
||||
Create user rns-filesync first if your distro does not use sysusers.
|
||||
|
||||
## dinit
|
||||
|
||||
```bash
|
||||
sudo install -m 644 packaging/dinit/rns-filesync /etc/dinit.d/rns-filesync
|
||||
sudo dinitctl enable rns-filesync
|
||||
```
|
||||
|
||||
## runit
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/sv/rns-filesync
|
||||
sudo install -m 755 packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
|
||||
sudo ln -s /etc/sv/rns-filesync /var/service/
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
System units use NoNewPrivileges, ProtectSystem=strict, empty capability set,
|
||||
and ReadWritePaths limited to /var/lib/rns-filesync. Sync ACL still comes from
|
||||
~/.rns_filesync style config under that state directory.
|
||||
|
||||
## Sandboxing
|
||||
|
||||
For an extra filesystem boundary on a host install (pip, pipx, or package),
|
||||
run under Bubblewrap. Network stays shared so Reticulum interfaces keep working.
|
||||
Do not add --unshare-net unless you intend to isolate networking.
|
||||
|
||||
```bash
|
||||
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox"
|
||||
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
|
||||
|
||||
exec bwrap \
|
||||
--die-with-parent \
|
||||
--new-session \
|
||||
--proc /proc \
|
||||
--dev /dev \
|
||||
--ro-bind / / \
|
||||
--tmpfs /tmp \
|
||||
--bind "$DATA" "$DATA" \
|
||||
--uid "$(id -u)" --gid "$(id -g)" \
|
||||
rns-filesync \
|
||||
--config "$DATA/config" \
|
||||
--rnsconfig "$DATA/reticulum" \
|
||||
-d "$DATA/sync" \
|
||||
--no-repl \
|
||||
-q
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Only "$DATA" is writable. The rest of the root filesystem is read-only.
|
||||
- Put config, sync tree, and Reticulum config under "$DATA" so the process
|
||||
does not need write access to your home directory.
|
||||
- If rns-filesync lives in a venv outside "$DATA", --ro-bind / / still allows
|
||||
reading it. Bind that path read-write only if the venv must be mutated.
|
||||
- For USB radios, device nodes under /dev are available via --dev /dev.
|
||||
Tighten further if your policy requires it.
|
||||
- Docker and Podman are a separate isolation model. See docker/README.md.
|
||||
|
||||
## Service file tests (Docker)
|
||||
|
||||
Run packaging unit tests against appropriate OS images:
|
||||
|
||||
| Init | Image |
|
||||
|------|-------|
|
||||
| systemd | debian:bookworm-slim (+ systemd) |
|
||||
| OpenRC | alpine:3.21 |
|
||||
| dinit | debian:bookworm-slim (service file + command) |
|
||||
| runit | Void Linux (Alpine fallback) |
|
||||
|
||||
```bash
|
||||
./packaging/docker-test/run-all.sh
|
||||
# or: make test-services
|
||||
```
|
||||
9
vendor/rns_filesync/packaging/dinit/rns-filesync
vendored
Normal file
9
vendor/rns_filesync/packaging/dinit/rns-filesync
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# dinit service for rns-filesync (non-root).
|
||||
# Install to /etc/dinit.d/rns-filesync
|
||||
# Create user rns-filesync and state dirs under /var/lib/rns-filesync first.
|
||||
|
||||
type = process
|
||||
command = /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q
|
||||
restart = true
|
||||
smooth-recovery = true
|
||||
runs-as = rns-filesync
|
||||
11
vendor/rns_filesync/packaging/docker-test/Dockerfile.dinit
vendored
Normal file
11
vendor/rns_filesync/packaging/docker-test/Dockerfile.dinit
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# Debian for dinit service-file validation and non-root command execution.
|
||||
# Full dinit as PID1 is not required to verify the shipped service definition.
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
python3 python3-pip ca-certificates procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
CMD ["/bin/sh"]
|
||||
9
vendor/rns_filesync/packaging/docker-test/Dockerfile.openrc
vendored
Normal file
9
vendor/rns_filesync/packaging/docker-test/Dockerfile.openrc
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Alpine + OpenRC: validate and start via openrc-run style command path.
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache \
|
||||
openrc python3 py3-pip procps shadow \
|
||||
&& mkdir -p /run/openrc \
|
||||
&& touch /run/openrc/softlevel
|
||||
|
||||
CMD ["/bin/sh"]
|
||||
8
vendor/rns_filesync/packaging/docker-test/Dockerfile.runit
vendored
Normal file
8
vendor/rns_filesync/packaging/docker-test/Dockerfile.runit
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Void Linux + runit.
|
||||
FROM ghcr.io/void-linux/void-glibc:latest
|
||||
|
||||
RUN xbps-install -Syu \
|
||||
&& xbps-install -Sy python3 python3-pip runit procps-ng shadow \
|
||||
&& xbps-remove -yO || true
|
||||
|
||||
CMD ["/bin/sh"]
|
||||
8
vendor/rns_filesync/packaging/docker-test/Dockerfile.runit-alpine
vendored
Normal file
8
vendor/rns_filesync/packaging/docker-test/Dockerfile.runit-alpine
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Alpine fallback when Void image cannot be pulled.
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk add --no-cache \
|
||||
runit python3 py3-pip procps shadow \
|
||||
runit-openrc 2>/dev/null || apk add --no-cache runit python3 py3-pip procps shadow
|
||||
|
||||
CMD ["/bin/sh"]
|
||||
12
vendor/rns_filesync/packaging/docker-test/Dockerfile.systemd
vendored
Normal file
12
vendor/rns_filesync/packaging/docker-test/Dockerfile.systemd
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Debian Bookworm + systemd: verify unit and start via systemd.
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
systemd systemd-sysv python3 python3-pip python3-venv \
|
||||
ca-certificates procps \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
STOPSIGNAL SIGRTMIN+3
|
||||
CMD ["/lib/systemd/systemd"]
|
||||
22
vendor/rns_filesync/packaging/docker-test/assert-running.sh
vendored
Executable file
22
vendor/rns_filesync/packaging/docker-test/assert-running.sh
vendored
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
# Assert daemon is alive as rns-filesync for a few seconds.
|
||||
set -eu
|
||||
pid="$1"
|
||||
user="${2:-rns-filesync}"
|
||||
i=0
|
||||
while [ "$i" -lt 8 ]; do
|
||||
if ! kill -0 "$pid" 2>/dev/null; then
|
||||
echo "FAIL: process $pid died"
|
||||
exit 1
|
||||
fi
|
||||
owner="$(ps -o user= -p "$pid" 2>/dev/null | tr -d ' ' || true)"
|
||||
if [ -n "$owner" ] && [ "$owner" != "$user" ]; then
|
||||
echo "FAIL: pid $pid owner is '$owner' expected '$user'"
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
echo "OK: pid $pid running as $user for ${i}s"
|
||||
kill "$pid" 2>/dev/null || true
|
||||
wait "$pid" 2>/dev/null || true
|
||||
88
vendor/rns_filesync/packaging/docker-test/common-setup.sh
vendored
Executable file
88
vendor/rns_filesync/packaging/docker-test/common-setup.sh
vendored
Executable file
|
|
@ -0,0 +1,88 @@
|
|||
#!/bin/sh
|
||||
# Shared setup for packaging service tests inside containers.
|
||||
set -eu
|
||||
|
||||
pip_install() {
|
||||
if python3 -m pip install --help 2>/dev/null | grep -q break-system-packages; then
|
||||
python3 -m pip install --break-system-packages "$@"
|
||||
else
|
||||
python3 -m pip install "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> install rns-filesync from wheel"
|
||||
pip_install /wheels/*.whl
|
||||
BIN="$(command -v rns-filesync)"
|
||||
case "$BIN" in
|
||||
/usr/bin/rns-filesync) ;;
|
||||
*) ln -sfn "$BIN" /usr/bin/rns-filesync ;;
|
||||
esac
|
||||
test -x /usr/bin/rns-filesync
|
||||
/usr/bin/rns-filesync -v
|
||||
|
||||
echo "==> create non-root user and state dirs"
|
||||
if ! id rns-filesync >/dev/null 2>&1; then
|
||||
if command -v useradd >/dev/null 2>&1; then
|
||||
useradd --system --home-dir /var/lib/rns-filesync --create-home \
|
||||
--shell /usr/sbin/nologin rns-filesync 2>/dev/null \
|
||||
|| useradd -r -d /var/lib/rns-filesync -m -s /sbin/nologin rns-filesync
|
||||
elif command -v adduser >/dev/null 2>&1; then
|
||||
adduser -S -D -h /var/lib/rns-filesync -s /sbin/nologin rns-filesync 2>/dev/null \
|
||||
|| adduser --system --home /var/lib/rns-filesync --shell /usr/sbin/nologin rns-filesync
|
||||
else
|
||||
echo "FAIL: no useradd/adduser"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure group exists for chown -g
|
||||
if ! getent group rns-filesync >/dev/null 2>&1; then
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
groupadd --system rns-filesync 2>/dev/null || groupadd rns-filesync
|
||||
elif command -v addgroup >/dev/null 2>&1; then
|
||||
addgroup -S rns-filesync 2>/dev/null || addgroup rns-filesync
|
||||
fi
|
||||
fi
|
||||
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
|
||||
|
||||
cat >/var/lib/rns-filesync/config/config <<'EOF'
|
||||
[filesync]
|
||||
announce_interval = 300
|
||||
directory = /var/lib/rns-filesync/sync
|
||||
identity = rns_filesync
|
||||
|
||||
[access]
|
||||
sync = r:all
|
||||
|
||||
[logging]
|
||||
loglevel = 6
|
||||
EOF
|
||||
|
||||
cat >/var/lib/rns-filesync/reticulum/config <<'EOF'
|
||||
[reticulum]
|
||||
enable_transport = Yes
|
||||
share_instance = No
|
||||
shared_instance_port = 37428
|
||||
instance_name = filesync_svc_test
|
||||
panic_on_interface_error = No
|
||||
|
||||
[logging]
|
||||
loglevel = 6
|
||||
|
||||
[interfaces]
|
||||
[[Loopback UDP]]
|
||||
type = UDPInterface
|
||||
enabled = yes
|
||||
listen_ip = 127.0.0.1
|
||||
listen_port = 42500
|
||||
forward_ip = 127.0.0.1
|
||||
forward_port = 42501
|
||||
EOF
|
||||
|
||||
chown -R rns-filesync:rns-filesync /var/lib/rns-filesync
|
||||
|
||||
echo "==> shared setup done"
|
||||
84
vendor/rns_filesync/packaging/docker-test/run-all.sh
vendored
Executable file
84
vendor/rns_filesync/packaging/docker-test/run-all.sh
vendored
Executable file
|
|
@ -0,0 +1,84 @@
|
|||
#!/bin/sh
|
||||
# Build wheels and run packaging service tests in appropriate OS images.
|
||||
set -eu
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
IMG_RUNIT="${IMG_RUNIT:-ghcr.io/void-linux/void-glibc-full:20240526R1}"
|
||||
|
||||
echo "==> bake wheel"
|
||||
mkdir -p dist
|
||||
make wheel
|
||||
WHEEL="$(ls -1 dist/rns_filesync-*.whl | head -n1)"
|
||||
test -n "$WHEEL"
|
||||
echo "wheel=$WHEEL"
|
||||
|
||||
run_named() {
|
||||
name="$1"
|
||||
dockerfile="$2"
|
||||
base_hint="$3"
|
||||
testscript="$4"
|
||||
echo ""
|
||||
echo "======== TEST $name ($base_hint) ========"
|
||||
docker build -f "packaging/docker-test/$dockerfile" \
|
||||
-t "rns-filesync-svc-$name" \
|
||||
packaging/docker-test
|
||||
docker run --rm \
|
||||
-v "$ROOT/packaging:/packaging:ro" \
|
||||
-v "$ROOT/packaging/docker-test:/test:ro" \
|
||||
-v "$ROOT/dist:/wheels:ro" \
|
||||
"rns-filesync-svc-$name" \
|
||||
/bin/sh /test/"$testscript"
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "======== TEST systemd (debian bookworm + systemd) ========"
|
||||
docker build -f packaging/docker-test/Dockerfile.systemd \
|
||||
-t rns-filesync-svc-systemd packaging/docker-test
|
||||
|
||||
cid="$(docker run -d --privileged --cgroupns=host \
|
||||
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
|
||||
-v "$ROOT/packaging:/packaging:ro" \
|
||||
-v "$ROOT/packaging/docker-test:/test:ro" \
|
||||
-v "$ROOT/dist:/wheels:ro" \
|
||||
rns-filesync-svc-systemd)"
|
||||
cleanup_systemd() {
|
||||
docker rm -f "$cid" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup_systemd EXIT
|
||||
i=0
|
||||
while [ "$i" -lt 40 ]; do
|
||||
if docker exec "$cid" systemctl list-units >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep 1
|
||||
done
|
||||
docker exec "$cid" /bin/sh /test/test-systemd.sh
|
||||
cleanup_systemd
|
||||
trap - EXIT
|
||||
echo "PASS: systemd outer"
|
||||
|
||||
run_named openrc Dockerfile.openrc "alpine:3.21 + openrc" test-openrc.sh
|
||||
run_named dinit Dockerfile.dinit "debian:bookworm (dinit service file)" test-dinit.sh
|
||||
|
||||
echo ""
|
||||
echo "======== TEST runit ========"
|
||||
if docker pull "$IMG_RUNIT" >/dev/null 2>&1 \
|
||||
&& docker build -f packaging/docker-test/Dockerfile.runit \
|
||||
-t rns-filesync-svc-runit packaging/docker-test; then
|
||||
echo "using Void Linux runit image"
|
||||
else
|
||||
echo "using Alpine runit fallback"
|
||||
docker build -f packaging/docker-test/Dockerfile.runit-alpine \
|
||||
-t rns-filesync-svc-runit packaging/docker-test
|
||||
fi
|
||||
docker run --rm \
|
||||
-v "$ROOT/packaging:/packaging:ro" \
|
||||
-v "$ROOT/packaging/docker-test:/test:ro" \
|
||||
-v "$ROOT/dist:/wheels:ro" \
|
||||
rns-filesync-svc-runit \
|
||||
/bin/sh /test/test-runit.sh
|
||||
|
||||
echo ""
|
||||
echo "ALL SERVICE TESTS PASSED"
|
||||
118
vendor/rns_filesync/packaging/docker-test/test-bwrap.sh
vendored
Executable file
118
vendor/rns_filesync/packaging/docker-test/test-bwrap.sh
vendored
Executable file
|
|
@ -0,0 +1,118 @@
|
|||
#!/bin/sh
|
||||
# Exercise the documented Bubblewrap sandbox command on the host.
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd)"
|
||||
if [ -x "$ROOT/.venv/bin/rns-filesync" ]; then
|
||||
RNS_BIN="$ROOT/.venv/bin/rns-filesync"
|
||||
elif command -v rns-filesync >/dev/null 2>&1; then
|
||||
RNS_BIN="$(command -v rns-filesync)"
|
||||
else
|
||||
echo "FAIL: rns-filesync not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v bwrap >/dev/null 2>&1; then
|
||||
echo "FAIL: bwrap not installed (package bubblewrap)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/rns-filesync-sandbox-test"
|
||||
rm -rf "$DATA"
|
||||
mkdir -p "$DATA/config" "$DATA/sync" "$DATA/reticulum"
|
||||
|
||||
cat > "$DATA/config/config" <<EOF
|
||||
[filesync]
|
||||
announce_interval = 300
|
||||
directory = $DATA/sync
|
||||
identity = rns_filesync
|
||||
|
||||
[access]
|
||||
sync = r:all
|
||||
|
||||
[logging]
|
||||
loglevel = 6
|
||||
EOF
|
||||
|
||||
cat > "$DATA/reticulum/config" <<'EOF'
|
||||
[reticulum]
|
||||
enable_transport = Yes
|
||||
share_instance = No
|
||||
shared_instance_port = 37501
|
||||
instance_name = filesync_bwrap_test
|
||||
panic_on_interface_error = No
|
||||
|
||||
[logging]
|
||||
loglevel = 6
|
||||
|
||||
[interfaces]
|
||||
[[Loopback UDP]]
|
||||
type = UDPInterface
|
||||
enabled = yes
|
||||
listen_ip = 127.0.0.1
|
||||
listen_port = 42610
|
||||
forward_ip = 127.0.0.1
|
||||
forward_port = 42611
|
||||
EOF
|
||||
|
||||
echo "==> version inside bwrap"
|
||||
bwrap \
|
||||
--die-with-parent \
|
||||
--new-session \
|
||||
--proc /proc \
|
||||
--dev /dev \
|
||||
--ro-bind / / \
|
||||
--tmpfs /tmp \
|
||||
--bind "$DATA" "$DATA" \
|
||||
--uid "$(id -u)" --gid "$(id -g)" \
|
||||
"$RNS_BIN" --version
|
||||
|
||||
echo "==> start sandboxed daemon (README command shape)"
|
||||
bwrap \
|
||||
--die-with-parent \
|
||||
--new-session \
|
||||
--proc /proc \
|
||||
--dev /dev \
|
||||
--ro-bind / / \
|
||||
--tmpfs /tmp \
|
||||
--bind "$DATA" "$DATA" \
|
||||
--uid "$(id -u)" --gid "$(id -g)" \
|
||||
"$RNS_BIN" \
|
||||
--config "$DATA/config" \
|
||||
--rnsconfig "$DATA/reticulum" \
|
||||
-d "$DATA/sync" \
|
||||
--no-repl \
|
||||
-q &
|
||||
BPID=$!
|
||||
sleep 3
|
||||
if ! kill -0 "$BPID" 2>/dev/null; then
|
||||
echo "FAIL: bwrap daemon died"
|
||||
wait "$BPID" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: sandboxed daemon pid=$BPID alive"
|
||||
|
||||
echo sandbox-write-ok > "$DATA/sync/probe.txt"
|
||||
test -f "$DATA/sync/probe.txt"
|
||||
echo "OK: DATA sync dir is writable"
|
||||
|
||||
if bwrap \
|
||||
--die-with-parent \
|
||||
--new-session \
|
||||
--proc /proc \
|
||||
--dev /dev \
|
||||
--ro-bind / / \
|
||||
--tmpfs /tmp \
|
||||
--bind "$DATA" "$DATA" \
|
||||
--uid "$(id -u)" --gid "$(id -g)" \
|
||||
/bin/sh -c 'echo should-fail > /etc/rns-filesync-bwrap-probe'; then
|
||||
echo "FAIL: wrote outside DATA"
|
||||
kill "$BPID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: root outside DATA is read-only"
|
||||
|
||||
kill "$BPID" 2>/dev/null || true
|
||||
wait "$BPID" 2>/dev/null || true
|
||||
rm -rf "$DATA"
|
||||
echo "PASS: bwrap sandbox command"
|
||||
35
vendor/rns_filesync/packaging/docker-test/test-dinit.sh
vendored
Executable file
35
vendor/rns_filesync/packaging/docker-test/test-dinit.sh
vendored
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/sh
|
||||
# Test dinit service file and the command it would run.
|
||||
set -eu
|
||||
. /test/common-setup.sh
|
||||
|
||||
svc=/packaging/dinit/rns-filesync
|
||||
install -d /etc/dinit.d
|
||||
install -m 644 "$svc" /etc/dinit.d/rns-filesync
|
||||
|
||||
echo "==> validate dinit service keys"
|
||||
grep -Eq '^type = process$' /etc/dinit.d/rns-filesync
|
||||
grep -Eq '^restart = true$' /etc/dinit.d/rns-filesync
|
||||
grep -Eq '^runs-as = rns-filesync$' /etc/dinit.d/rns-filesync
|
||||
grep -Eq '^command = .*/usr/bin/rns-filesync' /etc/dinit.d/rns-filesync
|
||||
|
||||
if command -v dinitcheck >/dev/null 2>&1; then
|
||||
echo "==> dinitcheck"
|
||||
dinitcheck /etc/dinit.d/rns-filesync || dinitcheck -d /etc/dinit.d
|
||||
fi
|
||||
|
||||
echo "==> run service command as rns-filesync"
|
||||
su -s /bin/sh -c \
|
||||
'exec /usr/bin/env HOME=/var/lib/rns-filesync /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
|
||||
rns-filesync &
|
||||
pid=$!
|
||||
sleep 1
|
||||
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
|
||||
if [ -z "$fs_pid" ]; then
|
||||
echo "FAIL: rns-filesync not running as rns-filesync"
|
||||
ps aux || true
|
||||
exit 1
|
||||
fi
|
||||
/test/assert-running.sh "$fs_pid" rns-filesync
|
||||
|
||||
echo "PASS: dinit"
|
||||
35
vendor/rns_filesync/packaging/docker-test/test-openrc.sh
vendored
Executable file
35
vendor/rns_filesync/packaging/docker-test/test-openrc.sh
vendored
Executable file
|
|
@ -0,0 +1,35 @@
|
|||
#!/bin/sh
|
||||
# Test OpenRC service on Alpine.
|
||||
set -eu
|
||||
. /test/common-setup.sh
|
||||
|
||||
install -m 755 /packaging/openrc/rns-filesync /etc/init.d/rns-filesync
|
||||
|
||||
echo "==> shell syntax check"
|
||||
sh -n /etc/init.d/rns-filesync
|
||||
|
||||
echo "==> openrc script metadata"
|
||||
grep -q 'command="/usr/bin/rns-filesync"' /etc/init.d/rns-filesync
|
||||
grep -q 'command_user="rns-filesync:rns-filesync"' /etc/init.d/rns-filesync
|
||||
grep -q 'command_background=yes' /etc/init.d/rns-filesync
|
||||
|
||||
echo "==> mimic start_pre (checkpath dirs)"
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
|
||||
|
||||
echo "==> start daemon via openrc command + args as service user"
|
||||
su -s /bin/sh -c \
|
||||
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
|
||||
rns-filesync &
|
||||
sleep 1
|
||||
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
|
||||
if [ -z "$fs_pid" ]; then
|
||||
echo "FAIL: rns-filesync not running as rns-filesync"
|
||||
ps aux || true
|
||||
exit 1
|
||||
fi
|
||||
/test/assert-running.sh "$fs_pid" rns-filesync
|
||||
|
||||
echo "PASS: openrc"
|
||||
28
vendor/rns_filesync/packaging/docker-test/test-runit.sh
vendored
Executable file
28
vendor/rns_filesync/packaging/docker-test/test-runit.sh
vendored
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/sh
|
||||
# Test runit run script on Void (or any runit-capable image).
|
||||
set -eu
|
||||
. /test/common-setup.sh
|
||||
|
||||
install -d /etc/sv/rns-filesync
|
||||
install -m 755 /packaging/runit/rns-filesync/run /etc/sv/rns-filesync/run
|
||||
|
||||
echo "==> shell syntax check"
|
||||
sh -n /etc/sv/rns-filesync/run
|
||||
|
||||
echo "==> start via runit run script"
|
||||
# run script execs forever - start in background by replacing exec with run
|
||||
# Invoke under a subshell that keeps it as supervised child.
|
||||
/etc/sv/rns-filesync/run &
|
||||
pid=$!
|
||||
# The run script may re-exec as another pid via su/chpst. Find the filesync process.
|
||||
sleep 2
|
||||
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
|
||||
if [ -z "$fs_pid" ]; then
|
||||
echo "FAIL: rns-filesync not running after runit start"
|
||||
ps aux || true
|
||||
exit 1
|
||||
fi
|
||||
/test/assert-running.sh "$fs_pid" rns-filesync
|
||||
kill "$pid" 2>/dev/null || true
|
||||
|
||||
echo "PASS: runit"
|
||||
51
vendor/rns_filesync/packaging/docker-test/test-systemd.sh
vendored
Executable file
51
vendor/rns_filesync/packaging/docker-test/test-systemd.sh
vendored
Executable file
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/sh
|
||||
# Test systemd unit inside a running systemd container.
|
||||
set -eu
|
||||
. /test/common-setup.sh
|
||||
|
||||
install -m 644 /packaging/sysusers.d/rns-filesync.conf /usr/lib/sysusers.d/ 2>/dev/null || true
|
||||
install -m 644 /packaging/tmpfiles.d/rns-filesync.conf /usr/lib/tmpfiles.d/ 2>/dev/null || true
|
||||
systemd-sysusers || true
|
||||
systemd-tmpfiles --create || true
|
||||
|
||||
install -m 644 /packaging/systemd/rns-filesync.service /etc/systemd/system/rns-filesync.service
|
||||
install -m 644 /packaging/systemd/rns-filesync.user.service /tmp/rns-filesync.user.service
|
||||
|
||||
echo "==> systemd-analyze verify (system unit)"
|
||||
# Documentation=man: fails verify when man page is not installed in the image.
|
||||
cp /etc/systemd/system/rns-filesync.service /tmp/rns-filesync.verify.service
|
||||
sed -i '/^Documentation=/d' /tmp/rns-filesync.verify.service
|
||||
systemd-analyze verify /tmp/rns-filesync.verify.service
|
||||
|
||||
echo "==> check user unit file parses"
|
||||
grep -q 'ExecStart=' /tmp/rns-filesync.user.service
|
||||
grep -q 'NoNewPrivileges=true' /tmp/rns-filesync.user.service
|
||||
|
||||
echo "==> systemctl enable and start"
|
||||
systemctl daemon-reload
|
||||
systemctl enable rns-filesync.service
|
||||
systemctl start rns-filesync.service
|
||||
sleep 4
|
||||
systemctl --no-pager --full status rns-filesync.service || true
|
||||
if ! systemctl is-active --quiet rns-filesync.service; then
|
||||
echo "WARN: systemctl start did not reach active (common in nested cgroup docker)"
|
||||
echo "==> fallback: start ExecStart as service user"
|
||||
su -s /bin/sh -c \
|
||||
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q' \
|
||||
rns-filesync &
|
||||
sleep 1
|
||||
fs_pid="$(pgrep -u rns-filesync -f '/usr/bin/rns-filesync' | head -n1 || true)"
|
||||
if [ -z "$fs_pid" ]; then
|
||||
echo "FAIL: rns-filesync not running as rns-filesync"
|
||||
ps aux || true
|
||||
exit 1
|
||||
fi
|
||||
/test/assert-running.sh "$fs_pid" rns-filesync
|
||||
else
|
||||
echo "==> active via systemd"
|
||||
sleep 2
|
||||
systemctl is-active rns-filesync.service | grep -qx active
|
||||
systemctl stop rns-filesync.service
|
||||
fi
|
||||
|
||||
echo "PASS: systemd"
|
||||
24
vendor/rns_filesync/packaging/openrc/rns-filesync
vendored
Executable file
24
vendor/rns_filesync/packaging/openrc/rns-filesync
vendored
Executable file
|
|
@ -0,0 +1,24 @@
|
|||
#!/sbin/openrc-run
|
||||
|
||||
description="RNS FileSync peer-to-peer directory sync"
|
||||
command="/usr/bin/rns-filesync"
|
||||
command_args="--config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q"
|
||||
command_user="rns-filesync:rns-filesync"
|
||||
command_background=yes
|
||||
pidfile="/run/rns-filesync.pid"
|
||||
directory="/var/lib/rns-filesync"
|
||||
|
||||
export HOME=/var/lib/rns-filesync
|
||||
|
||||
depend() {
|
||||
need localmount
|
||||
use net dns logger
|
||||
after firewall
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync
|
||||
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/config
|
||||
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
|
||||
checkpath -d -o rns-filesync:rns-filesync -m 0700 /var/lib/rns-filesync/sync
|
||||
}
|
||||
5
vendor/rns_filesync/packaging/rns-filesync.env.example
vendored
Normal file
5
vendor/rns_filesync/packaging/rns-filesync.env.example
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Optional environment overrides for system service installs.
|
||||
# Copy to /etc/default/rns-filesync or /etc/rns-filesync/env
|
||||
|
||||
# Example:
|
||||
# RNS_LOGLEVEL=4
|
||||
26
vendor/rns_filesync/packaging/runit/rns-filesync/run
vendored
Executable file
26
vendor/rns_filesync/packaging/runit/rns-filesync/run
vendored
Executable file
|
|
@ -0,0 +1,26 @@
|
|||
#!/bin/sh
|
||||
exec 2>&1
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/config
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/reticulum
|
||||
install -d -o rns-filesync -g rns-filesync -m 0700 /var/lib/rns-filesync/sync
|
||||
export HOME=/var/lib/rns-filesync
|
||||
cd /var/lib/rns-filesync || exit 1
|
||||
if command -v chpst >/dev/null 2>&1; then
|
||||
exec chpst -u rns-filesync:rns-filesync /usr/bin/rns-filesync \
|
||||
--config /var/lib/rns-filesync/config \
|
||||
--rnsconfig /var/lib/rns-filesync/reticulum \
|
||||
-d /var/lib/rns-filesync/sync \
|
||||
--no-repl \
|
||||
-q
|
||||
fi
|
||||
if command -v setuidgid >/dev/null 2>&1; then
|
||||
exec setuidgid rns-filesync /usr/bin/rns-filesync \
|
||||
--config /var/lib/rns-filesync/config \
|
||||
--rnsconfig /var/lib/rns-filesync/reticulum \
|
||||
-d /var/lib/rns-filesync/sync \
|
||||
--no-repl \
|
||||
-q
|
||||
fi
|
||||
exec su -s /bin/sh rns-filesync -c \
|
||||
'exec /usr/bin/rns-filesync --config /var/lib/rns-filesync/config --rnsconfig /var/lib/rns-filesync/reticulum -d /var/lib/rns-filesync/sync --no-repl -q'
|
||||
47
vendor/rns_filesync/packaging/systemd/rns-filesync.service
vendored
Normal file
47
vendor/rns_filesync/packaging/systemd/rns-filesync.service
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[Unit]
|
||||
Description=RNS FileSync peer-to-peer directory sync
|
||||
Documentation=man:rns-filesync(1)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=rns-filesync
|
||||
Group=rns-filesync
|
||||
Environment=HOME=/var/lib/rns-filesync
|
||||
EnvironmentFile=-/etc/default/rns-filesync
|
||||
EnvironmentFile=-/etc/rns-filesync/env
|
||||
ExecStart=/usr/bin/rns-filesync \
|
||||
--config /var/lib/rns-filesync/config \
|
||||
--rnsconfig /var/lib/rns-filesync/reticulum \
|
||||
-d /var/lib/rns-filesync/sync \
|
||||
--no-repl \
|
||||
-q
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
KillMode=mixed
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHostname=true
|
||||
ProtectClock=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
RestrictRealtime=true
|
||||
RestrictNamespaces=true
|
||||
SystemCallArchitectures=native
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
ReadWritePaths=/var/lib/rns-filesync
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
23
vendor/rns_filesync/packaging/systemd/rns-filesync.user.service
vendored
Normal file
23
vendor/rns_filesync/packaging/systemd/rns-filesync.user.service
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[Unit]
|
||||
Description=RNS FileSync (user session)
|
||||
Documentation=man:rns-filesync(1)
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/rns-filesync \
|
||||
--config %h/.rns_filesync \
|
||||
--rnsconfig %h/.reticulum \
|
||||
-d %h/Filesync \
|
||||
--no-repl \
|
||||
-q
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
1
vendor/rns_filesync/packaging/sysusers.d/rns-filesync.conf
vendored
Normal file
1
vendor/rns_filesync/packaging/sysusers.d/rns-filesync.conf
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
u rns-filesync - "RNS FileSync" /var/lib/rns-filesync -
|
||||
4
vendor/rns_filesync/packaging/tmpfiles.d/rns-filesync.conf
vendored
Normal file
4
vendor/rns_filesync/packaging/tmpfiles.d/rns-filesync.conf
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
d /var/lib/rns-filesync 0700 rns-filesync rns-filesync -
|
||||
d /var/lib/rns-filesync/config 0700 rns-filesync rns-filesync -
|
||||
d /var/lib/rns-filesync/reticulum 0700 rns-filesync rns-filesync -
|
||||
d /var/lib/rns-filesync/sync 0700 rns-filesync rns-filesync -
|
||||
35
vendor/rns_filesync/permissions.example
vendored
Normal file
35
vendor/rns_filesync/permissions.example
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# RNS FileSync access file (.allowed)
|
||||
#
|
||||
# Same rule format as rngit: permission:target (one rule per line).
|
||||
# You can also put comma-separated rules on one line.
|
||||
#
|
||||
# Place this file as:
|
||||
# <sync_dir>.allowed
|
||||
# <parent>/<sync_dir_name>.allowed
|
||||
# <sync_dir>/.allowed
|
||||
#
|
||||
# Permissions:
|
||||
# r = read
|
||||
# w = write
|
||||
# d = delete
|
||||
# rw = read/write
|
||||
# rwd = read/write/delete
|
||||
# adm = admin (all)
|
||||
#
|
||||
# Targets: identity hash, alias from config, all, or none.
|
||||
#
|
||||
# Default is deny until rules are added.
|
||||
|
||||
# Public read-only
|
||||
# r:all
|
||||
|
||||
# Full access for one peer
|
||||
# rwd:b559704616f042e0b7b6ba46ada4c670
|
||||
|
||||
# Read/write for one peer, read for everyone
|
||||
r:all
|
||||
w:b559704616f042e0b7b6ba46ada4c670
|
||||
d:b559704616f042e0b7b6ba46ada4c670
|
||||
|
||||
# Or combined:
|
||||
# r:all, rw:20d1f3debb50dde3df2cff74413b0f0d
|
||||
47
vendor/rns_filesync/pyproject.toml
vendored
Normal file
47
vendor/rns_filesync/pyproject.toml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rns-filesync"
|
||||
version = "1.0.0"
|
||||
description = "Peer-to-peer file synchronization over the Reticulum Network Stack"
|
||||
readme = "README.md"
|
||||
license = { text = "BSD-2-Clause" }
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"rns>=1.3.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-timeout>=2.3",
|
||||
"hypothesis>=6.100",
|
||||
"coverage>=7.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
rns-filesync = "rns_filesync.cli:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["rns_filesync*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"unit: fast isolated unit tests",
|
||||
"acceptance: behavior and policy acceptance tests",
|
||||
"smoke: basic import and CLI smoke tests",
|
||||
"e2e: end-to-end sync over isolated Reticulum",
|
||||
"fuzz: property and fuzz tests",
|
||||
"live: tests against a real Reticulum config",
|
||||
"exploratory: longer stress and edge-case exploration",
|
||||
]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q --strict-markers"
|
||||
timeout = 120
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["rns_filesync"]
|
||||
omit = ["rns_filesync/cli.py"]
|
||||
1
vendor/rns_filesync/requirements.txt
vendored
Normal file
1
vendor/rns_filesync/requirements.txt
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
rns>=1.3.9
|
||||
7
vendor/rns_filesync/rns_filesync.py
vendored
Executable file
7
vendor/rns_filesync/rns_filesync.py
vendored
Executable file
|
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compatibility shim for python rns_filesync.py."""
|
||||
|
||||
from rns_filesync.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
19
vendor/rns_filesync/rns_filesync/__init__.py
vendored
Normal file
19
vendor/rns_filesync/rns_filesync/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""RNS FileSync: peer-to-peer directory sync over Reticulum."""
|
||||
|
||||
from rns_filesync._meta import (
|
||||
BUILD_DATE,
|
||||
GIT_COMMIT,
|
||||
__version__,
|
||||
version_info,
|
||||
version_string,
|
||||
)
|
||||
from rns_filesync.service import FileSyncService
|
||||
|
||||
__all__ = [
|
||||
"FileSyncService",
|
||||
"__version__",
|
||||
"BUILD_DATE",
|
||||
"GIT_COMMIT",
|
||||
"version_info",
|
||||
"version_string",
|
||||
]
|
||||
6
vendor/rns_filesync/rns_filesync/__main__.py
vendored
Normal file
6
vendor/rns_filesync/rns_filesync/__main__.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Module entrypoint for python -m rns_filesync."""
|
||||
|
||||
from rns_filesync.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
35
vendor/rns_filesync/rns_filesync/_meta.py
vendored
Normal file
35
vendor/rns_filesync/rns_filesync/_meta.py
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Package version and build metadata.
|
||||
|
||||
Values are overwritten by make meta / make build when building from git.
|
||||
Defaults keep editable installs and tests working without a Makefile bake step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = '1.0.0'
|
||||
|
||||
BUILD_DATE = '2026-07-19T13:14:52Z'
|
||||
|
||||
GIT_COMMIT = '12161f3'
|
||||
|
||||
GIT_DIRTY = '1'
|
||||
|
||||
|
||||
def version_string() -> str:
|
||||
"""Single-line version for CLI -v / --version."""
|
||||
parts = [f"rns-filesync {__version__}"]
|
||||
if BUILD_DATE and BUILD_DATE != "unknown":
|
||||
parts.append(f"built {BUILD_DATE}")
|
||||
if GIT_COMMIT and GIT_COMMIT != "unknown":
|
||||
dirty = "+" if GIT_DIRTY in {"1", "true", "yes", "dirty"} else ""
|
||||
parts.append(f"commit {GIT_COMMIT}{dirty}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def version_info() -> dict[str, str]:
|
||||
return {
|
||||
"version": __version__,
|
||||
"build_date": BUILD_DATE,
|
||||
"git_commit": GIT_COMMIT,
|
||||
"git_dirty": GIT_DIRTY,
|
||||
}
|
||||
339
vendor/rns_filesync/rns_filesync/cli.py
vendored
Normal file
339
vendor/rns_filesync/rns_filesync/cli.py
vendored
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
"""Command-line interface for RNS FileSync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
import RNS
|
||||
|
||||
from rns_filesync._meta import version_string
|
||||
from rns_filesync.config import (
|
||||
allowed_sidecar_paths,
|
||||
config_get,
|
||||
load_config,
|
||||
parse_csv_hashes,
|
||||
)
|
||||
from rns_filesync.constants import ANNOUNCE_INTERVAL_DEFAULT, APP_NAME
|
||||
from rns_filesync.permissions import PermissionStore
|
||||
from rns_filesync.service import FileSyncService
|
||||
|
||||
|
||||
def load_or_create_identity(identity_name: str, identity_dir: str):
|
||||
os.makedirs(identity_dir, exist_ok=True)
|
||||
identity_path = os.path.join(identity_dir, identity_name)
|
||||
if os.path.isfile(identity_path):
|
||||
identity = RNS.Identity.from_file(identity_path)
|
||||
RNS.log(f"Loaded identity {identity_name}", RNS.LOG_INFO)
|
||||
else:
|
||||
identity = RNS.Identity()
|
||||
identity.to_file(identity_path)
|
||||
RNS.log(f"Created identity {identity_name}", RNS.LOG_INFO)
|
||||
return identity
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="RNS FileSync - peer-to-peer file sync over Reticulum",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
version=version_string(),
|
||||
help="print version, build date, and commit then exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help="FileSync config directory (default: ~/.rns_filesync)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rnsconfig",
|
||||
default=None,
|
||||
help="Reticulum config directory (default: ~/.reticulum)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--directory",
|
||||
default=None,
|
||||
help="directory to synchronize (overrides config)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--identity",
|
||||
default=None,
|
||||
help="identity name (stored under FileSync config dir)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--peer",
|
||||
action="append",
|
||||
dest="peers",
|
||||
help="peer identity hash (destination hash accepted as fallback)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n",
|
||||
"--no-monitor",
|
||||
action="store_true",
|
||||
help="disable file monitoring",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-a",
|
||||
"--announce-interval",
|
||||
type=int,
|
||||
default=None,
|
||||
help="announce interval in seconds",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allowed",
|
||||
default=None,
|
||||
help="path to .allowed ACL file (rngit-style permission:target lines)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--permissions-file",
|
||||
default=None,
|
||||
help="alias for --allowed (legacy name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow",
|
||||
action="append",
|
||||
dest="allowed_peers",
|
||||
help="ACL rule (r:hash) or bare identity hash used with --perms",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--perms",
|
||||
default="rwd",
|
||||
help="legacy shorthand for bare --allow hashes (r, w, d, rw, rwd)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="count",
|
||||
default=0,
|
||||
help="increase logging (use --verbose, repeat for more)",
|
||||
)
|
||||
parser.add_argument("-q", "--quiet", action="store_true", help="reduce logging")
|
||||
parser.add_argument(
|
||||
"--no-repl",
|
||||
action="store_true",
|
||||
help="run without interactive command prompt",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def _set_loglevel(verbose: int, quiet: bool, config_level: int | None) -> None:
|
||||
if quiet:
|
||||
RNS.loglevel = RNS.LOG_ERROR
|
||||
return
|
||||
if verbose == 1:
|
||||
RNS.loglevel = RNS.LOG_VERBOSE
|
||||
elif verbose == 2:
|
||||
RNS.loglevel = RNS.LOG_DEBUG
|
||||
elif verbose >= 3:
|
||||
RNS.loglevel = RNS.LOG_EXTREME
|
||||
elif config_level is not None:
|
||||
RNS.loglevel = int(config_level)
|
||||
else:
|
||||
RNS.loglevel = RNS.LOG_INFO
|
||||
|
||||
|
||||
def _print_help() -> None:
|
||||
print(
|
||||
"commands: status peers connect <hash> disconnect <id> browse <id> "
|
||||
"download <id> <path> announce files quit",
|
||||
)
|
||||
|
||||
|
||||
def run_repl(service: FileSyncService) -> None:
|
||||
_print_help()
|
||||
while True:
|
||||
try:
|
||||
line = input("filesync> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split()
|
||||
cmd = parts[0].lower()
|
||||
if cmd in {"quit", "exit", "q"}:
|
||||
break
|
||||
if cmd in {"help", "?"}:
|
||||
_print_help()
|
||||
elif cmd == "status":
|
||||
print(service.get_status())
|
||||
elif cmd == "peers":
|
||||
for peer in service.list_peers():
|
||||
print(peer)
|
||||
elif cmd == "files":
|
||||
for item in service.list_files():
|
||||
print(f"{item['path']}\t{item['size']}\t{item['hash']}")
|
||||
elif cmd == "announce":
|
||||
service.announce_now()
|
||||
print("announced")
|
||||
elif cmd == "connect" and len(parts) >= 2:
|
||||
print(service.connect_peer(parts[1]))
|
||||
elif cmd == "disconnect" and len(parts) >= 2:
|
||||
service.disconnect_peer(parts[1])
|
||||
print("disconnected")
|
||||
elif cmd == "browse" and len(parts) >= 2:
|
||||
for item in service.browse_peer(parts[1]):
|
||||
print(item)
|
||||
elif cmd == "download" and len(parts) >= 3:
|
||||
print(service.download_file(parts[1], parts[2]))
|
||||
else:
|
||||
print("unknown command")
|
||||
_print_help()
|
||||
|
||||
|
||||
def build_permissions(
|
||||
*,
|
||||
config,
|
||||
sync_directory: str,
|
||||
allowed_path: str | None,
|
||||
allow_args: list[str] | None,
|
||||
perms_shorthand: str,
|
||||
) -> PermissionStore:
|
||||
store = PermissionStore()
|
||||
|
||||
aliases = config.get("aliases") or {}
|
||||
if isinstance(aliases, dict):
|
||||
store.set_aliases(
|
||||
{str(k): str(v) for k, v in aliases.items() if not str(k).startswith("#")},
|
||||
)
|
||||
|
||||
blocked = config_get(config, "filesync", "blocked_identities", None)
|
||||
store.set_blocked(parse_csv_hashes(blocked))
|
||||
|
||||
access = config.get("access") or {}
|
||||
if isinstance(access, dict):
|
||||
for key, value in access.items():
|
||||
if str(key).startswith("#"):
|
||||
continue
|
||||
store.load_access_value(value)
|
||||
|
||||
for path in allowed_sidecar_paths(sync_directory):
|
||||
if os.path.isfile(path):
|
||||
loaded = store.load_file(path)
|
||||
if loaded:
|
||||
RNS.log(f"Loaded {loaded} ACL rules from {path}", RNS.LOG_INFO)
|
||||
|
||||
if allowed_path:
|
||||
store.load_file(allowed_path)
|
||||
|
||||
if allow_args:
|
||||
for item in allow_args:
|
||||
item = item.strip()
|
||||
if ":" in item:
|
||||
store.add_rule(item)
|
||||
else:
|
||||
# Bare hash: apply --perms shorthand as rngit-style rule(s).
|
||||
shorthand = perms_shorthand.strip().lower()
|
||||
if shorthand in {"r", "w", "d", "rw", "rwd", "adm", "admin"}:
|
||||
store.add_rule(f"{shorthand}:{item}")
|
||||
else:
|
||||
# Comma list of long names
|
||||
store.grant(item, [p.strip() for p in shorthand.split(",")])
|
||||
|
||||
return store
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
config_dir, config = load_config(args.config)
|
||||
config_level = config_get(config, "logging", "loglevel", None)
|
||||
try:
|
||||
config_level = int(config_level) if config_level is not None else None
|
||||
except (TypeError, ValueError):
|
||||
config_level = None
|
||||
_set_loglevel(args.verbose, args.quiet, config_level)
|
||||
|
||||
directory = args.directory or config_get(config, "filesync", "directory", None)
|
||||
if not directory:
|
||||
parser.error(
|
||||
"sync directory required via -d/--directory or config [filesync] directory",
|
||||
)
|
||||
directory = os.path.realpath(os.path.expanduser(str(directory)))
|
||||
|
||||
identity_name = args.identity or config_get(
|
||||
config,
|
||||
"filesync",
|
||||
"identity",
|
||||
APP_NAME,
|
||||
)
|
||||
announce_interval = args.announce_interval
|
||||
if announce_interval is None:
|
||||
raw = config_get(
|
||||
config,
|
||||
"filesync",
|
||||
"announce_interval",
|
||||
ANNOUNCE_INTERVAL_DEFAULT,
|
||||
)
|
||||
try:
|
||||
announce_interval = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
announce_interval = ANNOUNCE_INTERVAL_DEFAULT
|
||||
|
||||
peers = list(args.peers or [])
|
||||
peers.extend(parse_csv_hashes(config_get(config, "filesync", "peers", None)))
|
||||
|
||||
allowed_path = args.allowed or args.permissions_file
|
||||
permissions = build_permissions(
|
||||
config=config,
|
||||
sync_directory=directory,
|
||||
allowed_path=allowed_path,
|
||||
allow_args=args.allowed_peers,
|
||||
perms_shorthand=args.perms,
|
||||
)
|
||||
|
||||
rnsconfig = args.rnsconfig
|
||||
reticulum = RNS.Reticulum(rnsconfig)
|
||||
identity = load_or_create_identity(
|
||||
str(identity_name),
|
||||
os.path.join(config_dir, "identities"),
|
||||
)
|
||||
|
||||
service = FileSyncService(
|
||||
identity=identity,
|
||||
sync_directory=directory,
|
||||
reticulum=reticulum,
|
||||
configpath=rnsconfig,
|
||||
permissions=permissions,
|
||||
own_reticulum=True,
|
||||
)
|
||||
dest = service.start(
|
||||
monitor=not args.no_monitor,
|
||||
announce_interval=announce_interval,
|
||||
)
|
||||
print(f"config={config_dir}")
|
||||
print(f"identity={service.get_status()['identity_hash']}")
|
||||
print(f"destination={dest}")
|
||||
print(f"directory={directory}")
|
||||
if permissions.enabled:
|
||||
print("acl=enforced (deny by default)")
|
||||
else:
|
||||
print("acl=open (no rules configured)")
|
||||
|
||||
if peers:
|
||||
time.sleep(1.0)
|
||||
for peer in peers:
|
||||
result = service.connect_peer(peer)
|
||||
print(f"connect {peer}: {result}")
|
||||
|
||||
try:
|
||||
if args.no_repl:
|
||||
while True:
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
run_repl(service)
|
||||
finally:
|
||||
service.stop()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
116
vendor/rns_filesync/rns_filesync/config.py
vendored
Normal file
116
vendor/rns_filesync/rns_filesync/config.py
vendored
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""Config loading for RNS FileSync (rngit-style ConfigObj)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from RNS.vendor.configobj import ConfigObj
|
||||
|
||||
DEFAULT_CONFIG_DIR = os.path.expanduser("~/.rns_filesync")
|
||||
|
||||
DEFAULT_CONFIG = """# RNS FileSync config
|
||||
# Similar layout to rngit (~/.rngit/config).
|
||||
|
||||
[filesync]
|
||||
|
||||
# Automatic announce interval in seconds.
|
||||
announce_interval = 300
|
||||
|
||||
# Optional default sync directory (overridden by -d).
|
||||
# directory = ~/shared
|
||||
|
||||
# Identity name stored under this config dir.
|
||||
identity = rns_filesync
|
||||
|
||||
# Comma-separated peer identity hashes to connect on start.
|
||||
# peers = 9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
# Deny these identity hashes even if access rules would allow them.
|
||||
# blocked_identities = d31aeea49873006f13b3415520666a4e
|
||||
|
||||
[aliases]
|
||||
# alice = d09285e660cfe27cee6d9a0beb58b7e0
|
||||
|
||||
[access]
|
||||
# Access rules for the sync directory (comma-separated).
|
||||
# Format matches rngit: permission:target
|
||||
#
|
||||
# Permissions:
|
||||
# r = read
|
||||
# w = write
|
||||
# d = delete
|
||||
# rw = read/write
|
||||
# rwd = read/write/delete
|
||||
# adm = admin (all permissions)
|
||||
#
|
||||
# Targets: identity hash, alias name, all, or none.
|
||||
#
|
||||
# By default there are no permissions: peers cannot sync
|
||||
# until you add rules here and/or a sidecar .allowed file.
|
||||
#
|
||||
# sync = r:all, w:9710b86ba12c42d1d8f30f74fe509286, d:9710b86ba12c42d1d8f30f74fe509286
|
||||
|
||||
[logging]
|
||||
loglevel = 4
|
||||
"""
|
||||
|
||||
|
||||
def resolve_config_dir(config_dir: str | None = None) -> str:
|
||||
if config_dir:
|
||||
return os.path.realpath(os.path.expanduser(config_dir))
|
||||
if os.path.isfile("/etc/rns_filesync/config"):
|
||||
return "/etc/rns_filesync"
|
||||
return DEFAULT_CONFIG_DIR
|
||||
|
||||
|
||||
def ensure_config(config_dir: str | None = None) -> str:
|
||||
"""Ensure config directory and default config file exist. Return config dir."""
|
||||
path = resolve_config_dir(config_dir)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
os.makedirs(os.path.join(path, "identities"), exist_ok=True)
|
||||
config_path = os.path.join(path, "config")
|
||||
if not os.path.isfile(config_path):
|
||||
with open(config_path, "w", encoding="utf-8") as handle:
|
||||
handle.write(DEFAULT_CONFIG)
|
||||
return path
|
||||
|
||||
|
||||
def load_config(config_dir: str | None = None) -> tuple[str, ConfigObj]:
|
||||
"""Load ConfigObj from config dir, creating defaults if needed."""
|
||||
path = ensure_config(config_dir)
|
||||
config_path = os.path.join(path, "config")
|
||||
config = ConfigObj(config_path)
|
||||
return path, config
|
||||
|
||||
|
||||
def config_get(config: ConfigObj, section: str, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
section_obj = config.get(section) or {}
|
||||
if key in section_obj and section_obj[key] not in (None, ""):
|
||||
return section_obj[key]
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def parse_csv_hashes(value: str | list | None) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
parts = value
|
||||
else:
|
||||
parts = str(value).split(",")
|
||||
return [p.strip().lower().replace(":", "") for p in parts if p and str(p).strip()]
|
||||
|
||||
|
||||
def allowed_sidecar_paths(sync_directory: str) -> list[str]:
|
||||
"""Candidate .allowed paths for a sync directory (rngit-style sidecars)."""
|
||||
sync_directory = os.path.realpath(os.path.expanduser(sync_directory))
|
||||
parent = os.path.dirname(sync_directory.rstrip(os.sep))
|
||||
base = os.path.basename(sync_directory.rstrip(os.sep))
|
||||
return [
|
||||
os.path.join(sync_directory, ".allowed"),
|
||||
os.path.join(parent, f"{base}.allowed"),
|
||||
sync_directory.rstrip(os.sep) + ".allowed",
|
||||
]
|
||||
15
vendor/rns_filesync/rns_filesync/constants.py
vendored
Normal file
15
vendor/rns_filesync/rns_filesync/constants.py
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Shared constants for RNS FileSync."""
|
||||
|
||||
APP_NAME = "rns_filesync"
|
||||
ASPECT = "filesync"
|
||||
BLOCK_SIZE = 4096
|
||||
HASH_CHUNK_SIZE = 65536
|
||||
SCAN_INTERVAL = 5.0
|
||||
ANNOUNCE_INTERVAL_DEFAULT = 300
|
||||
PATH_TIMEOUT_DEFAULT = 30.0
|
||||
LINK_TIMEOUT_DEFAULT = 15.0
|
||||
RECONNECT_BASE_INTERVAL = 5.0
|
||||
RECONNECT_MAX_INTERVAL = 60.0
|
||||
HASH_DB_NAME = ".rns-filesync.db"
|
||||
VALID_PERMISSIONS = frozenset({"read", "write", "delete"})
|
||||
EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
197
vendor/rns_filesync/rns_filesync/inventory.py
vendored
Normal file
197
vendor/rns_filesync/rns_filesync/inventory.py
vendored
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"""Local file inventory, hashing, and persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from typing import Any
|
||||
|
||||
from rns_filesync.constants import BLOCK_SIZE, HASH_CHUNK_SIZE, HASH_DB_NAME
|
||||
from rns_filesync.paths import PathJailError, normalize_relpath, relative_to_root
|
||||
|
||||
|
||||
def hash_file(filepath: str) -> str | None:
|
||||
"""Return SHA-256 hex digest of a file, or None on error."""
|
||||
hasher = hashlib.sha256()
|
||||
try:
|
||||
with open(filepath, "rb") as handle:
|
||||
while chunk := handle.read(HASH_CHUNK_SIZE):
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def hash_blocks(filepath: str, block_size: int = BLOCK_SIZE) -> list[dict[str, Any]]:
|
||||
"""Return per-block SHA-256 hashes for delta sync."""
|
||||
blocks: list[dict[str, Any]] = []
|
||||
try:
|
||||
with open(filepath, "rb") as handle:
|
||||
block_num = 0
|
||||
while block := handle.read(block_size):
|
||||
blocks.append(
|
||||
{
|
||||
"num": block_num,
|
||||
"hash": hashlib.sha256(block).hexdigest(),
|
||||
"size": len(block),
|
||||
},
|
||||
)
|
||||
block_num += 1
|
||||
except OSError:
|
||||
return []
|
||||
return blocks
|
||||
|
||||
|
||||
def differing_block_nums(
|
||||
local_blocks: list[dict[str, Any]],
|
||||
peer_block_hashes: list[str],
|
||||
) -> list[int]:
|
||||
"""Return block numbers present locally that the peer does not have."""
|
||||
peer_set = set(peer_block_hashes)
|
||||
return [b["num"] for b in local_blocks if b["hash"] not in peer_set]
|
||||
|
||||
|
||||
def decide_sync_action(
|
||||
local_info: dict[str, Any] | None,
|
||||
peer_info: dict[str, Any] | None,
|
||||
) -> str:
|
||||
"""Decide sync action for one path.
|
||||
|
||||
Returns one of: skip, request_full, request_delta, ignore.
|
||||
"""
|
||||
if peer_info is None:
|
||||
return "ignore"
|
||||
if local_info is None:
|
||||
return "request_full"
|
||||
if local_info.get("hash") == peer_info.get("hash"):
|
||||
return "skip"
|
||||
if local_info.get("size", 0) > 0:
|
||||
return "request_delta"
|
||||
return "request_full"
|
||||
|
||||
|
||||
class Inventory:
|
||||
"""Tracks file hashes under a sync directory."""
|
||||
|
||||
def __init__(self, sync_directory: str):
|
||||
self.sync_directory = os.path.realpath(sync_directory)
|
||||
self._lock = threading.RLock()
|
||||
self._files: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def load(self) -> int:
|
||||
db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
|
||||
if not os.path.isfile(db_path):
|
||||
with self._lock:
|
||||
self._files = {}
|
||||
return 0
|
||||
try:
|
||||
with open(db_path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
with self._lock:
|
||||
self._files = data
|
||||
return len(data)
|
||||
|
||||
def save(self) -> None:
|
||||
db_path = os.path.join(self.sync_directory, HASH_DB_NAME)
|
||||
tmp_path = db_path + ".tmp"
|
||||
with self._lock:
|
||||
payload = dict(self._files)
|
||||
with open(tmp_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, sort_keys=True)
|
||||
os.replace(tmp_path, db_path)
|
||||
|
||||
def get(self, relpath: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
info = self._files.get(relpath)
|
||||
return dict(info) if info else None
|
||||
|
||||
def snapshot(self) -> dict[str, dict[str, Any]]:
|
||||
with self._lock:
|
||||
return {k: dict(v) for k, v in self._files.items()}
|
||||
|
||||
def set_file(self, relpath: str, info: dict[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self._files[relpath] = dict(info)
|
||||
|
||||
def remove_file(self, relpath: str) -> None:
|
||||
with self._lock:
|
||||
self._files.pop(relpath, None)
|
||||
|
||||
def _hash_if_needed(
|
||||
self,
|
||||
abspath: str,
|
||||
relpath: str,
|
||||
size: int,
|
||||
mtime: float,
|
||||
) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
cached = self._files.get(relpath)
|
||||
if (
|
||||
cached
|
||||
and cached.get("size") == size
|
||||
and abs(float(cached.get("mtime", 0)) - mtime) < 1e-6
|
||||
and cached.get("hash")
|
||||
):
|
||||
return {
|
||||
"hash": cached["hash"],
|
||||
"size": size,
|
||||
"mtime": mtime,
|
||||
}
|
||||
digest = hash_file(abspath)
|
||||
if not digest:
|
||||
return None
|
||||
return {"hash": digest, "size": size, "mtime": mtime}
|
||||
|
||||
def scan(self) -> dict[str, dict[str, Any]]:
|
||||
"""Scan sync directory and return current file map."""
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
for root, _dirs, files in os.walk(self.sync_directory):
|
||||
for filename in files:
|
||||
if filename == HASH_DB_NAME or filename.startswith(".rns-filesync"):
|
||||
continue
|
||||
abspath = os.path.join(root, filename)
|
||||
try:
|
||||
relpath = relative_to_root(self.sync_directory, abspath)
|
||||
except PathJailError:
|
||||
continue
|
||||
try:
|
||||
stat = os.stat(abspath)
|
||||
except OSError:
|
||||
continue
|
||||
info = self._hash_if_needed(
|
||||
abspath,
|
||||
relpath,
|
||||
stat.st_size,
|
||||
stat.st_mtime,
|
||||
)
|
||||
if info:
|
||||
found[relpath] = info
|
||||
with self._lock:
|
||||
self._files = found
|
||||
return self.snapshot()
|
||||
|
||||
def update_from_path(self, relpath: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
safe = normalize_relpath(relpath)
|
||||
abspath = os.path.join(self.sync_directory, safe)
|
||||
if not os.path.isfile(abspath):
|
||||
self.remove_file(safe)
|
||||
return None
|
||||
stat = os.stat(abspath)
|
||||
info = {
|
||||
"hash": hash_file(abspath),
|
||||
"size": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
}
|
||||
if not info["hash"]:
|
||||
return None
|
||||
self.set_file(safe, info)
|
||||
return info
|
||||
except (OSError, PathJailError):
|
||||
return None
|
||||
70
vendor/rns_filesync/rns_filesync/paths.py
vendored
Normal file
70
vendor/rns_filesync/rns_filesync/paths.py
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""Path jail helpers for sync directory confinement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class PathJailError(ValueError):
|
||||
"""Raised when a path escapes the sync root."""
|
||||
|
||||
|
||||
def normalize_relpath(relpath: str) -> str:
|
||||
"""Normalize a relative path for protocol use.
|
||||
|
||||
Raises:
|
||||
PathJailError: If the path is empty, absolute, or contains null bytes.
|
||||
|
||||
"""
|
||||
if not isinstance(relpath, str) or not relpath or "\x00" in relpath:
|
||||
raise PathJailError("invalid relative path")
|
||||
# Reject Windows drive / UNC style paths even on POSIX.
|
||||
if relpath.startswith(("/", "\\")) or os.path.isabs(relpath):
|
||||
raise PathJailError("absolute paths are not allowed")
|
||||
if len(relpath) >= 2 and relpath[1] == ":":
|
||||
raise PathJailError("absolute paths are not allowed")
|
||||
if relpath.startswith("\\\\") or relpath.startswith("//"):
|
||||
raise PathJailError("absolute paths are not allowed")
|
||||
|
||||
cleaned = os.path.normpath(relpath.replace("\\", "/"))
|
||||
if cleaned in (".", ""):
|
||||
raise PathJailError("empty relative path")
|
||||
parts = cleaned.split("/")
|
||||
if any(part in ("", "..") for part in parts):
|
||||
raise PathJailError("path escape attempt")
|
||||
if cleaned.startswith("../") or cleaned == "..":
|
||||
raise PathJailError("path escape attempt")
|
||||
# Disallow hidden protocol/control filenames under sync root.
|
||||
if any(
|
||||
part == ".rns-filesync.db" or part.startswith(".rns-xfer-") for part in parts
|
||||
):
|
||||
raise PathJailError("reserved path")
|
||||
return cleaned
|
||||
|
||||
|
||||
def resolve_under_root(root: str, relpath: str) -> str:
|
||||
"""Resolve relpath under root and ensure it stays jailed.
|
||||
|
||||
Returns:
|
||||
Absolute real path under root.
|
||||
|
||||
Raises:
|
||||
PathJailError: If resolution escapes root.
|
||||
|
||||
"""
|
||||
root_real = os.path.realpath(root)
|
||||
safe_rel = normalize_relpath(relpath)
|
||||
candidate = os.path.realpath(os.path.join(root_real, safe_rel))
|
||||
if candidate != root_real and not candidate.startswith(root_real + os.sep):
|
||||
raise PathJailError("path escapes sync root")
|
||||
return candidate
|
||||
|
||||
|
||||
def relative_to_root(root: str, abspath: str) -> str:
|
||||
"""Return a jailed relative path from an absolute path under root."""
|
||||
root_real = os.path.realpath(root)
|
||||
path_real = os.path.realpath(abspath)
|
||||
if path_real != root_real and not path_real.startswith(root_real + os.sep):
|
||||
raise PathJailError("path escapes sync root")
|
||||
rel = os.path.relpath(path_real, root_real)
|
||||
return normalize_relpath(rel)
|
||||
149
vendor/rns_filesync/rns_filesync/peers.py
vendored
Normal file
149
vendor/rns_filesync/rns_filesync/peers.py
vendored
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""Peer link lifecycle helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import RNS
|
||||
|
||||
from rns_filesync.constants import (
|
||||
APP_NAME,
|
||||
ASPECT,
|
||||
LINK_TIMEOUT_DEFAULT,
|
||||
PATH_TIMEOUT_DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
def parse_hash(value: str | bytes) -> bytes:
|
||||
if isinstance(value, bytes):
|
||||
if len(value) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
|
||||
return value
|
||||
raise ValueError("invalid identity/destination hash length")
|
||||
cleaned = value.lower().replace(":", "").strip()
|
||||
raw = bytes.fromhex(cleaned)
|
||||
expected = RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
|
||||
if len(raw) != expected:
|
||||
raise ValueError(f"hash must be {expected} bytes")
|
||||
return raw
|
||||
|
||||
|
||||
def hex_hash(value: bytes) -> str:
|
||||
return RNS.hexrep(value, delimit=False)
|
||||
|
||||
|
||||
def resolve_peer_identity(peer_hash: bytes):
|
||||
"""Resolve an identity from identity hash or destination hash."""
|
||||
identity = RNS.Identity.recall(peer_hash, from_identity_hash=True)
|
||||
if identity is not None:
|
||||
return identity, "identity"
|
||||
|
||||
identity = RNS.Identity.recall(peer_hash, from_identity_hash=False)
|
||||
if identity is not None:
|
||||
return identity, "destination"
|
||||
|
||||
# Same-process local destinations may not yet be in known_destinations.
|
||||
for destination in list(RNS.Transport.destinations):
|
||||
try:
|
||||
if destination.identity is None:
|
||||
continue
|
||||
if destination.identity.hash == peer_hash:
|
||||
identity = RNS.Identity(create_keys=False)
|
||||
identity.load_public_key(destination.identity.get_public_key())
|
||||
return identity, "local"
|
||||
if destination.hash == peer_hash:
|
||||
identity = RNS.Identity(create_keys=False)
|
||||
identity.load_public_key(destination.identity.get_public_key())
|
||||
return identity, "local_destination"
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def is_local_destination(destination_hash: bytes) -> bool:
|
||||
"""Return True when the destination is registered on this Reticulum instance."""
|
||||
for destination in list(RNS.Transport.destinations):
|
||||
try:
|
||||
if destination.hash == destination_hash:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
destinations_map = getattr(RNS.Transport, "destinations_map", None)
|
||||
if destinations_map is not None and destination_hash in destinations_map:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def wait_for_path(
|
||||
destination_hash: bytes,
|
||||
timeout: float = PATH_TIMEOUT_DEFAULT,
|
||||
) -> bool:
|
||||
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
||||
destination_hash,
|
||||
):
|
||||
return True
|
||||
RNS.Transport.request_path(destination_hash)
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if RNS.Transport.has_path(destination_hash) or is_local_destination(
|
||||
destination_hash,
|
||||
):
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return RNS.Transport.has_path(destination_hash) or is_local_destination(
|
||||
destination_hash,
|
||||
)
|
||||
|
||||
|
||||
def create_outbound_destination(peer_identity):
|
||||
return RNS.Destination(
|
||||
peer_identity,
|
||||
RNS.Destination.OUT,
|
||||
RNS.Destination.SINGLE,
|
||||
APP_NAME,
|
||||
ASPECT,
|
||||
)
|
||||
|
||||
|
||||
def establish_link(
|
||||
destination,
|
||||
*,
|
||||
established_callback: Callable | None = None,
|
||||
closed_callback: Callable | None = None,
|
||||
timeout: float = LINK_TIMEOUT_DEFAULT,
|
||||
):
|
||||
link = RNS.Link(
|
||||
destination,
|
||||
established_callback=established_callback,
|
||||
closed_callback=closed_callback,
|
||||
)
|
||||
deadline = time.time() + timeout
|
||||
while link.status not in (RNS.Link.ACTIVE, RNS.Link.CLOSED):
|
||||
if time.time() > deadline:
|
||||
try:
|
||||
link.teardown()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
time.sleep(0.05)
|
||||
if link.status != RNS.Link.ACTIVE:
|
||||
return None
|
||||
return link
|
||||
|
||||
|
||||
def peer_id_from_link(link) -> str | None:
|
||||
try:
|
||||
remote = link.get_remote_identity()
|
||||
if remote is not None:
|
||||
return hex_hash(remote.hash)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def destination_id_from_link(link) -> str | None:
|
||||
try:
|
||||
return hex_hash(link.destination.hash)
|
||||
except Exception:
|
||||
return None
|
||||
261
vendor/rns_filesync/rns_filesync/permissions.py
vendored
Normal file
261
vendor/rns_filesync/rns_filesync/permissions.py
vendored
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""rngit-style access control for FileSync.
|
||||
|
||||
Rules use permission:target (for example r:all, w:<hash>).
|
||||
When ACL enforcement is active, access is denied by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
from collections.abc import Iterable
|
||||
|
||||
from rns_filesync.constants import VALID_PERMISSIONS
|
||||
|
||||
TGT_ALL = "all"
|
||||
TGT_NONE = "none"
|
||||
|
||||
PERM_MAP = {
|
||||
"r": ("read",),
|
||||
"read": ("read",),
|
||||
"w": ("write",),
|
||||
"write": ("write",),
|
||||
"d": ("delete",),
|
||||
"delete": ("delete",),
|
||||
"rw": ("read", "write"),
|
||||
"readwrite": ("read", "write"),
|
||||
"rwd": ("read", "write", "delete"),
|
||||
"adm": ("read", "write", "delete"),
|
||||
"admin": ("read", "write", "delete"),
|
||||
}
|
||||
|
||||
TGT_ALL_ALIASES = frozenset({"all", "a", "everyone", "*"})
|
||||
TGT_NONE_ALIASES = frozenset({"none", "n", "nobody"})
|
||||
|
||||
|
||||
def _normalize_hash(value: bytes | str) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.hex()
|
||||
return value.lower().replace(":", "").strip()
|
||||
|
||||
|
||||
class PermissionStore:
|
||||
"""Thread-safe ACL using rngit-style permission:target rules."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entries: dict[str, list[str]] | None = None,
|
||||
*,
|
||||
enforce: bool | None = None,
|
||||
):
|
||||
self._lock = threading.RLock()
|
||||
self._aliases: dict[str, str] = {}
|
||||
self._blocked: set[str] = set()
|
||||
# permission -> set of targets (hash hex, ALL, NONE)
|
||||
self._rules: dict[str, set[str]] = {
|
||||
"read": set(),
|
||||
"write": set(),
|
||||
"delete": set(),
|
||||
}
|
||||
self._admin: set[str] = set()
|
||||
self._enforce = False if enforce is None else bool(enforce)
|
||||
if entries:
|
||||
for identity_hash, perms in entries.items():
|
||||
self.grant(identity_hash, perms)
|
||||
if enforce is None:
|
||||
self._enforce = True
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True when ACL enforcement is active (deny by default)."""
|
||||
with self._lock:
|
||||
return self._enforce
|
||||
|
||||
def set_alias(self, name: str, identity_hash: str) -> None:
|
||||
with self._lock:
|
||||
self._aliases[name.strip().lower()] = _normalize_hash(identity_hash)
|
||||
|
||||
def set_aliases(self, aliases: dict[str, str]) -> None:
|
||||
for name, identity_hash in aliases.items():
|
||||
self.set_alias(str(name), str(identity_hash))
|
||||
|
||||
def block(self, identity_hash: str | bytes) -> None:
|
||||
with self._lock:
|
||||
self._blocked.add(_normalize_hash(identity_hash))
|
||||
|
||||
def set_blocked(self, hashes: Iterable[str | bytes]) -> None:
|
||||
for value in hashes:
|
||||
self.block(value)
|
||||
|
||||
def _resolve_target(self, target: str) -> str | None:
|
||||
target = target.strip()
|
||||
if not target:
|
||||
return None
|
||||
lower = target.lower()
|
||||
if lower in self._aliases:
|
||||
return self._aliases[lower]
|
||||
if lower in TGT_ALL_ALIASES:
|
||||
return TGT_ALL
|
||||
if lower in TGT_NONE_ALIASES:
|
||||
return TGT_NONE
|
||||
cleaned = _normalize_hash(target)
|
||||
if len(cleaned) == 32:
|
||||
try:
|
||||
bytes.fromhex(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
def parse_rule(self, rule: str) -> tuple[tuple[str, ...], str] | None:
|
||||
"""Parse perm:target into permission names and resolved target."""
|
||||
rule = rule.strip()
|
||||
if not rule or rule.startswith("#"):
|
||||
return None
|
||||
if ":" not in rule:
|
||||
return None
|
||||
perm_s, target_s = rule.split(":", 1)
|
||||
perms = PERM_MAP.get(perm_s.strip().lower())
|
||||
if not perms:
|
||||
return None
|
||||
with self._lock:
|
||||
target = self._resolve_target(target_s)
|
||||
if target is None:
|
||||
return None
|
||||
return perms, target
|
||||
|
||||
def add_rule(self, rule: str) -> bool:
|
||||
parsed = self.parse_rule(rule)
|
||||
if not parsed:
|
||||
return False
|
||||
perms, target = parsed
|
||||
perm_key = rule.split(":", 1)[0].strip().lower()
|
||||
with self._lock:
|
||||
self._enforce = True
|
||||
if perm_key in ("adm", "admin"):
|
||||
self._admin.add(target)
|
||||
for perm in perms:
|
||||
self._rules[perm].add(target)
|
||||
return True
|
||||
|
||||
def add_rules(self, rules: Iterable[str]) -> int:
|
||||
loaded = 0
|
||||
for rule in rules:
|
||||
if self.add_rule(rule):
|
||||
loaded += 1
|
||||
return loaded
|
||||
|
||||
def grant(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
|
||||
"""Legacy helper: grant named permissions to one identity hash."""
|
||||
valid = []
|
||||
for perm in perms:
|
||||
p = str(perm).strip().lower()
|
||||
if p in VALID_PERMISSIONS and p not in valid:
|
||||
valid.append(p)
|
||||
if not valid:
|
||||
return []
|
||||
key = _normalize_hash(identity_hash)
|
||||
if key in TGT_ALL_ALIASES or key == "*":
|
||||
key = TGT_ALL
|
||||
with self._lock:
|
||||
self._enforce = True
|
||||
for perm in valid:
|
||||
self._rules[perm].add(key)
|
||||
return valid
|
||||
|
||||
def set_permissions(self, identity_hash: str, perms: Iterable[str]) -> list[str]:
|
||||
return self.grant(identity_hash, perms)
|
||||
|
||||
def load_allowed_text(self, text: str) -> int:
|
||||
loaded = 0
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if ":" in line:
|
||||
for part in [p.strip() for p in line.split(",") if p.strip()]:
|
||||
if self.add_rule(part):
|
||||
loaded += 1
|
||||
elif self._load_legacy_line(line):
|
||||
loaded += 1
|
||||
return loaded
|
||||
|
||||
def _load_legacy_line(self, line: str) -> bool:
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
identity_hash = parts[0]
|
||||
perms = [p.strip() for p in parts[1].split(",")]
|
||||
return bool(self.grant(identity_hash, perms))
|
||||
|
||||
def load_file(self, path: str) -> int:
|
||||
if not os.path.isfile(path):
|
||||
return 0
|
||||
if os.access(path, os.X_OK):
|
||||
# Executable .allowed: run and parse stdout (rngit behavior).
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
return self.load_allowed_text(result.stdout or "")
|
||||
except Exception:
|
||||
return 0
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
return self.load_allowed_text(handle.read())
|
||||
|
||||
def load_access_value(self, value: str | list | None) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
if isinstance(value, list):
|
||||
text = ", ".join(str(v) for v in value)
|
||||
else:
|
||||
text = str(value)
|
||||
return self.load_allowed_text(text)
|
||||
|
||||
def check(self, identity_hash: bytes | str, permission: str) -> bool:
|
||||
permission = permission.lower()
|
||||
if permission not in VALID_PERMISSIONS:
|
||||
return False
|
||||
key = _normalize_hash(identity_hash)
|
||||
with self._lock:
|
||||
if key in self._blocked:
|
||||
return False
|
||||
if not self._enforce:
|
||||
return True
|
||||
targets = self._rules.get(permission, set())
|
||||
if TGT_NONE in targets:
|
||||
return False
|
||||
if TGT_ALL in targets or key in targets:
|
||||
return True
|
||||
if TGT_ALL in self._admin or key in self._admin:
|
||||
return True
|
||||
return False
|
||||
|
||||
def can_connect(self, identity_hash: bytes | str) -> bool:
|
||||
key = _normalize_hash(identity_hash)
|
||||
with self._lock:
|
||||
if key in self._blocked:
|
||||
return False
|
||||
if not self._enforce:
|
||||
return True
|
||||
if TGT_ALL in self._admin or key in self._admin:
|
||||
return True
|
||||
for targets in self._rules.values():
|
||||
if TGT_NONE in targets:
|
||||
continue
|
||||
if TGT_ALL in targets or key in targets:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, identity_hash: bytes | str) -> list[str]:
|
||||
return [p for p in ("read", "write", "delete") if self.check(identity_hash, p)]
|
||||
|
||||
def as_dict(self) -> dict[str, list[str]]:
|
||||
with self._lock:
|
||||
return {perm: sorted(targets) for perm, targets in self._rules.items()}
|
||||
108
vendor/rns_filesync/rns_filesync/protocol.py
vendored
Normal file
108
vendor/rns_filesync/rns_filesync/protocol.py
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"""Control-plane message encode and decode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from RNS.vendor import umsgpack
|
||||
|
||||
MSG_FILE_LIST = "file_list"
|
||||
MSG_FILE_LIST_REQUEST = "file_list_request"
|
||||
MSG_FILE_REQUEST = "file_request"
|
||||
MSG_DELTA_REQUEST = "delta_request"
|
||||
MSG_EMPTY_FILE = "empty_file"
|
||||
MSG_FILE_UPDATE = "file_update"
|
||||
MSG_FILE_DELETION = "file_deletion"
|
||||
|
||||
KNOWN_TYPES = frozenset(
|
||||
{
|
||||
MSG_FILE_LIST,
|
||||
MSG_FILE_LIST_REQUEST,
|
||||
MSG_FILE_REQUEST,
|
||||
MSG_DELTA_REQUEST,
|
||||
MSG_EMPTY_FILE,
|
||||
MSG_FILE_UPDATE,
|
||||
MSG_FILE_DELETION,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ProtocolError(ValueError):
|
||||
"""Raised for invalid protocol messages."""
|
||||
|
||||
|
||||
def encode_message(payload: dict[str, Any]) -> bytes:
|
||||
if not isinstance(payload, dict) or "type" not in payload:
|
||||
raise ProtocolError("message must be a dict with type")
|
||||
return umsgpack.packb(payload)
|
||||
|
||||
|
||||
def decode_message(raw: bytes) -> dict[str, Any]:
|
||||
data = umsgpack.unpackb(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise ProtocolError("message must unpack to dict")
|
||||
msg_type = data.get("type")
|
||||
if not isinstance(msg_type, str) or msg_type not in KNOWN_TYPES:
|
||||
raise ProtocolError(f"unknown message type: {msg_type!r}")
|
||||
return data
|
||||
|
||||
|
||||
def make_file_list(files: dict[str, dict[str, Any]], browser: bool = False) -> bytes:
|
||||
return encode_message(
|
||||
{
|
||||
"type": MSG_FILE_LIST,
|
||||
"files": files,
|
||||
"browser": bool(browser),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_file_list_request(browser: bool = False) -> bytes:
|
||||
return encode_message({"type": MSG_FILE_LIST_REQUEST, "browser": bool(browser)})
|
||||
|
||||
|
||||
def make_file_request(path: str) -> bytes:
|
||||
return encode_message({"type": MSG_FILE_REQUEST, "path": path})
|
||||
|
||||
|
||||
def make_delta_request(path: str, local_blocks: list[str]) -> bytes:
|
||||
return encode_message(
|
||||
{
|
||||
"type": MSG_DELTA_REQUEST,
|
||||
"path": path,
|
||||
"local_blocks": list(local_blocks),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_empty_file(path: str, file_hash: str | None) -> bytes:
|
||||
return encode_message(
|
||||
{
|
||||
"type": MSG_EMPTY_FILE,
|
||||
"path": path,
|
||||
"hash": file_hash,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_file_update(path: str, info: dict[str, Any]) -> bytes:
|
||||
return encode_message(
|
||||
{
|
||||
"type": MSG_FILE_UPDATE,
|
||||
"path": path,
|
||||
"info": info,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def make_file_deletion(path: str) -> bytes:
|
||||
return encode_message({"type": MSG_FILE_DELETION, "path": path})
|
||||
|
||||
|
||||
def decode_metadata_value(value: Any) -> Any:
|
||||
if isinstance(value, bytes):
|
||||
try:
|
||||
return value.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return value.hex()
|
||||
return value
|
||||
1074
vendor/rns_filesync/rns_filesync/service.py
vendored
Normal file
1074
vendor/rns_filesync/rns_filesync/service.py
vendored
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue