Feat/dynamic domains tls#2
Merged
Merged
Conversation
Consolidates what were three separate docs (the architecture doc, an uncommitted setup-guide draft, and an uncommitted trimmed-scope draft) into one canonical file, correcting course on two things: - Drops the earlier self-hosted-DNS-server idea for answering delegated team-domain challenges -- unnecessary. A team's _acme-challenge CNAME can point at an ordinary subdomain of a zone we already manage via a normal DNS provider API (Cloudflare), no custom nameserver needed. - Restores full scope (team custom domain automation included) rather than the previous trimmed/parked version -- the concrete near-term goal is onboarding a team's domain (raba.resurgee.xyz) onto this instance, which the trimmed version would have blocked on. Adds: the anchor-host DNS pattern, the apex-vs-subdomain CNAME rule, full issuance sequence with a real example, cert storage/serving design (disk + DashMap + ResolvesServerCert), the RABA_HTTPS_PORT 443->7222 decision (one variable, no Docker/bare-metal split), and a full §8 task breakdown to work from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Avoids needing root/cap_net_bind_service to bind on bare metal, and avoids colliding with another webserver already on :443 -- the actual motivation for this VPS deployment. Matches the "RABA" phone-keypad convention .env.development already used for the dev port. One variable, no Docker-vs-bare-metal split: docker-compose.yml already maps host<->container symmetrically from this same var, so only the fallback number needed to change there and in docker/.env.example. Anyone who wants :443 directly still just overrides the one var. Part of docs/plan/dynamic-domains-tls.md's task 8.1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ates TECH_DOC.md's project structure only had a one-line description of install-server.sh (Docker-only, copy .env.example, run compose up). Expands it to the actual planned flow: interactive config prompts -> .env generation -> Docker vs Direct choice -> for Direct, a systemd service plus (only if :443 is already taken by something else) an SNI-passthrough proxy instead of raba binding it directly. Adds the three config templates that flow would install: - install/templates/raba.service.template -- systemd unit using AmbientCapabilities=CAP_NET_BIND_SERVICE (lets the binary bind :443 without running as root; preferred over setcap for a systemd-managed service since it's re-granted at every start, survives binary updates with no manual re-run needed) - install/templates/nginx.stream.conf.template -- SNI passthrough, with its real limitation documented inline (doesn't compose with other nginx sites also TLS-terminating on :443 via the http context) - install/templates/Caddyfile.template -- same via Caddy's layer4 module, flagged as best-effort syntax to verify against that module's current docs before relying on it The install-server.sh script itself stays parked (docs/plan/ dynamic-domains-tls.md §9 -- blocked on Phase 9's published release artifacts) -- these templates are what it would install once written, so they're ready ahead of that rather than designed from scratch later. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
zone_access ('direct' | 'delegated') and cert_status ('none' |
'pending' | 'issued' | 'failed'), plus cert_expires_at/cert_last_error
-- metadata only, the actual cert/key PEM files will live on disk
(/data/certs/<domain>/), never in SQLite, so a stolen DB still only
ever yields hashes, never a usable private key.
zone_access and cert_status are real enums (ZoneAccess, CertStatus)
backed by SQL CHECK constraints, matching the existing TeamRole
pattern rather than repeating the stringly-typed approach this
codebase already moved away from once.
Folded directly into 0001_init.sql per this repo's established
convention (nothing's deployed yet, so no additive-migration
discipline needed until it ships to a real user).
Part of docs/plan/dynamic-domains-tls.md's task 8.2. 83 tests still
passing (unchanged -- this is schema/model plumbing only, no new
behavior yet).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CloudflareClient (server/src/tls/dns_provider.rs): create_txt_record/ delete_txt_record against Cloudflare's v4 API -- writes/deletes the TXT record that answers a DNS-01 challenge (docs/plan/dynamic-domains-tls.md S4). Used identically for the instance's own domain and a team domain's delegated challenge record -- both write to the same Cloudflare zone, just a different record name (zone_access on the domains row decides which, not this client). zone_id is an explicit constructor arg, not auto-resolved from the domain name -- avoids needing broader Zone:Read permission beyond a token scoped to just DNS:Edit on one zone. base_url is overridable (test-only) so tests hit a local mock server, reusing the same lightweight raw-TCP mock pattern raba-core::management_api's tests already use. New dependency: reqwest (json + rustls features only, no default features, to reuse the rustls crypto stack already in the tree instead of pulling in native-tls). 3 new tests, 86 total passing (was 83). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/tls/acme.rs: create_account/load_account (register or restore an ACME account, staging-vs-production toggle -- staging should be used through all dev/testing, production has real rate limits: 5 failed validations/hour, ~50 certs/domain/week) and issue_certificate, the full order -> authorize -> DNS-01 challenge -> poll -> finalize -> download flow from docs/plan/dynamic-domains-tls.md S4.5. Alternates between instant-acme (the protocol conversation with Let's Encrypt) and dns_provider::CloudflareClient (writing the challenge TXT record) -- instant-acme computes what value a challenge needs, we write it via Cloudflare, then tell instant-acme to check. dns_record_name is a plain parameter, not decided inside this function -- stays agnostic to direct-vs-delegated (ZoneAccess), the caller picks the target per that distinction, matching the plan's "one code path over domains" design. Handles the plan's wildcard+apex-shares-one-challenge-name open question: each identifier gets its own TXT record via create_txt_record rather than overwriting, since Cloudflare allows multiple TXT records at one name. Challenge records get best-effort cleanup after validation -- a delete failure doesn't fail issuance, a stale record is harmless clutter. CSR built directly with rcgen (already a dependency, used by dev_cert.rs; same calls instant-acme's own finalize() uses internally) instead of enabling instant-acme's own rcgen feature -- avoids a redundant feature flag for identical behavior. New dependency: instant-acme, added via `cargo add --no-default-features --features ring,hyper-rustls` (not aws-lc-rs, keeping one crypto backend in the tree, consistent with the rest of the codebase). No new unit tests for acme.rs itself -- it's thin orchestration over instant-acme's own (already-tested) internals and the already-tested CloudflareClient; Account/Order are concrete structs, not traits, so there's nothing meaningfully mockable without hitting Let's Encrypt's real staging server, which is an integration-test concern for when this gets wired end-to-end (task 8.5), not a unit test. 86 tests still passing. Discovered along the way: this machine has limited free RAM (~4.5GB), and linking multiple heavy test binaries in parallel (reqwest + instant-acme roughly doubled the dependency graph) can OOM the linker -- `cargo test --jobs 1` works reliably, worth remembering for future test runs here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/tls/cert_store.rs: CertStore (DashMap<hostname, Arc<CertifiedKey>>), load_all (scans <certs_dir>/<domain>/ at startup, skips-and-logs a domain whose fullchain.pem/privkey.pem are missing or unparseable rather than failing the whole load -- e.g. an issuance that never finished), and save_and_insert (writes both PEM files to disk, then inserts into the map -- the "publish" step after tls::acme::issue_certificate returns). save_and_insert takes domain and store_key as separate params, not one -- they differ for the instance's own wildcard cert (the ACME order covers raba.codad5.me, but tls::resolver needs to find it under *.raba.codad5.me). Tested explicitly. Building a CertifiedKey from PEM needs the process's installed crypto provider (CryptoProvider::get_default()) -- only works after tls::install_crypto_provider() has run, same ordering main.rs already respects for the existing dev-cert path. 4 new tests, 90 total passing (was 86). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/tls/resolver.rs: CertResolver implements rustls's ResolvesServerCert, picking a cert per TLS handshake from the requested hostname (SNI) -- exact match against CertStore first, then the wildcard parent (myapp.raba.codad5.me -> *.raba.codad5.me). Replaces https_listener.rs's previous single static ServerConfig (with_single_cert, one cert for the whole server) with with_cert_resolver -- the actual mechanism multi-domain support needs. Fixes a real bug caught while wiring this in, not just plumbing: cert_store::save_and_insert now takes store_keys: &[&str] (plural), not one -- the instance's own cert covers both the apex (raba.codad5.me) and wildcard (*.raba.codad5.me) via one cert's SAN list, so both need their own exact CertStore entry pointing at the same CertifiedKey. A wildcard-only entry would never satisfy a request for the bare apex itself (dashboard/API traffic) -- the resolver's wildcard-fallback logic derives *.<parent> from a *subdomain* request, not a match for the apex's own name. dev_cert.rs and loaded_cert.rs refactored to expose a CertifiedKey directly (build_dev_certified_key, load_certified_key_from_files) instead of a full ServerConfig, so every cert source -- dev, manually loaded via RABA_TLS_CERT_PATH, or eventually ACME-issued -- flows through the same CertStore/resolver mechanism rather than a separate code path. tls::resolve_tls_config renamed to resolve_fallback_certified_key to match. https_listener.rs::serve() now: loads the cert store from certs_dir (new RABA_CERTS_DIR env var, default "certs", matching DATABASE_URL/ RABA_DASHBOARD_DIR's convention) once at startup, seeds the fallback under both domain and *.domain if nothing's persisted yet (never written to disk itself -- that's reserved for real issued/renewed certs the not-yet-built renewal task manages), builds the resolver, and serves every connection through it. All of this runs once at listener startup, not per request -- the per-connection cost is just an in-memory DashMap lookup during that connection's TLS handshake. Visible behavior today is unchanged (still the self-signed dev cert, since there's only one domain in the system) -- this commit is the mechanism multi-domain support depends on, not a new user-visible capability yet. 5 new resolver tests, 95 total passing (was 90). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…end to end server/src/tls/renewal.rs: periodic background task (same shape as tunnel::reaper's UDP session reaper -- a ticking loop, not request-triggered) that scans domains needing a cert (never issued, previously failed, or expiring within 30 days) and (re-)issues via tls::acme + tls::dns_provider, hot-swapping the result into the shared CertStore. Deliberately periodic, not per-request: the TLS handshake's cert lookup (tls::resolver::CertResolver::resolve) runs synchronously inside rustls's handshake state machine, too early/wrong a place to kick off async renewal for that connection; and a domain with no traffic for a while would never get proactively renewed if renewal only fired on request, risking an actually-expired cert greeting whoever visits next. Real bugs caught while wiring this in, not just plumbing: - CertStore::clone() deep-copies (DashMap's own Clone impl copies every shard's contents into a new map, confirmed by reading its source) -- a naive design would have renewal silently updating a map nobody reads from. Fixed by constructing the store once in main.rs, wrapping in Arc, and threading that same Arc through https_listener.rs and renewal.rs, instead of each building/loading its own. - An unsupported RABA_DNS_PROVIDER value would have `return Ok(())`'d out of main() entirely, killing tunnel/https listeners along with renewal instead of just skipping renewal -- caught in review. - DNS provider selection is now a real match (RABA_DNS_PROVIDER, defaults to "cloudflare", the only supported value for v1 -- TODO(post-v1) more arms behind a small trait once a second provider is actually needed), not a hardcoded call, per multi-tenancy.md/ dynamic-domains-tls.md's already-decided Cloudflare-only-for-v1 scope -- also caught in review, this was previously silently assumed. server/src/tls/mod.rs gains load_cert_store_with_fallback (moved the store-loading + fallback-seeding logic here from https_listener.rs, so main.rs can build the shared Arc<CertStore> before either consumer needs it). https_listener.rs::serve() now takes domain/cert_store as params instead of building them itself. resolver::CertResolver holds Arc<CertStore>, not an owned CertStore, for the same sharing reason. main.rs stays a single call (renewal::maybe_spawn_from_env) -- all env var reading, account loading, and graceful degradation when unconfigured lives in tls::renewal, same pattern as spawn_udp_reaper. New env vars: RABA_DNS_API_TOKEN, RABA_DNS_ZONE_ID, RABA_ACME_EMAIL (required together), RABA_DNS_PROVIDER (optional, default cloudflare), RABA_ACME_STAGING (optional). None set -> falls back to the existing manual RABA_TLS_CERT_PATH path or the self-signed dev cert, unchanged. Domain.cert_expires_at changed from a timestamp string to Unix epoch seconds -- avoids an X.509-parsing dependency just to read a cert's notAfter field; Let's Encrypt certs are always exactly 90 days' validity (stable CA policy), so issued_at + 90 days is accurate without parsing the issued cert. 2 new pure-logic tests (issuance_plan, no network/DB dependency -- Account/Order in instant-acme are concrete structs that always make real network calls, nothing meaningfully mockable there without hitting Let's Encrypt's staging server, which is a real deployment verification step, not a unit test). 97 total passing (was 95). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/api/team_routes.rs gains PATCH /api/teams/:id/domain and POST /api/teams/:id/verify-domain (docs/plan/multi-tenancy.md's API surface, previously unbuilt) -- admin-tier, matching the existing precedent for team rename. PATCH generates a fresh delegated_label per domain change and resets verified=0. Domain-hijack-by-reclaim fix (multi-tenancy.md's other half of the partial-unique-index fix, dynamic-domains-tls.md S8.7) lives here: if the team already had a different domain, its projects still snapshotted under the old domain string are revoked in the same transaction, closing the gap where letting go of a domain could leave an old project reachable to whoever controls that domain's DNS next. POST verify-domain does a real DNS lookup (tokio::net::lookup_host, follows CNAMEs transparently) confirming the domain resolves to this instance before flipping verified=1 -- no cert issuance happens here directly, that's tls::renewal's periodic scan, which now correctly requires verified=1 (a gap this commit also closes: previously an unverified domain could have triggered a real ACME order). Delegated cert issuance completed, not left stubbed: renewal.rs's issuance_plan now handles ZoneAccess::Delegated for real -- per-exact-hostname (no wildcard, per dynamic-domains-tls.md's decision for team domains), challenge written directly to delegated_label with no _acme-challenge. prefix (the team's own CNAME already provides that redirection). Also: 'tunnel' reserved as a forbidden subdomain at project creation (dynamic-domains-tls.md S9's parked SNI-multiplexing idea -- reserving the word now costs nothing, freeing it later would break whoever already has it). is_unique_violation moved from project_routes.rs to a shared api::mod helper, reused by team_routes.rs instead of duplicating it. New domains.delegated_label column, folded into 0001_init.sql per this repo's established convention (nothing deployed yet). 8 new tests: domain set/response shape, RBAC rejection, can't claim the instance's own domain, duplicate-domain-across-teams conflict, verify-without-domain 404, verify-fails-when-DNS-doesn't-point-here (RFC 2606 .invalid TLD for a deterministic negative, no mocking needed), the hijack-fix itself, and the tunnel reservation. All passing -- 106 total. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/auth/rate_limit.rs: LoginRateLimiter, a per-email fixed- window failed-attempt counter (5 attempts / 15 minutes), same DashMap- backed-registry pattern as ClientRegistry/StreamRegistry/etc. Lives on AppState (state.login_rate_limiter). Per-email, not per-IP -- a structural choice, not a shortcut: this server doesn't use axum::serve, it has a custom TLS/hyper pipeline (routing::https_listener) that doesn't currently thread the client's peer address into axum's extractors. Getting real per-IP limiting would mean modifying that pipeline. Per-email still closes the actual threat model (unlimited password guesses against one account, regardless of source IP); IP-based limiting would be a defense-in- depth layer on top, not a replacement -- noted as a documented follow-up in the module doc comment, not silently scoped out. Wired into auth_routes::login: checked before the DB lookup or password verification even happen, so a locked-out email can't be used to burn CPU on argon2 hashing. Cleared on successful login. Returns 429 with a retry-after message. 8 new tests: 6 unit (under/at/over limit, case-insensitive email, clear resets, independent per-email tracking, window-expiry reset via an injectable short window for the test), 2 integration against the real endpoint (lockout after repeated failures -- including confirming even the correct password is rejected while locked out -- and scoping to one email only). 114 total passing (was 106). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md S11's "connection/stream caps" -- StreamRegistry (HTTP+TCP visitor connections) and UdpSessionRegistry (UDP pseudo- sessions) both gain a configured capacity ceiling. insert becomes try_insert (bool return), rejecting once at capacity instead of growing unbounded -- a soft backstop against a flood of connections exhausting memory, not a hard atomic invariant (DashMap's per-shard locking means len()-then-insert isn't one operation; documented inline as an accepted small-overshoot-under-load tradeoff). New env var RABA_MAX_CONCURRENT_STREAMS (default 10,000), read once in AppState::new and applied to both registries as independent counters, not a shared pooled budget between them -- noted explicitly in both registries' doc comments, since they track different things with different lifecycles (per-connection vs idle-timeout-evicted). Wired into all three places a stream/session gets created: - https_listener.rs -- rejects with a real 503 Service Unavailable - tcp_router.rs -- drops the connection - udp_router.rs -- drops the datagram; existing sessions unaffected, only blocks new ones 3 new tests (capacity rejection in both registries, slot-frees-up re-acceptance), existing registry tests updated for the new constructor signature. 117 total passing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server/src/logging/log_queue.rs: LogQueue/RequestLogEntry, an mpsc-backed writer to request_logs (TECH_DOC.md S11). log() is a non-blocking try_send -- entries are dropped, not blocked on, if the channel's full, since the actual traffic-forwarding hot path must never wait on a DB write. Spawned once in AppState::new, held as state.log_queue. Wired into all three protocols, one row per completed connection/session: - HTTP (https_listener.rs): method/path come free from httparse's existing parse (already needed to find Host), no new protocol-aware parsing added. bytes_out tracked via a shared Arc<AtomicU64> since the writer task may be aborted rather than cleanly awaited, so a plain local counter wouldn't survive to be read after that. - TCP (tcp_router.rs): same byte-counting pattern; method/path are None (no HTTP concept here). - UDP (registry/udp_sessions.rs + tunnel/reaper.rs): logged per *session* (the reaper's idle-timeout-bounded unit), not per datagram -- per-datagram would be absurdly noisy for a connectionless protocol. The two byte-counting call sites know a session by different keys (udp_router.rs's inbound path knows peer_addr; tunnel::listener's dispatch of a Client's response frame knows only stream_id), so UdpSessionRegistry's forward/reverse entries now share Arc<AtomicU64> counters instead of duplicating plain u64 fields -- one pair of counters per session, reachable from either index. The reaper logs on eviction with the final totals. Known, documented gap: status_code isn't captured -- would need parsing the Client's response byte stream, which is forwarded completely opaquely today. Flagged in the module doc comment as deferred, not silently dropped. 2 new tests (log_queue's actual DB write, polled rather than a fixed sleep since it's genuinely async; UDP's dual-direction byte accumulation surviving to eviction), existing udp_sessions tests updated for the new touch/try_insert/resolve signatures (now take/ return byte counts) and remove_expired's new EvictedSession return type. 119 total passing (was 117). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md S9 Phase 10's "graceful shutdown" -- previously nothing
handled SIGINT/SIGTERM at all: docker stop/Ctrl+C just killed the
process instantly with zero app-level cleanup (signals don't trigger
Rust unwinding, so no Drop impl or in-flight write ever got a chance
to run).
shutdown_signal() races tokio::signal::ctrl_c() against SIGTERM
(Unix-only, what docker stop/systemd actually send -- Ctrl+C alone
wouldn't cover container shutdowns), wired via tokio::select! against
the existing listener try_join!. On signal: log it, wait a bounded
grace period (RABA_SHUTDOWN_GRACE_SECS, default 5s -- long enough for
logging::log_queue's writer task to drain whatever's buffered), then
exit cleanly with code 0.
Deliberately scoped, documented inline: does not stop new connections
from being accepted during the grace window, and doesn't explicitly
cancel already-spawned per-connection handler tasks -- those still
just get torn down when the process exits, after a deliberate pause
instead of instantly. Full connection-draining would need a
cancellation token threaded through every accept loop (tunnel/https
listeners, every per-project TCP/UDP listener) -- a real, larger
follow-up, not attempted here.
Also fixed a real bug caught while wiring this in: tokio::try_join!
eagerly awaits and resolves to a Result, not a Future, so it couldn't
be raced directly inside select! -- wrapped in async {} to make it
pollable.
Not meaningfully unit-testable (would require sending real OS signals
to the test process) -- 119 tests still passing, unchanged count.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md S9 Phase 10 -- previously visiting the server directly over plain http:// against the TLS-only HTTPS port failed at the TLS handshake level, before any app code ran -- not fixable with a nicer error page, only a real plain-HTTP listener helps (found 2026-07-10). server/src/routing/http_redirect_listener.rs: reads just the request line + Host header (same minimal httparse approach https_listener.rs uses to peek Host), responds 301 to https://<host><path>, closes. New RABA_HTTP_PORT env var, default 8080 -- same non-privileged-port reasoning as RABA_HTTPS_PORT's 7222 default. Security: validates Host against known hosts (instance apex/www, or an actual project subdomain -- reusing http_router::is_dashboard_host/ resolve_project, the same checks https_listener.rs itself relies on) before reflecting it into Location. Blindly trusting a client-supplied Host would make this an open redirect (CWE-601) -- a crafted request could get 301'd to an arbitrary attacker-controlled host while appearing to originate from this server. Unrecognized Host -> drop the connection, no response. Verified by drops_connection_for_unrecognized_host. Also fixes a real bug found during review, unrelated to this feature but caught while touching docker/Dockerfile: RABA_CERTS_DIR was never actually pointed at the /data volume, so ACME-issued certs (tls::renewal) would have been written outside persistent storage and lost on every container recreation. Added ENV RABA_CERTS_DIR=/data/certs. docker/.env.example brought current -- it was missing the entire ACME automation var set (RABA_DNS_API_TOKEN/RABA_DNS_ZONE_ID/RABA_ACME_EMAIL/ RABA_DNS_PROVIDER/RABA_ACME_STAGING), RABA_CERTS_DIR, RABA_MAX_CONCURRENT_STREAMS, RABA_SHUTDOWN_GRACE_SECS, and now RABA_HTTP_PORT. docker-compose.yml and the Dockerfile's EXPOSE/comments updated to match current port defaults (7222/8080, not 443/80). 3 new tests (redirects apex, strips port from Host before validating, drops connection for unrecognized Host). 122 total passing (was 119). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tem) TECH_DOC.md S9 Phase 10 -- request_count/avg-duration/bytes/requests-per-sec aggregation over a trailing window, both per-project and per-team. server/migrations/0001_init.sql: request_logs gains duration_ms (connection lifetime -- this server forwards bytes opaquely and never sees an actual HTTP response, S2, so "response time" here means stream-open to stream-close/session-eviction, not true latency -- same honest gap already documented for status_code) plus an index on (project_id, timestamp), since the new /stats endpoints filter+aggregate by project over a time window. Edited in place rather than an additive migration, per this project's established pre-beta convention (nothing deployed yet). duration_ms captured at every logging::log_queue call site: https_listener.rs and tcp_router.rs via an Instant captured at connection start; udp_sessions.rs's ForwardEntry gained created_at so the reaper's EvictedSession carries a real session lifetime instead of a placeholder. New endpoints (server/src/api/mod.rs holds the shared TrafficStats/ StatsQuery types, window clamped to [60s, 30 days]): - GET /api/projects/:id/stats?window_secs= - GET /api/teams/:id/stats?window_secs= -- team totals plus a per_project breakdown array Real bug caught by the new team-stats test, not just plumbing: ProjectTrafficStats' nested `stats: TrafficStats` field was missing #[serde(flatten)], so the JSON response nested every stat under a `stats` key instead of flattening it alongside project_id/project_name -- the test asserting on `per_project[i]["request_count"]` failed against a real `null` until this was added. Also fixes a stale doc comment on get_project_logs claiming nothing writes to request_logs yet -- false since logging::log_queue was wired in. 6 new tests (project stats: aggregates within window / empty-state; team stats: totals + per-project breakdown / non-member forbidden). 126 total passing (was 122). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
raba.service.template's AmbientCapabilities comment only mentioned RABA_HTTPS_PORT/:443 -- stale since Phase 10 added RABA_HTTP_PORT (default 8080, e.g. :80 for the plain-HTTP redirect listener), which needs the same capability if bound below 1024. Functionally harmless (AmbientCapabilities already covered any privileged port), just misleading to read. dynamic-domains-tls.md: the existing SNI-passthrough templates (nginx stream/Caddy layer4) only work for :443 because they forward opaque encrypted bytes with no need to understand the payload -- :80 plaintext HTTP has no SNI to route by, so a shared box already running another webserver on :80 has no equivalent passthrough option. Recorded as a known, deliberately unaddressed gap rather than silently missing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds TrafficStats/ProjectTrafficStats/TeamTrafficStats to api/client.ts (projectsApi.stats, teamsApi.stats) matching the server's GET /api/projects/:id/stats and GET /api/teams/:id/stats shapes. ProjectDetail gets a "Traffic (last hour)" stat-card row (requests, req/sec, avg duration, bytes in/out); TeamSettings gets the same totals plus a per-project breakdown list. Both reuse the existing StatCard component rather than introducing a new one. avg_duration_ms is documented at the type level as connection lifetime, not true HTTP response latency (the server forwards bytes opaquely and never sees an actual response) -- same caveat carried from the server's logging::log_queue doc comment, so the dashboard doesn't imply a precision the data doesn't have. Extracted formatMs/formatBytes into a shared src/lib/format.ts rather than duplicating them across ProjectDetail and TeamSettings. tsc --noEmit and `npm run build` both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
client/raba-core/src/management_api.rs was built against TECH_DOC.md's documented endpoint list before Phase 7's server routes existed (its own doc comment flagged this, and TODO.md tracked it as a known, blocking gap since 2026-07-10). Now that the server is real: - create_project was missing the now-required team_id and (for HTTP) subdomain -- every HTTP project creation from the CLI would 400 against a real server. Fixed, plus ProjectInfo/CreatedProject gained the fields the server actually returns (team_id, domain, local_target, online). - No team/domain/stats support existed at all. Added list_teams, update_team_domain/verify_team_domain, get_project_stats/ get_team_stats -- new TeamInfo/DomainInfo/TrafficStats/ ProjectTrafficStats/TeamTrafficStats types matching the server's response shapes exactly. client/raba-cli/src/cli.rs: - New resolve_team() helper: an explicit --team (id or name, case- insensitive) if given, otherwise the caller's sole team if they belong to exactly one, otherwise an error listing the options rather than guessing. - New default_subdomain(): auto-generates a subdomain from the project name when --subdomain is omitted (lowercased, non-alphanumeric runs collapsed to '-', trimmed) -- previously nothing generated one at all, a real functional gap since the server requires subdomain for HTTP projects (server/src/api/project_routes.rs). Avoids the server-reserved "tunnel" word as an exact match. - http/tcp/udp/connect gained --team (all three) and --subdomain (http only) flags; resolved choices persist into .raba.toml's new team_id/subdomain fields so a later run in the same directory doesn't need to re-decide. - A 409 from create_project (projects_domain_subdomain_unique -- see 0001_init.sql) now gets a specific "subdomain already taken, retry with --subdomain" message instead of a generic wrapped error. - New `raba team list` / `raba team domain-set <domain>` / `raba team domain-verify` subcommands (team custom domains, docs/plan/dynamic-domains-tls.md) and `raba stats [--team|--project]` for the new traffic-stats endpoints. spawn_mock_server (management_api.rs test helper) changed from &'static str to impl Into<String> so per-test response bodies can be built with format! instead of leaking memory to satisfy 'static. 23 raba-core + 9 raba-cli tests passing (added: 6 management_api endpoint tests, 5 default_subdomain tests). TODO.md's Phase 10 table and the standalone "CLI/server API reconciliation" row both updated to [x]. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The dashboard was still on Vite's default scaffold branding -- a
hardcoded blue accent (ui.tsx's own comment: "Accent is blue") with no
relation to TECH_DOC.md's brand spec, and a blue "R" square standing in
for a logo in three separate places (sidebar, Login, Signup) because
the real mark didn't exist yet.
dashboard/src/index.css: new --color-accent-{50,400,500,600,700}
tokens via Tailwind v4's @theme, aliased to the brand green (#22C55E,
same hex as Tailwind's stock green-500). Named semantically ("accent"),
not by hue, so a future rebrand is a one-line edit here instead of a
grep-and-replace across every consumer. Every hardcoded blue-* utility
across ui.tsx, Layout.tsx, and 6 pages swapped to accent-* at the same
shade number.
Real bug caught while wiring this in: @theme blocks only accept custom
properties, no comments -- Tailwind's parser silently miscompiled the
CSS when a comment (with an apostrophe, which the minifier also chokes
on outside @theme) lived inside the block. Moved the explanation above
the block instead, both times fixed before it shipped, not after.
dashboard/src/components/Logo.tsx (new): LogoMark + Logo, the real
arch/badge mark inlined as SVG (crisp at small sizes, no extra
request), replacing the placeholder "R" square in all three spots.
dashboard/public/favicon.svg swapped from the default Vite React logo
to the real mark.
npx tsc --noEmit and npm run build both clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md §12 already fully specced the logo (arch bridging two
connection points, dark badge, green arch, orange connection-point
circles) -- built the actual SVGs from that spec rather than inventing
a new concept: docs-site/static/img/brand/{mark,wordmark,
lockup-compact,lockup-full}.svg, plus favicon.svg/.ico derived from
the mark via ImageMagick (4-size .ico: 16/32/48/64).
Two real bugs caught before shipping, not after: text used
fill="currentColor", which only resolves inside an inlined SVG with a
CSS color context -- referenced as a plain image file (README, <img>,
favicons) it rendered invisible, fixed to a fixed dark fill instead.
Separately, the explanatory comments used "--" as a separator (this
codebase's usual comment style), which XML forbids anywhere inside a
comment, not just as the closing delimiter -- broke well-formedness,
caught by parsing every file with expat before considering them done.
BRAND.md (new): the user/contributor-facing version of TECH_DOC.md
§12's spec -- name meaning, mark concept, colors, asset list. Kept
deliberately separate from TECH_DOC.md/TODO.md, which track internal
build progress and may not survive to v1 in their current form.
README.md (new, was an empty placeholder): logo, name meaning, feature
list, npm/Docker/CLI quickstarts, project status, license -- makes no
reference to TECH_DOC.md or TODO.md, both treated as internal-only per
this round's discussion.
LICENSE (new): Functional Source License 1.1, Apache-2.0 future
license (FSL-1.1-ALv2) -- verbatim from the official
getsentry/fsl.software template, not reconstructed from memory.
Permits any use, including commercial/internal-business self-hosting,
except offering the software as a competing hosted/managed service;
each version automatically converts to Apache-2.0 two years after its
release. Chosen over plain MIT (no restriction at all) and SSPL
(considered too aggressive/non-OSI) for this project's specific goal:
freely usable, but not resellable as a competing SaaS.
TODO.md: Build Phases summary table brought in sync with the actual
per-phase tables (Phase 9 still not started, Phase 10 now complete) --
was already correct in the detailed tables but stale in the summary.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
TECH_DOC.md §9 Phase 9. Four independent parallel jobs (server, client, dashboard, docs-site) -- website/ deliberately excluded, already marked superseded in TODO.md (merging into docs-site, not built out further). Every job runs exactly the commands already verified by hand this session, nothing new invented for CI specifically. Prep fixes, made first so the gate isn't red on day one: - server/src/auth/jwt.rs: fixed 3 pre-existing clippy warnings (doc comment continuation-line indentation) -- server is now fully clippy-clean, so its job can enforce -D warnings. - client/raba-core/src/config.rs + tunnel_conn.rs: fixed the same for client (field_reassign_with_default in a test, and a documented #[allow(clippy::await_holding_lock)] for a std Mutex guard deliberately held across await in a test-only env-var-serializing helper -- explained inline why it's safe: shared with sync #[test] fns elsewhere so it can't become an async-aware Mutex, and every #[tokio::test] in this crate uses the default current-thread runtime flavor, so it can't deadlock against another thread). Real gap found and fixed while verifying docs-site's checks actually run clean: docs-site/node_modules was ~32KB with no package-lock.json at all -- scaffolded but npm install was never actually completed there. Ran it for real (1304 packages); typecheck and build both now pass and the lockfile is committed (ci.yml's cache-dependency-path and `npm ci` both need it to exist). rustfmt is deliberately NOT enforced yet -- the codebase isn't rustfmt-clean (894 lines would change in client/, 3773 in server/), and normalizing that is its own separate, deliberate change, not bundled into standing up CI. YAML verified structurally valid via js-yaml (no local Actions runner available to fully exercise it) -- real end-to-end verification happens on the first real PR/push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The server API for team custom domains (PATCH .../domain, POST .../verify-domain, docs/plan/dynamic-domains-tls.md) has existed since Phase 7/10, and raba-cli got team domain-set/domain-verify in the client reconciliation pass -- but the dashboard itself never got a form for it. TeamSettings.tsx had zero domain UI at all. server/src/api/team_routes.rs: adds GET /api/teams/:id/domain -- a real gap, not just a client-side omission. PATCH and verify-domain both return the current DomainResponse as the *result of an action*, but there was no way to plain-read the current state, so the dashboard couldn't show existing config on page load without provoking a domain change or a re-verify as a side effect. TeamRole::Member (view-only), same tier as get_team, not the Admin tier the two mutating endpoints require. 2 new tests. dashboard/src/api/client.ts: DomainResponse/CertStatus types plus teamsApi.getDomain/setDomain/verifyDomain. dashboard/src/pages/TeamSettings.tsx: new DomainSection -- shows the current domain (if any) with verified/cert-status badges and the DNS records to add, a set/change form and a verify button (both gated canManage, matching every other mutating action on this page), and a plain read-only view for non-admins. getDomain's 404 (no domain set yet) is handled as an expected empty state, not an error -- fetched separately from the page's other Promise.all data so it can't reject the whole load and hide the rest of the page. dashboard/src/components/Logo.tsx: arch color now reuses --color-accent-500 instead of duplicating its hex -- real DRY, since that token already exists dashboard-wide and the two values could otherwise silently drift apart. Badge background and connection-point orange stay plain hex (TECH_DOC.md §12's fixed brand colors, used nowhere else in the dashboard, so a token would only add indirection without preventing any actual duplication). tsc --noEmit, npm run build, npm run lint all clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…team
Previously POST /api/projects required team_id unconditionally, and
raba-cli errored out ("multiple teams found, pick one") the moment an
account had more than one team and no --team was given -- annoying for
what's supposed to be a zero-config CLI, and not what was actually
wanted: a silent, sensible default (the caller's oldest team
membership, in practice almost always their signup-created personal
team) rather than forcing every invocation to specify one.
server/src/api/project_routes.rs: CreateProjectRequest.team_id becomes
Option<String>. New resolve_default_team_id -- no schema change
needed, team_members.created_at already exists -- ORDER BY created_at
ASC LIMIT 1 for the caller. This is deliberately the *only* place the
default lives (not duplicated client-side) so it can never drift from
what the server actually does. 4 new tests: sole-team case (regression
check), multi-team defaults to the oldest (seeded with an explicit
created_at via direct SQL, not wall-clock timing -- SQLite's
CURRENT_TIMESTAMP only has second resolution, so two teams created
back-to-back through the API within the same test could tie and make
"oldest" nondeterministic), explicit --team still overrides the
default even with multiple teams available, and the defensive
zero-teams case (can't happen through normal signup, exercised via
direct team_members deletion).
client/raba-core/src/management_api.rs: create_project's team_id
becomes Option<&str>, omitted (not sent as null) from the JSON body
via #[serde(skip_serializing_if)] when absent -- the server
distinguishes "omitted, please default it" from an explicit null,
which serde wouldn't accept for a String field anyway. spawn_mock_server
(test helper) now captures the full request (was request-line only) so
tests can assert on JSON body content, not just method/path -- needed
to actually prove team_id is omitted, not just null. 2 tests (send
it when given, omit it entirely when not).
client/raba-cli/src/cli.rs: resolve_secret's Create branch only calls
resolve_team (local validation against the caller's own teams, nicer
error than a raw 403) when --team was actually given; when omitted, no
client-side resolution/guessing at all, team_id just isn't sent and
the server decides. resolve_team itself is unchanged and still used
as-is by `raba team`/`raba stats`, which always need a concrete
team_id for their URL path -- a different situation from project
creation, where omission is now a legitimate choice.
132 server tests, 33 client tests (24 raba-core + 9 raba-cli), all
passing. cargo clippy --all-targets -D warnings clean across server
and client.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ller's teams Adds the third way to pick which team a project belongs to (alongside the existing team_id and the just-added default-to-oldest-team): an explicit domain, resolved server-side to whichever team owns it. server/src/api/project_routes.rs: CreateProjectRequest gains an optional `domain` field. - Equal to the instance's own domain -> treated identically to omitting it entirely (not a team domain, nothing to resolve). - Otherwise resolved via resolve_team_id_from_domain, scoped to teams the caller can actually act on -- never a global lookup, or any authenticated caller could probe which team owns an arbitrary domain string just by trying to create a project under it. Instance admins/owners search every team's domain instead (consistent with require_team_role's existing is_admin_or_owner() bypass elsewhere in this file -- they can already act on any team's projects regardless of membership, so scoping --domain differently for them would just be a confusing gap, not an extra safety boundary). - Matched but unverified -> explicit 400, not a silent fallback to the instance domain -- the caller asked for this domain specifically, so silence would be worse here than in the implicit (no domain given) path, which does still fall back silently (existing behavior, unchanged). - domain given alongside an explicit team_id that disagrees with the resolved owner -> 400. 9 new tests: resolves to the owning team (single team and picked correctly among several), rejected when no team the caller can act on owns it, rejected (not silently ignored) when matched but unverified, team_id+domain agreeing/disagreeing, instance-domain-equals-omitted, and the two security-relevant ones -- scoped away from an unrelated account, but global for instance admins. client/raba-core/src/management_api.rs: create_project gains a `domain: Option<&str>` parameter, sent (or omitted) the same way team_id already is. 1 new test. client/raba-cli/src/cli.rs: new --domain flag on http/tcp/udp/connect, persisted into .raba.toml the same way --team/--subdomain already are. Passed straight through to create_project unresolved -- the server decides which team it belongs to, not this module. 150 server tests, 34 client tests (25 raba-core + 9 raba-cli), all passing. cargo clippy --all-targets -D warnings clean across server and client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… domain The implicit domain-resolution path (no explicit `domain` in the create request) has always silently used the instance domain when the resolved team's own domain isn't verified yet -- correct behavior, but silent, leaving the caller to wonder why their project didn't land on their team's domain. The explicit-domain path already 400s outright on the same situation (added earlier this session); this is the implicit path's counterpart, minus the hard failure -- it can't error, since nothing was explicitly requested to fail on. CreatedProjectResponse gains an optional `warning` field, set only when: the team resolved (via team_id or the default-team fallback, never via an explicit `domain` -- that path already rejects the unverified case outright) has a domain row that exists but isn't verified. The domain-resolution query no longer filters `WHERE verified = 1` -- it needed the unverified row's value too, to name it in the warning, not just know a verified one didn't exist. 2 new tests: the warning fires with the right domain name when the team's domain is set-but-unverified, and stays absent when the team has no domain configured at all (only the more specific "set but unverified" case should ever warn). 153 server tests passing (was 150). clippy --all-targets -D warnings clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
default_subdomain(name) slugified the *project* name into a subdomain when --subdomain was omitted. The project name commonly falls back to the current directory's basename (config::resolve_project_name) -- for a tunneling CLI invoked from wherever, that's often a completely unrelated folder name (downloads, scratch, whatever you happened to be cd'd into) with no business ending up in a public subdomain. Replaced with random_subdomain(): a fun, human-readable adverb-adjective-noun-NN name (e.g. briskly-brave-otter-42), Docker/Heroku-style, matching how ngrok/localtunnel actually behave by default (random unless you ask for something specific via --subdomain). No stability lost -- once created, the subdomain is already persisted into .raba.toml and the secret cached, so a later `raba connect` in the same directory reuses the exact same one regardless of how it was first picked; the randomness only affects the very first run. No new dependency: a nanosecond timestamp mixed with the process id is plenty of entropy for a friendly, non-security-sensitive name (unlike auth::secret's actual project secrets, which do need a real CSPRNG). SmallRng (splitmix64's finalizer, hand-rolled, no crate) decorrelates the four picks drawn from that one seed -- naively shifting/masking the raw seed per pick would make adjacent choices visibly correlated. 30 adverbs x 34 adjectives x 30 nouns x 100 suffixes = over 3 million combinations. 3 new tests (shape, DNS-safe charset, variation across 50 calls) replacing the 4 old default_subdomain-specific ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase10 followups and clients
Caught by CI (ci.yml), not locally -- my local clippy (1.88 toolchain) apparently doesn't warn on clippy::duplicated_attributes the same way CI's runner (rust-1.97.0) does, so `cargo clippy --workspace --all-targets -D warnings` passed locally right up until this was pushed. The duplicate came from two separate edits to cmd_tunnel's attributes this session, the second not noticing the first was already there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lockup-full.svg is transparent with fixed dark wordmark text -- fine on docs-site (light page background), but on GitHub's dark mode the README would render that dark text against a dark page background, nearly unreadable. lockup-full-white-bg.svg: same mark, plus a white background with 20px padding and a rounded card corner (a bare edge- to-edge white rect looked wrong against the padding-less original). README now points at this variant instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.github/workflows/release.yml: triggered on v* tags. Docker image build+push to GHCR (ghcr.io/codad5/raba, built-in GITHUB_TOKEN, no extra secrets). raba-cli binaries via a hand-rolled build matrix (linux-x86_64/macos-x86_64/macos-arm64/windows-x86_64 -- all native runners, no cross-compile toolchain) instead of cargo-dist, which TODO.md originally named: cargo-dist's setup is semi-interactive and generates its own workflow file, harder to author/verify correctly without a real local run. server binary covers linux-x86_64 only -- linux-arm64 for either binary needs a Docker-based cross toolchain (`cross`) that couldn't be verified end-to-end without a real CI run, left as a documented gap rather than shipped untested. install.sh (POSIX sh, Linux/macOS): detects OS/arch, downloads the matching raba-cli release asset, installs to ~/.local/bin, checks PATH and prints the exact fix if it's missing rather than assuming it worked. install.ps1 (Windows, not in the original TECH_DOC.md plan -- added since install.sh can't run there at all): same idea for raba-cli.exe. Neither needs a landing site to be reachable -- both are usable today via GitHub's raw-content host directly (curl .../master/ install.sh | sh), a real product domain later would just be a friendlier redirect to the same URL. install-server.sh (bash, needs `read -p` and more control flow than POSIX sh comfortably gives): interactive prompts (domain, optional Cloudflare DNS-01 ACME config, allow_signup), generates JWT_SECRET itself -- never prompted, matching TECH_DOC.md's explicit call-out that a user-supplied session secret is a foot-gun. Then Docker (pulls the GHCR image, writes a minimal self-contained docker-compose.yml rather than assuming a full repo checkout is present) or Direct (downloads the Linux server binary, installs as a systemd service via the existing raba.service.template, detects an already-occupied :443/:80 and points at the SNI-passthrough templates if so). Scope simplification, documented in the script itself: Direct mode runs the service as the invoking user, not a dedicated system account -- a real hardening improvement for later, not attempted here. .gitattributes (new): forces LF line endings on *.sh regardless of the checkout platform's git config -- a CRLF shebang line fails outright on Linux/macOS. Confirmed what's actually committed was already LF-only either way (the primary curl-from-GitHub usage was never at risk), but a local `git clone` + run-from-checkout on Windows could have been without this. TODO.md: Phase 9 marked complete, both the phase-table summary and its own detailed table, with the linux-arm64 gap called out in both places. Verified: release.yml validated as parseable YAML (js-yaml, no network needed); `cargo build --release` for both raba-cli and server confirmed locally; install.sh/install-server.sh checked with `sh -n`/`bash -n`; install.ps1 checked with PowerShell's own script parser. No local GitHub Actions runner exists to prove the workflow end-to-end -- that first real signal comes from an actual tag push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
No self-update command existed for raba-cli, despite install.sh/ install.ps1 already existing as of this same phase -- a real gap for any curl-installed CLI (rustup, gh, kubectl plugins all have one). Cli gains #[command(version)] (raba --version, derived from CARGO_PKG_VERSION) and a new `update` subcommand: checks GitHub's releases/latest API against the compiled-in version, and if newer, re-runs install.sh (Unix) or prints manual install.ps1 instructions (Windows) rather than attempting a same-process binary replacement. Unix is safe to just shell out to install.sh's own download-and-replace logic: overwriting the path of an already-running executable via `mv` works fine on Linux/macOS (the running process keeps its already-open file handle to the old inode until it exits) -- the same trick nearly every self-updating Unix CLI relies on. Windows deliberately does NOT attempt this: Windows locks an executable file while it's running, so a same-process self-replace needs a genuinely different mechanism (spawn a helper that waits for this process to exit, rename-then- replace tricks) that couldn't be verified safe without a real Windows test run -- printing instructions instead of shipping an untested risky binary-replacement path, same "document the gap" call as this project's other known, deliberately-deferred limits (linux-arm64 release builds, etc). New deps: reqwest (json feature, matching raba-core's own usage) and serde (derive) for parsing the GitHub API response -- both already present transitively via raba-core, now direct raba-cli deps since self-update logic is CLI-binary-specific (raba-napi has no equivalent concept, its own "update" is npm's job). Manually verified end-to-end on this (Windows) machine: --version prints correctly, and `update` against a repo with no release yet returns a clear "no release found -- has a v* tag been pushed?" message instead of a raw HTTP error or a panic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…setup Three real, independent bugs in install-server.sh, found during review rather than caught by any test (this script has none -- it's inherently an integration artifact, only really testable against a real server): 1. Idempotency: re-running the script unconditionally regenerated JWT_SECRET, silently logging out every active dashboard session on every re-run -- turning "update my server" into "log everyone out" by accident. write_env now reuses an existing JWT_SECRET if .env already has one. main() also now detects an existing .env and offers to update in place (pull the latest Docker image, or re-download+restart the systemd service) without repeating any prompt or touching existing config, rather than only ever running the full first-time interactive flow. 2. install_direct's systemd template lookup used "$(dirname "$0")/install/templates/raba.service.template" -- a local file path, which silently breaks under this script's own documented primary usage (`curl | bash`, where $0 isn't a real file path and no repo checkout exists on disk at all). Replaced with fetch_template, downloading from GitHub's raw-content host, the same source install.sh/install.ps1 already pull release binaries from. 3. Reverse-proxy setup was reactive and incomplete: only a port_in_use check *after* install, printing a generic pointer at the template file for the user to fill in themselves, and no distinction between :443 and :80 at all. Reworked to ask for the actual listener ports directly (RABA_HTTPS_PORT/RABA_HTTP_PORT, not an abstract "dedicated y/n") and infer the reverse-proxy need from what was entered: 443/80 means raba binds them directly (warns that nothing else can share those ports afterward, plus a pre-flight port_in_use sanity check); anything else asks which reverse proxy (nginx/Caddy/none) and generates the actual filled-in passthrough config via a new setup_reverse_proxy, instead of just pointing at a template. Also fixes a real gap in the generated guidance itself: :443 and :80 are NOT the same problem -- the SNI-passthrough trick this generates for :443 works because a TLS handshake's SNI is visible before decryption, but plain :80 has no SNI at all, so a shared :80 needs a structurally different ordinary proxy_pass block, which is now called out explicitly rather than implied to be covered by the same generated file. RABA_HTTPS_PORT/RABA_HTTP_PORT are now written to .env explicitly (previously never written at all, silently relying on the server's own built-in defaults) so the chosen ports are visible and persisted, not a one-time in-memory decision. bash -n clean. No test suite exists for this script (integration-only, needs a real server to meaningfully exercise) -- verified by full manual read-through end to end instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both had drifted from actual repo state: Phase 9's install-script row still described the old reactive port_in_use check and didn't mention raba update or the idempotency fix; Brand/Assets still showed every row as [ ] despite the logo/wordmark/lockups/favicon/README/BRAND.md/ LICENSE work having landed and been committed earlier this session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes out TODO.md's Landing/Marketing Site section: docs-site/ was still 100% stock Docusaurus (fake "My Site"/facebook.com/docusaurus branding, 6-page fake tutorial, sample "Welcome to Docusaurus" blog posts) despite the 2026-07-10 decision to merge website/'s intended role into it instead of keeping two sites. website/ itself was still just the bare Astro scaffold (<h1>Astro</h1>, nothing real), matching its own "superseded, will be removed" note in TODO.md -- removed now rather than left as dead weight. docusaurus.config.ts: real site identity, SEO meta (description/ keywords via headTags), GitHub Pages as the deploy target (url/baseUrl/ organizationName/projectName), navbar/footer pointing at the real repo instead of facebook/docusaurus, blog disabled entirely (blog: false -- shipping placeholder sample posts to a public site would be worse than no blog at all). src/pages/index.tsx + HomepageFeatures: real hero (brand lockup, pitch, install-command snippet, Get Started/GitHub CTAs) and 6 real feature cards (HTTP/TCP/UDP tunneling, self-hosted single Docker image, teams/custom domains, byte-level forwarding, dashboard, open source), replacing the 3 stock cards and their placeholder undraw.co illustrations. docs/intro.mdx: real "Getting Started" (install-server.sh, install.sh/ .ps1, first tunnel) replacing Docusaurus's entire 6-page fake tutorial tree (tutorial-basics/, tutorial-extras/), which was deleted rather than left live and fake. Full protocol/deployment docs are a separate, larger effort, not attempted here -- this is a landing-site pass, not a docs-content pass. static/img/social-card.png: real og:image generated from the existing brand lockup (ImageMagick), replacing Docusaurus's own stock social card. Removed the other now-unreferenced stock assets (logo.svg, the undraw_*.svg illustrations, docusaurus.png). .github/workflows/deploy-docs.yml (new): builds and publishes docs-site to GitHub Pages on push to master, using GitHub's first-party Pages actions (configure-pages/upload-pages-artifact/deploy-pages) -- no extra secret beyond the built-in GITHUB_TOKEN. Requires a one-time manual step (repo Settings > Pages > source = GitHub Actions) that no workflow file can do on its own. CLAUDE.md: "Landing site" section corrected -- was still describing website/ as the current marketing site; now describes docs-site/'s dual docs+landing role and records the merge/removal. npm run typecheck and npm run build both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Badges: CI status (links to ci.yml's own run history), latest GitHub release, license, and a docs-site link -- standard for an OSS README, gives a visitor at-a-glance signal the project is actually maintained and tested, not just a claim in prose. Opening paragraph rewritten to lead with the actual comparison terms someone would search for (ngrok/Cloudflare Tunnel/frp alternative, self-hosted, HTTP/TCP/UDP) instead of burying them one paragraph down -- same language already used in docs-site's meta description/keywords tags for consistency across both surfaces. Added a Contents section (anchor links) and a short Contributing section (issues/PRs welcome, no formal guide yet -- honest about what actually exists rather than linking a CONTRIBUTING.md that doesn't). Quickstart reordered to lead with the two things most visitors actually want first -- run a server, install the CLI -- ahead of local development setup, which matters to contributors, not users. Project status line was stale (said "CI/CD and a public landing site are the current gaps" -- both shipped since that was written); updated to reflect that every numbered build phase is now done, with the one real remaining gap (linux-arm64 release binaries) called out honestly instead of glossed over. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same root cause as the earlier duplicated-attribute CI failure: CI's dtolnay/rust-toolchain@stable resolves to whatever the latest stable Rust/clippy actually is at run time (1.97 this run), which flags collapsible_if suggestions this repo's local dev toolchain doesn't -- specifically, collapsing nested `if let` into a single `if let ... && let ...` chain, which edition 2024 (already this crate's edition) has supported since let-chains stabilized. 5 sites across 4 files, all pre-existing code from earlier in this branch, none newly introduced by anything in this PR: auth_extractor.rs (two nested ifs collapsed into one three-way chain), project_routes.rs's --domain/team_id mismatch check, team_routes.rs's old-domain project-revocation check, tunnel/listener.rs's UDP datagram dispatch. Purely a style/lint fix -- no behavior change, confirmed by the full test suite still passing unchanged. 143 server tests passing, cargo clippy --all-targets -D warnings clean locally. Root cause (local toolchain being older than CI's) is now confirmed twice -- worth periodically running `rustup update stable` before relying on a clean local clippy pass to mean CI will also be clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Completes raba's build through v1: full dynamic TLS/domain automation, all of Phase 10
hardening, client/dashboard reconciliation, branding + licensing, CI/CD, and a real landing
site. Every numbered build phase (1-10) is now done.
Dynamic TLS & custom domains (docs/plan/dynamic-domains-tls.md)
tls::acme/tls::dns_provider/tls::renewal/tls::resolver—Cloudflare DNS-01 challenges, per-domain SNI cert resolution (
ResolvesServerCert+Arc<CertStore>), periodic renewal background task. Covers both the instance's owndomain and team custom domains via delegated DNS-01 (CNAME into the operator's zone,
never needs the team's DNS credentials).
(changing a team's domain now revokes projects still snapshotted under the old one, in
the same transaction).
verified/cert-status badges) — required a new
GET /api/teams/:id/domainendpoint,since PATCH/verify only ever returned domain state as a side effect of an action.
Phase 10 — Hardening
Rate limiting (
/api/auth/login, per-email fixed-window), connection/stream caps,structured fire-and-forget request logging, graceful shutdown (SIGINT/SIGTERM, bounded
grace period), plain-HTTP
:80redirect listener (open-redirect-safe — validates Hostbefore reflecting it), and traffic stats (
request_logs.duration_ms+GET /api/{projects,teams}/:id/stats, wired into both the dashboard andraba stats).Smarter project creation
team_idis now optional (defaults to the caller's oldest team membership instead oferroring on ambiguity), and a new
--domainflag resolves whichever team owns a domain —scoped to the caller's own teams, global for instance admins (never leaks domain
ownership to an unrelated account). Unverified-domain silent fallback now surfaces a
warningin the create response instead of staying silent.Client/dashboard reconciliation
raba-core/raba-clihad drifted from the real server API since Phase 6 (missingteam_id/subdomainon project creation, no team/domain/stats support at all) —brought current:
list_teams, domain set/verify, project/team stats, newraba team/raba statssubcommands, and a funadverb-adjective-noun-NNrandom subdomain generator(was leaking arbitrary directory names into public subdomains before).
raba update+ install scriptsSelf-update subcommand (checks GitHub's latest release, re-runs
install.shon Unix;prints manual instructions on Windows rather than risking an unverified same-process
binary replacement).
install-server.shreworked: idempotent (won't regenerateJWT_SECRET— and therefore log everyone out — on every re-run), fetches templates fromGitHub instead of a local path that silently broke under
curl | bash, and asks for reallistener ports instead of an abstract "dedicated?" question, correctly distinguishing
:443(SNI-passthrough-able) from:80(needs an ordinaryproxy_passblock instead).CI/CD (Phase 9)
ci.yml(test+clippy,-D warnings, 4 parallel jobs),release.yml(Docker image toGHCR +
raba-clibinaries for 4 platforms + Linux server binary, onv*tag), anddeploy-docs.yml(GitHub Pages). Known gap, documented not shipped-untested:linux-arm64binaries need a Docker-based cross toolchain not verifiable from here.Branding, licensing, landing site
Real logo/wordmark/favicon SVGs (built from
TECH_DOC.md's existing spec), dashboardre-themed from placeholder blue to the brand green via a real CSS token (was hardcoded
utility classes with no relation to the brand at all),
LICENSE(FSL-1.1-ALv2 — free forany use including commercial self-hosting, except reselling as a competing hosted
service), and
docs-site/rebuilt as the real landing site (was 100% stock Docusaurus —fake blog, fake tutorial,
facebook/docusaurusbranding) withwebsite/dropped.Test plan
cargo test+cargo clippy --workspace --all-targets -D warningsclean (server +client)
npx tsc --noEmit/npm run build/npm run lintclean (dashboard, docs-site)npm run devanddocker compose upboth verified workingci.ymlhas run for real on GitHub (caught and fixed one real bug locally-cleanclippy missed — a duplicated attribute only the CI toolchain's newer clippy flagged)
release.yml/deploy-docs.ymlnot yet run for real — no tag pushed yet, and GitHubPages source needs a one-time manual switch to "GitHub Actions" first
session (no JS test framework in this repo yet)
Notes for reviewers
CreateProjectRequest.team_idchanged from required to optional — API-compatible, buta real contract change worth a second look.
--domain's scoping (caller's own teams vs. global for instance admins) is a deliberatesecurity decision, matching
require_team_role's existing admin bypass elsewhere —flagging in case it needs reconsidering.
release.ymlrun, orinstall-server.sh's Docker path breaks for everyone else.