Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b463409
fix(forwarder): pin static receivers so server allow-list snapshots c…
iwismer Jul 3, 2026
96b4343
docs(forwarder): clarify allow-list pinning contracts; add static-rec…
iwismer Jul 3, 2026
4cbb7a2
fix(forwarder): enforce negotiated capabilities and add control.allow…
iwismer Jul 3, 2026
93bb10c
test(forwarder): cover ConfigGet capability gate; document gate-vs-ha…
iwismer Jul 3, 2026
e6a44ff
fix(forwarder): write capability-gate denials inline to avoid control…
iwismer Jul 3, 2026
82e0398
fix(forwarder): block P2P remote-config writes to protected auth/p2p/…
iwismer Jul 3, 2026
7a4dc69
test(forwarder): guard remote-config round-trip with populated protec…
iwismer Jul 3, 2026
5a0d687
refactor(forwarder): remove unrestricted full-document config writer;…
iwismer Jul 3, 2026
77caab9
feat(forwarder): log peer node id for P2P control-plane verbs
iwismer Jul 3, 2026
3ad2a0f
fix(forwarder): log capability denials at warn, escape wire-controlle…
iwismer Jul 3, 2026
cd8c5bc
test(e2e): assert remote [control] writes are rejected; gate remote c…
iwismer Jul 3, 2026
33014ea
fix(forwarder): validate per-section config writes with the canonical…
iwismer Jul 3, 2026
8097fa9
test(forwarder): make config token-file fixtures hermetic (no shared …
iwismer Jul 3, 2026
8a01f22
fix(forwarder): retry journal appends with backoff instead of droppin…
iwismer Jul 3, 2026
912a8ff
feat(server): self-scoped GET /forwarder/catalog for stream high-wate…
iwismer Jul 3, 2026
192d90c
fix(forwarder): restore stream epoch/next_seq from server registry af…
iwismer Jul 3, 2026
ce4b240
fix(forwarder): treat fresh p2p identity as benign first boot in stre…
iwismer Jul 3, 2026
65ddb3d
perf(forwarder): serve P2P replay reads from per-subscriber read-only…
iwismer Jul 3, 2026
da19057
refactor(forwarder): drop dead P2pEndpoint::bind and trim ReadJournal…
iwismer Jul 3, 2026
b8787a6
refactor(forwarder): extract config persistence into config_service m…
iwismer Jul 3, 2026
fcb65e9
refactor(forwarder): make write_atomic private to config_service
iwismer Jul 3, 2026
9fe2225
refactor(forwarder): extract status store and decouple data plane
iwismer Jul 3, 2026
2b17212
refactor(forwarder): move display projection and updater state out of…
iwismer Jul 3, 2026
1e1666b
test(forwarder): keep control action tests with config service
iwismer Jul 3, 2026
0a915d3
refactor(rt-p2p-protocol): own length-prefixed frame decode in the pr…
iwismer Jul 3, 2026
190fb7e
refactor(forwarder): split server HTTP clients out of allowlist module
iwismer Jul 3, 2026
7ccffa3
fix(forwarder): retry device-token bootstrap in-process and re-poll a…
iwismer Jul 3, 2026
65caf07
test(forwarder): gate permission-based retry test to unix; fix stale …
iwismer Jul 3, 2026
a22a21d
fix(forwarder): serve current reader connectivity and generation in t…
iwismer Jul 3, 2026
8cab874
fix(forwarder): serve cached server reachability from the status endp…
iwismer Jul 3, 2026
5acf1da
fix(forwarder): notify UI on server-status changes; sync ServerDevice…
iwismer Jul 3, 2026
bb7ec82
refactor(forwarder): emit server-status event under lock; destructure…
iwismer Jul 3, 2026
495ffe8
fix(forwarder): validate heartbeat nonces, surface fanout drops, remo…
iwismer Jul 3, 2026
5819a74
fix(forwarder): accept pongs for any outstanding heartbeat nonce
iwismer Jul 3, 2026
b1e5480
docs(forwarder): update p2p module doc for production reader-control …
iwismer Jul 3, 2026
3c42646
fix(receiver-ui): make protected forwarder config sections read-only …
iwismer Jul 3, 2026
b994e96
fix(forwarder): adapt live-catalog test to iroh 1.0 connect-by-addr
iwismer Jul 4, 2026
d5b8aa3
fix(forwarder): address PR review findings (rename residue, lagged re…
iwismer Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/forwarder-ui/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ describe("forwarder api client", () => {
approval_state: "pending",
waiting_for_approval: true,
message: "Waiting for server admin approval",
cached: true,
checked_unix_ms: 1700000000000,
},
readers: [],
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/forwarder-ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export interface ServerDeviceStatus {
approval_state: string | null;
waiting_for_approval: boolean;
message: string | null;
cached: boolean;
checked_unix_ms: number | null;
}

export interface ForwarderStatus {
Expand Down
2 changes: 2 additions & 0 deletions apps/forwarder-ui/src/lib/config-load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ function makeStatus(restart_needed: boolean): ForwarderStatus {
approval_state: null,
waiting_for_approval: false,
message: null,
cached: true,
checked_unix_ms: null,
},
readers: [],
};
Expand Down
2 changes: 2 additions & 0 deletions apps/forwarder-ui/src/lib/reader-status-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ function makeStatus(readers: ForwarderStatus["readers"]): ForwarderStatus {
approval_state: null,
waiting_for_approval: false,
message: null,
cached: true,
checked_unix_ms: null,
},
readers,
};
Expand Down
11 changes: 10 additions & 1 deletion apps/forwarder-ui/src/lib/sse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { createSSE, type SseHandle } from "@rusty-timer/shared-ui/lib/sse";
import type { ReaderInfo, ReaderStatus, UpdateStatusResponse } from "./api";
import type {
ReaderInfo,
ReaderStatus,
ServerDeviceStatus,
UpdateStatusResponse,
} from "./api";

export type ForwarderSseCallbacks = {
onStatusChanged: (data: {
Expand All @@ -19,6 +24,7 @@ export type ForwarderSseCallbacks = {
available: boolean;
status: any | null;
}) => void;
onServerStatusChanged?: (payload: { server: ServerDeviceStatus }) => void;
};

let handle: SseHandle | null = null;
Expand Down Expand Up @@ -57,6 +63,9 @@ export function initSSE(callbacks: ForwarderSseCallbacks): void {
}) => {
callbacks.onUpsStatusChanged?.(data);
},
server_status_changed: (data: { server: ServerDeviceStatus }) => {
callbacks.onServerStatusChanged?.(data);
},
},
(connected) => {
callbacks.onConnectionChange(connected);
Expand Down
5 changes: 5 additions & 0 deletions apps/forwarder-ui/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,11 @@
onUpsStatusChanged: (payload) => {
upsState = { available: payload.available, status: payload.status };
},
onServerStatusChanged: (payload) => {
if (status) {
status = { ...status, server: payload.server };
}
},
onUpdateStatusChanged: (us) => {
if (
(us.status === "available" || us.status === "downloaded") &&
Expand Down
15 changes: 13 additions & 2 deletions apps/receiver-ui/src/lib/components/ForwarderConfigModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,15 @@

<Card title="P2P / Server">
<div class="space-y-3">
<p class="text-xs text-text-muted">
Managed locally on the forwarder — not editable remotely.
</p>
<label class="block text-sm font-medium text-text-secondary">
<span class="inline-flex items-center gap-2">
<input
class="accent-accent"
type="checkbox"
disabled
bind:checked={config.p2p.enabled}
/>
Enable P2P
Expand All @@ -270,16 +274,18 @@
<label class="block text-sm font-medium text-text-secondary">
Server URL
<input
class="mt-1 {inputClass}"
class="mt-1 {inputClass} opacity-50"
type="text"
disabled
bind:value={config.p2p.server_url}
/>
</label>
<label class="block text-sm font-medium text-text-secondary">
Server token file
<input
class="mt-1 {inputClass}"
class="mt-1 {inputClass} opacity-50"
type="text"
disabled
bind:value={config.p2p.server_token_file}
/>
</label>
Expand Down Expand Up @@ -322,11 +328,15 @@

<Card title="Control">
<div class="space-y-3">
<p class="text-xs text-text-muted">
Managed locally on the forwarder — not editable remotely.
</p>
<label class="block text-sm font-medium text-text-secondary">
<span class="inline-flex items-center gap-2">
<input
class="accent-accent"
type="checkbox"
disabled
bind:checked={config.control.allow_power_actions}
/>
Allow power actions
Expand All @@ -337,6 +347,7 @@
<input
class="accent-accent"
type="checkbox"
disabled
bind:checked={config.control.allow_remote_config}
/>
Allow remote config
Expand Down
43 changes: 43 additions & 0 deletions apps/receiver-ui/src/lib/forwarder-config-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,49 @@ describe("ForwarderConfigModal", () => {
expect(submitted.update?.mode).toBeUndefined();
});

it("renders protected sections read-only and round-trips their values verbatim", async () => {
render(ForwarderConfigModal, {
open: true,
endpointId: "endpoint-1",
displayName: "Timing Forwarder",
onClose: vi.fn(),
});

await screen.findByLabelText("Display name");

// Protected [p2p] and [control] inputs must be disabled.
expect(screen.getByLabelText("Enable P2P")).toBeDisabled();
expect(screen.getByLabelText("Server URL")).toBeDisabled();
expect(screen.getByLabelText("Server token file")).toBeDisabled();
expect(screen.getByLabelText("Allow power actions")).toBeDisabled();
expect(screen.getByLabelText("Allow remote config")).toBeDisabled();

// Each protected card explains why the fields are read-only.
expect(
screen.getAllByText(
"Managed locally on the forwarder — not editable remotely.",
),
).toHaveLength(2);

// Operational fields stay editable.
expect(screen.getByLabelText("Display name")).toBeEnabled();
expect(screen.getByLabelText("SQLite path")).toBeEnabled();

await fireEvent.click(screen.getByTestId("forwarder-config-save"));

await waitFor(() => {
expect(mockApi.setForwarderConfig).toHaveBeenCalled();
});

// Protected sections must round-trip unchanged — the forwarder rejects
// any write whose [auth]/[p2p]/[control] differ from the on-disk config.
const submitted = JSON.parse(mockApi.setForwarderConfig.mock.calls[0][1]);
const fetched = sampleConfig();
expect(submitted.p2p).toEqual(fetched.p2p);
expect(submitted.control).toEqual(fetched.control);
expect(submitted.auth).toEqual(fetched.auth);
});

it("shows a restart banner after a save that requires restart and restarts on demand", async () => {
mockApi.setForwarderConfig.mockResolvedValue({
ok: true,
Expand Down
28 changes: 24 additions & 4 deletions crates/rt-p2p-protocol/src/codec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,23 @@ pub fn encode_frame(message: &impl Message) -> Bytes {
buf.freeze()
}

/// Decodes and validates a little-endian frame payload length prefix.
pub fn decode_frame_len(len_buf: [u8; 4]) -> Result<usize, ProtocolError> {
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(ProtocolError::frame_too_large(len));
}

Ok(len)
}

/// Decodes one complete length-prefixed frame, if enough bytes are buffered.
pub fn decode_frame(buf: &mut BytesMut) -> Result<Option<Frame>, ProtocolError> {
if buf.len() < 4 {
return Ok(None);
}

let len = u32::from_le_bytes(buf[..4].try_into().expect("slice has 4 bytes")) as usize;
if len > MAX_FRAME_BYTES {
return Err(ProtocolError::frame_too_large());
}
let len = decode_frame_len(buf[..4].try_into().expect("slice has 4 bytes"))?;

if buf.len() < 4 + len {
return Ok(None);
Expand All @@ -42,6 +49,19 @@ pub fn decode_frame(buf: &mut BytesMut) -> Result<Option<Frame>, ProtocolError>
Ok(Some(buf.split_to(len).freeze()))
}

/// Decodes a protobuf message from a previously read length prefix and payload.
pub fn decode_frame_payload<M>(len_buf: [u8; 4], payload: &[u8]) -> Result<M, ProtocolError>
where
M: Message + Default,
{
let len = decode_frame_len(len_buf)?;
if payload.len() != len {
return Err(ProtocolError::protocol_violation());
}

M::decode(payload).map_err(ProtocolError::decode_error)
}

/// Decodes one complete length-prefixed protobuf message, if enough bytes are buffered.
pub fn decode_message_frame<M>(buf: &mut BytesMut) -> Result<Option<M>, ProtocolError>
where
Expand Down
12 changes: 11 additions & 1 deletion crates/rt-p2p-protocol/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ pub enum ProtocolError {
retryable: bool,
/// Stream the error pertains to, if any.
stream_id: Option<Vec<u8>>,
/// Advertised payload length.
length: usize,
},
/// A protobuf payload could not be decoded.
#[error("decode error: {source}")]
Expand Down Expand Up @@ -188,10 +190,18 @@ impl ProtocolError {
}
}

pub(crate) fn frame_too_large() -> Self {
pub(crate) fn frame_too_large(length: usize) -> Self {
Self::FrameTooLarge {
retryable: false,
stream_id: None,
length,
}
}

pub(crate) fn protocol_violation() -> Self {
Self::ProtocolViolation {
retryable: false,
stream_id: None,
}
}

Expand Down
5 changes: 4 additions & 1 deletion crates/rt-p2p-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ pub mod proto {
}

pub use capabilities::{CAP_CONTROL_EVENTS, CAP_READER_CONTROL, CAP_REMOTE_CONFIG, has_capability};
pub use codec::{Frame, MAX_FRAME_BYTES, decode_frame, decode_message_frame, encode_frame};
pub use codec::{
Frame, MAX_FRAME_BYTES, decode_frame, decode_frame_len, decode_frame_payload,
decode_message_frame, encode_frame,
};
pub use error::{ProtocolError, ProtocolErrorCode};
pub use generated::{
Ack, CaughtUp, ConfigGetRequest, ConfigGetResponse, ConfigSetRequest, ConfigSetResponse,
Expand Down
35 changes: 34 additions & 1 deletion crates/rt-p2p-protocol/tests/codec_negotiate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use bytes::{BufMut, BytesMut};
use prost::Message;
use rt_p2p_protocol::{
CAP_CONTROL_EVENTS, CAP_REMOTE_CONFIG, Hello, Ping, ProtocolError, decode_frame,
decode_message_frame, encode_frame, has_capability, negotiate,
decode_frame_payload, decode_message_frame, encode_frame, has_capability, negotiate,
};

#[test]
Expand All @@ -26,6 +26,39 @@ fn roundtrip_frame() {
assert_eq!(decoded, original);
}

#[test]
fn decode_frame_payload_roundtrips_encoded_message() {
let original = Ping { nonce: 42 };
let encoded = encode_frame(&original);
let len_buf = encoded[..4].try_into().expect("length prefix");

let decoded = decode_frame_payload::<Ping>(len_buf, &encoded[4..]).expect("decode payload");

assert_eq!(decoded, original);
}

#[test]
fn decode_frame_payload_rejects_oversize_prefix() {
let len_buf = u32::try_from(8 * 1024 * 1024 + 1)
.expect("fits u32")
.to_le_bytes();

let err = decode_frame_payload::<Ping>(len_buf, &[]).expect_err("oversize frame");

assert!(matches!(err, ProtocolError::FrameTooLarge { .. }));
}

#[test]
fn decode_frame_payload_rejects_payload_shorter_than_prefix() {
let encoded = encode_frame(&Ping { nonce: 42 });
let len_buf = encoded[..4].try_into().expect("length prefix");

let err = decode_frame_payload::<Ping>(len_buf, &encoded[4..encoded.len() - 1])
.expect_err("truncated payload");

assert!(matches!(err, ProtocolError::ProtocolViolation { .. }));
}

#[test]
fn partial_read_returns_none() {
let encoded = encode_frame(&Ping { nonce: 7 });
Expand Down
8 changes: 8 additions & 0 deletions crates/rt-server-api/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,11 @@ pub struct ForwarderCatalogResponse {
pub endpoint_id: String,
pub stream_count: usize,
}

/// Wire-format response for `GET /forwarder/catalog`: the caller's own stored
/// stream catalog (per-stream `epoch`/`next_seq` high-water), used by a forwarder
/// to restore stream identity after local journal loss.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForwarderOwnCatalogResponse {
pub streams: Vec<ForwarderCatalogStream>,
}
10 changes: 3 additions & 7 deletions crates/rt-test-utils/src/p2p/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ use std::time::Duration;

use prost::Message;
use rt_iroh::{RecvStream, SendStream};
use rt_p2p_protocol::{MAX_FRAME_BYTES, encode_frame};
use rt_p2p_protocol::{decode_frame_len, decode_frame_payload, encode_frame};

pub use mock_forwarder::{ForwarderScript, MockForwarderPeer};
pub use mock_receiver::{DataSubscription, MockReceiverPeer, ReceiverSession};
Expand All @@ -47,14 +47,10 @@ where
{
let mut len_buf = [0u8; 4];
recv.read_exact(&mut len_buf).await?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(format!("frame length {len} exceeds MAX_FRAME_BYTES {MAX_FRAME_BYTES}").into());
}

let len = decode_frame_len(len_buf)?;
let mut payload = vec![0u8; len];
recv.read_exact(&mut payload).await?;
Ok(M::decode(payload.as_slice())?)
Ok(decode_frame_payload(len_buf, payload.as_slice())?)
}

/// Test-only connectivity fault shim.
Expand Down
Loading
Loading