Skip to content

Feat/dynamic domains tls#2

Merged
codad5 merged 38 commits into
masterfrom
feat/dynamic-domains-tls
Jul 13, 2026
Merged

Feat/dynamic domains tls#2
codad5 merged 38 commits into
masterfrom
feat/dynamic-domains-tls

Conversation

@codad5

@codad5 codad5 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

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)

  • Full ACME automation: 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 own
    domain and team custom domains via delegated DNS-01 (CNAME into the operator's zone,
    never needs the team's DNS credentials).
  • Team custom-domain API: set/verify a domain, closes a domain-hijack-by-reclaim gap
    (changing a team's domain now revokes projects still snapshotted under the old one, in
    the same transaction).
  • Dashboard gained the custom-domain UI it never had (view/set/verify, DNS instructions,
    verified/cert-status badges) — required a new GET /api/teams/:id/domain endpoint,
    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 :80 redirect listener (open-redirect-safe — validates Host
before reflecting it), and traffic stats (request_logs.duration_ms +
GET /api/{projects,teams}/:id/stats, wired into both the dashboard and raba stats).

Smarter project creation

team_id is now optional (defaults to the caller's oldest team membership instead of
erroring on ambiguity), and a new --domain flag 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
warning in the create response instead of staying silent.

Client/dashboard reconciliation

raba-core/raba-cli had drifted from the real server API since Phase 6 (missing
team_id/subdomain on project creation, no team/domain/stats support at all) —
brought current: list_teams, domain set/verify, project/team stats, new raba team/
raba stats subcommands, and a fun adverb-adjective-noun-NN random subdomain generator
(was leaking arbitrary directory names into public subdomains before).

raba update + install scripts

Self-update subcommand (checks GitHub's latest release, re-runs install.sh on Unix;
prints manual instructions on Windows rather than risking an unverified same-process
binary replacement). install-server.sh reworked: idempotent (won't regenerate
JWT_SECRET — and therefore log everyone out — on every re-run), fetches templates from
GitHub instead of a local path that silently broke under curl | bash, and asks for real
listener ports instead of an abstract "dedicated?" question, correctly distinguishing
:443 (SNI-passthrough-able) from :80 (needs an ordinary proxy_pass block instead).

CI/CD (Phase 9)

ci.yml (test+clippy, -D warnings, 4 parallel jobs), release.yml (Docker image to
GHCR + raba-cli binaries for 4 platforms + Linux server binary, on v* tag), and
deploy-docs.yml (GitHub Pages). Known gap, documented not shipped-untested:
linux-arm64 binaries 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), dashboard
re-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 for
any 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/docusaurus branding) with website/ dropped.

Test plan

  • cargo test + cargo clippy --workspace --all-targets -D warnings clean (server +
    client)
  • npx tsc --noEmit / npm run build / npm run lint clean (dashboard, docs-site)
  • Manual end-to-end: npm run dev and docker compose up both verified working
  • ci.yml has run for real on GitHub (caught and fixed one real bug locally-clean
    clippy missed — a duplicated attribute only the CI toolchain's newer clippy flagged)
  • release.yml/deploy-docs.yml not yet run for real — no tag pushed yet, and GitHub
    Pages source needs a one-time manual switch to "GitHub Actions" first
  • Dashboard custom-domain UI verified by build/typecheck only, not a real browser
    session (no JS test framework in this repo yet)

Notes for reviewers

  • CreateProjectRequest.team_id changed from required to optional — API-compatible, but
    a real contract change worth a second look.
  • --domain's scoping (caller's own teams vs. global for instance admins) is a deliberate
    security decision, matching require_team_role's existing admin bypass elsewhere —
    flagging in case it needs reconsidering.
  • Before merging: GHCR package visibility needs to be set to Public after the first
    release.yml run, or install-server.sh's Docker path breaks for everyone else.

codad5 and others added 30 commits July 11, 2026 16:00
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>
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>
codad5 and others added 8 commits July 12, 2026 20:50
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>
@codad5
codad5 merged commit 9b45cc5 into master Jul 13, 2026
4 checks passed
@codad5
codad5 deleted the feat/dynamic-domains-tls branch July 13, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant