Skip to main content

Architecture

A technical look at how raba is actually built, for readers who want more than "it's a tunnel." (This is a public-facing summary — the full internal spec this is drawn from covers implementation-level detail this page deliberately leaves out.)

Monolithic, not microservices​

One Rust binary, one Docker image, SQLite for storage. TLS termination, subdomain routing, and static asset serving all happen inside that one binary — there's no nginx or reverse proxy in front by default (though nothing stops you from putting one there if you're sharing a host's :443 with other services — see the shared-VPS deployment notes in the repo). Internally, it's modular and independently testable; externally, it's one thing to deploy and keep running.

The tunnel protocol​

Server and Client (the CLI, on your machine) talk over a single persistent, TLS-wrapped TCP connection — not WebSocket, not a second HTTP layer, a custom binary frame protocol designed for exactly this job. Every tunneled connection (an HTTP request, a TCP session, a UDP pseudo-session) becomes a logical stream multiplexed over that one connection: a StreamOpen frame announces it, StreamData frames carry the bytes in both directions, and StreamClose ends it. A stream_id tags every frame so many concurrent tunneled connections can share the one underlying TCP connection without interfering with each other.

This is also where byte-level forwarding comes from structurally: the frame protocol carries opaque payload bytes: the server's job is moving StreamData frames between the visitor's connection and the Client's connection, not understanding what's inside them.

Three separate auth systems​

Deliberately three, each scoped to one actor, none interchangeable:

CredentialWho uses itScopeLifetime
JWT session cookieBrowser (dashboard)Dashboard onlyShort-lived
Personal Access Token (PAT)CLIManagement API only (/api/projects, etc.)Long-lived, revocable
Project secretClient (tunnel connection)Tunnel handshake onlyLong-lived

A PAT can never authenticate a tunnel handshake; a project secret can never call the management API. All three are CSPRNG-generated and stored only as a hash — shown to you in plaintext exactly once, at creation time.

TLS and domain resolution​

Certificates are resolved per-connection by SNI (the hostname the client's TLS handshake asks for), against an in-memory store kept in sync with what's on disk and in the database. Both the instance's own domain and every team's verified custom domain go through the same ACME DNS-01 issuance and renewal path — one code path regardless of who owns the domain, not two parallel mechanisms. See Teams and custom domains for how the delegated-DNS model avoids ever needing a team's DNS provider credentials.

Multi-tenancy​

Teams, not individual users, own projects and domains. Any member of a team can act on that team's projects, gated by a per-team role tier (member/admin/owner) — not just whoever happened to create a given project. Every account gets a personal team automatically on signup, so there's always at least one team to work in without any setup.

Instance-wide admin authority (managing every team's projects, instance settings, user accounts) is derived from this same team system rather than being a second, parallel permission model: one team is flagged as the instance team (the first signup's personal team, by default — enforced at the database level so only one team can ever hold that flag), and owner/admin-tier membership of it is instance-wide admin access. See Teams and custom domains for how that's actually granted.

Storage​

SQLite, one file, embedded — no separate database service to run or configure. Chosen deliberately for a self-hosted single-binary deployment: one less moving part, one less thing that can be misconfigured or need its own backup story separate from the rest of the instance's data.