Skip to main content

Architecture

rs-tunnel is designed as a self-contained monorepo with clear separation between user-facing CLI, backend API, and shared contracts. This page covers the project structure, technology choices, component interactions, and security model.

Monorepo Structure

The project uses a pnpm workspace with the following layout:
rs-tunnel/
├── apps/
│   ├── api/          # Backend API (Fastify + Postgres)
│   └── cli/          # User CLI (Commander)
├── packages/
│   ├── shared/       # Zod contracts & types
│   └── config/       # Shared tooling config
└── .github/
    └── workflows/    # CI/CD pipelines

apps/api

The backend API is the source of truth for identity, authorization, Cloudflare tunnel lifecycle, and DNS management. Key responsibilities:
  • Slack OAuth authentication flow
  • JWT access token + refresh token issuance
  • Cloudflare tunnel creation/deletion
  • DNS record provisioning
  • Heartbeat tracking and lease management
  • Tunnel quota enforcement
  • Audit logging
  • Background cleanup worker
Tech stack:
  • Runtime: Node.js 20+ with TypeScript
  • Web framework: Fastify 5.x
  • Database: Postgres with Drizzle ORM
  • Validation: Zod schemas from @ripeseed/shared
  • Auth: JWT (jsonwebtoken), Slack OpenID Connect
  • Infrastructure: Cloudflare API for tunnels + DNS
Directory structure:
apps/api/src/
├── config/          # Environment config
├── db/              # Drizzle schema, repository, migrations
├── lib/             # Logger, error handling
├── routes/          # Fastify route handlers
├── services/        # Business logic (tunnel, auth, cloudflare, cleanup)
├── utils/           # Helpers (slug, lease, time)
├── workers/         # Reaper background worker
└── index.ts         # Server entrypoint

apps/cli

The user-facing CLI provides commands for authentication and tunnel management. Commands:
  • login - Slack OAuth with PKCE flow
  • up - Start tunnel with ngrok-style dashboard
  • list - Show active tunnels
  • stop - Stop tunnel by ID or hostname
  • logout - Revoke tokens
  • doctor - Connectivity diagnostics
Tech stack:
  • Runtime: Node.js 20+ with TypeScript
  • CLI framework: Commander 13.x
  • HTTP client: Native fetch
  • Secure storage: keytar (optional, file fallback)
  • Validation: Zod schemas from @ripeseed/shared
  • Cloudflare tunnel: Spawns cloudflared process
Key features:
  • OAuth PKCE flow with local callback server
  • Token refresh logic
  • Heartbeat loop every 20 seconds
  • Local reverse proxy for request telemetry
  • Real-time dashboard rendering
  • --domain flag for self-hosted API configuration

packages/shared

Provides type-safe contracts between API and CLI using Zod schemas. Exports:
// Request/response schemas
tunnelCreateRequestSchema
tunnelCreateResponseSchema
authStartRequestSchema
heartbeatResponseSchema
tunnelListResponseSchema
// ... and more

// Types inferred from schemas
type TunnelCreateRequest = z.infer<typeof tunnelCreateRequestSchema>;
type TunnelSummary = z.infer<typeof tunnelSummarySchema>;
// ... and more
Benefits:
  • Single source of truth for API contracts
  • Compile-time type safety
  • Runtime validation with Zod
  • Prevents API/CLI drift

packages/config

Shared configuration for:
  • ESLint rules
  • Prettier formatting
  • TypeScript tsconfig.json base
All packages use ESM with .js import suffixes, strict TypeScript mode, and consistent code style.

Database Schema

The API uses Postgres with the following core tables:

users

Stores authenticated users:
{
  id: uuid,
  email: string,           // Validated against ALLOWED_EMAIL_DOMAIN
  slackUserId: string,
  slackTeamId: string,     // Validated against ALLOWED_SLACK_TEAM_ID
  status: 'active',
  createdAt: timestamp,
  updatedAt: timestamp
}

tunnels

Tracks tunnel lifecycle:
{
  id: uuid,
  userId: uuid,
  slug: string,            // 1-32 chars, alphanumeric + hyphens
  hostname: string,        // slug.CLOUDFLARE_BASE_DOMAIN
  requestedPort: number,   // Local port to forward to
  cfTunnelId: string,      // Cloudflare tunnel ID
  cfDnsRecordId: string,   // Cloudflare DNS record ID
  status: 'creating' | 'active' | 'stopping' | 'stopped' | 'failed',
  lastError: string,
  createdAt: timestamp,
  updatedAt: timestamp,
  stoppedAt: timestamp
}

tunnel_leases

Tracks heartbeat state:
{
  id: uuid,
  tunnelId: uuid,
  lastHeartbeatAt: timestamp,
  expiresAt: timestamp     // lastHeartbeatAt + LEASE_TIMEOUT_SEC
}

cleanup_jobs

Queues tunnels for deferred cleanup:
{
  id: uuid,
  tunnelId: uuid,
  reason: 'stale_lease' | 'active_connections' | 'deletion_failed',
  status: 'queued' | 'processing' | 'done' | 'failed',
  attemptCount: number,
  nextAttemptAt: timestamp,
  lastError: string
}

