tunneling: brainstorm, Phase 1 relay server built + tested, boards + history updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rokojori 2026-07-16 07:27:48 +02:00
parent 7c6fca54f9
commit 93f8c7bb33
7 changed files with 601 additions and 10 deletions

View File

@ -16,6 +16,7 @@ var NAV_DATA = {
path: 'outline/index.html',
children: [
{ title: 'rokojori-auth Restructure', path: 'outline/auth-restructure.html' },
{ title: 'User-Based Local Tunneling', path: 'outline/tunneling.html' },
]
},
{
@ -49,6 +50,7 @@ var NAV_DATA = {
title: 'History',
path: 'history/index.html',
children: [
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
{ title: 'Tuesday, 15 July 2026', path: 'history/2026/07-July/15-Tuesday/index.html' },
{ title: 'Monday, 14 July 2026', path: 'history/2026/07-July/14-Monday/index.html' },
{ title: 'Sunday, 13 July 2026', path: 'history/2026/07-July/13-Sunday/index.html' },

View File

@ -37,16 +37,6 @@
<div class="lane">
<div class="lane-header">Nice To Have</div>
<task-item class="purple hide-content">
<task-title>Internet Tunnel / Port Relay</task-title>
<task-content>
Allow local devices — a Stable Diffusion instance, a local LLM, a GDScript language
server — to be accessible to authorised Roject users over the internet, routed through
the Roject server via a persistent WebSocket. Primary use case: phone as a thin client
while the main machine handles all heavy processing.
</task-content>
</task-item>
<task-item class="purple hide-content">
<task-title>Real-Time Multi-User Collaboration</task-title>
<task-content>

View File

@ -113,6 +113,22 @@
<div class="lane">
<div class="lane-header">In Progress</div>
<task-item class="yellow hide-content">
<task-title>rokojori-tunnel — Phase 2</task-title>
<task-content>
Phase 1 complete: relay server, CRUD tunnel API, WebSocket agent endpoint,
HTTP proxy, test agent script. Tested end-to-end with local LLM (gemma4-coding
on port 8900) — request relayed and response returned correctly.
Service lives at C:\rokojori\projects\web-projects\tunnel.
Phase 2 remaining:
— Allowed users list enforcement (multi-user private access)
— GET /api/tunnels/available with ?purpose= filter
— Public access mode (no auth required on proxy route)
— Roject LLM provider picker integrating the discovery API
</task-content>
</task-item>
<task-item class="yellow hide-content">
<task-title>styles.rokojori.com — complete deployment</task-title>
<task-content>

View File

@ -0,0 +1,126 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wednesday, 16 July 2026 — 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">Wednesday, 16 July 2026</p>
<h1>Session History</h1>
<p class="subtitle">Brainstormed and scaffolded rokojori-tunnel — user-based local tunneling service.</p>
</header>
<section>
<h2>What we built</h2>
<div class="card">
<h3>rokojori-tunnel — Phase 1 (relay server)</h3>
<p>
New standalone service at <code>C:\rokojori\projects\web-projects\tunnel</code>,
same stack as the rest of the ecosystem (Node.js, Express, ts-node, JSON file
storage, shared JWT verification).
</p>
<ul style="line-height:1.9;margin-top:0.75rem">
<li><code>source/server/db.ts</code> — JSON file storage for tunnel configs
(<code>build/data/db/tunnels.json</code>)</li>
<li><code>source/server/middleware/requireAuth.ts</code> — JWT verification,
same pattern as rokojori-auth</li>
<li><code>source/server/relay/TunnelRegistry.ts</code> — in-memory
<code>Map&lt;tunnelId, WebSocket&gt;</code> of active agent connections</li>
<li><code>source/server/relay/pending.ts</code> — pending request callbacks
keyed by <code>reqId</code> for matching responses to waiting HTTP connections</li>
<li><code>source/server/routes/tunnels.ts</code> — full CRUD plus
<code>GET /api/tunnels/available?purpose=</code> discovery endpoint</li>
<li><code>source/server/routes/agent.ts</code> — WebSocket upgrade handler;
verifies JWT, confirms ownership, registers socket in registry</li>
<li><code>source/server/routes/proxy.ts</code><code>ALL /t/:tunnelId/*</code>
relay; soft auth check for access mode, raw body forwarding, 30s timeout</li>
<li><code>source/server/index.ts</code> — Express + HTTP server with manual
WebSocket upgrade routing; JSON middleware applied only to <code>/api</code>
routes so proxy receives raw body streams</li>
<li><code>scripts/test-agent.ts</code> — standalone Node.js agent for
testing before the Electron app exists; connects via WebSocket and forwards
inbound relay requests to a local port</li>
</ul>
</div>
<div class="card">
<h3>End-to-end test — local LLM over tunnel</h3>
<p>
Registered a tunnel via <code>POST /api/tunnels</code>, started the test agent
forwarding to port 8900 (<code>gemma4-coding-Q4_K_M.gguf</code> running locally),
and sent an OpenAI-compatible <code>/v1/chat/completions</code> request through
the relay. Full round-trip succeeded — request forwarded, response relayed back,
streaming token count confirmed in the response.
</p>
</div>
<div class="card">
<h3>tunneling.html outline document</h3>
<p>
New plan document at <code>workspace/outline/tunneling.html</code> covering:
the relay mechanic (three-leg model, minimal inspection, raw byte forwarding),
access modes (private / public / password-protected), tunnel metadata shape and
purpose tags, all three components (relay server, Electron agent app, rokojori-auth
permission), API endpoint reference, Roject LLM provider integration example,
file structure, and phased implementation plan.
</p>
</div>
</section>
<section>
<h2>Key decisions</h2>
<div class="card">
<p>
<strong>Separate service, not part of rokojori-auth.</strong>
Auth stays focused on identity. tunnel.rokojori.com is its own Express process
that verifies the shared JWT but owns all relay logic independently.
</p>
</div>
<div class="card">
<p>
<strong>HTTP-only for Phase 1.</strong> Stable Diffusion (AUTOMATIC1111) and
OpenAI-compatible LLMs (Ollama, LM Studio) all speak HTTP with SSE streaming —
no WebSocket from the app side needed. Language servers use raw TCP and are out
of scope for now.
</p>
</div>
<div class="card">
<p>
<strong>JSON envelope protocol for Phase 1.</strong> Requests and responses are
wrapped as <code>{ reqId, method, path, headers, body (base64) }</code> JSON
messages over the agent WebSocket. No raw byte framing needed at this stage;
the JSON envelope is simple enough and sidesteps binary WebSocket complexity.
</p>
</div>
<div class="card">
<p>
<strong>No express.json() on proxy routes.</strong> The JSON middleware is applied
only to <code>/api/tunnels</code> so the proxy handler always receives a raw
readable body stream, regardless of content type.
</p>
</div>
</section>
<footer>
Roject &mdash; session history
</footer>
</div>
<script>var NAV_ROOT = '../../../../';</script>
<script src="../../../../_assets_/nav-data.js"></script>
<script src="../../../../_assets_/nav.js"></script>
</body>
</html>

View File

@ -19,6 +19,11 @@
<section>
<h2>2026 — July</h2>
<div class="card">
<h3><a href="2026/07-July/16-Wednesday/index.html">Wednesday, 16 July 2026</a></h3>
<p>Brainstormed and scaffolded rokojori-tunnel — user-based local tunneling service; Phase 1 relay server built and tested end-to-end with local LLM (gemma4-coding on port 8900).</p>
</div>
<div class="card">
<h3><a href="2026/07-July/15-Tuesday/index.html">Tuesday, 15 July 2026</a></h3>
<p>styles.rokojori.com built and deployed (font hosting, /get-font public CSS endpoint, CORS allowlist, auth-gated management pages); Roject Barlow font migrated from Google Fonts; old dashboard/groups/projects pages removed; add-subdomain deployment guide with live variable inputs.</p>

View File

@ -81,6 +81,23 @@
</p>
</div>
<div class="card">
<h3>Ecosystem — tunnel.rokojori.com</h3>
<p>
A user-based local tunneling service (in development at
<code>C:\rokojori\projects\web-projects\tunnel</code>, planned to live at
<code>tunnel.rokojori.com</code>). Exposes local services — LLMs, Stable
Diffusion, language servers — to authorised rokojori users over the internet,
routed through a relay server via a persistent WebSocket agent connection.
Each tunnel is owned by a rokojori account and carries metadata (name, purpose
tag, description, access mode, allowed users) so apps can discover and select
tunnels via <code>GET /api/tunnels/available?purpose=</code>.
Phase 1 (relay server, CRUD API, HTTP proxy, test agent) is complete and
tested end-to-end. Phase 2 (multi-user access, discovery API, public mode)
is in progress.
</p>
</div>
<div class="card">
<h3>Ecosystem — styles.rokojori.com</h3>
<p>

View File

@ -0,0 +1,435 @@
<!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 &rarr; relay server &rarr; local agent &rarr; local service.
</p>
<pre style="margin-top:0.75rem"><code>App (Roject on phone)
&rarr; HTTPS &rarr; tunnel.rokojori.com/t/&lt;tunnelId&gt;/v1/chat/completions
&rarr; WebSocket envelope &rarr; Electron agent (on the home machine)
&rarr; HTTP &rarr; 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/&lt;tunnelId&gt;</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:&lt;port&gt;</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> &mdash; 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> &mdash; AUTOMATIC1111 or ComfyUI image generation</li>
<li><code>general</code> &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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> &mdash; Anthropic, OpenAI, or any OpenAI-compatible
endpoint with an API key and base URL configured manually.</li>
<li><strong>Tunneled</strong> &mdash; 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/&lt;tunnelId&gt;/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 &mdash; 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 &mdash; 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 &mdash; 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&lt;tunnelId, AgentSocket&gt;
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 &mdash; single user, HTTP only &mdash; <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 &mdash; multi-user and discovery &mdash; <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 &mdash; 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 &mdash; 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>