diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d5af635..c999664 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,13 @@ jobs: target: x86_64-unknown-linux-musl archive: tar.gz musl: true + packages: true + # arm64 Linux — the prerequisite for arm64 .deb/.apk and multi-arch + # images, which had no upstream binary at all before this. + - os: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + archive: tar.gz + packages: true - os: macos-latest target: aarch64-apple-darwin archive: tar.gz @@ -91,6 +98,22 @@ jobs: sha256 "${pkg}.tar.gz" > "${pkg}.tar.gz.sha256" fi + # Distro packages, from the binaries this job just built. One nfpm + # config yields deb + apk + rpm, so the file list, maintainer scripts + # and systemd units cannot drift between formats — the failure mode of + # keeping a debian/ tree, an APKBUILD and a .spec in parallel. + - name: Build distro packages + if: matrix.packages + run: | + set -euo pipefail + # dpkg's own architecture name is what nfpm's .deb assets use, so + # this resolves on both amd64 and arm64 runners without a case. + arch="$(dpkg --print-architecture)" + curl -sfL -o /tmp/nfpm.deb \ + "https://github.com/goreleaser/nfpm/releases/latest/download/nfpm_${arch}.deb" + sudo dpkg -i /tmp/nfpm.deb + PKG_TARGET="${{ matrix.target }}" scripts/package.sh + - name: Publish to GitHub Release uses: softprops/action-gh-release@v2 with: @@ -98,4 +121,7 @@ jobs: dist/*.tar.gz dist/*.zip dist/*.sha256 - fail_on_unmatched_files: true + dist/*.deb + dist/*.apk + dist/*.rpm + fail_on_unmatched_files: false diff --git a/.gitignore b/.gitignore index 215c5ae..d6c9f88 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .DS_Store /dist node_modules +.trunk/ # Agent worktrees and local session state .claude/ diff --git a/Cargo.lock b/Cargo.lock index 99aa10e..1863a86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,7 +313,7 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "burrow" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "async-trait", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "burrow-tui" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "clap", @@ -2051,7 +2051,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "looking-glass" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "clap", @@ -2735,7 +2735,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rabbit" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "clap", @@ -2753,13 +2753,14 @@ dependencies = [ [[package]] name = "rabbit-tui" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "clap", "crossterm", "looking-glass", "rabbithole-core", + "rabbithole-directory", "rabbithole-proto", "ratatui", "tokio", @@ -2767,14 +2768,14 @@ dependencies = [ [[package]] name = "rabbithole-art" -version = "0.188.0" +version = "0.191.0" dependencies = [ "png", ] [[package]] name = "rabbithole-audio" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", "tokio", @@ -2782,7 +2783,7 @@ dependencies = [ [[package]] name = "rabbithole-blobs" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "hex", @@ -2793,7 +2794,7 @@ dependencies = [ [[package]] name = "rabbithole-core" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "ed25519-dalek", @@ -2812,9 +2813,20 @@ dependencies = [ "url", ] +[[package]] +name = "rabbithole-directory" +version = "0.191.0" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "webpki-roots 1.0.8", +] + [[package]] name = "rabbithole-e2ee" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "chacha20poly1305", @@ -2830,7 +2842,7 @@ dependencies = [ [[package]] name = "rabbithole-federation" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "postcard", @@ -2841,7 +2853,7 @@ dependencies = [ [[package]] name = "rabbithole-identity" -version = "0.188.0" +version = "0.191.0" dependencies = [ "argon2", "blake3", @@ -2859,7 +2871,7 @@ dependencies = [ [[package]] name = "rabbithole-legacy-binkp" -version = "0.188.0" +version = "0.191.0" dependencies = [ "hmac", "md-5", @@ -2868,7 +2880,7 @@ dependencies = [ [[package]] name = "rabbithole-legacy-doors" -version = "0.188.0" +version = "0.191.0" dependencies = [ "serde", "thiserror 2.0.18", @@ -2877,7 +2889,7 @@ dependencies = [ [[package]] name = "rabbithole-legacy-finger" -version = "0.188.0" +version = "0.191.0" dependencies = [ "async-trait", "tokio", @@ -2886,21 +2898,21 @@ dependencies = [ [[package]] name = "rabbithole-legacy-ftn" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "rabbithole-legacy-hotline" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "rabbithole-legacy-icecast" -version = "0.188.0" +version = "0.191.0" dependencies = [ "data-encoding", "thiserror 2.0.18", @@ -2908,14 +2920,14 @@ dependencies = [ [[package]] name = "rabbithole-legacy-nntp" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "rabbithole-legacy-qwk" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "thiserror 2.0.18", @@ -2923,14 +2935,14 @@ dependencies = [ [[package]] name = "rabbithole-legacy-syndication" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", ] [[package]] name = "rabbithole-legacy-telnet" -version = "0.188.0" +version = "0.191.0" dependencies = [ "async-trait", "rabbithole-art", @@ -2939,14 +2951,14 @@ dependencies = [ [[package]] name = "rabbithole-legacy-zmodem" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "rabbithole-net" -version = "0.188.0" +version = "0.191.0" dependencies = [ "async-trait", "blake3", @@ -2966,7 +2978,7 @@ dependencies = [ [[package]] name = "rabbithole-portmap" -version = "0.188.0" +version = "0.191.0" dependencies = [ "thiserror 2.0.18", "tokio", @@ -2974,7 +2986,7 @@ dependencies = [ [[package]] name = "rabbithole-proto" -version = "0.188.0" +version = "0.191.0" dependencies = [ "bytes", "postcard", @@ -2984,7 +2996,7 @@ dependencies = [ [[package]] name = "rabbithole-radio" -version = "0.188.0" +version = "0.191.0" dependencies = [ "parking_lot", "rabbithole-audio", @@ -2993,7 +3005,7 @@ dependencies = [ [[package]] name = "rabbithole-reticulum" -version = "0.188.0" +version = "0.191.0" dependencies = [ "chacha20poly1305", "ed25519-dalek", @@ -3010,14 +3022,14 @@ dependencies = [ [[package]] name = "rabbithole-screen" -version = "0.188.0" +version = "0.191.0" dependencies = [ "rabbithole-art", ] [[package]] name = "rabbithole-server-core" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "chrono", @@ -3039,7 +3051,7 @@ dependencies = [ [[package]] name = "rabbithole-store-client" -version = "0.188.0" +version = "0.191.0" dependencies = [ "rabbithole-proto", "rusqlite", @@ -3049,7 +3061,7 @@ dependencies = [ [[package]] name = "rabbithole-store-server" -version = "0.188.0" +version = "0.191.0" dependencies = [ "hex", "serde_json", @@ -3062,7 +3074,7 @@ dependencies = [ [[package]] name = "rabbithole-swarm" -version = "0.188.0" +version = "0.191.0" dependencies = [ "bao-tree", "blake3", @@ -3081,7 +3093,7 @@ dependencies = [ [[package]] name = "rabbithole-ui-web" -version = "0.188.0" +version = "0.191.0" dependencies = [ "blake3", "ed25519-dalek", @@ -3094,6 +3106,7 @@ dependencies = [ "png", "rabbithole-art", "rabbithole-core", + "rabbithole-directory", "rabbithole-proto", "serde", "serde_json", @@ -4683,7 +4696,7 @@ dependencies = [ [[package]] name = "warren-stampede" -version = "0.188.0" +version = "0.191.0" dependencies = [ "anyhow", "burrow", diff --git a/Cargo.toml b/Cargo.toml index 308a4a5..fbf0e57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/store-client", "crates/swarm", "crates/federation", + "crates/directory", "crates/reticulum", "crates/art", "crates/screen", @@ -39,7 +40,7 @@ members = [ ] [workspace.package] -version = "0.189.0" +version = "0.191.0" edition = "2021" rust-version = "1.85" authors = ["RabbitHole contributors"] @@ -59,9 +60,10 @@ rabbithole-blobs = { path = "crates/blobs" } rabbithole-store-server = { path = "crates/store-server" } rabbithole-store-client = { path = "crates/store-client" } rabbithole-swarm = { path = "crates/swarm" } +rabbithole-directory = { path = "crates/directory" } rabbithole-federation = { path = "crates/federation" } rabbithole-reticulum = { path = "crates/reticulum" } -rabbithole-art = { path = "crates/art" } +rabbithole-art = { path = "crates/art", default-features = false } rabbithole-screen = { path = "crates/screen" } rabbithole-audio = { path = "crates/audio" } rabbithole-radio = { path = "crates/radio" } @@ -168,6 +170,28 @@ gloo-timers = { version = "0.3", features = ["futures"] } [profile.release] lto = "thin" strip = "symbols" +codegen-units = 1 + +# The wasm SPA is the download. Native binaries keep rustc's default opt-level +# (3) so the burrow stays fast; only the frontend crate-graph is size-tuned. +# `trunk build --release` plus wasm-opt -Oz (see crates/ui-web/index.html) do +# the rest. +[profile.release.package.rabbithole-ui-web] +opt-level = "z" +[profile.release.package.leptos] +opt-level = "z" +[profile.release.package.leptos_router] +opt-level = "z" +[profile.release.package.wasm-bindgen] +opt-level = "z" +[profile.release.package.wasm-bindgen-futures] +opt-level = "z" +[profile.release.package.js-sys] +opt-level = "z" +[profile.release.package.web-sys] +opt-level = "z" +[profile.release.package.gloo-timers] +opt-level = "z" [profile.dev.package."*"] opt-level = 1 diff --git a/Dockerfile b/Dockerfile index 6fb5541..159f991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,25 @@ RUN apt-get update \ COPY . . # Compile only the server binary and its dependency tree, then strip it. -RUN cargo build --release -p burrow \ - && strip target/release/burrow +# Both server-side binaries: the compose stack runs the tracker from this +# same image (different entrypoint), so building only `burrow` would mean a +# second image for one extra binary. +RUN cargo build --release -p burrow -p looking-glass \ + && strip target/release/burrow target/release/looking-glass + +# --- Web stage: build the SPA the burrow serves ------------------------------ +# Separate stage so it caches independently of the server build, and so a +# change to the Rust server doesn't rebuild wasm (or vice versa). Without this +# the image would ship a server that serves nothing at `/`, and +# `docker compose up` would not actually give you the whole thing. +FROM rust:slim AS web +WORKDIR /src +RUN rustup target add wasm32-unknown-unknown \ + && cargo install trunk --locked +COPY . . +RUN cd crates/ui-web && trunk build --release -# --- Runtime stage: minimal image with just burrow --------------------------- +# --- Runtime stage: minimal image with the server binaries ------------------- FROM debian:stable-slim AS runtime RUN apt-get update \ @@ -28,6 +43,9 @@ RUN apt-get update \ && chown burrow:burrow /data COPY --from=builder /src/target/release/burrow /usr/local/bin/burrow +COPY --from=builder /src/target/release/looking-glass /usr/local/bin/looking-glass +# The built web client. `--web-root /srv/web` (see docker-compose.yml) serves it. +COPY --from=web /src/crates/ui-web/dist /srv/web USER burrow WORKDIR /data @@ -39,6 +57,7 @@ ENV RABBITHOLE_DATA_DIR=/data # backend is loopback-only and intentionally not exposed by this image. EXPOSE 4653/udp EXPOSE 4655/tcp +EXPOSE 8080/tcp ENTRYPOINT ["burrow"] CMD ["run"] diff --git a/README.md b/README.md index 0e3ee9c..3e7ec9a 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,10 @@ surface. directory, admin console, theme editor (live light/dark/retro preview), and a radio player — an installable **PWA** with an offline app-shell, served by `burrow --http`. -- **TUI clients** (`rabbit-tui`): chat/who/DMs, a radio now-playing panel with - external-player handoff, and a **Looking Glass** server browser - (INDEX/CATEGORIES/HEALTH with an uptime sparkline). +- **TUI clients** (`rabbit-tui`): chat/who, a radio now-playing panel with + external-player handoff, and a **Looking Glass** server browser that + opens on `rabbithole.directory` (INDEX/CATEGORIES/HEALTH on a named + tracker, with an uptime sparkline). - **`rabbit` CLI**: login (password/guest, QUIC or WS), chat, boards, files, swarm, transfer queue, wishing well, `--json` mode. - **`looking-glass` tracker**: signed self-certifying descriptors, UDP gossip @@ -110,7 +111,8 @@ Native transports and rate limiting are the only things on out of the box; | Radio DJ source + `updinfo` | 8001 (`radio_source_addr`) | `radio_source_enabled` | off | | RSS/Atom syndication | — (outbound fetcher) | `syndication_enabled` | off | | QWK/QWKE offline mail | — (telnet `[M]` + `ctl`) | `qwk_enabled` | off | -| Looking Glass tracker | 5498 TCP / 5499 UDP + 4656 UDP gossip | (`looking-glass` daemon) | — | +| Looking Glass tracker | 5498 TCP / 5499 UDP + 4656 UDP gossip; status INDEX on 4655 | (`looking-glass` daemon) | — | +| Looking Glass announce | outbound HTTPS `POST /api/announce` | `announce_enabled` (needs `advertise_host`) | **on**, inert until a public host is set | Federation also requires a restart-only `federation_origin`. This immutable lowercase namespace is bound to the server key in protocol-v2 handshakes and diff --git a/TODO.md b/TODO.md index c4eb175..4b2f04c 100644 --- a/TODO.md +++ b/TODO.md @@ -378,7 +378,7 @@ lines), and the mobile builds (iOS simulator / Android NDK). - [ ] Tauri iOS/iPadOS + Android builds; mobile plugin glue: notifications, background audio + audio session, share sheet - [~] Transport resilience on mobile (QUIC connection migration, WS fallback) — **QUIC migration landed**: `net::Connection` gains additive (default-bodied, non-breaking) `migrate()` + `local_addr()`; the QUIC client now RETAINS its `quinn::Endpoint` so `migrate()` calls `Endpoint::rebind` with a fresh wildcard `UdpSocket` — the live connection moves to a new local socket/port (WiFi↔cellular) WITHOUT re-handshaking (connection IDs carry it). New `NetError::Unsupported(&str)` is the documented "fall back to reconnect+auth_resume" signal returned by WS + server-side QUIC. `tests/quic_migration.rs` proves it: post-migrate the local addr changed, the remote addr didn't, a 2nd request round-tripped on the SAME accepted connection (server never re-accepted), and WS migrate reports Unsupported. 8 net tests. **Auto-reconnect helper landed**: `Client` now remembers its dial params + the resumable session token (captured from `auth_password`/`auth_resume`; guests get an empty token → not resumable) and gained `reconnect()` (re-dial + Hello + `AuthResume` with the never-rewound replay cursor, preserving buffered-but-unread pushes ahead of replayed ones), `is_resumable()`, and `request_resilient()` (retries once on a transient network drop — documented at-least-once, for idempotent reads). A pure `is_transient(&ClientError)` classifier (host-tested) gates the retry so refusals/decode errors never loop. 1 host + 2 e2e (`e2e_w12_resilience`: a password session reconnects → resumed=true, same identity, cursor doesn't rewind, who+chat work after; a guest session is refused a resume). QUIC `migrate()` stays the proactive WiFi↔cellular path; `reconnect()` is the after-a-drop recovery path - [ ] App Store (TestFlight) + Play (.aab) packaging, signing, privacy manifests, entitlements -- [~] `dist` release automation (CLI/TUI/server): archives, installers, Homebrew — `.github/workflows/release.yml` (tag-triggered cross-platform binary archives + checksums + GitHub Release) and `scripts/release.sh` landed; installers/Homebrew tap pending +- [x] `dist` release automation (CLI/TUI/server): archives, installers, Homebrew — `.github/workflows/release.yml` (tag-triggered cross-platform binary archives + checksums + GitHub Release) and `scripts/release.sh` landed. **Distro packages + one-command stack landed (0.191.0)**: `packaging/nfpm.yaml` describes the file list once and `scripts/package.sh` renders it (nfpm does *not* expand `${VAR}` inside `contents[].src`, so the script sed-renders to a temp config) into **deb + apk + rpm** for the host arch — verified end to end (`rabbithole_0.191.0_arm64.deb`, 13.6 MB, correct control metadata + contents). Installs all four binaries to `/usr/bin`, both systemd units as `config|noreplace`, and `/var/lib/burrow` 0750 burrow:burrow; maintainer scripts create the `burrow` user and `daemon-reload` but deliberately never enable/start a service or delete data on removal. `contrib/looking-glass.service` mirrors the burrow unit's hardening. `packaging/homebrew/rabbithole.rb` consumes the archives `release.yml` already publishes (no second artifact) with a `service do` block and a test that asserts the *version* matches, not merely that the binary exists. `release.yml` gained an `aarch64-unknown-linux-gnu` row and a package-building step. `justfile` + `scripts/stack.sh` are the one command for the server side (`just up` = burrow with the SPA + tracker, one Ctrl-C stops both); the SPA failing to build warns instead of taking the stack down, and `WEB_ROOT` is absolute because burrow resolves a *relative* `--web-root` under `--data-dir`. `Dockerfile`/`docker-compose.yml` now build the SPA and run the tracker alongside the burrow. **Also fixed a real blocker**: `crates/ui-web/index.html` needed `data-wasm-opt-params` post-MVP feature flags — without them `trunk build --release` failed outright, so release SPA builds were impossible - [x] Docker images (multi-stage → slim) + docker-compose; systemd unit; install docs — `Dockerfile` + `.dockerignore` + `docker-compose.yml` + hardened `contrib/burrow.service` + `docs/deployment.md` (accurate to real bins/ports/env vars) - [~] Versioned protocol docs published (docs site) — the spec itself is current in-repo (see W-Continuous lockstep line); publishing to a docs site is the remaining step diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 58479ee..a559ab6 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -49,6 +49,9 @@ enum Cmd { /// Password (or set RABBIT_PASSWORD). #[arg(long)] password: Option, + /// Current 6-digit code, for accounts with two-factor enabled. + #[arg(long)] + totp: Option, /// Sign in as a guest. #[arg(long)] guest: bool, @@ -58,6 +61,12 @@ enum Cmd { }, /// Forget the cached session. Logout, + /// Accept this burrow's agreement (read it first with `rabbit status`). + Agree { + /// Accept without printing the agreement first. + #[arg(long)] + yes: bool, + }, /// Show the cached session. Status, /// List who's online. @@ -100,6 +109,11 @@ enum Cmd { parent: String, text: Vec, }, + /// Direct messages — read, send, and list private conversations. + Dm { + #[command(subcommand)] + action: DmAction, + }, /// The Wishing Well — a request board for wanted files/boards/features. Wish { #[command(subcommand)] @@ -242,6 +256,31 @@ enum QueueAction { Clear, } +/// Direct-message actions. +#[derive(Subcommand)] +enum DmAction { + /// List conversations, most recent first. + List, + /// Print a conversation, newest last. + Read { + /// The other party's handle. + with: String, + /// How many messages to fetch. + #[arg(long, default_value_t = 50)] + limit: u32, + /// Leave the conversation marked unread. + #[arg(long)] + keep_unread: bool, + }, + /// Send a direct message. + Send { + /// The other party's handle. + to: String, + /// The message (joined with spaces). + text: Vec, + }, +} + #[derive(Subcommand)] enum WishAction { /// List wishes (optionally by status: open|claimed|fulfilled|declined). @@ -280,6 +319,13 @@ struct Session { guest_name: Option, screen_name: String, replay_cursor: u64, + /// The agreement this burrow requires, if we have not accepted it yet. + /// + /// Stored so `rabbit status` can actually show it — login used to say + /// "read it with `rabbit status`" while `status` had no idea it existed, + /// and every other command silently accepted it on the user's behalf. + #[serde(default)] + pending_agreement: Option, } /// The per-user RabbitHole data directory, created private (0700 on Unix) since @@ -409,6 +455,7 @@ async fn main() -> Result<()> { server_name, user, password, + totp, guest, name, } => { @@ -429,7 +476,8 @@ async fn main() -> Result<()> { let password = password .or_else(|| std::env::var("RABBIT_PASSWORD").ok()) .context("--password or RABBIT_PASSWORD")?; - c.auth_password(&user, &password).await? + c.auth_password_totp(&user, &password, totp.as_deref()) + .await? }; let welcome = c.expect_welcome().await?; @@ -443,6 +491,7 @@ async fn main() -> Result<()> { .filter(|s| !s.is_empty()), screen_name: ok.screen_name.clone(), replay_cursor: c.replay_cursor, + pending_agreement: welcome.agreement.clone(), }; save_session(&session)?; @@ -466,7 +515,10 @@ async fn main() -> Result<()> { println!("\n{}\n", welcome.motd); } if welcome.agreement.is_some() { - println!("(this server has an agreement; commands will auto-accept — read it with `rabbit status`)"); + println!( + "\nThis burrow has an agreement you have not accepted.\n\ + Read it with `rabbit status`, then accept with `rabbit agree`." + ); } } c.close().await; @@ -482,6 +534,28 @@ async fn main() -> Result<()> { } Ok(()) } + Cmd::Agree { yes } => { + let (mut c, mut s) = reconnect().await?; + let Some(text) = s.pending_agreement.clone() else { + println!("nothing to accept \u{2014} this burrow has no pending agreement"); + return Ok(()); + }; + // Show what is being agreed to unless the caller says they have + // already read it. Accepting terms you were never shown is the + // behaviour this command exists to replace. + if !yes && !cli.json { + println!("\u{2500}\u{2500} agreement \u{2500}\u{2500}\n{text}\n"); + } + c.agreement_accept().await?; + s.pending_agreement = None; + save_session(&s)?; + if cli.json { + println!("{}", serde_json::json!({"accepted": true})); + } else { + println!("accepted \u{2014} you're in."); + } + Ok(()) + } Cmd::Status => { let s = load_session()?; if cli.json { @@ -490,6 +564,14 @@ async fn main() -> Result<()> { println!("endpoint: {}", s.endpoint); println!("screen name: {}", s.screen_name); println!("resumable: {}", s.token.is_some()); + match &s.pending_agreement { + Some(text) => { + println!("agreement: NOT ACCEPTED"); + println!("\n\u{2500}\u{2500} agreement \u{2500}\u{2500}\n{text}\n"); + println!("Accept it with `rabbit agree`."); + } + None => println!("agreement: none pending"), + } } Ok(()) } @@ -505,6 +587,11 @@ async fn main() -> Result<()> { "role": u.role, "transport": u.transport, "connected_secs": u.connected_secs, + // The wire has carried these since presence + // landed; `who` just threw them away. + "state": format!("{:?}", u.state).to_lowercase(), + "status": u.status, + "identity_key": u.pubkey.as_ref().map(hex::encode), }) }) .collect(); @@ -512,10 +599,18 @@ async fn main() -> Result<()> { } else { println!("{} online:", users.len()); for u in users { + // A key glyph marks a portable identity — the same hint + // the web roster shows, and the reason two people with the + // same handle are distinguishable. + let keyed = if u.pubkey.is_some() { "\u{26bf}" } else { " " }; + let state = format!("{:?}", u.state).to_lowercase(); println!( - " {:24} {:10} {:>6}s", - u.screen_name, u.transport, u.connected_secs + " {keyed} {:22} {:8} {:10} {:>6}s", + u.screen_name, state, u.transport, u.connected_secs ); + if let Some(msg) = u.status.as_deref().filter(|m| !m.is_empty()) { + println!(" \u{201c}{msg}\u{201d}"); + } } } c.close().await; @@ -663,6 +758,7 @@ async fn main() -> Result<()> { parent, text, } => cmd_reply(cli.json, board, parent, text.join(" ")).await, + Cmd::Dm { action } => cmd_dm(cli.json, action).await, Cmd::Wish { action } => cmd_wish(cli.json, action).await, Cmd::File { action } => cmd_file(cli.json, action).await, Cmd::Queue { action } => cmd_queue(cli.json, action).await, @@ -1402,6 +1498,109 @@ async fn cmd_reply(json: bool, board: String, parent: String, text: String) -> R Ok(()) } +/// Direct messages. +/// +/// The DM family has been on the wire since Wave 2 and the CLI had no surface +/// for it at all — a terminal user could read every board and file on a burrow +/// but not the message someone sent them. +async fn cmd_dm(json: bool, action: DmAction) -> Result<()> { + let (mut c, _) = reconnect().await?; + match action { + DmAction::List => { + let threads = c.dm_threads().await?; + if json { + let rows: Vec<_> = threads + .iter() + .map(|t| { + serde_json::json!({ + "with": t.with, + "last_text": t.last_text, + "last_at_unix_ms": t.last_at_unix_ms, + "unread": t.unread, + }) + }) + .collect(); + println!("{}", serde_json::Value::Array(rows)); + } else if threads.is_empty() { + println!("no conversations yet"); + } else { + for t in threads { + // Unread first in the eye-line: it's the reason to look. + let mark = if t.unread > 0 { + format!("({})", t.unread) + } else { + " ".to_string() + }; + let preview: String = t.last_text.replace('\n', " ").chars().take(56).collect(); + println!("{mark:>5} {:20} {preview}", t.with); + } + } + } + DmAction::Read { + with, + limit, + keep_unread, + } => { + // before_id 0 = from the newest backwards. + let msgs = c.dm_history(&with, 0, limit).await?; + if json { + let rows: Vec<_> = msgs + .iter() + .map(|m| { + serde_json::json!({ + "id": m.id, + "from": m.from, + "to": m.to, + "text": m.text, + "at_unix_ms": m.at_unix_ms, + "is_auto": m.is_auto, + "encrypted": m.encrypted.is_some(), + }) + }) + .collect(); + println!("{}", serde_json::Value::Array(rows)); + } else if msgs.is_empty() { + println!("no messages with {with}"); + } else { + for m in &msgs { + // An encrypted DM's `text` is empty by construction; say so + // rather than printing a blank line that looks like a bug. + let body = if m.encrypted.is_some() && m.text.is_empty() { + "[end-to-end encrypted \u{2014} not readable here]" + } else { + &m.text + }; + let auto = if m.is_auto { " (auto)" } else { "" }; + println!("{:>16}{auto}: {body}", m.from); + } + } + // Reading marks read, which is what reading means — unless the + // caller is scripting and wants the unread flag left alone. + if !keep_unread { + if let Some(newest) = msgs.iter().map(|m| m.id).max() { + c.dm_mark_read(&with, newest).await?; + } + } + } + DmAction::Send { to, text } => { + let body = text.join(" "); + if body.trim().is_empty() { + bail!("nothing to send"); + } + let sent = c + .dm_send(&rabbithole_proto::dm::DmSend::new(&to, body)) + .await?; + if json { + println!("{}", serde_json::json!({"id": sent.id, "to": to})); + } else { + println!("sent to {to}"); + } + } + } + c.close().await; + Ok(()) +} + async fn cmd_wish(json: bool, action: WishAction) -> Result<()> { use rabbithole_proto::wish::WishSetStatus; let (mut c, _) = reconnect().await?; @@ -1506,8 +1705,12 @@ fn report_wish(json: bool, verb: &str, w: &rabbithole_proto::wish::WishView) { } /// Re-establish a session from the cache: token resume for accounts, -/// fresh guest sign-in for guests. Auto-accepts a pending agreement -/// (the login command surfaced it to the human). +/// fresh guest sign-in for guests. +/// +/// A pending agreement is **not** accepted here. Accepting a burrow's terms is +/// a decision, and a decision the user never sees is not one they made — this +/// used to happen silently on every command. `rabbit agree` is the deliberate +/// act; until then the agreement is remembered and reported. async fn reconnect() -> Result<(Client, Session)> { let mut s = load_session()?; let identity = load_or_create_identity()?; @@ -1526,9 +1729,8 @@ async fn reconnect() -> Result<(Client, Session)> { }; s.screen_name = ok.screen_name.clone(); let welcome = c.expect_welcome().await?; - if welcome.agreement.is_some() { - c.agreement_accept().await?; - } + // Track it so `status` stays accurate; never accept on their behalf. + s.pending_agreement = welcome.agreement.clone(); Ok((c, s)) } diff --git a/apps/desktop/tauri.conf.json b/apps/desktop/tauri.conf.json index aefe772..183a777 100644 --- a/apps/desktop/tauri.conf.json +++ b/apps/desktop/tauri.conf.json @@ -9,7 +9,7 @@ "cwd": "../../crates/ui-web" }, "beforeBuildCommand": { - "script": "trunk build", + "script": "trunk build --release", "cwd": "../../crates/ui-web" }, "devUrl": "http://localhost:1420", diff --git a/apps/server/src/announce.rs b/apps/server/src/announce.rs new file mode 100644 index 0000000..af46ee3 --- /dev/null +++ b/apps/server/src/announce.rs @@ -0,0 +1,602 @@ +//! Announcing this burrow to Looking Glass trackers, so people who don't +//! already know its address can find it. +//! +//! A burrow that nobody can discover is a burrow nobody joins, so this runs by +//! default. It stays inert until [`ServerConfig::advertise_host`] is set: we +//! will not list an address we can't state. +//! +//! # The wire contract +//! +//! A Looking Glass takes `POST /api/announce` with +//! `{"descriptor": {…}, "signature": ""}`, where the signature is Ed25519 +//! over the **canonical JSON** of the descriptor — object keys sorted +//! recursively, no insignificant whitespace, UTF-8. The coordinator then +//! publishes its index onward to `rabbithole.directory`; burrows never talk to +//! the directory themselves. +//! +//! [`canonical_json`] is the security-critical piece and is written out +//! explicitly rather than leaning on `serde_json`'s map ordering, which is a +//! function of a feature flag (`preserve_order`) that any crate in the tree +//! could turn on. Signing bytes we didn't deliberately produce is how a +//! signature quietly starts covering something else. +//! +//! # Opting out +//! +//! `announce_enabled = false` stops the announce *and* stamps a `noindex` +//! feature tag into the signed `.well-known` descriptor ([`crate::well_known`]). +//! Discovery here is gossip: a visitor can pass your burrow along to a tracker +//! or a friend. Because the tag rides inside your own signature, the wish not +//! to be listed is attributable to you and survives the retelling, rather than +//! depending on the good behaviour of everyone who ever saw you. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, bail, Result}; +use rabbithole_identity::IdentityKey; +use rabbithole_server_core::config::ServerConfig; +use serde_json::{Map, Value}; +use tokio::task::JoinHandle; + +use crate::syndication::{exchange, parse_http_response, FeedUrl}; +use crate::Shared; + +/// How long a single announce may take, end to end. +const ANNOUNCE_TIMEOUT: Duration = Duration::from_secs(20); + +/// Bounds the glass protocol enforces on `ttl`. Sending something outside them +/// is a guaranteed rejection, so clamp rather than argue. +const TTL_MIN_SECS: u32 = 30; +const TTL_MAX_SECS: u32 = 3600; + +/// Field caps from the glass protocol. Over-long values are rejected wholesale, +/// so truncate: a listing with a clipped description beats no listing. +const MAX_SYSOP: usize = 64; +const MAX_DESCRIPTION: usize = 240; +const MAX_LISTENERS: usize = 12; + +/// Serialize `value` as canonical JSON: object keys sorted recursively, no +/// insignificant whitespace, UTF-8. +/// +/// Both sides of an announce hash *these* bytes, so this is the actual signed +/// message. See the module docs for why it isn't left to `serde_json`. +pub fn canonical_json(value: &Value) -> String { + let mut out = String::new(); + write_canonical(value, &mut out); + out +} + +fn write_canonical(value: &Value, out: &mut String) { + match value { + Value::Object(map) => { + // BTreeMap sorts by Rust's `Ord` for `String`, i.e. by UTF-8 bytes, + // which is what "sorted keys" means for a JSON canonicalization. + let sorted: BTreeMap<&String, &Value> = map.iter().collect(); + out.push('{'); + for (i, (k, v)) in sorted.iter().enumerate() { + if i > 0 { + out.push(','); + } + out.push_str(&Value::String((*k).clone()).to_string()); + out.push(':'); + write_canonical(v, out); + } + out.push('}'); + } + Value::Array(items) => { + // Arrays are ordered data, not a set: order is preserved. + out.push('['); + for (i, v) in items.iter().enumerate() { + if i > 0 { + out.push(','); + } + write_canonical(v, out); + } + out.push(']'); + } + // Scalars have one JSON spelling each, and `serde_json` already emits + // strings with the escaping the spec requires. + other => out.push_str(&other.to_string()), + } +} + +/// The announced name, in the glass's `handle@host` form (`alice@wonderland`). +/// +/// The handle half is the operator; the host half is what people dial. Falls +/// back to a slug of the burrow's display name so an unconfigured burrow still +/// announces something a human recognizes. +pub fn announce_name(cfg: &ServerConfig) -> Option { + let host = cfg.advertise_host.trim(); + if host.is_empty() { + return None; + } + let sysop = cfg.announce_sysop.trim(); + let handle = if sysop.is_empty() { + slug(&cfg.name) + } else { + clip(sysop, MAX_SYSOP) + }; + if handle.is_empty() { + return None; + } + Some(format!("{handle}@{host}")) +} + +/// Lowercase a display name into a DNS-ish label: alphanumerics kept, runs of +/// anything else collapsed to a single dash, no leading or trailing dash. +fn slug(name: &str) -> String { + let mut out = String::new(); + let mut pending_dash = false; + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !out.is_empty() { + out.push('-'); + } + pending_dash = false; + out.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + out +} + +/// Truncate to `max` **characters**, never splitting a UTF-8 sequence. +fn clip(s: &str, max: usize) -> String { + s.chars().take(max).collect() +} + +/// Build the descriptor to announce, or `None` when this burrow has nothing +/// truthful to say — announcing off, or no `advertise_host` to point at. +/// +/// Pure over config + clock so the exact signed document is host-testable. +pub fn descriptor(cfg: &ServerConfig, public_key_hex: &str, now_ms: i64) -> Option { + if !cfg.announce_enabled { + return None; + } + let name = announce_name(cfg)?; + + let mut d = Map::new(); + d.insert("name".into(), Value::String(name)); + d.insert( + "publicKey".into(), + Value::String(public_key_hex.to_string()), + ); + d.insert("timestamp".into(), Value::from(now_ms)); + d.insert("ttl".into(), Value::from(ttl_secs(cfg))); + d.insert( + "version".into(), + Value::String(clip(env!("CARGO_PKG_VERSION"), 32)), + ); + + let slug_cfg = cfg.announce_slug.trim(); + if !slug_cfg.is_empty() { + d.insert("slug".into(), Value::String(slug_cfg.to_string())); + } + let sysop = cfg.announce_sysop.trim(); + if !sysop.is_empty() { + d.insert("sysop".into(), Value::String(clip(sysop, MAX_SYSOP))); + } + if let Some(text) = description(cfg) { + d.insert("description".into(), Value::String(text)); + } + + let listeners = listeners(cfg); + if !listeners.is_empty() { + d.insert( + "listeners".into(), + Value::Array(listeners.into_iter().map(Value::String).collect()), + ); + } + let endpoints = endpoints(cfg); + if !endpoints.is_empty() { + d.insert("endpoints".into(), Value::Object(endpoints)); + } + + Some(Value::Object(d)) +} + +/// The announce interval, clamped into what the glass protocol accepts. +fn ttl_secs(cfg: &ServerConfig) -> u32 { + cfg.announce_ttl_secs.clamp(TTL_MIN_SECS, TTL_MAX_SECS) +} + +/// The listing blurb: the explicit setting, else the welcome ticker (already a +/// one-liner written for strangers), else nothing. +fn description(cfg: &ServerConfig) -> Option { + for candidate in [&cfg.announce_description, &cfg.welcome_ticker] { + let text = candidate.trim(); + if !text.is_empty() { + return Some(clip(text, MAX_DESCRIPTION)); + } + } + None +} + +/// Protocol tokens for the surfaces actually switched on, in a fixed order so +/// the signed bytes are deterministic for a given config. +fn listeners(cfg: &ServerConfig) -> Vec { + let mut out = vec!["quic".to_string()]; + if !cfg.ws_public_url.trim().is_empty() { + out.push("ws".into()); + } + for (on, tag) in [ + (cfg.telnet_enabled, "telnet"), + (cfg.hotline_enabled, "hotline"), + (cfg.finger_enabled, "finger"), + (cfg.radio_enabled, "radio"), + (cfg.nntp_enabled, "nntp"), + ] { + if on { + out.push(tag.into()); + } + } + out.truncate(MAX_LISTENERS); + out +} + +/// Dialable URIs per protocol. Only surfaces whose *public* address we actually +/// know: the QUIC port under `advertise_host`, and the WebSocket proxy URL if +/// one is configured. A backend bind address can't reveal its external scheme, +/// host or port, so it is never guessed. +fn endpoints(cfg: &ServerConfig) -> Map { + let mut m = Map::new(); + let host = cfg.advertise_host.trim(); + if !host.is_empty() { + m.insert( + "quic".into(), + Value::String(format!("quic://{host}:{}", cfg.quic_addr.port())), + ); + } + let ws = cfg.ws_public_url.trim(); + if !ws.is_empty() { + m.insert("ws".into(), Value::String(ws.to_string())); + } + m +} + +/// The full `{descriptor, signature}` body to POST, signed with `key`. +pub fn signed_body(cfg: &ServerConfig, key: &IdentityKey, now_ms: i64) -> Option { + let public_hex = hex::encode(key.public().0); + let descriptor = descriptor(cfg, &public_hex, now_ms)?; + let signature = key.sign(canonical_json(&descriptor).as_bytes()); + + let mut body = Map::new(); + body.insert("descriptor".into(), descriptor); + body.insert("signature".into(), Value::String(hex::encode(signature.0))); + serde_json::to_string(&Value::Object(body)).ok() +} + +/// Normalize a tracker config entry into the announce endpoint URL. +/// +/// Accepts `tracker.rabbit.direct`, `tracker.rabbit.direct:8443`, +/// `https://tracker.rabbit.direct`, or a full path. A bare host gets HTTPS: +/// an announce carries a signature over a public descriptor, but downgrading a +/// coordinator to plaintext by omission is not a decision config should make +/// silently. +pub fn announce_url(entry: &str) -> Option { + let e = entry.trim().trim_end_matches('/'); + if e.is_empty() { + return None; + } + if e.contains("://") { + return Some(if e.contains("/api/") { + e.to_string() + } else { + format!("{e}/api/announce") + }); + } + Some(format!("https://{e}/api/announce")) +} + +/// POST one announce and return the tracker's HTTP status. +async fn post_announce(url: &str, body: &str) -> Result { + let target = FeedUrl::parse(url)?; + let request = format!( + "POST {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: rabbithole-burrow/{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + target.path, + target.host_header(), + env!("CARGO_PKG_VERSION"), + body.len(), + body, + ); + let raw = tokio::time::timeout(ANNOUNCE_TIMEOUT, exchange(&target, request.as_bytes())) + .await + .map_err(|_| anyhow!("announce to {url} timed out after {ANNOUNCE_TIMEOUT:?}"))??; + let resp = parse_http_response(&raw)?; + if !(200..300).contains(&resp.status) { + let detail = String::from_utf8_lossy(&resp.body); + bail!( + "{url} answered {} — {}", + resp.status, + clip(detail.trim(), 200) + ); + } + Ok(resp.status) +} + +/// The POST path, exposed for the integration test in `tests/e2e_announce.rs` +/// so it exercises the shipping request builder rather than a copy of it. +#[doc(hidden)] +pub async fn post_announce_for_test(url: &str, body: &str) -> Result { + post_announce(url, body).await +} + +/// Announce to every configured tracker once. Each is independent: one +/// coordinator being down must not cost you a listing on the others. +async fn announce_round(shared: &Arc) { + let (body, trackers) = { + let cfg = shared.config.read(); + let key = IdentityKey::from_seed(&shared.server_signing_seed); + match signed_body(&cfg, &key, now_unix_millis()) { + Some(b) => (b, cfg.announce_trackers.clone()), + None => return, + } + }; + + for entry in trackers { + let Some(url) = announce_url(&entry) else { + continue; + }; + match post_announce(&url, &body).await { + Ok(_) => tracing::debug!(tracker = %url, "announced"), + // A tracker we can't reach is normal weather, not an incident: the + // burrow keeps serving and the next round tries again. + Err(e) => tracing::warn!(tracker = %url, error = %e, "announce failed"), + } + } +} + +/// Spawn the announce loop. Re-reads config every round, so `ctl config set +/// announce_enabled false` takes effect without a restart — and the `noindex` +/// tag in the descriptor flips with it. +pub fn spawn_announce(shared: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + loop { + announce_round(&shared).await; + let ttl = { + let cfg = shared.config.read(); + if cfg.announce_enabled { + ttl_secs(&cfg) + } else { + // Announcing is off. Idle at the floor rather than exiting, + // so turning it back on doesn't need a restart. + TTL_MIN_SECS + } + }; + tokio::time::sleep(Duration::from_secs(u64::from(ttl))).await; + } + }) +} + +fn now_unix_millis() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> ServerConfig { + let mut c = ServerConfig { + name: "Wonderland BBS".into(), + advertise_host: "wonderland.example".into(), + ..Default::default() + }; + c.quic_addr = "0.0.0.0:4653".parse().unwrap(); + c + } + + #[test] + fn canonical_json_sorts_keys_at_every_level() { + let v: Value = serde_json::from_str( + r#"{"z":1,"a":{"y":[3,{"q":1,"b":2}],"b":"x"},"m":null,"c":true}"#, + ) + .unwrap(); + assert_eq!( + canonical_json(&v), + r#"{"a":{"b":"x","y":[3,{"b":2,"q":1}]},"c":true,"m":null,"z":1}"# + ); + } + + #[test] + fn canonical_json_preserves_array_order_and_escapes_strings() { + // Arrays are ordered data, not sets — sorting them would change what + // the descriptor says (a listener list is a list). + let v: Value = serde_json::from_str(r#"{"l":["quic","ws","telnet"]}"#).unwrap(); + assert_eq!(canonical_json(&v), r#"{"l":["quic","ws","telnet"]}"#); + + // The signed bytes must survive anything a sysop can type into a + // description: quotes, newlines, and non-ASCII. + let v = serde_json::json!({ "d": "a \"quoted\" line\nwith ünïcödé" }); + let out = canonical_json(&v); + assert_eq!( + serde_json::from_str::(&out).unwrap(), + v, + "round-trips through a real JSON parser" + ); + assert!(!out.contains('\n'), "the literal newline is escaped"); + } + + #[test] + fn canonical_json_has_no_insignificant_whitespace() { + let v = serde_json::json!({"a": 1, "b": [1, 2], "c": {"d": "e"}}); + let out = canonical_json(&v); + // Whitespace only ever appears inside a string value. + assert!(!out.contains(' '), "{out}"); + } + + #[test] + fn a_burrow_with_no_advertise_host_announces_nothing() { + // We would be listing an address we cannot state. Better to be + // undiscoverable than to be discoverable and unreachable. + let mut c = cfg(); + c.advertise_host = String::new(); + assert!(descriptor(&c, "aa", 1).is_none()); + assert!(announce_name(&c).is_none()); + } + + #[test] + fn opting_out_announces_nothing() { + let mut c = cfg(); + c.announce_enabled = false; + assert!(descriptor(&c, "aa", 1).is_none()); + assert!( + signed_body(&c, &IdentityKey::from_seed(&[7u8; 32]), 1).is_none(), + "and there is nothing to POST" + ); + } + + #[test] + fn the_name_is_handle_at_host() { + let mut c = cfg(); + assert_eq!( + announce_name(&c).unwrap(), + "wonderland-bbs@wonderland.example", + "an unconfigured burrow still announces a recognizable handle" + ); + c.announce_sysop = "alice".into(); + assert_eq!(announce_name(&c).unwrap(), "alice@wonderland.example"); + } + + #[test] + fn slugs_collapse_punctuation_without_leading_or_trailing_dashes() { + assert_eq!(slug(" The Rabbit's *Hole* "), "the-rabbit-s-hole"); + assert_eq!(slug("!!!"), ""); + assert_eq!(slug("A1"), "a1"); + } + + #[test] + fn the_descriptor_states_only_endpoints_we_actually_know() { + let mut c = cfg(); + let d = descriptor(&c, "ab12", 1_700_000_000_000).unwrap(); + assert_eq!(d["endpoints"]["quic"], "quic://wonderland.example:4653"); + assert!( + d["endpoints"].get("ws").is_none(), + "a backend bind address cannot reveal a public ws URL" + ); + assert_eq!(d["listeners"], serde_json::json!(["quic"])); + + c.ws_public_url = "wss://wonderland.example/rhp".into(); + c.telnet_enabled = true; + let d = descriptor(&c, "ab12", 1).unwrap(); + assert_eq!(d["endpoints"]["ws"], "wss://wonderland.example/rhp"); + assert_eq!(d["listeners"], serde_json::json!(["quic", "ws", "telnet"])); + } + + #[test] + fn optional_fields_are_omitted_rather_than_sent_empty() { + let d = descriptor(&cfg(), "ab12", 1).unwrap(); + for absent in ["slug", "sysop", "description"] { + assert!(d.get(absent).is_none(), "{absent} should be omitted"); + } + for required in ["name", "publicKey", "timestamp", "ttl"] { + assert!(d.get(required).is_some(), "{required} is required"); + } + } + + #[test] + fn the_description_falls_back_to_the_welcome_ticker() { + let mut c = cfg(); + c.welcome_ticker = "Open since 1994. Be kind.".into(); + assert_eq!( + descriptor(&c, "ab", 1).unwrap()["description"], + c.welcome_ticker + ); + + c.announce_description = "The tea party never ended.".into(); + assert_eq!( + descriptor(&c, "ab", 1).unwrap()["description"], + "The tea party never ended.", + "the explicit setting wins" + ); + } + + #[test] + fn over_long_text_is_truncated_rather_than_rejected_wholesale() { + // The glass rejects an over-long field outright, which would cost the + // whole listing. A clipped description beats no listing. + let mut c = cfg(); + c.announce_description = "é".repeat(400); + let d = descriptor(&c, "ab", 1).unwrap(); + let got = d["description"].as_str().unwrap(); + assert_eq!(got.chars().count(), MAX_DESCRIPTION); + assert!(std::str::from_utf8(got.as_bytes()).is_ok(), "no split char"); + } + + #[test] + fn the_ttl_is_clamped_into_what_the_protocol_accepts() { + let mut c = cfg(); + c.announce_ttl_secs = 1; + assert_eq!(descriptor(&c, "ab", 1).unwrap()["ttl"], TTL_MIN_SECS); + c.announce_ttl_secs = 99_999; + assert_eq!(descriptor(&c, "ab", 1).unwrap()["ttl"], TTL_MAX_SECS); + } + + #[test] + fn the_signature_verifies_over_the_canonical_bytes() { + // The whole contract: a tracker re-canonicalizes the descriptor it + // received and checks the signature against the key inside it. + let key = IdentityKey::from_seed(&[42u8; 32]); + let body: Value = + serde_json::from_str(&signed_body(&cfg(), &key, 1_700_000_000_000).unwrap()).unwrap(); + + let descriptor = &body["descriptor"]; + let sig_hex = body["signature"].as_str().unwrap(); + assert_eq!(sig_hex.len(), 128, "64 signature bytes, hex"); + + let announced_key = descriptor["publicKey"].as_str().unwrap(); + assert_eq!( + announced_key, + hex::encode(key.public().0), + "the descriptor names the key that signed it" + ); + + // Rebuild the verifier from the *announced* hex, the way a tracker + // that has only the JSON would, rather than from the key we signed + // with. + let vk = + rabbithole_identity::PublicKey(hex::decode(announced_key).unwrap().try_into().unwrap()); + let sig = rabbithole_identity::Signature(hex::decode(sig_hex).unwrap().try_into().unwrap()); + assert!( + vk.verify(canonical_json(descriptor).as_bytes(), &sig), + "verifies over the canonical bytes" + ); + + // And it is genuinely bound to the content: change one field and the + // signature must stop verifying. + let mut tampered = descriptor.clone(); + tampered["name"] = Value::String("evil@elsewhere".into()); + assert!( + !vk.verify(canonical_json(&tampered).as_bytes(), &sig), + "a rewritten descriptor does not verify" + ); + } + + #[test] + fn tracker_entries_normalize_to_an_announce_url() { + assert_eq!( + announce_url("tracker.rabbit.direct").unwrap(), + "https://tracker.rabbit.direct/api/announce", + "a bare host gets HTTPS, never a silent plaintext downgrade" + ); + assert_eq!( + announce_url(" glass.example:8443/ ").unwrap(), + "https://glass.example:8443/api/announce" + ); + assert_eq!( + announce_url("https://glass.example").unwrap(), + "https://glass.example/api/announce" + ); + assert_eq!( + announce_url("http://127.0.0.1:3000/api/announce").unwrap(), + "http://127.0.0.1:3000/api/announce", + "an explicit scheme and path are honored — local coordinators exist" + ); + assert!(announce_url(" ").is_none()); + } +} diff --git a/apps/server/src/lib.rs b/apps/server/src/lib.rs index 9ff7c22..8641eb7 100644 --- a/apps/server/src/lib.rs +++ b/apps/server/src/lib.rs @@ -3,6 +3,7 @@ #![forbid(unsafe_code)] pub mod admin_store; +pub mod announce; pub mod backup; pub mod ctl; pub mod doors; @@ -483,6 +484,27 @@ impl Burrow { )); tracing::info!("syndication feed ingest running"); } + // Looking Glass announce: tell the trackers this burrow exists so it + // can be found by people who don't already know its address. The task + // re-reads config every round, so it is spawned unconditionally and + // stays inert while announcing is off or `advertise_host` is unset. + { + let cfg = shared.config.read(); + if cfg.announce_enabled { + if cfg.advertise_host.trim().is_empty() { + tracing::info!( + "announce is on but advertise_host is unset — not listing an address \ + we cannot state; set advertise_host to be discoverable" + ); + } else { + tracing::info!( + trackers = ?cfg.announce_trackers, + "announcing to Looking Glass" + ); + } + } + } + tasks.push(announce::spawn_announce(shared.clone())); let mut federation_addr = None; if let Some(addr) = federation { let (bound, handle) = diff --git a/apps/server/src/syndication.rs b/apps/server/src/syndication.rs index d4c8d2f..5fb283e 100644 --- a/apps/server/src/syndication.rs +++ b/apps/server/src/syndication.rs @@ -631,7 +631,7 @@ fn build_request( } /// Connect, send the request, and read the raw response to EOF (size-capped). -async fn exchange(target: &FeedUrl, request: &[u8]) -> Result> { +pub(crate) async fn exchange(target: &FeedUrl, request: &[u8]) -> Result> { let tcp = TcpStream::connect((target.host.as_str(), target.port)) .await .map_err(|e| anyhow!("connect {}:{}: {e}", target.host, target.port))?; diff --git a/apps/server/src/well_known.rs b/apps/server/src/well_known.rs index dd47c0c..4a142b9 100644 --- a/apps/server/src/well_known.rs +++ b/apps/server/src/well_known.rs @@ -96,7 +96,14 @@ fn advertised_features(cfg: &ServerConfig) -> Vec { .iter() .map(|s| s.to_string()) .collect(); + // The gossip opt-out. Discovery here is word of mouth — anyone who visits + // can pass this burrow along to a tracker or a friend — so a burrow that + // does not want to be listed has to say so somewhere that travels with it. + // Inside the signed descriptor, the wish is attributable to the burrow + // itself and survives every retelling, instead of depending on the good + // behaviour of whoever happens to be sharing it. for (on, tag) in [ + (!cfg.announce_enabled, "noindex"), (cfg.guest_enabled, "guest"), (cfg.federation_enabled, "federation"), (cfg.radio_enabled, "radio"), @@ -159,6 +166,24 @@ mod tests { ); } + #[test] + fn opting_out_of_announcing_stamps_noindex_into_the_signed_descriptor() { + // A burrow that doesn't want to be listed can't rely on everyone who + // visits behaving well — discovery here is gossip. The tag rides inside + // the burrow's own signature, so a client holding a shared discovery + // can check the wish against the descriptor it fetches and drop it. + let mut c = cfg(); + assert!( + !advertised_features(&c).contains(&"noindex".to_string()), + "announcing is on by default, so nothing to say" + ); + + c.announce_enabled = false; + let f = advertised_features(&c); + assert_eq!(f.first().map(String::as_str), Some("boards"), "core intact"); + assert!(f.contains(&"noindex".to_string())); + } + #[test] fn disabled_surfaces_are_dropped() { let mut c = cfg(); diff --git a/apps/server/tests/e2e_announce.rs b/apps/server/tests/e2e_announce.rs new file mode 100644 index 0000000..ca2a867 --- /dev/null +++ b/apps/server/tests/e2e_announce.rs @@ -0,0 +1,199 @@ +//! The Looking Glass announce, end to end against a stand-in coordinator. +//! +//! The unit tests in `burrow::announce` pin the document and its signature. +//! What they can't show is that the burrow *sends* it: correct HTTP framing, a +//! `Content-Length` that matches the body, and a signature that still verifies +//! after the bytes have been through a socket. +//! +//! So this stands up a one-shot HTTP listener, points a burrow's tracker list +//! at it, and checks what actually arrives — validating it the way +//! `tracker.rabbit.direct` would: re-canonicalize the received descriptor and +//! verify the signature against the key the descriptor itself names. + +use std::time::Duration; + +use burrow::announce::{announce_url, canonical_json, signed_body}; +use rabbithole_identity::{IdentityKey, PublicKey, Signature}; +use rabbithole_server_core::config::{ServerConfig, DEFAULT_TRACKER}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +/// Accept one connection, read the request, answer 200, return the raw request. +async fn one_shot_tracker(listener: TcpListener) -> String { + let (mut sock, _) = listener.accept().await.expect("accept"); + let mut raw = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let n = sock.read(&mut buf).await.expect("read"); + if n == 0 { + break; + } + raw.extend_from_slice(&buf[..n]); + // Stop once the body named by Content-Length has arrived; the client + // keeps the socket open waiting for our response. + let text = String::from_utf8_lossy(&raw).to_string(); + if let Some((head, body)) = text.split_once("\r\n\r\n") { + let len: usize = head + .lines() + .find_map(|l| l.strip_prefix("Content-Length: ")) + .and_then(|v| v.trim().parse().ok()) + .unwrap_or(0); + if body.len() >= len { + break; + } + } + } + sock.write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 15\r\nConnection: close\r\n\r\n{\"ok\":true,\"a\":1}", + ) + .await + .ok(); + let _ = sock.shutdown().await; + String::from_utf8_lossy(&raw).to_string() +} + +fn advertised_cfg(tracker: &str) -> ServerConfig { + ServerConfig { + name: "Wonderland".into(), + advertise_host: "wonderland.example".into(), + announce_sysop: "alice".into(), + announce_description: "Down the rabbit hole.".into(), + announce_trackers: vec![tracker.to_string()], + quic_addr: "0.0.0.0:4653".parse().unwrap(), + ..ServerConfig::default() + } +} + +#[tokio::test] +async fn the_burrow_posts_an_announce_a_coordinator_would_accept() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(one_shot_tracker(listener)); + + let key = IdentityKey::from_seed(&[9u8; 32]); + let cfg = advertised_cfg(&format!("http://127.0.0.1:{port}")); + let body = signed_body(&cfg, &key, 1_700_000_000_000).expect("a body to send"); + let url = announce_url(&cfg.announce_trackers[0]).expect("a url"); + assert_eq!(url, format!("http://127.0.0.1:{port}/api/announce")); + + // Drive the same POST path the announce loop uses. + let (_status, raw) = tokio::time::timeout(Duration::from_secs(10), async { + let posted = burrow::announce::post_announce_for_test(&url, &body).await; + let raw = server.await.expect("listener"); + (posted, raw) + }) + .await + .expect("the exchange completes"); + + let (head, wire_body) = raw.split_once("\r\n\r\n").expect("a framed request"); + assert!( + head.starts_with(&format!( + "POST /api/announce HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n" + )), + "method, path and Host are right:\n{head}" + ); + assert!( + head.contains("Content-Type: application/json\r\n"), + "the coordinator parses JSON:\n{head}" + ); + assert!( + head.contains(&format!("Content-Length: {}\r\n", wire_body.len())), + "the declared length matches the body actually sent:\n{head}" + ); + + // Now validate exactly as the coordinator does, from the received bytes. + let received: Value = serde_json::from_str(wire_body).expect("valid JSON arrived"); + let descriptor = &received["descriptor"]; + let sig_hex = received["signature"].as_str().expect("a signature"); + + let announced_key = descriptor["publicKey"].as_str().expect("a key"); + let vk = PublicKey(hex::decode(announced_key).unwrap().try_into().unwrap()); + let sig = Signature(hex::decode(sig_hex).unwrap().try_into().unwrap()); + assert!( + vk.verify(canonical_json(descriptor).as_bytes(), &sig), + "the signature verifies after a round trip through the socket" + ); + + assert_eq!(descriptor["name"], "alice@wonderland.example"); + assert_eq!(descriptor["description"], "Down the rabbit hole."); + assert_eq!( + descriptor["endpoints"]["quic"], + "quic://wonderland.example:4653" + ); + assert_eq!(descriptor["timestamp"], 1_700_000_000_000i64); + assert_eq!(descriptor["ttl"], 120); +} + +#[tokio::test] +async fn a_burrow_that_opted_out_sends_nothing_at_all() { + // Not "sends an announce marked private" — sends nothing. The opt-out has + // to hold even if a coordinator ignores flags it doesn't understand. + let mut cfg = advertised_cfg(DEFAULT_TRACKER); + cfg.announce_enabled = false; + assert!( + signed_body(&cfg, &IdentityKey::from_seed(&[9u8; 32]), 1).is_none(), + "there is no body to POST" + ); +} + +/// Reach the real `tracker.rabbit.direct` over TLS and confirm it *rejects* a +/// tampered announce. +/// +/// Deliberately a rejection: a valid announce would publish a listing for a +/// burrow that doesn't exist onto a public directory. A 4xx here still proves +/// the whole client path — DNS, TLS, request framing, endpoint, JSON shape — +/// because the coordinator had to parse the document to refuse it. +/// +/// Ignored by default: it needs the network, and CI shouldn't depend on a +/// third-party service being up. Run it with +/// `cargo test -p burrow --test e2e_announce -- --ignored --nocapture`. +#[tokio::test] +#[ignore = "requires network access to tracker.rabbit.direct"] +async fn the_real_coordinator_refuses_a_tampered_announce() { + let key = IdentityKey::from_seed(&[3u8; 32]); + let mut cfg = advertised_cfg(DEFAULT_TRACKER); + cfg.advertise_host = "example.invalid".into(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let mut body: Value = serde_json::from_str(&signed_body(&cfg, &key, now).unwrap()).unwrap(); + // Flip one signature byte. Everything else stays well-formed, so the + // coordinator has to get all the way to signature verification to say no. + let mut sig = hex::decode(body["signature"].as_str().unwrap()).unwrap(); + sig[0] ^= 0xff; + body["signature"] = Value::String(hex::encode(sig)); + + let url = announce_url(DEFAULT_TRACKER).unwrap(); + let result = + burrow::announce::post_announce_for_test(&url, &serde_json::to_string(&body).unwrap()) + .await; + + let err = result.expect_err("a tampered signature must not be accepted"); + let text = err.to_string(); + println!("tracker said: {text}"); + assert!( + text.contains(" 400") || text.contains(" 401") || text.contains(" 403"), + "expected a validation refusal, got: {text}" + ); +} + +#[test] +fn the_standard_coordinator_is_the_default() { + let cfg = ServerConfig::default(); + assert!( + cfg.announce_enabled, + "a burrow nobody can find is a burrow nobody joins" + ); + assert_eq!(cfg.announce_trackers, vec![DEFAULT_TRACKER.to_string()]); + assert_eq!( + announce_url(DEFAULT_TRACKER).unwrap(), + "https://tracker.rabbit.direct/api/announce" + ); + assert!( + signed_body(&cfg, &IdentityKey::from_seed(&[1u8; 32]), 1).is_none(), + "but inert until advertise_host names somewhere reachable" + ); +} diff --git a/apps/server/tests/e2e_w53.rs b/apps/server/tests/e2e_w53.rs index 68d2fcd..1e78ace 100644 --- a/apps/server/tests/e2e_w53.rs +++ b/apps/server/tests/e2e_w53.rs @@ -263,7 +263,10 @@ async fn resume_token_authorizes_a_second_concurrent_session() { // The resumed session must be able to make the PRIVILEGED swarm calls the // download path needs — not merely be connected. let list = native.swarm_find([9u8; 32]).await.unwrap(); - assert_eq!(list.root, [9u8; 32], "swarm_find answered the native session"); + assert_eq!( + list.root, [9u8; 32], + "swarm_find answered the native session" + ); native.swarm_ticket([9u8; 32]).await.unwrap(); // And session 1 is unharmed by session 2's resume. diff --git a/apps/tui/Cargo.toml b/apps/tui/Cargo.toml index 3b8f45c..e31f1ee 100644 --- a/apps/tui/Cargo.toml +++ b/apps/tui/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] rabbithole-proto.workspace = true +rabbithole-directory = { workspace = true, features = ["native"] } rabbithole-core = { workspace = true, features = ["native"] } anyhow.workspace = true clap.workspace = true diff --git a/apps/tui/src/browser.rs b/apps/tui/src/browser.rs index 936f8e3..95d36e1 100644 --- a/apps/tui/src/browser.rs +++ b/apps/tui/src/browser.rs @@ -40,6 +40,52 @@ use tokio::net::TcpStream; /// Environment variable holding the default tracker address. pub const TRACKER_ENV: &str = "RABBIT_TRACKER"; +/// Ask the wide sources who is out there: `rabbithole.directory` over HTTPS, +/// falling back to the standard Looking Glass when it can't be reached. +/// +/// This is what the browser opens on. Pointing it at a specific tracker +/// (`a`, or `$RABBIT_TRACKER`) switches to that coordinator's `INDEX` instead, +/// with no fallback — asking for a particular tracker is a choice, and quietly +/// answering from somewhere else would be a different answer. +/// +/// Returns the rows plus which source answered and, when the fallback was used, +/// why — because "the directory is down" and "there is nobody out there" look +/// identical in a list otherwise. +pub async fn fetch_wide_directory() -> Result<(Vec, String, Option), String> { + // A terminal client speaks QUIC as well as WebSocket, so it keeps the + // rows a browser has to skip. + let listing = rabbithole_directory::fetch::discover(None, &["ws", "quic"]).await?; + let rows: Vec = listing.servers.iter().map(directory_row).collect(); + Ok(( + rows, + listing.source.label().to_string(), + listing.fallback_reason, + )) +} + +/// Map a directory row onto the browser's table shape. +/// +/// The fields the directory doesn't publish stay empty rather than being +/// filled with defaults: `last_seen` is 0 and `signed` is false because the +/// directory reports neither, and a `✓` this client did not verify would be +/// exactly the badge people should be able to trust. +fn directory_row(s: &rabbithole_directory::DirectoryServer) -> IndexEntry { + IndexEntry { + name: s.name.clone(), + addr: s.endpoint.clone(), + users: s.users_online.map(u64::from), + categories: s.listeners.clone(), + uptime_pct: s + .uptime_pct + .map(|p| format!("{:.1}", f64::from(p))) + .unwrap_or_else(|| "-".into()), + last_seen_secs: 0, + signed: false, + key_prefix: None, + generation: None, + } +} + /// The tracker's classic native status port, appended when the user types a /// bare host with no `:port`. pub const STATUS_PORT: u16 = 4655; @@ -63,9 +109,14 @@ const MAX_RESPONSE: u64 = 512 * 1024; pub struct IndexEntry { pub name: String, /// `ip:port` as printed by the tracker (kept as text — it is display - /// data and the `HEALTH` argument, not something we dial). + /// data and the `HEALTH` argument, not something we dial). Directory rows + /// put their dialable URI here, which is also what they are known by. pub addr: String, - pub users: u64, + /// Members online, when the source reports it. `None` where it doesn't — + /// rabbithole.directory publishes uptime and listeners but no population, + /// and printing a confident `0` for "not reported" would be this client + /// inventing a fact on the directory's behalf. + pub users: Option, pub categories: Vec, /// Observed 24 h uptime as the tracker rendered it (e.g. `"100.0"`, /// a percent with one decimal). Validated numeric, kept verbatim. @@ -132,6 +183,7 @@ pub fn parse_index_line(line: &str) -> Option { return None; } let users: u64 = cols[2].trim().parse().ok()?; + let users = Some(users); let categories = parse_categories_field(cols[3]); let uptime_pct = cols[4].trim(); // Validate numeric (the tracker prints a percent like "97.5") but keep @@ -320,6 +372,8 @@ pub type IndexPayload = (Vec, Option>); #[derive(Debug)] pub enum Outcome { Index(Result), + /// The wide view: rows plus the source label and, on fallback, why. + Wide(Result<(Vec, String, Option), String>), Health(Result), } @@ -339,8 +393,14 @@ pub struct BrowserState { pub addr_input: String, /// Whether the address line currently captures keystrokes. pub editing_addr: bool, - /// The connected (normalized) tracker address, once committed. + /// The connected (normalized) tracker address, once committed. `None` = + /// the wide view: rabbithole.directory with the standard tracker behind it. pub addr: Option, + /// Which source the current rows came from, for the pane title. "Who told + /// you this" is part of the answer to "who is out there". + pub source: Option, + /// Why the fallback was used, when the wider source didn't answer. + pub fallback_note: Option, /// `INDEX` rows exactly as served (tracker sort order preserved). pub rows: Vec, /// `CATEGORIES` rows for the filter cycle. @@ -362,9 +422,14 @@ impl BrowserState { /// Fresh state; `env_addr` (from `$RABBIT_TRACKER`) prefille the address /// input, which starts in editing mode until an address is committed. pub fn new(env_addr: Option) -> Self { + let addr_input = env_addr.map(|s| s.trim().to_string()).unwrap_or_default(); + // With no tracker named, open on the wide view rather than an empty + // address prompt: "who is out there" should have an answer before the + // user has to know what a coordinator is. + let editing_addr = !addr_input.is_empty(); Self { - addr_input: env_addr.map(|s| s.trim().to_string()).unwrap_or_default(), - editing_addr: true, + addr_input, + editing_addr, ..Self::default() } } @@ -395,6 +460,23 @@ impl BrowserState { self.error = None; } Outcome::Index(Err(err)) => self.error = Some(err), + Outcome::Wide(Ok((rows, source, fallback))) => { + self.rows = rows; + // The directory publishes no categories, so the filter row + // from a previous tracker session would be a lie here. + self.categories = Vec::new(); + self.filter = None; + self.selected = self.selected.min(self.rows.len().saturating_sub(1)); + self.health = None; + self.error = None; + self.source = Some(source); + self.fallback_note = fallback; + } + Outcome::Wide(Err(err)) => { + self.error = Some(err); + self.source = None; + self.fallback_note = None; + } Outcome::Health(Ok(detail)) => { self.health = Some(detail); self.error = None; @@ -459,7 +541,10 @@ pub fn format_row(entry: &IndexEntry) -> String { "{:<18.18} {:<21.21} {:>5} {:>6.6} {:>5}s {:<3} {}", entry.name, entry.addr, - entry.users, + // "-" rather than "0": the directory doesn't count people. + entry + .users + .map_or_else(|| "-".to_string(), |u| u.to_string()), entry.uptime_pct, entry.last_seen_secs, if entry.signed { "✓" } else { "-" }, @@ -528,7 +613,7 @@ mod tests { let row = parse_index_line(SIGNED_ROW).unwrap(); assert_eq!(row.name, "Wonderland"); assert_eq!(row.addr, "10.0.0.1:5500"); - assert_eq!(row.users, 12); + assert_eq!(row.users, Some(12)); assert_eq!(row.categories, vec!["chat".to_string()]); assert_eq!(row.uptime_pct, "100.0"); assert_eq!(row.last_seen_secs, 0); @@ -713,6 +798,138 @@ mod tests { parse_index_line(&format!("{name}\t10.0.0.1:5500\t1\t-\t100.0\t0\tno\t-\t-")).unwrap() } + #[test] + fn with_no_tracker_named_the_browser_opens_on_the_wide_view() { + // "Who is out there" should have an answer before the user has to know + // what a coordinator is — so no address prompt with nothing behind it. + let state = BrowserState::new(None); + assert!(!state.editing_addr, "no empty prompt to dismiss"); + assert!(state.addr.is_none(), "nothing named = the wide sources"); + + // Naming one via $RABBIT_TRACKER is still an explicit choice, and the + // prompt lets the user confirm or change it before dialing. + let state = BrowserState::new(Some(" glass.example ".into())); + assert!(state.editing_addr); + assert_eq!(state.addr_input, "glass.example"); + } + + #[test] + fn a_directory_listing_names_its_source_and_drops_tracker_only_state() { + let mut state = BrowserState::new(None); + // Arrive carrying a previous tracker session's categories and filter. + state.categories = vec![CategoryCount { + name: "chat".into(), + count: 3, + }]; + state.filter = Some("chat".into()); + let seq = state.begin(); + + state.apply(Fetched { + seq, + outcome: Outcome::Wide(Ok(( + vec![row("Wonderland")], + "rabbithole.directory".into(), + None, + ))), + }); + + assert_eq!(state.source.as_deref(), Some("rabbithole.directory")); + assert_eq!(state.rows.len(), 1); + assert!(!state.loading); + // The directory publishes no categories, so keeping the tracker's + // would be a filter row that filters nothing. + assert!(state.categories.is_empty()); + assert!(state.filter.is_none()); + } + + #[test] + fn falling_back_to_the_tracker_says_why() { + // "The directory is down" and "there is nobody out there" look + // identical in a list, so the reason has to survive to the pane. + let mut state = BrowserState::new(None); + let seq = state.begin(); + state.apply(Fetched { + seq, + outcome: Outcome::Wide(Ok(( + vec![row("Night Pool")], + "tracker.rabbit.direct".into(), + Some("connect rabbithole.directory:443: refused".into()), + ))), + }); + assert_eq!(state.source.as_deref(), Some("tracker.rabbit.direct")); + assert!(state.fallback_note.unwrap().contains("refused")); + } + + #[test] + fn both_sources_failing_is_an_error_with_no_stale_source_label() { + let mut state = BrowserState::new(None); + let seq = state.begin(); + state.apply(Fetched { + seq, + outcome: Outcome::Wide(Ok((vec![row("A")], "rabbithole.directory".into(), None))), + }); + let seq = state.begin(); + state.apply(Fetched { + seq, + outcome: Outcome::Wide(Err("nothing answered".into())), + }); + assert_eq!(state.error.as_deref(), Some("nothing answered")); + assert!( + state.source.is_none(), + "a stale label would credit a source that said nothing" + ); + assert!(!state.loading); + } + + #[test] + fn a_source_that_does_not_count_people_renders_a_dash_not_a_zero() { + // rabbithole.directory publishes uptime and listeners, not population. + // "0" would be this client inventing a fact on its behalf. + let mut entry = row("Quiet"); + entry.users = None; + let line = format_row(&entry); + assert!(line.contains('-'), "{line}"); + assert!(!line.contains(" 0 "), "{line}"); + + entry.users = Some(7); + assert!(format_row(&entry).contains('7')); + } + + #[test] + fn a_directory_row_never_claims_a_signature_this_client_did_not_check() { + let mapped = directory_row(&rabbithole_directory::DirectoryServer { + name: "alice@wonderland".into(), + endpoint: "ws://wonderland.co:4654".into(), + description: "The flagship.".into(), + users_online: None, + listeners: vec!["quic".into(), "ws".into()], + uptime_pct: Some(99), + reachable: true, + }); + assert_eq!(mapped.name, "alice@wonderland"); + assert_eq!(mapped.addr, "ws://wonderland.co:4654"); + assert_eq!(mapped.users, None); + assert_eq!(mapped.categories, vec!["quic", "ws"]); + assert_eq!(mapped.uptime_pct, "99.0"); + // The ✓ means "this client verified a signed descriptor". The + // directory doesn't ship one, so the badge stays off. + assert!(!mapped.signed); + assert!(mapped.key_prefix.is_none()); + assert!(selection_detail(&mapped).contains("unsigned")); + + // A glass reports liveness, not a history. "-" not "0.0". + let glass = directory_row(&rabbithole_directory::DirectoryServer { + name: "chesire@woods".into(), + endpoint: "quic://c.example:4653".into(), + description: "Cryptic boards.".into(), + users_online: None, + listeners: vec!["quic".into()], + uptime_pct: None, + reachable: false, + }); + assert_eq!(glass.uptime_pct, "-"); + } + #[test] fn state_seq_gates_stale_replies() { let mut state = BrowserState::new(Some(" tracker.example:4655 ".into())); @@ -908,7 +1125,7 @@ mod tests { let rows = parse_index(&index).expect("parse INDEX"); assert_eq!(rows.len(), 1); assert_eq!(rows[0].name, "Warren"); - assert_eq!(rows[0].users, 4); + assert_eq!(rows[0].users, Some(4)); assert!(!rows[0].signed); let health = query(&tracker, &format!("HEALTH {}", rows[0].addr)) diff --git a/apps/tui/src/chatlog.rs b/apps/tui/src/chatlog.rs new file mode 100644 index 0000000..949ab8e --- /dev/null +++ b/apps/tui/src/chatlog.rs @@ -0,0 +1,213 @@ +//! The chat log's scrollback model: which slice of the buffer is on screen. +//! +//! The lobby pane used to map *every* line into a `List` and render it with no +//! state. Ratatui draws a stateless list from index 0, so a client that seeds +//! 50 lines of history showed the **oldest** 50 and every new message landed +//! below the fold, invisible — on any terminal shorter than the backlog. The +//! comment above it said "tail to fit", which is what it was supposed to do. +//! +//! So the window is computed here, as arithmetic, and tested. Two rules: +//! +//! * **Follow by default.** A chat log that doesn't show the newest line is +//! broken; you should have to *choose* to leave the bottom. +//! * **Scrolling back pins.** Once you scroll up, incoming messages must not +//! yank you away from what you're reading. Returning to the bottom resumes +//! following. + +/// How many lines the buffer keeps. Beyond this the oldest are dropped: a +/// terminal client is a window on a conversation, not an archive, and an +/// unbounded `Vec` in a long-lived session is just a slow leak. +pub const MAX_LINES: usize = 2_000; + +/// The scrollback position for one log. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scroll { + /// Lines scrolled up from the newest. `0` = pinned to the bottom. + from_bottom: usize, +} + +impl Default for Scroll { + fn default() -> Self { + Self::new() + } +} + +impl Scroll { + pub const fn new() -> Self { + Self { from_bottom: 0 } + } + + /// Is the view pinned to the newest line (and therefore following)? + pub fn at_bottom(&self) -> bool { + self.from_bottom == 0 + } + + /// The window of `[first, last)` indices to render for a `len`-line buffer + /// in a pane `height` rows tall. + /// + /// Clamped on every axis: a buffer shorter than the pane starts at 0, and + /// a scroll position stranded by trimming (the buffer shrank under it) + /// resolves to the oldest line rather than panicking on a bad range. + pub fn window(&self, len: usize, height: usize) -> (usize, usize) { + if len == 0 || height == 0 { + return (0, 0); + } + let visible = height.min(len); + let max_scroll = len - visible; + let up = self.from_bottom.min(max_scroll); + let first = max_scroll - up; + (first, first + visible) + } + + /// Scroll up (toward older lines) by `n`, stopping at the oldest. + pub fn up(&mut self, n: usize, len: usize, height: usize) { + let max_scroll = len.saturating_sub(height.min(len)); + self.from_bottom = (self.from_bottom + n).min(max_scroll); + } + + /// Scroll down (toward newer lines) by `n`, stopping at — and re-pinning + /// to — the bottom. + pub fn down(&mut self, n: usize) { + self.from_bottom = self.from_bottom.saturating_sub(n); + } + + /// Jump to the oldest line. + pub fn jump_top(&mut self, len: usize, height: usize) { + self.from_bottom = len.saturating_sub(height.min(len)); + } + + /// Jump to the newest line and resume following. + pub fn jump_bottom(&mut self) { + self.from_bottom = 0; + } + + /// Account for `n` new lines arriving. + /// + /// Following (at the bottom) stays at the bottom — that's the point. + /// Scrolled back, the position shifts with the content so the line you + /// were reading stays under your eyes instead of sliding away. + pub fn on_appended(&mut self, n: usize, len_before: usize) { + if self.from_bottom == 0 { + return; + } + // Cap by what the buffer can actually hold: once trimming starts, + // holding position for every appended line would walk off the top. + let ceiling = len_before.min(MAX_LINES).saturating_sub(1); + self.from_bottom = (self.from_bottom + n).min(ceiling); + } +} + +/// Push a line, trimming to [`MAX_LINES`]. Returns how many were dropped off +/// the front, so a scrolled-back view can compensate. +pub fn push_trimmed(buf: &mut Vec, line: T) -> usize { + buf.push(line); + if buf.len() > MAX_LINES { + let excess = buf.len() - MAX_LINES; + buf.drain(..excess); + return excess; + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_log_shows_the_newest_lines_not_the_oldest() { + // The shipped bug: 50 lines of seeded history in a 10-row pane + // rendered lines 0..10 — the oldest — and every new message landed + // out of sight below. + let s = Scroll::new(); + assert_eq!(s.window(50, 10), (40, 50), "the last 10 lines"); + assert!(s.at_bottom()); + } + + #[test] + fn a_buffer_shorter_than_the_pane_starts_at_the_top() { + let s = Scroll::new(); + assert_eq!(s.window(3, 10), (0, 3)); + assert_eq!(s.window(0, 10), (0, 0), "empty log renders nothing"); + assert_eq!(s.window(50, 0), (0, 0), "no room, no window"); + } + + #[test] + fn scrolling_up_walks_back_and_stops_at_the_oldest() { + let mut s = Scroll::new(); + s.up(5, 50, 10); + assert_eq!(s.window(50, 10), (35, 45)); + assert!(!s.at_bottom(), "scrolled back is not following"); + // Past the top clamps rather than underflowing. + s.up(1_000, 50, 10); + assert_eq!(s.window(50, 10), (0, 10), "the oldest 10"); + s.up(1, 50, 10); + assert_eq!(s.window(50, 10), (0, 10), "already there"); + } + + #[test] + fn scrolling_down_returns_to_the_bottom_and_resumes_following() { + let mut s = Scroll::new(); + s.up(20, 50, 10); + s.down(5); + assert_eq!(s.window(50, 10), (25, 35)); + s.down(1_000); + assert!(s.at_bottom(), "clamps to the bottom, and follows again"); + assert_eq!(s.window(50, 10), (40, 50)); + } + + #[test] + fn home_and_end_jump() { + let mut s = Scroll::new(); + s.jump_top(50, 10); + assert_eq!(s.window(50, 10), (0, 10)); + s.jump_bottom(); + assert_eq!(s.window(50, 10), (40, 50)); + assert!(s.at_bottom()); + } + + #[test] + fn following_stays_at_the_bottom_when_messages_arrive() { + // The whole point of following. + let mut s = Scroll::new(); + s.on_appended(3, 50); + assert!(s.at_bottom()); + assert_eq!(s.window(53, 10), (43, 53), "the newest, including the new"); + } + + #[test] + fn reading_back_is_not_yanked_away_by_new_messages() { + // Scrolled up to read something, three messages land. The lines you + // were reading must stay put, or a busy room makes the backlog + // unreadable. + let mut s = Scroll::new(); + s.up(20, 50, 10); + let before = s.window(50, 10); + s.on_appended(3, 50); + assert_eq!(s.window(53, 10), before, "same lines under the eyes"); + assert!(!s.at_bottom()); + } + + #[test] + fn the_buffer_is_a_window_not_an_archive() { + // Below the cap nothing is dropped. + let mut buf: Vec = (0..(MAX_LINES as u32 - 1)).collect(); + assert_eq!(push_trimmed(&mut buf, 111), 0, "still room"); + assert_eq!(buf.len(), MAX_LINES); + assert_eq!(buf[0], 0, "nothing trimmed yet"); + + // At the cap, one in means one out — and it's the OLDEST that goes. + assert_eq!(push_trimmed(&mut buf, 999), 1, "full: one falls off"); + assert_eq!(buf.len(), MAX_LINES, "held at the cap"); + assert_eq!(*buf.last().unwrap(), 999, "the newest is kept"); + assert_eq!(buf[0], 1, "the oldest went"); + } + + #[test] + fn a_scroll_position_stranded_by_trimming_still_renders() { + // The buffer can shrink under a scrolled-back reader (trimming). The + // window must clamp, not panic on a reversed range. + let mut s = Scroll::new(); + s.up(500, 600, 10); + assert_eq!(s.window(20, 10), (0, 10), "clamped to what exists"); + } +} diff --git a/apps/tui/src/main.rs b/apps/tui/src/main.rs index 7bb4d17..7c09426 100644 --- a/apps/tui/src/main.rs +++ b/apps/tui/src/main.rs @@ -32,6 +32,7 @@ #![forbid(unsafe_code)] mod browser; +mod chatlog; mod handoff; mod radio; @@ -93,6 +94,12 @@ enum View { struct App { lines: Vec<(String, String)>, // (from, text); from "" = system + /// Where the chat log is scrolled to. Follows the newest line unless the + /// reader deliberately scrolls back (see [`chatlog`]). + scroll: chatlog::Scroll, + /// Rows the chat pane had at the last draw — scrolling is measured in + /// screen rows, and only the renderer knows how many there are. + chat_height: usize, online: Vec, input: String, pack: ThemePack, @@ -124,7 +131,9 @@ impl App { } fn sys(&mut self, text: impl Into) { - self.lines.push((String::new(), text.into())); + let before = self.lines.len(); + chatlog::push_trimmed(&mut self.lines, (String::new(), text.into())); + self.scroll.on_appended(1, before); } fn status(&mut self, text: impl Into) { @@ -137,9 +146,6 @@ impl App { self.status = None; self.base_edit = None; self.view = if self.view == view { View::Lobby } else { view }; - if self.view == View::Browser && self.browser.addr.is_none() { - self.browser.editing_addr = true; - } } } @@ -180,6 +186,8 @@ async fn main() -> Result<()> { let mut app = App { lines: history.into_iter().map(|m| (m.from, m.text)).collect(), + scroll: chatlog::Scroll::new(), + chat_height: 0, online, input: String::new(), pack: ThemePack::Clean, @@ -267,7 +275,9 @@ async fn run( fn apply_push(app: &mut App, frame: &rabbithole_proto::Frame) { if let Some(Ok(m)) = frame.decode::() { if m.room == "lobby" { - app.lines.push((m.from, m.text)); + let before = app.lines.len(); + chatlog::push_trimmed(&mut app.lines, (m.from, m.text)); + app.scroll.on_appended(1, before); } } else if let Some(Ok(j)) = frame.decode::() { if !app.online.contains(&j.user.screen_name) { @@ -323,6 +333,13 @@ async fn handle_key( } KeyCode::Char('b') if ctrl => { app.switch_view(View::Browser); + // Opening the browser asks the question. "Who is out there" should + // have an answer on screen before the user has to know what a + // coordinator is, or press anything. + if app.view == View::Browser && !app.browser.editing_addr && app.browser.rows.is_empty() + { + start_index_fetch(app, fetch_tx); + } return Ok(()); } _ => {} @@ -341,6 +358,36 @@ async fn handle_lobby_key( key: KeyEvent, ctrl: bool, ) -> Result<()> { + // Scrollback first: these keys never reach the input line, so reading + // history can't accidentally type into the room. + let page = app.chat_height.max(1); + match key.code { + KeyCode::PageUp => { + app.scroll.up(page, app.lines.len(), app.chat_height); + return Ok(()); + } + KeyCode::PageDown => { + app.scroll.down(page); + return Ok(()); + } + KeyCode::Up => { + app.scroll.up(1, app.lines.len(), app.chat_height); + return Ok(()); + } + KeyCode::Down => { + app.scroll.down(1); + return Ok(()); + } + KeyCode::Home => { + app.scroll.jump_top(app.lines.len(), app.chat_height); + return Ok(()); + } + KeyCode::End => { + app.scroll.jump_bottom(); + return Ok(()); + } + _ => {} + } match key.code { KeyCode::Esc => app.should_quit = true, KeyCode::Backspace => { @@ -519,6 +566,12 @@ fn handle_browser_key( match key.code { KeyCode::Esc => app.view = View::Lobby, KeyCode::Char('a') => app.browser.editing_addr = true, + // Back to the wide view from a named tracker. + KeyCode::Char('d') => { + app.browser.addr = None; + app.browser.addr_input.clear(); + start_index_fetch(app, fetch_tx); + } KeyCode::Char('r') => start_index_fetch(app, fetch_tx), KeyCode::Char('c') => { if app.browser.categories.is_empty() && app.browser.filter.is_none() { @@ -538,8 +591,18 @@ fn handle_browser_key( /// Spawn one `INDEX` (+ best-effort `CATEGORIES`) exchange; the reply comes /// back through the fetch channel tagged with a sequence number. fn start_index_fetch(app: &mut App, fetch_tx: &UnboundedSender) { + // No tracker named: ask the wide sources — rabbithole.directory, with the + // standard Looking Glass behind it. Naming a tracker (`a`) is a choice to + // ask that one coordinator instead. let Some(addr) = app.browser.addr.clone() else { - app.status("no tracker address yet — press a"); + let seq = app.browser.begin(); + let tx = fetch_tx.clone(); + tokio::spawn(async move { + let _ = tx.send(browser::Fetched { + seq, + outcome: browser::Outcome::Wide(browser::fetch_wide_directory().await), + }); + }); return; }; let seq = app.browser.begin(); @@ -575,7 +638,7 @@ fn start_index_fetch(app: &mut App, fetch_tx: &UnboundedSender /// Spawn a `HEALTH ` exchange for the selected row. fn start_health_fetch(app: &mut App, fetch_tx: &UnboundedSender) { let Some(addr) = app.browser.addr.clone() else { - app.status("no tracker address yet — press a"); + app.status("health is a tracker probe — press a to name a coordinator"); return; }; let Some(row) = app.browser.selected_row() else { @@ -601,7 +664,7 @@ fn start_health_fetch(app: &mut App, fetch_tx: &UnboundedSender draw_lobby(f, app, bands[0], accent, muted, text), View::Radio => draw_radio(f, app, bands[0], accent, muted), @@ -625,7 +690,7 @@ fn draw(f: &mut Frame, app: &App) { draw_status_bar(f, app, bands[1], accent, muted); } -fn draw_lobby(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Style, text: Style) { +fn draw_lobby(f: &mut Frame, app: &mut App, area: Rect, accent: Style, muted: Style, text: Style) { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Min(20), Constraint::Length(22)]) @@ -635,9 +700,15 @@ fn draw_lobby(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Style, .constraints([Constraint::Min(3), Constraint::Length(3)]) .split(cols[0]); - // Chat log (tail to fit). - let log: Vec = app - .lines + // Chat log — the window the scroll model picked. `rows[0]` includes the + // block's top and bottom borders, so the text area is two rows shorter; + // getting that wrong hides the newest line. + let height = rows[0].height.saturating_sub(2) as usize; + // Remember it: scrolling is measured in screen rows, and only the + // renderer knows how many the pane has. + app.chat_height = height; + let (first, last) = app.scroll.window(app.lines.len(), height); + let log: Vec = app.lines[first..last] .iter() .map(|(from, line)| { if from.is_empty() { @@ -650,7 +721,14 @@ fn draw_lobby(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Style, } }) .collect(); - let title = format!(" {} — lobby ", app.server_name); + let behind = app.lines.len().saturating_sub(last); + let title = if app.scroll.at_bottom() { + format!(" {} — lobby ", app.server_name) + } else { + // Scrolled back: say how much is below, so a busy room can't look + // idle just because you were reading something older. + format!(" {} — lobby — {behind} below (End) ", app.server_name) + }; f.render_widget( List::new(log).block( Block::default() @@ -793,16 +871,40 @@ fn draw_browser(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Styl } None => format!("all ({} cats)", b.categories.len()), }; - Line::from(vec![ - Span::styled("tracker: ", muted), - Span::styled(b.addr.clone().unwrap_or_else(|| "—".into()), accent), - Span::styled(format!(" · filter: {filter}"), muted), - ]) + match (&b.addr, &b.source) { + // Pointed at one coordinator: name it, and the filter it serves. + (Some(addr), _) => Line::from(vec![ + Span::styled("tracker: ", muted), + Span::styled(addr.clone(), accent), + Span::styled(format!(" · filter: {filter}"), muted), + ]), + // The wide view. Say which source answered — a fallback listing is + // a different answer, not the same one arriving late. + (None, Some(source)) => Line::from(vec![ + Span::styled("source: ", muted), + Span::styled(source.clone(), accent), + Span::styled( + b.fallback_note + .as_ref() + .map(|why| format!(" · fell back: {why}")) + .unwrap_or_default(), + muted, + ), + ]), + (None, None) => Line::from(Span::styled( + "source: rabbithole.directory (r to look)", + muted, + )), + } }; let bar_title = if b.editing_addr { " tracker address — Enter connect · Esc cancel " } else { - " server browser — a addr · r refresh · c filter · h health · Esc back " + if b.addr.is_some() { + " server browser — a tracker · d directory · r refresh · c filter · h health · Esc back " + } else { + " server browser — a tracker · d directory · r refresh · Esc back " + } }; f.render_widget( Paragraph::new(bar).block( @@ -827,12 +929,10 @@ fn draw_browser(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Styl muted.add_modifier(Modifier::BOLD), )))); if b.rows.is_empty() { - let placeholder = if b.addr.is_none() { - "(no tracker yet — press a to enter one, or set $RABBIT_TRACKER)" - } else if b.loading { + let placeholder = if b.loading { "(fetching…)" } else if b.error.is_none() { - "(no servers listed — r to refresh)" + "(no burrows listed — r to refresh, a to name a tracker)" } else { "" }; @@ -860,7 +960,7 @@ fn draw_browser(f: &mut Frame, app: &App, area: Rect, accent: Style, muted: Styl } } let table_title = format!( - " servers {}{} — sorted by tracker · uptime is tracker-observed ", + " burrows {}{} — as served · uptime is the source's own observation ", b.rows.len(), if b.loading { " · fetching…" } else { "" } ); diff --git a/contrib/burrow.service b/contrib/burrow.service index 4b7abff..2c27a3b 100644 --- a/contrib/burrow.service +++ b/contrib/burrow.service @@ -1,6 +1,6 @@ [Unit] Description=RabbitHole burrow server -Documentation=https://github.com/kevinelliott/RabbitHole +Documentation=https://github.com/mirrorward/rabbithole After=network-online.target Wants=network-online.target @@ -8,7 +8,12 @@ Wants=network-online.target Type=simple User=burrow Group=burrow -ExecStart=/usr/local/bin/burrow --data-dir /var/lib/burrow run +# /usr/bin is where the distro packages install; a from-source install into +# /usr/local/bin should edit this line (or use a drop-in). +ExecStart=/usr/bin/burrow --data-dir /var/lib/burrow run +# Serving the web client too? Point --web-root at a built SPA and add: +# ExecStart=/usr/bin/burrow --data-dir /var/lib/burrow \ +# --http --http-addr 0.0.0.0:8080 --web-root /var/lib/burrow/web run Restart=on-failure RestartSec=5s diff --git a/contrib/looking-glass.service b/contrib/looking-glass.service new file mode 100644 index 0000000..0206f83 --- /dev/null +++ b/contrib/looking-glass.service @@ -0,0 +1,53 @@ +[Unit] +Description=RabbitHole Looking Glass tracker +Documentation=https://github.com/mirrorward/rabbithole +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=burrow +Group=burrow +ExecStart=/usr/bin/looking-glass --status 0.0.0.0:4655 +Restart=on-failure +RestartSec=5s + +# The tracker keeps its directory in memory and takes its listeners from the +# command line, so there is nothing to configure by environment. +# EnvironmentFile=-/etc/burrow/looking-glass.env + +# The tracker holds no persistent state; it still gets a private directory so +# ProtectSystem=strict has somewhere writable rather than an exception. +StateDirectory=looking-glass +StateDirectoryMode=0750 +WorkingDirectory=/var/lib/looking-glass + +# --- Sandboxing / hardening --- +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ProtectClock=true +ProtectHostname=true +ProtectKernelLogs=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectProc=invisible +ReadWritePaths=/var/lib/looking-glass +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +RestrictNamespaces=true +RestrictRealtime=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM +SystemCallArchitectures=native +CapabilityBoundingSet= +AmbientCapabilities= +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/crates/art/Cargo.toml b/crates/art/Cargo.toml index d8de4f8..18fc67d 100644 --- a/crates/art/Cargo.toml +++ b/crates/art/Cargo.toml @@ -7,5 +7,11 @@ rust-version.workspace = true repository.workspace = true publish.workspace = true +[features] +default = ["png"] +# PNG thumbnail rasterizer (`render_png`). The wasm SPA only needs the ANSI +# parser and VGA palette, so it disables this and never ships `png` + flate. +png = ["dep:png"] + [dependencies] -png.workspace = true +png = { workspace = true, optional = true } diff --git a/crates/art/src/lib.rs b/crates/art/src/lib.rs index aa2d1da..aaec784 100644 --- a/crates/art/src/lib.rs +++ b/crates/art/src/lib.rs @@ -28,6 +28,7 @@ pub mod ansi; pub mod cp437; +#[cfg(feature = "png")] pub mod font; pub mod raster; pub mod render; @@ -35,7 +36,9 @@ pub mod sauce; pub use ansi::{AnsiParser, Attrs, Canvas, Cell}; pub use cp437::{cp437_to_string, cp437_to_unicode, unicode_to_cp437, unicode_to_cp437_lossy}; -pub use raster::{render_png, PngOptions, PALETTE}; +pub use raster::PALETTE; +#[cfg(feature = "png")] +pub use raster::{render_png, PngOptions}; pub use render::{render_ansi, render_html, render_plain}; pub use sauce::SauceRecord; diff --git a/crates/art/src/raster.rs b/crates/art/src/raster.rs index e643763..5803d44 100644 --- a/crates/art/src/raster.rs +++ b/crates/art/src/raster.rs @@ -15,8 +15,11 @@ //! Encoding writes into an in-memory `Vec` (an infallible sink), so the //! function is total — arbitrary canvases render, never panic. +#[cfg(feature = "png")] use crate::ansi::{Attrs, Canvas, Cell}; +#[cfg(feature = "png")] use crate::cp437::unicode_to_cp437; +#[cfg(feature = "png")] use crate::font::{FONT_8X16, GLYPH_HEIGHT, GLYPH_WIDTH}; /// The canonical IBM VGA/DOS 16-color palette as RGB triples, indexed by @@ -42,11 +45,14 @@ pub const PALETTE: [[u8; 3]; 16] = [ /// Upper bound on the per-cell scale factor (keeps `GLYPH_* * scale` well /// away from overflow and absurd allocations). +#[cfg(feature = "png")] const MAX_SCALE: u32 = 64; /// Hard ceiling for [`PngOptions::max_dimension`] regardless of caller. +#[cfg(feature = "png")] const MAX_DIMENSION_CAP: u32 = 16_384; /// Options controlling [`render_png`]. +#[cfg(feature = "png")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PngOptions { /// Integer scale factor applied to each cell. `1` yields the native @@ -59,6 +65,7 @@ pub struct PngOptions { pub max_dimension: u32, } +#[cfg(feature = "png")] impl Default for PngOptions { fn default() -> Self { PngOptions { @@ -68,6 +75,7 @@ impl Default for PngOptions { } } +#[cfg(feature = "png")] impl PngOptions { /// Options for a small preview: native cell size, tight dimension cap. pub fn thumbnail() -> Self { @@ -82,6 +90,7 @@ impl PngOptions { /// 16-color VGA palette, honoring foreground/background and the reverse /// attribute. Returns the encoded PNG bytes (empty only if encoding fails, /// which cannot happen for the in-memory sink). +#[cfg(feature = "png")] pub fn render_png(canvas: &Canvas, opts: &PngOptions) -> Vec { let scale = opts.scale.clamp(1, MAX_SCALE); let max_dim = opts.max_dimension.clamp(1, MAX_DIMENSION_CAP); @@ -132,6 +141,7 @@ pub fn render_png(canvas: &Canvas, opts: &PngOptions) -> Vec { } /// Resolve a cell's foreground/background RGB, applying reverse video. +#[cfg(feature = "png")] fn cell_colors(cell: &Cell) -> ([u8; 3], [u8; 3]) { let fg = PALETTE[cell.fg as usize & 0x0F]; let bg = PALETTE[cell.bg as usize & 0x0F]; @@ -143,6 +153,7 @@ fn cell_colors(cell: &Cell) -> ([u8; 3], [u8; 3]) { } /// The 8×16 bitmap for `ch`, or a blank glyph when it has no CP437 byte. +#[cfg(feature = "png")] fn glyph_for(ch: char) -> &'static [u8; GLYPH_HEIGHT] { match unicode_to_cp437(ch) { Some(byte) => &FONT_8X16[byte as usize], @@ -151,6 +162,7 @@ fn glyph_for(ch: char) -> &'static [u8; GLYPH_HEIGHT] { } /// Encode a tightly-packed RGB8 buffer as a PNG into a fresh `Vec`. +#[cfg(feature = "png")] fn encode_rgb(width: u32, height: u32, buf: &[u8]) -> Vec { let mut out = Vec::new(); let mut encoder = png::Encoder::new(&mut out, width, height); @@ -163,7 +175,7 @@ fn encode_rgb(width: u32, height: u32, buf: &[u8]) -> Vec { out } -#[cfg(test)] +#[cfg(all(test, feature = "png"))] mod tests { use super::*; use crate::ansi::parse; diff --git a/crates/core/src/client.rs b/crates/core/src/client.rs index 842b121..2fd9299 100644 --- a/crates/core/src/client.rs +++ b/crates/core/src/client.rs @@ -258,9 +258,26 @@ impl Client { login: &str, password: &str, ) -> Result { - let ok: psess::AuthOk = self - .request(&psess::AuthPassword::new(login, password)) - .await?; + self.auth_password_totp(login, password, None).await + } + + /// Sign in with a password and, when the account has two-factor enabled, + /// its current TOTP code. + /// + /// `AuthPassword::with_totp` has existed since the 2FA slice, but nothing + /// called it — so an account with 2FA turned on simply could not sign in + /// from any native client, which is a lockout, not a missing feature. + pub async fn auth_password_totp( + &mut self, + login: &str, + password: &str, + totp: Option<&str>, + ) -> Result { + let mut req = psess::AuthPassword::new(login, password); + if let Some(code) = totp.map(str::trim).filter(|c| !c.is_empty()) { + req = req.with_totp(code); + } + let ok: psess::AuthOk = self.request(&req).await?; self.remember_token(&ok); Ok(ok) } diff --git a/crates/directory/Cargo.toml b/crates/directory/Cargo.toml new file mode 100644 index 0000000..113dd31 --- /dev/null +++ b/crates/directory/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "rabbithole-directory" +version.workspace = true +edition.workspace = true +description = "Looking Glass discovery client: read rabbithole.directory, fall back to a tracker" + +[dependencies] +# The pure half deliberately has NO dependencies: it is the same code in the +# wasm SPA and in the terminal clients, and a discovery parser that only +# compiles on one of them is a discovery parser that gets forked. +tokio = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +tokio-rustls = { workspace = true, optional = true } +rustls-pki-types = { workspace = true, optional = true } +webpki-roots = { workspace = true, optional = true } + +[features] +default = [] +# Native transport: HTTPS to the directory and TCP to a tracker's status port. +# Off for wasm, which has neither and uses the browser's own fetch. +native = [ + "dep:tokio", + "dep:rustls", + "dep:tokio-rustls", + "dep:rustls-pki-types", + "dep:webpki-roots", +] + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/directory/src/fetch.rs b/crates/directory/src/fetch.rs new file mode 100644 index 0000000..34adf62 --- /dev/null +++ b/crates/directory/src/fetch.rs @@ -0,0 +1,541 @@ +//! The network edge: HTTPS to `rabbithole.directory`, TCP to a tracker. +//! +//! Native only. A browser tab has no TCP and reaches the directory through its +//! own `fetch`, so this whole module sits behind the `native` feature and the +//! wasm build never sees rustls. +//! +//! The HTTP client here is deliberately small — one GET, no redirects, no +//! conditional requests, no cookies — because that is the entire requirement. +//! Everything it reads is size-capped and time-bounded: discovery talks to +//! hosts you have not chosen and cannot vouch for, so an unbounded read is a +//! way for a stranger to hang your client. + +use std::sync::Arc; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::{ + parse_directory_json_with, parse_glass_json, parse_tracker_index, DirectoryServer, + DirectorySource, DIRECTORY_URL, TRACKER_STATUS_PORT, TRACKER_URL, +}; + +/// Overall budget for one source. Discovery is something a person is waiting +/// on, so a slow source has to lose to the fallback rather than stall the view. +const TIMEOUT: Duration = Duration::from_secs(8); + +/// Response cap. The directory serves a few hundred rows; anything past this is +/// not a listing. +const MAX_RESPONSE: usize = 1024 * 1024; + +/// What a discovery attempt produced. +pub struct Listing { + pub servers: Vec, + pub source: DirectorySource, + /// Why the wider source was not used, when a narrower one answered. Worth + /// showing: "the directory is down" and "there is nobody out there" look + /// identical in a list otherwise. + pub fallback_reason: Option, +} + +/// Ask who is out there: `rabbithole.directory` first, then the standard +/// Looking Glass behind it. +/// +/// `tracker` names a coordinator to ask **instead**. Naming one is a choice, so +/// it gets no fallback — quietly answering from somewhere else would be a +/// different answer. A named coordinator is tried as HTTPS `/api/burrows` and +/// then on its line-oriented status port, which is what a self-hosted +/// `looking-glass` serves. +/// +/// `endpoint_fields` selects which URIs count as dialable, in preference order +/// — a WebSocket-only client passes `["ws"]`, a QUIC-speaking one +/// `["ws", "quic"]`. (The directory spells these `wsUri`/`quicUri`; the +/// suffix is added here so callers state the protocol once.) +pub async fn discover(tracker: Option<&str>, endpoint_fields: &[&str]) -> Result { + if let Some(named) = tracker.map(str::trim).filter(|t| !t.is_empty()) { + return named_tracker(named, endpoint_fields).await; + } + + let directory_error = match fetch_directory(endpoint_fields).await { + Ok(servers) => { + return Ok(Listing { + servers, + source: DirectorySource::Directory, + fallback_reason: None, + }) + } + Err(e) => e, + }; + + match fetch_glass(TRACKER_URL, endpoint_fields).await { + Ok(servers) => Ok(Listing { + servers, + source: DirectorySource::Tracker, + fallback_reason: Some(directory_error), + }), + // Both failed. Lead with the directory's failure: it is what the user + // expected to see, and burying that under the fallback's error hides + // the real problem. + Err(glass_error) => Err(format!( + "{directory_error} The tracker didn't answer either: {glass_error}" + )), + } +} + +/// Ask one named coordinator. HTTPS first (what a hosted glass serves), then +/// its status port (what the self-hosted `looking-glass` binary serves). +async fn named_tracker(entry: &str, endpoint_fields: &[&str]) -> Result { + let https_error = if entry.starts_with("http://") { + // An explicit plaintext entry is a local coordinator; skip straight to + // the status port rather than pretending we tried HTTPS. + "not tried (plaintext entry)".to_string() + } else { + let url = if entry.contains("://") { + let e = entry.trim().trim_end_matches('/'); + if e.contains("/api/burrows") { + e.to_string() + } else { + format!("{e}/api/burrows") + } + } else { + format!("https://{}/api/burrows", entry.trim_end_matches('/')) + }; + match fetch_glass(&url, endpoint_fields).await { + Ok(servers) => { + return Ok(Listing { + servers, + source: DirectorySource::Tracker, + fallback_reason: None, + }) + } + Err(e) => e, + } + }; + + let addr = tracker_addr(entry); + let text = query_tracker(&addr, "INDEX").await.map_err(|e| { + format!("{entry} didn't answer over HTTPS ({https_error}) or on {addr}: {e}") + })?; + parse_tracker_index(&text).map(|servers| Listing { + servers, + source: DirectorySource::Tracker, + // HTTPS was the wider try on this coordinator; the status port + // answering is a narrower path, and the reason belongs on the listing. + fallback_reason: (https_error != "not tried (plaintext entry)").then_some(https_error), + }) +} + +/// Fetch and parse a Looking Glass listing over HTTPS. +pub async fn fetch_glass( + url: &str, + endpoint_kinds: &[&str], +) -> Result, String> { + let body = https_get(url).await?; + parse_glass_json(&body, endpoint_kinds) +} + +/// Fetch and parse the directory snapshot over HTTPS. +pub async fn fetch_directory(endpoint_fields: &[&str]) -> Result, String> { + let body = https_get(DIRECTORY_URL).await?; + // The directory names its endpoints `wsUri` / `quicUri`; callers state the + // protocol once and the suffix is applied here. + let fields: Vec = endpoint_fields.iter().map(|k| format!("{k}Uri")).collect(); + let refs: Vec<&str> = fields.iter().map(String::as_str).collect(); + parse_directory_json_with(&body, &refs) +} + +/// Normalize a tracker entry into `host:port`, appending the status port when +/// the user typed a bare host. +pub fn tracker_addr(entry: &str) -> String { + let e = host_port_of(entry); + // An IPv6 literal is bracketed; a bare `::1` has colons but no port, and + // splitting on the last colon would silently eat a hextet. + let has_port = if e.starts_with('[') { + e.rfind("]:").is_some() + } else { + e.matches(':').count() == 1 + }; + if has_port { + e.to_string() + } else { + format!("{e}:{TRACKER_STATUS_PORT}") + } +} + +/// Strip a scheme and path so `https://glass.example:8443/api/burrows` becomes +/// `glass.example:8443`. A `https://` entry has a colon in the scheme; treating +/// that as "already has a port" would dial the URL string as a TCP address. +fn host_port_of(entry: &str) -> &str { + let e = entry.trim(); + let e = e + .strip_prefix("https://") + .or_else(|| e.strip_prefix("http://")) + .unwrap_or(e); + e.split('/').next().unwrap_or(e) +} + +/// One command/reply exchange with a tracker's status port. +pub async fn query_tracker(addr: &str, command: &str) -> Result { + let run = async { + let mut sock = TcpStream::connect(addr) + .await + .map_err(|e| format!("connect {addr}: {e}"))?; + sock.write_all(format!("{command}\n").as_bytes()) + .await + .map_err(|e| format!("send: {e}"))?; + let mut buf = Vec::new(); + // The status port is one-shot: it answers and closes, so reading to EOF + // is the framing. The cap is what keeps a hostile "tracker" from + // streaming forever. + let mut chunk = [0u8; 16 * 1024]; + loop { + let n = sock + .read(&mut chunk) + .await + .map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + if buf.len() > MAX_RESPONSE { + return Err("the tracker's reply is too large to be a listing".to_string()); + } + } + Ok(String::from_utf8_lossy(&buf).into_owned()) + }; + tokio::time::timeout(TIMEOUT, run) + .await + .map_err(|_| format!("{addr} did not answer within {}s", TIMEOUT.as_secs()))? +} + +/// A single HTTPS GET, returning the body of a 2xx response. +async fn https_get(url: &str) -> Result { + let (host, port, path) = split_url(url)?; + let run = async { + let tcp = TcpStream::connect((host.as_str(), port)) + .await + .map_err(|e| format!("connect {host}:{port}: {e}"))?; + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: rabbithole/{}\r\nAccept: application/json\r\nConnection: close\r\n\r\n", + env!("CARGO_PKG_VERSION"), + ); + let raw = tls_exchange(tcp, &host, request.as_bytes()).await?; + let (status, body) = split_response(&raw)?; + if !(200..300).contains(&status) { + return Err(format!("{host} answered {status}")); + } + Ok(body) + }; + tokio::time::timeout(TIMEOUT, run) + .await + .map_err(|_| format!("{host} did not answer within {}s", TIMEOUT.as_secs()))? +} + +/// `https://host[:port]/path` → `(host, port, path)`. Only HTTPS: discovery +/// endpoints are public URLs we ship, and accepting a plaintext one here would +/// make a downgrade a config typo away. +fn split_url(url: &str) -> Result<(String, u16, String), String> { + let rest = url + .strip_prefix("https://") + .ok_or_else(|| format!("{url} is not an https:// URL"))?; + let (authority, path) = match rest.find('/') { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, "/"), + }; + let (host, port) = match authority.rsplit_once(':') { + Some((h, p)) if !h.is_empty() => ( + h.to_string(), + p.parse().map_err(|_| format!("bad port in {url}"))?, + ), + _ => (authority.to_string(), 443), + }; + if host.is_empty() { + return Err(format!("{url} has no host")); + } + Ok((host, port, path.to_string())) +} + +/// The shared rustls client config: webpki (Mozilla) roots, no client auth. +fn tls_config() -> Arc { + static CONFIG: std::sync::OnceLock> = std::sync::OnceLock::new(); + CONFIG + .get_or_init(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(), + ) + }) + .clone() +} + +async fn tls_exchange(tcp: TcpStream, host: &str, request: &[u8]) -> Result, String> { + let server_name = rustls_pki_types::ServerName::try_from(host.to_string()) + .map_err(|_| format!("{host} is not a valid TLS server name"))?; + let connector = tokio_rustls::TlsConnector::from(tls_config()); + let mut stream = connector + .connect(server_name, tcp) + .await + .map_err(|e| format!("tls handshake with {host}: {e}"))?; + stream + .write_all(request) + .await + .map_err(|e| format!("send: {e}"))?; + let mut response = Vec::new(); + let mut buf = [0u8; 16 * 1024]; + loop { + match stream.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + response.extend_from_slice(&buf[..n]); + if response.len() > MAX_RESPONSE { + return Err("the reply is too large to be a listing".to_string()); + } + } + // A close without `close_notify`. rustls reports it as an error + // because in general it could be a truncation attack, but plenty + // of real servers and CDNs just close the socket — including the + // ones we have to read. The response is framed by Content-Length + // or chunked encoding, and `split_response` rejects a body that + // doesn't match, so a genuine truncation still fails there rather + // than here. + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, + Err(e) => return Err(format!("read: {e}")), + } + } + let _ = stream.shutdown().await; + Ok(response) +} + +/// Split a raw HTTP/1.1 response into `(status, body)`, decoding a chunked +/// body. Total over arbitrary bytes: a malformed reply is an `Err`. +/// +/// Framing is done on **bytes**, then the assembled body is decoded as UTF-8. +/// Interpreting the stream as a string first would both corrupt a UTF-8 +/// character split across chunks (lossy replacement) and panic if a chunk +/// size landed mid-character on the already-decoded `&str`. +fn split_response(raw: &[u8]) -> Result<(u16, String), String> { + let head_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .ok_or_else(|| "the reply has no header/body break".to_string())?; + let head = String::from_utf8_lossy(&raw[..head_end]); + let mut lines = head.lines(); + let status: u16 = lines + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|s| s.parse().ok()) + .ok_or_else(|| "the reply has no status line".to_string())?; + let headers: Vec = lines.map(|l| l.to_ascii_lowercase()).collect(); + let body = &raw[head_end + 4..]; + let chunked = headers + .iter() + .any(|l| l.starts_with("transfer-encoding:") && l.contains("chunked")); + if chunked { + // The terminating zero-length chunk is the framing; `dechunk` errors + // without it, so a truncated stream cannot read as a complete body. + return Ok(( + status, + String::from_utf8_lossy(&dechunk(body)?).into_owned(), + )); + } + // Not chunked: `Content-Length` is the framing, and a short body means the + // connection was cut mid-reply. Accepting it would hand a truncated + // listing to the parser as though it were the whole thing. + if let Some(len) = headers + .iter() + .find_map(|l| l.strip_prefix("content-length:")) + .and_then(|v| v.trim().parse::().ok()) + { + if body.len() < len { + return Err(format!( + "the reply was cut short: {} of {len} bytes", + body.len() + )); + } + return Ok((status, String::from_utf8_lossy(&body[..len]).into_owned())); + } + Ok((status, String::from_utf8_lossy(body).into_owned())) +} + +/// Decode a `Transfer-Encoding: chunked` body. Chunk sizes are byte counts +/// of the original stream, so this stays on `[u8]`. +fn dechunk(body: &[u8]) -> Result, String> { + let mut out = Vec::new(); + let mut rest = body; + loop { + let line_end = rest + .windows(2) + .position(|w| w == b"\r\n") + .ok_or_else(|| "truncated chunk header".to_string())?; + let size_line = + std::str::from_utf8(&rest[..line_end]).map_err(|_| "bad chunk header".to_string())?; + // A chunk size may carry `;extensions`, which are not part of the size. + let size_hex = size_line.split(';').next().unwrap_or("").trim(); + let size = usize::from_str_radix(size_hex, 16) + .map_err(|_| format!("bad chunk size {size_hex:?}"))?; + rest = &rest[line_end + 2..]; + if size == 0 { + return Ok(out); + } + if rest.len() < size { + return Err("truncated chunk body".to_string()); + } + out.extend_from_slice(&rest[..size]); + rest = rest[size..].strip_prefix(b"\r\n").unwrap_or(&rest[size..]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_bare_tracker_host_gets_the_status_port() { + assert_eq!( + tracker_addr("tracker.rabbit.direct"), + "tracker.rabbit.direct:4655" + ); + assert_eq!(tracker_addr(" glass.example "), "glass.example:4655"); + assert_eq!(tracker_addr("glass.example:9000"), "glass.example:9000"); + // A URL is a coordinator name, not a TCP address. The scheme colon + // must not be read as "already has a port". + assert_eq!( + tracker_addr("https://tracker.rabbit.direct/api/burrows"), + "tracker.rabbit.direct:4655" + ); + assert_eq!(tracker_addr("http://127.0.0.1:3000/"), "127.0.0.1:3000"); + } + + #[test] + fn ipv6_literals_keep_their_hextets() { + // Splitting on the last colon would turn `::1` into host `:` port `1`. + assert_eq!(tracker_addr("::1"), "::1:4655"); + assert_eq!(tracker_addr("[::1]:4655"), "[::1]:4655"); + } + + #[test] + fn urls_split_into_host_port_and_path() { + assert_eq!( + split_url(DIRECTORY_URL).unwrap(), + ("rabbithole.directory".into(), 443, "/api/burrows".into()) + ); + assert_eq!( + split_url("https://glass.example:8443").unwrap(), + ("glass.example".into(), 8443, "/".into()) + ); + // Plaintext would make a downgrade one config typo away. + assert!(split_url("http://rabbithole.directory/api/burrows").is_err()); + assert!(split_url("https://").is_err()); + } + + #[test] + fn responses_split_into_status_and_body() { + let raw = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}"; + assert_eq!(split_response(raw).unwrap(), (200, "{\"ok\":true}".into())); + + let raw = b"HTTP/1.1 503 Service Unavailable\r\n\r\nnope"; + assert_eq!(split_response(raw).unwrap().0, 503); + + assert!(split_response(b"garbage").is_err()); + assert!(split_response(b"not a status line\r\n\r\nbody").is_err()); + } + + #[test] + fn chunked_bodies_are_reassembled() { + // The directory is served through a CDN, which chunks; a client that + // couldn't read that would parse chunk-size lines as JSON. + let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\n{\"ok\"\r\nc\r\n:true,\"a\":1}\r\n0\r\n\r\n"; + assert_eq!( + split_response(raw).unwrap(), + (200, "{\"ok\":true,\"a\":1}".into()) + ); + } + + #[test] + fn a_truncated_chunked_body_is_an_error_not_a_panic() { + assert!(dechunk(b"5\r\nab").is_err(), "body shorter than declared"); + assert!(dechunk(b"zz\r\n").is_err(), "size is not hex"); + assert!(dechunk(b"").is_err(), "no chunk header at all"); + } + + #[test] + fn a_utf8_character_split_across_chunks_survives() { + // `é` is `c3 a9`. A CDN that chunks on byte 1 used to hand a `&str` + // slice that was not a char boundary — panic — or, after lossy + // conversion, a replacement character instead of the letter. + let raw = b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n1\r\n\xc3\r\n1\r\n\xa9\r\n0\r\n\r\n"; + assert_eq!(split_response(raw).unwrap(), (200, "é".into())); + } + + /// Read the real `rabbithole.directory` and the real tracker. + /// + /// Ignored by default — it needs the network, and CI shouldn't depend on a + /// third-party service being up. It exists because the parsers are pinned + /// against a *copy* of a reply, and a copy stops being evidence the moment + /// the service changes shape. Run with + /// `cargo test -p rabbithole-directory --features native -- --ignored --nocapture`. + #[tokio::test] + #[ignore = "requires network access to rabbithole.directory"] + async fn the_live_services_still_serve_what_we_parse() { + let listing = discover(None, &["ws", "quic"]) + .await + .expect("one of the two sources answers"); + println!( + "source: {} ({} burrows){}", + listing.source.label(), + listing.servers.len(), + listing + .fallback_reason + .as_ref() + .map(|r| format!(" — fell back: {r}")) + .unwrap_or_default() + ); + for s in listing.servers.iter().take(5) { + println!( + " {:<28} {:<38} {}", + s.name, + s.endpoint, + s.uptime_pct + .map(|p| format!("{p}%")) + .unwrap_or_else(|| "-".into()) + ); + } + assert!(!listing.servers.is_empty()); + assert!( + listing.servers.iter().all(|s| !s.endpoint.is_empty()), + "every row is dialable" + ); + + // And the fallback path independently, so a working directory doesn't + // hide a broken tracker until the day the directory goes down. + // And the fallback path independently, so a working directory doesn't + // hide a broken glass until the day the directory goes down. + let body = https_get(TRACKER_URL).await.expect("the glass answers"); + println!("glass /api/burrows: {} bytes", body.len()); + match parse_glass_json(&body, &["ws", "quic"]) { + Ok(rows) => println!(" {} dialable", rows.len()), + // An empty glass is a legitimate state (nobody has announced) and + // not this test's business; a *malformed* reply would be. + Err(e) => assert!( + e.contains("no burrows this client can dial"), + "the glass reply did not parse: {e}" + ), + } + assert!( + crate::json::parse(&body).is_ok(), + "the glass still serves JSON we can read" + ); + } + + #[tokio::test] + async fn an_unreachable_tracker_reports_rather_than_hangs() { + // Port 1 on loopback refuses immediately — the error path, fast. + let err = query_tracker("127.0.0.1:1", "INDEX").await.unwrap_err(); + assert!(err.contains("connect"), "{err}"); + } +} diff --git a/crates/directory/src/json.rs b/crates/directory/src/json.rs new file mode 100644 index 0000000..c9f9dd9 --- /dev/null +++ b/crates/directory/src/json.rs @@ -0,0 +1,405 @@ +//! A small, total JSON reader. +//! +//! The discovery parsers used to find fields by splitting on `{` and searching +//! for `"key":`, which works only while every object is flat. The Looking +//! Glass nests (`endpoints: {quic, ws}`, and a whole `descriptor` object per +//! row), and under that heuristic a nested object starts a new "row" and its +//! fields leak into the wrong entry. So the shape is actually parsed. +//! +//! `serde_json` isn't used here because this crate is compiled into the wasm +//! SPA, where one endpoint's worth of parsing is not worth serde's derive +//! machinery in the bundle. What is needed is small: read a document, walk to a +//! field, read a string or an array of strings. +//! +//! Total by construction: every entry point returns `Result`/`Option`, the +//! parser is depth-limited so a hostile reply can't blow the stack, and no +//! path can panic on arbitrary bytes. + +/// Nesting a reply may reach. Discovery documents are three or four levels +/// deep; anything beyond this is someone trying to exhaust the stack. +const MAX_DEPTH: usize = 32; + +/// A parsed JSON value. Numbers keep their `f64` value — nothing here needs +/// integer precision beyond what a percentage or a timestamp requires. +#[derive(Debug, Clone, PartialEq)] +pub enum Json { + Null, + Bool(bool), + Num(f64), + Str(String), + Arr(Vec), + /// Object members in document order. A `Vec` rather than a map: these + /// documents have a handful of keys and lookup is by name once. + Obj(Vec<(String, Json)>), +} + +impl Json { + /// The value of object field `name`, if this is an object that has it. + pub fn get(&self, name: &str) -> Option<&Json> { + match self { + Json::Obj(fields) => fields.iter().find(|(k, _)| k == name).map(|(_, v)| v), + _ => None, + } + } + + /// Field `name` as a string, if it is one. + pub fn str_field(&self, name: &str) -> Option<&str> { + match self.get(name)? { + Json::Str(s) => Some(s.as_str()), + _ => None, + } + } + + /// Field `name` as an array's items, if it is an array. + pub fn arr_field(&self, name: &str) -> &[Json] { + match self.get(name) { + Some(Json::Arr(items)) => items, + _ => &[], + } + } + + /// Field `name` as a list of strings, skipping any item that isn't one. + pub fn str_array_field(&self, name: &str) -> Vec { + self.arr_field(name) + .iter() + .filter_map(|v| match v { + Json::Str(s) => Some(s.clone()), + _ => None, + }) + .collect() + } + + /// This value as a string, if it is one. + pub fn as_str(&self) -> Option<&str> { + match self { + Json::Str(s) => Some(s.as_str()), + _ => None, + } + } +} + +/// Parse a complete JSON document. Trailing whitespace is fine; trailing +/// content is not. +pub fn parse(text: &str) -> Result { + let bytes = text.as_bytes(); + let mut p = Parser { bytes, i: 0 }; + p.skip_ws(); + let value = p.value(0)?; + p.skip_ws(); + if p.i != bytes.len() { + return Err(format!("trailing content at byte {}", p.i)); + } + Ok(value) +} + +struct Parser<'a> { + bytes: &'a [u8], + i: usize, +} + +impl Parser<'_> { + fn peek(&self) -> Option { + self.bytes.get(self.i).copied() + } + + fn skip_ws(&mut self) { + while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.i += 1; + } + } + + fn expect(&mut self, want: u8) -> Result<(), String> { + if self.peek() == Some(want) { + self.i += 1; + Ok(()) + } else { + Err(format!("expected {:?} at byte {}", want as char, self.i)) + } + } + + fn literal(&mut self, word: &str, value: Json) -> Result { + if self.bytes[self.i..].starts_with(word.as_bytes()) { + self.i += word.len(); + Ok(value) + } else { + Err(format!("unexpected input at byte {}", self.i)) + } + } + + fn value(&mut self, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err("nested too deeply".to_string()); + } + match self.peek() { + Some(b'{') => self.object(depth), + Some(b'[') => self.array(depth), + Some(b'"') => self.string().map(Json::Str), + Some(b't') => self.literal("true", Json::Bool(true)), + Some(b'f') => self.literal("false", Json::Bool(false)), + Some(b'n') => self.literal("null", Json::Null), + Some(_) => self.number(), + None => Err("unexpected end of input".to_string()), + } + } + + fn object(&mut self, depth: usize) -> Result { + self.expect(b'{')?; + let mut fields = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b'}') { + self.i += 1; + return Ok(Json::Obj(fields)); + } + loop { + self.skip_ws(); + let key = self.string()?; + self.skip_ws(); + self.expect(b':')?; + self.skip_ws(); + let value = self.value(depth + 1)?; + fields.push((key, value)); + self.skip_ws(); + match self.peek() { + Some(b',') => self.i += 1, + Some(b'}') => { + self.i += 1; + return Ok(Json::Obj(fields)); + } + _ => return Err(format!("expected ',' or '}}' at byte {}", self.i)), + } + } + } + + fn array(&mut self, depth: usize) -> Result { + self.expect(b'[')?; + let mut items = Vec::new(); + self.skip_ws(); + if self.peek() == Some(b']') { + self.i += 1; + return Ok(Json::Arr(items)); + } + loop { + self.skip_ws(); + items.push(self.value(depth + 1)?); + self.skip_ws(); + match self.peek() { + Some(b',') => self.i += 1, + Some(b']') => { + self.i += 1; + return Ok(Json::Arr(items)); + } + _ => return Err(format!("expected ',' or ']' at byte {}", self.i)), + } + } + } + + fn string(&mut self) -> Result { + self.expect(b'"')?; + let mut out = String::new(); + loop { + let Some(b) = self.peek() else { + return Err("unterminated string".to_string()); + }; + self.i += 1; + match b { + b'"' => return Ok(out), + b'\\' => { + let Some(esc) = self.peek() else { + return Err("unterminated escape".to_string()); + }; + self.i += 1; + match esc { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => out.push(self.unicode_escape()?), + _ => return Err(format!("bad escape at byte {}", self.i)), + } + } + // Multi-byte UTF-8 arrives as its own bytes; copy them through + // untouched rather than reinterpreting each as a char. + _ => { + let start = self.i - 1; + while self + .peek() + .is_some_and(|n| n != b'"' && n != b'\\' && n >= 0x80) + { + self.i += 1; + } + match std::str::from_utf8(&self.bytes[start..self.i]) { + Ok(s) => out.push_str(s), + Err(_) => return Err(format!("invalid UTF-8 at byte {start}")), + } + } + } + } + } + + /// A `\uXXXX` escape, joining a surrogate pair when one follows. + fn unicode_escape(&mut self) -> Result { + let hi = self.hex4()?; + // A lone surrogate is not a character. Pair it if the partner is + // there, and otherwise substitute rather than fail: a display name + // with a broken escape shouldn't cost the whole listing. + if (0xD800..0xDC00).contains(&hi) { + if self.bytes[self.i..].starts_with(b"\\u") { + let save = self.i; + self.i += 2; + let lo = self.hex4()?; + if (0xDC00..0xE000).contains(&lo) { + let c = 0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00); + return Ok(char::from_u32(c).unwrap_or('\u{fffd}')); + } + self.i = save; + } + return Ok('\u{fffd}'); + } + Ok(char::from_u32(hi).unwrap_or('\u{fffd}')) + } + + fn hex4(&mut self) -> Result { + let end = self.i + 4; + let slice = self + .bytes + .get(self.i..end) + .ok_or_else(|| "truncated \\u escape".to_string())?; + let text = std::str::from_utf8(slice).map_err(|_| "bad \\u escape".to_string())?; + let value = u32::from_str_radix(text, 16).map_err(|_| "bad \\u escape".to_string())?; + self.i = end; + Ok(value) + } + + fn number(&mut self) -> Result { + let start = self.i; + while self + .peek() + .is_some_and(|b| matches!(b, b'0'..=b'9' | b'-' | b'+' | b'.' | b'e' | b'E')) + { + self.i += 1; + } + let text = std::str::from_utf8(&self.bytes[start..self.i]) + .map_err(|_| format!("bad number at byte {start}"))?; + text.parse::() + .map(Json::Num) + .map_err(|_| format!("bad number {text:?} at byte {start}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_shapes_both_services_serve() { + let v = parse( + r#"{"ok":true,"n":3,"burrows":[{"name":"a","endpoints":{"ws":"ws://x:1"}, + "listeners":["quic","ws"],"status":"online"}]}"#, + ) + .expect("parses"); + assert_eq!(v.get("ok"), Some(&Json::Bool(true))); + assert_eq!(v.get("n"), Some(&Json::Num(3.0))); + let row = &v.arr_field("burrows")[0]; + assert_eq!(row.str_field("name"), Some("a")); + // The nesting the old brace-splitting heuristic got wrong. + assert_eq!( + row.get("endpoints").and_then(|e| e.str_field("ws")), + Some("ws://x:1") + ); + assert_eq!(row.str_array_field("listeners"), vec!["quic", "ws"]); + } + + #[test] + fn a_nested_object_does_not_leak_into_its_neighbour() { + // Precisely the bug: with `endpoints` nested, splitting on `{` made the + // inner object look like the next burrow, and the burrow after it + // inherited fields it never declared. + let v = parse( + r#"{"burrows":[ + {"name":"first","endpoints":{"ws":"ws://first:1"}}, + {"name":"second"} + ]}"#, + ) + .unwrap(); + let rows = v.arr_field("burrows"); + assert_eq!(rows.len(), 2, "two burrows, not three"); + assert_eq!(rows[1].str_field("name"), Some("second")); + assert!( + rows[1].get("endpoints").is_none(), + "the second burrow declared no endpoints and must not borrow any" + ); + } + + #[test] + fn strings_survive_escapes_and_non_ascii() { + let v = parse(r#"{"s":"a \"q\" line\nwith ü and 😀 \\ /"}"#).unwrap(); + assert_eq!(v.str_field("s"), Some("a \"q\" line\nwith ü and 😀 \\ /")); + // Non-ASCII arriving raw, not escaped. + let v = parse("{\"s\":\"ünïcödé 🐇\"}").unwrap(); + assert_eq!(v.str_field("s"), Some("ünïcödé 🐇")); + } + + #[test] + fn a_lone_surrogate_is_substituted_rather_than_failing_the_listing() { + let v = parse(r#"{"s":"x\ud800y"}"#).unwrap(); + assert_eq!(v.str_field("s"), Some("x\u{fffd}y")); + } + + #[test] + fn malformed_input_is_an_error_not_a_panic() { + for bad in [ + "", + "{", + "}", + "[", + r#"{"a"}"#, + r#"{"a":}"#, + r#"{"a":1,}"#, + r#"["#, + "tru", + r#"{"a":"unterminated"#, + r#"{"a":01f}"#, + "{} extra", + r#"{"a":"\u00"}"#, + ] { + assert!(parse(bad).is_err(), "{bad:?} should not parse"); + } + } + + #[test] + fn deep_nesting_is_refused_rather_than_blowing_the_stack() { + let deep = format!("{}{}", "[".repeat(500), "]".repeat(500)); + assert!(parse(&deep).is_err()); + } + + #[test] + fn arbitrary_bytes_never_panic() { + // A discovery client reads replies from hosts it did not choose. + let mut seed = 0x12345678u32; + for _ in 0..2_000 { + let len = (seed % 40) as usize; + let s: String = (0..len) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + const ALPHABET: &[u8] = b"{}[]\":,0123456789truefalsnul \\/\xe9"; + ALPHABET[(seed >> 16) as usize % ALPHABET.len()] as char + }) + .collect(); + let _ = parse(&s); + } + } + + #[test] + fn accessors_are_total_over_the_wrong_shape() { + let v = parse(r#"{"a":1,"b":"x","c":[1,"two",null]}"#).unwrap(); + assert!(v.get("missing").is_none()); + assert!(v.str_field("a").is_none(), "a number is not a string"); + assert!(v.arr_field("b").is_empty(), "a string is not an array"); + assert_eq!(v.str_array_field("c"), vec!["two"], "non-strings skipped"); + assert!(Json::Num(1.0).get("anything").is_none()); + } +} diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs new file mode 100644 index 0000000..99a5e2d --- /dev/null +++ b/crates/directory/src/lib.rs @@ -0,0 +1,557 @@ +//! Finding burrows: the client half of Looking Glass discovery. +//! +//! There are two places to ask who is out there, and they answer differently: +//! +//! * **`rabbithole.directory`** — HTTPS, `GET /api/burrows`, a JSON snapshot +//! aggregated from every Looking Glass that publishes to it. The widest view, +//! and the one with CORS open so a browser tab can read it directly. +//! * **A Looking Glass** — `tracker.rabbit.direct` by default, HTTPS +//! `GET /api/burrows`. Narrower (one coordinator's own announces) and a +//! *different shape*: a glass relays announced descriptors, so endpoints are +//! a nested `endpoints: {quic, ws}` object rather than the directory's flat +//! `wsUri` / `quicUri`, and it reports liveness rather than uptime. +//! * **A self-hosted tracker's status port** — the `looking-glass` binary in +//! this workspace also serves a line-oriented TCP protocol on port 4655 +//! (`INDEX` in, tab-separated rows out). A browser tab has no TCP, so this +//! one is native-only, and it is what a user gets when they name their own +//! coordinator. +//! +//! So: directory first, the standard glass behind it, and the UI says which +//! answered — "who told you this" is part of the answer to "who is out there". +//! +//! # Why this is its own crate +//! +//! The wasm SPA and the terminal clients need exactly the same parsers. +//! The pure half here therefore has **no dependencies at all** and compiles for +//! both; the network edge is behind the `native` feature, since a browser tab +//! has no TCP and reaches the directory through `fetch` instead. +//! +//! # Totality +//! +//! The parsers never panic. A malformed tracker row is skipped rather than +//! failing the listing (a tracker that grows a column must not blank the +//! browser); a reply with nothing usable in it becomes an `Err` with something +//! a person can read. + +#![forbid(unsafe_code)] + +#[cfg(feature = "native")] +pub mod fetch; +pub mod json; + +/// One directory entry: a public burrow and its latest health snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DirectoryServer { + /// Human-facing burrow name. + pub name: String, + /// Connection endpoint (a `ws://`/`wss://` URL or `host:port`), what the + /// login screen dials. + pub endpoint: String, + /// One-line description / theme of the burrow. + pub description: String, + /// Members currently online, when the source reports it. `None` where it + /// doesn't — rabbithole.directory publishes uptime and listeners but no + /// population, and rendering a confident "0 online" for "not reported" + /// would be the client inventing a fact. + pub users_online: Option, + /// The protocols this burrow listens on (`quic`, `ws`, `telnet`…), when + /// the source says. Empty when unknown. + pub listeners: Vec, + /// 24-hour uptime, 0–100 %, when the source reports it. `None` where it + /// doesn't — a Looking Glass publishes liveness, not a history, and + /// rendering "0% up" for "not reported" would be the client inventing one. + pub uptime_pct: Option, + /// Whether the source's most recent probe reached it. + pub reachable: bool, +} + +/// Browse the directory: keep entries matching `query` (case-insensitive +/// substring over name + description; empty = all), ranked for a "where should +/// I go" list — reachable burrows first, then most-populated, then by name. +/// Total: never panics. +pub fn browse(servers: &[DirectoryServer], query: &str) -> Vec { + let q = query.trim().to_ascii_lowercase(); + let mut out: Vec = servers + .iter() + .filter(|s| { + q.is_empty() + || s.name.to_ascii_lowercase().contains(&q) + || s.description.to_ascii_lowercase().contains(&q) + }) + .cloned() + .collect(); + out.sort_by(|a, b| { + b.reachable + .cmp(&a.reachable) + .then( + b.users_online + .unwrap_or(0) + .cmp(&a.users_online.unwrap_or(0)), + ) + .then(a.name.cmp(&b.name)) + }); + out +} + +/// Where a listing came from — shown to the user, because a narrower source +/// answering is a different answer, not the same one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirectorySource { + /// `rabbithole.directory` over HTTPS. + Directory, + /// A Looking Glass tracker's status port (the `INDEX` line protocol). + Tracker, + /// The built-in sample list — nothing reachable answered. + Seeded, +} + +impl DirectorySource { + pub fn label(self) -> &'static str { + match self { + DirectorySource::Directory => "rabbithole.directory", + DirectorySource::Tracker => "tracker.rabbit.direct", + DirectorySource::Seeded => "built-in sample \u{2014} no directory reachable", + } + } +} + +/// The directory's HTTPS endpoint. Returns `{ok, burrows: [...]}` with CORS +/// open, so the browser build can read it directly. +pub const DIRECTORY_URL: &str = "https://rabbithole.directory/api/burrows"; + +/// The standard Looking Glass, and the fallback when the directory is +/// unreachable. +pub const TRACKER_HOST: &str = "tracker.rabbit.direct"; +/// The standard glass's HTTPS listing endpoint (a different shape from the +/// directory's — see [`parse_glass_json`]). +pub const TRACKER_URL: &str = "https://tracker.rabbit.direct/api/burrows"; +/// A self-hosted `looking-glass`'s line-oriented TCP status port. Native only: +/// a browser tab has no TCP. +pub const TRACKER_STATUS_PORT: u16 = 4655; + +/// Parse `rabbithole.directory`'s JSON into directory rows. +/// +/// A burrow with no `wsUri` is skipped — this parser serves WebSocket clients, +/// and a row you cannot connect to is a row that only wastes a click. Native +/// callers that speak QUIC pass `["wsUri", "quicUri"]` to +/// [`parse_directory_json_with`] and keep those rows. +pub fn parse_directory_json(text: &str) -> Result, String> { + parse_directory_json_with(text, &["wsUri"]) +} + +/// Like [`parse_directory_json`], but choosing which URI fields count as a +/// dialable endpoint, in preference order. +pub fn parse_directory_json_with( + text: &str, + endpoint_fields: &[&str], +) -> Result, String> { + let doc = json::parse(text).map_err(|e| format!("That directory reply isn't JSON: {e}"))?; + let rows = doc.arr_field("burrows"); + if rows.is_empty() && doc.get("burrows").is_none() { + return Err("That directory reply has no burrows list.".to_string()); + } + let out: Vec = rows + .iter() + .filter_map(|row| { + // The directory publishes flat `wsUri` / `quicUri` fields. + let endpoint = endpoint_fields + .iter() + .find_map(|f| row.str_field(f).filter(|u| !u.is_empty()))?; + Some(DirectoryServer { + name: row.str_field("name").unwrap_or(endpoint).to_string(), + endpoint: endpoint.to_string(), + description: row.str_field("description").unwrap_or_default().to_string(), + // The directory publishes uptime and listeners, not population. + users_online: None, + listeners: row.str_array_field("listeners"), + uptime_pct: percent(row.str_field("uptime")), + reachable: row.str_field("status") == Some("online"), + }) + }) + .collect(); + if out.is_empty() { + return Err("The directory listed no burrows this client can dial.".to_string()); + } + Ok(out) +} + +/// Parse a **Looking Glass** `/api/burrows` reply. +/// +/// A different shape from the directory's, not a variant of it: a glass row +/// carries the announced descriptor more or less verbatim, so its endpoints +/// are a nested `endpoints: {quic, ws}` object rather than flat `wsUri` / +/// `quicUri` fields, and it publishes no uptime at all. `endpoint_kinds` are +/// the keys inside `endpoints`, in preference order (`["ws", "quic"]`). +pub fn parse_glass_json( + text: &str, + endpoint_kinds: &[&str], +) -> Result, String> { + let doc = json::parse(text).map_err(|e| format!("That tracker reply isn't JSON: {e}"))?; + let rows = doc.arr_field("burrows"); + if rows.is_empty() && doc.get("burrows").is_none() { + return Err("That tracker reply has no burrows list.".to_string()); + } + let out: Vec = rows + .iter() + .filter_map(|row| { + let endpoints = row.get("endpoints")?; + let endpoint = endpoint_kinds + .iter() + .find_map(|k| endpoints.str_field(k).filter(|u| !u.is_empty()))?; + Some(DirectoryServer { + name: row.str_field("name").unwrap_or(endpoint).to_string(), + endpoint: endpoint.to_string(), + // A glass row's blurb lives on the descriptor it relayed. + description: row + .str_field("description") + .or_else(|| { + row.get("descriptor") + .and_then(|d| d.str_field("description")) + }) + .unwrap_or_default() + .to_string(), + users_online: None, + listeners: row.str_array_field("listeners"), + // A glass reports liveness, not a percentage. Claiming 0% or + // 100% because it answered would be inventing a history. + uptime_pct: None, + reachable: row.str_field("status") == Some("online"), + }) + }) + .collect(); + if out.is_empty() { + return Err("The tracker listed no burrows this client can dial.".to_string()); + } + Ok(out) +} + +/// Does this burrow's `/.well-known/rabbithole/server` descriptor ask not to +/// be listed? +/// +/// Discovery here is gossip: whoever visits a burrow can pass it along. A +/// burrow that doesn't want that says so inside its own **signed** descriptor, +/// as a `noindex` feature tag, so the wish is attributable to the burrow and +/// survives the retelling rather than depending on everyone who saw it. +/// +/// Anything that would publish a burrow onward — a tracker relaying gossip, a +/// client sharing a discovery — should check this first. Note the asymmetry: +/// this governs *advertising*, not access. Someone who was handed the address +/// can still connect; the burrow simply isn't added to a public list. +/// +/// A reply that can't be read is **not** treated as consent. An unreachable or +/// malformed descriptor returns `false` only because there is nothing to +/// honor; callers that can wait should retry rather than publish on the +/// strength of a failed fetch. +pub fn noindex_in_descriptor(descriptor_json: &str) -> bool { + let Ok(doc) = json::parse(descriptor_json) else { + return false; + }; + // The document is `{body: {...}, signature: ...}`; older or flattened + // shapes may put features at the top level. + let features = doc + .get("body") + .map(|b| b.str_array_field("features")) + .filter(|f| !f.is_empty()) + .unwrap_or_else(|| doc.str_array_field("features")); + features.iter().any(|f| f == "noindex") +} + +/// A `"99.8%"`-style uptime string as a 0–100 percentage. Absent or +/// unparseable is `None` rather than a invented 0 — the row still lists. +fn percent(raw: Option<&str>) -> Option { + raw.and_then(|u| u.trim().trim_end_matches('%').parse::().ok()) + .map(|p| p.round().clamp(0.0, 100.0) as u8) +} + +/// Parse a tracker `INDEX` reply: one tab-separated row per live burrow, +/// `name\tip:port\tusers\tcategories\tuptime\tlast_seen\tsigned\tkey\tgen` +/// (see `apps/tracker`). Short rows are skipped rather than failing the whole +/// listing — a tracker that grows a column must not blank the browser. +pub fn parse_tracker_index(text: &str) -> Result, String> { + if let Some(first) = text.lines().next() { + let first = first.trim(); + if !first.contains('\t') && first.starts_with("ERR") { + let msg = first.trim_start_matches("ERR").trim(); + return Err(if msg.is_empty() { + "tracker error".to_string() + } else { + format!("tracker: {msg}") + }); + } + } + let mut out = Vec::new(); + for line in text.lines().filter(|l| !l.trim().is_empty()) { + let f: Vec<&str> = line.split('\t').collect(); + if f.len() < 6 { + continue; + } + let addr = f[1].trim(); + if addr.is_empty() { + continue; + } + out.push(DirectoryServer { + name: f[0].trim().to_string(), + // The tracker prints a dialable host:port; this client speaks + // WebSocket, so that is the scheme it gets. + endpoint: format!("ws://{addr}"), + description: String::new(), + users_online: f[2].trim().parse::().ok(), + listeners: Vec::new(), + uptime_pct: percent(Some(f[4])), + reachable: true, + }); + } + if out.is_empty() { + return Err("The tracker returned no servers.".to_string()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn srv(name: &str, desc: &str, users: u32, uptime: u8, reachable: bool) -> DirectoryServer { + DirectoryServer { + name: name.into(), + endpoint: format!("wss://{}.example", name.to_ascii_lowercase()), + description: desc.into(), + users_online: Some(users), + listeners: Vec::new(), + uptime_pct: Some(uptime), + reachable, + } + } + + /// A trimmed copy of a real `rabbithole.directory` reply (fetched + /// 2026-08-12), so these tests fail if the shape we parse drifts from the + /// shape it serves. + const REAL_DIRECTORY: &str = r#"{"ok":true,"version":5,"now":1786524774226,"durable":true, + "burrows":[ + {"name":"alice@wonderland","sysop":"Alice Liddell","status":"online","uptime":"99.8%", + "sparkline":[99.8,100],"description":"The flagship central sanctuary Burrow.", + "listeners":["quic","ws","telnet","hotline"], + "identity":"7d6cf4a1","plan":"Project: routing.","quicUri":"quic://wonderland.co:4653", + "wsUri":"ws://wonderland.co:4654","seed":true,"live":false}, + {"name":"chesire@woods","sysop":"Cheshire Cat","status":"offline","uptime":"91.5%", + "sparkline":[100],"description":"Cryptic boards.","listeners":["quic","ws"], + "identity":"9f8e7d6c","plan":"Ephemeral.","quicUri":"quic://c.example:4653", + "wsUri":"ws://chesire-woods.org:4654","seed":true,"live":false}, + {"name":"quic-only@nowhere","status":"online","uptime":"50%","listeners":["quic"], + "quicUri":"quic://nowhere.example:4653"} + ],"glasses":[],"log":[]}"#; + + #[test] + fn the_directory_reply_maps_to_rows_this_client_can_dial() { + let rows = parse_directory_json(REAL_DIRECTORY).expect("parses"); + // The quic-only burrow is skipped: a WebSocket client can't connect to + // it, and a row you can't use only wastes a click. + assert_eq!(rows.len(), 2); + let a = &rows[0]; + assert_eq!(a.name, "alice@wonderland"); + assert_eq!(a.endpoint, "ws://wonderland.co:4654"); + assert_eq!(a.uptime_pct, Some(100), "99.8% rounds to 100"); + assert!(a.reachable, "status online"); + assert_eq!(a.listeners, vec!["quic", "ws", "telnet", "hotline"]); + // The directory publishes no population; saying "0 online" would be + // the client inventing a fact. + assert_eq!(a.users_online, None); + assert!(!rows[1].reachable, "status offline"); + } + + #[test] + fn a_quic_speaking_client_keeps_the_rows_a_browser_has_to_skip() { + let rows = + parse_directory_json_with(REAL_DIRECTORY, &["wsUri", "quicUri"]).expect("parses"); + assert_eq!(rows.len(), 3, "the quic-only burrow is dialable natively"); + assert_eq!(rows[2].endpoint, "quic://nowhere.example:4653"); + assert_eq!( + rows[0].endpoint, "ws://wonderland.co:4654", + "preference order holds: ws first where both exist" + ); + } + + /// The Looking Glass's own `/api/burrows` shape (`api/burrows.mjs` in the + /// glass.rabbit.direct project): nested `endpoints`, a relayed + /// `descriptor`, liveness instead of uptime. Deliberately *not* the + /// directory's shape — the two services answer differently. + const REAL_GLASS: &str = r#"{"ok":true,"updatedAt":1786533368707,"total":2,"online":1, + "burrows":[ + {"slug":"wonderland","url":"https://wonderland.glass.rabbit.direct", + "name":"alice@wonderland","publicKey":"7d6c","sysop":"Alice Liddell", + "listeners":["quic","ws","telnet"], + "endpoints":{"quic":"quic://wonderland.co:4653","ws":"ws://wonderland.co:4654"}, + "status":"online","lastSeen":1786533300000,"firstSeen":1786000000000, + "descriptor":{"name":"alice@wonderland","description":"The flagship.", + "endpoints":{"quic":"quic://wonderland.co:4653"}}, + "signature":"ab","source":"tracker"}, + {"slug":"woods","url":"https://woods.glass.rabbit.direct","name":"chesire@woods", + "publicKey":"9f8e","listeners":["quic"], + "endpoints":{"quic":"quic://c.example:4653"}, + "status":"offline","descriptor":{"description":"Cryptic boards."}, + "source":"tracker"} + ]}"#; + + #[test] + fn the_glass_reply_is_a_different_shape_and_parses_as_one() { + // A WebSocket client: only the burrow with a `ws` endpoint. + let rows = parse_glass_json(REAL_GLASS, &["ws"]).expect("parses"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "alice@wonderland"); + assert_eq!(rows[0].endpoint, "ws://wonderland.co:4654"); + assert!(rows[0].reachable); + assert_eq!(rows[0].listeners, vec!["quic", "ws", "telnet"]); + // The blurb lives on the relayed descriptor, one level in — exactly + // the nesting the old brace-splitting parser could not reach. + assert_eq!(rows[0].description, "The flagship."); + // A glass reports liveness, not a history. Claiming 0% or 100% + // because it answered would be inventing one. + assert_eq!(rows[0].uptime_pct, None); + assert_eq!(rows[0].users_online, None); + + // A QUIC-speaking client gets both, ws preferred where both exist. + let rows = parse_glass_json(REAL_GLASS, &["ws", "quic"]).expect("parses"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].endpoint, "ws://wonderland.co:4654"); + assert_eq!(rows[1].endpoint, "quic://c.example:4653"); + assert!(!rows[1].reachable, "status offline"); + assert_eq!(rows[1].description, "Cryptic boards."); + } + + #[test] + fn the_two_services_are_not_interchangeable() { + // Reading a glass reply with the directory's parser finds no flat + // `wsUri`, and vice versa. Each says so instead of silently + // returning an empty listing that looks like "nobody is out there". + assert!(parse_directory_json(REAL_GLASS).is_err()); + assert!(parse_glass_json(REAL_DIRECTORY, &["ws", "quic"]).is_err()); + } + + #[test] + fn an_empty_but_valid_glass_listing_is_distinguished_from_a_broken_one() { + // The live tracker really does serve this when nobody has announced. + let empty = r#"{"ok":true,"updatedAt":1786533368707,"total":0,"online":0,"burrows":[]}"#; + let err = parse_glass_json(empty, &["ws"]).unwrap_err(); + assert!(err.contains("no burrows this client can dial"), "{err}"); + // Whereas a reply with no burrows *field* is a different problem. + let broken = parse_glass_json(r#"{"ok":false,"error":"store_not_provisioned"}"#, &["ws"]) + .unwrap_err(); + assert!(broken.contains("no burrows list"), "{broken}"); + } + + #[test] + fn a_burrow_can_ask_not_to_be_listed_and_the_ask_travels_with_its_signature() { + // The document a burrow serves at /.well-known/rabbithole/server with + // `announce_enabled = false` — field names and feature list copied + // from a live burrow's reply, so this fails if the shape drifts. + let opted_out = r#"{"body":{"server_key":[1,2],"name":"Wonderland","origin":"wonderland", + "addresses":["quic://wonderland.example:4893"], + "features":["boards","chat","dm","files","swarm","noindex","guest"], + "issued_at":1786536378515},"sig":[9,9]}"#; + assert!(noindex_in_descriptor(opted_out)); + + let listed = opted_out.replace(r#""noindex","#, ""); + assert!(!noindex_in_descriptor(&listed)); + } + + #[test] + fn an_unreadable_descriptor_is_not_treated_as_consent_to_list() { + // These return false because there is nothing to honor, not because + // the burrow agreed. A caller that can wait should retry rather than + // publish on the strength of a failed fetch — which is why the doc + // comment says so and this test pins the distinction. + for nothing in ["", "not json", "{}", r#"{"body":{}}"#] { + assert!(!noindex_in_descriptor(nothing), "{nothing:?}"); + } + // A flattened shape still gets honored: the tag is what matters, not + // where a future descriptor version happens to put it. + assert!(noindex_in_descriptor(r#"{"features":["chat","noindex"]}"#)); + // And a burrow whose *name* is "noindex" has not opted out. + assert!(!noindex_in_descriptor( + r#"{"body":{"name":"noindex","features":["chat"]}}"# + )); + } + + #[test] + fn a_directory_reply_we_cannot_use_says_so() { + assert!(parse_directory_json("{}").is_err(), "no burrows list"); + assert!(parse_directory_json("").is_err()); + // Well-formed but nothing dialable. + let quic_only = r#"{"burrows":[{"name":"x","quicUri":"quic://x:1"}]}"#; + assert!(parse_directory_json(quic_only).is_err()); + // A missing uptime is "not reported", not a confident 0%. + let no_up = r#"{"burrows":[{"name":"x","wsUri":"ws://x:1","status":"online"}]}"#; + assert_eq!(parse_directory_json(no_up).unwrap()[0].uptime_pct, None); + } + + #[test] + fn tracker_index_rows_parse_and_short_rows_are_skipped() { + // The documented column layout (apps/tracker): name, addr, users, + // categories, uptime, last_seen, signed, key, gen. + let reply = "The Warren\t10.0.0.1:4654\t42\tchat,files\t99.5\t12\tyes\tdeadbeef\t1786\nNight Pool\t10.0.0.2:4654\t7\t\t88.0\t30\tno\t-\t-\ntruncated\trow\n"; + let rows = parse_tracker_index(reply).expect("parses"); + assert_eq!(rows.len(), 2, "the short row is skipped, not fatal"); + assert_eq!(rows[0].name, "The Warren"); + assert_eq!(rows[0].endpoint, "ws://10.0.0.1:4654", "dialable as ws"); + assert_eq!( + rows[0].users_online, + Some(42), + "the tracker DOES count users" + ); + assert_eq!(rows[0].uptime_pct, Some(100)); + assert_eq!(rows[1].uptime_pct, Some(88)); + } + + #[test] + fn a_tracker_error_line_is_reported_not_parsed_as_a_server() { + let err = parse_tracker_index("ERR unknown command\n").unwrap_err(); + assert!(err.contains("unknown command"), "{err}"); + // A server that named itself "ERR …" is tab-framed and stays a server. + let rows = parse_tracker_index("ERR Lounge\t1.2.3.4:1\t3\t\t100\t1\tno\t-\t-\n") + .expect("tab-framed rows are data"); + assert_eq!(rows[0].name, "ERR Lounge"); + assert!(parse_tracker_index("").is_err(), "empty is not a listing"); + } + + #[test] + fn the_two_sources_are_labelled_for_the_user() { + // A narrower source answering is a different answer, not the same one. + assert!(DirectorySource::Directory + .label() + .contains("rabbithole.directory")); + assert!(DirectorySource::Tracker + .label() + .contains("tracker.rabbit.direct")); + assert!(DirectorySource::Seeded.label().contains("sample")); + } + + #[test] + fn ranks_reachable_then_populated_then_name() { + let servers = vec![ + srv("Zeta", "quiet", 2, 90, true), + srv("Down", "offline now", 99, 10, false), + srv("Alpha", "busy hub", 40, 99, true), + srv("Beta", "busy too", 40, 95, true), + ]; + let order: Vec = browse(&servers, "") + .iter() + .map(|s| s.name.clone()) + .collect(); + // Reachable first; among reachable, more users first; Alpha before Beta + // on the name tiebreak at equal population; the unreachable one last. + assert_eq!(order, ["Alpha", "Beta", "Zeta", "Down"]); + } + + #[test] + fn filter_matches_name_and_description_case_insensitively() { + let servers = vec![ + srv("Warren", "cozy ANSI art bbs", 5, 100, true), + srv("Hollow", "fast files hub", 8, 100, true), + ]; + assert_eq!(browse(&servers, "ART").len(), 1); + assert_eq!(browse(&servers, "art")[0].name, "Warren"); + assert_eq!(browse(&servers, "hub")[0].name, "Hollow"); + assert_eq!(browse(&servers, " ").len(), 2, "blank = all"); + assert!(browse(&servers, "nope").is_empty()); + } +} diff --git a/crates/server-core/src/config.rs b/crates/server-core/src/config.rs index abe9ef7..59bb54d 100644 --- a/crates/server-core/src/config.rs +++ b/crates/server-core/src/config.rs @@ -36,6 +36,12 @@ pub struct FederationPeer { pub fingerprint: String, } +/// The standard Looking Glass coordinator, and the default entry in +/// [`ServerConfig::announce_trackers`]. Well-known so a fresh burrow is +/// discoverable without configuration, and just a default so a warren can run +/// its own coordinator instead. +pub const DEFAULT_TRACKER: &str = "tracker.rabbit.direct"; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct ServerConfig { @@ -49,10 +55,11 @@ pub struct ServerConfig { /// Whether guests may sign in. pub guest_enabled: bool, /// Public hostname advertised in the signed `.well-known/rabbithole/server` - /// descriptor (and later tracker registrations), e.g. - /// `"rabbithole.example"`. Empty = derive host-based addresses from - /// concrete bind IPs and omit them for wildcard (`0.0.0.0`/`::`) binds. - /// TOML-only (edited on disk, not via `ctl config set`), like `ftn_areas`. + /// descriptor and Looking Glass announces, e.g. `"rabbithole.example"`. + /// Empty = derive host-based addresses from concrete bind IPs and omit + /// them for wildcard (`0.0.0.0`/`::`) binds. Live: `.well-known` and the + /// announce loop re-read it, so a `ctl config set` takes effect without + /// a restart. Announcing stays inert while this is empty. pub advertise_host: String, /// QUIC listener (primary transport). pub quic_addr: SocketAddr, @@ -72,7 +79,8 @@ pub struct ServerConfig { pub ws_allowed_origins: Vec, /// Public WebSocket URL advertised in discovery, normally the `wss://` /// address of a TLS reverse proxy. Empty means no public WS advertisement. - /// TOML-only; it is deliberately independent of the backend bind address. + /// Deliberately independent of the backend bind address. Live: announce + /// and `.well-known` re-read it. pub ws_public_url: String, /// Where the database, blobs, keys, and ctl socket live. pub data_dir: PathBuf, @@ -309,6 +317,39 @@ pub struct ServerConfig { /// Requested lifetime, in seconds, for each mapping. NAT-PMP/PCP leases /// are short-lived, so the mapper refreshes at roughly half this interval. pub portmap_lifetime_secs: u32, + /// Announce this burrow to the configured Looking Glass trackers so it can + /// be found by people who don't already know its address. + /// + /// **On by default**, because a burrow nobody can find is a burrow nobody + /// joins. It is nonetheless inert until `advertise_host` is set: announcing + /// a burrow whose address we can't state would list something unreachable. + /// + /// Turning this off is a real opt-out, not just silence. RabbitHole + /// discovery is gossip — a visitor who finds your burrow can share it + /// onward — so the signed `.well-known` descriptor carries a `noindex` + /// feature tag while this is false, and a client that would pass your + /// burrow along drops it instead. The wish not to be listed travels with + /// your signature rather than depending on everyone who saw you. + pub announce_enabled: bool, + /// Looking Glass trackers to announce to, as `host`, `host:port`, or a full + /// `https://…` base URL. The standard coordinator is the default; add more + /// to be listed in several places at once. Serialized as a TOML array and + /// edited on disk (like `ftn_areas`), not via `ctl config set`. + pub announce_trackers: Vec, + /// Seconds between announces. A tracker marks a burrow offline after twice + /// this without a fresh one, so it is also the staleness budget. The glass + /// protocol accepts 30–3600. + pub announce_ttl_secs: u32, + /// DNS label to claim on the tracker (`.glass.rabbit.direct`). Empty + /// = let the tracker derive it from the announced name. Live: the next + /// announce round picks it up. + pub announce_slug: String, + /// Operator handle in the announced `handle@host` name, e.g. the `alice` of + /// `alice@wonderland`. Empty = derived from the burrow name. Live. + pub announce_sysop: String, + /// One-line description for the tracker and directory listing (≤240 chars). + /// Empty = the welcome ticker, then nothing. Live. + pub announce_description: String, /// Master switch for token-bucket rate limiting (Wave 13). On by default /// with generous per-class budgets; see the `ratelimit_*` knobs below. pub ratelimit_enabled: bool, @@ -468,6 +509,12 @@ impl Default for ServerConfig { portmap_enabled: false, portmap_gateway: String::new(), portmap_lifetime_secs: 7200, + announce_enabled: true, + announce_trackers: vec![DEFAULT_TRACKER.to_string()], + announce_ttl_secs: 120, + announce_slug: String::new(), + announce_sysop: String::new(), + announce_description: String::new(), ratelimit_enabled: true, ratelimit_conn_per_min: 30, ratelimit_conn_burst: 10, @@ -573,6 +620,13 @@ impl ServerConfig { "ws_allow_insecure_remote" => self.ws_allow_insecure_remote.to_string(), "ws_allowed_origins" => self.ws_allowed_origins.join(","), "ws_public_url" => self.ws_public_url.clone(), + "advertise_host" => self.advertise_host.clone(), + "announce_enabled" => self.announce_enabled.to_string(), + "announce_trackers" => self.announce_trackers.join(","), + "announce_ttl_secs" => self.announce_ttl_secs.to_string(), + "announce_slug" => self.announce_slug.clone(), + "announce_sysop" => self.announce_sysop.clone(), + "announce_description" => self.announce_description.clone(), "data_dir" => self.data_dir.display().to_string(), "session_ttl_secs" => self.session_ttl_secs.to_string(), "chat_max_len" => self.chat_max_len.to_string(), @@ -1031,6 +1085,38 @@ impl ServerConfig { self.portmap_lifetime_secs = parse_u32(key, value)?; Ok(false) } + // Discovery advertisement. `.well-known` is built per request and + // the announce loop re-reads every round, so these apply live — + // including `announce_enabled false`, which also stamps `noindex` + // into the next signed descriptor. + "advertise_host" => { + self.advertise_host = value.trim().to_string(); + Ok(true) + } + "ws_public_url" => { + self.ws_public_url = value.trim().to_string(); + Ok(true) + } + "announce_enabled" => { + self.announce_enabled = parse_bool(key, value)?; + Ok(true) + } + "announce_ttl_secs" => { + self.announce_ttl_secs = parse_u32(key, value)?; + Ok(true) + } + "announce_slug" => { + self.announce_slug = value.trim().to_string(); + Ok(true) + } + "announce_sysop" => { + self.announce_sysop = value.trim().to_string(); + Ok(true) + } + "announce_description" => { + self.announce_description = value.to_string(); + Ok(true) + } // Rate limiting applies live: every check re-reads the config, // so a `ctl config set` takes effect on the next request. "ratelimit_enabled" => { @@ -1478,4 +1564,61 @@ mod tests { Err(ConfigError::UnknownKey(_)) )); } + + #[test] + fn announce_keys_apply_live_so_a_burrow_can_become_discoverable() { + // The announce loop and `.well-known` re-read config every time. If + // these keys were unknown to `ctl config set`, the documented + // "turning announce off stamps noindex without a restart" path + // would be a lie — and a fresh burrow couldn't name its host + // without editing TOML. + let live = LiveConfig::new(ServerConfig::default()); + assert_eq!(live.get_key("announce_enabled").unwrap(), "true"); + assert_eq!(live.get_key("announce_trackers").unwrap(), DEFAULT_TRACKER); + assert_eq!(live.get_key("advertise_host").unwrap(), ""); + assert!(live + .set_key("advertise_host", " wonderland.example ") + .unwrap()); + assert_eq!( + live.get_key("advertise_host").unwrap(), + "wonderland.example" + ); + assert!(live.set_key("announce_sysop", "alice").unwrap()); + assert!(live + .set_key("announce_description", "Down the rabbit hole.") + .unwrap()); + assert!(live.set_key("announce_ttl_secs", "60").unwrap()); + assert!(live + .set_key("ws_public_url", "wss://wonderland.example/rhp") + .unwrap()); + assert!(live.set_key("announce_enabled", "off").unwrap()); + assert_eq!(live.get_key("announce_enabled").unwrap(), "false"); + assert_eq!(live.get_key("announce_sysop").unwrap(), "alice"); + assert_eq!( + live.get_key("ws_public_url").unwrap(), + "wss://wonderland.example/rhp" + ); + assert!(matches!( + live.set_key("announce_enabled", "maybe"), + Err(ConfigError::BadValue { .. }) + )); + assert!(matches!( + live.set_key("announce_trackers", "other.example"), + Err(ConfigError::UnknownKey(_)) + )); + } + + #[test] + fn the_flagship_sample_still_loads() { + // The sample claims every key is real (`deny_unknown_fields`). A new + // announce key that isn't in Default, or a typo in the comments-as- + // keys, would refuse to boot a documented config. + let path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/flagship-burrow.toml"); + let text = std::fs::read_to_string(&path).expect("flagship sample"); + let cfg: ServerConfig = toml::from_str(&text).expect("every key in the sample is real"); + assert_eq!(cfg.advertise_host, "rabbithole.example"); + assert!(cfg.announce_enabled, "announce stays on in the sample"); + assert_eq!(cfg.announce_trackers, vec![DEFAULT_TRACKER.to_string()]); + } } diff --git a/crates/ui-web/Cargo.toml b/crates/ui-web/Cargo.toml index 9b3691c..715c2c2 100644 --- a/crates/ui-web/Cargo.toml +++ b/crates/ui-web/Cargo.toml @@ -13,19 +13,20 @@ publish.workspace = true # can show fabricated data as if it were real. Dev workflows opt in: # trunk serve --features demo # `cargo tauri dev` does this via beforeDevCommand; `cargo tauri build` runs -# the plain `trunk build`, so a packaged app never contains it. +# `trunk build --release`, so a packaged app never contains it. demo = [] [dependencies] +rabbithole-directory.workspace = true leptos.workspace = true leptos_router.workspace = true # rabbithole-core WITHOUT the "native" feature: this crate must stay # wasm-clean (no tokio / net / store-client pulled in). rabbithole-core.workspace = true rabbithole-proto.workspace = true -# CP437/ANSI cell + palette model reused by `src/art.rs`. Pure Rust (its only -# dependency is `png`), so it stays wasm-clean and we avoid duplicating the -# ANSI parser that already lives here. +# CP437/ANSI cell + palette model reused by `src/art.rs`. Workspace members +# get this crate without the `png` thumbnail feature (see root Cargo.toml), +# so the wasm bundle does not carry `png` + flate. rabbithole-art.workspace = true # Theme-pack token files: packs round-trip as JSON (`src/packs.rs`) — the seam # a future server theme bundle plugs into. Both wasm-clean. diff --git a/crates/ui-web/examples/icon_sheet.rs b/crates/ui-web/examples/icon_sheet.rs index 7cc80ff..ad98cb1 100644 --- a/crates/ui-web/examples/icon_sheet.rs +++ b/crates/ui-web/examples/icon_sheet.rs @@ -9,7 +9,9 @@ use rabbithole_ui_web::icons::{bell_icon, file_icon, rail_icon, section_icon}; fn main() { - let out = std::env::args().nth(1).unwrap_or_else(|| "icons.html".into()); + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "icons.html".into()); let mut h = String::from( "Nav icons\ \ @@ -25,7 +27,16 @@ fn main() { )); } h.push_str("

