From e07375f64291b9822f067dbfbb5023250170c9de Mon Sep 17 00:00:00 2001 From: Hermes Date: Wed, 27 May 2026 22:20:21 -0700 Subject: [PATCH] fix: mount openclaw routes at /openclaw prefix + fix docker.client wrapper + strip duplicate /apps/ paths across sub-routers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 / --- dashcaddy-api/routes/apps/deploy.js | 4 +- dashcaddy-api/routes/apps/index.js | 20 +- dashcaddy-api/routes/apps/removal.js | 2 +- dashcaddy-api/routes/apps/restore.js | 6 +- dashcaddy-api/routes/apps/templates.js | 10 +- dashcaddy-api/src/app.js | 2 + routes/openclaw.js | 272 +++++++++++++++++++++++++ 7 files changed, 300 insertions(+), 16 deletions(-) create mode 100644 routes/openclaw.js diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index b419b17..c884c81 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -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'); diff --git a/dashcaddy-api/routes/apps/index.js b/dashcaddy-api/routes/apps/index.js index cc8945e..2b2fa03 100644 --- a/dashcaddy-api/routes/apps/index.js +++ b/dashcaddy-api/routes/apps/index.js @@ -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; }; diff --git a/dashcaddy-api/routes/apps/removal.js b/dashcaddy-api/routes/apps/removal.js index 5295a68..1e000a0 100644 --- a/dashcaddy-api/routes/apps/removal.js +++ b/dashcaddy-api/routes/apps/removal.js @@ -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'; diff --git a/dashcaddy-api/routes/apps/restore.js b/dashcaddy-api/routes/apps/restore.js index 5b37596..4fb65e6 100644 --- a/dashcaddy-api/routes/apps/restore.js +++ b/dashcaddy-api/routes/apps/restore.js @@ -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 = []; diff --git a/dashcaddy-api/routes/apps/templates.js b/dashcaddy-api/routes/apps/templates.js index 7ce0750..d073082 100644 --- a/dashcaddy-api/routes/apps/templates.js +++ b/dashcaddy-api/routes/apps/templates.js @@ -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'); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index cb7f5e8..0769b5f 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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, diff --git a/routes/openclaw.js b/routes/openclaw.js new file mode 100644 index 0000000..c4e6689 --- /dev/null +++ b/routes/openclaw.js @@ -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; +}