Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions dashboard/backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ def unauthorized():
"/api/version",
"/api/version/check",
"/api/agents/active",
# Traefik forwardauth probe — validates the forwarded session cookie and
# returns 200 (allow) or 401 (deny). Must be PUBLIC_PATHS so this
# middleware doesn't reject it before the handler resolves the session.
"/api/auth/check",
}

def _try_api_token_auth():
Expand Down
12 changes: 12 additions & 0 deletions dashboard/backend/routes/auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ def _as_text(value) -> str:
return "" if value is None else str(value)


# ── ForwardAuth check — used by Traefik forwardauth middleware ───────────────
# Returns 200 if the current session is authenticated, 401 otherwise.
# Traefik forwardauth: any 2xx = allow, any other status = deny (returns the
# response body + status directly to the client).
# This endpoint must be in PUBLIC_PATHS so auth_middleware doesn't block it.
@bp.route("/api/auth/check")
def auth_check():
if current_user.is_authenticated:
return "", 200
return jsonify({"error": "Authentication required"}), 401


# ── Setup (first run only) ───────────────────────────

@bp.route("/api/auth/needs-setup")
Expand Down
24 changes: 20 additions & 4 deletions dashboard/backend/routes/terminal_proxy.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Proxy HTTP and WebSocket traffic to the local terminal-server.

The terminal-server (Node, dashboard/terminal-server/bin/server.js) binds to
a random port (commonly 32352) on 0.0.0.0. Browsers connecting to the
dashboard from a different host than `localhost` historically had to hit
a random port (commonly 32352) on 127.0.0.1. Browsers connecting to the
dashboard from a different host than ``localhost`` historically had to hit
that port directly, which fails in three common scenarios:

1. Browsing via SSH tunnel (`ssh -L 8080:localhost:8080`) — only port 8080
1. Browsing via SSH tunnel (``ssh -L 8080:localhost:8080``) — only port 8080
is forwarded, the random terminal-server port is not.
2. Browsing via a public tunnel (Tailscale Funnel, Cloudflare Tunnel, an
nginx reverse proxy on a VPS) — only the dashboard port is exposed.
Expand All @@ -19,13 +19,20 @@

This module is intentionally minimal — it forwards bytes both ways for
HTTP and WebSocket; it does not inspect or rewrite payloads.