Additional tables

  • oauth_sessions: Tracks OAuth state and login codes
  • refresh_tokens: Stores hashed refresh tokens
  • audit_logs: Records tunnel.created and tunnel.stopped events
  • tunnel_metrics: Historical connection/request metrics
  • tunnel_requests: Individual HTTP request logs
  • tunnel_live_metrics: Latest metrics for active tunnels
See apps/api/src/db/schema.ts for the complete Drizzle schema definition.

Component Interaction

Here’s how the components work together during common operations:

Tunnel Creation Flow

1

CLI sends create request

POST /v1/tunnels
Authorization: Bearer <jwt>
{ "port": 3000, "requestedSlug": "my-app" }
2

API validates and enforces quota

  • Validates JWT and extracts userId
  • Checks active tunnel count < MAX_ACTIVE_TUNNELS
  • Validates or generates slug
  • Ensures slug is not already in use
3

API creates DB tunnel record

  • Insert tunnel with status creating
  • Transaction ensures atomicity
4

API provisions Cloudflare resources

// 1. Create Cloudflare tunnel
const tunnel = await cloudflare.createTunnel('rs-my-app-1234567890');

// 2. Configure ingress rules
await cloudflare.configureTunnel({
  tunnelId: tunnel.id,
  hostname: 'my-app.tunnel.company.com',
  port: 3000
});

// 3. Create DNS CNAME record
const dnsRecordId = await cloudflare.createDnsRecord(
  'my-app.tunnel.company.com',
  tunnel.id
);

// 4. Get tunnel token for cloudflared
const token = await cloudflare.getTunnelToken(tunnel.id);
5

API updates tunnel to active

  • Update tunnel with cfTunnelId, cfDnsRecordId, status active
  • Create initial lease entry
  • Log tunnel.created audit event
6

API returns tunnel details

{
  "tunnelId": "uuid",
  "hostname": "my-app.tunnel.company.com",
  "cloudflaredToken": "encrypted-token",
  "heartbeatIntervalSec": 20
}
7

CLI spawns cloudflared

cloudflared tunnel run \
  --token <encrypted-token> \
  --url http://localhost:3000
If any Cloudflare operation fails, the API performs rollback cleanup and marks the tunnel as failed.

Heartbeat Flow

1

CLI sends heartbeat

Every 20 seconds:
POST /v1/tunnels/:tunnelId/heartbeat
Authorization: Bearer <jwt>
2

API validates tunnel ownership

  • Verify JWT userId matches tunnel userId
  • Ensure tunnel is in active or stopping state
3

API updates lease

UPDATE tunnel_leases SET
  lastHeartbeatAt = NOW(),
  expiresAt = NOW() + INTERVAL '60 seconds'
WHERE tunnelId = $1
4

API returns expiry

{ "expiresAt": "2024-03-05T12:34:56.789Z" }

Stale Tunnel Cleanup

1

Reaper worker sweeps leases

Runs every REAPER_INTERVAL_SEC (default: 30s):
SELECT tunnelId FROM tunnel_leases
WHERE expiresAt < NOW()
2

Reaper enqueues cleanup jobs

INSERT INTO cleanup_jobs (tunnelId, reason, status)
VALUES ($1, 'stale_lease', 'queued')
3

Reaper processes queue

Claims up to 25 jobs and calls tunnelService.stopTunnelById()
4

Cleanup attempts deletion

  • Delete DNS record
  • Delete Cloudflare tunnel (with retry logic)
  • If tunnel has active connections, re-queue with backoff
  • If deletion succeeds, mark tunnel stopped and job done
Cleanup jobs use exponential backoff: 30s, 60s, 120s, 240s, etc. Jobs are retried indefinitely until successful.

Security Model

Principle: Least Privilege

The security model is designed around strict separation of concerns:

API Runtime

Has access to:
  • CLOUDFLARE_API_TOKEN
  • SLACK_CLIENT_SECRET
  • JWT_SECRET
  • REFRESH_TOKEN_SECRET
  • Database credentials
Responsibilities:
  • Provision infrastructure
  • Issue time-limited credentials
  • Enforce access policies

CLI Runtime

Has access to:
  • User’s JWT access token (15 min TTL)
  • User’s refresh token (30 day TTL)
  • Tunnel-specific cloudflared token
Cannot:
  • Create tunnels for other users
  • Access Cloudflare API directly
  • Bypass email/Slack validation

Authentication & Authorization

Authentication flow:
  1. CLI initiates OAuth with PKCE challenge
  2. User authenticates via Slack OpenID Connect
  3. API validates:
    • Email ends with ALLOWED_EMAIL_DOMAIN
    • Slack team ID matches ALLOWED_SLACK_TEAM_ID
  4. API issues JWT access token + refresh token
  5. CLI uses access token for all API requests
