436 lines
18 KiB
HTML
436 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>User-Based Local Tunneling — Roject</title>
|
|
<link rel="stylesheet" href="../_assets_/styles.css">
|
|
<link rel="stylesheet" href="../_assets_/nav.css">
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
|
|
<header>
|
|
<p class="date">Plan — In Progress</p>
|
|
<h1>User-Based Local Tunneling</h1>
|
|
<p class="subtitle">
|
|
A generic rokojori-ecosystem service at <code>tunnel.rokojori.com</code> that lets
|
|
authenticated users expose local services to the internet — with access control,
|
|
metadata, and discovery built in from the start.
|
|
</p>
|
|
</header>
|
|
|
|
<section>
|
|
<h2>What it is</h2>
|
|
|
|
<div class="card">
|
|
<p>
|
|
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 <code>localhost</code>. The tunnel service makes it reachable over
|
|
the internet by routing traffic through a relay server.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
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.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
The service lives at <code>tunnel.rokojori.com</code> — a standalone Express app,
|
|
same stack as the rest of the ecosystem, verified via the shared JWT from
|
|
<code>account.rokojori.com</code>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>How the Relay Works</h2>
|
|
|
|
<div class="card">
|
|
<h3>The three-leg model</h3>
|
|
<p>
|
|
Traffic flows through three legs: browser/app → relay server → local agent → local service.
|
|
</p>
|
|
<pre style="margin-top:0.75rem"><code>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</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>What must be inspected vs. what is forwarded raw</h3>
|
|
<p>
|
|
The server must read the HTTP request line (method, path) to strip the
|
|
<code>/t/<tunnelId></code> prefix, and must rewrite the <code>Host</code>
|
|
header. Everything else — body bytes, SSE chunks, binary image data — is piped
|
|
through opaque without parsing.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
The WebSocket connection between the server and the agent requires a minimal
|
|
envelope to multiplex concurrent requests over one socket:
|
|
</p>
|
|
<pre style="margin-top:0.75rem"><code>[4 bytes: requestId] [raw HTTP bytes...]</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
The agent strips the 4-byte prefix, forwards the remaining bytes to
|
|
<code>localhost:<port></code>, 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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Streaming (SSE / chunked responses)</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
This means the app receives the stream in real time, exactly as it would if it
|
|
were calling the local service directly.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>WebSocket-based services — out of scope for v1</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Access Modes</h2>
|
|
|
|
<div class="card">
|
|
<h3>Private</h3>
|
|
<p>
|
|
Only authenticated rokojori users in the tunnel's <code>allowedUserIds</code>
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Public (link-based)</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Password-protected (v2)</h3>
|
|
<p>
|
|
A single shared secret is required in an <code>X-Tunnel-Key</code> header or as
|
|
a query parameter. Simpler than full account management for trusted-but-anonymous
|
|
recipients. Not planned for v1.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Tunnel Metadata</h2>
|
|
|
|
<div class="card">
|
|
<p>
|
|
Each tunnel carries metadata so that apps and users can discover, filter, and
|
|
select tunnels without prior knowledge of what is running behind them.
|
|
</p>
|
|
<pre style="margin-top:0.75rem"><code>{
|
|
"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"
|
|
}</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Purpose tags</h3>
|
|
<p>
|
|
The <code>purpose</code> field is a well-known tag that apps use to filter
|
|
relevant tunnels. Defined as a fixed set in the service:
|
|
</p>
|
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
|
<li><code>llm-openai-compatible</code> — drop-in replacement for the OpenAI
|
|
<code>/v1/chat/completions</code> endpoint; works with any OpenAI-compatible API
|
|
(Ollama, LM Studio, etc.)</li>
|
|
<li><code>stable-diffusion</code> — AUTOMATIC1111 or ComfyUI image generation</li>
|
|
<li><code>general</code> — generic HTTP relay, no specific integration contract</li>
|
|
</ul>
|
|
<p style="margin-top:0.75rem">
|
|
More purpose tags are added as concrete integrations are built.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Active vs. registered</h3>
|
|
<p>
|
|
A tunnel can be registered (config saved, metadata available for discovery) while
|
|
the agent is offline. The <code>active</code> flag reflects whether the agent
|
|
WebSocket is currently connected. Apps should check this before attempting to
|
|
send requests through a tunnel.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Components</h2>
|
|
|
|
<div class="card">
|
|
<h3>1 — tunnel.rokojori.com (relay server)</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
|
<li>Verifies JWTs from <code>account.rokojori.com</code> (shared <code>JWT_SECRET</code>)</li>
|
|
<li>Checks <code>tunnel:create</code> permission before allowing tunnel registration</li>
|
|
<li>Maintains an in-memory map of active agent WebSocket connections</li>
|
|
<li>Proxies inbound HTTP requests to the correct agent, streams responses back</li>
|
|
<li>Stores tunnel configs in <code>build/data/db/tunnels.json</code></li>
|
|
</ul>
|
|
<div class="tags">
|
|
<span class="tag">Node.js</span>
|
|
<span class="tag">Express</span>
|
|
<span class="tag">ts-node</span>
|
|
<span class="tag">ws (WebSocket)</span>
|
|
<span class="tag">tunnel.rokojori.com</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>2 — Electron agent app (local machine)</h3>
|
|
<p>
|
|
A small Electron desktop app that runs in the system tray. The user logs in once
|
|
via <code>account.rokojori.com</code> (same direct API call pattern as the Roject
|
|
Electron app). Tokens are stored in <code>userData/tokens.json</code>.
|
|
</p>
|
|
<p style="margin-top:0.75rem">
|
|
The user configures one or more tunnels — each with a name, purpose tag,
|
|
description, and local port. The app opens a persistent WebSocket to
|
|
<code>wss://tunnel.rokojori.com/api/agent/:tunnelId</code> for each active tunnel
|
|
and forwards inbound byte envelopes to the local port.
|
|
</p>
|
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
|
<li>System tray icon — green dot when at least one tunnel is active</li>
|
|
<li>Main window: list of configured tunnels with active/inactive status</li>
|
|
<li>Add tunnel: name, purpose, description, local port, access mode, allowed users</li>
|
|
<li>Auto-reconnect on disconnect with exponential backoff</li>
|
|
<li>Start on boot option</li>
|
|
</ul>
|
|
<div class="tags">
|
|
<span class="tag">Electron</span>
|
|
<span class="tag">system tray</span>
|
|
<span class="tag">WebSocket agent</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>3 — rokojori-auth (permission only)</h3>
|
|
<p>
|
|
No tunnel logic lives in <code>rokojori-auth</code>. The only addition is a
|
|
<code>tunnel:create</code> permission added to the roles map. Users without this
|
|
permission are rejected by the relay server when attempting to register a tunnel.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>API Endpoints — tunnel.rokojori.com</h2>
|
|
|
|
<div class="card">
|
|
<h3>Tunnel management (authenticated)</h3>
|
|
<pre><code>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)</code></pre>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Agent connection (authenticated, WebSocket upgrade)</h3>
|
|
<pre><code>GET /api/agent/:tunnelId — WebSocket; agent connects here, stays open</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Proxy route (access mode enforced)</h3>
|
|
<pre><code>ALL /t/:tunnelId/* — relay any HTTP method to the agent</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
For private tunnels the JWT is verified and the requesting user must be the owner
|
|
or appear in <code>allowedUserIds</code>. For public tunnels no auth is required.
|
|
If the tunnel is registered but the agent is offline, the server returns
|
|
<code>503 Service Unavailable</code>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Integration in Apps — Roject Example</h2>
|
|
|
|
<div class="card">
|
|
<h3>The pattern</h3>
|
|
<p>
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>LLM provider selection</h3>
|
|
<p>
|
|
When a user configures an AI agent or task in Roject, they choose an LLM
|
|
provider. Providers come in two kinds:
|
|
</p>
|
|
<ul style="line-height:1.9;margin-top:0.75rem">
|
|
<li><strong>External</strong> — Anthropic, OpenAI, or any OpenAI-compatible
|
|
endpoint with an API key and base URL configured manually.</li>
|
|
<li><strong>Tunneled</strong> — a local service exposed via
|
|
<code>tunnel.rokojori.com</code>. The user clicks "Browse tunnels", Roject
|
|
fetches <code>GET /api/tunnels/available?purpose=llm-openai-compatible</code>
|
|
and shows a picker with each tunnel's name, description, owner, and active
|
|
status.</li>
|
|
</ul>
|
|
<p style="margin-top:0.75rem">
|
|
Once selected, Roject stores the <code>tunnelId</code> and constructs the
|
|
endpoint at runtime:
|
|
</p>
|
|
<pre style="margin-top:0.75rem"><code>https://tunnel.rokojori.com/t/<tunnelId>/v1/chat/completions</code></pre>
|
|
<p style="margin-top:0.75rem">
|
|
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.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Family / team sharing</h3>
|
|
<p>
|
|
If a tunnel's <code>allowedUserIds</code> includes other rokojori accounts,
|
|
those users see the tunnel in their <code>/api/tunnels/available</code> 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 <code>tunnel.rokojori.com</code>.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>File Structure — rokojori-tunnel</h2>
|
|
|
|
<div class="card">
|
|
<pre><code>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</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Environment Variables</h2>
|
|
|
|
<div class="card">
|
|
<pre><code>JWT_SECRET=... — same shared value as all other rokojori services
|
|
PORT=3002 — or whichever port nginx proxies to</code></pre>
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<h2>Phased Implementation</h2>
|
|
|
|
<div class="card" style="border-left: 3px solid #4caf50;">
|
|
<h3>Phase 1 — single user, HTTP only — <em>Complete</em></h3>
|
|
<ul style="line-height:1.9">
|
|
<li>Relay server built at <code>C:\rokojori\projects\web-projects\tunnel</code></li>
|
|
<li>Tunnel CRUD API, WebSocket agent endpoint, HTTP proxy route</li>
|
|
<li>Standalone test agent script (<code>scripts/test-agent.ts</code>)</li>
|
|
<li>Tested end-to-end: request relayed through tunnel to local <code>gemma4-coding</code> LLM on port 8900 and response returned correctly</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card" style="border-left: 3px solid #ffb300;">
|
|
<h3>Phase 2 — multi-user and discovery — <em>Next</em></h3>
|
|
<ul style="line-height:1.9">
|
|
<li>Allowed users list on tunnel config</li>
|
|
<li><code>GET /api/tunnels/available</code> endpoint with purpose filtering</li>
|
|
<li>Public access mode</li>
|
|
<li>Roject LLM provider picker integrating the discovery API</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h3>Phase 3 — polish</h3>
|
|
<ul style="line-height:1.9">
|
|
<li>Electron app: multiple tunnel configs, start on boot, tray status per tunnel</li>
|
|
<li>Web UI at <code>tunnel.rokojori.com</code> for managing tunnels without the desktop app</li>
|
|
<li>Per-tunnel request logs (count, last active timestamp)</li>
|
|
<li>Password-protected access mode</li>
|
|
</ul>
|
|
</div>
|
|
</section>
|
|
|
|
<footer>
|
|
Roject — user-based local tunneling plan
|
|
</footer>
|
|
|
|
</div>
|
|
<script>var NAV_ROOT = '../';</script>
|
|
<script src="../_assets_/nav-data.js"></script>
|
|
<script src="../_assets_/nav.js"></script>
|
|
</body>
|
|
</html>
|