Plan — In Progress

User-Based Local Tunneling

A generic rokojori-ecosystem service at tunnel.rokojori.com that lets authenticated users expose local services to the internet — with access control, metadata, and discovery built in from the start.

What it is

Local tunneling solves one problem: your local machine is running something useful (a local LLM, a Stable Diffusion instance, a language server) but it is only reachable on localhost. The tunnel service makes it reachable over the internet by routing traffic through a relay server.

What makes this different from existing tools (ngrok, localtunnel, pinggy) is identity. Every tunnel is owned by a rokojori user account. Access is controlled per-user — you can keep a tunnel private, share it with specific people, or open it to anyone with the link. Apps in the rokojori ecosystem can discover and use tunnels via a standard API, so a user can point Roject's AI agent at their own local LLM without configuring endpoints manually.

The service lives at tunnel.rokojori.com — a standalone Express app, same stack as the rest of the ecosystem, verified via the shared JWT from account.rokojori.com.

How the Relay Works

The three-leg model

Traffic flows through three legs: browser/app → relay server → local agent → local service.

App (Roject on phone)
  → HTTPS  →  tunnel.rokojori.com/t/<tunnelId>/v1/chat/completions
  → WebSocket envelope  →  Electron agent (on the home machine)
  → HTTP  →  localhost:11434/v1/chat/completions

The relay server is a consensual man-in-the-middle. Both sides connect to it knowingly. The server's job is to forward traffic as transparently as possible — inspect only what is required for routing, pass everything else as raw bytes.

What must be inspected vs. what is forwarded raw

The server must read the HTTP request line (method, path) to strip the /t/<tunnelId> prefix, and must rewrite the Host header. Everything else — body bytes, SSE chunks, binary image data — is piped through opaque without parsing.

The WebSocket connection between the server and the agent requires a minimal envelope to multiplex concurrent requests over one socket:

[4 bytes: requestId] [raw HTTP bytes...]

The agent strips the 4-byte prefix, forwards the remaining bytes to localhost:<port>, and prepends the same requestId to the response bytes before sending back. The server matches the response to the waiting HTTP connection by requestId and flushes it.

Streaming (SSE / chunked responses)

Local LLMs stream tokens via Server-Sent Events or chunked transfer encoding. The agent forwards response bytes as they arrive — the relay server keeps the HTTP response to the app open and flushes each chunk immediately. No buffering, no full-response collection needed.

This means the app receives the stream in real time, exactly as it would if it were calling the local service directly.

WebSocket-based services — out of scope for v1