Security: the WebSocket proxy appends the shared ``TERMINAL_WS_TOKEN`` as a
``?token=`` query param when connecting to the upstream terminal-server. This
lets the terminal-server's ``verifyClient`` hook reject unauthenticated
WebSocket upgrades even when coming through the Traefik route (defence-in-
depth layer 2). The token never reaches the browser.
"""

from __future__ import annotations

import logging
import os
import threading
from urllib.parse import quote

import requests
from flask import Blueprint, Response, request, stream_with_context
Expand All @@ -42,6 +49,11 @@
TERMINAL_HTTP_BASE = f"http://{TERMINAL_HOST}:{TERMINAL_PORT}"
TERMINAL_WS_BASE = f"ws://{TERMINAL_HOST}:{TERMINAL_PORT}"

# Shared secret for terminal-server WebSocket auth (defence-in-depth layer 2).
# The proxy appends this as ?token=<value> when connecting upstream; the
# terminal-server's verifyClient rejects connections without a valid token.
_TERMINAL_WS_TOKEN = os.environ.get("TERMINAL_WS_TOKEN", "").strip()

# Hop-by-hop headers that must not be forwarded (RFC 7230 §6.1).
_HOP_BY_HOP = frozenset(
{
Expand Down Expand Up @@ -153,7 +165,11 @@ def proxy_ws(client_ws):
pass
return

target = f"{TERMINAL_WS_BASE}/ws"
# Append the shared token so the terminal-server's verifyClient
# accepts this connection (defence-in-depth — auth already enforced
# above via current_user.is_authenticated).
token_qs = f"?token={quote(_TERMINAL_WS_TOKEN, safe='')}" if _TERMINAL_WS_TOKEN else ""
target = f"{TERMINAL_WS_BASE}/ws{token_qs}"
try:
upstream = create_connection(target, timeout=10)
except Exception as exc:
Expand Down
58 changes: 55 additions & 3 deletions dashboard/terminal-server/src/server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const express = require('express');
const http = require('http');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
const WebSocket = require('ws');
Expand All @@ -11,6 +12,37 @@ const SessionStore = require('./utils/session-store');
const ChatLogger = require('./utils/chat-logger');
const { loadProviderConfig, getProviderMode } = require('./provider-config');

// ---------------------------------------------------------------------------
// WS token auth — shared secret between dashboard (Flask) and terminal-server.
// Set TERMINAL_WS_TOKEN in env (generated by entrypoint on first boot, same
// as EVONEXUS_SECRET_KEY derivation pattern). Connections without a valid
// ?token=<value> query param are rejected at the WS upgrade step.
// ---------------------------------------------------------------------------
const _WS_TOKEN = (process.env.TERMINAL_WS_TOKEN || '').trim();

/**
* Constant-time comparison of two strings to prevent timing attacks.
* Returns true only when both token and expected are non-empty and equal.
*/
function _verifyWsToken(provided) {
if (!_WS_TOKEN) {
// No token configured — running in dev mode without auth enforcement.
// Log a warning so operators notice.
console.warn('[security] TERMINAL_WS_TOKEN not set — WebSocket auth disabled');
return true;
}
if (!provided) return false;
try {
// Use constant-time comparison; pad to same length before comparing.
const a = Buffer.from(provided.padEnd(128), 'utf8');
const b = Buffer.from(_WS_TOKEN.padEnd(128), 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch {
return false;
}
}

class TerminalServer {
constructor(options = {}) {
this.port = options.port || 32352;
Expand Down Expand Up @@ -253,7 +285,10 @@ class TerminalServer {
}

setupExpress() {
this.app.use(cors());
// CORS: restrict to the dashboard origin when EVONEXUS_DASHBOARD_ORIGIN
// is set (production). Falls back to wide-open for local dev.
const dashboardOrigin = (process.env.EVONEXUS_DASHBOARD_ORIGIN || '').trim();
this.app.use(cors(dashboardOrigin ? { origin: dashboardOrigin, credentials: true } : {}));
this.app.use(express.json());

this.app.get('/api/health', (req, res) => {
Expand Down Expand Up @@ -531,11 +566,28 @@ class TerminalServer {
await this.ready;
const server = http.createServer(this.app);

this.wss = new WebSocket.Server({ server });
// verifyClient runs during the HTTP→WebSocket upgrade, before the
// connection is accepted. Reject any upgrade without a valid token.
this.wss = new WebSocket.Server({
server,
verifyClient: ({ req }, cb) => {
const url = new URL(req.url, 'ws://localhost');
const token = url.searchParams.get('token');
if (_verifyWsToken(token)) {
cb(true);
} else {
// 401 Unauthorized — browser sees this as a failed WS handshake
cb(false, 401, 'Unauthorized');
}
},
});
this.wss.on('connection', (ws, req) => this.handleWebSocketConnection(ws, req));

// Bind to loopback only — external callers must go through the Flask
// proxy (which enforces session auth) or the Traefik forwardauth chain.
const bindHost = process.env.TERMINAL_BIND_HOST || '127.0.0.1';
return new Promise((resolve, reject) => {
server.listen(this.port, '0.0.0.0', (err) => {
server.listen(this.port, bindHost, (err) => {
if (err) return reject(err);
this.server = server;
resolve(server);
Expand Down
110 changes: 110 additions & 0 deletions dashboard/terminal-server/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const test = require('node:test');
const http = require('http');
const WebSocket = require('ws');

const SessionStore = require('../src/utils/session-store');
const { TerminalServer } = require('../src/server');
Expand Down Expand Up @@ -114,3 +116,111 @@ test('TerminalServer purges stale sessions and reports health', async () => {

server.close();
});

// ---------------------------------------------------------------------------
// WebSocket token auth tests
// ---------------------------------------------------------------------------

/**
* Start a real TerminalServer on a random port and return { server, addr }.
* The caller must call server.close() in cleanup.
*/
async function startTestServer(token) {
const orig = process.env.TERMINAL_WS_TOKEN;
if (token !== undefined) {
process.env.TERMINAL_WS_TOKEN = token;
} else {
delete process.env.TERMINAL_WS_TOKEN;
}

// Re-require server so it picks up the updated env (module is cached, so
// we need to invalidate the cache for the token to take effect).
Object.keys(require.cache).forEach((k) => {
if (k.includes('terminal-server') && !k.includes('node_modules')) {
delete require.cache[k];
}
});
const { TerminalServer: TS } = require('../src/server');

const srv = new TS({ port: 0, dev: false, sessionGcIntervalMs: 0, autoSaveIntervalMs: 0 });
const httpServer = await srv.start();

// Restore env
if (orig !== undefined) {
process.env.TERMINAL_WS_TOKEN = orig;
} else {
delete process.env.TERMINAL_WS_TOKEN;
}

const addr = httpServer.address();
return { server: srv, addr };
}

function wsConnect(addr, tokenQuery) {
return new Promise((resolve, reject) => {
const url = `ws://127.0.0.1:${addr.port}/ws${tokenQuery ? `?token=${tokenQuery}` : ''}`;
const ws = new WebSocket(url);
ws.on('open', () => resolve({ ws, opened: true }));
ws.on('unexpected-response', (req, res) => {
resolve({ ws: null, opened: false, statusCode: res.statusCode });
});
ws.on('error', (err) => {
// Connection refused or similar — treat as rejection
resolve({ ws: null, opened: false, error: err.message });
});
// Timeout safety
setTimeout(() => reject(new Error('WS connect timeout')), 3000);
});
}

test('WS token auth — accepts connection with correct token', async () => {
const TOKEN = 'test-secret-abc123';
const { server, addr } = await startTestServer(TOKEN);

try {
const result = await wsConnect(addr, TOKEN);
assert.equal(result.opened, true, 'WebSocket should open with correct token');
if (result.ws) result.ws.close();
} finally {
server.close();
}
});

test('WS token auth — rejects connection without token (401)', async () => {
const TOKEN = 'test-secret-abc123';
const { server, addr } = await startTestServer(TOKEN);

try {
const result = await wsConnect(addr, null);
assert.equal(result.opened, false, 'WebSocket should be rejected without token');
assert.equal(result.statusCode, 401, 'Expected HTTP 401 on upgrade rejection');
} finally {
server.close();
}
});

test('WS token auth — rejects connection with wrong token (401)', async () => {
const TOKEN = 'test-secret-abc123';
const { server, addr } = await startTestServer(TOKEN);

try {
const result = await wsConnect(addr, 'wrong-token');
assert.equal(result.opened, false, 'WebSocket should be rejected with wrong token');
assert.equal(result.statusCode, 401, 'Expected HTTP 401 on upgrade rejection');
} finally {
server.close();
}
});

test('WS token auth — allows all connections when TERMINAL_WS_TOKEN is unset (dev mode)', async () => {
const { server, addr } = await startTestServer(undefined);

try {
// No token in query — should still succeed in dev mode
const result = await wsConnect(addr, null);
assert.equal(result.opened, true, 'WebSocket should open in dev mode without token');
if (result.ws) result.ws.close();
} finally {
server.close();
}
});
18 changes: 15 additions & 3 deletions evonexus.stack.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ services:
- EVONEXUS_PORT=8080
- TERMINAL_SERVER_PORT=32352
- FORWARDED_ALLOW_IPS=*
## Shared secret for terminal-server WebSocket auth (defence-in-depth).
## Set this to a random 64-char hex string (e.g. openssl rand -hex 32).
## Both the dashboard proxy and the terminal-server must use the same value.
## If unset, token verification is disabled (dev mode only).
- TERMINAL_WS_TOKEN=${TERMINAL_WS_TOKEN}

deploy:
placement:
Expand All @@ -71,14 +76,21 @@ services:

## Terminal-server router — Node PTY (HTTP + WebSocket) on port 32352.
## Higher priority (10) so /terminal/* wins over the primary router.
## The stripprefix middleware removes /terminal before forwarding
## because the terminal-server exposes /api/... at its root.
##
## Auth chain: forwardauth → /api/auth/check (Flask, port 8080).
## Traefik forwards the original request headers (including the session
## cookie) to Flask. Flask returns 200 (authenticated) or 401 (denied).
## On 401, Traefik returns the Flask response directly to the client
## instead of forwarding to the terminal-server.
## The stripprefix middleware then removes /terminal before forwarding.
- traefik.http.routers.evonexus_terminal.rule=Host(`evonexus.example.com`) && PathPrefix(`/terminal`)
- traefik.http.routers.evonexus_terminal.entrypoints=websecure
- traefik.http.routers.evonexus_terminal.priority=10
- traefik.http.routers.evonexus_terminal.tls.certresolver=letsencryptresolver
- traefik.http.routers.evonexus_terminal.service=evonexus_terminal
- traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_strip
- traefik.http.routers.evonexus_terminal.middlewares=evonexus_terminal_auth,evonexus_terminal_strip
- traefik.http.middlewares.evonexus_terminal_auth.forwardauth.address=http://127.0.0.1:8080/api/auth/check
- traefik.http.middlewares.evonexus_terminal_auth.forwardauth.authResponseHeaders=X-User-Id,X-User-Role
- traefik.http.middlewares.evonexus_terminal_strip.stripprefix.prefixes=/terminal
- traefik.http.services.evonexus_terminal.loadbalancer.server.port=32352
- traefik.http.services.evonexus_terminal.loadbalancer.passHostHeader=true
Expand Down