Sidebar (18px)

"); - for path in ["/lobby", "/boards", "/dms", "/directory", "/files", "/radio", "/art", "/admin"] { + for path in [ + "/lobby", + "/boards", + "/dms", + "/directory", + "/files", + "/radio", + "/art", + "/admin", + ] { h.push_str(&format!( "
{}
{}
", section_icon(path), @@ -39,7 +50,9 @@ fn main() { (file_icon(true), "folder"), (file_icon(false), "file"), ] { - h.push_str(&format!("
{svg}
{name}
")); + h.push_str(&format!( + "
{svg}
{name}
" + )); } h.push_str("
"); std::fs::write(&out, h).expect("write the contact sheet"); diff --git a/crates/ui-web/examples/sprite_sheet.rs b/crates/ui-web/examples/sprite_sheet.rs index c5eac24..39c1040 100644 --- a/crates/ui-web/examples/sprite_sheet.rs +++ b/crates/ui-web/examples/sprite_sheet.rs @@ -9,7 +9,9 @@ use rabbithole_ui_web::avatar::{glyph_name, glyph_svg, GLYPH_COUNT, PALETTE}; fn main() { - let out = std::env::args().nth(1).unwrap_or_else(|| "sprites.html".into()); + let out = std::env::args() + .nth(1) + .unwrap_or_else(|| "sprites.html".into()); let mut h = String::from( "Warren marks\ \ diff --git a/crates/ui-web/index.html b/crates/ui-web/index.html index a40da1b..fdda3d5 100644 --- a/crates/ui-web/index.html +++ b/crates/ui-web/index.html @@ -17,7 +17,25 @@ - + +