The primary target services (Stable Diffusion AUTOMATIC1111, Ollama, LM Studio, OpenAI-compatible APIs) all speak HTTP with optional SSE streaming. None require WebSocket from the app side. Language servers (C# OmniSharp, Godot LSP) use raw TCP — a separate and harder problem, not planned for v1.

Access Modes

Private

Only authenticated rokojori users in the tunnel's allowedUserIds list (plus the owner) can send requests through it. The relay server verifies the JWT on every inbound request. This is the default for new tunnels.

Public (link-based)

Anyone with the tunnel URL can send requests — no authentication required. Equivalent to what ngrok and localtunnel provide out of the box. Useful for quick demos or sharing with clients who have no rokojori account.

Password-protected (v2)

A single shared secret is required in an X-Tunnel-Key header or as a query parameter. Simpler than full account management for trusted-but-anonymous recipients. Not planned for v1.

Tunnel Metadata

Each tunnel carries metadata so that apps and users can discover, filter, and select tunnels without prior knowledge of what is running behind them.

{
  "id": "uuid",
  "name": "Josef's Ollama",
  "description": "Local Ollama instance, llama3.2 and mistral available",
  "purpose": "llm-openai-compatible",
  "ownerId": "uuid",
  "access": "private",
  "allowedUserIds": ["uuid-alice", "uuid-bob"],
  "localPort": 11434,
  "active": true,
  "createdAt": "2026-07-16T00:00:00Z"
}

Purpose tags

The purpose field is a well-known tag that apps use to filter relevant tunnels. Defined as a fixed set in the service:

  • llm-openai-compatible — drop-in replacement for the OpenAI /v1/chat/completions endpoint; works with any OpenAI-compatible API (Ollama, LM Studio, etc.)
  • stable-diffusion — AUTOMATIC1111 or ComfyUI image generation
  • general — generic HTTP relay, no specific integration contract

More purpose tags are added as concrete integrations are built.

Active vs. registered

A tunnel can be registered (config saved, metadata available for discovery) while the agent is offline. The active flag reflects whether the agent WebSocket is currently connected. Apps should check this before attempting to send requests through a tunnel.

Components

1 — tunnel.rokojori.com (relay server)

A standalone Express + Node.js service. Owns tunnel configs (JSON file storage), the WebSocket server that agents connect to, and the HTTP proxy routes that apps call.

  • Verifies JWTs from account.rokojori.com (shared JWT_SECRET)
  • Checks tunnel:create permission before allowing tunnel registration
  • Maintains an in-memory map of active agent WebSocket connections
  • Proxies inbound HTTP requests to the correct agent, streams responses back
  • Stores tunnel configs in build/data/db/tunnels.json
Node.js Express ts-node ws (WebSocket) tunnel.rokojori.com

2 — Electron agent app (local machine)

A small Electron desktop app that runs in the system tray. The user logs in once via account.rokojori.com (same direct API call pattern as the Roject Electron app). Tokens are stored in userData/tokens.json.

The user configures one or more tunnels — each with a name, purpose tag, description, and local port. The app opens a persistent WebSocket to wss://tunnel.rokojori.com/api/agent/:tunnelId for each active tunnel and forwards inbound byte envelopes to the local port.

  • System tray icon — green dot when at least one tunnel is active
  • Main window: list of configured tunnels with active/inactive status
  • Add tunnel: name, purpose, description, local port, access mode, allowed users
  • Auto-reconnect on disconnect with exponential backoff
  • Start on boot option
Electron system tray WebSocket agent

3 — rokojori-auth (permission only)

No tunnel logic lives in rokojori-auth. The only addition is a tunnel:create permission added to the roles map. Users without this permission are rejected by the relay server when attempting to register a tunnel.

API Endpoints — tunnel.rokojori.com

Tunnel management (authenticated)

POST   /api/tunnels                    — register a new tunnel config (requires tunnel:create)
GET    /api/tunnels                    — list tunnels owned by the requesting user
GET    /api/tunnels/available          — list tunnels the user has access to (owned + allowed)
GET    /api/tunnels/available?purpose= — same, filtered by purpose tag
GET    /api/tunnels/:id                — get one tunnel's metadata
PATCH  /api/tunnels/:id                — update name, description, access, allowedUserIds
DELETE /api/tunnels/:id                — remove tunnel config (owner only)

Agent connection (authenticated, WebSocket upgrade)

GET    /api/agent/:tunnelId            — WebSocket; agent connects here, stays open

On connection the server verifies the JWT, confirms the connecting user owns the tunnel, and registers the socket in the in-memory map. On disconnect the tunnel is marked inactive.

Proxy route (access mode enforced)

ALL    /t/:tunnelId/*                  — relay any HTTP method to the agent

For private tunnels the JWT is verified and the requesting user must be the owner or appear in allowedUserIds. For public tunnels no auth is required. If the tunnel is registered but the agent is offline, the server returns 503 Service Unavailable.

Integration in Apps — Roject Example

The pattern

Any app in the rokojori ecosystem can query the tunnel service for tunnels available to the current user, filtered by purpose. The app then uses the tunnel URL as a standard HTTP endpoint — no special tunnel SDK required on the app side.

LLM provider selection

When a user configures an AI agent or task in Roject, they choose an LLM provider. Providers come in two kinds:

  • External — Anthropic, OpenAI, or any OpenAI-compatible endpoint with an API key and base URL configured manually.
  • Tunneled — a local service exposed via tunnel.rokojori.com. The user clicks "Browse tunnels", Roject fetches GET /api/tunnels/available?purpose=llm-openai-compatible and shows a picker with each tunnel's name, description, owner, and active status.

Once selected, Roject stores the tunnelId and constructs the endpoint at runtime:

https://tunnel.rokojori.com/t/<tunnelId>/v1/chat/completions

From Roject's perspective this is a standard OpenAI-compatible HTTP endpoint. Streaming works identically — SSE chunks flow from the local LLM through the relay to the Roject UI in real time. No code in Roject knows or cares that a tunnel is involved.

Family / team sharing

If a tunnel's allowedUserIds includes other rokojori accounts, those users see the tunnel in their /api/tunnels/available response too — even though they do not own it. A household can share one Stable Diffusion machine; a small team can share one GPU server. The owner adds user IDs via the Electron app or a future web UI on tunnel.rokojori.com.

File Structure — rokojori-tunnel

C:\rokojori\projects\web-projects\tunnel\
  source/
    server/
      routes/
        tunnels.ts           — CRUD for tunnel configs
        proxy.ts             — ALL /t/:tunnelId/* relay handler
        agent.ts             — WebSocket upgrade endpoint for agents
      middleware/
        requireAuth.ts       — JWT verification (shared pattern)
      relay/
        TunnelRegistry.ts    — in-memory Map<tunnelId, AgentSocket>
      db.ts                  — tunnel config storage (JSON files)
      index.ts               — Express entry point
  electron-agent/
    main.ts                  — Electron main process, tray setup
    login-window.ts          — direct API login, token storage
    tray.ts                  — system tray icon and menu
    agent/
      TunnelAgent.ts         — WebSocket connection + HTTP forwarding loop
      AgentConfig.ts         — local tunnel config (name, port, tunnelId)
  build/
    data/
      db/
        tunnels.json         — registered tunnel configs
  package.json
  tsconfig.json
  tsconfig.ts-node.json
  .env

Environment Variables

JWT_SECRET=...          — same shared value as all other rokojori services
PORT=3002               — or whichever port nginx proxies to

Phased Implementation

Phase 1 — single user, HTTP only — Complete

  • Relay server built at C:\rokojori\projects\web-projects\tunnel
  • Tunnel CRUD API, WebSocket agent endpoint, HTTP proxy route
  • Standalone test agent script (scripts/test-agent.ts)
  • Tested end-to-end: request relayed through tunnel to local gemma4-coding LLM on port 8900 and response returned correctly

Phase 2 — multi-user and discovery — Next

  • Allowed users list on tunnel config
  • GET /api/tunnels/available endpoint with purpose filtering
  • Public access mode
  • Roject LLM provider picker integrating the discovery API

Phase 3 — polish

  • Electron app: multiple tunnel configs, start on boot, tray status per tunnel
  • Web UI at tunnel.rokojori.com for managing tunnels without the desktop app
  • Per-tunnel request logs (count, last active timestamp)
  • Password-protected access mode