Compare commits
5
Commits
8df5214a45
..
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce0a18f98 | ||
|
|
e07375f642 | ||
|
|
17edb3bc90 | ||
|
|
445da9f5fc | ||
|
|
fe0f52ce17 |
@@ -49,54 +49,72 @@ function parseCookie(cookieHeader) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to set CSRF cookie on requests.
|
* Create CSRF middleware with cookie domain support.
|
||||||
* Preserves existing nonce to avoid invalidating tokens the client has cached.
|
* When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami
|
||||||
* New nonce is generated only on first visit (no cookie) or after TOTP login
|
* so they are shared across all subdomains for forward_auth SSO.
|
||||||
* (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once
|
* @param {Object} [options]
|
||||||
* and never changes.
|
* @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains
|
||||||
|
* @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }}
|
||||||
*/
|
*/
|
||||||
function csrfCookieMiddleware(req, res, next) {
|
function createCSRFMiddleware(options = {}) {
|
||||||
const cookies = parseCookie(req.headers.cookie);
|
const { cookieDomain } = options;
|
||||||
const existingNonce = cookies[CSRF_COOKIE_NAME];
|
|
||||||
|
|
||||||
// Reuse existing nonce; only generate fresh if no cookie exists yet
|
/**
|
||||||
const csrfNonce = existingNonce || generateToken();
|
* 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) {
|
||||||
|
const cookies = parseCookie(req.headers.cookie);
|
||||||
|
const existingNonce = cookies[CSRF_COOKIE_NAME];
|
||||||
|
|
||||||
// Store nonce + signature on request so endpoints can access them
|
// Reuse existing nonce; only generate fresh if no cookie exists yet
|
||||||
req.csrfToken = signToken(csrfNonce);
|
const csrfNonce = existingNonce || generateToken();
|
||||||
req.csrfNonce = csrfNonce;
|
|
||||||
|
|
||||||
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
|
// Store nonce + signature on request so endpoints can access them
|
||||||
if (!existingNonce) {
|
req.csrfToken = signToken(csrfNonce);
|
||||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
|
req.csrfNonce = csrfNonce;
|
||||||
httpOnly: false, // Must be readable by JavaScript for signing
|
|
||||||
secure: req.secure || req.protocol === 'https',
|
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
|
||||||
sameSite: 'strict',
|
if (!existingNonce) {
|
||||||
path: '/',
|
const cookieOpts = {
|
||||||
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
const csrfNonce = generateToken();
|
||||||
|
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 };
|
||||||
* 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) {
|
|
||||||
const csrfNonce = generateToken();
|
|
||||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, {
|
|
||||||
httpOnly: false,
|
|
||||||
secure: !!secure,
|
|
||||||
sameSite: 'strict',
|
|
||||||
path: '/',
|
|
||||||
maxAge: 365 * 24 * 60 * 60 * 1000
|
|
||||||
});
|
|
||||||
return signToken(csrfNonce);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -194,6 +212,9 @@ function csrfValidationMiddleware(req, res, next) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default instance (no domain) for backward compatibility with tests
|
||||||
|
const defaultInstance = createCSRFMiddleware();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
CSRF_TOKEN_LENGTH,
|
CSRF_TOKEN_LENGTH,
|
||||||
CSRF_COOKIE_NAME,
|
CSRF_COOKIE_NAME,
|
||||||
@@ -201,7 +222,9 @@ module.exports = {
|
|||||||
generateToken,
|
generateToken,
|
||||||
signToken,
|
signToken,
|
||||||
parseCookie,
|
parseCookie,
|
||||||
csrfCookieMiddleware,
|
createCSRFMiddleware,
|
||||||
csrfValidationMiddleware,
|
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 compression = require('compression');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const rateLimit = require('express-rate-limit');
|
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 { RATE_LIMITS, LIMITS, APP } = require('./constants');
|
||||||
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
||||||
|
|
||||||
@@ -75,7 +75,10 @@ module.exports = function configureMiddleware(app, {
|
|||||||
// ── Compress responses (gzip/brotli) ──
|
// ── Compress responses (gzip/brotli) ──
|
||||||
app.use(compression());
|
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(csrfCookieMiddleware);
|
||||||
app.use(csrfValidationMiddleware);
|
app.use(csrfValidationMiddleware);
|
||||||
|
|
||||||
@@ -221,8 +224,9 @@ module.exports = function configureMiddleware(app, {
|
|||||||
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
const key = cryptoUtils.loadOrCreateKey();
|
const key = cryptoUtils.loadOrCreateKey();
|
||||||
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
|
||||||
|
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||||
res.setHeader('Set-Cookie',
|
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) {
|
function clearSessionCookie(res) {
|
||||||
|
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
|
||||||
res.setHeader('Set-Cookie',
|
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 ──
|
// ── TOTP auth middleware ──
|
||||||
const totpAuthMiddleware = (req, res, next) => {
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TOTP is enabled — require a valid session, JWT, or API key
|
||||||
if (isPublicRoute(req)) return next();
|
if (isPublicRoute(req)) return next();
|
||||||
if (isSessionValid(req)) return next();
|
if (isSessionValid(req)) return next();
|
||||||
|
|
||||||
@@ -364,14 +376,7 @@ module.exports = function configureMiddleware(app, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!totpConfig.enabled || totpConfig.sessionDuration === 'never') {
|
// No valid auth — reject
|
||||||
req.auth = {
|
|
||||||
type: 'none',
|
|
||||||
scope: ['admin']
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
|
error: '[DC-110] Authentication required - provide TOTP session, JWT token, or API key',
|
||||||
@@ -428,6 +433,7 @@ module.exports = function configureMiddleware(app, {
|
|||||||
clearIPSession,
|
clearIPSession,
|
||||||
clearSessionCookie,
|
clearSessionCookie,
|
||||||
isSessionValid,
|
isSessionValid,
|
||||||
ipSessions
|
ipSessions,
|
||||||
|
renewCSRFToken
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing container before deployment
|
// 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 { appId } = req.body;
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
if (!template) throw new ValidationError('Invalid app template');
|
if (!template) throw new ValidationError('Invalid app template');
|
||||||
@@ -240,7 +240,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}, 'check-existing'));
|
}, 'check-existing'));
|
||||||
|
|
||||||
// Deploy new app
|
// Deploy new app
|
||||||
router.post('/apps/deploy', asyncHandler(async (req, res) => {
|
router.post('/deploy', asyncHandler(async (req, res) => {
|
||||||
const { appId, config } = req.body;
|
const { appId, config } = req.body;
|
||||||
if (!appId || typeof appId !== 'string') {
|
if (!appId || typeof appId !== 'string') {
|
||||||
throw new ValidationError('appId is required');
|
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
|
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
|
||||||
const subCtx = Object.assign({}, ctx, { helpers });
|
const subCtx = Object.assign({}, ctx, { helpers });
|
||||||
router.use(initDeploy(subCtx));
|
|
||||||
router.use(initRemoval(subCtx));
|
try { router.use('/deploy', initDeploy(subCtx)); }
|
||||||
router.use(initTemplates(subCtx));
|
catch(e) { (ctx.log || console).error('[apps] deploy routes init failed:', e.message); }
|
||||||
router.use(initRestore(subCtx));
|
|
||||||
router.use(initCompose(subCtx));
|
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;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ module.exports = function({
|
|||||||
* @param {Function} deps.safeErrorMessage - Safe error message formatter
|
* @param {Function} deps.safeErrorMessage - Safe error message formatter
|
||||||
* @returns {express.Router}
|
* @returns {express.Router}
|
||||||
*/
|
*/
|
||||||
router.delete('/apps/:appId', asyncHandler(async (req, res) => {
|
router.delete('/:appId', asyncHandler(async (req, res) => {
|
||||||
const { appId } = req.params;
|
const { appId } = req.params;
|
||||||
const { containerId, subdomain, ip, deleteContainer } = req.query;
|
const { containerId, subdomain, ip, deleteContainer } = req.query;
|
||||||
const shouldDeleteContainer = deleteContainer === 'true';
|
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.
|
* Pulls image, creates container, starts it, recreates Caddy config.
|
||||||
* Skips if container is already running.
|
* 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 { appId } = req.params;
|
||||||
const services = await servicesStateManager.read();
|
const services = await servicesStateManager.read();
|
||||||
const service = services.find(s => s.id === appId);
|
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.
|
* Restore all services that have deployment manifests.
|
||||||
* Returns per-service results.
|
* 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 services = await servicesStateManager.read();
|
||||||
const restoreable = services.filter(s => s.deploymentManifest);
|
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.
|
* 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 services = await servicesStateManager.read();
|
||||||
const status = [];
|
const status = [];
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ module.exports = function({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Get available app templates
|
// Get available app templates
|
||||||
router.get('/apps/templates', asyncHandler(async (req, res) => {
|
router.get('/templates', asyncHandler(async (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
templates: ctx.APP_TEMPLATES,
|
templates: ctx.APP_TEMPLATES,
|
||||||
@@ -51,7 +51,7 @@ module.exports = function({
|
|||||||
}, 'apps-templates'));
|
}, 'apps-templates'));
|
||||||
|
|
||||||
// Get specific app template
|
// 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 { appId } = req.params;
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -62,7 +62,7 @@ module.exports = function({
|
|||||||
}, 'apps-template-detail'));
|
}, 'apps-template-detail'));
|
||||||
|
|
||||||
// Check port availability
|
// 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 port = req.params.port;
|
||||||
const conflicts = await helpers.checkPortConflicts([port]);
|
const conflicts = await helpers.checkPortConflicts([port]);
|
||||||
if (conflicts.length > 0) {
|
if (conflicts.length > 0) {
|
||||||
@@ -74,7 +74,7 @@ module.exports = function({
|
|||||||
}, 'check-port'));
|
}, 'check-port'));
|
||||||
|
|
||||||
// Get suggested available 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 basePort = parseInt(req.params.basePort) || 8080;
|
||||||
const maxAttempts = 100;
|
const maxAttempts = 100;
|
||||||
const usedPorts = await docker.getUsedPorts();
|
const usedPorts = await docker.getUsedPorts();
|
||||||
@@ -88,7 +88,7 @@ module.exports = function({
|
|||||||
}, 'suggest-port'));
|
}, 'suggest-port'));
|
||||||
|
|
||||||
// Update subdomain for deployed app
|
// 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 { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
|
||||||
const { ValidationError } = require('../../errors');
|
const { ValidationError } = require('../../errors');
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ module.exports = function(ctx) {
|
|||||||
fetchT: ctx.fetchT,
|
fetchT: ctx.fetchT,
|
||||||
getServiceById: ctx.getServiceById,
|
getServiceById: ctx.getServiceById,
|
||||||
licenseManager: ctx.licenseManager,
|
licenseManager: ctx.licenseManager,
|
||||||
servicesStateManager: ctx.servicesStateManager
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
renewCSRFToken: ctx.middlewareResult?.renewCSRFToken
|
||||||
};
|
};
|
||||||
|
|
||||||
const { getAppSession, appSessionCache } = initSessionHandlers(deps);
|
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')}`;
|
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
break;
|
break;
|
||||||
case 'router': {
|
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`;
|
const routerBody = `username=${formEncode(username)}&password=${formEncode(password)}&Continue=Continue`;
|
||||||
try {
|
try {
|
||||||
const { spawnSync } = require('child_process');
|
const { spawnSync } = require('child_process');
|
||||||
@@ -121,7 +127,7 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
loginUrl = `${baseUrl}login`;
|
loginUrl = `${baseUrl.replace(/\/+$/, '')}/login`;
|
||||||
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
|
loginBody = `username=${formEncode(username)}&password=${formEncode(password)}&rememberMe=on`;
|
||||||
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
extraHeaders['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
break;
|
break;
|
||||||
@@ -168,7 +174,9 @@ module.exports = function({ authManager: _authManager, credentialManager: _crede
|
|||||||
|
|
||||||
const rawCookie = resp.headers.get('set-cookie');
|
const rawCookie = resp.headers.get('set-cookie');
|
||||||
if (rawCookie) {
|
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 });
|
appSessionCache.set(serviceId, { cookies, exp: Date.now() + SESSION_TTL.COOKIE_SESSION });
|
||||||
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
|
log.info('auth', 'Auto-login successful (fallback), session cached', { serviceId });
|
||||||
return cookies;
|
return cookies;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { renewCSRFToken } = require('../../csrf-protection');
|
|
||||||
const { ValidationError, AuthenticationError } = require('../../errors');
|
const { ValidationError, AuthenticationError } = require('../../errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +14,7 @@ const { ValidationError, AuthenticationError } = require('../../errors');
|
|||||||
* @param {Object} deps.log - Logger instance
|
* @param {Object} deps.log - Logger instance
|
||||||
* @returns {express.Router}
|
* @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();
|
const router = express.Router();
|
||||||
|
|
||||||
// Ctx shim for backward compatibility
|
// Ctx shim for backward compatibility
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ module.exports = function(ctx) {
|
|||||||
if (needsRegeneration) {
|
if (needsRegeneration) {
|
||||||
execSync(`openssl genrsa -out "${keyFile}" 2048`, { stdio: 'pipe' });
|
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' });
|
execSync(`openssl req -new -key "${keyFile}" -out "${csrFile}" -subj "${subject}"`, { stdio: 'pipe' });
|
||||||
|
|
||||||
const configContent = `[req]
|
const configContent = `[req]
|
||||||
@@ -189,7 +191,7 @@ req_extensions = v3_req
|
|||||||
prompt = no
|
prompt = no
|
||||||
|
|
||||||
[req_distinguished_name]
|
[req_distinguished_name]
|
||||||
CN = ${domain}
|
CN = ${safeDomain}
|
||||||
|
|
||||||
[v3_req]
|
[v3_req]
|
||||||
keyUsage = keyEncipherment, dataEncipherment, digitalSignature
|
keyUsage = keyEncipherment, dataEncipherment, digitalSignature
|
||||||
@@ -197,8 +199,8 @@ extendedKeyUsage = serverAuth
|
|||||||
subjectAltName = @alt_names
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
[alt_names]
|
[alt_names]
|
||||||
DNS.1 = ${domain}
|
DNS.1 = ${safeDomain}
|
||||||
${domain.includes('.') ? `DNS.2 = *.${domain}` : ''}`;
|
${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||||
|
|
||||||
const configFile = path.join(domainDir, 'openssl.cnf');
|
const configFile = path.join(domainDir, 'openssl.cnf');
|
||||||
await fsp.writeFile(configFile, configContent);
|
await fsp.writeFile(configFile, configContent);
|
||||||
|
|||||||
@@ -10,25 +10,61 @@ const docker = new Docker();
|
|||||||
* @param {http.Server} server - The HTTP server instance
|
* @param {http.Server} server - The HTTP server instance
|
||||||
* @param {Object} log - Logger
|
* @param {Object} log - Logger
|
||||||
*/
|
*/
|
||||||
module.exports = function attachExecWS(server, log) {
|
module.exports = function attachExecWS(server, log, authManager) {
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
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 parsed = url.parse(req.url, true);
|
||||||
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
|
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
|
||||||
if (!match) return; // Not our route — let other handlers deal with it
|
if (!match) return; // Not our route — let other handlers deal with it
|
||||||
|
|
||||||
const containerId = decodeURIComponent(match[1]);
|
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) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
handleExec(ws, containerId, log);
|
handleExec(ws, containerId, log, auth);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return wss;
|
return wss;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleExec(ws, containerId, log) {
|
async function handleExec(ws, containerId, log, auth) {
|
||||||
let execStream = null;
|
let execStream = null;
|
||||||
let execInstance = null;
|
let execInstance = null;
|
||||||
|
|
||||||
@@ -42,6 +78,12 @@ async function handleExec(ws, containerId, log) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info('exec', 'Authenticated exec session started', {
|
||||||
|
containerId,
|
||||||
|
authType: auth.type,
|
||||||
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
|
||||||
|
});
|
||||||
|
|
||||||
// Detect available shell
|
// Detect available shell
|
||||||
let shell = '/bin/sh';
|
let shell = '/bin/sh';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ module.exports = function({
|
|||||||
const caCert = fs.readFileSync(CA_CERT_PATH);
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
||||||
probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] });
|
probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] });
|
||||||
} catch (_) {
|
} 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) {
|
function isServiceUp(statusCode) {
|
||||||
@@ -195,8 +196,14 @@ module.exports = function({
|
|||||||
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
||||||
|
|
||||||
// Store credentials for a service
|
// 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;
|
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;
|
const { apiKey, username, password } = req.body;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
@@ -213,8 +220,14 @@ module.exports = function({
|
|||||||
}, 'store-service-creds'));
|
}, 'store-service-creds'));
|
||||||
|
|
||||||
// Delete credentials for a service
|
// 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;
|
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}.apikey`);
|
||||||
await credentialManager.delete(`service.${serviceId}.username`);
|
await credentialManager.delete(`service.${serviceId}.username`);
|
||||||
await credentialManager.delete(`service.${serviceId}.password`);
|
await credentialManager.delete(`service.${serviceId}.password`);
|
||||||
@@ -222,9 +235,13 @@ module.exports = function({
|
|||||||
}, 'delete-service-creds'));
|
}, 'delete-service-creds'));
|
||||||
|
|
||||||
// Check credential status for a service (what's stored)
|
// Check credential status for a service (what's stored)
|
||||||
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
router.get('/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||||
try {
|
const { serviceId } = req.params;
|
||||||
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 arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||||
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||||
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
@@ -233,9 +250,6 @@ module.exports = function({
|
|||||||
hasBasicAuth: !!username,
|
hasBasicAuth: !!username,
|
||||||
username: username || null
|
username: username || null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
success(res, { hasApiKey: false, hasBasicAuth: false });
|
|
||||||
}
|
|
||||||
}, 'service-creds'));
|
}, 'service-creds'));
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ process.on('uncaughtException', (error) => {
|
|||||||
environment: process.env.NODE_ENV || 'production'
|
environment: process.env.NODE_ENV || 'production'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attach WebSocket exec handler
|
// Attach WebSocket exec handler (with auth)
|
||||||
const attachExecWS = require('./routes/exec');
|
const attachExecWS = require('./routes/exec');
|
||||||
attachExecWS(server, log);
|
const authManager = require('./auth-manager');
|
||||||
log.info('server', 'WebSocket exec handler attached');
|
attachExecWS(server, log, authManager);
|
||||||
|
log.info('server', 'WebSocket exec handler attached (auth enforced)');
|
||||||
|
|
||||||
// Start feature modules
|
// Start feature modules
|
||||||
const resourceMonitor = require('./resource-monitor');
|
const resourceMonitor = require('./resource-monitor');
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const caRoutes = require('../routes/ca');
|
|||||||
const browseRoutes = require('../routes/browse');
|
const browseRoutes = require('../routes/browse');
|
||||||
const errorLogsRoutes = require('../routes/errorlogs');
|
const errorLogsRoutes = require('../routes/errorlogs');
|
||||||
const licenseRoutes = require('../routes/license');
|
const licenseRoutes = require('../routes/license');
|
||||||
|
const openClawRoutes = require('../routes/openclaw');
|
||||||
const recipesRoutes = require('../routes/recipes');
|
const recipesRoutes = require('../routes/recipes');
|
||||||
const themesRoutes = require('../routes/themes');
|
const themesRoutes = require('../routes/themes');
|
||||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||||
@@ -394,6 +395,7 @@ async function createApp() {
|
|||||||
}));
|
}));
|
||||||
apiRouter.use(arrRoutes(ctx));
|
apiRouter.use(arrRoutes(ctx));
|
||||||
apiRouter.use(appsRoutes(ctx));
|
apiRouter.use(appsRoutes(ctx));
|
||||||
|
apiRouter.use('/openclaw', openClawRoutes(ctx));
|
||||||
apiRouter.use(logsRoutes({
|
apiRouter.use(logsRoutes({
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
|
|||||||
@@ -160,6 +160,9 @@ function assembleContext({
|
|||||||
loadNotificationConfig,
|
loadNotificationConfig,
|
||||||
resyncHealthChecker,
|
resyncHealthChecker,
|
||||||
|
|
||||||
|
// Middleware result (exposes renewCSRFToken etc.)
|
||||||
|
middlewareResult,
|
||||||
|
|
||||||
// File paths
|
// File paths
|
||||||
SERVICES_FILE,
|
SERVICES_FILE,
|
||||||
CONFIG_FILE,
|
CONFIG_FILE,
|
||||||
|
|||||||
@@ -86,7 +86,13 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
|||||||
statusText: res.statusMessage,
|
statusText: res.statusMessage,
|
||||||
json: () => Promise.resolve(JSON.parse(data)),
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
text: () => Promise.resolve(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,
|
statusText: res.statusMessage,
|
||||||
json: () => Promise.resolve(JSON.parse(data)),
|
json: () => Promise.resolve(JSON.parse(data)),
|
||||||
text: () => Promise.resolve(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