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.