Compare commits

...
5 Commits
Author SHA1 Message Date
Hermes 6ce0a18f98 release: 1.6.0 — openclaw routes, docker.client fix, /apps/ path deduplication 2026-05-27 22:22:37 -07:00
Hermes e07375f642 fix: mount openclaw routes at /openclaw prefix + fix docker.client wrapper + strip duplicate /apps/ paths across sub-routers
- openClawRoutes was mounted at root causing /status vs /openclaw/status mismatch
- ctx.docker is a typed wrapper {client,pull,...} — all calls now use docker.client.*
- templates/deploy/removal/restore sub-routers had /apps/ hardcoded in inner routes
  causing double-stacking when mounted under /apps (→ /apps/apps/templates etc)
- openclaw.js: GET /status, POST /deploy, GET/POST /proxy/*, DELETE /
2026-05-27 22:20:21 -07:00
Hermes 17edb3bc90 Fix 5 critical security vulnerabilities
1. WebSocket exec auth bypass (exec.js): Require valid JWT or API key
   before accepting WebSocket upgrade. Reject unauthenticated requests
   with 401 before the upgrade completes.

2. Shell injection in router auto-login (session-handlers.js): Validate
   baseUrl against safe hostname pattern before embedding in wget shell
   command. Reject with null session if invalid.

3. Path traversal in credentials routes (services.js): Add explicit
   serviceId validation (alphanumeric + dash/underscore/dot, max 100
   chars) to all three credential endpoints. Removed redundant
   try/catch wrapper.

4. execSync injection in CA CSR generation (ca.js): Add sanitize step
   replacing any non-alphanumeric domain chars with underscore before
   interpolation into shell subj argument. Redundant with existing
   validation but provides defense-in-depth.

5. Auth bypass when TOTP disabled (middleware.js): Split the logic
   cleanly — disabled TOTP means no auth (initial setup state), enabled
   TOTP means all auth methods checked (session/JWT/API key). Removed
   the sessionDuration:never conflating shortcut.
2026-05-27 18:05:35 -07:00
Coderbot 445da9f5fc fix: cross-subdomain SSO auto-login for *arr services
- Set Domain=.sami on session + CSRF cookies so browsers send them to all subdomains
- This fixes Caddy forward_auth returning 401 for radarr/sonarr/prowlarr
- Fix login URL concatenation bug (radarr.samilogin -> radarr.sami/login)
- Fix getSetCookie() missing from _httpsFetch/_httpFetch response objects
- Fix array/string handling for set-cookie header in session-handlers fallback
- Refactor csrf-protection to createCSRFMiddleware() factory with cookieDomain support
- Pass renewCSRFToken through middleware deps chain to TOTP route
2026-05-23 16:15:56 -07:00
Coderbot fe0f52ce17 fix: services/status probe fails with self-signed certs when CA is missing in container
The /api/v1/services/status endpoint (dashboard card ON/OFF) uses an
HTTPS agent to probe each service. When /app/pki/root.crt is missing
inside the container, it fell back to new https.Agent() which rejects
self-signed certificates. This caused all .sami domain probes to fail
with UNABLE_TO_GET_ISSUER_CERT_LOCALLY, making dashboard cards randomly
flip between ON and OFF depending on whether the Pylon relay responded
before the 10s deadline.

Fix: use rejectUnauthorized: false as fallback when CA cert is absent.
2026-05-23 14:35:39 -07:00
19 changed files with 496 additions and 101 deletions
+33 -10
View File
@@ -49,13 +49,24 @@ function parseCookie(cookieHeader) {
}
/**
* Create CSRF middleware with cookie domain support.
* When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami
* so they are shared across all subdomains for forward_auth SSO.
* @param {Object} [options]
* @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains
* @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }}
*/
function createCSRFMiddleware(options = {}) {
const { cookieDomain } = options;
/**
* Middleware to set CSRF cookie on requests.
* Preserves existing nonce to avoid invalidating tokens the client has cached.
* New nonce is generated only on first visit (no cookie) or after TOTP login
* (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once
* and never changes.
*/
function csrfCookieMiddleware(req, res, next) {
function csrfCookieMiddleware(req, res, next) {
const cookies = parseCookie(req.headers.cookie);
const existingNonce = cookies[CSRF_COOKIE_NAME];
@@ -68,35 +79,42 @@ function csrfCookieMiddleware(req, res, next) {
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
if (!existingNonce) {
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
const cookieOpts = {
httpOnly: false, // Must be readable by JavaScript for signing
secure: req.secure || req.protocol === 'https',
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
});
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
}
next();
}
}
/**
/**
* Generate a fresh CSRF nonce and set it on the response.
* Called after TOTP login to rotate the token for the new session.
* @param {Object} res - Express response object
* @param {boolean} secure - Whether to set Secure flag on cookie
* @returns {string} The new CSRF signed token
*/
function renewCSRFToken(res, secure) {
function renewCSRFToken(res, secure) {
const csrfNonce = generateToken();
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
const cookieOpts = {
httpOnly: false,
secure: !!secure,
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000
});
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
return signToken(csrfNonce);
}
return { csrfCookieMiddleware, renewCSRFToken };
}
/**
@@ -194,6 +212,9 @@ function csrfValidationMiddleware(req, res, next) {
}
}
// Default instance (no domain) for backward compatibility with tests
const defaultInstance = createCSRFMiddleware();
module.exports = {
CSRF_TOKEN_LENGTH,
CSRF_COOKIE_NAME,
@@ -201,7 +222,9 @@ module.exports = {
generateToken,
signToken,
parseCookie,
csrfCookieMiddleware,
createCSRFMiddleware,
csrfValidationMiddleware,
renewCSRFToken
// Default instance exports for backward compat
csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware,
renewCSRFToken: defaultInstance.renewCSRFToken
};
+20 -14
View File
@@ -13,7 +13,7 @@ const helmet = require('helmet');
const compression = require('compression');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { csrfCookieMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('./csrf-protection');
const { RATE_LIMITS, LIMITS, APP } = require('./constants');
const { CACHE_CONFIGS, createCache } = require('./cache-config');
@@ -75,7 +75,10 @@ module.exports = function configureMiddleware(app, {
// ── Compress responses (gzip/brotli) ──
app.use(compression());
// ── CSRF Protection ──
// ── CSRF protection (cookie domain set to TLD for cross-subdomain SSO) ──
const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({
cookieDomain: siteConfig.tld || undefined
});
app.use(csrfCookieMiddleware);
app.use(csrfValidationMiddleware);
@@ -221,8 +224,9 @@ module.exports = function configureMiddleware(app, {
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
@@ -253,8 +257,9 @@ module.exports = function configureMiddleware(app, {
}
function clearSessionCookie(res) {
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax`
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
);
}
@@ -311,9 +316,16 @@ module.exports = function configureMiddleware(app, {
// ── TOTP auth middleware ──
const totpAuthMiddleware = (req, res, next) => {
if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') {
// If TOTP is not enabled at all, skip auth entirely — this is the initial-setup state
if (!totpConfig.enabled) {
req.auth = {
type: 'none',
scope: ['admin']
};
return next();
}
// TOTP is enabled — require a valid session, JWT, or API key
if (isPublicRoute(req)) return next();
if (isSessionValid(req)) return next();
@@ -364,14 +376,7 @@ module.exports = function configureMiddleware(app, {
}
}
if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') {
req.auth = {
type: 'none',
scope: ['admin']
};
return next();
}
// No valid auth — reject
return res.status(401).json({
success: false,
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
@@ -428,6 +433,7 @@ module.exports = function configureMiddleware(app, {
clearIPSession,
clearSessionCookie,
isSessionValid,
ipSessions
ipSessions,
renewCSRFToken
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dashcaddy-api",
"version": "1.5.0",
"version": "1.6.0",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js",
"scripts": {
+2 -2
View File
@@ -227,7 +227,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
}
// Check for existing container before deployment
router.post('/apps/check-existing', asyncHandler(async (req, res) => {
router.post('/check-existing', asyncHandler(async (req, res) => {
const { appId } = req.body;
const template = ctx.APP_TEMPLATES[appId];
if (!template) throw new ValidationError('Invalid app template');
@@ -240,7 +240,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
}, 'check-existing'));
// Deploy new app
router.post('/apps/deploy', asyncHandler(async (req, res) => {
router.post('/deploy', asyncHandler(async (req, res) => {
const { appId, config } = req.body;
if (!appId || typeof appId !== 'string') {
throw new ValidationError('appId is required');
+15 -5
View File
@@ -45,11 +45,21 @@ module.exports = function(ctx) {
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
const subCtx = Object.assign({}, ctx, { helpers });
router.use(initDeploy(subCtx));
router.use(initRemoval(subCtx));
router.use(initTemplates(subCtx));
router.use(initRestore(subCtx));
router.use(initCompose(subCtx));
try { router.use('/deploy', initDeploy(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
try { router.use('/remove', initRemoval(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] removal routes init failed:', e.message); }
try { router.use('/apps', initTemplates(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] templates routes init failed:', e.message); }
try { router.use('/restore', initRestore(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] restore routes init failed:', e.message); }
try { router.use('/compose', initCompose(subCtx)); }
catch(e) { (ctx.log || console).error('[apps] compose routes init failed:', e.message); }
return router;
};
+1 -1
View File
@@ -35,7 +35,7 @@ module.exports = function({
* @param {Function} deps.safeErrorMessage - Safe error message formatter
* @returns {express.Router}
*/
router.delete('/apps/:appId', asyncHandler(async (req, res) => {
router.delete('/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params;
const { containerId, subdomain, ip, deleteContainer } = req.query;
const shouldDeleteContainer = deleteContainer === 'true';
+3 -3
View File
@@ -30,7 +30,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
* Pulls image, creates container, starts it, recreates Caddy config.
* Skips if container is already running.
*/
router.post('/apps/:appId/restore', asyncHandler(async (req, res) => {
router.post('/:appId/restore', asyncHandler(async (req, res) => {
const { appId } = req.params;
const services = await servicesStateManager.read();
const service = services.find(s => s.id === appId);
@@ -50,7 +50,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
* Restore all services that have deployment manifests.
* Returns per-service results.
*/
router.post('/apps/restore-all', asyncHandler(async (req, res) => {
router.post('/restore-all', asyncHandler(async (req, res) => {
const services = await servicesStateManager.read();
const restoreable = services.filter(s => s.deploymentManifest);
@@ -91,7 +91,7 @@ module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, e
/**
* List all services and their restore status.
*/
router.get('/apps/restore-status', asyncHandler(async (req, res) => {
router.get('/restore-status', asyncHandler(async (req, res) => {
const services = await servicesStateManager.read();
const status = [];
+5 -5
View File
@@ -41,7 +41,7 @@ module.exports = function({
};
// Get available app templates
router.get('/apps/templates', asyncHandler(async (req, res) => {
router.get('/templates', asyncHandler(async (req, res) => {
res.json({
success: true,
templates: ctx.APP_TEMPLATES,
@@ -51,7 +51,7 @@ module.exports = function({
}, 'apps-templates'));
// Get specific app template
router.get('/apps/templates/:appId', asyncHandler(async (req, res) => {
router.get('/templates/:appId', asyncHandler(async (req, res) => {
const { appId } = req.params;
const template = ctx.APP_TEMPLATES[appId];
if (!template) {
@@ -62,7 +62,7 @@ module.exports = function({
}, 'apps-template-detail'));
// Check port availability
router.get('/apps/ports/:port/check', asyncHandler(async (req, res) => {
router.get('/ports/:port/check', asyncHandler(async (req, res) => {
const port = req.params.port;
const conflicts = await helpers.checkPortConflicts([port]);
if (conflicts.length > 0) {
@@ -74,7 +74,7 @@ module.exports = function({
}, 'check-port'));
// Get suggested available port
router.get('/apps/ports/:basePort/suggest', asyncHandler(async (req, res) => {
router.get('/ports/:basePort/suggest', asyncHandler(async (req, res) => {
const basePort = parseInt(req.params.basePort) || 8080;
const maxAttempts = 100;
const usedPorts = await docker.getUsedPorts();
@@ -88,7 +88,7 @@ module.exports = function({
}, 'suggest-port'));
// Update subdomain for deployed app
router.post('/apps/update-subdomain', asyncHandler(async (req, res) => {
router.post('/update-subdomain', asyncHandler(async (req, res) => {
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
const { ValidationError } = require('../../errors');
+2 -1
View File
@@ -27,7 +27,8 @@ module.exports = function(ctx) {
fetchT: ctx.fetchT,
getServiceById: ctx.getServiceById,
licenseManager: ctx.licenseManager,
servicesStateManager: ctx.servicesStateManager
servicesStateManager: ctx.servicesStateManager,
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken
};
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
+10 -2
View File
@@ -35,6 +35,12 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
break;
case 'router': {
// Validate baseUrl is a safe hostname before using in shell command
if (!baseUrl || typeof baseUrl !== 'string' || !/^https?:\/\/[a-zA-Z0-9]([a-zA-Z0-9.\-]{0,253}[a-zA-Z0-9])?(:\d{1,5})?(\/|$)/.test(baseUrl)) {
log.warn('auth', 'Router auto-login rejected: invalid baseUrl', { serviceId, baseUrl: String(baseUrl).substring(0, 50) });
appSessionCache.set(serviceId, { failed: true, exp: Date.now() + SESSION_TTL.FAILED_LOGIN });
return null;
}
const routerBody = `username=${formEncode(username)}&password=${formEncode(password)}&Continue=Continue`;
try {
const { spawnSync } = require('child_process');
@@ -121,7 +127,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
return null;
}
default:
loginUrl = `${baseUrl}login`;
loginUrl = `${baseUrl.replace(/\/+$/, '')}/login`;
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
break;
@@ -168,7 +174,9 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
const rawCookie = resp.headers.get('set-cookie');
if (rawCookie) {
const cookies = rawCookie.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; ');
// headers.get('set-cookie') may return an array (Node http) or string
const cookieStr = Array.isArray(rawCookie) ? rawCookie.join('; ') : rawCookie;
const cookies = cookieStr.split(/,(?=[^ ])/).map(c => c.split(';')[0].trim()).join('; ');
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
return cookies;
+1 -2
View File
@@ -1,5 +1,4 @@
const express = require('express');
const { renewCSRFToken } = require('../../csrf-protection');
const { ValidationError, AuthenticationError } = require('../../errors');
/**
@@ -15,7 +14,7 @@ const { ValidationError, AuthenticationError } = require('../../errors');
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log }) {
module.exports = function({ authManager, credentialManager, totpConfig, saveTotpConfig, session, asyncHandler, errorResponse, log, renewCSRFToken }) {
const router = express.Router();
// Ctx shim for backward compatibility
+6 -4
View File
@@ -180,7 +180,9 @@ module.exports = function(ctx) {
if (needsRegeneration) {
execSync(`openssl genrsa -out "${keyFile}" 2048`, { stdio: 'pipe' });
const subject = `/CN=${domain}`;
// Sanitize domain for safe use in shell arguments — defensive, since validation already restricts input
const safeDomain = domain.replace(/[^a-zA-Z0-9.-]/g, '_');
const subject = `/CN=${safeDomain}`;
execSync(`openssl req -new -key "${keyFile}" -out "${csrFile}" -subj "${subject}"`, { stdio: 'pipe' });
const configContent = `[req]
@@ -189,7 +191,7 @@ req_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = ${domain}
CN = ${safeDomain}
[v3_req]
keyUsage = keyEncipherment, dataEncipherment, digitalSignature
@@ -197,8 +199,8 @@ extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = ${domain}
${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
DNS.1 = ${safeDomain}
${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
const configFile = path.join(domainDir, 'openssl.cnf');
await fsp.writeFile(configFile, configContent);
+46 -4
View File
@@ -10,25 +10,61 @@ const docker = new Docker();
* @param {http.Server} server - The HTTP server instance
* @param {Object} log - Logger
*/
module.exports = function attachExecWS(server, log) {
module.exports = function attachExecWS(server, log, authManager) {
const wss = new WebSocketServer({ noServer: true });
server.on('upgrade', (req, socket, head) => {
// Authenticate WebSocket upgrade request before accepting it
server.on('upgrade', async (req, socket, head) => {
const parsed = url.parse(req.url, true);
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
if (!match) return; // Not our route — let other handlers deal with it
const containerId = decodeURIComponent(match[1]);
// Validate container ID format to prevent injection
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
// Check auth — require valid JWT or API key from the upgrade request
let auth = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const payload = await authManager.verifyJWT(token);
if (payload) {
auth = { type: 'jwt', userId: payload.userId, scope: payload.scope || [] };
}
} else {
const apiKey = req.headers['x-api-key'];
if (apiKey) {
const keyData = await authManager.verifyAPIKey(apiKey);
if (keyData) {
auth = { type: 'apikey', keyId: keyData.keyId, scope: keyData.scopes || [] };
}
}
}
if (!auth) {
log.warn('exec', 'Unauthenticated WebSocket exec attempt', { containerId, ip: req.socket.remoteAddress });
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// Auth passed — proceed with WebSocket upgrade
wss.handleUpgrade(req, socket, head, (ws) => {
handleExec(ws, containerId, log);
handleExec(ws, containerId, log, auth);
});
});
return wss;
};
async function handleExec(ws, containerId, log) {
async function handleExec(ws, containerId, log, auth) {
let execStream = null;
let execInstance = null;
@@ -42,6 +78,12 @@ async function handleExec(ws, containerId, log) {
return;
}
log.info('exec', 'Authenticated exec session started', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
});
// Detect available shell
let shell = '/bin/sh';
try {
+22 -8
View File
@@ -54,7 +54,8 @@ module.exports = function({
const caCert = fs.readFileSync(CA_CERT_PATH);
probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] });
} catch (_) {
probeHttpsAgent = new https.Agent();
// CA cert not available — trust self-signed certs so probes still work
probeHttpsAgent = new https.Agent({ rejectUnauthorized: false });
}
function isServiceUp(statusCode) {
@@ -195,8 +196,14 @@ module.exports = function({
// ===== SERVICE CREDENTIAL ENDPOINTS =====
// Store credentials for a service
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
router.post('/:serviceId/credentials', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Validate serviceId to prevent path traversal in credential keys
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
return ctx.errorResponse(res, 400, 'Invalid service ID');
}
const { apiKey, username, password } = req.body;
if (apiKey) {
@@ -213,8 +220,14 @@ module.exports = function({
}, 'store-service-creds'));
// Delete credentials for a service
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
router.delete('/:serviceId/credentials', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Validate serviceId to prevent path traversal in credential keys
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
return ctx.errorResponse(res, 400, 'Invalid service ID');
}
await credentialManager.delete(`service.${serviceId}.apikey`);
await credentialManager.delete(`service.${serviceId}.username`);
await credentialManager.delete(`service.${serviceId}.password`);
@@ -222,9 +235,13 @@ module.exports = function({
}, 'delete-service-creds'));
// Check credential status for a service (what's stored)
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
try {
router.get('/:serviceId/credentials', asyncHandler(async (req, res) => {
const { serviceId } = req.params;
// Validate serviceId to prevent path traversal in credential keys
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
return ctx.errorResponse(res, 400, 'Invalid service ID');
}
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
@@ -233,9 +250,6 @@ module.exports = function({
hasBasicAuth: !!username,
username: username || null
});
} catch (error) {
success(res, { hasApiKey: false, hasBasicAuth: false });
}
}, 'service-creds'));
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
+4 -3
View File
@@ -52,10 +52,11 @@ process.on('uncaughtException', (error) => {
environment: process.env.NODE_ENV || 'production'
});
// Attach WebSocket exec handler
// Attach WebSocket exec handler (with auth)
const attachExecWS = require('./routes/exec');
attachExecWS(server, log);
log.info('server', 'WebSocket exec handler attached');
const authManager = require('./auth-manager');
attachExecWS(server, log, authManager);
log.info('server', 'WebSocket exec handler attached (auth enforced)');
// Start feature modules
const resourceMonitor = require('./resource-monitor');
+2
View File
@@ -64,6 +64,7 @@ const caRoutes = require('../routes/ca');
const browseRoutes = require('../routes/browse');
const errorLogsRoutes = require('../routes/errorlogs');
const licenseRoutes = require('../routes/license');
const openClawRoutes = require('../routes/openclaw');
const recipesRoutes = require('../routes/recipes');
const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
@@ -394,6 +395,7 @@ async function createApp() {
}));
apiRouter.use(arrRoutes(ctx));
apiRouter.use(appsRoutes(ctx));
apiRouter.use('/openclaw', openClawRoutes(ctx));
apiRouter.use(logsRoutes({
asyncHandler: ctx.asyncHandler,
docker: ctx.docker,
+3
View File
@@ -160,6 +160,9 @@ function assembleContext({
loadNotificationConfig,
resyncHealthChecker,
// Middleware result (exposes renewCSRFToken etc.)
middlewareResult,
// File paths
SERVICES_FILE,
CONFIG_FILE,
+14 -2
View File
@@ -86,7 +86,13 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: { get: (k) => res.headers[k.toLowerCase()] },
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
}
},
});
});
});
@@ -142,7 +148,13 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
statusText: res.statusMessage,
json: () => Promise.resolve(JSON.parse(data)),
text: () => Promise.resolve(data),
headers: { get: (k) => res.headers[k.toLowerCase()] },
headers: {
get: (k) => res.headers[k.toLowerCase()],
getSetCookie: () => {
const sc = res.headers['set-cookie'];
return sc ? (Array.isArray(sc) ? sc : [sc]) : [];
}
},
});
});
});
+272
View File
@@ -0,0 +1,272 @@
const express = require('express');
const http = require('http');
/**
* OpenClaw management routes
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
*
* GET /openclaw/status → container info + gateway health
* POST /openclaw/deploy → deploy OpenClaw container
* GET /openclaw/proxy/* → proxy GET to gateway
* POST /openclaw/proxy/* → proxy POST to gateway
* DELETE /openclaw → remove container
*/
module.exports = function openClawRoutes(ctx) {
const router = express.Router();
const docker = ctx.docker;
const asyncHandler = ctx.asyncHandler;
const log = ctx.log || console;
// ── helpers ──────────────────────────────────────────────────────────────
async function findOpenClawContainer() {
const containers = await docker.client.listContainers({ all: true });
return containers.find(function(c) {
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
c.Names.some(function(n) { return n.includes('openclaw'); }));
}) || null;
}
async function getGatewayToken(containerId) {
try {
const info = await docker.client.containerInfo(containerId);
const entry = (info.Config.Env || []).find(function(e) {
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
});
return entry ? entry.split('=')[1] : null;
} catch(err) {
return null;
}
}
async function getContainerPort(containerId) {
try {
const containers = await docker.client.listContainers({ all: true });
const c = containers.find(function(x) {
return x.Id === containerId || x.Id.startsWith(containerId);
});
if (c && c.Ports) {
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
if (p && p.PublicPort) return String(p.PublicPort);
}
return '18792';
} catch(err) {
return '18792';
}
}
async function gatewayHealth(baseUrl, token) {
return new Promise(function(resolve) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
let data = '';
res.on('data', function(d) { data += d; });
res.on('end', function() {
try { resolve({ ok: true, data: JSON.parse(data) }); }
catch(e) { resolve({ ok: true, data: data }); }
});
});
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
});
}
function proxyRequest(req, res, targetBase, path, token) {
const headers = {};
if (token) headers['Authorization'] = 'Bearer ' + token;
headers['X-Forwarded-For'] = req.ip;
headers['X-Forwarded-Proto'] = req.protocol;
const url = targetBase + '/' + path;
const method = req.method;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
const body = JSON.stringify(req.body);
headers['Content-Type'] = 'application/json';
headers['Content-Length'] = Buffer.byteLength(body);
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
proxyReq.write(body);
proxyReq.end();
} else {
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
res.set(proxyRes.headers);
res.status(proxyRes.statusCode);
proxyRes.on('data', function(d) { res.write(d); });
proxyRes.on('end', function() { res.end(); });
});
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
}
}
// ── GET /openclaw/status ────────────────────────────────────────────────
router.get('/status', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) {
return res.json({ success: true, deployed: false });
}
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const health = await gatewayHealth(baseUrl, token);
res.json({
success: true,
deployed: true,
container: {
id: container.Id.slice(0, 12),
name: container.Name,
state: container.State,
status: container.Status,
created: container.Created,
image: container.Image
},
gateway: {
url: baseUrl,
port: port,
healthy: health.ok,
healthData: health.data || null,
tokenSet: !!token
}
});
}));
// ── POST /openclaw/deploy ───────────────────────────────────────────────
router.post('/deploy', asyncHandler(async function(req, res) {
const existing = await findOpenClawContainer();
if (existing) {
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
}
const image = 'ghcr.io/nousresearch/openclaw:latest';
const name = 'openclaw-' + Date.now();
const gatewayToken = generateToken();
// Pull image
log.info('Pulling ' + image + '...');
try {
await new Promise(function(resolve, reject) {
docker.client.pull(image, function(err, stream) {
if (err) return reject(err);
docker.client.modem.followProgress(stream, function(err2) {
if (err2) return reject(err2);
resolve();
});
});
});
} catch(e) {
log.error('OpenClaw pull failed: ' + e.message);
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
}
// Create + start container
try {
const container = await docker.client.createContainer({
name: name,
Image: image,
Env: [
'OPENCLAW_GATEWAY_MODE=local',
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
],
HostConfig: {
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
RestartPolicy: { Name: 'unless-stopped' },
Labels: {
'dashcaddy.managed': 'true',
'dashcaddy.app': 'openclaw'
}
},
ExposedPorts: { '18792/tcp': {} }
});
await container.start();
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
res.json({
success: true,
deployed: true,
container: { id: container.id.slice(0, 12), name: name },
gateway: {
url: 'http://localhost:18792',
token: gatewayToken
}
});
} catch(e) {
log.error('OpenClaw deploy failed: ' + e.message);
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
}
}));
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
router.get('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
router.post('/proxy/*', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
const token = await getGatewayToken(container.Id);
const port = await getContainerPort(container.Id);
const baseUrl = 'http://localhost:' + port;
const path = req.params[0];
proxyRequest(req, res, baseUrl, path, token);
}));
// ── DELETE /openclaw ───────────────────────────────────────────────────
router.delete('/', asyncHandler(async function(req, res) {
const container = await findOpenClawContainer();
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
try {
const c = docker.client.container(container.Id);
await c.stop().catch(function() {});
await c.remove({ force: true });
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
res.json({ success: true, message: 'OpenClaw removed' });
} catch(e) {
log.error('Failed to remove OpenClaw: ' + e.message);
res.status(500).json({ success: false, error: e.message });
}
}));
return router;
};
// ── token generator ──────────────────────────────────────────────────────────
function generateToken() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < 32; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}