Token security:
  • Access tokens: 15-minute TTL, signed with JWT_SECRET
  • Refresh tokens: 30-day TTL, hashed in database, signed with REFRESH_TOKEN_SECRET
  • CLI automatically refreshes expired access tokens
  • Tokens revoked on logout
Cloudflared tokens:
  • Tunnel-specific: Only work for the assigned tunnel ID
  • Short-lived: Issued per tunnel session
  • Never logged or exposed in API responses to unrelated users
Never commit secrets to version control. Use environment variables for all sensitive configuration.

Network Security

  • TLS everywhere: All tunnel traffic goes through Cloudflare’s edge with automatic SSL
  • No exposed localhost: CLI’s local proxy is bound to 127.0.0.1 only
  • Rate limiting: Fastify rate-limit plugin on all API routes
  • CORS: Configured for CLI and browser OAuth flows only

Cloudflare Token Scopes

The CLOUDFLARE_API_TOKEN should have minimum required permissions:
Permissions:
- Account > Cloudflare Tunnel > Edit
- Zone > DNS > Edit

Zone Resources:
- Include > Specific zone > <your-zone>
Use Cloudflare API token templates to ensure least-privilege scopes.

Configuration Management

API Configuration

Loaded from environment variables (see apps/api/src/config/env.ts):
// Core
API_BASE_URL=https://api.company.com
PORT=8080
DATABASE_URL=postgresql://...
JWT_SECRET=...
REFRESH_TOKEN_SECRET=...

// Access policy
ALLOWED_EMAIL_DOMAIN=@company.com
ALLOWED_SLACK_TEAM_ID=T01234567

// Slack OAuth
SLACK_CLIENT_ID=...
SLACK_CLIENT_SECRET=...
SLACK_REDIRECT_URI=https://api.company.com/v1/auth/slack/callback

// Cloudflare
CLOUDFLARE_ACCOUNT_ID=...
CLOUDFLARE_ZONE_ID=...
CLOUDFLARE_API_TOKEN=...
CLOUDFLARE_BASE_DOMAIN=tunnel.company.com

// Behavior
MAX_ACTIVE_TUNNELS=5
HEARTBEAT_INTERVAL_SEC=20
LEASE_TIMEOUT_SEC=60
REAPER_INTERVAL_SEC=30

CLI Configuration

Stores minimal state in ~/.rs-tunnel/:
~/.rs-tunnel/
├── config.json      # API domain preference
└── tokens.json      # Access + refresh tokens (if keytar unavailable)
Domain precedence:
  1. --domain flag (saves to config.json)
  2. RS_TUNNEL_API_URL environment variable
  3. Saved value in config.json
  4. Interactive prompt (first run)
CLI prefers keytar for secure token storage when available. Falls back to file storage with restrictive permissions (0600).

Deployment Architecture

Typical Production Setup

┌─────────────────┐
│   End Users     │
└────────┬────────┘
         │ HTTPS

┌─────────────────┐
│  Cloudflare     │
│  Edge Network   │
└────────┬────────┘

         ├─► Tunnel 1 ──► Developer's localhost:3000
         ├─► Tunnel 2 ──► Developer's localhost:8080
         └─► Tunnel N ──► Developer's localhost:5000

┌─────────────────┐         ┌─────────────────┐
│   Developers    │         │   API Server    │
│   (CLI)         │◄───────►│   (Fastify)     │
└─────────────────┘  HTTPS  └────────┬────────┘

                             ┌────────┼────────┐
                             │        │        │
                             ▼        ▼        ▼
                      ┌──────────┬─────────┬──────────┐
                      │ Postgres │ Slack   │ Cloudflare
                      │          │ OAuth   │ API      │
                      └──────────┴─────────┴──────────┘

Scaling Considerations

  • API: Stateless design allows horizontal scaling behind a load balancer
  • Database: Connection pooling via Drizzle, indexes on hot paths
  • Reaper worker: Single instance recommended to avoid duplicate cleanup jobs
  • Cloudflare tunnels: No practical limit on concurrent tunnels
For high-availability deployments, run multiple API instances with a shared Postgres database and a single reaper worker instance.

Technology Decisions

Why Fastify?

  • Native async/await support
  • Plugin ecosystem (CORS, rate limiting)
  • Excellent TypeScript support
  • High performance

Why Drizzle ORM?

  • Type-safe query builder
  • Zero runtime overhead
  • Migration system
  • Raw SQL escape hatch when needed

Why Postgres?

  • JSONB for flexible audit logs
  • Reliable transactions for tunnel lifecycle
  • Excellent indexing for lease expiry queries
  • Battle-tested reliability

Why Zod?

  • Runtime validation + TypeScript types from single source
  • Composable schemas
  • Clear error messages
  • Works in both Node.js and browser

Why Commander?

  • De facto standard for Node.js CLIs
  • Subcommand support
  • Automatic help generation
  • Option parsing

Next Steps

How It Works

Deep dive into tunnel creation, heartbeats, and cleanup mechanisms

Installation

Set up rs-tunnel for local development or production

API Reference

Complete REST API documentation

Database Schema

Detailed schema reference and migration guide

Build docs developers (and LLMs) love