initial commit

This commit is contained in:
kc1awv
2025-12-29 16:08:24 -05:00
commit 262b8b36ce
19 changed files with 3488 additions and 0 deletions

38
.github/workflows/ci.yml vendored Normal file
View 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
View 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
View 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
View 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
View 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 Links 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 Links 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 hubs control**: your threat model
depends on Reticulums 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: dont run it as root.

35
VERSIONING.md Normal file
View 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
View 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
View File

@@ -0,0 +1,3 @@
__all__ = ["__version__"]
__version__ = "0.1.0"

6
rrcd/__main__.py Normal file
View File

@@ -0,0 +1,6 @@
from __future__ import annotations
from rrcd.cli import main
if __name__ == "__main__":
main()

359
rrcd/cli.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

7
rrcd/util.py Normal file
View 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
View 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
View 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)