harden: relay timeouts, DNS resolver reuse, cache hygiene + frontend polish

Server (relay/SSRF):
- Bound connect+TLS+request under CONNECT_TIMEOUT and body download under
  BODY_TIMEOUT so a stalling upstream can't pin a relay permit indefinitely
- Add inbound header_read_timeout to defeat slow-loris connections
- Allowlist relayed Content-Type (fall back to octet-stream) to block
  content-confusion from attacker-controlled stream origins
- Reuse one DNS resolver process-wide instead of rebuilding it per request
  (restores hickory's DNS cache; drops per-segment resolver setup)
- Probe cache: random per-process hash seed (DefaultHasher was deterministic)
  + bounded growth with expired-entry eviction
- Real Ctrl-C handler for a clean shutdown (honours the banner)
- Collapse the wrap_raw no-op alias into wrap

Frontend:
- Guide: visible focus ring tracking arrow-key navigation
- Player: buffering/tuning indicator while <video> is stalled
- prefers-contrast: more pass (lifts --ink-dim to meet WCAG AA)
- Service worker: date-stamped cache names so shell updates propagate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:20:06 -04:00
parent a1e3e6852f
commit 0b761f684b
7 changed files with 218 additions and 72 deletions
+56 -20
View File
@@ -7,9 +7,10 @@
mod relay;
mod ssrf;
use std::collections::hash_map::RandomState;
use std::collections::HashMap;
use std::convert::Infallible;
use std::hash::{Hash, Hasher};
use std::hash::{BuildHasher, Hash, Hasher};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -36,6 +37,12 @@ use relay::{
const MAX_INFLIGHT_RELAY: usize = 32;
const PROBE_TIMEOUT: Duration = Duration::from_secs(6);
const PROBE_TTL: Duration = Duration::from_secs(300);
/// Bound how long a client may take to send its request headers — defeats
/// slow-loris connections that would otherwise hold a Tokio task open forever.
const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
/// Hard ceiling on cached probe entries; past this we evict expired ones (and,
/// if still full, clear) so the cache can't grow without bound over a long run.
const PROBE_CACHE_MAX: usize = 4096;
#[derive(Default)]
struct Metrics {
@@ -57,6 +64,10 @@ struct AppState {
metrics: Metrics,
relay_gate: Semaphore,
probe_cache: Mutex<HashMap<u64, ProbeCacheEntry>>,
// Random per-process seed for the probe-cache key. DefaultHasher is
// deterministic across runs, so an attacker could engineer URL collisions;
// RandomState reseeds every boot, making the key unpredictable.
cache_hasher: RandomState,
}
#[tokio::main]
@@ -75,28 +86,44 @@ async fn main() {
metrics: Metrics::default(),
relay_gate: Semaphore::new(MAX_INFLIGHT_RELAY),
probe_cache: Mutex::new(HashMap::new()),
cache_hasher: RandomState::new(),
});
let listener = TcpListener::bind(bind).await.expect("bind");
eprintln!("The Pondering Orb is gazing on http://{bind} (Ctrl-C to close the eye)");
loop {
let (stream, _peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
log_line("accept_error", &e.to_string(), None, None);
continue;
}
};
let io = TokioIo::new(stream);
let st = state.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| handle(st.clone(), req));
if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
// client disconnects are noise; log at debug granularity only
let _ = e;
}
});
let serve = async {
loop {
let (stream, _peer) = match listener.accept().await {
Ok(v) => v,
Err(e) => {
log_line("accept_error", &e.to_string(), None, None);
continue;
}
};
let io = TokioIo::new(stream);
let st = state.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| handle(st.clone(), req));
let conn = http1::Builder::new()
.header_read_timeout(HEADER_READ_TIMEOUT)
.serve_connection(io, svc);
if let Err(e) = conn.await {
// client disconnects are noise; log at debug granularity only
let _ = e;
}
});
}
};
// Honour the banner: a real Ctrl-C handler exits cleanly instead of relying
// on the default SIGINT kill (and keeps tokio's `signal` feature in use).
tokio::select! {
_ = serve => {}
_ = tokio::signal::ctrl_c() => {
eprintln!("\nThe eye closes.");
log_line("shutdown", "ctrl-c", None, None);
}
}
}
@@ -217,8 +244,9 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
_ => return json(StatusCode::BAD_REQUEST, "{\"ok\":false,\"status\":0,\"latency_ms\":0}"),
};
// hash the URL for the cache key (never store the raw URL — token hygiene)
let mut hasher = std::collections::hash_map::DefaultHasher::new();
// hash the URL for the cache key (never store the raw URL — token hygiene).
// Random per-process seed → keys aren't attacker-predictable across runs.
let mut hasher = state.cache_hasher.build_hasher();
raw.hash(&mut hasher);
let key = hasher.finish();
@@ -244,6 +272,14 @@ async fn serve_probe(state: &AppState, req: &Request<Incoming>) -> Response<Full
state.metrics.probe_dead.fetch_add(1, Ordering::Relaxed);
}
if let Ok(mut cache) = state.probe_cache.lock() {
// Bound growth: at the ceiling, drop expired entries first; if that
// doesn't free space (all entries still fresh), clear and start over.
if cache.len() >= PROBE_CACHE_MAX {
cache.retain(|_, (ts, ..)| ts.elapsed() < PROBE_TTL);
if cache.len() >= PROBE_CACHE_MAX {
cache.clear();
}
}
cache.insert(key, (Instant::now(), outcome.ok, outcome.status, outcome.latency_ms));
}
log_line("probe", if outcome.ok { "live" } else { "dead" }, parsed.host_str(), Some(outcome.latency_ms));
+83 -41
View File
@@ -25,6 +25,12 @@ const MAX_MANIFEST: usize = 10 * 1024 * 1024; // 10 MB
const MAX_SEGMENT: usize = 64 * 1024 * 1024; // 64 MB
const MAX_REDIRECTS: u32 = 4;
const UA: &str = "PonderingOrb/0.1 (+hardened-relay)";
/// Bound connect + TLS handshake + request → response-headers. A stalling
/// upstream must never pin one of the relay's permits open indefinitely.
const CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Bound the body download too — the size cap stops a *large* body, but a
/// slow-drip body would otherwise hold the permit far longer than that implies.
const BODY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug)]
pub enum RelayError {
@@ -36,6 +42,7 @@ pub enum RelayError {
Http(String),
TooLarge,
TooManyRedirects,
Timeout,
Upstream(StatusCode),
}
@@ -50,6 +57,7 @@ impl std::fmt::Display for RelayError {
RelayError::Http(e) => write!(f, "http: {e}"),
RelayError::TooLarge => write!(f, "response exceeds size cap"),
RelayError::TooManyRedirects => write!(f, "too many redirects"),
RelayError::Timeout => write!(f, "upstream timed out"),
RelayError::Upstream(s) => write!(f, "upstream status {s}"),
}
}
@@ -105,43 +113,49 @@ async fn fetch_once(
.await
.map_err(RelayError::Ssrf)?;
let tcp = TcpStream::connect(addr)
.await
.map_err(|e| RelayError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
// Bound connect → TLS → request → response-headers as one unit. A stalling
// upstream cannot hold one of the 32 relay permits open past CONNECT_TIMEOUT.
let resp = tokio::time::timeout(CONNECT_TIMEOUT, async {
let tcp = TcpStream::connect(addr)
.await
.map_err(|e| RelayError::Connect(e.to_string()))?;
let _ = tcp.set_nodelay(true);
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
let tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| RelayError::Tls(e.to_string()))?;
let server_name = rustls::pki_types::ServerName::try_from(host.clone())
.map_err(|_| RelayError::Tls("invalid sni".into()))?;
let tls = connector
.connect(server_name, tcp)
.await
.map_err(|e| RelayError::Tls(e.to_string()))?;
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
tokio::spawn(async move {
let _ = conn.await;
});
let io = TokioIo::new(tls);
let (mut sender, conn) = http1::handshake(io)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
tokio::spawn(async move {
let _ = conn.await;
});
let mut pq = url.path().to_string();
if let Some(q) = url.query() {
pq.push('?');
pq.push_str(q);
}
let req = Request::builder()
.uri(pq)
.header(hyper::header::HOST, host.as_str())
.header(hyper::header::USER_AGENT, UA)
.header(hyper::header::ACCEPT, "*/*")
.body(Empty::<Bytes>::new())
.map_err(|e| RelayError::Http(e.to_string()))?;
let mut pq = url.path().to_string();
if let Some(q) = url.query() {
pq.push('?');
pq.push_str(q);
}
let req = Request::builder()
.uri(pq)
.header(hyper::header::HOST, host.as_str())
.header(hyper::header::USER_AGENT, UA)
.header(hyper::header::ACCEPT, "*/*")
.body(Empty::<Bytes>::new())
.map_err(|e| RelayError::Http(e.to_string()))?;
let resp = sender
.send_request(req)
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
sender
.send_request(req)
.await
.map_err(|e| RelayError::Http(e.to_string()))
})
.await
.map_err(|_| RelayError::Timeout)??;
let status = resp.status();
let content_type = resp
@@ -171,18 +185,48 @@ async fn fetch_once(
}
let limited = Limited::new(resp.into_body(), max_bytes);
let collected = limited.collect().await.map_err(|_| RelayError::TooLarge)?;
let collected = tokio::time::timeout(BODY_TIMEOUT, limited.collect())
.await
.map_err(|_| RelayError::Timeout)?
.map_err(|_| RelayError::TooLarge)?;
let body = collected.to_bytes();
Ok(Upstream {
status,
content_type,
content_type: sanitize_content_type(&content_type),
body,
final_url: url.clone(),
redirects: 0,
})
}
/// Clamp the upstream-declared Content-Type to a known-safe media allowlist.
/// An attacker-controlled stream origin could otherwise label segment bytes as
/// `text/html` or `application/javascript`; if a tab ever navigates straight to
/// a /relay URL, that declared type (not just sniffing, which nosniff blocks)
/// would govern interpretation. Anything off-list collapses to octet-stream.
fn sanitize_content_type(ct: &str) -> String {
// Compare on the bare type, ignoring any `; charset=`/`; codecs=` parameters.
let base = ct.split(';').next().unwrap_or("").trim().to_ascii_lowercase();
const ALLOWED: &[&str] = &[
"application/vnd.apple.mpegurl",
"application/x-mpegurl",
"audio/mpegurl",
"audio/x-mpegurl",
"video/mp2t",
"video/mp4",
"video/iso.segment",
"audio/aac",
"audio/mp4",
"application/octet-stream",
];
if ALLOWED.contains(&base.as_str()) {
ct.to_string()
} else {
"application/octet-stream".to_string()
}
}
/// Public entry: validated GET with bounded, re-validated redirect following.
pub async fn fetch_validated(
connector: &TlsConnector,
@@ -350,7 +394,7 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
Ok(abs) => {
let mut s = String::with_capacity(line.len() + 40);
s.push_str(&line[..uri_start]);
s.push_str(&wrap_raw(&abs));
s.push_str(&wrap(&abs));
s.push_str(&line[end..]);
s
}
@@ -358,14 +402,12 @@ fn rewrite_tag_uris(line: &str, base: &Url) -> String {
}
}
/// "/relay?url=<pct-encoded absolute>"
/// "/relay?url=<pct-encoded absolute>". The percent-encoding leaves only
/// RFC3986-unreserved bytes, so the result is safe both on a bare URI line and
/// inside an already-quoted `URI="..."` tag attribute (no `"` can appear).
fn wrap(abs: &Url) -> String {
format!("/relay?url={}", pct(abs.as_str()))
}
fn wrap_raw(abs: &Url) -> String {
// for inside an already-quoted attribute
wrap(abs)
}
/// Minimal RFC3986 component percent-encoding (encode everything non-unreserved).
fn pct(s: &str) -> String {
+14 -5
View File
@@ -7,10 +7,23 @@
//! between check and connect cannot smuggle us onto an internal address.
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::OnceLock;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use hickory_resolver::TokioAsyncResolver;
/// Built once on first use and reused for the process lifetime. Rebuilding the
/// resolver per request re-read /etc/resolv.conf and — worse — discarded
/// hickory's in-memory DNS cache, forcing a fresh lookup for every HLS segment.
static RESOLVER: OnceLock<TokioAsyncResolver> = OnceLock::new();
fn resolver() -> &'static TokioAsyncResolver {
RESOLVER.get_or_init(|| {
TokioAsyncResolver::tokio_from_system_conf()
.unwrap_or_else(|_| TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()))
})
}
#[derive(Debug)]
pub enum SsrfError {
Resolve(String),
@@ -78,11 +91,7 @@ pub async fn resolve_and_validate(host: &str, port: u16) -> Result<SocketAddr, S
return Ok(SocketAddr::new(ip, port));
}
let resolver = match TokioAsyncResolver::tokio_from_system_conf() {
Ok(r) => r,
Err(_) => TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()),
};
let lookup = resolver
let lookup = resolver()
.lookup_ip(host)
.await
.map_err(|e| SsrfError::Resolve(e.to_string()))?;
+9 -3
View File
@@ -112,7 +112,7 @@ export class Guide {
_renderRow(ch, idx) {
const row = document.createElement('div');
row.className = 'guide-row';
row.className = 'guide-row' + (idx === this.focusRow ? ' focused' : '');
row.style.position = 'absolute';
row.style.top = (idx * ROW_H) + 'px';
row.style.left = '0'; row.style.right = '0';
@@ -189,12 +189,18 @@ export class Guide {
_onKey(e) {
const list = this.agg.channels;
switch (e.key) {
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._scrollToFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._scrollToFocus(); break;
case 'ArrowDown': e.preventDefault(); this.focusRow = Math.min(list.length-1, this.focusRow+1); this._moveFocus(); break;
case 'ArrowUp': e.preventDefault(); this.focusRow = Math.max(0, this.focusRow-1); this._moveFocus(); break;
case 'Enter': e.preventDefault(); { const ch = list[this.focusRow]; if (ch){ this.onTune(ch); this.hide(); } } break;
case 'Escape': e.preventDefault(); this.hide(); break;
}
}
// Scroll the focused row into view, then repaint so the .focused highlight
// tracks the cursor even when no scroll was needed (scroll event wouldn't fire).
_moveFocus() {
this._scrollToFocus();
this._renderViewport();
}
_scrollToFocus() {
const y = this.focusRow * ROW_H;
if (y < this.scroll.scrollTop) this.scroll.scrollTop = y;
+21 -1
View File
@@ -27,6 +27,7 @@ export class Player {
this._fallbackIdx = 0;
this._wireControls();
this._wireActivity();
this._wireBuffering();
}
/**
@@ -93,12 +94,31 @@ export class Player {
if (this.hls) { try { this.hls.destroy(); } catch {} this.hls = null; }
this.video.removeAttribute('src');
try { this.video.load(); } catch {}
this._setBuffering(false); // never leave the indicator stuck across a zap
}
destroy() { this._teardown(); }
// ---------- buffering / stall indicator ----------
// Bridge the gap between the scrying static clearing and the first frame:
// without a hint, a slow-loading stream is indistinguishable from a dead one.
_wireBuffering() {
const v = this.video;
const on = () => this._setBuffering(true);
const off = () => this._setBuffering(false);
v.addEventListener('waiting', on);
v.addEventListener('stalled', on);
v.addEventListener('playing', off);
v.addEventListener('canplay', off);
v.addEventListener('pause', off);
v.addEventListener('emptied', off);
}
_setBuffering(on) {
if (this.els.screen) this.els.screen.classList.toggle('buffering', on);
}
// ---------- dead-stream "the orb clouds over" ----------
_showNoSignal() { this.els.nosignal.hidden = false; }
_showNoSignal() { this.els.nosignal.hidden = false; this._setBuffering(false); }
_hideNoSignal() { this.els.nosignal.hidden = true; }
// ---------- live-aware chrome ----------
+30
View File
@@ -91,6 +91,19 @@ img{display:block}
transition:opacity .12s linear}
.static.show{opacity:.9}
/* ===================== buffering / tuning ===================== */
/* Corner hint shown while <video> is stalled — top-left keeps clear of the
brand bug (top-right) and the channel banner (bottom-left). */
.screen.buffering .bezel::after{
content:"\21BB tuning\2026";
position:absolute;left:18px;top:14px;z-index:7;
font-size:12px;letter-spacing:2px;color:var(--red);
text-shadow:0 0 10px var(--red-glow);
background:var(--osd-bg);border:1px solid var(--line);
padding:4px 10px;border-radius:6px;
animation:orb-buf 1.1s ease-in-out infinite}
@keyframes orb-buf{0%,100%{opacity:.45}50%{opacity:1}}
/* ===================== the orb clouds over ===================== */
.nosignal{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;
gap:10px;background:#050102;text-align:center;padding:20px}
@@ -155,6 +168,10 @@ img{display:block}
.guide-chcell{position:sticky;left:0;z-index:2;flex:0 0 var(--ch-col,150px);display:flex;align-items:center;gap:8px;
padding:0 8px;background:var(--panel-2);border-right:1px solid var(--line);cursor:pointer}
.guide-chcell:hover{background:var(--red-deep)}
/* keyboard cursor: arrow-key navigation marks the focused row so it isn't blind */
.guide-row.focused{background:rgba(255,43,43,.07)}
.guide-row.focused .guide-chcell{background:var(--red-deep);
box-shadow:inset 3px 0 0 var(--red),0 0 12px rgba(255,43,43,.25)}
.guide-chcell .num{color:var(--red);font-weight:bold;font-variant-numeric:tabular-nums;min-width:24px}
.guide-chcell .nm{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.guide-chcell .skull{margin-left:auto;opacity:.55;font-size:12px}
@@ -202,6 +219,19 @@ kbd{background:#000;border:1px solid var(--line);border-bottom-width:2px;border-
.brand-bug{display:none}
}
/* ===================== high-contrast pass (a11y) ===================== */
/* Default --ink-dim (#c98b86 on #050102 ≈ 3.5:1) misses WCAG AA for the small
text it's used on everywhere (OSD sub, guide names, time labels, help dd).
Lift it and strengthen panels/borders only when the user asks for it. */
@media (prefers-contrast: more){
:root{
--ink-dim:#ffb8b0;
--line:#5a1416;
--panel:#1e0608;
--panel-2:#260a0c;
}
}
/* ===================== reduced motion kill-switch ===================== */
@media (prefers-reduced-motion: reduce){
.crt-vignette{animation:none}
+5 -2
View File
@@ -9,8 +9,11 @@
// NEVER cached — the relay marks them no-store and we bypass them here, so the SW
// is the sole freshness authority for metadata only.
const SHELL = 'orb-shell-v1';
const DATA = 'orb-data-v1';
// Bump these on every shell change so returning visitors actually receive the
// new assets — a static 'v1' name would serve stale JS/CSS indefinitely. The
// activate handler deletes any cache whose name isn't in this current pair.
const SHELL = 'orb-shell-20260615';
const DATA = 'orb-data-20260615';
const SHELL_ASSETS = [
'/', '/index.html', '/styles.css',