2026-09-27 18:41:42 +03:00
|
|
|
#!/usr/bin/env -S uv run --quiet --script
|
|
|
|
|
# /// script
|
|
|
|
|
# requires-python = ">=3.11"
|
|
|
|
|
# dependencies = ["rns>=0.7.3", "cryptography>=42"]
|
|
|
|
|
# ///
|
2026-09-28 22:23:01 +03:00
|
|
|
# Copyright 2026 randogoth
|
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
2026-09-27 18:41:42 +03:00
|
|
|
"""Smol Mail reference server -- Reticulum transport.
|
|
|
|
|
|
|
|
|
|
Implements reticulum.md (protocol 1.2), the Reticulum-transport addendum to
|
|
|
|
|
SPEC.md's version 1.1. It is the same store-and-forward mailbox as
|
|
|
|
|
smolmaild.py -- the same envelopes, operations, quotas and status codes --
|
|
|
|
|
reached over the Reticulum Network Stack instead of TCP and Noise. Only
|
|
|
|
|
session authentication changes: AUTH and REGISTER sign a value derived from
|
|
|
|
|
the Reticulum link, in place of the Noise handshake hash and static key
|
|
|
|
|
(reticulum.md S13.6). A server MAY point --db at the same file a smolmaild.py
|
|
|
|
|
instance uses; the two transports share one store (S15).
|
|
|
|
|
|
|
|
|
|
uv run smolmaild_rns.py keygen --key server.rns.key
|
|
|
|
|
uv run smolmaild_rns.py serve --key server.rns.key --db mail.db
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import sqlite3
|
|
|
|
|
import struct
|
|
|
|
|
import sys
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
import RNS
|
|
|
|
|
from cryptography.exceptions import InvalidSignature
|
|
|
|
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger("smolmaild-rns")
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Protocol constants (SPEC.md S6, S11, S12; reticulum.md S13, S14)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
APP_NAME, ASPECT = "smolmail", "server" # RNS.Destination(identity, IN, SINGLE, ...) (S13.1)
|
|
|
|
|
PATH = "smolmail/1" # the one request path; the type byte multiplexes operations (S13.5)
|
|
|
|
|
|
|
|
|
|
LABEL_AUTH = b"smolmail/1 auth"
|
|
|
|
|
LABEL_ID = b"smolmail/1 id"
|
|
|
|
|
LABEL_MAC = b"smolmail/1 mac"
|
|
|
|
|
LABEL_ROTATE = b"smolmail/1 rotate"
|
|
|
|
|
LABEL_REGISTER = b"smolmail/1 register"
|
|
|
|
|
LABEL_BIND = b"smolmail/1 bind" # S14: link binding, replaces the Noise handshake
|
|
|
|
|
|
|
|
|
|
OP_AUTH = 0x00
|
|
|
|
|
OP_RESOLVE = 0x01
|
|
|
|
|
OP_SEND = 0x02
|
|
|
|
|
OP_FETCH = 0x03
|
|
|
|
|
OP_DELETE = 0x04
|
|
|
|
|
OP_REGISTER = 0x05
|
|
|
|
|
|
|
|
|
|
OK = 0
|
|
|
|
|
MALFORMED = 1
|
|
|
|
|
BAD_VERSION = 2
|
|
|
|
|
UNKNOWN_USER = 3
|
|
|
|
|
AUTH_REQUIRED = 4
|
|
|
|
|
AUTH_FAILED = 5
|
|
|
|
|
QUOTA_EXCEEDED = 6
|
|
|
|
|
TOO_LARGE = 7
|
|
|
|
|
RATE_LIMITED = 8
|
|
|
|
|
NOT_PERMITTED = 9
|
|
|
|
|
INTERNAL_ERROR = 10
|
|
|
|
|
|
|
|
|
|
ENVELOPE_MAGIC = b"SMOL"
|
|
|
|
|
ENVELOPE_VERSION = 1
|
|
|
|
|
ENVELOPE_HEADER = 69 # magic 4 + version 1 + to 32 + epk 32
|
|
|
|
|
ENVELOPE_MIN = ENVELOPE_HEADER + 16 # + Poly1305 tag
|
|
|
|
|
ID_LEN = 32
|
|
|
|
|
KEY_LEN = 32
|
|
|
|
|
SIG_LEN = 64
|
|
|
|
|
TOKEN_LEN = 32 # S5.8 accept token, and the MAC derived from it
|
|
|
|
|
CERT_LEN = 200 # old_pub 32 + new_pub 32 + time 8 + two signatures
|
|
|
|
|
MAX_CHAIN = 16
|
|
|
|
|
SEPARATORS = "._-"
|
|
|
|
|
|
|
|
|
|
TIER_MAIN = 0
|
|
|
|
|
TIER_REQUESTS = 1
|
|
|
|
|
FLAG_REQUESTS = 0x01 # S6.1 record flags
|
|
|
|
|
|
|
|
|
|
RECORD_OVERHEAD = ID_LEN + 8 + 1 + 4 # id + received_at + flags + env_len
|
|
|
|
|
PURGE_INTERVAL = 60.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ProtocolError(Exception):
|
|
|
|
|
"""A peer sent something unparseable. Always answered with MALFORMED."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Encoding helpers
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Reader:
|
|
|
|
|
"""Fail-closed reader over a request body.
|
|
|
|
|
|
|
|
|
|
Every parse path raises rather than reading past the end, so a truncated
|
|
|
|
|
request can never be mistaken for a short but valid one.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, buf: bytes) -> None:
|
|
|
|
|
self.buf = buf
|
|
|
|
|
self.pos = 0
|
|
|
|
|
|
|
|
|
|
def take(self, n: int) -> bytes:
|
|
|
|
|
if n < 0 or self.pos + n > len(self.buf):
|
|
|
|
|
raise ProtocolError(f"short read: want {n}, have {len(self.buf) - self.pos}")
|
|
|
|
|
out = self.buf[self.pos : self.pos + n]
|
|
|
|
|
self.pos += n
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
def u8(self) -> int:
|
|
|
|
|
return self.take(1)[0]
|
|
|
|
|
|
|
|
|
|
def u16(self) -> int:
|
|
|
|
|
return struct.unpack(">H", self.take(2))[0]
|
|
|
|
|
|
|
|
|
|
def i64(self) -> int:
|
|
|
|
|
return struct.unpack(">q", self.take(8))[0]
|
|
|
|
|
|
|
|
|
|
def rest(self) -> bytes:
|
|
|
|
|
return self.take(len(self.buf) - self.pos)
|
|
|
|
|
|
|
|
|
|
def done(self) -> None:
|
|
|
|
|
if self.pos != len(self.buf):
|
|
|
|
|
raise ProtocolError(f"{len(self.buf) - self.pos} trailing bytes")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def valid_username(name: str) -> bool:
|
|
|
|
|
"""SPEC.md S3: 1-63 bytes of [a-z0-9._-], alphanumeric ends, no adjacent separators."""
|
|
|
|
|
if not 1 <= len(name) <= 63:
|
|
|
|
|
return False
|
|
|
|
|
if not all("0" <= c <= "9" or "a" <= c <= "z" or c in SEPARATORS for c in name):
|
|
|
|
|
return False
|
|
|
|
|
if name[0] in SEPARATORS or name[-1] in SEPARATORS:
|
|
|
|
|
return False
|
|
|
|
|
return not any(a in SEPARATORS and b in SEPARATORS for a, b in zip(name, name[1:]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def message_id(envelope: bytes) -> bytes:
|
|
|
|
|
"""SPEC.md S5.4. Derived from the envelope, so a sender cannot choose it."""
|
|
|
|
|
return hashlib.sha256(LABEL_ID + envelope).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify(pubkey: bytes, signature: bytes, message: bytes) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
Ed25519PublicKey.from_public_bytes(pubkey).verify(signature, message)
|
|
|
|
|
return True
|
|
|
|
|
except (InvalidSignature, ValueError):
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bind_h(destination_hash: bytes, link_id: bytes) -> bytes:
|
|
|
|
|
"""reticulum.md S13.6: substitutes the Noise handshake hash in AUTH's signature."""
|
|
|
|
|
return hashlib.sha256(LABEL_BIND + destination_hash + link_id).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bind_server_static(destination_hash: bytes) -> bytes:
|
|
|
|
|
"""reticulum.md S13.6: substitutes the Noise static key in REGISTER's signature."""
|
|
|
|
|
return hashlib.sha256(LABEL_BIND + destination_hash).digest()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def max_request_bytes(max_envelope: int, max_accepted: int) -> int:
|
|
|
|
|
"""S13.5: the greater of an envelope plus 34 bytes and a full-token AUTH."""
|
|
|
|
|
send_max = 1 + 1 + TOKEN_LEN + max_envelope # type + mac_len + mac + envelope
|
|
|
|
|
auth_max = 1 + 1 + 63 + KEY_LEN + SIG_LEN + 1 + 2 + TOKEN_LEN * max_accepted
|
|
|
|
|
return max(send_max, auth_max)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Storage (unchanged from smolmaild.py; a server MAY share this file across
|
|
|
|
|
# both transports, S15)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
SCHEMA = """
|
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
|
|
|
username TEXT PRIMARY KEY,
|
|
|
|
|
identity BLOB NOT NULL
|
|
|
|
|
);
|
|
|
|
|
-- Every key ever bound to a username, so a superseded key stays addressable
|
|
|
|
|
-- across a rotation (SPEC.md S7).
|
|
|
|
|
CREATE TABLE IF NOT EXISTS keys (
|
|
|
|
|
identity BLOB PRIMARY KEY,
|
|
|
|
|
username TEXT NOT NULL
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS rotations (
|
|
|
|
|
username TEXT NOT NULL,
|
|
|
|
|
seq INTEGER NOT NULL,
|
|
|
|
|
cert BLOB NOT NULL,
|
|
|
|
|
PRIMARY KEY (username, seq)
|
|
|
|
|
);
|
|
|
|
|
-- Accept tokens (SPEC.md S5.8): opaque secrets the mailbox owner uploads with
|
|
|
|
|
-- AUTH. The server can match them against a message's MAC and nothing else;
|
|
|
|
|
-- it never learns which correspondent a token belongs to.
|
|
|
|
|
CREATE TABLE IF NOT EXISTS accepted (
|
|
|
|
|
username TEXT NOT NULL,
|
|
|
|
|
token BLOB NOT NULL,
|
|
|
|
|
PRIMARY KEY (username, token)
|
|
|
|
|
);
|
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
|
|
|
id BLOB PRIMARY KEY,
|
|
|
|
|
recipient BLOB NOT NULL,
|
|
|
|
|
received_at INTEGER NOT NULL,
|
|
|
|
|
tier INTEGER NOT NULL,
|
|
|
|
|
envelope BLOB NOT NULL
|
|
|
|
|
);
|
|
|
|
|
-- (received_at, id) is FETCH's sort and cursor order (SPEC.md S6.1).
|
|
|
|
|
CREATE INDEX IF NOT EXISTS messages_by_recipient
|
|
|
|
|
ON messages (recipient, received_at, id);
|
|
|
|
|
-- Identifiers of every envelope accepted within the retention window, so an
|
|
|
|
|
-- envelope captured and resent after DELETE does not reappear (SPEC.md S10).
|
|
|
|
|
CREATE TABLE IF NOT EXISTS seen (
|
|
|
|
|
id BLOB PRIMARY KEY,
|
|
|
|
|
at INTEGER NOT NULL
|
|
|
|
|
);
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Store:
|
|
|
|
|
"""SQLite behind a connection per thread.
|
|
|
|
|
|
|
|
|
|
RNS may call a request handler from more than one internal thread (packet
|
|
|
|
|
reception, resource assembly), and sqlite3 connections are not shareable
|
|
|
|
|
across threads, so each thread gets its own via thread-local state.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, path: str) -> None:
|
|
|
|
|
self.path = path
|
|
|
|
|
self.local = threading.local()
|
|
|
|
|
with sqlite3.connect(path) as db:
|
|
|
|
|
db.execute("PRAGMA journal_mode=WAL")
|
|
|
|
|
db.executescript(SCHEMA)
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def db(self) -> sqlite3.Connection:
|
|
|
|
|
conn = getattr(self.local, "conn", None)
|
|
|
|
|
if conn is None:
|
|
|
|
|
conn = sqlite3.connect(self.path, timeout=10.0)
|
|
|
|
|
conn.execute("PRAGMA busy_timeout=10000")
|
|
|
|
|
self.local.conn = conn
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
def identity_of(self, username: str) -> bytes | None:
|
|
|
|
|
row = self.db.execute(
|
|
|
|
|
"SELECT identity FROM users WHERE username = ?", (username,)
|
|
|
|
|
).fetchone()
|
|
|
|
|
return row[0] if row else None
|
|
|
|
|
|
|
|
|
|
def chain(self, username: str) -> list[bytes]:
|
|
|
|
|
return [
|
|
|
|
|
row[0]
|
|
|
|
|
for row in self.db.execute(
|
|
|
|
|
"SELECT cert FROM rotations WHERE username = ? ORDER BY seq", (username,)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def keys_of(self, username: str) -> list[bytes]:
|
|
|
|
|
return [
|
|
|
|
|
row[0]
|
|
|
|
|
for row in self.db.execute(
|
|
|
|
|
"SELECT identity FROM keys WHERE username = ?", (username,)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def username_for_key(self, identity: bytes) -> str | None:
|
|
|
|
|
row = self.db.execute(
|
|
|
|
|
"SELECT username FROM keys WHERE identity = ?", (identity,)
|
|
|
|
|
).fetchone()
|
|
|
|
|
return row[0] if row else None
|
|
|
|
|
|
|
|
|
|
def register(self, username: str, identity: bytes) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
with self.db:
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT INTO users (username, identity) VALUES (?, ?)",
|
|
|
|
|
(username, identity),
|
|
|
|
|
)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT INTO keys (identity, username) VALUES (?, ?)",
|
|
|
|
|
(identity, username),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
# Username taken, or this key is already bound to another username.
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def rotate(self, username: str, new_key: bytes, cert: bytes, seq: int) -> bool:
|
|
|
|
|
try:
|
|
|
|
|
with self.db:
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"UPDATE users SET identity = ? WHERE username = ?",
|
|
|
|
|
(new_key, username),
|
|
|
|
|
)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT INTO keys (identity, username) VALUES (?, ?)",
|
|
|
|
|
(new_key, username),
|
|
|
|
|
)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT INTO rotations (username, seq, cert) VALUES (?, ?, ?)",
|
|
|
|
|
(username, seq, cert),
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
def accepted_tokens(self, username: str) -> list[bytes]:
|
|
|
|
|
return [
|
|
|
|
|
row[0]
|
|
|
|
|
for row in self.db.execute(
|
|
|
|
|
"SELECT token FROM accepted WHERE username = ?", (username,)
|
|
|
|
|
)
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
def set_accepted(self, username: str, tokens: list[bytes]) -> int:
|
|
|
|
|
"""S4: an AUTH with sync = 1 replaces the whole set, which is how a
|
|
|
|
|
token is both added and removed."""
|
|
|
|
|
with self.db:
|
|
|
|
|
self.db.execute("DELETE FROM accepted WHERE username = ?", (username,))
|
|
|
|
|
self.db.executemany(
|
|
|
|
|
"INSERT OR IGNORE INTO accepted (username, token) VALUES (?, ?)",
|
|
|
|
|
[(username, token) for token in tokens],
|
|
|
|
|
)
|
|
|
|
|
return self.count_accepted(username)
|
|
|
|
|
|
|
|
|
|
def count_accepted(self, username: str) -> int:
|
|
|
|
|
return self.db.execute(
|
|
|
|
|
"SELECT COUNT(*) FROM accepted WHERE username = ?", (username,)
|
|
|
|
|
).fetchone()[0]
|
|
|
|
|
|
|
|
|
|
def tombstoned(self, mid: bytes) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
self.db.execute("SELECT 1 FROM seen WHERE id = ?", (mid,)).fetchone()
|
|
|
|
|
is not None
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def mailbox_bytes(self, keys: list[bytes], tier: int) -> int:
|
|
|
|
|
marks = ",".join("?" * len(keys))
|
|
|
|
|
row = self.db.execute(
|
|
|
|
|
f"SELECT COALESCE(SUM(LENGTH(envelope)), 0) FROM messages "
|
|
|
|
|
f"WHERE tier = ? AND recipient IN ({marks})",
|
|
|
|
|
[tier] + keys,
|
|
|
|
|
).fetchone()
|
|
|
|
|
return row[0]
|
|
|
|
|
|
|
|
|
|
def store_message(self, mid: bytes, recipient: bytes, tier: int, envelope: bytes) -> None:
|
|
|
|
|
with self.db:
|
|
|
|
|
now = int(time.time())
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT OR IGNORE INTO messages "
|
|
|
|
|
"(id, recipient, received_at, tier, envelope) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
(mid, recipient, now, tier, envelope),
|
|
|
|
|
)
|
|
|
|
|
self.db.execute(
|
|
|
|
|
"INSERT OR IGNORE INTO seen (id, at) VALUES (?, ?)", (mid, now)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def pending(
|
|
|
|
|
self, keys: list[bytes], after_time: int, after_id: bytes, budget: int
|
|
|
|
|
) -> list[tuple[bytes, int, int, bytes]]:
|
|
|
|
|
marks = ",".join("?" * len(keys))
|
|
|
|
|
rows = self.db.execute(
|
|
|
|
|
f"SELECT id, received_at, tier, envelope FROM messages "
|
|
|
|
|
f"WHERE recipient IN ({marks}) "
|
|
|
|
|
f"AND (received_at > ? OR (received_at = ? AND id > ?)) "
|
|
|
|
|
f"ORDER BY received_at, id",
|
|
|
|
|
keys + [after_time, after_time, after_id],
|
|
|
|
|
)
|
|
|
|
|
out: list[tuple[bytes, int, int, bytes]] = []
|
|
|
|
|
used = 0
|
|
|
|
|
for mid, received_at, tier, envelope in rows:
|
|
|
|
|
size = RECORD_OVERHEAD + len(envelope)
|
|
|
|
|
# Always return at least one record, even if it alone exceeds the
|
|
|
|
|
# budget, so an oversized envelope cannot wedge a mailbox shut.
|
|
|
|
|
if out and used + size > budget:
|
|
|
|
|
break
|
|
|
|
|
out.append((mid, received_at, tier, envelope))
|
|
|
|
|
used += size
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
def delete(self, keys: list[bytes], ids: list[bytes]) -> int:
|
|
|
|
|
kmarks = ",".join("?" * len(keys))
|
|
|
|
|
imarks = ",".join("?" * len(ids))
|
|
|
|
|
with self.db:
|
|
|
|
|
cur = self.db.execute(
|
|
|
|
|
f"DELETE FROM messages WHERE id IN ({imarks}) "
|
|
|
|
|
f"AND recipient IN ({kmarks})",
|
|
|
|
|
ids + keys,
|
|
|
|
|
)
|
|
|
|
|
return cur.rowcount
|
|
|
|
|
|
|
|
|
|
def purge(self, main_cutoff: int, requests_cutoff: int, seen_cutoff: int) -> int:
|
|
|
|
|
with self.db:
|
|
|
|
|
cur = self.db.execute(
|
|
|
|
|
"DELETE FROM messages WHERE (tier = ? AND received_at < ?) "
|
|
|
|
|
"OR (tier = ? AND received_at < ?)",
|
|
|
|
|
(TIER_MAIN, main_cutoff, TIER_REQUESTS, requests_cutoff),
|
|
|
|
|
)
|
|
|
|
|
gone = cur.rowcount
|
|
|
|
|
self.db.execute("DELETE FROM seen WHERE at < ?", (seen_cutoff,))
|
|
|
|
|
return gone
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Rate limiting (S13.8: no per-IP handle exists, so limits key on the link and
|
|
|
|
|
# on the accept token instead)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RateLimiter:
|
|
|
|
|
"""Fixed-window counter, keyed by link id, or by accept token for SEND."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, limit: int, window: float = 60.0) -> None:
|
|
|
|
|
self.limit = limit
|
|
|
|
|
self.window = window
|
|
|
|
|
self.lock = threading.Lock()
|
|
|
|
|
self.hits: dict[str, tuple[float, int]] = {}
|
|
|
|
|
|
|
|
|
|
def allow(self, key: str, cost: int = 1) -> bool:
|
|
|
|
|
if self.limit <= 0:
|
|
|
|
|
return True
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
with self.lock:
|
|
|
|
|
start, count = self.hits.get(key, (now, 0))
|
|
|
|
|
if now - start >= self.window:
|
|
|
|
|
start, count = now, 0
|
|
|
|
|
if count + cost > self.limit:
|
|
|
|
|
return False
|
|
|
|
|
self.hits[key] = (start, count + cost)
|
|
|
|
|
if len(self.hits) > 4096:
|
|
|
|
|
self._evict(now)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
def _evict(self, now: float) -> None:
|
|
|
|
|
stale = [key for key, (start, _) in self.hits.items() if now - start >= self.window]
|
|
|
|
|
for key in stale:
|
|
|
|
|
del self.hits[key]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Server (S13)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MailServer:
|
|
|
|
|
"""Owns the RNS destination and the per-link session state it requires.
|
|
|
|
|
|
|
|
|
|
Session state is a plain dict keyed by link id (S13.6): a link is the
|
|
|
|
|
session, and there is no other handle to key it on -- S13.8 is explicit
|
|
|
|
|
that a server must not ask for one.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, identity: RNS.Identity, args: argparse.Namespace) -> None:
|
|
|
|
|
self.store = Store(args.db)
|
|
|
|
|
self.max_envelope = args.max_envelope
|
|
|
|
|
self.fetch_budget = args.fetch_budget
|
|
|
|
|
self.quota = args.quota
|
|
|
|
|
self.requests_quota = args.requests_quota
|
|
|
|
|
self.retention = args.retention_days * 86400
|
|
|
|
|
self.requests_retention = args.requests_retention_days * 86400
|
|
|
|
|
self.max_accepted = args.max_accepted
|
|
|
|
|
self.invite_token = args.invite_token.encode() if args.invite_token else None
|
|
|
|
|
self.max_links = args.max_links
|
|
|
|
|
|
|
|
|
|
self.sessions_lock = threading.Lock()
|
|
|
|
|
self.sessions: dict[bytes, str] = {} # link_id -> authenticated username
|
|
|
|
|
|
|
|
|
|
self.link_request_limiter = RateLimiter(args.rate_link_requests)
|
|
|
|
|
self.link_byte_limiter = RateLimiter(args.rate_link_bytes)
|
|
|
|
|
self.send_limiter = RateLimiter(args.rate_sends) # global ceiling, not per-peer
|
|
|
|
|
self.token_limiter = RateLimiter(args.rate_token) # per accept token; the main defence
|
|
|
|
|
|
|
|
|
|
self.destination = RNS.Destination(
|
|
|
|
|
identity, RNS.Destination.IN, RNS.Destination.SINGLE, APP_NAME, ASPECT
|
|
|
|
|
)
|
|
|
|
|
self.destination.set_max_request_size(max_request_bytes(self.max_envelope, self.max_accepted))
|
|
|
|
|
self.destination.set_link_established_callback(self.on_link_established)
|
|
|
|
|
self.destination.register_request_handler(
|
|
|
|
|
PATH, response_generator=self.handle_request, allow=RNS.Destination.ALLOW_ALL
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
threading.Thread(target=self._purge_loop, daemon=True).start()
|
|
|
|
|
if args.announce_interval > 0:
|
|
|
|
|
threading.Thread(
|
|
|
|
|
target=self._announce_loop, args=(args.announce_interval,), daemon=True
|
|
|
|
|
).start()
|
|
|
|
|
|
|
|
|
|
# -- link lifecycle -------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def on_link_established(self, link: RNS.Link) -> None:
|
|
|
|
|
link.set_link_closed_callback(self.on_link_closed)
|
|
|
|
|
log.debug("link established: %s", RNS.prettyhexrep(link.link_id))
|
|
|
|
|
if len(self.destination.links) >= self.max_links:
|
|
|
|
|
log.info("at the %d concurrent link cap; refusing further links (S13.8)", self.max_links)
|
|
|
|
|
self.destination.accept_link_requests = False
|
|
|
|
|
|
|
|
|
|
def on_link_closed(self, link: RNS.Link) -> None:
|
|
|
|
|
with self.sessions_lock:
|
|
|
|
|
self.sessions.pop(link.link_id, None)
|
|
|
|
|
if len(self.destination.links) < self.max_links:
|
|
|
|
|
self.destination.accept_link_requests = True
|
|
|
|
|
|
|
|
|
|
# -- request handling (S13.5) ----------------------------------------
|
|
|
|
|
|
|
|
|
|
def handle_request(self, path, data, request_id, link_id, remote_identity, requested_at):
|
|
|
|
|
# S13.8: link_id is an ephemeral handle, nothing more; remote_identity
|
|
|
|
|
# would be a durable Reticulum identity, and a server must not use one
|
|
|
|
|
# even when a client volunteers it, so it is never read below.
|
|
|
|
|
del path, request_id, remote_identity, requested_at
|
|
|
|
|
if not self.link_request_limiter.allow(link_id.hex()):
|
|
|
|
|
return bytes([RATE_LIMITED])
|
|
|
|
|
if not isinstance(data, (bytes, bytearray)) or len(data) < 1:
|
|
|
|
|
return bytes([MALFORMED])
|
|
|
|
|
if not self.link_byte_limiter.allow(link_id.hex(), cost=len(data)):
|
|
|
|
|
return bytes([RATE_LIMITED])
|
|
|
|
|
op, body = data[0], bytes(data[1:])
|
|
|
|
|
try:
|
|
|
|
|
status, payload = self.dispatch(link_id, op, Reader(body))
|
|
|
|
|
except (ProtocolError, UnicodeDecodeError) as exc:
|
|
|
|
|
log.info("bad request on %s: %s", RNS.prettyhexrep(link_id), exc)
|
|
|
|
|
return bytes([MALFORMED])
|
|
|
|
|
except Exception:
|
|
|
|
|
log.exception("handler error on %s", RNS.prettyhexrep(link_id))
|
|
|
|
|
return bytes([INTERNAL_ERROR])
|
|
|
|
|
# A server MUST answer every registered request, including with an
|
|
|
|
|
# error status (S13.5); this function therefore never returns None.
|
|
|
|
|
return bytes([status]) + payload
|
|
|
|
|
|
|
|
|
|
def dispatch(self, link_id: bytes, op: int, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
handlers = {
|
|
|
|
|
OP_AUTH: self.op_auth,
|
|
|
|
|
OP_RESOLVE: self.op_resolve,
|
|
|
|
|
OP_SEND: self.op_send,
|
|
|
|
|
OP_FETCH: self.op_fetch,
|
|
|
|
|
OP_DELETE: self.op_delete,
|
|
|
|
|
OP_REGISTER: self.op_register,
|
|
|
|
|
}
|
|
|
|
|
handler = handlers.get(op)
|
|
|
|
|
if handler is None:
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
with self.sessions_lock:
|
|
|
|
|
username = self.sessions.get(link_id)
|
|
|
|
|
if op in (OP_FETCH, OP_DELETE) and username is None:
|
|
|
|
|
return AUTH_REQUIRED, b""
|
|
|
|
|
return handler(link_id, username, r)
|
|
|
|
|
|
|
|
|
|
# -- operations (SPEC.md S6, with S13.6's substituted signatures) ----
|
|
|
|
|
|
|
|
|
|
def op_auth(self, link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
username = r.take(r.u8()).decode("utf-8", "strict")
|
|
|
|
|
identity = r.take(KEY_LEN)
|
|
|
|
|
signature = r.take(SIG_LEN)
|
|
|
|
|
sync = r.u8()
|
|
|
|
|
tokens = [r.take(TOKEN_LEN) for _ in range(r.u16())]
|
|
|
|
|
r.done()
|
|
|
|
|
bound = self.store.identity_of(username)
|
|
|
|
|
# A wrong username and a wrong signature are both AUTH_FAILED: telling
|
|
|
|
|
# them apart would turn this into an account-existence oracle.
|
|
|
|
|
if bound is None or bound != identity:
|
|
|
|
|
return AUTH_FAILED, b""
|
|
|
|
|
h = bind_h(self.destination.hash, link_id)
|
|
|
|
|
if not verify(identity, signature, LABEL_AUTH + h):
|
|
|
|
|
return AUTH_FAILED, b""
|
|
|
|
|
if sync not in (0, 1) or (sync == 0 and tokens):
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
# Refused before authenticating, so the client has to trim and retry
|
|
|
|
|
# rather than silently running with a truncated set (S4).
|
|
|
|
|
if len(tokens) > self.max_accepted:
|
|
|
|
|
return TOO_LARGE, b""
|
|
|
|
|
with self.sessions_lock:
|
|
|
|
|
self.sessions[link_id] = username
|
|
|
|
|
held = self.store.set_accepted(username, tokens) if sync else self.store.count_accepted(username)
|
|
|
|
|
return OK, struct.pack(">H", held)
|
|
|
|
|
|
|
|
|
|
def op_resolve(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
username = r.take(r.u8()).decode("utf-8", "strict")
|
|
|
|
|
r.done()
|
|
|
|
|
identity = self.store.identity_of(username)
|
|
|
|
|
if identity is None:
|
|
|
|
|
return UNKNOWN_USER, b""
|
|
|
|
|
chain = self.store.chain(username)
|
|
|
|
|
return OK, identity + bytes([len(chain)]) + b"".join(chain)
|
|
|
|
|
|
|
|
|
|
def match_token(self, username: str, mid: bytes, mac: bytes) -> bytes | None:
|
|
|
|
|
"""S5.8. Trial-match the MAC against the mailbox's tokens.
|
|
|
|
|
|
|
|
|
|
Bounded by --max-accepted, so an unmatchable MAC costs a known amount
|
|
|
|
|
of HMAC rather than an unbounded scan.
|
|
|
|
|
"""
|
|
|
|
|
if len(mac) != TOKEN_LEN:
|
|
|
|
|
return None
|
|
|
|
|
for token in self.store.accepted_tokens(username):
|
|
|
|
|
want = hmac.new(token, LABEL_MAC + mid, hashlib.sha256).digest()
|
|
|
|
|
if hmac.compare_digest(want, mac):
|
|
|
|
|
return token
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
def op_send(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
mac = r.take(r.u8())
|
|
|
|
|
envelope = r.rest()
|
|
|
|
|
if not self.send_limiter.allow("global"): # S13.8: global ceiling, not per-peer
|
|
|
|
|
return RATE_LIMITED, b""
|
|
|
|
|
if len(envelope) > self.max_envelope:
|
|
|
|
|
return TOO_LARGE, b""
|
|
|
|
|
if len(envelope) < ENVELOPE_MIN or envelope[:4] != ENVELOPE_MAGIC:
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
if envelope[4] != ENVELOPE_VERSION:
|
|
|
|
|
return BAD_VERSION, b""
|
|
|
|
|
recipient = envelope[5:37]
|
|
|
|
|
username = self.store.username_for_key(recipient)
|
|
|
|
|
if username is None:
|
|
|
|
|
return UNKNOWN_USER, b""
|
|
|
|
|
mid = message_id(envelope)
|
|
|
|
|
# A resend is answered with the same id and stores nothing, whether
|
|
|
|
|
# the original is still here or was deleted (S10).
|
|
|
|
|
if self.store.tombstoned(mid):
|
|
|
|
|
return OK, mid
|
|
|
|
|
# An unmatched MAC is not an error: SEND must not reveal whether a
|
|
|
|
|
# token is still accepted (S5.8).
|
|
|
|
|
token = self.match_token(username, mid, mac)
|
|
|
|
|
if token is None:
|
|
|
|
|
tier, quota = TIER_REQUESTS, self.requests_quota
|
|
|
|
|
else:
|
|
|
|
|
tier, quota = TIER_MAIN, self.quota
|
|
|
|
|
if not self.token_limiter.allow(token.hex()):
|
|
|
|
|
return RATE_LIMITED, b""
|
|
|
|
|
keys = self.store.keys_of(username)
|
|
|
|
|
if self.store.mailbox_bytes(keys, tier) + len(envelope) > quota:
|
|
|
|
|
return QUOTA_EXCEEDED, b""
|
|
|
|
|
# The ciphertext is never inspected; the server cannot read it.
|
|
|
|
|
self.store.store_message(mid, recipient, tier, envelope)
|
|
|
|
|
return OK, mid
|
|
|
|
|
|
|
|
|
|
def op_fetch(self, _link_id: bytes, username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
after_time = r.i64()
|
|
|
|
|
after_id = r.take(ID_LEN)
|
|
|
|
|
r.done()
|
|
|
|
|
assert username is not None
|
|
|
|
|
keys = self.store.keys_of(username)
|
|
|
|
|
records = self.store.pending(keys, after_time, after_id, self.fetch_budget)
|
|
|
|
|
out = [struct.pack(">H", len(records))]
|
|
|
|
|
for mid, received_at, tier, envelope in records:
|
|
|
|
|
flags = FLAG_REQUESTS if tier == TIER_REQUESTS else 0
|
|
|
|
|
out.append(mid + struct.pack(">qBI", received_at, flags, len(envelope)) + envelope)
|
|
|
|
|
return OK, b"".join(out)
|
|
|
|
|
|
|
|
|
|
def op_delete(self, _link_id: bytes, username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
count = r.u16()
|
|
|
|
|
ids = [r.take(ID_LEN) for _ in range(count)]
|
|
|
|
|
r.done()
|
|
|
|
|
assert username is not None
|
|
|
|
|
if not ids:
|
|
|
|
|
return OK, struct.pack(">H", 0)
|
|
|
|
|
# Scoped to the caller's own keys, so ids cannot be used to probe or
|
|
|
|
|
# delete another mailbox.
|
|
|
|
|
keys = self.store.keys_of(username)
|
|
|
|
|
removed = self.store.delete(keys, ids)
|
|
|
|
|
return OK, struct.pack(">H", removed)
|
|
|
|
|
|
|
|
|
|
def op_register(self, _link_id: bytes, _username: str | None, r: Reader) -> tuple[int, bytes]:
|
|
|
|
|
username = r.take(r.u8()).decode("utf-8", "strict")
|
|
|
|
|
identity = r.take(KEY_LEN)
|
|
|
|
|
signature = r.take(SIG_LEN)
|
|
|
|
|
token = r.take(r.u8())
|
|
|
|
|
cert = r.take(r.u8())
|
|
|
|
|
r.done()
|
|
|
|
|
|
|
|
|
|
if not valid_username(username):
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
try:
|
|
|
|
|
Ed25519PublicKey.from_public_bytes(identity)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
# S13.6 proof of possession, bound to this server's destination so the
|
|
|
|
|
# attestation cannot be replayed elsewhere.
|
|
|
|
|
server_static = bind_server_static(self.destination.hash)
|
|
|
|
|
if not verify(identity, signature, LABEL_REGISTER + server_static + username.encode() + identity):
|
|
|
|
|
return AUTH_FAILED, b""
|
|
|
|
|
expected = self.invite_token
|
|
|
|
|
if expected is not None and token != expected:
|
|
|
|
|
return NOT_PERMITTED, b""
|
|
|
|
|
|
|
|
|
|
if not cert:
|
|
|
|
|
if not self.store.register(username, identity):
|
|
|
|
|
return NOT_PERMITTED, b""
|
|
|
|
|
return OK, b""
|
|
|
|
|
|
|
|
|
|
if len(cert) != CERT_LEN:
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
old_pub, new_pub, when = cert[:32], cert[32:64], cert[64:72]
|
|
|
|
|
sig_old, sig_new = cert[72:136], cert[136:200]
|
|
|
|
|
if new_pub != identity:
|
|
|
|
|
return MALFORMED, b""
|
|
|
|
|
bound = self.store.identity_of(username)
|
|
|
|
|
if bound is None:
|
|
|
|
|
return UNKNOWN_USER, b""
|
|
|
|
|
if bound != old_pub:
|
|
|
|
|
# Only the currently bound key may hand the username on (S7).
|
|
|
|
|
return NOT_PERMITTED, b""
|
|
|
|
|
# Both keys sign: the old one alone could otherwise rotate to a key
|
|
|
|
|
# nobody controls (S7).
|
|
|
|
|
signed = LABEL_ROTATE + username.encode() + old_pub + new_pub + when
|
|
|
|
|
if not verify(old_pub, sig_old, signed) or not verify(new_pub, sig_new, signed):
|
|
|
|
|
return AUTH_FAILED, b""
|
|
|
|
|
chain = self.store.chain(username)
|
|
|
|
|
if len(chain) >= MAX_CHAIN:
|
|
|
|
|
return NOT_PERMITTED, b""
|
|
|
|
|
if not self.store.rotate(username, new_pub, cert, len(chain)):
|
|
|
|
|
return NOT_PERMITTED, b""
|
|
|
|
|
return OK, b""
|
|
|
|
|
|
|
|
|
|
# -- background loops --------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _purge_loop(self) -> None:
|
|
|
|
|
store = Store(self.store.path)
|
|
|
|
|
while True:
|
|
|
|
|
time.sleep(PURGE_INTERVAL)
|
|
|
|
|
try:
|
|
|
|
|
now = int(time.time())
|
|
|
|
|
# Tombstones outlive both tiers, so a replay cannot slip in
|
|
|
|
|
# between a message expiring and its id being forgotten.
|
|
|
|
|
gone = store.purge(
|
|
|
|
|
now - self.retention,
|
|
|
|
|
now - self.requests_retention,
|
|
|
|
|
now - max(self.retention, self.requests_retention),
|
|
|
|
|
)
|
|
|
|
|
if gone:
|
|
|
|
|
log.info("expired %d message(s)", gone)
|
|
|
|
|
except Exception:
|
|
|
|
|
log.exception("purge failed")
|
|
|
|
|
|
|
|
|
|
def _announce_loop(self, interval: float) -> None:
|
|
|
|
|
# S13.1: interfaces rate-limit a destination to roughly one announce
|
|
|
|
|
# an hour; the default keeps well inside that.
|
|
|
|
|
while True:
|
|
|
|
|
time.sleep(interval)
|
|
|
|
|
try:
|
|
|
|
|
self.destination.announce()
|
|
|
|
|
log.info("announced %s", self.destination.hexhash)
|
|
|
|
|
except Exception:
|
|
|
|
|
log.exception("announce failed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# CLI
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_keygen(args: argparse.Namespace) -> int:
|
|
|
|
|
if os.path.exists(args.key) and not args.force:
|
|
|
|
|
print(f"{args.key} exists; refusing to overwrite (use --force)", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
identity = RNS.Identity()
|
|
|
|
|
# RNS writes this private key unencrypted (S13.1); mode 0600 before any
|
|
|
|
|
# bytes land, so it is never briefly world-readable.
|
|
|
|
|
fd = os.open(args.key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
|
|
|
os.close(fd)
|
|
|
|
|
identity.to_file(args.key)
|
|
|
|
|
dest_hash = RNS.Destination.hash(identity, APP_NAME, ASPECT)
|
|
|
|
|
print(f"identity: {args.key} (unencrypted; back it up, keep it mode 0600, never transmit it)")
|
|
|
|
|
print(f"destination: {dest_hash.hex()}")
|
|
|
|
|
print(f"address: smol+rns://<username>@{dest_hash.hex()}")
|
|
|
|
|
print()
|
|
|
|
|
print("The destination hash is the whole trust model (reticulum.md S13.4): publish it,")
|
|
|
|
|
print("and there is nothing else to pin. It is stable for as long as this file survives.")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def cmd_serve(args: argparse.Namespace) -> int:
|
|
|
|
|
identity = RNS.Identity.from_file(args.key)
|
|
|
|
|
if identity is None:
|
|
|
|
|
print(f"no identity at {args.key}; run: smolmaild_rns.py keygen --key {args.key}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
RNS.Reticulum(configdir=args.config, loglevel=RNS.LOG_DEBUG if args.verbose else RNS.LOG_NOTICE)
|
|
|
|
|
server = MailServer(identity, args)
|
|
|
|
|
log.info("destination: %s", server.destination.hexhash)
|
|
|
|
|
log.info("address: smol+rns://<username>@%s", server.destination.hexhash)
|
|
|
|
|
if not args.no_announce:
|
|
|
|
|
server.destination.announce()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
while True:
|
|
|
|
|
time.sleep(3600) # RNS runs its own threads; this just keeps the process up
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
log.info("shutting down")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
|
|
|
parser.add_argument("-v", "--verbose", action="store_true")
|
|
|
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
|
|
|
|
|
|
keygen = sub.add_parser("keygen", help="generate the server's Reticulum identity")
|
|
|
|
|
keygen.add_argument("--key", default="server.rns.key")
|
|
|
|
|
keygen.add_argument("--force", action="store_true")
|
|
|
|
|
keygen.set_defaults(func=cmd_keygen)
|
|
|
|
|
|
|
|
|
|
serve = sub.add_parser("serve", help="run the mailbox server")
|
|
|
|
|
serve.add_argument("--key", default="server.rns.key")
|
|
|
|
|
serve.add_argument("--db", default="mail.db")
|
|
|
|
|
serve.add_argument("--config", default=None, metavar="DIR",
|
|
|
|
|
help="Reticulum config directory (default: the usual RNS location)")
|
|
|
|
|
serve.add_argument("--max-envelope", type=int, default=32 << 10, metavar="BYTES",
|
|
|
|
|
help="S13.7 recommends a much smaller cap than TCP's 768 KiB")
|
|
|
|
|
serve.add_argument("--fetch-budget", type=int, default=32 << 10, metavar="BYTES",
|
|
|
|
|
help="S13.7 recommends a much smaller cap than TCP's 512 KiB")
|
|
|
|
|
serve.add_argument("--quota", type=int, default=64 << 20, metavar="BYTES")
|
|
|
|
|
serve.add_argument("--requests-quota", type=int, default=2 << 20, metavar="BYTES",
|
|
|
|
|
help="quota for mail arriving without an accept token")
|
|
|
|
|
serve.add_argument("--retention-days", type=int, default=30)
|
|
|
|
|
serve.add_argument("--requests-retention-days", type=int, default=7)
|
|
|
|
|
serve.add_argument("--max-accepted", type=int, default=1024, metavar="TOKENS",
|
|
|
|
|
help="accept tokens a mailbox may hold (SPEC.md S5.8)")
|
|
|
|
|
serve.add_argument("--invite-token", default=None,
|
|
|
|
|
help="require this token to register; open registration if unset")
|
|
|
|
|
serve.add_argument("--max-links", type=int, default=256,
|
|
|
|
|
help="cap on concurrent Reticulum links (S13.8)")
|
|
|
|
|
serve.add_argument("--rate-link-requests", type=int, default=120, metavar="PER_MIN",
|
|
|
|
|
help="requests per minute, per link (S13.8)")
|
|
|
|
|
serve.add_argument("--rate-link-bytes", type=int, default=1 << 20, metavar="BYTES_PER_MIN",
|
|
|
|
|
help="request bytes per minute, per link (S13.8)")
|
|
|
|
|
serve.add_argument("--rate-sends", type=int, default=600, metavar="PER_MIN",
|
|
|
|
|
help="global SEND ceiling; S13.8 has no per-peer handle to key one on")
|
|
|
|
|
serve.add_argument("--rate-token", type=int, default=120, metavar="PER_MIN",
|
|
|
|
|
help="sends per minute per accept token, the main defence (S13.8)")
|
|
|
|
|
serve.add_argument("--announce-interval", type=int, default=3 * 3600, metavar="SECONDS",
|
|
|
|
|
help="0 disables periodic announcing (S13.1)")
|
|
|
|
|
serve.add_argument("--no-announce", action="store_true",
|
|
|
|
|
help="do not announce at startup either; rely on path requests alone")
|
|
|
|
|
serve.set_defaults(func=cmd_serve)
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
|
|
|
format="%(asctime)s %(levelname)s %(message)s",
|
|
|
|
|
)
|
|
|
|
|
return args.func(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|