mirror of
https://github.com/kc1awv/rrcd.git
synced 2026-04-23 20:19:59 -07:00
initial commit
This commit is contained in:
38
.github/workflows/ci.yml
vendored
Normal file
38
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.11", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: |
|
||||
ruff check .
|
||||
|
||||
- name: Compile
|
||||
run: |
|
||||
python -m compileall rrcd
|
||||
|
||||
- name: Tests (pytest)
|
||||
run: |
|
||||
pytest -q
|
||||
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.pyre/
|
||||
.pytype/
|
||||
.ruff_cache/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Packaging / builds
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS / editor temp
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Reticulum / local runtime state (keep secrets out of git)
|
||||
# (Adjust if you want to commit a sample config separately)
|
||||
.rrc/
|
||||
*.identity
|
||||
|
||||
# Test artifacts
|
||||
/tmp/
|
||||
|
||||
# Local logs
|
||||
*.log
|
||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
This project follows the versioning policy in VERSIONING.md.
|
||||
|
||||
## 0.1.0 - 2025-12-29
|
||||
|
||||
Initial public release.
|
||||
|
||||
- Standalone Reticulum Relay Chat daemon (hub service)
|
||||
- RRC v1 envelope + CBOR wire encoding
|
||||
- Core hub features: HELLO/WELCOME gating, JOIN/PART, MSG/NOTICE forwarding, PING/PONG
|
||||
- Operator and moderation commands via slash-command convention in MSG/NOTICE bodies
|
||||
- Persistent config + room registry in TOML (`rrcd.toml`, `rooms.toml`)
|
||||
- Reduced lock contention by flushing outbound packets outside the shared state lock
|
||||
- Added small packaging metadata and README polish
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 S. Miller, KC1AWV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
215
README.md
Normal file
215
README.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# rrcd
|
||||
|
||||
`rrcd` is a standalone RRC hub daemon (server) built on Reticulum (RNS).
|
||||
|
||||
- License: MIT (see LICENSE)
|
||||
- Changelog: CHANGELOG.md
|
||||
- Versioning policy: VERSIONING.md
|
||||
|
||||
## Install (dev)
|
||||
|
||||
From the `rrcd/` directory:
|
||||
|
||||
To install from source (non-editable):
|
||||
|
||||
- `python -m pip install .`
|
||||
|
||||
- `python -m pip install -e .`
|
||||
|
||||
For contributors (lint + tests):
|
||||
|
||||
- `python -m pip install -e ".[dev]"`
|
||||
- `ruff check .`
|
||||
- `pytest -q`
|
||||
|
||||
## Run
|
||||
|
||||
To run a basic RRC hub with default settings:
|
||||
- `rrcd`
|
||||
|
||||
You can also run it as a module:
|
||||
- `python -m rrcd`
|
||||
|
||||
First run will create a default config file at `~/.rrcd/rrcd.toml` and
|
||||
a default identity at `~/.rrcd/hub_identity`, plus a room registry at
|
||||
`~/.rrcd/rooms.toml`. You should read and edit the config before starting again.
|
||||
|
||||
To override the default state directory (`~/.rrcd/`), set `RRCD_HOME`, e.g.
|
||||
`RRCD_HOME=/tmp/rrcd rrcd`.
|
||||
|
||||
To specify a custom identity and destination name:
|
||||
- `rrcd --identity ~/.rrcd/hub_identity --dest-name rrc.hub`
|
||||
|
||||
Optional:
|
||||
|
||||
- `rrcd --config rrcd.toml`
|
||||
|
||||
You need a working Reticulum configuration (see Reticulum docs).
|
||||
|
||||
## Compatibility
|
||||
|
||||
`rrcd` implements the core RRC protocol as described in the RRC docs.
|
||||
|
||||
Extensions beyond core RRC will be documented in the Extensions section of this
|
||||
README as they are added.
|
||||
|
||||
In addition to the core protocol, `rrcd` includes operator-facing and
|
||||
policy-level features that are allowed by the spec:
|
||||
|
||||
- **First-run bootstrap**: if the default config and identity are missing,
|
||||
`rrcd` will create them and exit with a note asking you to edit the config
|
||||
before starting again.
|
||||
- **Rate limiting**: per-link message rate limiting may reject messages with
|
||||
`ERROR`.
|
||||
- **Room and input limits**: limits such as maximum rooms per session and
|
||||
maximum room name length.
|
||||
- **Optional `JOINED` member list**: can include a best-effort list of members.
|
||||
- **Optional hub-initiated `PING`**: can periodically ping clients and
|
||||
optionally close links that do not respond in time.
|
||||
|
||||
## Extensions
|
||||
|
||||
`rrcd` intentionally avoids adding new on-wire message types. Operator features
|
||||
use a hub-local convention: if a client sends a `MSG`/`NOTICE` whose body is a
|
||||
string beginning with `/`, and the command is recognized, the hub treats it as a
|
||||
command and does not forward it.
|
||||
|
||||
Configure trusted operators and banned identities in the TOML config:
|
||||
|
||||
- `trusted_identities`: list of Reticulum Identity hashes (hex) allowed to run
|
||||
commands
|
||||
- `banned_identities`: list of Identity hashes (hex) that are disconnected on
|
||||
identify
|
||||
|
||||
Implemented commands (best-effort):
|
||||
|
||||
Server operator commands (require identity in `trusted_identities`):
|
||||
|
||||
- `/stats` — show hub stats (uptime, clients, rooms, counters)
|
||||
- `/reload` — reload `rrcd.toml` and `rooms.toml` from disk
|
||||
- `/who [room]` — list members (nick and/or hash prefix)
|
||||
- `/kline add <nick|hashprefix|hash>` — add a server-global ban (persists to
|
||||
`banned_identities`)
|
||||
- `/kline del <hash>` — remove a server-global ban (persists to
|
||||
`banned_identities`)
|
||||
- `/kline list` — list global bans
|
||||
|
||||
Room moderation commands (room founder/ops; some actions may also work for
|
||||
server operators):
|
||||
|
||||
- `/kick <room> <nick|hashprefix>` — remove a client from a room
|
||||
- `/register <room>` — persist room settings to `rooms.toml` (founder only; must
|
||||
be in the room)
|
||||
- `/unregister <room>` — remove room settings from `rooms.toml` (founder only;
|
||||
must be in the room)
|
||||
- `/topic <room> [topic]` — show or set a room topic
|
||||
- `/mode <room> (+m|-m)` — set moderated mode
|
||||
- `/mode <room> (+i|-i)` — set invite-only mode
|
||||
- `/mode <room> (+k|-k) [key]` — set/clear room key (password)
|
||||
- `/mode <room> (+t|-t)` — set topic-ops-only (only ops can change topic)
|
||||
- `/mode <room> (+n|-n)` — set no-outside-messages
|
||||
- `/mode <room> (+r|-r)` — read-only; use /register or /unregister
|
||||
- `/mode <room> (+o|-o|+v|-v) <nick|hashprefix|hash>` — IRC-style user modes
|
||||
(alias for op/voice)
|
||||
- `/op <room> <nick|hashprefix|hash>` / `/deop ...` — grant/revoke room operator
|
||||
- `/voice <room> <nick|hashprefix|hash>` / `/devoice ...` — grant/revoke voice
|
||||
(for moderated rooms)
|
||||
- `/ban <room> add <nick|hashprefix|hash>` — add a room-local ban
|
||||
- `/ban <room> del <nick|hashprefix|hash>` — remove a room-local ban
|
||||
- `/ban <room> list` — list room-local bans
|
||||
- `/invite <room> add <nick|hashprefix|hash>` — send a room invite (as a
|
||||
`NOTICE` to the target)
|
||||
- `/invite <room> del <nick|hashprefix|hash>` — remove a room-local invite
|
||||
- `/invite <room> list` — list room-local invites
|
||||
|
||||
Notes:
|
||||
|
||||
- On successful JOIN, the hub sends a follow-up `NOTICE` to the joining client
|
||||
with room info (registered/unregistered, mode flags, and topic).
|
||||
- When a room is registered, default mode flags are `+nrt`.
|
||||
- `/invite` always sends the target a `NOTICE` (and fails if the target is not
|
||||
currently connected).
|
||||
- If the room has join restrictions, the hub also records an expiring invite so
|
||||
the target can actually use it to join:
|
||||
- `+i` (invite-only): only an invite allows a user to JOIN.
|
||||
- `+k` (keyed): an invite allows a user to JOIN without knowing the key. The
|
||||
key can then be disseminated in-band (in room) if desired.
|
||||
|
||||
These stored invites are consumed on successful JOIN or discarded when they
|
||||
expire. Configure the expiry with `room_invite_timeout_s` in `rrcd.toml`.
|
||||
- Registered-but-empty rooms may be pruned after a period of inactivity.
|
||||
Configure `room_registry_prune_after_s` and `room_registry_prune_interval_s`
|
||||
in `rrcd.toml`.
|
||||
|
||||
## rooms.toml format
|
||||
|
||||
The room registry file (`~/.rrcd/rooms.toml` by default) is a TOML document with
|
||||
a top-level `[rooms]` table. Each registered room is stored under a per-room
|
||||
table.
|
||||
|
||||
Example:
|
||||
|
||||
- `[rooms."lobby"]`
|
||||
|
||||
Supported keys per room:
|
||||
|
||||
- `founder`: hex Reticulum Identity hash (string)
|
||||
- `topic`: room topic (string, optional)
|
||||
- `moderated`: whether the room is in +m (bool)
|
||||
- `invite_only`: whether the room is in +i (bool)
|
||||
- `topic_ops_only`: whether the room is in +t (bool)
|
||||
- `no_outside_msgs`: whether the room is in +n (bool)
|
||||
- `key`: room key/password for +k (string, optional)
|
||||
- `operators`: list of identity hashes (strings)
|
||||
- `voiced`: list of identity hashes (strings)
|
||||
- `bans`: list of identity hashes (strings)
|
||||
- `invited`: table mapping identity hash (hex string) -> expiry unix timestamp
|
||||
seconds (float)
|
||||
- `last_used_ts`: unix timestamp seconds (float; used for pruning)
|
||||
|
||||
Note: room names are TOML keys. Quote room names that contain spaces or other
|
||||
non-identifier characters, e.g. `[rooms."my room"]`.
|
||||
|
||||
## Security and threat model
|
||||
|
||||
This section describes what `rrcd` is designed to protect against, what it is
|
||||
*not* designed to protect against, and the assumptions you should keep in mind
|
||||
when deploying it.
|
||||
|
||||
Assumptions:
|
||||
|
||||
- Reticulum link establishment and remote identity are authoritative for who a
|
||||
peer “is” (the hub uses the Link’s remote identity hash as the peer
|
||||
identity).
|
||||
- The host running `rrcd` is trusted by the operator (if the host is
|
||||
compromised, the hub and its policy controls are compromised).
|
||||
|
||||
What `rrcd` aims to protect against:
|
||||
|
||||
- **Unauthenticated pre-handshake traffic**: inbound packets are ignored until
|
||||
the Link’s remote identity is available.
|
||||
- **Protocol misuse**: clients must `HELLO` before they can perform other
|
||||
actions (WELCOME gating).
|
||||
- **Accidental resource exhaustion**: basic per-link rate limiting and input
|
||||
limits (rooms per session, room name length).
|
||||
- **Basic abuse controls**: operator identities, global bans (`/kline`), and
|
||||
per-room bans/modes.
|
||||
|
||||
What `rrcd` does *not* protect against (non-goals):
|
||||
|
||||
- **Denial of service by a determined attacker**: rate limiting is best-effort
|
||||
and does not prevent all forms of DoS.
|
||||
- **A malicious or compromised operator**: identities in `trusted_identities`
|
||||
can enforce policy; they can also abuse that power.
|
||||
- **Metadata/privacy leakage outside the hub’s control**: your threat model
|
||||
depends on Reticulum’s transport and your network topology.
|
||||
- **Confidentiality against the hub itself**: the hub can observe and forward
|
||||
traffic; do not treat it as a “zero trust” component.
|
||||
|
||||
Operational guidance:
|
||||
|
||||
- Keep the hub identity file and config directory private (the default storage
|
||||
directory is `~/.rrcd/`).
|
||||
- Treat `trusted_identities` like admin keys.
|
||||
- Prefer running `rrcd` under a dedicated OS user with locked-down permissions,
|
||||
aka “least privilege” principle. TL;DR: don’t run it as root.
|
||||
35
VERSIONING.md
Normal file
35
VERSIONING.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Versioning policy
|
||||
|
||||
`rrcd` uses Semantic Versioning (SemVer 2.0.0): `MAJOR.MINOR.PATCH`.
|
||||
|
||||
## Compatibility promises
|
||||
|
||||
- **Patch** releases are for bug fixes and internal refactors that do not change public behavior.
|
||||
- **Minor** releases may add functionality in a backwards-compatible way.
|
||||
- **Major** releases may include breaking changes.
|
||||
|
||||
### Pre-1.0 (current)
|
||||
|
||||
Before `1.0.0`, the project is still stabilizing. We still try to avoid breaking changes, but:
|
||||
|
||||
- Breaking changes may occur in **minor** releases.
|
||||
- When possible, we will call out breaking changes prominently in the changelog.
|
||||
|
||||
## What counts as a breaking change
|
||||
|
||||
A change is considered breaking if it requires updating an existing deployment or client setup, including:
|
||||
|
||||
- Changes to the RRC hub on-wire behavior that existing clients depend on
|
||||
- Changes to configuration keys or defaults that change behavior in surprising ways
|
||||
- Changes to persisted file formats (`rrcd.toml`, `rooms.toml`) that are not backwards-compatible
|
||||
|
||||
## Deprecation policy
|
||||
|
||||
When we can do so safely, we prefer deprecation over immediate removal:
|
||||
|
||||
- Deprecations are documented in the changelog.
|
||||
- A deprecation may include a warning period before removal (typically at least one minor release).
|
||||
|
||||
## Release notes
|
||||
|
||||
Every release updates CHANGELOG.md.
|
||||
53
pyproject.toml
Normal file
53
pyproject.toml
Normal file
@@ -0,0 +1,53 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rrcd"
|
||||
version = "0.1.0"
|
||||
description = "Reticulum Relay Chat daemon (hub service)"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["reticulum", "rns", "chat", "daemon", "cbor"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Communications :: Chat",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/kc1awv/rrcd"
|
||||
Repository = "https://github.com/kc1awv/rrcd"
|
||||
Issues = "https://github.com/kc1awv/rrcd/issues"
|
||||
dependencies = [
|
||||
"cbor2>=5.6.0",
|
||||
"rns>=0.8.0",
|
||||
"tomlkit>=0.13.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"ruff>=0.6.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
rrcd = "rrcd.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["rrcd"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py311"
|
||||
line-length = 88
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP"]
|
||||
ignore = ["E501"]
|
||||
3
rrcd/__init__.py
Normal file
3
rrcd/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
6
rrcd/__main__.py
Normal file
6
rrcd/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rrcd.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
359
rrcd/cli.py
Normal file
359
rrcd/cli.py
Normal file
@@ -0,0 +1,359 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import asdict, replace
|
||||
|
||||
import RNS
|
||||
|
||||
from .config import HubRuntimeConfig
|
||||
from .paths import (
|
||||
default_config_path,
|
||||
default_identity_path,
|
||||
default_room_registry_path,
|
||||
ensure_private_dir,
|
||||
)
|
||||
from .service import HubService
|
||||
|
||||
|
||||
def _load_toml(path: str) -> dict:
|
||||
import tomllib
|
||||
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def _apply_config_file(cfg: HubRuntimeConfig, path: str) -> HubRuntimeConfig:
|
||||
data = _load_toml(path)
|
||||
|
||||
hub = data.get("hub") if isinstance(data, dict) else None
|
||||
if isinstance(hub, dict):
|
||||
data = {**data, **hub}
|
||||
|
||||
allowed = set(asdict(cfg).keys())
|
||||
# This identifies where to reload/persist from; do not let the file override it.
|
||||
allowed.discard("config_path")
|
||||
updates = {k: v for k, v in data.items() if k in allowed}
|
||||
|
||||
for list_key in ("trusted_identities", "banned_identities"):
|
||||
if list_key in updates and isinstance(updates[list_key], list):
|
||||
updates[list_key] = tuple(str(x) for x in updates[list_key])
|
||||
|
||||
if "announce" in data and "announce_on_start" not in updates:
|
||||
try:
|
||||
updates["announce_on_start"] = bool(data["announce"])
|
||||
except Exception:
|
||||
pass
|
||||
if "configdir" in updates and updates["configdir"] == "":
|
||||
updates["configdir"] = None
|
||||
if "greeting" in updates and updates["greeting"] == "":
|
||||
updates["greeting"] = None
|
||||
return replace(cfg, **updates) if updates else cfg
|
||||
|
||||
|
||||
def _write_default_config(config_path: str, identity_path: str) -> None:
|
||||
cfg_dir = os.path.dirname(config_path)
|
||||
if cfg_dir:
|
||||
ensure_private_dir(__import__("pathlib").Path(cfg_dir))
|
||||
|
||||
storage_dir = os.path.dirname(identity_path)
|
||||
if storage_dir:
|
||||
ensure_private_dir(__import__("pathlib").Path(storage_dir))
|
||||
|
||||
room_registry_path = str(default_room_registry_path())
|
||||
|
||||
content = f"""# rrcd configuration (TOML)
|
||||
#
|
||||
# This file was created on first run.
|
||||
# Edit it, then start rrcd again.
|
||||
|
||||
[hub]
|
||||
|
||||
# Optional: Reticulum configuration directory.
|
||||
# If left unset, Reticulum will choose its default (usually ~/.reticulum).
|
||||
configdir = ""
|
||||
|
||||
# Where rrcd stores its persistent identity (Reticulum Identity file).
|
||||
identity_path = {identity_path!r}
|
||||
|
||||
# Separate room registry file (registered rooms, topics, modes, bans, etc).
|
||||
# This file is maintained by rrcd. You can edit it manually, but keep it valid TOML.
|
||||
# A running hub can reload both rrcd.toml and rooms.toml with the /reload command.
|
||||
room_registry_path = {room_registry_path!r}
|
||||
|
||||
# Destination name to host the hub on.
|
||||
dest_name = "rrc.hub"
|
||||
|
||||
# Announcing (Reticulum destination announces)
|
||||
#
|
||||
# announce_on_start: send a single announce right after startup.
|
||||
# announce_period_s: if >0, periodically re-announce.
|
||||
# To disable announcing entirely, set:
|
||||
# announce_on_start = false
|
||||
# announce_period_s = 0.0
|
||||
announce_on_start = true
|
||||
announce_period_s = 0.0
|
||||
|
||||
# WELCOME message fields.
|
||||
hub_name = "rrc"
|
||||
greeting = ""
|
||||
|
||||
# Operator / moderation
|
||||
#
|
||||
# trusted_identities: list of Reticulum Identity hashes (hex) allowed to run
|
||||
# operator commands.
|
||||
# banned_identities: list of Identity hashes (hex) that will be disconnected.
|
||||
trusted_identities = []
|
||||
banned_identities = []
|
||||
|
||||
# Registered-room pruning.
|
||||
# Only applies to registered rooms with no connected members.
|
||||
room_registry_prune_after_s = {30 * 24 * 3600}
|
||||
room_registry_prune_interval_s = 3600.0
|
||||
|
||||
# Keyed-room invites.
|
||||
# Room operators can use /invite to let a user join a +k room without the key.
|
||||
# Invites are removed on join or after this timeout.
|
||||
room_invite_timeout_s = 900.0
|
||||
|
||||
# Optional behaviors.
|
||||
include_joined_member_list = false
|
||||
|
||||
# Limits.
|
||||
max_rooms_per_session = 32
|
||||
max_room_name_len = 64
|
||||
rate_limit_msgs_per_minute = 240
|
||||
|
||||
# Hub-initiated liveness checks (0 disables).
|
||||
ping_interval_s = 0.0
|
||||
ping_timeout_s = 0.0
|
||||
"""
|
||||
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def _ensure_first_run_files(
|
||||
config_path: str, identity_path: str, room_registry_path: str
|
||||
) -> bool:
|
||||
created_any = False
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
_write_default_config(config_path, identity_path)
|
||||
created_any = True
|
||||
|
||||
if not os.path.exists(identity_path):
|
||||
storage_dir = os.path.dirname(identity_path)
|
||||
if storage_dir:
|
||||
ensure_private_dir(__import__("pathlib").Path(storage_dir))
|
||||
ident = RNS.Identity()
|
||||
ident.to_file(identity_path)
|
||||
try:
|
||||
os.chmod(identity_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
created_any = True
|
||||
|
||||
if room_registry_path and not os.path.exists(room_registry_path):
|
||||
storage_dir = os.path.dirname(room_registry_path)
|
||||
if storage_dir:
|
||||
ensure_private_dir(__import__("pathlib").Path(storage_dir))
|
||||
content = """# rrcd room registry (TOML)
|
||||
#
|
||||
# This file stores registered rooms and their moderation state.
|
||||
# It is maintained by rrcd and may be updated while rrcd is running.
|
||||
#
|
||||
# Schema
|
||||
# ------
|
||||
#
|
||||
# Each registered room is a table under [rooms]. Room names are TOML keys.
|
||||
# If your room name contains spaces or punctuation, quote it:
|
||||
#
|
||||
# [rooms."my room"]
|
||||
#
|
||||
# Supported keys per room:
|
||||
#
|
||||
# - founder: string, hex Reticulum Identity hash
|
||||
# - topic: string (optional)
|
||||
# - moderated: bool (defaults false)
|
||||
# - operators: list of string identity hashes (hex)
|
||||
# - voiced: list of string identity hashes (hex)
|
||||
# - bans: list of string identity hashes (hex)
|
||||
# - invited: table mapping identity hash (hex) -> expiry unix timestamp seconds
|
||||
# - last_used_ts: float unix timestamp seconds (used for pruning; optional)
|
||||
#
|
||||
# Example
|
||||
# -------
|
||||
#
|
||||
# [rooms."lobby"]
|
||||
# founder = "0123abcd..."
|
||||
# topic = "Welcome"
|
||||
# moderated = false
|
||||
# operators = ["0123abcd..."]
|
||||
# voiced = []
|
||||
# bans = []
|
||||
# invited = { "89abcdef..." = 1730003600.0 }
|
||||
# last_used_ts = 1730000000.0
|
||||
|
||||
[rooms]
|
||||
"""
|
||||
with open(room_registry_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
try:
|
||||
os.chmod(room_registry_path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
created_any = True
|
||||
|
||||
return created_any
|
||||
|
||||
|
||||
def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="rrcd", description="Run an RRC hub daemon")
|
||||
|
||||
p.add_argument(
|
||||
"--config",
|
||||
default=str(default_config_path()),
|
||||
help="Path to a TOML config file (created on first run)",
|
||||
)
|
||||
p.add_argument("--configdir", default=None, help="Reticulum config directory")
|
||||
|
||||
p.add_argument(
|
||||
"--identity",
|
||||
default=str(default_identity_path()),
|
||||
help="Path to hub identity file (created on first run)",
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--room-registry",
|
||||
default=str(default_room_registry_path()),
|
||||
help="Path to separate room registry TOML (created on first run)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dest-name", default=None, help="Destination app name (default: rrc.hub)"
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--no-announce",
|
||||
action="store_true",
|
||||
help="Disable announce on start (does not affect periodic announce)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--announce-period",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Periodic announce interval seconds (0 disables)",
|
||||
)
|
||||
|
||||
p.add_argument("--hub-name", default=None, help="Hub name in WELCOME")
|
||||
p.add_argument("--greeting", default=None, help="Greeting in WELCOME")
|
||||
p.add_argument(
|
||||
"--include-joined-member-list",
|
||||
action="store_true",
|
||||
help="Include member list in JOINED (best-effort)",
|
||||
)
|
||||
|
||||
p.add_argument("--max-rooms", type=int, default=None, help="Max rooms per session")
|
||||
p.add_argument(
|
||||
"--max-room-name-len", type=int, default=None, help="Max room name length"
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--rate-limit-msgs-per-minute",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Per-link message rate limit",
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--ping-interval",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Hub-initiated PING interval seconds (0 disables)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--ping-timeout",
|
||||
type=float,
|
||||
default=None,
|
||||
help="Close link if PONG not received within this many seconds (0 disables)",
|
||||
)
|
||||
|
||||
p.add_argument(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level (DEBUG, INFO, WARNING, ERROR)",
|
||||
)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = _build_arg_parser().parse_args(sys.argv[1:] if argv is None else argv)
|
||||
|
||||
config_path = str(args.config)
|
||||
identity_path = str(args.identity)
|
||||
room_registry_path = str(args.room_registry)
|
||||
|
||||
if _ensure_first_run_files(config_path, identity_path, room_registry_path):
|
||||
print(
|
||||
"Created default rrcd files. Edit the configuration before starting:\n"
|
||||
f"- Config: {config_path}\n"
|
||||
f"- Identity: {identity_path}\n"
|
||||
f"- Rooms: {room_registry_path}\n"
|
||||
"\nThen re-run rrcd.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
cfg = HubRuntimeConfig(configdir=args.configdir, identity_path=identity_path)
|
||||
cfg = replace(cfg, config_path=config_path)
|
||||
cfg = replace(cfg, room_registry_path=room_registry_path)
|
||||
|
||||
if config_path:
|
||||
cfg = _apply_config_file(cfg, config_path)
|
||||
|
||||
if args.dest_name is not None:
|
||||
cfg = replace(cfg, dest_name=args.dest_name)
|
||||
|
||||
if args.no_announce:
|
||||
cfg = replace(cfg, announce_on_start=False)
|
||||
if args.announce_period is not None:
|
||||
cfg = replace(cfg, announce_period_s=float(args.announce_period))
|
||||
|
||||
if args.hub_name is not None:
|
||||
cfg = replace(cfg, hub_name=args.hub_name)
|
||||
if args.greeting is not None:
|
||||
cfg = replace(cfg, greeting=args.greeting)
|
||||
|
||||
if args.include_joined_member_list:
|
||||
cfg = replace(cfg, include_joined_member_list=True)
|
||||
|
||||
if args.max_rooms is not None:
|
||||
cfg = replace(cfg, max_rooms_per_session=int(args.max_rooms))
|
||||
if args.max_room_name_len is not None:
|
||||
cfg = replace(cfg, max_room_name_len=int(args.max_room_name_len))
|
||||
|
||||
if args.rate_limit_msgs_per_minute is not None:
|
||||
cfg = replace(
|
||||
cfg, rate_limit_msgs_per_minute=int(args.rate_limit_msgs_per_minute)
|
||||
)
|
||||
|
||||
if args.ping_interval is not None:
|
||||
cfg = replace(cfg, ping_interval_s=float(args.ping_interval))
|
||||
if args.ping_timeout is not None:
|
||||
cfg = replace(cfg, ping_timeout_s=float(args.ping_timeout))
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, str(args.log_level).upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
svc = HubService(cfg)
|
||||
svc.start()
|
||||
svc.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
11
rrcd/codec.py
Normal file
11
rrcd/codec.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import cbor2
|
||||
|
||||
|
||||
def encode(obj) -> bytes:
|
||||
return cbor2.dumps(obj)
|
||||
|
||||
|
||||
def decode(b: bytes):
|
||||
return cbor2.loads(b)
|
||||
33
rrcd/config.py
Normal file
33
rrcd/config.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HubRuntimeConfig:
|
||||
config_path: str | None = None
|
||||
room_registry_path: str | None = None
|
||||
configdir: str | None = None
|
||||
identity_path: str | None = None
|
||||
dest_name: str = "rrc.hub"
|
||||
announce_on_start: bool = True
|
||||
announce_period_s: float = 0.0
|
||||
hub_name: str = "rrc"
|
||||
greeting: str | None = None
|
||||
# Hex-encoded Reticulum identity hashes trusted as operators.
|
||||
trusted_identities: tuple[str, ...] = ()
|
||||
# Hex-encoded Reticulum identity hashes banned from connecting.
|
||||
banned_identities: tuple[str, ...] = ()
|
||||
|
||||
# Room registry maintenance (registered rooms are stored in room_registry_path).
|
||||
# Pruning only applies to registered rooms with no connected members.
|
||||
room_registry_prune_after_s: float = 30 * 24 * 3600
|
||||
room_registry_prune_interval_s: float = 3600.0
|
||||
# Invite timeout for keyed rooms (+k). Invites are removed on join or expiry.
|
||||
room_invite_timeout_s: float = 900.0
|
||||
include_joined_member_list: bool = False
|
||||
max_rooms_per_session: int = 32
|
||||
max_room_name_len: int = 64
|
||||
rate_limit_msgs_per_minute: int = 240
|
||||
ping_interval_s: float = 0.0
|
||||
ping_timeout_s: float = 0.0
|
||||
39
rrcd/constants.py
Normal file
39
rrcd/constants.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# RRC protocol constants (numeric keys and message types)
|
||||
|
||||
RRC_VERSION = 1
|
||||
|
||||
# Envelope keys
|
||||
K_V = 0
|
||||
K_T = 1
|
||||
K_ID = 2
|
||||
K_TS = 3
|
||||
K_SRC = 4
|
||||
K_ROOM = 5
|
||||
K_BODY = 6
|
||||
|
||||
# Message types
|
||||
T_HELLO = 1
|
||||
T_WELCOME = 2
|
||||
|
||||
T_JOIN = 10
|
||||
T_JOINED = 11
|
||||
T_PART = 12
|
||||
|
||||
T_MSG = 20
|
||||
T_NOTICE = 21
|
||||
|
||||
T_PING = 30
|
||||
T_PONG = 31
|
||||
|
||||
T_ERROR = 40
|
||||
|
||||
# HELLO body keys
|
||||
B_HELLO_NICK = 0
|
||||
B_HELLO_NAME = 1
|
||||
B_HELLO_VER = 2
|
||||
B_HELLO_CAPS = 3
|
||||
|
||||
# WELCOME body keys
|
||||
B_WELCOME_HUB = 0
|
||||
B_WELCOME_GREETING = 1
|
||||
B_WELCOME_CAPS = 2
|
||||
83
rrcd/envelope.py
Normal file
83
rrcd/envelope.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from .constants import K_BODY, K_ID, K_ROOM, K_SRC, K_T, K_TS, K_V, RRC_VERSION
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def msg_id() -> bytes:
|
||||
return os.urandom(8)
|
||||
|
||||
|
||||
def make_envelope(
|
||||
msg_type: int,
|
||||
*,
|
||||
src: bytes,
|
||||
room: str | None = None,
|
||||
body=None,
|
||||
mid: bytes | None = None,
|
||||
ts: int | None = None,
|
||||
) -> dict:
|
||||
env: dict[int, object] = {
|
||||
K_V: RRC_VERSION,
|
||||
K_T: int(msg_type),
|
||||
K_ID: mid or msg_id(),
|
||||
K_TS: ts or now_ms(),
|
||||
K_SRC: src,
|
||||
}
|
||||
if room is not None:
|
||||
env[K_ROOM] = room
|
||||
if body is not None:
|
||||
env[K_BODY] = body
|
||||
return env
|
||||
|
||||
|
||||
def validate_envelope(env: dict) -> None:
|
||||
if not isinstance(env, dict):
|
||||
raise TypeError("envelope must be a CBOR map (dict)")
|
||||
|
||||
for k in env.keys():
|
||||
if not isinstance(k, int):
|
||||
raise TypeError("envelope keys must be integers")
|
||||
if k < 0:
|
||||
raise ValueError("envelope keys must be unsigned integers")
|
||||
|
||||
for k in (K_V, K_T, K_ID, K_TS, K_SRC):
|
||||
if k not in env:
|
||||
raise ValueError(f"missing envelope key {k}")
|
||||
|
||||
v = env[K_V]
|
||||
if not isinstance(v, int):
|
||||
raise TypeError("protocol version must be an integer")
|
||||
if v != RRC_VERSION:
|
||||
raise ValueError(f"unsupported version {v}")
|
||||
|
||||
t = env[K_T]
|
||||
if not isinstance(t, int):
|
||||
raise TypeError("message type must be an integer")
|
||||
|
||||
mid = env[K_ID]
|
||||
if not isinstance(mid, (bytes, bytearray)):
|
||||
raise TypeError("message id must be bytes")
|
||||
|
||||
ts = env[K_TS]
|
||||
if not isinstance(ts, int):
|
||||
raise TypeError("timestamp must be an integer")
|
||||
if ts < 0:
|
||||
raise ValueError("timestamp must be unsigned")
|
||||
|
||||
src = env[K_SRC]
|
||||
if not isinstance(src, (bytes, bytearray)):
|
||||
raise TypeError("sender identity must be bytes")
|
||||
|
||||
if K_ROOM in env:
|
||||
room = env[K_ROOM]
|
||||
if not isinstance(room, str):
|
||||
raise TypeError("room name must be a string")
|
||||
if room == "":
|
||||
raise ValueError("room name must not be empty")
|
||||
32
rrcd/paths.py
Normal file
32
rrcd/paths.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def default_rrcd_dir() -> Path:
|
||||
override = os.environ.get("RRCD_HOME")
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".rrcd"
|
||||
|
||||
|
||||
def default_config_path() -> Path:
|
||||
return default_rrcd_dir() / "rrcd.toml"
|
||||
|
||||
|
||||
def default_identity_path() -> Path:
|
||||
return default_rrcd_dir() / "hub_identity"
|
||||
|
||||
|
||||
def default_room_registry_path() -> Path:
|
||||
return default_rrcd_dir() / "rooms.toml"
|
||||
|
||||
|
||||
def ensure_private_dir(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
# Best-effort tightening; may fail on some filesystems.
|
||||
os.chmod(path, 0o700)
|
||||
except Exception:
|
||||
pass
|
||||
2418
rrcd/service.py
Normal file
2418
rrcd/service.py
Normal file
File diff suppressed because it is too large
Load Diff
7
rrcd/util.py
Normal file
7
rrcd/util.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def expand_path(p: str) -> str:
|
||||
return os.path.expanduser(os.path.expandvars(p))
|
||||
11
tests/test_codec.py
Normal file
11
tests/test_codec.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from rrcd.codec import decode, encode
|
||||
from rrcd.constants import T_MSG
|
||||
from rrcd.envelope import make_envelope, validate_envelope
|
||||
|
||||
|
||||
def test_codec_round_trip() -> None:
|
||||
env = make_envelope(T_MSG, src=b"peer", room="#general", body="hello")
|
||||
data = encode(env)
|
||||
decoded = decode(data)
|
||||
assert decoded == env
|
||||
validate_envelope(decoded)
|
||||
69
tests/test_envelope.py
Normal file
69
tests/test_envelope.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import pytest
|
||||
|
||||
from rrcd.constants import (
|
||||
B_HELLO_NICK,
|
||||
K_BODY,
|
||||
K_ID,
|
||||
K_SRC,
|
||||
K_T,
|
||||
K_TS,
|
||||
K_V,
|
||||
RRC_VERSION,
|
||||
T_HELLO,
|
||||
)
|
||||
from rrcd.envelope import make_envelope, validate_envelope
|
||||
|
||||
|
||||
def test_validate_accepts_make_envelope() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body={B_HELLO_NICK: "alice"})
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_rejects_missing_required_key() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env.pop(K_TS)
|
||||
with pytest.raises(ValueError):
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_rejects_wrong_version() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env[K_V] = RRC_VERSION + 1
|
||||
with pytest.raises(ValueError):
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_rejects_non_integer_keys() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env["1"] = env.pop(K_T)
|
||||
with pytest.raises(TypeError):
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_allows_unknown_extension_keys() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env[64] = {"future": True}
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_allows_omitted_body() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
assert K_BODY not in env
|
||||
validate_envelope(env)
|
||||
|
||||
|
||||
def test_validate_rejects_wrong_field_types() -> None:
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env[K_ID] = "not-bytes"
|
||||
with pytest.raises(TypeError):
|
||||
validate_envelope(env)
|
||||
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env[K_SRC] = "not-bytes"
|
||||
with pytest.raises(TypeError):
|
||||
validate_envelope(env)
|
||||
|
||||
env = make_envelope(T_HELLO, src=b"peer", body=None)
|
||||
env[K_TS] = "not-int"
|
||||
with pytest.raises(TypeError):
|
||||
validate_envelope(env)
|
||||
Reference in New Issue
Block a user