Compare commits
5
Commits
8df5214a45
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce0a18f98 | ||
|
|
e07375f642 | ||
|
|
17edb3bc90 | ||
|
|
445da9f5fc | ||
|
|
fe0f52ce17 |
@@ -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
@@ -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,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": {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =====
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -160,6 +160,9 @@ function assembleContext({
|
||||
loadNotificationConfig,
|
||||
resyncHealthChecker,
|
||||
|
||||
// Middleware result (exposes renewCSRFToken etc.)
|
||||
middlewareResult,
|
||||
|
||||
// File paths
|
||||
SERVICES_FILE,
|
||||
CONFIG_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]) : [];
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user