From 03687dcb7e8b64f0e184af664699840cbab092aa Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 15:49:36 +0200 Subject: [PATCH 01/23] feat(federation): implement S2S message delivery and authentication - Add federation module with client and outbox components. - Implement outbox worker for durable cross-node message delivery with exponential backoff. - Create node authentication middleware to validate S2S requests from peer nodes. - Define models for federation nodes and outbox entries. - Implement repository functions for managing federation nodes and outbox entries. - Add S2S routes for node-to-node communication, including info, user devices, keys, sessions, and message handling. - Update main application to initialize federation components and start outbox worker. --- Cargo.lock | 2 + Cargo.toml | 2 + docs/API.md | 532 ++++++++++++++++++++++ sql_models/federation.sql | 138 ++++++ sql_models/seed.sql | 139 ++++++ src/app_state.rs | 17 + src/controllers/federation_controller.rs | 557 +++++++++++++++++++++++ src/controllers/messages_controller.rs | 223 ++++++++- src/controllers/mod.rs | 1 + src/controllers/session_controller.rs | 95 +++- src/federation/client.rs | 199 ++++++++ src/federation/mod.rs | 39 ++ src/federation/outbox.rs | 150 ++++++ src/main.rs | 36 +- src/middlewares/mod.rs | 1 + src/middlewares/node_auth.rs | 186 ++++++++ src/models/federation.rs | 124 +++++ src/models/message.rs | 10 + src/models/mod.rs | 1 + src/repository/federation_repository.rs | 324 +++++++++++++ src/repository/message_repository.rs | 45 ++ src/repository/mod.rs | 1 + src/repository/user_repository.rs | 15 + src/routes/federation.rs | 39 ++ src/routes/mod.rs | 1 + 25 files changed, 2851 insertions(+), 26 deletions(-) create mode 100644 sql_models/federation.sql create mode 100644 src/controllers/federation_controller.rs create mode 100644 src/federation/client.rs create mode 100644 src/federation/mod.rs create mode 100644 src/federation/outbox.rs create mode 100644 src/middlewares/node_auth.rs create mode 100644 src/models/federation.rs create mode 100644 src/repository/federation_repository.rs create mode 100644 src/routes/federation.rs diff --git a/Cargo.lock b/Cargo.lock index c47ce1c..1ec4885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -851,11 +851,13 @@ dependencies = [ "chrono", "dotenvy", "ed25519-dalek", + "hex", "jsonwebtoken", "reqwest", "rsa", "serde", "serde_json", + "sha2", "sqlx", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index a587991..9830f2d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,4 +24,6 @@ jsonwebtoken = {version = "10.0.0", features = ["rust_crypto"]} base64 = "0.22.1" rsa = "0.9.8" reqwest = { version = "0.12.24", features = ["json", "rustls-tls"] } +sha2 = "0.10" +hex = "0.4" # TODO : Monitor the RSA and ed25519-dalek crates for updates and security patches. \ No newline at end of file diff --git a/docs/API.md b/docs/API.md index 3fc731b..4bf89b7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,6 +14,7 @@ Complete API documentation for HushNet Backend. - [Chat Endpoints](#chat-endpoints) - [Message Endpoints](#message-endpoints) - [WebSocket Endpoints](#websocket-endpoints) +- [Federation — Inter-Node Messaging](#federation--inter-node-messaging) - [Error Responses](#error-responses) --- @@ -609,6 +610,537 @@ const ws = new WebSocket('ws://127.0.0.1:8080/ws?user_id='); --- +--- + +## Federation — Inter-Node Messaging + +Federation allows users registered on different HushNet nodes to exchange messages. The cryptographic layer (X3DH, Double Ratchet) is unchanged — clients remain responsible for all key agreement and encryption. Servers route opaque ciphertexts; no node can read message content. + +--- + +### Architecture Overview + +``` +┌───────────────────────────────────────────────────────────────────────┐ +│ HushNet Network │ +│ │ +│ ┌──────────────────┐ S2S (Ed25519) ┌───────────────┐ │ +│ │ Node A │◄────────────────────────────► Node B │ │ +│ │ │ │ │ │ +│ │ Alice (local) │ │ Bob (local) │ │ +│ │ Bob (shadow)◄──┼──────────────────────────────┼─────────────┘ │ +│ └──────────────────┘ └───────────────┘ │ +│ ▲ ▲ │ +│ │ client API client API │ │ +│ │ │ │ +│ Client A Client B │ +└───────────────────────────────────────────────────────────────────────┘ +``` + +**Invariants:** +- Clients always talk to their home node only. +- Nodes forward already-encrypted payloads. No node sees plaintext. +- Private keys never leave the client device. +- Double Ratchet session state never touches a server. + +--- + +### Federated User Addressing + +A federated address uniquely identifies a user across all nodes: + +``` +alice@node-a.hushnet.net +bob@node-b.hushnet.net +``` + +Format: `{username}@{node_host}` + +The node host is the `NODE_HOST` environment variable set at startup. It is registered at the central registry (`registry.hushnet.net`) alongside the node's Ed25519 public key and API base URL. + +**Routing rule (applied server-side on every relevant request):** +- If `node_host == this node` → local delivery (existing path, unchanged). +- If `node_host != this node` → federated path (S2S forwarding). + +--- + +### Node-to-Node Authentication (S2S) + +Every outbound S2S request from Node A carries four headers. Node B verifies them in sequence before executing the handler. + +| Header | Value | +|--------|-------| +| `X-Node-ID` | Sender's canonical node identifier (`node-a.hushnet.net`) | +| `X-Timestamp` | Unix seconds as a decimal string | +| `X-Nonce` | 16 random bytes, base64-encoded | +| `X-Node-Signature` | Ed25519 signature, base64-encoded | + +**Canonical string signed (fields joined by `\n`, UTF-8):** + +``` +{HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +``` + +Only the path portion of the URL is signed (no scheme, no host), so the canonical string is stable regardless of which domain name the caller used. + +**Verification sequence on Node B:** + +```mermaid +flowchart TD + A[Inbound S2S request] --> B{|now − timestamp| ≤ 60s?} + B -- no --> R1[401 timestamp out of window] + B -- yes --> C{node_id in federation_nodes?} + C -- no --> D[GET registry/nodes/node_id] + D --> E{registry responds 200?} + E -- no --> R2[401 peer not found in registry] + E -- yes --> F[upsert federation_nodes] + F --> G + C -- yes --> G{is_blocked = false?} + G -- no --> R3[403 node is blocked] + G -- yes --> H[verify Ed25519 signature] + H --> I{valid?} + I -- no --> R4[401 invalid node signature] + I -- yes --> J[INSERT used_node_nonces ON CONFLICT DO NOTHING] + J --> K{rows_affected = 1?} + K -- no --> R5[401 replayed nonce] + K -- yes --> L[execute handler] +``` + +**Public key discovery:** On first contact from an unknown peer, Node B fetches `GET {registry_url}/api/registry/nodes/{node_id}` and caches the result in `federation_nodes`. Subsequent requests use the cache (no registry call). + +**Anti-replay:** The `(node_id, nonce)` pair is stored in `used_node_nonces` immediately after signature verification. Nonces are unique per request; the 60-second timestamp window bounds how long they need to be retained. The outbox worker purges entries older than 5 minutes. + +--- + +### Flow 1 — Federated Prekey Lookup + +Before initiating X3DH with a remote user, Client A must obtain Bob's prekey bundle from Node B. Node A acts as a transparent proxy; it does not cache the bundle because OTPKs are one-time-use. + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + + CA->>NA: GET /users/federated/bob@node-b.hushnet.net/keys + Note over NA: AuthenticatedDevice (Alice's headers) + NA->>NA: resolve node-b in federation_nodes
(registry fallback if unknown) + NA->>NB: GET /s2s/users/bob/keys
X-Node-ID / X-Timestamp / X-Nonce / X-Node-Signature + Note over NB: AuthenticatedNode verifies signature + NB->>NB: look up local user "bob"
fetch device bundles
consume one OTPK per device + NB-->>NA: 200 DeviceBundle[] + NA-->>CA: 200 DeviceBundle[] + + Note over CA: X3DH key agreement performed locally
shared secret never leaves client +``` + +--- + +### Flow 2 — Cross-Node X3DH Session Initiation + +Client A performs X3DH locally using Bob's prekeys, then sends the encrypted init envelope to Node A. Node A forwards it to Node B synchronously (no outbox — session inits are small and need to be prompt). + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + participant CB as Client B + + CA->>NA: POST /sessions
{ recipient_user_address: "bob@node-b.hushnet.net",
sessions_init: [...] } + Note over NA: AuthenticatedDevice + NA->>NA: parse address → username=bob, node=node-b + NA->>NA: resolve Node B (DB cache / registry) + NA->>NB: POST /s2s/sessions
{ from_federated_address: "alice@node-a",
from_device_id, from_identity_pubkey,
to_user: "bob", sessions_init: [...] } + Note over NB: AuthenticatedNode + NB->>NB: upsert shadow user alice@node-a
upsert shadow device (alice_device_id) + NB->>NB: INSERT pending_sessions
(pg trigger fires) + NB-->>NA: 200 { status: "ok" } + NA-->>CA: 202 Accepted { status: "forwarded" } + NB-->>CB: WebSocket event
{ type: "pending_session", ... } + Note over CB: fetches GET /sessions/pending
confirms session locally (unchanged flow) +``` + +--- + +### Flow 3 — Cross-Node Message Delivery + +Client A sends an already-encrypted message to Node A. Node A writes to the outbox for durability, then immediately attempts delivery to Node B in a background task. The client receives 202 Accepted before the S2S call completes. + +```mermaid +sequenceDiagram + participant CA as Client A + participant NA as Node A + participant NB as Node B + participant CB as Client B + + CA->>NA: POST /messages
{ to_user_address: "bob@node-b.hushnet.net",
logical_msg_id, payloads: [...] } + Note over NA: AuthenticatedDevice + NA->>NA: resolve Node B + NA->>NA: serialize S2sMessagePayload + NA->>NA: INSERT federation_outbox (durable) + NA-->>CA: 202 Accepted { status: "queued" } + + NA-)NB: POST /s2s/messages (background task)
{ logical_msg_id, from_federated_address,
from_device_id, from_identity_pubkey,
to_user: "bob", payloads: [...] } + Note over NB: AuthenticatedNode + NB->>NB: resolve local user "bob" (404 if not found) + NB->>NB: upsert shadow user + device for Alice + NB->>NB: get_or_create_direct_chat(shadow_alice, bob) + NB->>NB: INSERT messages per device payload
ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING + NB->>NB: pg trigger fires → pg_notify + NB-->>NA: 200 S2sAck { status: "delivered" | "duplicate" } + NA->>NA: UPDATE federation_outbox SET status='delivered' + NB-->>CB: WebSocket event { type: "message", ... } + Note over CB: fetches GET /messages/pending (unchanged) +``` + +**Outbox retry schedule:** + +| Attempt | Delay before next retry | +|---------|------------------------| +| 1 | 10 s | +| 2 | 20 s | +| 3 | 40 s | +| 4 | 80 s | +| 5 | 160 s | +| 6 | 320 s | +| 7 | 640 s | +| 8 | 1 280 s | +| 9 | 2 560 s | +| 10 | — (marked `failed`) | + +--- + +### Shadow Records + +When Node B receives a message from `alice@node-a`, it needs valid rows in `users` and `devices` to satisfy the foreign-key constraints on the `messages` table. Two shadow records are upserted automatically: + +**Shadow user** (`users.home_node_id IS NOT NULL`): +- `username` = `"alice"` (local part of the federated address) +- `federated_address` = `"alice@node-a.hushnet.net"` (unique key for upsert) +- `home_node_id` → FK to `federation_nodes` + +**Shadow device** (`devices` with empty prekey fields): +- `id` = device UUID from Node A (reused; UUID collision probability negligible) +- `identity_pubkey` = Alice's Ed25519 IK (included in every S2S payload) +- `prekey_pubkey`, `signed_prekey_pub`, `signed_prekey_sig` = `""` (never queried) +- `one_time_prekeys` = `[]` (never queried) + +Shadow records are never returned by client-facing endpoints (`GET /users`, `GET /users/:id/keys`, etc.) because those queries filter `WHERE home_node_id IS NULL`. + +--- + +### New Client-Facing Endpoint + +#### GET `/users/federated/{address}/keys` + +Fetch the prekey bundle for a remote user. Node A proxies the request to the user's home node. + +**Authentication:** Required (standard `AuthenticatedDevice` headers) + +**Path parameter:** + +| Name | Type | Description | +|------|------|-------------| +| `address` | string | Full federated address: `bob@node-b.hushnet.net` | + +**Response:** `200 OK` — same structure as local `GET /users/:id/keys` + +```json +[ + { + "device_id": "d1e2f3a4-b5c6-7890-abcd-ef1234567890", + "identity_pubkey": "base64...", + "signed_prekey_pub": "base64...", + "signed_prekey_sig": "base64...", + "one_time_prekeys": ["base64_otpk_1"] + } +] +``` + +**Errors:** + +| Status | Condition | +|--------|-----------| +| `400` | Address missing `@` separator | +| `403` | Target node is blocked | +| `404` | Remote user not found on target node | +| `502` | Target node returned an error | +| `503` | Registry unreachable and node unknown | + +--- + +### Extended Client Endpoints + +Two existing endpoints accept an additional optional field for cross-node delivery. Clients that do not send the new field continue to work without modification. + +#### POST `/messages` — new optional field + +```json +{ + "chat_id": "uuid", + "logical_msg_id": "string", + "to_user_id": "uuid", + "to_user_address": "bob@node-b.hushnet.net", + "payloads": [ + { + "to_device_id": "uuid", + "header": { "...": "..." }, + "ciphertext": "base64..." + } + ] +} +``` + +When `to_user_address` is present and its node portion differs from this node's `NODE_HOST`, the message is forwarded via S2S. `to_user_id` is still required for schema compatibility but is ignored in the federated path. + +**Response when federated:** `202 Accepted` +```json +{ "status": "queued" } +``` + +#### POST `/sessions` — new optional field + +```json +{ + "recipient_user_id": "uuid", + "recipient_user_address": "bob@node-b.hushnet.net", + "sessions_init": [ { "...": "..." } ] +} +``` + +**Response when federated:** `202 Accepted` +```json +{ "status": "forwarded" } +``` + +--- + +### S2S Endpoints (Node-to-Node Only) + +These endpoints are consumed exclusively by peer nodes. Client applications must never call them directly. + +#### GET `/s2s/info` + +Return this node's public identity. No authentication required — this is the bootstrap endpoint that lets an unknown peer fetch the public key before the registry has been consulted. + +**Response:** `200 OK` + +```json +{ + "node_id": "node-a.hushnet.net", + "api_url": "https://node-a.hushnet.net/api", + "public_key_b64": "base64_ed25519_verifying_key", + "protocol_version": "0.0.1" +} +``` + +> **Security note:** The returned key should be cross-checked against the central registry before being trusted. A MITM that intercepts this call could substitute their own key if the channel is not TLS-protected. + +--- + +#### GET `/s2s/users/{username}/devices` + +Return the full device list for a local user. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Response:** `200 OK` — array of `Devices` records (same structure as `GET /users/:id/devices`) + +**Errors:** `404` if user does not exist or is a shadow record. + +--- + +#### GET `/s2s/users/{username}/keys` + +Return the prekey bundle for a local user, consuming one OTPK per device. Semantics are identical to the local `GET /users/:id/keys`. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Response:** `200 OK` — `DeviceBundle[]` + +**Errors:** `404` if user does not exist or is a shadow record. + +--- + +#### POST `/s2s/sessions` + +Accept a forwarded X3DH session initiation. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "from_federated_address": "alice@node-a.hushnet.net", + "from_device_id": "uuid", + "from_identity_pubkey": "base64...", + "to_user": "bob", + "sessions_init": [ + { + "recipient_device_id": "uuid", + "ephemeral_pubkey": "base64...", + "sender_prekey_pub": "base64...", + "otpk_used": "true", + "ciphertext": "base64..." + } + ] +} +``` + +**Response:** `200 OK` +```json +{ "status": "ok" } +``` + +--- + +#### POST `/s2s/messages` + +Accept forwarded encrypted message payloads for a local recipient. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "logical_msg_id": "string", + "from_federated_address": "alice@node-a.hushnet.net", + "from_device_id": "uuid", + "from_identity_pubkey": "base64...", + "to_user": "bob", + "payloads": [ + { + "to_device_id": "uuid", + "header": { "dh": "base64...", "pn": 0, "n": 1 }, + "ciphertext": "base64..." + } + ] +} +``` + +**Response:** `200 OK` + +```json +{ + "logical_msg_id": "string", + "status": "delivered" +} +``` + +`status` is `"duplicate"` if all payloads were already present in the database (idempotent retry from the sender's outbox). The sender must treat both `"delivered"` and `"duplicate"` as success and stop retrying. + +**Errors:** + +| Status | Condition | +|--------|-----------| +| `404` | Recipient username not found or is a shadow record | +| `500` | DB error during shadow upsert or message insert | + +--- + +#### POST `/s2s/ack` + +Delivery acknowledgment sent from Node B back to Node A. Advisory: Node A's outbox worker already marks entries delivered when it receives a `200` from `POST /s2s/messages`. This endpoint is for explicit acks sent by Node B after delayed processing. + +**Authentication:** S2S (`AuthenticatedNode`) + +**Request body:** + +```json +{ + "logical_msg_id": "string", + "status": "delivered" +} +``` + +**Response:** `200 OK` +```json +{ "status": "ack received" } +``` + +--- + +### Failure Handling Reference + +| Failure | Node A behavior | Node B behavior | +|---------|----------------|-----------------| +| Node B unreachable | 202 to client; outbox retries with backoff | — | +| Node B returns 404 for recipient | outbox entry marked `failed` immediately; no retry | 404 response | +| All OTPKs depleted on Node B | bundle returned with empty `one_time_prekeys`; client proceeds with SPK-only X3DH | `one_time_prekeys: []` in response | +| Duplicate message delivery (outbox retry) | outbox marked `delivered` on any 200 | `INSERT ... ON CONFLICT DO NOTHING`; returns `status: "duplicate"` | +| Delayed or missing ack | outbox resends after TTL; Node B's idempotent insert prevents double storage | — | +| Invalid S2S signature | — | 401; sender logs and does not retry same payload | +| Blocked peer node | 403 returned on any S2S request | — | +| Registry unreachable at auth time | — | 503; request rejected; sender may retry later | + +--- + +### Database Schema Additions + +The federation layer adds three tables and two columns to the existing schema. All changes are additive; no existing table is altered destructively. + +```sql +-- Peer node registry +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Anti-replay nonce store (TTL: 5 minutes) +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Outbound delivery queue with retry state +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, -- verbatim S2S request body + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Users: federation columns +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id), + -- NULL = local user; non-NULL = shadow record for a remote user + ADD COLUMN federated_address TEXT UNIQUE; + -- "alice@node-a.hushnet.net"; populated for all users after migration + +-- Messages: deduplication constraint +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); +``` + +--- + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NODE_HOST` | `node-unknown.hushnet.net` | This node's canonical identifier (used as `node_id` in S2S auth) | +| `NODE_API_URL` | `https://{NODE_HOST}/api` | Base API URL announced to peers | +| `REGISTRY_URL` | `https://registry.hushnet.net` | Central registry for peer node discovery | +| `REGISTER_TO_REGISTRY` | `false` | Set to `true` to register at startup | + +--- + ## Error Responses ### Standard Error Format diff --git a/sql_models/federation.sql b/sql_models/federation.sql new file mode 100644 index 0000000..7264893 --- /dev/null +++ b/sql_models/federation.sql @@ -0,0 +1,138 @@ +-- ============================================================================= +-- Migration: inter-node federation support +-- +-- Run this after sql_models/seed.sql. Every change here is purely additive: +-- no existing column is dropped or renamed, no existing constraint is altered. +-- +-- The three new tables (federation_nodes, used_node_nonces, federation_outbox) +-- and the two new columns on users (home_node_id, federated_address) are the +-- only schema deltas required to support cross-node message routing. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Peer node registry +-- +-- One row per known peer. Rows are created lazily: the first time this node +-- receives an S2S request from an unknown peer, it fetches that peer's record +-- from the central registry and inserts it here. +-- +-- public_key_b64 is the Ed25519 verifying key used to authenticate every +-- inbound S2S request from that peer. It must match what the peer registered +-- at the central registry (registry.hushnet.net). +-- +-- is_blocked allows an operator to stop accepting traffic from a specific peer +-- without removing the row (which would just re-create it on the next contact). +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, -- "https://node-a.hushnet.net/api" + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key, base64 + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Anti-replay nonce store +-- +-- Every accepted S2S request carries a random 16-byte nonce (base64-encoded). +-- The pair (node_id, nonce) is stored here immediately after signature +-- verification to prevent exact-replay attacks within the timestamp acceptance +-- window (currently 60 s). +-- +-- Rows older than 5 minutes can be safely deleted; the outbox worker runs a +-- periodic purge via: +-- DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes' +-- ----------------------------------------------------------------------------- +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Supports cheap TTL-based cleanup without a sequential scan. +CREATE INDEX idx_used_node_nonces_used_at ON used_node_nonces (used_at); + +-- ----------------------------------------------------------------------------- +-- Outbound delivery queue (federation outbox) +-- +-- Every logical message addressed to a remote node is written here before any +-- network call is made. The outbox worker reads pending entries, attempts +-- delivery to the target node, and transitions entries to 'delivered' or +-- 'failed'. +-- +-- payload is the verbatim JSON body of the POST /s2s/messages request that +-- will be sent to the target node. Storing it here means retries require no +-- additional DB reads to reconstruct the request. +-- +-- Backoff schedule implemented by the worker (seconds): +-- 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600 (cap) +-- After 10 failed attempts the entry is marked 'failed' and abandoned. +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, -- destination node_id + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partial index: only pending entries participate in the outbox work loop. +CREATE INDEX idx_federation_outbox_work + ON federation_outbox (target_node_id, next_attempt) + WHERE status = 'pending'; + +-- ----------------------------------------------------------------------------- +-- Users: federation columns +-- +-- home_node_id NULL → the user is local to this node; their devices are +-- authoritative here and they can log in normally. +-- +-- home_node_id SET → shadow record for a remote user whose real account lives +-- on another node. Created automatically the first time +-- this node receives a message from that user. Shadow +-- users cannot register devices or log in here; they +-- exist only to satisfy foreign-key constraints on the +-- messages and pending_sessions tables. +-- +-- federated_address is globally unique across all nodes: +-- "alice@node-a.hushnet.net" +-- It is the canonical identifier for cross-node addressing. Username alone is +-- not globally unique since each node has its own namespace. +-- ----------------------------------------------------------------------------- +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id) ON DELETE SET NULL, + ADD COLUMN federated_address TEXT UNIQUE; + +-- After running this migration, populate federated_address for every existing +-- local user by substituting the actual NODE_HOST value: +-- +-- UPDATE users +-- SET federated_address = username || '@' +-- WHERE home_node_id IS NULL AND federated_address IS NULL; +-- +-- This can be run as a separate step; the column is nullable so existing rows +-- are not broken before the backfill runs. + +-- ----------------------------------------------------------------------------- +-- Messages: deduplication constraint +-- +-- A given (logical_msg_id, to_device_id) pair must appear at most once in the +-- messages table. This is already implied by correct client behavior (one +-- logical message fan-out produces exactly one row per recipient device), but +-- the unique constraint makes idempotent S2S delivery safe: the receiving node +-- can INSERT ... ON CONFLICT DO NOTHING and check rows_affected to distinguish +-- a fresh delivery from a duplicate. +-- +-- If existing data violates this constraint (which it should not under correct +-- operation), the migration will fail and duplicates must be resolved first. +-- ----------------------------------------------------------------------------- +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); diff --git a/sql_models/seed.sql b/sql_models/seed.sql index 0e9ecdc..3e84e78 100644 --- a/sql_models/seed.sql +++ b/sql_models/seed.sql @@ -312,3 +312,142 @@ CREATE TRIGGER pending_sessions_notify_trigger AFTER INSERT ON pending_sessions FOR EACH ROW EXECUTE FUNCTION notify_new_pending_session(); + +-- ============================================================================= +-- Migration: inter-node federation support +-- +-- Run this after sql_models/seed.sql. Every change here is purely additive: +-- no existing column is dropped or renamed, no existing constraint is altered. +-- +-- The three new tables (federation_nodes, used_node_nonces, federation_outbox) +-- and the two new columns on users (home_node_id, federated_address) are the +-- only schema deltas required to support cross-node message routing. +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- Peer node registry +-- +-- One row per known peer. Rows are created lazily: the first time this node +-- receives an S2S request from an unknown peer, it fetches that peer's record +-- from the central registry and inserts it here. +-- +-- public_key_b64 is the Ed25519 verifying key used to authenticate every +-- inbound S2S request from that peer. It must match what the peer registered +-- at the central registry (registry.hushnet.net). +-- +-- is_blocked allows an operator to stop accepting traffic from a specific peer +-- without removing the row (which would just re-create it on the next contact). +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_nodes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + node_id TEXT UNIQUE NOT NULL, -- "node-a.hushnet.net" + api_url TEXT NOT NULL, -- "https://node-a.hushnet.net/api" + public_key_b64 TEXT NOT NULL, -- Ed25519 verifying key, base64 + last_seen TIMESTAMPTZ, + is_blocked BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ----------------------------------------------------------------------------- +-- Anti-replay nonce store +-- +-- Every accepted S2S request carries a random 16-byte nonce (base64-encoded). +-- The pair (node_id, nonce) is stored here immediately after signature +-- verification to prevent exact-replay attacks within the timestamp acceptance +-- window (currently 60 s). +-- +-- Rows older than 5 minutes can be safely deleted; the outbox worker runs a +-- periodic purge via: +-- DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes' +-- ----------------------------------------------------------------------------- +CREATE TABLE used_node_nonces ( + nonce TEXT NOT NULL, + node_id TEXT NOT NULL, + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (nonce, node_id) +); + +-- Supports cheap TTL-based cleanup without a sequential scan. +CREATE INDEX idx_used_node_nonces_used_at ON used_node_nonces (used_at); + +-- ----------------------------------------------------------------------------- +-- Outbound delivery queue (federation outbox) +-- +-- Every logical message addressed to a remote node is written here before any +-- network call is made. The outbox worker reads pending entries, attempts +-- delivery to the target node, and transitions entries to 'delivered' or +-- 'failed'. +-- +-- payload is the verbatim JSON body of the POST /s2s/messages request that +-- will be sent to the target node. Storing it here means retries require no +-- additional DB reads to reconstruct the request. +-- +-- Backoff schedule implemented by the worker (seconds): +-- 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600 (cap) +-- After 10 failed attempts the entry is marked 'failed' and abandoned. +-- ----------------------------------------------------------------------------- +CREATE TABLE federation_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + target_node_id TEXT NOT NULL, -- destination node_id + logical_msg_id TEXT NOT NULL, + payload JSONB NOT NULL, + attempt_count INT NOT NULL DEFAULT 0, + last_attempt TIMESTAMPTZ, + next_attempt TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'delivered', 'failed')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partial index: only pending entries participate in the outbox work loop. +CREATE INDEX idx_federation_outbox_work + ON federation_outbox (target_node_id, next_attempt) + WHERE status = 'pending'; + +-- ----------------------------------------------------------------------------- +-- Users: federation columns +-- +-- home_node_id NULL → the user is local to this node; their devices are +-- authoritative here and they can log in normally. +-- +-- home_node_id SET → shadow record for a remote user whose real account lives +-- on another node. Created automatically the first time +-- this node receives a message from that user. Shadow +-- users cannot register devices or log in here; they +-- exist only to satisfy foreign-key constraints on the +-- messages and pending_sessions tables. +-- +-- federated_address is globally unique across all nodes: +-- "alice@node-a.hushnet.net" +-- It is the canonical identifier for cross-node addressing. Username alone is +-- not globally unique since each node has its own namespace. +-- ----------------------------------------------------------------------------- +ALTER TABLE users + ADD COLUMN home_node_id UUID REFERENCES federation_nodes(id) ON DELETE SET NULL, + ADD COLUMN federated_address TEXT UNIQUE; + +-- After running this migration, populate federated_address for every existing +-- local user by substituting the actual NODE_HOST value: +-- +-- UPDATE users +-- SET federated_address = username || '@' +-- WHERE home_node_id IS NULL AND federated_address IS NULL; +-- +-- This can be run as a separate step; the column is nullable so existing rows +-- are not broken before the backfill runs. + +-- ----------------------------------------------------------------------------- +-- Messages: deduplication constraint +-- +-- A given (logical_msg_id, to_device_id) pair must appear at most once in the +-- messages table. This is already implied by correct client behavior (one +-- logical message fan-out produces exactly one row per recipient device), but +-- the unique constraint makes idempotent S2S delivery safe: the receiving node +-- can INSERT ... ON CONFLICT DO NOTHING and check rows_affected to distinguish +-- a fresh delivery from a duplicate. +-- +-- If existing data violates this constraint (which it should not under correct +-- operation), the migration will fail and duplicates must be resolved first. +-- ----------------------------------------------------------------------------- +ALTER TABLE messages + ADD CONSTRAINT uniq_message_per_device UNIQUE (logical_msg_id, to_device_id); diff --git a/src/app_state.rs b/src/app_state.rs index 78c343c..40381f6 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,7 +1,24 @@ +use std::sync::Arc; + use sqlx::PgPool; +use crate::utils::node_keys::NodeKeys; + #[derive(Clone)] pub struct AppState { pub pool: PgPool, pub jwt_secret: String, + /// This node's Ed25519 keypair, used to sign outbound S2S requests. + pub node_keys: Arc, + /// Canonical identifier for this node (e.g. "node-a.hushnet.net"). + /// Matches the node_id registered at the central registry. + pub this_node_id: String, + /// Base API URL for this node (e.g. "https://node-a.hushnet.net/api"). + /// Included in GET /s2s/info responses so peers know where to send requests. + pub this_api_url: String, + /// Central registry URL used for peer node discovery. + pub registry_url: String, + /// Shared HTTP client for outbound requests (registry lookups + S2S calls). + /// reqwest::Client is Clone and internally reference-counted. + pub http_client: reqwest::Client, } diff --git a/src/controllers/federation_controller.rs b/src/controllers/federation_controller.rs new file mode 100644 index 0000000..a359e97 --- /dev/null +++ b/src/controllers/federation_controller.rs @@ -0,0 +1,557 @@ +// src/controllers/federation_controller.rs +// +// Handlers for the /s2s/* endpoint group. +// +// All handlers except node_info require the AuthenticatedNode extractor, which +// verifies the peer's Ed25519 signature before the handler body runs. Handlers +// receive the authenticated peer's FederationNode as a typed argument and can +// use it for logging or for constructing shadow records. +// +// Endpoint overview: +// +// GET /s2s/info — public, no auth +// GET /s2s/users/:username/devices — return device list (auth required) +// GET /s2s/users/:username/keys — return prekey bundle, consume OTPK (auth) +// POST /s2s/sessions — accept forwarded X3DH init (auth) +// POST /s2s/messages — accept forwarded ciphertexts (auth) +// POST /s2s/ack — delivery acknowledgment (auth) +// +// Plus one client-facing federated lookup: +// +// GET /users/federated/:address/keys — proxy prekey bundle from remote node + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use serde_json::json; + +use crate::{ + app_state::AppState, + federation::{client::FederationClient, parse_federated_address}, + middlewares::node_auth::AuthenticatedNode, + models::federation::{ + NodeInfo, S2sAck, S2sMessagePayload, S2sSessionPayload, + }, + repository::{device_repository, federation_repository, message_repository, session_repository}, +}; + +// ─── GET /s2s/info ─────────────────────────────────────────────────────────── + +/// Return this node's public identity. +/// +/// No authentication required. Peers call this during bootstrapping to obtain +/// the public key before they have a cached entry for this node. The caller +/// should cross-check the returned key against the central registry to guard +/// against a MITM substituting a different key. +pub async fn node_info(State(state): State) -> impl IntoResponse { + let info = NodeInfo { + node_id: state.this_node_id.clone(), + api_url: state.this_api_url.clone(), + public_key_b64: state.node_keys.public_b64.clone(), + protocol_version: "0.0.1", + }; + (StatusCode::OK, Json(info)) +} + +// ─── GET /s2s/users/:username/devices ──────────────────────────────────────── + +/// Return the device list for a local user. +/// +/// Used by a peer to enumerate recipient devices before building per-device +/// encrypted payloads. Only local (non-shadow) users are served; requests for +/// shadow users (home_node_id IS NOT NULL) return 404. +pub async fn get_user_devices( + State(state): State, + AuthenticatedNode(_peer): AuthenticatedNode, + Path(username): Path, +) -> impl IntoResponse { + let user_id = match federation_repository::get_local_user_id_by_username(&state.pool, &username) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response() + } + Err(e) => { + eprintln!("[s2s] db error in get_user_devices: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + match device_repository::get_devices_by_user_id(&state.pool, &user_id).await { + Ok(devices) => (StatusCode::OK, Json(devices)).into_response(), + Err(e) => { + eprintln!("[s2s] db error fetching devices: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + } +} + +// ─── GET /s2s/users/:username/keys ─────────────────────────────────────────── + +/// Return the prekey bundle for a local user, consuming one OTPK per device. +/// +/// Semantics are identical to GET /users/:id/keys on the client API. The +/// caller (Node A) receives the bundles, passes them to its client (Client A), +/// who uses them for X3DH key agreement without the servers ever seeing the +/// resulting shared secret. +/// +/// If OTPKs are exhausted, the bundle is still returned (with an empty +/// one_time_prekeys list). The caller signals this to its client via the +/// `otpk_available` flag so the client can decide whether to proceed with +/// SPK-only X3DH or wait for replenishment. +pub async fn get_user_keys( + State(state): State, + AuthenticatedNode(_peer): AuthenticatedNode, + Path(username): Path, +) -> impl IntoResponse { + let user_id = match federation_repository::get_local_user_id_by_username(&state.pool, &username) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response() + } + Err(e) => { + eprintln!("[s2s] db error in get_user_keys: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + match device_repository::get_device_bundle(&state.pool, &user_id).await { + Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Err(e) => { + eprintln!("[s2s] db error fetching key bundle: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + } +} + +// ─── POST /s2s/sessions ────────────────────────────────────────────────────── + +/// Accept a forwarded X3DH session initiation from a peer node. +/// +/// Inserts the pending session(s) into this node's pending_sessions table. +/// The PostgreSQL trigger notify_new_pending_session fires automatically, +/// delivering a WebSocket event to the recipient client — the existing +/// real-time path requires no changes. +/// +/// Shadow records for the sender (user + device) are upserted if they do not +/// already exist so that the FK constraints on pending_sessions are satisfied. +pub async fn receive_session( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Json(payload): Json, +) -> impl IntoResponse { + // Upsert shadow user for the remote sender. + let sender_local_id = match federation_repository::upsert_shadow_user( + &state.pool, + payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"), + &payload.from_federated_address, + peer.id, + ) + .await + { + Ok(id) => id, + Err(e) => { + eprintln!("[s2s] shadow user upsert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if let Err(e) = federation_repository::upsert_shadow_device( + &state.pool, + payload.from_device_id, + sender_local_id, + &payload.from_identity_pubkey, + ) + .await + { + eprintln!("[s2s] shadow device upsert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + + // Insert one pending_session row per recipient device. + for init in &payload.sessions_init { + if let Err(e) = session_repository::create_pending_session( + &state.pool, + &payload.from_device_id, + &init.recipient_device_id, + &init.ephemeral_pubkey, + &init.sender_prekey_pub, + &init.otpk_used, + &init.ciphertext, + ) + .await + { + eprintln!("[s2s] pending session insert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "failed to store pending session"})), + ) + .into_response(); + } + } + + (StatusCode::OK, Json(json!({"status": "ok"}))).into_response() +} + +// ─── POST /s2s/messages ────────────────────────────────────────────────────── + +/// Accept forwarded ciphertexts for a local recipient. +/// +/// For each device payload: +/// - Deduplication check via the unique constraint (logical_msg_id, to_device_id). +/// Duplicate payloads (from outbox retries) are silently skipped; the 200 +/// response is returned regardless so the sender stops retrying. +/// - The PostgreSQL trigger notify_new_message fires on every genuine insert, +/// pushing a WebSocket event to the recipient. No changes to the real-time +/// path are needed. +/// +/// The returned S2sAck.status is "delivered" if at least one new row was +/// inserted, "duplicate" if all payloads were already present. +pub async fn receive_messages( + State(state): State, + AuthenticatedNode(peer): AuthenticatedNode, + Json(payload): Json, +) -> impl IntoResponse { + // Resolve the local recipient. + let recipient_id = + match federation_repository::get_local_user_id_by_username(&state.pool, &payload.to_user) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "recipient not found or not local to this node"})), + ) + .into_response() + } + Err(e) => { + eprintln!("[s2s] db error resolving recipient: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Upsert shadow records for the remote sender. + let sender_local_id = match federation_repository::upsert_shadow_user( + &state.pool, + payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"), + &payload.from_federated_address, + peer.id, + ) + .await + { + Ok(id) => id, + Err(e) => { + eprintln!("[s2s] shadow user upsert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if let Err(e) = federation_repository::upsert_shadow_device( + &state.pool, + payload.from_device_id, + sender_local_id, + &payload.from_identity_pubkey, + ) + .await + { + eprintln!("[s2s] shadow device upsert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + + // Find or create the local chat between shadow-sender and local-recipient. + let chat_id = match federation_repository::get_or_create_direct_chat( + &state.pool, + sender_local_id, + recipient_id, + ) + .await + { + Ok(id) => id, + Err(e) => { + eprintln!("[s2s] get_or_create_direct_chat failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Insert each per-device ciphertext, skipping duplicates. + let mut any_new = false; + for dev in &payload.payloads { + match message_repository::insert_federated_message( + &state.pool, + &payload.logical_msg_id, + chat_id, + sender_local_id, + payload.from_device_id, + recipient_id, + dev.to_device_id, + &dev.header, + &dev.ciphertext, + ) + .await + { + Ok(true) => any_new = true, + Ok(false) => {} // duplicate, silently skip + Err(e) => { + eprintln!("[s2s] message insert failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + } + } + + let ack = S2sAck { + logical_msg_id: payload.logical_msg_id, + status: if any_new { + "delivered".into() + } else { + "duplicate".into() + }, + }; + (StatusCode::OK, Json(ack)).into_response() +} + +// ─── POST /s2s/ack ─────────────────────────────────────────────────────────── + +/// Receive a delivery acknowledgment from a peer. +/// +/// The outbox worker already marks entries delivered when it gets a 2xx from +/// forward_messages, so this endpoint is not on the critical path. It exists +/// for peers that want to proactively signal delivery (e.g. after a delayed +/// WebSocket push) and for future monitoring use cases. +pub async fn receive_ack( + State(state): State, + AuthenticatedNode(_peer): AuthenticatedNode, + Json(ack): Json, +) -> impl IntoResponse { + if let Err(e) = federation_repository::mark_outbox_delivered_by_logical_id( + &state.pool, + &ack.logical_msg_id, + ) + .await + { + eprintln!("[s2s] ack db error: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + (StatusCode::OK, Json(json!({"status": "ack received"}))).into_response() +} + +// ─── GET /users/federated/:address/keys ────────────────────────────────────── + +/// Client-facing proxy: fetch the prekey bundle of a remote user. +/// +/// `address` is the full federated address: "bob@node-b.hushnet.net". +/// +/// This node (Node A) authenticates the request from Client A, resolves the +/// target node, makes an authenticated S2S call to Node B, and returns the +/// bundle verbatim. OTPKs are consumed on Node B; Node A never stores them. +/// +/// If the target node's address matches this node's own node_id, the request +/// is redirected to the local GET /users/:id/keys path instead (handled in the +/// same response to avoid a network round-trip). +pub async fn federated_keys( + State(state): State, + crate::middlewares::auth::AuthenticatedDevice(_device): crate::middlewares::auth::AuthenticatedDevice, + Path(address): Path, +) -> impl IntoResponse { + let (username, node_id) = match parse_federated_address(&address) { + Some(parts) => parts, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid federated address, expected user@node"})), + ) + .into_response() + } + }; + + // If the address points to this node, serve locally. + if node_id == state.this_node_id { + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, username) + .await + { + Ok(Some(id)) => id, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found"})), + ) + .into_response() + } + Err(e) => { + eprintln!("[federated_keys] local db error: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + return match device_repository::get_device_bundle(&state.pool, &user_id).await { + Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Err(e) => { + eprintln!("[federated_keys] local bundle error: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response() + } + }; + } + + // Remote node: look up in federation_nodes or fall back to registry. + let node = match federation_repository::get_federation_node(&state.pool, node_id).await { + Ok(Some(n)) => n, + Ok(None) => { + // Try to discover via registry. + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + match state.http_client.get(&url).send().await { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(body) => { + let api_url = body["api_url"].as_str().unwrap_or(""); + let pubkey = body["public_key_b64"].as_str().unwrap_or(""); + match federation_repository::upsert_federation_node( + &state.pool, + node_id, + api_url, + pubkey, + ) + .await + { + Ok(n) => n, + Err(e) => { + eprintln!("[federated_keys] upsert node failed: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + } + } + Err(_) => { + return ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "malformed registry response"})), + ) + .into_response(); + } + } + } + _ => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "target node not found in registry"})), + ) + .into_response(); + } + } + } + Err(e) => { + eprintln!("[federated_keys] db error: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + if node.is_blocked { + return ( + StatusCode::FORBIDDEN, + Json(json!({"error": "target node is blocked"})), + ) + .into_response(); + } + + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + + match fed_client.fetch_peer_keys(&node.api_url, username).await { + Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Err(e) => { + eprintln!("[federated_keys] peer key fetch failed: {e}"); + ( + StatusCode::BAD_GATEWAY, + Json(json!({"error": format!("peer returned error: {e}")})), + ) + .into_response() + } + } +} diff --git a/src/controllers/messages_controller.rs b/src/controllers/messages_controller.rs index 8b7a9e6..cf4ce71 100644 --- a/src/controllers/messages_controller.rs +++ b/src/controllers/messages_controller.rs @@ -1,8 +1,15 @@ use crate::{ app_state::AppState, + federation::{client::FederationClient, parse_federated_address}, middlewares::auth::AuthenticatedDevice, - models::message::OutgoingMessage, - repository::message_repository::{fetch_pending_messages, insert_message}, + models::{ + federation::{S2sDevicePayload, S2sMessagePayload}, + message::OutgoingMessage, + }, + repository::{ + federation_repository, message_repository::{fetch_pending_messages, insert_message}, + user_repository, + }, }; use axum::{ extract::{Json, State}, @@ -17,28 +24,150 @@ pub async fn send_message( AuthenticatedDevice(device): AuthenticatedDevice, Json(msg): Json, ) -> impl IntoResponse { - // Find user_id of sender let from_user_id: Uuid = device.user_id; + // ── Federated path ──────────────────────────────────────────────────────── + // When to_user_address is present and points to a different node, bypass + // local delivery entirely and queue the message for S2S forwarding. + if let Some(ref addr) = msg.to_user_address { + if let Some((username, node_id)) = parse_federated_address(addr) { + if node_id != state.this_node_id { + return handle_federated_message( + &state, + &device, + &msg, + from_user_id, + username, + node_id, + ) + .await; + } + } + } + + // ── Local delivery (existing path) ──────────────────────────────────────── match insert_message(&state.pool, device.id, from_user_id, msg).await { - Ok(()) => ( - StatusCode::OK, - Json(json!({ - "success": "true" - })), - ) - .into_response(), + Ok(()) => (StatusCode::OK, Json(json!({"success": "true"}))).into_response(), Err(e) => { - eprintln!("Error when inserting message {}", e); + eprintln!("Error inserting message: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Internal server error"})), + Json(json!({"error": "internal server error"})), ) .into_response() } } } +/// Build and queue a cross-node message for delivery to `username@node_id`. +/// +/// Steps: +/// 1. Look up sender's username (needed for from_federated_address). +/// 2. Resolve target node from DB cache or central registry. +/// 3. Serialize the S2S payload and write to federation_outbox (durable). +/// 4. Spawn a task for immediate delivery; if it fails, the outbox worker +/// will retry on its next poll cycle. +/// 5. Return 202 Accepted — the client does not wait for Node B to respond. +async fn handle_federated_message( + state: &AppState, + device: &crate::models::device::Devices, + msg: &OutgoingMessage, + from_user_id: Uuid, + to_username: &str, + target_node_id: &str, +) -> axum::response::Response { + // Look up sender's username for the federated address. + let sender_username = match user_repository::find_user_by_id(&state.pool, &from_user_id).await + { + Ok(Some(u)) => u.username, + _ => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "cannot resolve sender identity"})), + ) + .into_response() + } + }; + + // Resolve the target node (DB → registry). + let node = match resolve_node(state, target_node_id).await { + Ok(n) => n, + Err(resp) => return resp, + }; + + let s2s_payload = S2sMessagePayload { + logical_msg_id: msg.logical_msg_id.clone(), + from_federated_address: format!("{}@{}", sender_username, state.this_node_id), + from_device_id: device.id, + from_identity_pubkey: device.identity_pubkey.clone(), + to_user: to_username.to_string(), + payloads: msg + .payloads + .iter() + .map(|p| S2sDevicePayload { + to_device_id: p.to_device_id, + header: p.header.clone(), + ciphertext: p.ciphertext.clone(), + }) + .collect(), + }; + + let payload_json = match serde_json::to_value(&s2s_payload) { + Ok(v) => v, + Err(e) => { + eprintln!("Failed to serialize S2S payload: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Write to outbox for durability before attempting delivery. + let outbox_id = match federation_repository::enqueue_outbox( + &state.pool, + target_node_id, + &msg.logical_msg_id, + &payload_json, + ) + .await + { + Ok(id) => id, + Err(e) => { + eprintln!("Failed to enqueue outbox entry: {e}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + // Spawn immediate delivery attempt; failures are handled by the outbox worker. + let pool = state.pool.clone(); + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + let api_url = node.api_url.clone(); + + tokio::spawn(async move { + match fed_client.forward_messages(&api_url, &s2s_payload).await { + Ok(_) => { + let _ = federation_repository::mark_outbox_delivered(&pool, outbox_id).await; + } + Err(e) => { + eprintln!("[federated send] immediate delivery failed, will retry: {e}"); + // Outbox worker schedules the next attempt automatically. + } + } + }); + + (StatusCode::ACCEPTED, Json(json!({"status": "queued"}))).into_response() +} + pub async fn get_pending_messages( State(state): State, AuthenticatedDevice(device): AuthenticatedDevice, @@ -46,12 +175,78 @@ pub async fn get_pending_messages( match fetch_pending_messages(&state.pool, AuthenticatedDevice(device)).await { Ok(messages) => (StatusCode::OK, Json(messages)).into_response(), Err(e) => { - eprintln!("Error when fetching pending messages {}", e); + eprintln!("Error fetching pending messages: {e}"); ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "Internal server error"})), + Json(json!({"error": "internal server error"})), ) .into_response() } } } + +// ── Shared helper ───────────────────────────────────────────────────────────── + +/// Look up a FederationNode by node_id, falling back to the central registry +/// if the node is not yet cached locally. +pub(crate) async fn resolve_node( + state: &AppState, + node_id: &str, +) -> Result { + if let Ok(Some(n)) = federation_repository::get_federation_node(&state.pool, node_id).await { + if n.is_blocked { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({"error": "target node is blocked"})), + ) + .into_response()); + } + return Ok(n); + } + + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + let resp = match state.http_client.get(&url).send().await { + Ok(r) => r, + Err(_) => { + return Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "registry unreachable"})), + ) + .into_response()) + } + }; + + if !resp.status().is_success() { + return Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "target node not found in registry"})), + ) + .into_response()); + } + + let body = match resp.json::().await { + Ok(b) => b, + Err(_) => { + return Err(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "malformed registry response"})), + ) + .into_response()) + } + }; + + let api_url = body["api_url"].as_str().unwrap_or(""); + let pubkey = body["public_key_b64"].as_str().unwrap_or(""); + match federation_repository::upsert_federation_node(&state.pool, node_id, api_url, pubkey).await + { + Ok(n) => Ok(n), + Err(e) => { + eprintln!("Failed to upsert federation node: {e}"); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response()) + } + } +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 9a4fcde..29481e6 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,5 +1,6 @@ pub mod chats_controller; pub mod device_controller; +pub mod federation_controller; pub mod messages_controller; pub mod root_controller; pub mod session_controller; diff --git a/src/controllers/session_controller.rs b/src/controllers/session_controller.rs index b73410b..8f42458 100644 --- a/src/controllers/session_controller.rs +++ b/src/controllers/session_controller.rs @@ -4,10 +4,14 @@ use serde_json::json; use uuid::Uuid; use crate::app_state::AppState; +use crate::federation::{client::FederationClient, parse_federated_address}; use crate::middlewares::auth::AuthenticatedDevice; -use crate::repository::session_repository; +use crate::models::federation::{S2sSessionInit, S2sSessionPayload}; +use crate::repository::{session_repository, user_repository}; -#[derive(Debug, serde::Deserialize)] +use super::messages_controller::resolve_node; + +#[derive(Debug, Deserialize)] pub struct SessionInit { pub recipient_device_id: Uuid, pub ephemeral_pubkey: String, @@ -16,9 +20,13 @@ pub struct SessionInit { pub ciphertext: String, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize)] pub struct CreateSessionBody { pub recipient_user_id: Uuid, + /// Optional federated address for cross-node session initiation. + /// Format: "username@node-host" (e.g. "bob@node-b.hushnet.net"). + #[serde(default)] + pub recipient_user_address: Option, pub sessions_init: Vec, } @@ -34,6 +42,18 @@ pub async fn create_session( AuthenticatedDevice(sender): AuthenticatedDevice, Json(payload): Json, ) -> Result { + // ── Federated path ──────────────────────────────────────────────────────── + if let Some(ref addr) = payload.recipient_user_address { + if let Some((username, node_id)) = parse_federated_address(addr) { + if node_id != state.this_node_id { + return handle_federated_session(&state, &sender, &payload, username, node_id) + .await + .map_err(|_| (StatusCode::BAD_GATEWAY, "failed to forward session")); + } + } + } + + // ── Local path (unchanged) ──────────────────────────────────────────────── if sender.user_id == payload.recipient_user_id { return Err((StatusCode::BAD_REQUEST, "Cannot create session with self")); } @@ -57,8 +77,7 @@ pub async fn create_session( ) .await .map_err(|e| { - // Print the underlying database error for debugging before mapping to a generic HTTP error - eprintln!("Failed to insert pending session: {:#?}", e); + eprintln!("Failed to insert pending session: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to insert pending session", @@ -70,7 +89,66 @@ pub async fn create_session( .await .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Failed to commit"))?; - Ok((StatusCode::CREATED, Json(json!({ "status": "ok" })))) + Ok((StatusCode::CREATED, Json(json!({ "status": "ok" }))).into_response()) +} + +/// Forward an X3DH session initiation to a peer node. +/// +/// Node A signs and POSTs to Node B's /s2s/sessions endpoint. Node B inserts +/// the pending session records and delivers a WebSocket notification to the +/// recipient client. Node A returns 202 Accepted immediately. +async fn handle_federated_session( + state: &AppState, + sender: &crate::models::device::Devices, + payload: &CreateSessionBody, + to_username: &str, + target_node_id: &str, +) -> Result { + let sender_username = + match user_repository::find_user_by_id(&state.pool, &sender.user_id).await { + Ok(Some(u)) => u.username, + _ => return Err(()), + }; + + let node = match resolve_node(state, target_node_id).await { + Ok(n) => n, + Err(resp) => return Ok(resp), + }; + + let s2s_payload = S2sSessionPayload { + from_federated_address: format!("{}@{}", sender_username, state.this_node_id), + from_device_id: sender.id, + from_identity_pubkey: sender.identity_pubkey.clone(), + to_user: to_username.to_string(), + sessions_init: payload + .sessions_init + .iter() + .map(|i| S2sSessionInit { + recipient_device_id: i.recipient_device_id, + ephemeral_pubkey: i.ephemeral_pubkey.clone(), + sender_prekey_pub: i.sender_prekey_pub.clone(), + otpk_used: i.otpk_used.clone(), + ciphertext: i.ciphertext.clone(), + }) + .collect(), + }; + + let fed_client = FederationClient::new( + state.http_client.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + ); + + if let Err(e) = fed_client.forward_session(&node.api_url, &s2s_payload).await { + eprintln!("[federated session] forward failed: {e}"); + return Ok(( + StatusCode::BAD_GATEWAY, + Json(json!({"error": "failed to reach target node"})), + ) + .into_response()); + } + + Ok((StatusCode::ACCEPTED, Json(json!({"status": "forwarded"}))).into_response()) } pub async fn get_pending_sessions_handler( @@ -124,7 +202,7 @@ pub async fn confirm_session( ) .await .map_err(|e| { - eprintln!("Error {:#?}", e); + eprintln!("Error: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to get or create chat", @@ -139,12 +217,13 @@ pub async fn confirm_session( ) .await .map_err(|e| { - eprintln!("Error {:#?}", e); + eprintln!("Error: {e:#?}"); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to insert session", ) })?; + session_repository::delete_pending_session(&state.pool, &payload.pending_session_id) .await .map_err(|_| { diff --git a/src/federation/client.rs b/src/federation/client.rs new file mode 100644 index 0000000..88102de --- /dev/null +++ b/src/federation/client.rs @@ -0,0 +1,199 @@ +// src/federation/client.rs +// +// Authenticated HTTP client for outbound S2S requests. +// +// Every outbound request is signed with this node's Ed25519 private key so +// that the receiving node can verify the sender's identity against the public +// key stored in the central registry. +// +// Canonical string signed (UTF-8, fields separated by "\n"): +// +// {HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +// +// The path component is extracted from the full URL by stripping the scheme +// and authority, making it consistent with what the receiver reconstructs from +// the incoming request URI. Only the path+query portion is signed, not the +// host, so that node API URLs can change without invalidating the signing logic. +// +// Four headers carry the authentication material: +// +// X-Node-ID — this node's canonical identifier +// X-Timestamp — Unix seconds (string) +// X-Nonce — 16 random bytes, base64-encoded +// X-Node-Signature — Ed25519(canonical), base64-encoded + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use ed25519_dalek::Signer; +use reqwest::Client; +use serde::Serialize; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + models::{ + device::DeviceBundle, + federation::{NodeInfo, S2sAck, S2sMessagePayload, S2sSessionPayload}, + }, + utils::node_keys::NodeKeys, +}; + +/// HTTP client for outbound S2S communication. +/// +/// Clone is cheap: both `http` (reqwest::Client) and `node_keys` (Arc) are +/// reference-counted internally. +#[derive(Clone)] +pub struct FederationClient { + pub http: Client, + node_keys: Arc, + pub this_node_id: String, +} + +impl FederationClient { + pub fn new(http: Client, node_keys: Arc, this_node_id: String) -> Self { + Self { + http, + node_keys, + this_node_id, + } + } + + /// Fetch the prekey bundle for `username` from a peer node. + /// + /// The returned Vec has one DeviceBundle per device registered for that + /// user on the remote node. One-time prekeys are consumed by the remote + /// node on fetch (same semantics as the local GET /users/:id/keys endpoint). + pub async fn fetch_peer_keys(&self, api_url: &str, username: &str) -> Result> { + let url = format!("{api_url}/s2s/users/{username}/keys"); + self.signed_get(&url) + .await? + .error_for_status() + .context("peer returned error for key fetch")? + .json::>() + .await + .context("invalid key bundle in peer response") + } + + /// Forward an X3DH session initiation to the peer that hosts the recipient. + pub async fn forward_session(&self, api_url: &str, payload: &S2sSessionPayload) -> Result<()> { + self.signed_post(api_url, "/s2s/sessions", payload) + .await? + .error_for_status() + .context("peer rejected session forward")?; + Ok(()) + } + + /// Forward a batch of device-specific ciphertexts to the peer. + /// Returns the S2sAck the receiving node sends back. + pub async fn forward_messages( + &self, + api_url: &str, + payload: &S2sMessagePayload, + ) -> Result { + self.signed_post(api_url, "/s2s/messages", payload) + .await? + .error_for_status() + .context("peer rejected message forward")? + .json::() + .await + .context("invalid ack in peer response") + } + + /// Query the peer's public node info (used for bootstrapping / key pinning). + pub async fn fetch_node_info(&self, api_url: &str) -> Result { + // /s2s/info does not require authentication, so this is an unsigned GET. + let body = self.http + .get(format!("{api_url}/s2s/info")) + .send() + .await + .context("GET /s2s/info failed")? + .error_for_status() + .context("peer info endpoint returned error")? + .text() + .await + .context("failed to read node info response body")?; + + let leaked_body: &'static str = Box::leak(body.into_boxed_str()); + serde_json::from_str::(leaked_body).context("invalid node info response") + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + async fn signed_get(&self, url: &str) -> Result { + let path = url_path(url); + let (ts, nonce, sig) = self.sign("GET", path)?; + self.http + .get(url) + .header("X-Node-ID", &self.this_node_id) + .header("X-Timestamp", &ts) + .header("X-Nonce", &nonce) + .header("X-Node-Signature", &sig) + .send() + .await + .context("S2S GET request failed") + } + + async fn signed_post( + &self, + api_url: &str, + path: &str, + body: &T, + ) -> Result { + let (ts, nonce, sig) = self.sign("POST", path)?; + let url = format!("{api_url}{path}"); + self.http + .post(&url) + .header("Content-Type", "application/json") + .header("X-Node-ID", &self.this_node_id) + .header("X-Timestamp", &ts) + .header("X-Nonce", &nonce) + .header("X-Node-Signature", &sig) + .json(body) + .send() + .await + .context("S2S POST request failed") + } + + /// Build the canonical string and sign it with this node's private key. + /// + /// canonical = "{method}\n{path}\n{ts}\n{nonce}" + fn sign(&self, method: &str, path: &str) -> Result<(String, String, String)> { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + .to_string(); + + let nonce = { + use ed25519_dalek::ed25519::signature::rand_core::{OsRng, RngCore}; + let mut buf = [0u8; 16]; + OsRng.fill_bytes(&mut buf); + B64.encode(buf) + }; + + let canonical = format!("{method}\n{path}\n{ts}\n{nonce}"); + let signing_key = self.node_keys.signing_key()?; + let signature = signing_key.sign(canonical.as_bytes()); + let sig_b64 = B64.encode(signature.to_bytes()); + + Ok((ts, nonce, sig_b64)) + } +} + +/// Extract the path+query portion from a full URL. +/// +/// "https://node-a.hushnet.net/api/s2s/messages?x=1" → "/api/s2s/messages?x=1" +/// +/// This is what the receiving node reconstructs from the incoming request URI, +/// so both sides of the signature use the same string. +fn url_path(url: &str) -> &str { + if let Some(pos) = url.find("://") { + let after_scheme = &url[pos + 3..]; + if let Some(slash) = after_scheme.find('/') { + return &after_scheme[slash..]; + } + return "/"; + } + url +} diff --git a/src/federation/mod.rs b/src/federation/mod.rs new file mode 100644 index 0000000..82be538 --- /dev/null +++ b/src/federation/mod.rs @@ -0,0 +1,39 @@ +pub mod client; +pub mod outbox; + +/// Parse a federated user address into its local and node components. +/// +/// "alice@node-a.hushnet.net" → ("alice", "node-a.hushnet.net") +/// +/// Uses rfind('@') so that a username containing '@' (unlikely but possible) +/// is tolerated: the rightmost '@' is taken as the domain separator. +/// Returns None if the address contains no '@'. +pub fn parse_federated_address(addr: &str) -> Option<(&str, &str)> { + let at = addr.rfind('@')?; + Some((&addr[..at], &addr[at + 1..])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_normal_address() { + let (user, node) = parse_federated_address("alice@node-a.hushnet.net").unwrap(); + assert_eq!(user, "alice"); + assert_eq!(node, "node-a.hushnet.net"); + } + + #[test] + fn parse_missing_at() { + assert!(parse_federated_address("alice").is_none()); + } + + #[test] + fn parse_rightmost_at() { + // degenerate case: username itself contains '@' + let (user, node) = parse_federated_address("a@b@node-a.hushnet.net").unwrap(); + assert_eq!(user, "a@b"); + assert_eq!(node, "node-a.hushnet.net"); + } +} diff --git a/src/federation/outbox.rs b/src/federation/outbox.rs new file mode 100644 index 0000000..7826c7f --- /dev/null +++ b/src/federation/outbox.rs @@ -0,0 +1,150 @@ +// src/federation/outbox.rs +// +// Background worker that delivers queued outbound S2S messages. +// +// The outbox provides durability for cross-node message delivery: when Node A +// forwards a message to Node B, it first writes the request body to the +// federation_outbox table, then attempts immediate delivery in a spawned task. +// If that attempt fails (Node B is unreachable, times out, etc.), the outbox +// worker picks up the entry on its next poll cycle and retries with exponential +// backoff. +// +// This decouples the client-facing POST /messages response from the S2S +// network call: Node A returns 202 Accepted to the client as soon as the +// entry is written to the outbox, regardless of Node B's availability. +// +// Backoff schedule (seconds): +// attempt 0 → immediate (spawned task at request time) +// attempt 1 → 10 s +// attempt 2 → 20 s +// attempt 3 → 40 s +// ... +// attempt 12+ → 3600 s (1 hour, cap) +// +// After MAX_ATTEMPTS the entry is marked 'failed'. A separate mechanism +// (not implemented here) could push a delivery-failure event to the +// originating client's WebSocket connection. + +use std::sync::Arc; +use std::time::Duration; + +use sqlx::PgPool; +use tokio::time; + +use crate::{ + models::federation::S2sMessagePayload, + repository::federation_repository, + utils::node_keys::NodeKeys, +}; + +use super::client::FederationClient; + +const POLL_INTERVAL: Duration = Duration::from_secs(10); +const MAX_ATTEMPTS: i32 = 10; + +/// Long-running task: poll the outbox and retry failed deliveries. +/// +/// Spawn this once at startup: +/// ```rust +/// tokio::spawn(federation::outbox::run(pool, node_keys, node_id, http)); +/// ``` +pub async fn run( + pool: PgPool, + node_keys: Arc, + this_node_id: String, + http_client: reqwest::Client, +) { + let mut interval = time::interval(POLL_INTERVAL); + // Delay mode: if a tick is missed (the previous iteration took longer than + // POLL_INTERVAL), skip the missed ticks rather than bursting. + interval.set_missed_tick_behavior(time::MissedTickBehavior::Delay); + + loop { + interval.tick().await; + + // Housekeeping: purge nonces older than 5 minutes. + if let Err(e) = federation_repository::purge_expired_nonces(&pool).await { + eprintln!("[outbox] nonce purge failed: {e}"); + } + + let entries = match federation_repository::fetch_due_outbox_entries(&pool).await { + Ok(v) => v, + Err(e) => { + eprintln!("[outbox] db error fetching due entries: {e}"); + continue; + } + }; + + for entry in entries { + let pool = pool.clone(); + let client = FederationClient::new( + http_client.clone(), + node_keys.clone(), + this_node_id.clone(), + ); + + tokio::spawn(async move { + let payload: S2sMessagePayload = match serde_json::from_value(entry.payload) { + Ok(p) => p, + Err(e) => { + eprintln!("[outbox] cannot deserialize entry {}: {e}", entry.id); + // Malformed entries will never succeed; mark failed immediately. + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + MAX_ATTEMPTS, + MAX_ATTEMPTS, + ) + .await; + return; + } + }; + + let node = + match federation_repository::get_federation_node(&pool, &entry.target_node_id) + .await + { + Ok(Some(n)) => n, + Ok(None) => { + eprintln!( + "[outbox] unknown target node '{}' for entry {}", + entry.target_node_id, entry.id + ); + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + entry.attempt_count + 1, + MAX_ATTEMPTS, + ) + .await; + return; + } + Err(e) => { + eprintln!("[outbox] db error looking up node: {e}"); + return; + } + }; + + match client.forward_messages(&node.api_url, &payload).await { + Ok(_) => { + let _ = federation_repository::mark_outbox_delivered(&pool, entry.id).await; + } + Err(e) => { + eprintln!( + "[outbox] delivery attempt {} for entry {} failed: {e}", + entry.attempt_count + 1, + entry.id + ); + let _ = federation_repository::record_outbox_failure( + &pool, + entry.id, + entry.attempt_count + 1, + MAX_ATTEMPTS, + ) + .await; + } + } + }); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0d91333..74525e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod controllers; +mod federation; mod middlewares; mod models; mod repository; @@ -7,6 +8,7 @@ mod services; use axum::{Extension, Router}; use sqlx::PgPool; use std::net::SocketAddr; +use std::sync::Arc; use tokio::sync::broadcast; mod app_state; mod realtime; @@ -25,31 +27,57 @@ use registry::register::register_with_registry; async fn main() -> Result<(), anyhow::Error> { tracing_subscriber::fmt::init(); dotenvy::dotenv().ok(); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "0.0.0.0".into()); let server_port = env::var("SERVER_PORT").unwrap_or_else(|_| "8080".into()); - let pool: sqlx::Pool = PgPool::connect(&database_url).await?; - let jwt_secret = std::env::var("JWT_SECRET").unwrap(); + let jwt_secret = env::var("JWT_SECRET").unwrap(); + let registry_url = env::var("REGISTRY_URL").unwrap_or_else(|_| "https://registry.hushnet.net".into()); + let node_host = + env::var("NODE_HOST").unwrap_or_else(|_| "node-unknown.hushnet.net".into()); + let node_api_url = + env::var("NODE_API_URL").unwrap_or_else(|_| format!("https://{node_host}/api")); + + let pool: sqlx::Pool = PgPool::connect(&database_url).await?; let keys = NodeKeys::load_or_generate()?; println!("Public key (base64): {}", keys.public_b64); + if env::var("REGISTER_TO_REGISTRY") .unwrap_or_else(|_| "false".into()) .to_lowercase() == "true" { - println!("Registering with registry at {}", registry_url); + println!("Registering with registry at {registry_url}"); register_with_registry(®istry_url).await?; } + let http_client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()?; + let state: AppState = AppState { pool: pool.clone(), jwt_secret, + node_keys: Arc::new(keys), + this_node_id: node_host.clone(), + this_api_url: node_api_url, + registry_url: registry_url.clone(), + http_client: http_client.clone(), }; + let (tx, _rx) = broadcast::channel::(100); tokio::spawn(start_pg_listeners(pool.clone(), tx.clone())); + // Outbox worker: retries failed cross-node message deliveries. + tokio::spawn(federation::outbox::run( + pool.clone(), + state.node_keys.clone(), + state.this_node_id.clone(), + http_client, + )); + let app = Router::new() .merge(routes::users::routes().with_state(state.clone())) .merge(routes::devices::routes().with_state(state.clone())) @@ -57,11 +85,11 @@ async fn main() -> Result<(), anyhow::Error> { .merge(routes::sessions::routes().with_state(state.clone())) .merge(routes::chats::routes().with_state(state.clone())) .merge(routes::messages::routes().with_state(state.clone())) + .merge(routes::federation::routes().with_state(state.clone())) .merge(routes::websocket::routes()) .layer(Extension(tx)); let addr = SocketAddr::new(server_host.parse().unwrap(), server_port.parse().unwrap()); - let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); axum::serve(listener, app).await.unwrap(); Ok(()) diff --git a/src/middlewares/mod.rs b/src/middlewares/mod.rs index 0e4a05d..b55c3a3 100644 --- a/src/middlewares/mod.rs +++ b/src/middlewares/mod.rs @@ -1 +1,2 @@ pub mod auth; +pub mod node_auth; diff --git a/src/middlewares/node_auth.rs b/src/middlewares/node_auth.rs new file mode 100644 index 0000000..8f0c678 --- /dev/null +++ b/src/middlewares/node_auth.rs @@ -0,0 +1,186 @@ +// src/middlewares/node_auth.rs +// +// Authenticates inbound S2S requests from peer nodes. +// +// Every request to a /s2s/* endpoint (except /s2s/info) must carry four +// headers that together prove the request was sent by the node that owns the +// private key registered at the central registry: +// +// X-Node-ID — canonical node identifier ("node-a.hushnet.net") +// X-Timestamp — Unix seconds as a decimal string +// X-Nonce — random 16-byte value, base64-encoded +// X-Node-Signature — Ed25519 signature, base64-encoded +// +// Canonical string (UTF-8, signed verbatim, fields separated by "\n"): +// +// {HTTP_METHOD}\n{path}\n{timestamp}\n{nonce} +// +// The path component is the request URI path only (no scheme or host), so +// that the canonical string is independent of which domain name the caller +// used to reach this node. +// +// Verification sequence +// --------------------- +// 1. Reject if |now − timestamp| > 60 s. +// 2. Look up the peer's FederationNode record (DB cache → registry fallback). +// 3. Reject if the node is flagged is_blocked. +// 4. Verify the Ed25519 signature over the canonical string. +// 5. Atomically claim the (node_id, nonce) pair in used_node_nonces; reject +// if the pair was already present (replay attack). +// +// On success the FederationNode record is inserted into request Extensions so +// that handlers can access it with `Extension`. + +use crate::{ + app_state::AppState, + models::federation::FederationNode, + repository::federation_repository, +}; +use axum::{ + async_trait, + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +/// Extractor that validates the four S2S authentication headers and returns the +/// authenticated peer's FederationNode record on success. +/// +/// Usage in a handler: +/// ``` +/// pub async fn my_handler( +/// AuthenticatedNode(peer): AuthenticatedNode, +/// ... +/// ) -> impl IntoResponse { ... } +/// ``` +pub struct AuthenticatedNode(pub FederationNode); + +#[async_trait] +impl FromRequestParts for AuthenticatedNode { + type Rejection = (StatusCode, String); + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let node_id = header_str(&parts.headers, "X-Node-ID")?; + let ts_str = header_str(&parts.headers, "X-Timestamp")?; + let nonce = header_str(&parts.headers, "X-Nonce")?; + let sig_b64 = header_str(&parts.headers, "X-Node-Signature")?; + + // ── 1. timestamp check ─────────────────────────────────────────────── + let now = chrono::Utc::now().timestamp(); + let ts: i64 = ts_str + .parse() + .map_err(|_| (StatusCode::BAD_REQUEST, "X-Timestamp must be an integer".into()))?; + if (now - ts).abs() > 60 { + return Err((StatusCode::UNAUTHORIZED, "timestamp outside 60-second window".into())); + } + + // ── 2. peer public key lookup (DB cache → registry fallback) ───────── + let node = resolve_peer(state, &node_id).await?; + + // ── 3. blocked check ───────────────────────────────────────────────── + if node.is_blocked { + return Err((StatusCode::FORBIDDEN, "node is blocked".into())); + } + + // ── 4. signature verification ──────────────────────────────────────── + let path = parts + .uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let canonical = format!("{}\n{}\n{}\n{}", parts.method.as_str(), path, ts_str, nonce); + + let sig_bytes: [u8; 64] = B64 + .decode(&sig_b64) + .map_err(|_| (StatusCode::BAD_REQUEST, "bad signature base64".into()))? + .try_into() + .map_err(|_| (StatusCode::BAD_REQUEST, "signature must be 64 bytes".into()))?; + let sig = Signature::from_bytes(&sig_bytes); + + let vk_bytes: [u8; 32] = B64 + .decode(&node.public_key_b64) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "bad cached peer pubkey".into()))? + .try_into() + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "peer pubkey must be 32 bytes".into(), + ) + })?; + let vk = VerifyingKey::from_bytes(&vk_bytes) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "invalid peer pubkey".into()))?; + + vk.verify(canonical.as_bytes(), &sig) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid node signature".into()))?; + + // ── 5. nonce claim (replay prevention) ─────────────────────────────── + let fresh = federation_repository::claim_nonce(&state.pool, &node_id, &nonce) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))?; + if !fresh { + return Err((StatusCode::UNAUTHORIZED, "replayed nonce".into())); + } + + Ok(AuthenticatedNode(node)) + } +} + +fn header_str( + headers: &axum::http::HeaderMap, + name: &'static str, +) -> Result { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(String::from) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, format!("missing header: {name}"))) +} + +/// Look up a peer's FederationNode, falling back to the central registry if the +/// node is not yet cached locally. +/// +/// On a successful registry fetch, the node record is upserted into +/// federation_nodes so subsequent requests use the local cache. +async fn resolve_peer( + state: &AppState, + node_id: &str, +) -> Result { + if let Some(node) = federation_repository::get_federation_node(&state.pool, node_id) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))? + { + return Ok(node); + } + + // Cache miss: ask the central registry. + let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + let resp = state + .http_client + .get(&url) + .send() + .await + .map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "registry unreachable".into()))? + .error_for_status() + .map_err(|_| (StatusCode::UNAUTHORIZED, "peer node not found in registry".into()))? + .json::() + .await + .map_err(|_| (StatusCode::BAD_GATEWAY, "malformed registry response".into()))?; + + let api_url = resp["api_url"] + .as_str() + .ok_or((StatusCode::BAD_GATEWAY, "registry response missing api_url".into()))?; + let pubkey = resp["public_key_b64"] + .as_str() + .ok_or((StatusCode::BAD_GATEWAY, "registry response missing public_key_b64".into()))?; + + let node = + federation_repository::upsert_federation_node(&state.pool, node_id, api_url, pubkey) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".into()))?; + + Ok(node) +} diff --git a/src/models/federation.rs b/src/models/federation.rs new file mode 100644 index 0000000..126f015 --- /dev/null +++ b/src/models/federation.rs @@ -0,0 +1,124 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +// ─── Peer node record ──────────────────────────────────────────────────────── + +/// A peer node as stored in the federation_nodes table. +/// Rows are created lazily on first contact (via registry lookup) and cached. +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FederationNode { + pub id: Uuid, + /// Canonical host-based identifier: "node-a.hushnet.net" + pub node_id: String, + /// Base API URL the S2S client uses for outbound requests. + pub api_url: String, + /// Ed25519 verifying key (base64) for authenticating inbound S2S requests. + pub public_key_b64: String, + pub last_seen: Option>, + pub is_blocked: bool, + pub created_at: DateTime, +} + +// ─── Outbox entry ──────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct FederationOutboxEntry { + pub id: Uuid, + pub target_node_id: String, + pub logical_msg_id: String, + /// Verbatim JSON body to POST to /s2s/messages on the target node. + pub payload: Value, + pub attempt_count: i32, + pub last_attempt: Option>, + pub next_attempt: DateTime, + /// "pending" | "delivered" | "failed" + pub status: String, + pub created_at: DateTime, +} + +// ─── S2S wire types ────────────────────────────────────────────────────────── + +/// Body of POST /s2s/messages. +/// +/// Sent by Node A to Node B to deliver one logical message to a local user. +/// Each entry in `payloads` is encrypted specifically for one recipient device; +/// Node B stores each as an independent row in the messages table. +/// +/// `from_identity_pubkey` is included so Node B can upsert the shadow device +/// row (devices table) without requiring a round-trip back to Node A. Shadow +/// devices need a valid identity_pubkey but no actual prekey material. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sMessagePayload { + /// Shared across all device fanouts of this message. Used for idempotent + /// delivery: Node B rejects duplicates keyed on (logical_msg_id, to_device_id). + pub logical_msg_id: String, + /// "alice@node-a.hushnet.net" — used to upsert the shadow user on Node B. + pub from_federated_address: String, + /// UUID of the sending device, authoritative on Node A. + pub from_device_id: Uuid, + /// Ed25519 identity public key of the sending device (base64). + pub from_identity_pubkey: String, + /// Local username of the recipient on Node B. + pub to_user: String, + pub payloads: Vec, +} + +/// One ciphertext destined for a single recipient device. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sDevicePayload { + pub to_device_id: Uuid, + pub header: Value, + pub ciphertext: String, +} + +/// Body of POST /s2s/sessions. +/// +/// Sent by Node A to Node B to forward an X3DH session initiation. +/// Node B inserts the data into pending_sessions so the local recipient +/// sees it via GET /sessions/pending or the WebSocket stream. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sSessionPayload { + pub from_federated_address: String, + pub from_device_id: Uuid, + pub from_identity_pubkey: String, + /// Local username of the recipient on Node B. + pub to_user: String, + pub sessions_init: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct S2sSessionInit { + pub recipient_device_id: Uuid, + pub ephemeral_pubkey: String, + pub sender_prekey_pub: String, + pub otpk_used: String, + pub ciphertext: String, +} + +/// Body of POST /s2s/ack (Node B → Node A). +/// +/// Advisory: the outbox worker already marks entries delivered when it receives +/// a 2xx from forward_messages, so this ack is redundant in the happy path. +/// It exists as an explicit signal for cases where Node B wants to proactively +/// confirm delivery without waiting for Node A to poll. +#[derive(Debug, Serialize, Deserialize)] +pub struct S2sAck { + pub logical_msg_id: String, + /// "delivered" | "duplicate" + pub status: String, +} + +/// Response body for GET /s2s/info. +/// +/// Used by peers during bootstrapping to obtain this node's public key before +/// the registry has been consulted. The caller must still verify the returned +/// key against the registry to prevent a MITM from substituting its own key. +#[derive(Debug, Serialize, Deserialize)] +pub struct NodeInfo { + pub node_id: String, + pub api_url: String, + pub public_key_b64: String, + pub protocol_version: &'static str, +} diff --git a/src/models/message.rs b/src/models/message.rs index 2a09baf..995c349 100644 --- a/src/models/message.rs +++ b/src/models/message.rs @@ -29,11 +29,21 @@ pub struct OutgoingMessagePayload { } /// Represents the logical message (fan-out over multiple recipient devices). +/// +/// For local delivery, set `to_user_id`. +/// For cross-node delivery, also set `to_user_address` ("bob@node-b.hushnet.net"). +/// When `to_user_address` points to a remote node, `to_user_id` is ignored by +/// the server and the message is forwarded via S2S. Existing clients that do +/// not send `to_user_address` continue to work unchanged. #[derive(Debug, Deserialize)] pub struct OutgoingMessage { pub chat_id: Uuid, pub logical_msg_id: String, pub to_user_id: Uuid, + /// Optional federated address for cross-node delivery. + /// Format: "username@node-host" (e.g. "bob@node-b.hushnet.net"). + #[serde(default)] + pub to_user_address: Option, pub payloads: Vec, } diff --git a/src/models/mod.rs b/src/models/mod.rs index ad38a72..f168a06 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod chat; pub mod device; pub mod enrollment_token; +pub mod federation; pub mod message; pub mod realtime; pub mod session; diff --git a/src/repository/federation_repository.rs b/src/repository/federation_repository.rs new file mode 100644 index 0000000..0634337 --- /dev/null +++ b/src/repository/federation_repository.rs @@ -0,0 +1,324 @@ +use sqlx::PgPool; +use uuid::Uuid; + +use crate::models::federation::{FederationNode, FederationOutboxEntry}; + +// ─── federation_nodes ──────────────────────────────────────────────────────── + +/// Insert or update a peer node record. Called after a successful registry +/// lookup; the last_seen timestamp is refreshed on every upsert. +pub async fn upsert_federation_node( + pool: &PgPool, + node_id: &str, + api_url: &str, + public_key_b64: &str, +) -> Result { + sqlx::query_as!( + FederationNode, + r#" + INSERT INTO federation_nodes (node_id, api_url, public_key_b64) + VALUES ($1, $2, $3) + ON CONFLICT (node_id) DO UPDATE + SET api_url = EXCLUDED.api_url, + public_key_b64 = EXCLUDED.public_key_b64, + last_seen = NOW() + RETURNING id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at + "#, + node_id, + api_url, + public_key_b64, + ) + .fetch_one(pool) + .await +} + +pub async fn get_federation_node( + pool: &PgPool, + node_id: &str, +) -> Result, sqlx::Error> { + sqlx::query_as!( + FederationNode, + r#" + SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at + FROM federation_nodes + WHERE node_id = $1 + "#, + node_id, + ) + .fetch_optional(pool) + .await +} + +// ─── used_node_nonces ──────────────────────────────────────────────────────── + +/// Try to claim a (nonce, node_id) pair atomically. +/// +/// Returns true if the nonce was fresh (insert succeeded), false if it already +/// existed (replay detected). The INSERT ... ON CONFLICT DO NOTHING pattern is +/// safe under concurrent requests: at most one INSERT per (nonce, node_id) pair +/// can succeed within a single PostgreSQL transaction. +pub async fn claim_nonce( + pool: &PgPool, + node_id: &str, + nonce: &str, +) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO used_node_nonces (nonce, node_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + "#, + nonce, + node_id, + ) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + +/// Delete nonces older than 5 minutes. The acceptance window is 60 s, so any +/// nonce older than 5 minutes is guaranteed to be outside that window and will +/// never be re-accepted even if deleted. +pub async fn purge_expired_nonces(pool: &PgPool) -> Result { + let result = sqlx::query!( + "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'" + ) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +// ─── federation_outbox ─────────────────────────────────────────────────────── + +/// Enqueue a new outbound message for delivery to target_node_id. +/// Returns the UUID of the created outbox entry. +pub async fn enqueue_outbox( + pool: &PgPool, + target_node_id: &str, + logical_msg_id: &str, + payload: &serde_json::Value, +) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload) + VALUES ($1, $2, $3) + RETURNING id + "#, + target_node_id, + logical_msg_id, + payload, + ) + .fetch_one(pool) + .await?; + Ok(id) +} + +/// Fetch all pending entries whose next_attempt is now or past. +/// Capped at 100 per poll cycle to bound per-iteration latency. +pub async fn fetch_due_outbox_entries( + pool: &PgPool, +) -> Result, sqlx::Error> { + sqlx::query_as!( + FederationOutboxEntry, + r#" + SELECT id, target_node_id, logical_msg_id, payload, + attempt_count, last_attempt, next_attempt, status, created_at + FROM federation_outbox + WHERE status = 'pending' + AND next_attempt <= NOW() + ORDER BY next_attempt ASC + LIMIT 100 + "#, + ) + .fetch_all(pool) + .await +} + +pub async fn mark_outbox_delivered(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE id = $1", + id, + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Mark all pending entries for a logical_msg_id as delivered. +/// Called when Node B sends a POST /s2s/ack back to Node A. +pub async fn mark_outbox_delivered_by_logical_id( + pool: &PgPool, + logical_msg_id: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() + WHERE logical_msg_id = $1 AND status = 'pending'", + logical_msg_id, + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Advance the retry schedule for a failed delivery attempt. +/// +/// Backoff in seconds: 10 * 2^attempt_count, capped at 3600 (one hour). +/// After max_attempts the entry is permanently marked 'failed'. +pub async fn record_outbox_failure( + pool: &PgPool, + id: Uuid, + attempt_count: i32, + max_attempts: i32, +) -> Result<(), sqlx::Error> { + if attempt_count >= max_attempts { + sqlx::query!( + "UPDATE federation_outbox + SET status = 'failed', last_attempt = NOW(), attempt_count = $2 + WHERE id = $1", + id, + attempt_count, + ) + .execute(pool) + .await?; + } else { + let backoff_secs = (10_i64 * (1_i64 << attempt_count.min(12))).min(3600); + sqlx::query!( + r#" + UPDATE federation_outbox + SET attempt_count = $2, + last_attempt = NOW(), + next_attempt = NOW() + ($3 || ' seconds')::interval + WHERE id = $1 + "#, + id, + attempt_count, + backoff_secs.to_string(), + ) + .execute(pool) + .await?; + } + Ok(()) +} + +// ─── Shadow user / device creation ─────────────────────────────────────────── +// +// When this node (Node B) receives a message from a remote user (Alice on +// Node A), it needs valid rows in users and devices to satisfy the FK +// constraints on messages.from_user_id and messages.from_device_id. +// +// Shadow rows are identified by a non-NULL home_node_id. They are never +// returned by normal user lookup endpoints, and their devices are never +// queried for prekey material. + +/// Upsert a shadow user record for a remote user. +/// Conflict key is federated_address (globally unique). +/// Returns the local UUID of the (possibly newly created) shadow user. +pub async fn upsert_shadow_user( + pool: &PgPool, + username: &str, + federated_address: &str, + home_node_id: Uuid, +) -> Result { + let id = sqlx::query_scalar!( + r#" + INSERT INTO users (username, federated_address, home_node_id) + VALUES ($1, $2, $3) + ON CONFLICT (federated_address) DO UPDATE + SET username = EXCLUDED.username + RETURNING id + "#, + username, + federated_address, + home_node_id, + ) + .fetch_one(pool) + .await?; + Ok(id) +} + +/// Upsert a shadow device record for a remote device. +/// +/// The device UUID is reused from Node A (random UUID collision probability +/// is negligible across nodes). Prekey fields are stored as empty values +/// because shadow devices are never queried for key material; they exist +/// solely to satisfy FK constraints. +pub async fn upsert_shadow_device( + pool: &PgPool, + device_id: Uuid, + user_id: Uuid, + identity_pubkey: &str, +) -> Result<(), sqlx::Error> { + sqlx::query!( + r#" + INSERT INTO devices ( + id, user_id, identity_pubkey, + prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys + ) + VALUES ($1, $2, $3, '', '', '', '[]'::jsonb) + ON CONFLICT (id) DO NOTHING + "#, + device_id, + user_id, + identity_pubkey, + ) + .execute(pool) + .await?; + Ok(()) +} + +/// Find or create a direct chat between two users. +/// +/// Enforces the schema constraint user_a < user_b by sorting before insert. +/// The unique index on (LEAST(user_a,user_b), GREATEST(user_a,user_b)) prevents +/// duplicate chats regardless of the argument order. +pub async fn get_or_create_direct_chat( + pool: &PgPool, + user_x: Uuid, + user_y: Uuid, +) -> Result { + let (ua, ub) = if user_x < user_y { + (user_x, user_y) + } else { + (user_y, user_x) + }; + + if let Some(id) = sqlx::query_scalar!( + "SELECT id FROM chats WHERE user_a = $1 AND user_b = $2 AND chat_type = 'direct'", + ua, + ub, + ) + .fetch_optional(pool) + .await? + { + return Ok(id); + } + + let id = sqlx::query_scalar!( + r#" + INSERT INTO chats (user_a, user_b, chat_type) + VALUES ($1, $2, 'direct') + RETURNING id + "#, + ua, + ub, + ) + .fetch_one(pool) + .await?; + + Ok(id) +} + +/// Look up a local user (home_node_id IS NULL) by username. +/// Returns None if the user does not exist or is a shadow record. +pub async fn get_local_user_id_by_username( + pool: &PgPool, + username: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query!( + "SELECT id FROM users WHERE username = $1 AND home_node_id IS NULL", + username, + ) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.id)) +} diff --git a/src/repository/message_repository.rs b/src/repository/message_repository.rs index 7841ef1..7c0fd8c 100644 --- a/src/repository/message_repository.rs +++ b/src/repository/message_repository.rs @@ -5,6 +5,7 @@ use crate::{ use serde_json::Value; use sqlx::PgPool; use uuid::Uuid; + pub async fn insert_message( pool: &PgPool, from_device_id: Uuid, @@ -42,6 +43,50 @@ pub async fn insert_message( Ok(()) } +/// Insert a message that arrived via S2S forwarding from a peer node. +/// +/// Idempotent: if a row with the same (logical_msg_id, to_device_id) already +/// exists (duplicate delivery from outbox retry), the INSERT is skipped and +/// the function returns Ok(false). Returns Ok(true) when a new row is created. +/// +/// The unique constraint `uniq_message_per_device` (added in federation.sql) +/// makes the ON CONFLICT clause safe without a preceding SELECT. +pub async fn insert_federated_message( + pool: &PgPool, + logical_msg_id: &str, + chat_id: Uuid, + from_user_id: Uuid, + from_device_id: Uuid, + to_user_id: Uuid, + to_device_id: Uuid, + header: &Value, + ciphertext: &str, +) -> Result { + let result = sqlx::query!( + r#" + INSERT INTO messages ( + logical_msg_id, chat_id, + from_user_id, from_device_id, + to_user_id, to_device_id, + header, ciphertext + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING + "#, + logical_msg_id, + chat_id, + from_user_id, + from_device_id, + to_user_id, + to_device_id, + header, + ciphertext, + ) + .execute(pool) + .await?; + Ok(result.rows_affected() == 1) +} + pub async fn fetch_pending_messages( pool: &PgPool, AuthenticatedDevice(device): AuthenticatedDevice, diff --git a/src/repository/mod.rs b/src/repository/mod.rs index e848e7f..1e4ee80 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,6 +1,7 @@ pub mod chat_repository; pub mod device_repository; pub mod enrollment_token_repository; +pub mod federation_repository; pub mod keys_repository; pub mod message_repository; pub mod session_repository; diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 6210f02..252e83a 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -44,6 +44,21 @@ pub async fn find_user_by_pubkey(pool: &PgPool, pubkey_b64: &str) -> Result Result> { + let user = sqlx::query_as!( + User, + r#" + SELECT id, username, created_at + FROM users + WHERE username = $1 + "#, + username + ) + .fetch_optional(pool) + .await?; + Ok(user) +} + pub async fn find_user_by_id(pool: &PgPool, user_id: &uuid::Uuid) -> Result> { let user = sqlx::query_as!( User, diff --git a/src/routes/federation.rs b/src/routes/federation.rs new file mode 100644 index 0000000..468390f --- /dev/null +++ b/src/routes/federation.rs @@ -0,0 +1,39 @@ +use axum::{ + routing::{get, post}, + Router, +}; + +use crate::{app_state::AppState, controllers::federation_controller}; + +/// S2S routes (consumed by peer nodes, not by end clients). +/// +/// /s2s/info is intentionally unauthenticated: it is the bootstrap endpoint +/// that lets an unknown peer fetch this node's public key before they have a +/// cached record. All other /s2s/* routes require the AuthenticatedNode +/// extractor (Ed25519 signature verification + nonce claim). +/// +/// The client-facing federated proxy (GET /users/federated/:address/keys) +/// is included here for co-location but uses the normal AuthenticatedDevice +/// extractor, not AuthenticatedNode. +pub fn routes() -> Router { + Router::new() + // ── Public ────────────────────────────────────────────────────────── + .route("/s2s/info", get(federation_controller::node_info)) + // ── S2S (node-to-node, AuthenticatedNode required inside handler) ─── + .route( + "/s2s/users/{username}/devices", + get(federation_controller::get_user_devices), + ) + .route( + "/s2s/users/{username}/keys", + get(federation_controller::get_user_keys), + ) + .route("/s2s/sessions", post(federation_controller::receive_session)) + .route("/s2s/messages", post(federation_controller::receive_messages)) + .route("/s2s/ack", post(federation_controller::receive_ack)) + // ── Client-facing federated proxy ──────────────────────────────────── + .route( + "/users/federated/{address}/keys", + get(federation_controller::federated_keys), + ) +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 6ec01e9..d552cd1 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,5 +1,6 @@ pub mod chats; pub mod devices; +pub mod federation; pub mod messages; pub mod root; pub mod sessions; From 7bae624e2508ab09d3eb368114e73f07d3f98d74 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 15:49:42 +0200 Subject: [PATCH 02/23] feat(federation): refactor fetch_peer_keys and fetch_node_info for improved readability --- src/federation/client.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/federation/client.rs b/src/federation/client.rs index 88102de..5c69ef4 100644 --- a/src/federation/client.rs +++ b/src/federation/client.rs @@ -64,7 +64,11 @@ impl FederationClient { /// The returned Vec has one DeviceBundle per device registered for that /// user on the remote node. One-time prekeys are consumed by the remote /// node on fetch (same semantics as the local GET /users/:id/keys endpoint). - pub async fn fetch_peer_keys(&self, api_url: &str, username: &str) -> Result> { + pub async fn fetch_peer_keys( + &self, + api_url: &str, + username: &str, + ) -> Result> { let url = format!("{api_url}/s2s/users/{username}/keys"); self.signed_get(&url) .await? @@ -103,7 +107,8 @@ impl FederationClient { /// Query the peer's public node info (used for bootstrapping / key pinning). pub async fn fetch_node_info(&self, api_url: &str) -> Result { // /s2s/info does not require authentication, so this is an unsigned GET. - let body = self.http + let body = self + .http .get(format!("{api_url}/s2s/info")) .send() .await From 0ebaae084d2f7813bc4a21db2dcca1a5de6a1d60 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 15:52:16 +0200 Subject: [PATCH 03/23] feat(federation): clarify timestamp validation condition in S2S request flowchart --- docs/API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/API.md b/docs/API.md index 4bf89b7..2a67124 100644 --- a/docs/API.md +++ b/docs/API.md @@ -687,7 +687,7 @@ Only the path portion of the URL is signed (no scheme, no host), so the canonica ```mermaid flowchart TD - A[Inbound S2S request] --> B{|now − timestamp| ≤ 60s?} + A[Inbound S2S request] --> B{"abs(now - timestamp) <= 60s?"} B -- no --> R1[401 timestamp out of window] B -- yes --> C{node_id in federation_nodes?} C -- no --> D[GET registry/nodes/node_id] From 58bccd74c1190407ad3b213cfacd70022c6dd30e Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 16:38:29 +0200 Subject: [PATCH 04/23] feat(database): add SQL queries for federation and user management --- ...5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json | 23 +++++++ ...ac1d7479e399cb4d5bf96f47d607c9f158295.json | 15 ++++ ...c1dc9207dd104762ec355e4d92df8d8aa90c3.json | 24 +++++++ ...3eb095234eec88131b9ff82010f1cc2ba6bef.json | 58 ++++++++++++++++ ...4ab9fccfaf4c4376d8091bcddb58b16b15c5e.json | 16 +++++ ...effb919803cabf97c794ae26000642edb0d9b.json | 24 +++++++ ...9c2170076d0aac620a6c8e2735ea0c6177648.json | 21 ++++++ ...d4653182d9daefe8bbea58737ba841de7f334.json | 16 +++++ ...239f99af495c156fd533431d3d994f8bf4196.json | 23 +++++++ ...a1cf581b2be68cc0ae8d33f3479d655af0aa4.json | 68 +++++++++++++++++++ ...8c31e1b2e62ee61ded3a5e59eb2f78807413a.json | 14 ++++ ...eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json | 12 ++++ ...daf34084aaf4805b88c7cbf88ec2f28e5cd45.json | 14 ++++ ...2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json | 60 ++++++++++++++++ ...fd30227624d0322949b690aa56cc8846407f3.json | 15 ++++ ...a47e963acf5471962f4cc283b202e1c045a7d.json | 34 ++++++++++ ...a6b299c775030e0c50f5305684e205e8a4d8e.json | 22 ++++++ 17 files changed, 459 insertions(+) create mode 100644 .sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json create mode 100644 .sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json create mode 100644 .sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json create mode 100644 .sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json create mode 100644 .sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json create mode 100644 .sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json create mode 100644 .sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json create mode 100644 .sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json create mode 100644 .sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json create mode 100644 .sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json create mode 100644 .sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json create mode 100644 .sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json create mode 100644 .sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json create mode 100644 .sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json create mode 100644 .sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json create mode 100644 .sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json create mode 100644 .sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json diff --git a/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json b/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json new file mode 100644 index 0000000..4e04744 --- /dev/null +++ b/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM chats WHERE user_a = $1 AND user_b = $2 AND chat_type = 'direct'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6" +} diff --git a/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json b/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json new file mode 100644 index 0000000..8c220a6 --- /dev/null +++ b/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO used_node_nonces (nonce, node_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295" +} diff --git a/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json b/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json new file mode 100644 index 0000000..a0c152d --- /dev/null +++ b/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload)\n VALUES ($1, $2, $3)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3" +} diff --git a/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json b/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json new file mode 100644 index 0000000..e4405b5 --- /dev/null +++ b/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at\n FROM federation_nodes\n WHERE node_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "node_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "api_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "public_key_b64", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "last_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "is_blocked", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef" +} diff --git a/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json b/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json new file mode 100644 index 0000000..ed108ca --- /dev/null +++ b/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO devices (\n id, user_id, identity_pubkey,\n prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys\n )\n VALUES ($1, $2, $3, '', '', '', '[]'::jsonb)\n ON CONFLICT (id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e" +} diff --git a/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json b/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json new file mode 100644 index 0000000..c2630b3 --- /dev/null +++ b/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (username, federated_address, home_node_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (federated_address) DO UPDATE\n SET username = EXCLUDED.username\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b" +} diff --git a/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json b/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json new file mode 100644 index 0000000..a07b1f3 --- /dev/null +++ b/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO messages (\n logical_msg_id, chat_id,\n from_user_id, from_device_id,\n to_user_id, to_device_id,\n header, ciphertext\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Uuid", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648" +} diff --git a/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json b/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json new file mode 100644 index 0000000..e833664 --- /dev/null +++ b/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE federation_outbox\n SET attempt_count = $2,\n last_attempt = NOW(),\n next_attempt = NOW() + ($3 || ' seconds')::interval\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334" +} diff --git a/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json b/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json new file mode 100644 index 0000000..30c6039 --- /dev/null +++ b/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO chats (user_a, user_b, chat_type)\n VALUES ($1, $2, 'direct')\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196" +} diff --git a/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json b/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json new file mode 100644 index 0000000..2bf994f --- /dev/null +++ b/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, target_node_id, logical_msg_id, payload,\n attempt_count, last_attempt, next_attempt, status, created_at\n FROM federation_outbox\n WHERE status = 'pending'\n AND next_attempt <= NOW()\n ORDER BY next_attempt ASC\n LIMIT 100\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "target_node_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "logical_msg_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "payload", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "attempt_count", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "next_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4" +} diff --git a/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json b/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json new file mode 100644 index 0000000..a4d326b --- /dev/null +++ b/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a" +} diff --git a/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json b/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json new file mode 100644 index 0000000..0429e31 --- /dev/null +++ b/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f" +} diff --git a/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json b/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json new file mode 100644 index 0000000..8c26681 --- /dev/null +++ b/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW()\n WHERE logical_msg_id = $1 AND status = 'pending'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45" +} diff --git a/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json b/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json new file mode 100644 index 0000000..f28befa --- /dev/null +++ b/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO federation_nodes (node_id, api_url, public_key_b64)\n VALUES ($1, $2, $3)\n ON CONFLICT (node_id) DO UPDATE\n SET api_url = EXCLUDED.api_url,\n public_key_b64 = EXCLUDED.public_key_b64,\n last_seen = NOW()\n RETURNING id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "node_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "api_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "public_key_b64", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "last_seen", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "is_blocked", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27" +} diff --git a/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json b/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json new file mode 100644 index 0000000..db1c17e --- /dev/null +++ b/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE federation_outbox\n SET status = 'failed', last_attempt = NOW(), attempt_count = $2\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3" +} diff --git a/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json b/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json new file mode 100644 index 0000000..52206f5 --- /dev/null +++ b/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, username, created_at\n FROM users\n WHERE username = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d" +} diff --git a/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json b/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json new file mode 100644 index 0000000..8e1b7a8 --- /dev/null +++ b/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE username = $1 AND home_node_id IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e" +} From 6240daa9feb352cfea954dd6509820747b0c16c3 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 16:41:34 +0200 Subject: [PATCH 05/23] feat(docker): remove container names for postgres and backend services --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 8142498..9600cd4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,6 @@ services: # PostgreSQL Database postgres: image: postgres:17-alpine - container_name: hushnet-postgres restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-postgres} @@ -28,7 +27,6 @@ services: build: context: . dockerfile: Dockerfile - container_name: hushnet-backend restart: unless-stopped environment: DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-dev}@postgres:5432/${POSTGRES_DB:-e2ee} From 1f45c7d81f2c74490d9fbf9cc6184e0257f75aa0 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 16:42:35 +0200 Subject: [PATCH 06/23] feat(docker): update backend port configuration and healthcheck URL --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9600cd4..2ae27c0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,14 +38,14 @@ services: env_file: - .env ports: - - "${BACKEND_PORT:-8080}:8080" + - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" depends_on: postgres: condition: service_healthy networks: - hushnet healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/"] + test: ["CMD", "curl", "-f", "http://localhost:${SERVER_PORT:-8080}/"] interval: 30s timeout: 3s retries: 3 From a8db8c560783392ab96ebbf56562ef2c03ea652b Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 17:00:02 +0200 Subject: [PATCH 07/23] feat(docker): add environment variables for node configuration and registry settings --- docker-compose.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 2ae27c0..181876b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,12 @@ services: SERVER_HOST: ${SERVER_HOST:-0.0.0.0} SERVER_PORT: ${SERVER_PORT:-8080} RUST_BACKTRACE: "full" + NODE_NAME: ${NODE_NAME:-node-eu1} + NODE_HOST: ${NODE_HOST:-host.docker.internal} + NODE_API_URL: ${NODE_API_URL:-http://host.docker.internal:8080} + REGISTRY_URL: ${REGISTRY_URL:-https://registry.hushnet.net/} + CONTACT_EMAIL: ${CONTACT_EMAIL:-ops@hushnet.net} + REGISTER_TO_REGISTRY: ${REGISTER_TO_REGISTRY:-true} env_file: - .env ports: From 7cb3c067d101c4705f627a20e77c5d8a4c9e39cf Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:03:34 +0200 Subject: [PATCH 08/23] feat(database): refactor SQL queries to use bind parameters for improved readability and maintainability --- src/controllers/federation_controller.rs | 418 ++++++++++++----------- src/repository/federation_repository.rs | 228 +++++-------- src/repository/message_repository.rs | 36 +- src/repository/user_repository.rs | 11 +- 4 files changed, 310 insertions(+), 383 deletions(-) diff --git a/src/controllers/federation_controller.rs b/src/controllers/federation_controller.rs index a359e97..28efcd8 100644 --- a/src/controllers/federation_controller.rs +++ b/src/controllers/federation_controller.rs @@ -1,25 +1,3 @@ -// src/controllers/federation_controller.rs -// -// Handlers for the /s2s/* endpoint group. -// -// All handlers except node_info require the AuthenticatedNode extractor, which -// verifies the peer's Ed25519 signature before the handler body runs. Handlers -// receive the authenticated peer's FederationNode as a typed argument and can -// use it for logging or for constructing shadow records. -// -// Endpoint overview: -// -// GET /s2s/info — public, no auth -// GET /s2s/users/:username/devices — return device list (auth required) -// GET /s2s/users/:username/keys — return prekey bundle, consume OTPK (auth) -// POST /s2s/sessions — accept forwarded X3DH init (auth) -// POST /s2s/messages — accept forwarded ciphertexts (auth) -// POST /s2s/ack — delivery acknowledgment (auth) -// -// Plus one client-facing federated lookup: -// -// GET /users/federated/:address/keys — proxy prekey bundle from remote node - use axum::{ extract::{Path, State}, http::StatusCode, @@ -27,6 +5,7 @@ use axum::{ Json, }; use serde_json::json; +use tracing::{debug, error, info, warn}; use crate::{ app_state::AppState, @@ -40,13 +19,8 @@ use crate::{ // ─── GET /s2s/info ─────────────────────────────────────────────────────────── -/// Return this node's public identity. -/// -/// No authentication required. Peers call this during bootstrapping to obtain -/// the public key before they have a cached entry for this node. The caller -/// should cross-check the returned key against the central registry to guard -/// against a MITM substituting a different key. pub async fn node_info(State(state): State) -> impl IntoResponse { + info!(node_id = %state.this_node_id, "GET /s2s/info"); let info = NodeInfo { node_id: state.this_node_id.clone(), api_url: state.this_api_url.clone(), @@ -58,41 +32,44 @@ pub async fn node_info(State(state): State) -> impl IntoResponse { // ─── GET /s2s/users/:username/devices ──────────────────────────────────────── -/// Return the device list for a local user. -/// -/// Used by a peer to enumerate recipient devices before building per-device -/// encrypted payloads. Only local (non-shadow) users are served; requests for -/// shadow users (home_node_id IS NOT NULL) return 404. pub async fn get_user_devices( State(state): State, - AuthenticatedNode(_peer): AuthenticatedNode, + AuthenticatedNode(peer): AuthenticatedNode, Path(username): Path, ) -> impl IntoResponse { - let user_id = match federation_repository::get_local_user_id_by_username(&state.pool, &username) - .await - { - Ok(Some(id)) => id, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found or not local to this node"})), - ) - .into_response() - } - Err(e) => { - eprintln!("[s2s] db error in get_user_devices: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal error"})), - ) - .into_response(); - } - }; + info!(peer = %peer.node_id, %username, "GET /s2s/users/:username/devices"); + + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, &username).await { + Ok(Some(id)) => { + debug!(%username, %id, "local user found"); + id + } + Ok(None) => { + warn!(%username, "user not found or is a shadow record"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(%username, err = %e, "db error resolving user"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; match device_repository::get_devices_by_user_id(&state.pool, &user_id).await { - Ok(devices) => (StatusCode::OK, Json(devices)).into_response(), + Ok(devices) => { + debug!(%username, count = devices.len(), "returning devices"); + (StatusCode::OK, Json(devices)).into_response() + } Err(e) => { - eprintln!("[s2s] db error fetching devices: {e}"); + error!(%username, err = %e, "db error fetching devices"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -104,47 +81,44 @@ pub async fn get_user_devices( // ─── GET /s2s/users/:username/keys ─────────────────────────────────────────── -/// Return the prekey bundle for a local user, consuming one OTPK per device. -/// -/// Semantics are identical to GET /users/:id/keys on the client API. The -/// caller (Node A) receives the bundles, passes them to its client (Client A), -/// who uses them for X3DH key agreement without the servers ever seeing the -/// resulting shared secret. -/// -/// If OTPKs are exhausted, the bundle is still returned (with an empty -/// one_time_prekeys list). The caller signals this to its client via the -/// `otpk_available` flag so the client can decide whether to proceed with -/// SPK-only X3DH or wait for replenishment. pub async fn get_user_keys( State(state): State, - AuthenticatedNode(_peer): AuthenticatedNode, + AuthenticatedNode(peer): AuthenticatedNode, Path(username): Path, ) -> impl IntoResponse { - let user_id = match federation_repository::get_local_user_id_by_username(&state.pool, &username) - .await - { - Ok(Some(id)) => id, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "user not found or not local to this node"})), - ) - .into_response() - } - Err(e) => { - eprintln!("[s2s] db error in get_user_keys: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal error"})), - ) - .into_response(); - } - }; + info!(peer = %peer.node_id, %username, "GET /s2s/users/:username/keys"); + + let user_id = + match federation_repository::get_local_user_id_by_username(&state.pool, &username).await { + Ok(Some(id)) => { + debug!(%username, %id, "local user found for key fetch"); + id + } + Ok(None) => { + warn!(%username, "user not found or is a shadow record (key fetch)"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "user not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(%username, err = %e, "db error resolving user for key fetch"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; match device_repository::get_device_bundle(&state.pool, &user_id).await { - Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Ok(bundle) => { + debug!(%username, devices = bundle.len(), "returning key bundle"); + (StatusCode::OK, Json(bundle)).into_response() + } Err(e) => { - eprintln!("[s2s] db error fetching key bundle: {e}"); + error!(%username, err = %e, "db error fetching key bundle"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -156,36 +130,39 @@ pub async fn get_user_keys( // ─── POST /s2s/sessions ────────────────────────────────────────────────────── -/// Accept a forwarded X3DH session initiation from a peer node. -/// -/// Inserts the pending session(s) into this node's pending_sessions table. -/// The PostgreSQL trigger notify_new_pending_session fires automatically, -/// delivering a WebSocket event to the recipient client — the existing -/// real-time path requires no changes. -/// -/// Shadow records for the sender (user + device) are upserted if they do not -/// already exist so that the FK constraints on pending_sessions are satisfied. pub async fn receive_session( State(state): State, AuthenticatedNode(peer): AuthenticatedNode, Json(payload): Json, ) -> impl IntoResponse { - // Upsert shadow user for the remote sender. + info!( + peer = %peer.node_id, + from = %payload.from_federated_address, + to = %payload.to_user, + sessions = payload.sessions_init.len(), + "POST /s2s/sessions" + ); + + let sender_username = payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"); + let sender_local_id = match federation_repository::upsert_shadow_user( &state.pool, - payload - .from_federated_address - .split('@') - .next() - .unwrap_or("unknown"), + sender_username, &payload.from_federated_address, peer.id, ) .await { - Ok(id) => id, + Ok(id) => { + debug!(federated = %payload.from_federated_address, local_id = %id, "shadow user upserted"); + id + } Err(e) => { - eprintln!("[s2s] shadow user upsert failed: {e}"); + error!(err = %e, "shadow user upsert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -202,7 +179,7 @@ pub async fn receive_session( ) .await { - eprintln!("[s2s] shadow device upsert failed: {e}"); + error!(device_id = %payload.from_device_id, err = %e, "shadow device upsert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -210,8 +187,8 @@ pub async fn receive_session( .into_response(); } - // Insert one pending_session row per recipient device. for init in &payload.sessions_init { + debug!(recipient_device = %init.recipient_device_id, "inserting pending session"); if let Err(e) = session_repository::create_pending_session( &state.pool, &payload.from_device_id, @@ -223,7 +200,7 @@ pub async fn receive_session( ) .await { - eprintln!("[s2s] pending session insert failed: {e}"); + error!(recipient_device = %init.recipient_device_id, err = %e, "pending session insert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "failed to store pending session"})), @@ -232,67 +209,74 @@ pub async fn receive_session( } } + info!(from = %payload.from_federated_address, to = %payload.to_user, "sessions stored ok"); (StatusCode::OK, Json(json!({"status": "ok"}))).into_response() } // ─── POST /s2s/messages ────────────────────────────────────────────────────── -/// Accept forwarded ciphertexts for a local recipient. -/// -/// For each device payload: -/// - Deduplication check via the unique constraint (logical_msg_id, to_device_id). -/// Duplicate payloads (from outbox retries) are silently skipped; the 200 -/// response is returned regardless so the sender stops retrying. -/// - The PostgreSQL trigger notify_new_message fires on every genuine insert, -/// pushing a WebSocket event to the recipient. No changes to the real-time -/// path are needed. -/// -/// The returned S2sAck.status is "delivered" if at least one new row was -/// inserted, "duplicate" if all payloads were already present. pub async fn receive_messages( State(state): State, AuthenticatedNode(peer): AuthenticatedNode, Json(payload): Json, ) -> impl IntoResponse { - // Resolve the local recipient. - let recipient_id = - match federation_repository::get_local_user_id_by_username(&state.pool, &payload.to_user) - .await - { - Ok(Some(id)) => id, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "recipient not found or not local to this node"})), - ) - .into_response() - } - Err(e) => { - eprintln!("[s2s] db error resolving recipient: {e}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "internal error"})), - ) - .into_response(); - } - }; + info!( + peer = %peer.node_id, + logical_id = %payload.logical_msg_id, + from = %payload.from_federated_address, + to_user = %payload.to_user, + device_count = payload.payloads.len(), + "POST /s2s/messages" + ); + + let recipient_id = match federation_repository::get_local_user_id_by_username( + &state.pool, + &payload.to_user, + ) + .await + { + Ok(Some(id)) => { + debug!(username = %payload.to_user, local_id = %id, "recipient resolved"); + id + } + Ok(None) => { + warn!(username = %payload.to_user, "recipient not found or is a shadow record"); + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "recipient not found or not local to this node"})), + ) + .into_response(); + } + Err(e) => { + error!(username = %payload.to_user, err = %e, "db error resolving recipient"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal error"})), + ) + .into_response(); + } + }; + + let sender_username = payload + .from_federated_address + .split('@') + .next() + .unwrap_or("unknown"); - // Upsert shadow records for the remote sender. let sender_local_id = match federation_repository::upsert_shadow_user( &state.pool, - payload - .from_federated_address - .split('@') - .next() - .unwrap_or("unknown"), + sender_username, &payload.from_federated_address, peer.id, ) .await { - Ok(id) => id, + Ok(id) => { + debug!(federated = %payload.from_federated_address, local_id = %id, "shadow user upserted"); + id + } Err(e) => { - eprintln!("[s2s] shadow user upsert failed: {e}"); + error!(err = %e, "shadow user upsert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -309,7 +293,7 @@ pub async fn receive_messages( ) .await { - eprintln!("[s2s] shadow device upsert failed: {e}"); + error!(device_id = %payload.from_device_id, err = %e, "shadow device upsert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -317,7 +301,6 @@ pub async fn receive_messages( .into_response(); } - // Find or create the local chat between shadow-sender and local-recipient. let chat_id = match federation_repository::get_or_create_direct_chat( &state.pool, sender_local_id, @@ -325,9 +308,12 @@ pub async fn receive_messages( ) .await { - Ok(id) => id, + Ok(id) => { + debug!(chat_id = %id, "chat resolved"); + id + } Err(e) => { - eprintln!("[s2s] get_or_create_direct_chat failed: {e}"); + error!(err = %e, "get_or_create_direct_chat failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -336,9 +322,9 @@ pub async fn receive_messages( } }; - // Insert each per-device ciphertext, skipping duplicates. let mut any_new = false; for dev in &payload.payloads { + debug!(to_device = %dev.to_device_id, "inserting device payload"); match message_repository::insert_federated_message( &state.pool, &payload.logical_msg_id, @@ -352,10 +338,15 @@ pub async fn receive_messages( ) .await { - Ok(true) => any_new = true, - Ok(false) => {} // duplicate, silently skip + Ok(true) => { + debug!(to_device = %dev.to_device_id, "message inserted"); + any_new = true; + } + Ok(false) => { + debug!(to_device = %dev.to_device_id, "duplicate, skipped"); + } Err(e) => { - eprintln!("[s2s] message insert failed: {e}"); + error!(to_device = %dev.to_device_id, err = %e, "message insert failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -365,37 +356,30 @@ pub async fn receive_messages( } } + let status = if any_new { "delivered" } else { "duplicate" }; + info!(logical_id = %payload.logical_msg_id, %status, "messages processed"); + let ack = S2sAck { logical_msg_id: payload.logical_msg_id, - status: if any_new { - "delivered".into() - } else { - "duplicate".into() - }, + status: status.into(), }; (StatusCode::OK, Json(ack)).into_response() } // ─── POST /s2s/ack ─────────────────────────────────────────────────────────── -/// Receive a delivery acknowledgment from a peer. -/// -/// The outbox worker already marks entries delivered when it gets a 2xx from -/// forward_messages, so this endpoint is not on the critical path. It exists -/// for peers that want to proactively signal delivery (e.g. after a delayed -/// WebSocket push) and for future monitoring use cases. pub async fn receive_ack( State(state): State, - AuthenticatedNode(_peer): AuthenticatedNode, + AuthenticatedNode(peer): AuthenticatedNode, Json(ack): Json, ) -> impl IntoResponse { - if let Err(e) = federation_repository::mark_outbox_delivered_by_logical_id( - &state.pool, - &ack.logical_msg_id, - ) - .await + info!(peer = %peer.node_id, logical_id = %ack.logical_msg_id, status = %ack.status, "POST /s2s/ack"); + + if let Err(e) = + federation_repository::mark_outbox_delivered_by_logical_id(&state.pool, &ack.logical_msg_id) + .await { - eprintln!("[s2s] ack db error: {e}"); + error!(logical_id = %ack.logical_msg_id, err = %e, "ack db update failed"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -407,49 +391,45 @@ pub async fn receive_ack( // ─── GET /users/federated/:address/keys ────────────────────────────────────── -/// Client-facing proxy: fetch the prekey bundle of a remote user. -/// -/// `address` is the full federated address: "bob@node-b.hushnet.net". -/// -/// This node (Node A) authenticates the request from Client A, resolves the -/// target node, makes an authenticated S2S call to Node B, and returns the -/// bundle verbatim. OTPKs are consumed on Node B; Node A never stores them. -/// -/// If the target node's address matches this node's own node_id, the request -/// is redirected to the local GET /users/:id/keys path instead (handled in the -/// same response to avoid a network round-trip). pub async fn federated_keys( State(state): State, crate::middlewares::auth::AuthenticatedDevice(_device): crate::middlewares::auth::AuthenticatedDevice, Path(address): Path, ) -> impl IntoResponse { + info!(%address, "GET /users/federated/:address/keys"); + let (username, node_id) = match parse_federated_address(&address) { Some(parts) => parts, None => { + warn!(%address, "invalid federated address (no '@')"); return ( StatusCode::BAD_REQUEST, Json(json!({"error": "invalid federated address, expected user@node"})), ) - .into_response() + .into_response(); } }; - // If the address points to this node, serve locally. + debug!(%username, %node_id, "parsed federated address"); + + // Local shortcut: address points to this node. if node_id == state.this_node_id { + debug!(%username, "address is local, serving directly"); let user_id = match federation_repository::get_local_user_id_by_username(&state.pool, username) .await { Ok(Some(id)) => id, Ok(None) => { + warn!(%username, "local user not found"); return ( StatusCode::NOT_FOUND, Json(json!({"error": "user not found"})), ) - .into_response() + .into_response(); } Err(e) => { - eprintln!("[federated_keys] local db error: {e}"); + error!(%username, err = %e, "db error on local key fetch"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -458,9 +438,12 @@ pub async fn federated_keys( } }; return match device_repository::get_device_bundle(&state.pool, &user_id).await { - Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Ok(bundle) => { + debug!(%username, devices = bundle.len(), "local bundle returned"); + (StatusCode::OK, Json(bundle)).into_response() + } Err(e) => { - eprintln!("[federated_keys] local bundle error: {e}"); + error!(%username, err = %e, "db error fetching local bundle"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -470,29 +453,32 @@ pub async fn federated_keys( }; } - // Remote node: look up in federation_nodes or fall back to registry. + // Remote: resolve the target node. + debug!(%node_id, "resolving remote node"); let node = match federation_repository::get_federation_node(&state.pool, node_id).await { - Ok(Some(n)) => n, + Ok(Some(n)) => { + debug!(%node_id, api_url = %n.api_url, "node found in local cache"); + n + } Ok(None) => { - // Try to discover via registry. + info!(%node_id, "node not in cache, querying registry"); let url = format!("{}/api/registry/nodes/{}", state.registry_url, node_id); + debug!(registry_url = %url, "registry lookup"); match state.http_client.get(&url).send().await { Ok(resp) if resp.status().is_success() => { match resp.json::().await { Ok(body) => { let api_url = body["api_url"].as_str().unwrap_or(""); let pubkey = body["public_key_b64"].as_str().unwrap_or(""); + debug!(%node_id, %api_url, "registry returned node info"); match federation_repository::upsert_federation_node( - &state.pool, - node_id, - api_url, - pubkey, + &state.pool, node_id, api_url, pubkey, ) .await { Ok(n) => n, Err(e) => { - eprintln!("[federated_keys] upsert node failed: {e}"); + error!(%node_id, err = %e, "failed to cache node from registry"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -501,7 +487,8 @@ pub async fn federated_keys( } } } - Err(_) => { + Err(e) => { + error!(%node_id, err = %e, "malformed registry response"); return ( StatusCode::BAD_GATEWAY, Json(json!({"error": "malformed registry response"})), @@ -510,17 +497,26 @@ pub async fn federated_keys( } } } - _ => { + Ok(resp) => { + warn!(%node_id, status = %resp.status(), "registry returned non-200"); return ( StatusCode::NOT_FOUND, Json(json!({"error": "target node not found in registry"})), ) .into_response(); } + Err(e) => { + error!(%node_id, err = %e, "registry request failed"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({"error": "registry unreachable"})), + ) + .into_response(); + } } } Err(e) => { - eprintln!("[federated_keys] db error: {e}"); + error!(%node_id, err = %e, "db error looking up federation node"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "internal error"})), @@ -530,6 +526,7 @@ pub async fn federated_keys( }; if node.is_blocked { + warn!(%node_id, "node is blocked"); return ( StatusCode::FORBIDDEN, Json(json!({"error": "target node is blocked"})), @@ -537,6 +534,8 @@ pub async fn federated_keys( .into_response(); } + info!(%node_id, api_url = %node.api_url, %username, "proxying key fetch to remote node"); + let fed_client = FederationClient::new( state.http_client.clone(), state.node_keys.clone(), @@ -544,9 +543,12 @@ pub async fn federated_keys( ); match fed_client.fetch_peer_keys(&node.api_url, username).await { - Ok(bundle) => (StatusCode::OK, Json(bundle)).into_response(), + Ok(bundle) => { + info!(%node_id, %username, devices = bundle.len(), "remote key fetch succeeded"); + (StatusCode::OK, Json(bundle)).into_response() + } Err(e) => { - eprintln!("[federated_keys] peer key fetch failed: {e}"); + error!(%node_id, %username, err = %e, "remote key fetch failed"); ( StatusCode::BAD_GATEWAY, Json(json!({"error": format!("peer returned error: {e}")})), diff --git a/src/repository/federation_repository.rs b/src/repository/federation_repository.rs index 0634337..6636f98 100644 --- a/src/repository/federation_repository.rs +++ b/src/repository/federation_repository.rs @@ -3,18 +3,18 @@ use uuid::Uuid; use crate::models::federation::{FederationNode, FederationOutboxEntry}; +// Non-macro sqlx throughout: avoids compile-time DATABASE_URL requirement and +// the need to run `cargo sqlx prepare` every time a query changes. + // ─── federation_nodes ──────────────────────────────────────────────────────── -/// Insert or update a peer node record. Called after a successful registry -/// lookup; the last_seen timestamp is refreshed on every upsert. pub async fn upsert_federation_node( pool: &PgPool, node_id: &str, api_url: &str, public_key_b64: &str, ) -> Result { - sqlx::query_as!( - FederationNode, + sqlx::query_as::<_, FederationNode>( r#" INSERT INTO federation_nodes (node_id, api_url, public_key_b64) VALUES ($1, $2, $3) @@ -24,10 +24,10 @@ pub async fn upsert_federation_node( last_seen = NOW() RETURNING id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at "#, - node_id, - api_url, - public_key_b64, ) + .bind(node_id) + .bind(api_url) + .bind(public_key_b64) .fetch_one(pool) .await } @@ -36,52 +36,36 @@ pub async fn get_federation_node( pool: &PgPool, node_id: &str, ) -> Result, sqlx::Error> { - sqlx::query_as!( - FederationNode, - r#" - SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at - FROM federation_nodes - WHERE node_id = $1 - "#, - node_id, + sqlx::query_as::<_, FederationNode>( + "SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at + FROM federation_nodes WHERE node_id = $1", ) + .bind(node_id) .fetch_optional(pool) .await } // ─── used_node_nonces ──────────────────────────────────────────────────────── -/// Try to claim a (nonce, node_id) pair atomically. -/// -/// Returns true if the nonce was fresh (insert succeeded), false if it already -/// existed (replay detected). The INSERT ... ON CONFLICT DO NOTHING pattern is -/// safe under concurrent requests: at most one INSERT per (nonce, node_id) pair -/// can succeed within a single PostgreSQL transaction. +/// Returns true if the nonce was fresh (not seen before), false on replay. pub async fn claim_nonce( pool: &PgPool, node_id: &str, nonce: &str, ) -> Result { - let result = sqlx::query!( - r#" - INSERT INTO used_node_nonces (nonce, node_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING - "#, - nonce, - node_id, + let result = sqlx::query( + "INSERT INTO used_node_nonces (nonce, node_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) + .bind(nonce) + .bind(node_id) .execute(pool) .await?; Ok(result.rows_affected() == 1) } -/// Delete nonces older than 5 minutes. The acceptance window is 60 s, so any -/// nonce older than 5 minutes is guaranteed to be outside that window and will -/// never be re-accepted even if deleted. pub async fn purge_expired_nonces(pool: &PgPool) -> Result { - let result = sqlx::query!( - "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'" + let result = sqlx::query( + "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'", ) .execute(pool) .await?; @@ -90,80 +74,64 @@ pub async fn purge_expired_nonces(pool: &PgPool) -> Result { // ─── federation_outbox ─────────────────────────────────────────────────────── -/// Enqueue a new outbound message for delivery to target_node_id. -/// Returns the UUID of the created outbox entry. pub async fn enqueue_outbox( pool: &PgPool, target_node_id: &str, logical_msg_id: &str, payload: &serde_json::Value, ) -> Result { - let id = sqlx::query_scalar!( - r#" - INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload) - VALUES ($1, $2, $3) - RETURNING id - "#, - target_node_id, - logical_msg_id, - payload, + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload) + VALUES ($1, $2, $3) RETURNING id", ) + .bind(target_node_id) + .bind(logical_msg_id) + .bind(payload) .fetch_one(pool) .await?; - Ok(id) + Ok(row.0) } -/// Fetch all pending entries whose next_attempt is now or past. -/// Capped at 100 per poll cycle to bound per-iteration latency. pub async fn fetch_due_outbox_entries( pool: &PgPool, ) -> Result, sqlx::Error> { - sqlx::query_as!( - FederationOutboxEntry, - r#" - SELECT id, target_node_id, logical_msg_id, payload, - attempt_count, last_attempt, next_attempt, status, created_at - FROM federation_outbox - WHERE status = 'pending' - AND next_attempt <= NOW() - ORDER BY next_attempt ASC - LIMIT 100 - "#, + sqlx::query_as::<_, FederationOutboxEntry>( + "SELECT id, target_node_id, logical_msg_id, payload, + attempt_count, last_attempt, next_attempt, status, created_at + FROM federation_outbox + WHERE status = 'pending' AND next_attempt <= NOW() + ORDER BY next_attempt ASC LIMIT 100", ) .fetch_all(pool) .await } pub async fn mark_outbox_delivered(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query!( + sqlx::query( "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE id = $1", - id, ) + .bind(id) .execute(pool) .await?; Ok(()) } -/// Mark all pending entries for a logical_msg_id as delivered. -/// Called when Node B sends a POST /s2s/ack back to Node A. pub async fn mark_outbox_delivered_by_logical_id( pool: &PgPool, logical_msg_id: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( + sqlx::query( "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE logical_msg_id = $1 AND status = 'pending'", - logical_msg_id, ) + .bind(logical_msg_id) .execute(pool) .await?; Ok(()) } -/// Advance the retry schedule for a failed delivery attempt. -/// -/// Backoff in seconds: 10 * 2^attempt_count, capped at 3600 (one hour). -/// After max_attempts the entry is permanently marked 'failed'. +/// Exponential backoff: 10s * 2^attempt, capped at 3600s. +/// Marks 'failed' after max_attempts. pub async fn record_outbox_failure( pool: &PgPool, id: Uuid, @@ -171,106 +139,75 @@ pub async fn record_outbox_failure( max_attempts: i32, ) -> Result<(), sqlx::Error> { if attempt_count >= max_attempts { - sqlx::query!( + sqlx::query( "UPDATE federation_outbox SET status = 'failed', last_attempt = NOW(), attempt_count = $2 WHERE id = $1", - id, - attempt_count, ) + .bind(id) + .bind(attempt_count) .execute(pool) .await?; } else { let backoff_secs = (10_i64 * (1_i64 << attempt_count.min(12))).min(3600); - sqlx::query!( - r#" - UPDATE federation_outbox - SET attempt_count = $2, - last_attempt = NOW(), - next_attempt = NOW() + ($3 || ' seconds')::interval - WHERE id = $1 - "#, - id, - attempt_count, - backoff_secs.to_string(), + sqlx::query( + "UPDATE federation_outbox + SET attempt_count = $2, + last_attempt = NOW(), + next_attempt = NOW() + ($3 || ' seconds')::interval + WHERE id = $1", ) + .bind(id) + .bind(attempt_count) + .bind(backoff_secs.to_string()) .execute(pool) .await?; } Ok(()) } -// ─── Shadow user / device creation ─────────────────────────────────────────── -// -// When this node (Node B) receives a message from a remote user (Alice on -// Node A), it needs valid rows in users and devices to satisfy the FK -// constraints on messages.from_user_id and messages.from_device_id. -// -// Shadow rows are identified by a non-NULL home_node_id. They are never -// returned by normal user lookup endpoints, and their devices are never -// queried for prekey material. +// ─── Shadow user / device ──────────────────────────────────────────────────── -/// Upsert a shadow user record for a remote user. -/// Conflict key is federated_address (globally unique). -/// Returns the local UUID of the (possibly newly created) shadow user. pub async fn upsert_shadow_user( pool: &PgPool, username: &str, federated_address: &str, home_node_id: Uuid, ) -> Result { - let id = sqlx::query_scalar!( - r#" - INSERT INTO users (username, federated_address, home_node_id) - VALUES ($1, $2, $3) - ON CONFLICT (federated_address) DO UPDATE - SET username = EXCLUDED.username - RETURNING id - "#, - username, - federated_address, - home_node_id, + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO users (username, federated_address, home_node_id) + VALUES ($1, $2, $3) + ON CONFLICT (federated_address) DO UPDATE SET username = EXCLUDED.username + RETURNING id", ) + .bind(username) + .bind(federated_address) + .bind(home_node_id) .fetch_one(pool) .await?; - Ok(id) + Ok(row.0) } -/// Upsert a shadow device record for a remote device. -/// -/// The device UUID is reused from Node A (random UUID collision probability -/// is negligible across nodes). Prekey fields are stored as empty values -/// because shadow devices are never queried for key material; they exist -/// solely to satisfy FK constraints. pub async fn upsert_shadow_device( pool: &PgPool, device_id: Uuid, user_id: Uuid, identity_pubkey: &str, ) -> Result<(), sqlx::Error> { - sqlx::query!( - r#" - INSERT INTO devices ( - id, user_id, identity_pubkey, - prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys - ) - VALUES ($1, $2, $3, '', '', '', '[]'::jsonb) - ON CONFLICT (id) DO NOTHING - "#, - device_id, - user_id, - identity_pubkey, + sqlx::query( + "INSERT INTO devices (id, user_id, identity_pubkey, + prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys) + VALUES ($1, $2, $3, '', '', '', '[]'::jsonb) + ON CONFLICT (id) DO NOTHING", ) + .bind(device_id) + .bind(user_id) + .bind(identity_pubkey) .execute(pool) .await?; Ok(()) } -/// Find or create a direct chat between two users. -/// -/// Enforces the schema constraint user_a < user_b by sorting before insert. -/// The unique index on (LEAST(user_a,user_b), GREATEST(user_a,user_b)) prevents -/// duplicate chats regardless of the argument order. pub async fn get_or_create_direct_chat( pool: &PgPool, user_x: Uuid, @@ -282,43 +219,38 @@ pub async fn get_or_create_direct_chat( (user_y, user_x) }; - if let Some(id) = sqlx::query_scalar!( + if let Some(row) = sqlx::query_as::<_, (Uuid,)>( "SELECT id FROM chats WHERE user_a = $1 AND user_b = $2 AND chat_type = 'direct'", - ua, - ub, ) + .bind(ua) + .bind(ub) .fetch_optional(pool) .await? { - return Ok(id); + return Ok(row.0); } - let id = sqlx::query_scalar!( - r#" - INSERT INTO chats (user_a, user_b, chat_type) - VALUES ($1, $2, 'direct') - RETURNING id - "#, - ua, - ub, + let row: (Uuid,) = sqlx::query_as( + "INSERT INTO chats (user_a, user_b, chat_type) VALUES ($1, $2, 'direct') RETURNING id", ) + .bind(ua) + .bind(ub) .fetch_one(pool) .await?; - - Ok(id) + Ok(row.0) } -/// Look up a local user (home_node_id IS NULL) by username. +/// Returns the UUID of a local (non-shadow) user by username. /// Returns None if the user does not exist or is a shadow record. pub async fn get_local_user_id_by_username( pool: &PgPool, username: &str, ) -> Result, sqlx::Error> { - let row = sqlx::query!( + let row = sqlx::query_as::<_, (Uuid,)>( "SELECT id FROM users WHERE username = $1 AND home_node_id IS NULL", - username, ) + .bind(username) .fetch_optional(pool) .await?; - Ok(row.map(|r| r.id)) + Ok(row.map(|r| r.0)) } diff --git a/src/repository/message_repository.rs b/src/repository/message_repository.rs index 7c0fd8c..a02e0f3 100644 --- a/src/repository/message_repository.rs +++ b/src/repository/message_repository.rs @@ -62,26 +62,24 @@ pub async fn insert_federated_message( header: &Value, ciphertext: &str, ) -> Result { - let result = sqlx::query!( - r#" - INSERT INTO messages ( - logical_msg_id, chat_id, - from_user_id, from_device_id, - to_user_id, to_device_id, - header, ciphertext - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING - "#, - logical_msg_id, - chat_id, - from_user_id, - from_device_id, - to_user_id, - to_device_id, - header, - ciphertext, + let result = sqlx::query( + "INSERT INTO messages ( + logical_msg_id, chat_id, + from_user_id, from_device_id, + to_user_id, to_device_id, + header, ciphertext + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING", ) + .bind(logical_msg_id) + .bind(chat_id) + .bind(from_user_id) + .bind(from_device_id) + .bind(to_user_id) + .bind(to_device_id) + .bind(header) + .bind(ciphertext) .execute(pool) .await?; Ok(result.rows_affected() == 1) diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 252e83a..7a22cfd 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -45,15 +45,10 @@ pub async fn find_user_by_pubkey(pool: &PgPool, pubkey_b64: &str) -> Result Result> { - let user = sqlx::query_as!( - User, - r#" - SELECT id, username, created_at - FROM users - WHERE username = $1 - "#, - username + let user = sqlx::query_as::<_, User>( + "SELECT id, username, created_at FROM users WHERE username = $1", ) + .bind(username) .fetch_optional(pool) .await?; Ok(user) From c7cd5102f5a22316ad03531be2a0a79aa19ca26d Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:18:16 +0200 Subject: [PATCH 09/23] feat(routes): update federated keys route to include S2S prefix for consistency --- src/routes/federation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/federation.rs b/src/routes/federation.rs index 468390f..a6b73ef 100644 --- a/src/routes/federation.rs +++ b/src/routes/federation.rs @@ -33,7 +33,7 @@ pub fn routes() -> Router { .route("/s2s/ack", post(federation_controller::receive_ack)) // ── Client-facing federated proxy ──────────────────────────────────── .route( - "/users/federated/{address}/keys", + "/s2s/users/federated/{address}/keys", get(federation_controller::federated_keys), ) } From 6252425d808091a53936fddcfda5f5db4b110da3 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:21:59 +0200 Subject: [PATCH 10/23] feat(routes): remove duplicate route for federated keys in S2S messaging --- src/routes/federation.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/routes/federation.rs b/src/routes/federation.rs index a6b73ef..445eb6c 100644 --- a/src/routes/federation.rs +++ b/src/routes/federation.rs @@ -20,6 +20,10 @@ pub fn routes() -> Router { // ── Public ────────────────────────────────────────────────────────── .route("/s2s/info", get(federation_controller::node_info)) // ── S2S (node-to-node, AuthenticatedNode required inside handler) ─── + .route( + "/s2s/users/federated/{address}/keys", + get(federation_controller::federated_keys), + ) .route( "/s2s/users/{username}/devices", get(federation_controller::get_user_devices), @@ -32,8 +36,4 @@ pub fn routes() -> Router { .route("/s2s/messages", post(federation_controller::receive_messages)) .route("/s2s/ack", post(federation_controller::receive_ack)) // ── Client-facing federated proxy ──────────────────────────────────── - .route( - "/s2s/users/federated/{address}/keys", - get(federation_controller::federated_keys), - ) } From ff650c6e58e00efcb57a12c30678f9b8bf66e903 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:29:59 +0200 Subject: [PATCH 11/23] feat(routes): update federated keys route to use username and node_id path parameters --- src/controllers/federation_controller.rs | 21 ++++----------------- src/routes/federation.rs | 20 ++++++-------------- 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/src/controllers/federation_controller.rs b/src/controllers/federation_controller.rs index 28efcd8..5b9f8fe 100644 --- a/src/controllers/federation_controller.rs +++ b/src/controllers/federation_controller.rs @@ -9,7 +9,7 @@ use tracing::{debug, error, info, warn}; use crate::{ app_state::AppState, - federation::{client::FederationClient, parse_federated_address}, + federation::client::FederationClient, middlewares::node_auth::AuthenticatedNode, models::federation::{ NodeInfo, S2sAck, S2sMessagePayload, S2sSessionPayload, @@ -394,23 +394,10 @@ pub async fn receive_ack( pub async fn federated_keys( State(state): State, crate::middlewares::auth::AuthenticatedDevice(_device): crate::middlewares::auth::AuthenticatedDevice, - Path(address): Path, + Path((username, node_id)): Path<(String, String)>, ) -> impl IntoResponse { - info!(%address, "GET /users/federated/:address/keys"); - - let (username, node_id) = match parse_federated_address(&address) { - Some(parts) => parts, - None => { - warn!(%address, "invalid federated address (no '@')"); - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "invalid federated address, expected user@node"})), - ) - .into_response(); - } - }; - - debug!(%username, %node_id, "parsed federated address"); + info!(%username, %node_id, "GET /users/federated/:username/:node_id/keys"); + let (username, node_id) = (username.as_str(), node_id.as_str()); // Local shortcut: address points to this node. if node_id == state.this_node_id { diff --git a/src/routes/federation.rs b/src/routes/federation.rs index 445eb6c..c6f28b9 100644 --- a/src/routes/federation.rs +++ b/src/routes/federation.rs @@ -5,25 +5,11 @@ use axum::{ use crate::{app_state::AppState, controllers::federation_controller}; -/// S2S routes (consumed by peer nodes, not by end clients). -/// -/// /s2s/info is intentionally unauthenticated: it is the bootstrap endpoint -/// that lets an unknown peer fetch this node's public key before they have a -/// cached record. All other /s2s/* routes require the AuthenticatedNode -/// extractor (Ed25519 signature verification + nonce claim). -/// -/// The client-facing federated proxy (GET /users/federated/:address/keys) -/// is included here for co-location but uses the normal AuthenticatedDevice -/// extractor, not AuthenticatedNode. pub fn routes() -> Router { Router::new() // ── Public ────────────────────────────────────────────────────────── .route("/s2s/info", get(federation_controller::node_info)) // ── S2S (node-to-node, AuthenticatedNode required inside handler) ─── - .route( - "/s2s/users/federated/{address}/keys", - get(federation_controller::federated_keys), - ) .route( "/s2s/users/{username}/devices", get(federation_controller::get_user_devices), @@ -36,4 +22,10 @@ pub fn routes() -> Router { .route("/s2s/messages", post(federation_controller::receive_messages)) .route("/s2s/ack", post(federation_controller::receive_ack)) // ── Client-facing federated proxy ──────────────────────────────────── + // Two separate path segments to avoid %40-encoding issues with proxies. + // URL: GET /users/federated/{username}/{node_id}/keys + .route( + "/users/federated/{username}/{node_id}/keys", + get(federation_controller::federated_keys), + ) } From caddb2d7a252a91be9f86c9618f40526e848c1f3 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:31:31 +0200 Subject: [PATCH 12/23] feat(docker): add volume configuration for node keys storage --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 181876b..9c18cb8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,6 +43,8 @@ services: REGISTER_TO_REGISTRY: ${REGISTER_TO_REGISTRY:-true} env_file: - .env + volumes: + - node_keys:/app/.hushnet ports: - "${SERVER_PORT:-8080}:${SERVER_PORT:-8080}" depends_on: @@ -60,6 +62,8 @@ services: volumes: postgres_data: driver: local + node_keys: + driver: local networks: hushnet: From 4e52d8f11f66dd59493560b9e981d4ed34688df9 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:33:53 +0200 Subject: [PATCH 13/23] feat(docker): create .hushnet directory and update ownership in Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a416b44..8c3321e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,7 +47,7 @@ WORKDIR /app COPY --from=builder /app/target/release/hushnet-backend /app/hushnet-backend # Change ownership -RUN chown -R hushnet:hushnet /app +RUN chown -R hushnet:hushnet /app && mkdir -p /app/.hushnet && chown hushnet:hushnet /app/.hushnet # Switch to non-root user USER hushnet From e3a5169c97b3f553f6916ccf5720d989ffe9d70b Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:36:19 +0200 Subject: [PATCH 14/23] feat(controller): update protocol version to 0.0.2 in node info response --- src/controllers/federation_controller.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/federation_controller.rs b/src/controllers/federation_controller.rs index 5b9f8fe..30f600f 100644 --- a/src/controllers/federation_controller.rs +++ b/src/controllers/federation_controller.rs @@ -25,7 +25,7 @@ pub async fn node_info(State(state): State) -> impl IntoResponse { node_id: state.this_node_id.clone(), api_url: state.this_api_url.clone(), public_key_b64: state.node_keys.public_b64.clone(), - protocol_version: "0.0.1", + protocol_version: "0.0.2", }; (StatusCode::OK, Json(info)) } From 0d718085b541f863f98ce690f0610369e390b2de Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:45:52 +0200 Subject: [PATCH 15/23] fix: fixed route path --- src/routes/federation.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/routes/federation.rs b/src/routes/federation.rs index c6f28b9..ba10080 100644 --- a/src/routes/federation.rs +++ b/src/routes/federation.rs @@ -22,10 +22,8 @@ pub fn routes() -> Router { .route("/s2s/messages", post(federation_controller::receive_messages)) .route("/s2s/ack", post(federation_controller::receive_ack)) // ── Client-facing federated proxy ──────────────────────────────────── - // Two separate path segments to avoid %40-encoding issues with proxies. - // URL: GET /users/federated/{username}/{node_id}/keys .route( - "/users/federated/{username}/{node_id}/keys", + "/s2s/federated/{username}/{node_id}/keys", get(federation_controller::federated_keys), ) } From 1646b9d7bc73c13f460346ef42d97c1e8ed016d2 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 18:49:35 +0200 Subject: [PATCH 16/23] fix: fixed axum routing --- src/routes/federation.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/routes/federation.rs b/src/routes/federation.rs index ba10080..3fe788d 100644 --- a/src/routes/federation.rs +++ b/src/routes/federation.rs @@ -11,11 +11,11 @@ pub fn routes() -> Router { .route("/s2s/info", get(federation_controller::node_info)) // ── S2S (node-to-node, AuthenticatedNode required inside handler) ─── .route( - "/s2s/users/{username}/devices", + "/s2s/users/:username/devices", get(federation_controller::get_user_devices), ) .route( - "/s2s/users/{username}/keys", + "/s2s/users/:username/keys", get(federation_controller::get_user_keys), ) .route("/s2s/sessions", post(federation_controller::receive_session)) @@ -23,7 +23,7 @@ pub fn routes() -> Router { .route("/s2s/ack", post(federation_controller::receive_ack)) // ── Client-facing federated proxy ──────────────────────────────────── .route( - "/s2s/federated/{username}/{node_id}/keys", + "/s2s/federated/:username/:node_id/keys", get(federation_controller::federated_keys), ) } From 8a4e8761ee1c3543cb344a3a4b348b00b8de0209 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 19:30:04 +0200 Subject: [PATCH 17/23] fix: fixed HMAC auth --- src/models/device.rs | 1 + src/repository/device_repository.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/models/device.rs b/src/models/device.rs index 988f4e8..3114e78 100644 --- a/src/models/device.rs +++ b/src/models/device.rs @@ -33,6 +33,7 @@ pub struct OneTimePrekeys { pub struct DeviceBundle { pub device_id: Uuid, pub identity_pubkey: String, + pub prekey_pubkey: String, pub signed_prekey_pub: String, pub signed_prekey_sig: String, pub one_time_prekeys: Vec, diff --git a/src/repository/device_repository.rs b/src/repository/device_repository.rs index 7a4f8c2..ef9d309 100644 --- a/src/repository/device_repository.rs +++ b/src/repository/device_repository.rs @@ -130,6 +130,7 @@ pub async fn get_device_bundle( bundles.push(DeviceBundle { device_id: row.id, identity_pubkey: row.identity_pubkey, + prekey_pubkey: row.prekey_pubkey, signed_prekey_pub: row.signed_prekey_pub, signed_prekey_sig: row.signed_prekey_sig, one_time_prekeys: otpks, From 18b0f63617554dd56988d68fd50133c6d3afe68c Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 19:37:52 +0200 Subject: [PATCH 18/23] feat: added partner_federated_adress when fetching chats --- src/models/chat.rs | 1 + src/repository/chat_repository.rs | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/models/chat.rs b/src/models/chat.rs index b7c6003..e75f437 100644 --- a/src/models/chat.rs +++ b/src/models/chat.rs @@ -22,6 +22,7 @@ pub struct ChatView { pub chat_type: Option, pub partner_user_id: Option, pub partner_username: Option, + pub partner_federated_address: Option, pub name: Option, pub last_message_id: Option, pub updated_at: Option, diff --git a/src/repository/chat_repository.rs b/src/repository/chat_repository.rs index 8dc8e65..8b207e1 100644 --- a/src/repository/chat_repository.rs +++ b/src/repository/chat_repository.rs @@ -22,26 +22,34 @@ pub async fn get_chats_for_device( let chats = sqlx::query_as!( ChatView, r#" - SELECT + SELECT c.id, c.chat_type, - CASE + CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END AS partner_user_id, ( - SELECT u.username + SELECT u.username FROM users u - WHERE u.id = CASE + WHERE u.id = CASE WHEN c.user_a = $1 THEN c.user_b ELSE c.user_a END ) AS partner_username, + ( + SELECT u.federated_address + FROM users u + WHERE u.id = CASE + WHEN c.user_a = $1 THEN c.user_b + ELSE c.user_a + END + ) AS partner_federated_address, c.name, c.last_message_id, c.updated_at FROM chats c - WHERE + WHERE (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b))) OR (c.chat_type = 'group' AND c.id IN ( SELECT chat_id FROM chat_members WHERE user_id = $1 From a010274d8da5303939761986f2736ab05b6ef1dd Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 18 Apr 2026 19:40:38 +0200 Subject: [PATCH 19/23] chore: update sqlx files --- ...5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json | 23 ------- ...ac1d7479e399cb4d5bf96f47d607c9f158295.json | 15 ---- ...486ac047279a245e917dc944a1e122a1bef8a.json | 64 +++++++++++++++++ ...c1dc9207dd104762ec355e4d92df8d8aa90c3.json | 24 ------- ...3eb095234eec88131b9ff82010f1cc2ba6bef.json | 58 ---------------- ...4ab9fccfaf4c4376d8091bcddb58b16b15c5e.json | 16 ----- ...effb919803cabf97c794ae26000642edb0d9b.json | 24 ------- ...9c2170076d0aac620a6c8e2735ea0c6177648.json | 21 ------ ...d4653182d9daefe8bbea58737ba841de7f334.json | 16 ----- ...239f99af495c156fd533431d3d994f8bf4196.json | 23 ------- ...a1cf581b2be68cc0ae8d33f3479d655af0aa4.json | 68 ------------------- ...8c31e1b2e62ee61ded3a5e59eb2f78807413a.json | 14 ---- ...eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json | 12 ---- ...8daf21cbb03abb40100bb9919a8851a17e17d.json | 58 ---------------- ...daf34084aaf4805b88c7cbf88ec2f28e5cd45.json | 14 ---- ...2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json | 60 ---------------- ...fd30227624d0322949b690aa56cc8846407f3.json | 15 ---- ...a47e963acf5471962f4cc283b202e1c045a7d.json | 34 ---------- ...a6b299c775030e0c50f5305684e205e8a4d8e.json | 22 ------ 19 files changed, 64 insertions(+), 517 deletions(-) delete mode 100644 .sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json delete mode 100644 .sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json create mode 100644 .sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json delete mode 100644 .sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json delete mode 100644 .sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json delete mode 100644 .sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json delete mode 100644 .sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json delete mode 100644 .sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json delete mode 100644 .sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json delete mode 100644 .sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json delete mode 100644 .sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json delete mode 100644 .sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json delete mode 100644 .sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json delete mode 100644 .sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json delete mode 100644 .sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json delete mode 100644 .sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json delete mode 100644 .sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json delete mode 100644 .sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json delete mode 100644 .sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json diff --git a/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json b/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json deleted file mode 100644 index 4e04744..0000000 --- a/.sqlx/query-007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM chats WHERE user_a = $1 AND user_b = $2 AND chat_type = 'direct'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "007408a143234c71345920b5b0b5095f3e5f4e930fc73bd4412f41bc3e96c1c6" -} diff --git a/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json b/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json deleted file mode 100644 index 8c220a6..0000000 --- a/.sqlx/query-09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO used_node_nonces (nonce, node_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "09dd6d4c33a8ad7648f7ae873e2ac1d7479e399cb4d5bf96f47d607c9f158295" -} diff --git a/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json b/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json new file mode 100644 index 0000000..ce02bb9 --- /dev/null +++ b/.sqlx/query-0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n c.id,\n c.chat_type,\n CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END AS partner_user_id,\n (\n SELECT u.username\n FROM users u\n WHERE u.id = CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_username,\n (\n SELECT u.federated_address\n FROM users u\n WHERE u.id = CASE\n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_federated_address,\n c.name,\n c.last_message_id,\n c.updated_at\n FROM chats c\n WHERE\n (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b)))\n OR (c.chat_type = 'group' AND c.id IN (\n SELECT chat_id FROM chat_members WHERE user_id = $1\n ))\n ORDER BY c.updated_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "chat_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "partner_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "partner_username", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "partner_federated_address", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "last_message_id", + "type_info": "Uuid" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamp" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true, + null, + null, + null, + true, + true, + true + ] + }, + "hash": "0ecaf246a0fa50df1d53c63a277486ac047279a245e917dc944a1e122a1bef8a" +} diff --git a/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json b/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json deleted file mode 100644 index a0c152d..0000000 --- a/.sqlx/query-11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO federation_outbox (target_node_id, logical_msg_id, payload)\n VALUES ($1, $2, $3)\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Jsonb" - ] - }, - "nullable": [ - false - ] - }, - "hash": "11450d9551802ea5ec7170681a6c1dc9207dd104762ec355e4d92df8d8aa90c3" -} diff --git a/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json b/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json deleted file mode 100644 index e4405b5..0000000 --- a/.sqlx/query-1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at\n FROM federation_nodes\n WHERE node_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "node_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "api_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "public_key_b64", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "last_seen", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "is_blocked", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - false - ] - }, - "hash": "1b9e36dca8201195032f1514f283eb095234eec88131b9ff82010f1cc2ba6bef" -} diff --git a/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json b/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json deleted file mode 100644 index ed108ca..0000000 --- a/.sqlx/query-38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO devices (\n id, user_id, identity_pubkey,\n prekey_pubkey, signed_prekey_pub, signed_prekey_sig, one_time_prekeys\n )\n VALUES ($1, $2, $3, '', '', '', '[]'::jsonb)\n ON CONFLICT (id) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "38f3df401e8dd0ab450303b16354ab9fccfaf4c4376d8091bcddb58b16b15c5e" -} diff --git a/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json b/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json deleted file mode 100644 index c2630b3..0000000 --- a/.sqlx/query-3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO users (username, federated_address, home_node_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (federated_address) DO UPDATE\n SET username = EXCLUDED.username\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "3fcc831a945000efa238968e5e8effb919803cabf97c794ae26000642edb0d9b" -} diff --git a/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json b/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json deleted file mode 100644 index a07b1f3..0000000 --- a/.sqlx/query-4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO messages (\n logical_msg_id, chat_id,\n from_user_id, from_device_id,\n to_user_id, to_device_id,\n header, ciphertext\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ON CONFLICT (logical_msg_id, to_device_id) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid", - "Uuid", - "Uuid", - "Uuid", - "Uuid", - "Jsonb", - "Text" - ] - }, - "nullable": [] - }, - "hash": "4a1eb4649fae7791b4ec718053e9c2170076d0aac620a6c8e2735ea0c6177648" -} diff --git a/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json b/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json deleted file mode 100644 index e833664..0000000 --- a/.sqlx/query-4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE federation_outbox\n SET attempt_count = $2,\n last_attempt = NOW(),\n next_attempt = NOW() + ($3 || ' seconds')::interval\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Int4", - "Text" - ] - }, - "nullable": [] - }, - "hash": "4a4cdc56b0336ee0f5de050c5d3d4653182d9daefe8bbea58737ba841de7f334" -} diff --git a/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json b/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json deleted file mode 100644 index 30c6039..0000000 --- a/.sqlx/query-598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO chats (user_a, user_b, chat_type)\n VALUES ($1, $2, 'direct')\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Uuid", - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "598a9a4592264e2c6f986a133c4239f99af495c156fd533431d3d994f8bf4196" -} diff --git a/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json b/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json deleted file mode 100644 index 2bf994f..0000000 --- a/.sqlx/query-82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, target_node_id, logical_msg_id, payload,\n attempt_count, last_attempt, next_attempt, status, created_at\n FROM federation_outbox\n WHERE status = 'pending'\n AND next_attempt <= NOW()\n ORDER BY next_attempt ASC\n LIMIT 100\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "target_node_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "logical_msg_id", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "payload", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "attempt_count", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "last_attempt", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "next_attempt", - "type_info": "Timestamptz" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Text" - }, - { - "ordinal": 8, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - false, - false, - false - ] - }, - "hash": "82b738e114efe4f12b0665b448ba1cf581b2be68cc0ae8d33f3479d655af0aa4" -} diff --git a/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json b/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json deleted file mode 100644 index a4d326b..0000000 --- a/.sqlx/query-85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW() WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "85b407d6d22188a6d7afe46369f8c31e1b2e62ee61ded3a5e59eb2f78807413a" -} diff --git a/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json b/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json deleted file mode 100644 index 0429e31..0000000 --- a/.sqlx/query-a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM used_node_nonces WHERE used_at < NOW() - INTERVAL '5 minutes'", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "a0bfd1d3617c99d8723b5fca590eb97f6b525b4186f8a5e56fd2dc4e6fc6ef6f" -} diff --git a/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json b/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json deleted file mode 100644 index 6527807..0000000 --- a/.sqlx/query-a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT \n c.id,\n c.chat_type,\n CASE \n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END AS partner_user_id,\n (\n SELECT u.username \n FROM users u\n WHERE u.id = CASE \n WHEN c.user_a = $1 THEN c.user_b\n ELSE c.user_a\n END\n ) AS partner_username,\n c.name,\n c.last_message_id,\n c.updated_at\n FROM chats c\n WHERE \n (c.chat_type = 'direct' AND ($1 IN (c.user_a, c.user_b)))\n OR (c.chat_type = 'group' AND c.id IN (\n SELECT chat_id FROM chat_members WHERE user_id = $1\n ))\n ORDER BY c.updated_at DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "chat_type", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "partner_user_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "partner_username", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "last_message_id", - "type_info": "Uuid" - }, - { - "ordinal": 6, - "name": "updated_at", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - null, - null, - true, - true, - true - ] - }, - "hash": "a3a32ad2342203341c15b016ca88daf21cbb03abb40100bb9919a8851a17e17d" -} diff --git a/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json b/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json deleted file mode 100644 index 8c26681..0000000 --- a/.sqlx/query-b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE federation_outbox SET status = 'delivered', last_attempt = NOW()\n WHERE logical_msg_id = $1 AND status = 'pending'", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "b85f0b94d30a3beababf5313c2fdaf34084aaf4805b88c7cbf88ec2f28e5cd45" -} diff --git a/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json b/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json deleted file mode 100644 index f28befa..0000000 --- a/.sqlx/query-d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO federation_nodes (node_id, api_url, public_key_b64)\n VALUES ($1, $2, $3)\n ON CONFLICT (node_id) DO UPDATE\n SET api_url = EXCLUDED.api_url,\n public_key_b64 = EXCLUDED.public_key_b64,\n last_seen = NOW()\n RETURNING id, node_id, api_url, public_key_b64, last_seen, is_blocked, created_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "node_id", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "api_url", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "public_key_b64", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "last_seen", - "type_info": "Timestamptz" - }, - { - "ordinal": 5, - "name": "is_blocked", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - false - ] - }, - "hash": "d02fecd0075a0ca8a964c4d408d2f5a45cb3bcd8dc457ba2b469ba5cb4a7cb27" -} diff --git a/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json b/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json deleted file mode 100644 index db1c17e..0000000 --- a/.sqlx/query-da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE federation_outbox\n SET status = 'failed', last_attempt = NOW(), attempt_count = $2\n WHERE id = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "da57eb809c61ab7ffc3f4d6387dfd30227624d0322949b690aa56cc8846407f3" -} diff --git a/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json b/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json deleted file mode 100644 index 52206f5..0000000 --- a/.sqlx/query-eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT id, username, created_at\n FROM users\n WHERE username = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "username", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamp" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "eb2e4580973e609ae0f2282a7caa47e963acf5471962f4cc283b202e1c045a7d" -} diff --git a/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json b/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json deleted file mode 100644 index 8e1b7a8..0000000 --- a/.sqlx/query-fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM users WHERE username = $1 AND home_node_id IS NULL", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fb8d1e13b267920c14ca5984ed8a6b299c775030e0c50f5305684e205e8a4d8e" -} From f57807e87a237f9b27f3ac8a56072800f16a1252 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sat, 25 Apr 2026 22:29:42 +0200 Subject: [PATCH 20/23] feat: enhance logging in outbox and websocket components --- src/federation/outbox.rs | 43 ++++++++++++++++++++++++---------- src/realtime/listener.rs | 49 ++++++++++++++++++++++++++++++++------- src/realtime/websocket.rs | 43 ++++++++++++++++++++++++---------- 3 files changed, 103 insertions(+), 32 deletions(-) diff --git a/src/federation/outbox.rs b/src/federation/outbox.rs index 7826c7f..aea89cd 100644 --- a/src/federation/outbox.rs +++ b/src/federation/outbox.rs @@ -30,6 +30,7 @@ use std::time::Duration; use sqlx::PgPool; use tokio::time; +use tracing::{debug, error, info, warn}; use crate::{ models::federation::S2sMessagePayload, @@ -64,17 +65,21 @@ pub async fn run( // Housekeeping: purge nonces older than 5 minutes. if let Err(e) = federation_repository::purge_expired_nonces(&pool).await { - eprintln!("[outbox] nonce purge failed: {e}"); + warn!(err = %e, "outbox: nonce purge failed"); } let entries = match federation_repository::fetch_due_outbox_entries(&pool).await { Ok(v) => v, Err(e) => { - eprintln!("[outbox] db error fetching due entries: {e}"); + error!(err = %e, "outbox: db error fetching due entries"); continue; } }; + if !entries.is_empty() { + info!(count = entries.len(), "outbox: processing due entries"); + } + for entry in entries { let pool = pool.clone(); let client = FederationClient::new( @@ -87,8 +92,7 @@ pub async fn run( let payload: S2sMessagePayload = match serde_json::from_value(entry.payload) { Ok(p) => p, Err(e) => { - eprintln!("[outbox] cannot deserialize entry {}: {e}", entry.id); - // Malformed entries will never succeed; mark failed immediately. + error!(entry_id = %entry.id, err = %e, "outbox: cannot deserialize entry, marking failed"); let _ = federation_repository::record_outbox_failure( &pool, entry.id, @@ -106,9 +110,10 @@ pub async fn run( { Ok(Some(n)) => n, Ok(None) => { - eprintln!( - "[outbox] unknown target node '{}' for entry {}", - entry.target_node_id, entry.id + warn!( + target_node = %entry.target_node_id, + entry_id = %entry.id, + "outbox: unknown target node" ); let _ = federation_repository::record_outbox_failure( &pool, @@ -120,20 +125,34 @@ pub async fn run( return; } Err(e) => { - eprintln!("[outbox] db error looking up node: {e}"); + error!(err = %e, "outbox: db error looking up node"); return; } }; + debug!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + attempt = entry.attempt_count + 1, + "outbox: attempting delivery" + ); + match client.forward_messages(&node.api_url, &payload).await { Ok(_) => { + info!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + "outbox: delivery succeeded" + ); let _ = federation_repository::mark_outbox_delivered(&pool, entry.id).await; } Err(e) => { - eprintln!( - "[outbox] delivery attempt {} for entry {} failed: {e}", - entry.attempt_count + 1, - entry.id + warn!( + entry_id = %entry.id, + target_node = %entry.target_node_id, + attempt = entry.attempt_count + 1, + err = %e, + "outbox: delivery failed" ); let _ = federation_repository::record_outbox_failure( &pool, diff --git a/src/realtime/listener.rs b/src/realtime/listener.rs index 515ed4f..ab21015 100644 --- a/src/realtime/listener.rs +++ b/src/realtime/listener.rs @@ -1,6 +1,7 @@ use serde_json::Value; use sqlx::{postgres::PgListener, PgPool}; use tokio::sync::broadcast; +use tracing::{error, info, warn}; use crate::models::realtime::RealtimeEvent; @@ -16,14 +17,46 @@ pub async fn start_pg_listeners(pool: PgPool, tx: broadcast::Sender(notif.payload()) { - if let Some(event_type) = payload.get("type").and_then(|v| v.as_str()) { - let event = RealtimeEvent { - event_type: event_type.to_string(), - payload, - }; - let _ = tx.send(event); + info!("PG listener started, watching 4 channels"); + + loop { + match listener.recv().await { + Ok(notif) => { + let channel = notif.channel(); + match serde_json::from_str::(notif.payload()) { + Ok(payload) => { + let event_type = payload + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let user_id = payload + .get("user_id") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + + info!( + %channel, + %event_type, + %user_id, + subscribers = tx.receiver_count(), + "PG notify received, forwarding to broadcast" + ); + + let event = RealtimeEvent { + event_type: event_type.to_string(), + payload, + }; + if let Err(e) = tx.send(event) { + warn!(%channel, err = %e, "broadcast send failed (no subscribers?)"); + } + } + Err(e) => { + warn!(%channel, err = %e, "PG notify payload parse failed"); + } + } + } + Err(e) => { + error!(err = %e, "PG listener error"); } } } diff --git a/src/realtime/websocket.rs b/src/realtime/websocket.rs index 0a9ce12..2fe22b6 100644 --- a/src/realtime/websocket.rs +++ b/src/realtime/websocket.rs @@ -8,8 +8,10 @@ use axum::{ }; use tokio::sync::broadcast; +use tracing::{info, warn}; use crate::models::realtime::RealtimeEvent; + pub async fn ws_route( Path(user_id): Path, ws: WebSocketUpgrade, @@ -25,24 +27,41 @@ async fn handle_socket( ) { let mut rx = tx.subscribe(); - println!("WS connected for user {}", user_id); + info!(%user_id, subscribers = tx.receiver_count(), "WS connected"); tokio::spawn(async move { - while let Ok(event) = rx.recv().await { - let payload_user = event - .payload - .get("user_id") - .and_then(|v| v.as_str()) - .unwrap_or_default(); + loop { + match rx.recv().await { + Ok(event) => { + let payload_user = event + .payload + .get("user_id") + .and_then(|v| v.as_str()) + .unwrap_or_default(); - if payload_user == user_id { - if let Ok(json) = serde_json::to_string(&event) { - if socket.send(Message::Text(json)).await.is_err() { - break; + if payload_user == user_id { + info!( + %user_id, + event_type = %event.event_type, + "WS dispatching event to client" + ); + if let Ok(json) = serde_json::to_string(&event) { + if socket.send(Message::Text(json)).await.is_err() { + info!(%user_id, "WS send failed, closing"); + break; + } + } } } + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!(%user_id, skipped = n, "WS broadcast lagged, events dropped"); + } + Err(broadcast::error::RecvError::Closed) => { + info!(%user_id, "WS broadcast channel closed"); + break; + } } } - println!("WS closed for user {}", user_id); + info!(%user_id, "WS disconnected"); }); } From 8adb4885cf9e374d2a7d5f29da7e2ca418382bcb Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sun, 26 Apr 2026 10:42:56 +0200 Subject: [PATCH 21/23] feat: refine user retrieval query to filter by home_node_id --- src/repository/user_repository.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 7a22cfd..330f653 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -2,9 +2,11 @@ use crate::models::user::User; use sqlx::{PgPool, Result}; pub async fn get_all_users(pool: &PgPool) -> Result> { - let users = sqlx::query_as::<_, User>("SELECT * FROM users") - .fetch_all(pool) - .await?; + let users = sqlx::query_as::<_, User>( + "SELECT id, username, created_at FROM users WHERE home_node_id IS NULL", + ) + .fetch_all(pool) + .await?; Ok(users) } From c9824ea591b717b5dab8f57de1ba64f91ba787d2 Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sun, 26 Apr 2026 10:53:20 +0200 Subject: [PATCH 22/23] Update dependencies and refactor code - Bump axum from 0.7 to 0.8 and update related dependencies. - Upgrade uuid to 1.23.1 and chrono to 0.4.44 for improved functionality. - Update jsonwebtoken to 10.3.0, reqwest to 0.13.2, and sha2 to 0.11 for security and performance enhancements. - Remove unused fetch_node_info method from FederationClient. - Add clippy lint allowance for too many arguments in insert_federated_message function. - Remove commented-out get_user_by_username function from user_repository.rs. --- Cargo.lock | 854 ++++++++++++++++----------- Cargo.toml | 14 +- src/federation/client.rs | 19 - src/repository/message_repository.rs | 1 + src/repository/user_repository.rs | 10 - 5 files changed, 524 insertions(+), 374 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1ec4885..a944a4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "async-trait" @@ -148,15 +148,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -184,15 +184,15 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.44" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "shlex", @@ -212,16 +212,16 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -249,6 +249,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -266,18 +276,18 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crossbeam-queue" @@ -345,9 +355,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "der" @@ -362,9 +372,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -517,9 +527,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "ff" @@ -539,9 +549,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flume" @@ -592,9 +602,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -602,15 +612,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -630,27 +640,27 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -658,7 +668,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -675,9 +684,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -695,11 +704,24 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + [[package]] name = "group" version = "0.13.0" @@ -713,9 +735,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -743,9 +765,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "hashlink" @@ -797,12 +819,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -867,9 +888,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.7.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -882,7 +903,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -890,15 +910,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -923,14 +942,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -949,9 +967,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -973,12 +991,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -986,9 +1005,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -999,9 +1018,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1013,15 +1032,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1033,15 +1052,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1052,6 +1071,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -1075,25 +1100,27 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1101,35 +1128,37 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] [[package]] name = "jsonwebtoken" -version = "10.1.0" +version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d119c6924272d16f0ab9ce41f7aa0bfef9340c00b0bb7ca3dd3b263d4a9150b" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64", "ed25519-dalek", - "getrandom 0.2.16", + "getrandom 0.2.17", "hmac", "js-sys", "p256", "p384", "pem", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "serde_json", @@ -1147,27 +1176,34 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "bitflags", "libc", - "redox_syscall", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1182,15 +1218,15 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1203,9 +1239,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru-slab" @@ -1240,9 +1276,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1252,9 +1288,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mio" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi", @@ -1263,9 +1299,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -1299,25 +1335,25 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82c79c15c05d4bf82b6f5ef163104cc81a760d8e874d38ac50ab67c8877b647b" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -1351,15 +1387,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl" -version = "0.10.74" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags", "cfg-if", @@ -1383,15 +1419,15 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -1447,9 +1483,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1479,15 +1515,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkcs1" @@ -1512,15 +1542,21 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -1540,6 +1576,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -1551,9 +1597,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -1572,7 +1618,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1580,20 +1626,20 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1615,9 +1661,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1628,11 +1674,17 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -1641,12 +1693,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1666,7 +1718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -1675,14 +1727,14 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -1696,11 +1748,20 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1709,15 +1770,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -1775,7 +1836,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1783,9 +1844,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ "const-oid", "digest", @@ -1803,9 +1864,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -1818,9 +1879,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ "bitflags", "errno", @@ -1831,9 +1892,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ "once_cell", "ring", @@ -1845,9 +1906,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -1855,9 +1916,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1872,15 +1933,15 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -1907,12 +1968,12 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -1920,9 +1981,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -1930,9 +1991,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1966,15 +2027,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -2039,10 +2100,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2058,21 +2120,21 @@ dependencies = [ [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -2085,12 +2147,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2153,7 +2215,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2229,7 +2291,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -2237,7 +2299,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2269,14 +2331,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2302,7 +2364,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2333,9 +2395,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.108" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -2364,12 +2426,12 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2385,12 +2447,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2407,11 +2469,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2427,9 +2489,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2447,30 +2509,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2478,9 +2540,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -2488,9 +2550,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -2503,9 +2565,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2520,9 +2582,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -2551,9 +2613,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -2574,9 +2636,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2587,9 +2649,9 @@ dependencies = [ [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -2603,9 +2665,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags", "bytes", @@ -2633,9 +2695,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -2645,9 +2707,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2656,9 +2718,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2677,9 +2739,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -2711,7 +2773,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.5", + "rand 0.8.6", "sha1", "thiserror 1.0.69", "utf-8", @@ -2719,9 +2781,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicode-bidi" @@ -2731,9 +2793,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -2750,6 +2812,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "untrusted" version = "0.9.0" @@ -2758,9 +2826,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2782,13 +2850,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.18.1" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -2827,11 +2895,20 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -2842,9 +2919,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -2855,22 +2932,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2878,9 +2952,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -2891,18 +2965,52 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -2920,9 +3028,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -2945,9 +3053,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -2972,12 +3080,6 @@ dependencies = [ "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -2986,22 +3088,13 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" -dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-result" -version = "0.3.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3010,16 +3103,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -3028,7 +3112,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3064,7 +3148,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3104,7 +3188,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3255,21 +3339,109 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3278,9 +3450,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -3290,18 +3462,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -3310,18 +3482,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -3337,9 +3509,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -3348,9 +3520,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -3359,11 +3531,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 9830f2d..c9cb9cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] -axum = { version = "0.7", features = ["ws", "macros"] } +axum = { version = "0.8", features = ["ws", "macros"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "full"] } tracing = "0.1" @@ -17,13 +17,13 @@ serde_json = "1.0" dotenvy = "0.15" anyhow = "1.0" sqlx = { version = "0.8.6", features = ["runtime-tokio", "macros", "postgres", "uuid", "chrono"] } -uuid = {version = "1.18.1", features = ["serde", "v4"]} -chrono = {version = "0.4.42", features = ["serde"]} +uuid = {version = "1.23.1", features = ["serde", "v4"]} +chrono = {version = "0.4.44", features = ["serde"]} ed25519-dalek = { version = "2.2.0", features = ["rand_core", "serde"] } -jsonwebtoken = {version = "10.0.0", features = ["rust_crypto"]} +jsonwebtoken = {version = "10.3.0", features = ["rust_crypto"]} base64 = "0.22.1" -rsa = "0.9.8" -reqwest = { version = "0.12.24", features = ["json", "rustls-tls"] } -sha2 = "0.10" +rsa = "0.9.10" +reqwest = { version = "0.13.2", features = ["json", "rustls-tls"] } +sha2 = "0.11" hex = "0.4" # TODO : Monitor the RSA and ed25519-dalek crates for updates and security patches. \ No newline at end of file diff --git a/src/federation/client.rs b/src/federation/client.rs index 5c69ef4..d8a6492 100644 --- a/src/federation/client.rs +++ b/src/federation/client.rs @@ -104,25 +104,6 @@ impl FederationClient { .context("invalid ack in peer response") } - /// Query the peer's public node info (used for bootstrapping / key pinning). - pub async fn fetch_node_info(&self, api_url: &str) -> Result { - // /s2s/info does not require authentication, so this is an unsigned GET. - let body = self - .http - .get(format!("{api_url}/s2s/info")) - .send() - .await - .context("GET /s2s/info failed")? - .error_for_status() - .context("peer info endpoint returned error")? - .text() - .await - .context("failed to read node info response body")?; - - let leaked_body: &'static str = Box::leak(body.into_boxed_str()); - serde_json::from_str::(leaked_body).context("invalid node info response") - } - // ── Private helpers ─────────────────────────────────────────────────────── async fn signed_get(&self, url: &str) -> Result { diff --git a/src/repository/message_repository.rs b/src/repository/message_repository.rs index a02e0f3..88a6497 100644 --- a/src/repository/message_repository.rs +++ b/src/repository/message_repository.rs @@ -51,6 +51,7 @@ pub async fn insert_message( /// /// The unique constraint `uniq_message_per_device` (added in federation.sql) /// makes the ON CONFLICT clause safe without a preceding SELECT. +#[allow(clippy::too_many_arguments)] pub async fn insert_federated_message( pool: &PgPool, logical_msg_id: &str, diff --git a/src/repository/user_repository.rs b/src/repository/user_repository.rs index 330f653..fa2b35d 100644 --- a/src/repository/user_repository.rs +++ b/src/repository/user_repository.rs @@ -46,16 +46,6 @@ pub async fn find_user_by_pubkey(pool: &PgPool, pubkey_b64: &str) -> Result Result> { - let user = sqlx::query_as::<_, User>( - "SELECT id, username, created_at FROM users WHERE username = $1", - ) - .bind(username) - .fetch_optional(pool) - .await?; - Ok(user) -} - pub async fn find_user_by_id(pool: &PgPool, user_id: &uuid::Uuid) -> Result> { let user = sqlx::query_as!( User, From 77d11b25e7ba771388115600284e1603a73bf8bd Mon Sep 17 00:00:00 2001 From: Adam Elaoumari Date: Sun, 26 Apr 2026 11:04:47 +0200 Subject: [PATCH 23/23] Refactor dependencies and improve code structure - Updated `reqwest` to disable default features and added `async-trait` dependency. - Removed unused imports from `client.rs` to clean up the code. - Removed `async_trait` attribute from `AuthenticatedDevice` and `AuthenticatedNode` in middleware files to simplify implementation. - Fixed websocket message sending by converting JSON string to `Message::Text` directly. --- Cargo.lock | 656 +++++++++++++++++++---------------- Cargo.toml | 3 +- src/federation/client.rs | 2 +- src/middlewares/auth.rs | 2 - src/middlewares/node_auth.rs | 2 - src/realtime/websocket.rs | 2 +- 6 files changed, 367 insertions(+), 300 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a944a4b..694e7db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,17 +64,39 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", "axum-macros", "base64", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -87,8 +109,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -104,19 +125,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -125,9 +144,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -170,6 +189,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -195,9 +223,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -224,6 +260,25 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -240,14 +295,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "core-foundation" -version = "0.9.4" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "core-foundation" @@ -274,6 +325,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -326,6 +386,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -333,9 +402,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -365,7 +434,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -385,12 +454,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -408,6 +488,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecdsa" version = "0.16.9" @@ -415,7 +501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -443,7 +529,7 @@ dependencies = [ "ed25519", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -465,7 +551,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -478,15 +564,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -525,12 +602,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - [[package]] name = "ff" version = "0.13.1" @@ -564,33 +635,12 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -600,6 +650,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -733,25 +789,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.15.5" @@ -805,7 +842,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -867,6 +904,7 @@ name = "hushnet-backend" version = "0.0.1-alpha" dependencies = [ "anyhow", + "async-trait", "axum", "base64", "chrono", @@ -878,7 +916,7 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "sqlx", "tokio", "tracing", @@ -886,6 +924,15 @@ dependencies = [ "uuid", ] +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -896,7 +943,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -921,23 +967,6 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", ] [[package]] @@ -958,11 +987,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1132,6 +1159,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -1162,7 +1243,7 @@ dependencies = [ "rsa", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "signature", "simple_asn1", ] @@ -1216,12 +1297,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1260,9 +1335,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" @@ -1271,7 +1346,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1297,23 +1372,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1391,50 +1449,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "p256" version = "0.13.2" @@ -1444,7 +1464,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1456,7 +1476,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1630,6 +1650,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -1776,37 +1797,31 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -1815,7 +1830,6 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", ] [[package]] @@ -1848,8 +1862,8 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -1877,33 +1891,32 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" dependencies = [ + "aws-lc-rs", "once_cell", - "ring", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1914,12 +1927,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1937,6 +1978,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1973,7 +2023,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation 0.10.1", + "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", @@ -2068,8 +2118,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2079,8 +2129,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", ] [[package]] @@ -2114,7 +2175,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -2213,7 +2274,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tokio", @@ -2251,7 +2312,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -2274,7 +2335,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2295,7 +2356,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2334,7 +2395,7 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2424,40 +2485,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2591,16 +2618,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2624,9 +2641,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -2634,19 +2651,6 @@ dependencies = [ "tungstenite", ] -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - [[package]] name = "tower" version = "0.5.3" @@ -2763,20 +2767,18 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.6", + "rand 0.9.4", "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror 2.0.18", ] [[package]] @@ -2836,12 +2838,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2878,6 +2874,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3027,10 +3033,10 @@ dependencies = [ ] [[package]] -name = "webpki-roots" +name = "webpki-root-certs" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] @@ -3045,6 +3051,15 @@ dependencies = [ "wasite", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3086,17 +3101,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -3115,6 +3119,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -3151,6 +3164,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3199,6 +3227,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3217,6 +3251,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3235,6 +3275,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3265,6 +3311,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3283,6 +3335,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3301,6 +3359,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3319,6 +3383,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" diff --git a/Cargo.toml b/Cargo.toml index c9cb9cc..a1bf52c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,8 @@ ed25519-dalek = { version = "2.2.0", features = ["rand_core", "serde"] } jsonwebtoken = {version = "10.3.0", features = ["rust_crypto"]} base64 = "0.22.1" rsa = "0.9.10" -reqwest = { version = "0.13.2", features = ["json", "rustls-tls"] } +reqwest = { version = "0.13.2", default-features = false, features = ["json", "rustls"] } +async-trait = "0.1" sha2 = "0.11" hex = "0.4" # TODO : Monitor the RSA and ed25519-dalek crates for updates and security patches. \ No newline at end of file diff --git a/src/federation/client.rs b/src/federation/client.rs index d8a6492..9e4d52d 100644 --- a/src/federation/client.rs +++ b/src/federation/client.rs @@ -34,7 +34,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ models::{ device::DeviceBundle, - federation::{NodeInfo, S2sAck, S2sMessagePayload, S2sSessionPayload}, + federation::{S2sAck, S2sMessagePayload, S2sSessionPayload}, }, utils::node_keys::NodeKeys, }; diff --git a/src/middlewares/auth.rs b/src/middlewares/auth.rs index 988d777..eeeca50 100644 --- a/src/middlewares/auth.rs +++ b/src/middlewares/auth.rs @@ -1,7 +1,6 @@ // src/middlewares/auth.rs use crate::{app_state::AppState, models::device::Devices, repository::device_repository}; use axum::{ - async_trait, extract::FromRequestParts, http::{request::Parts, StatusCode}, }; @@ -10,7 +9,6 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; pub struct AuthenticatedDevice(pub Devices); -#[async_trait] impl FromRequestParts for AuthenticatedDevice { type Rejection = (StatusCode, String); diff --git a/src/middlewares/node_auth.rs b/src/middlewares/node_auth.rs index 8f0c678..acbc6a6 100644 --- a/src/middlewares/node_auth.rs +++ b/src/middlewares/node_auth.rs @@ -37,7 +37,6 @@ use crate::{ repository::federation_repository, }; use axum::{ - async_trait, extract::FromRequestParts, http::{request::Parts, StatusCode}, }; @@ -56,7 +55,6 @@ use ed25519_dalek::{Signature, Verifier, VerifyingKey}; /// ``` pub struct AuthenticatedNode(pub FederationNode); -#[async_trait] impl FromRequestParts for AuthenticatedNode { type Rejection = (StatusCode, String); diff --git a/src/realtime/websocket.rs b/src/realtime/websocket.rs index 2fe22b6..539e241 100644 --- a/src/realtime/websocket.rs +++ b/src/realtime/websocket.rs @@ -46,7 +46,7 @@ async fn handle_socket( "WS dispatching event to client" ); if let Ok(json) = serde_json::to_string(&event) { - if socket.send(Message::Text(json)).await.is_err() { + if socket.send(Message::Text(json.into())).await.is_err() { info!(%user_id, "WS send failed, closing"); break; }