Files
dashcaddy/dashcaddy-api/routes/services.js
Sami f61e85d9a7 Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.
2026-03-05 02:26:12 -08:00

310 lines
11 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const validatorLib = require('validator');
const { REGEX } = require('../constants');
const { validateServiceConfig, isValidPort } = require('../input-validator');
const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination');
module.exports = function(ctx) {
const router = express.Router();
// ===== SERVICE CREDENTIAL ENDPOINTS =====
// Store credentials for a service
router.post('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => {
const { serviceId } = req.params;
const { apiKey, username, password } = req.body;
if (apiKey) {
await ctx.credentialManager.store(`service.${serviceId}.apikey`, apiKey);
}
if (username) {
await ctx.credentialManager.store(`service.${serviceId}.username`, username);
}
if (password) {
await ctx.credentialManager.store(`service.${serviceId}.password`, password);
}
res.json({ success: true, message: `Credentials stored for ${serviceId}` });
}, 'store-service-creds'));
// Delete credentials for a service
router.delete('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => {
const { serviceId } = req.params;
await ctx.credentialManager.delete(`service.${serviceId}.apikey`);
await ctx.credentialManager.delete(`service.${serviceId}.username`);
await ctx.credentialManager.delete(`service.${serviceId}.password`);
res.json({ success: true, message: `Credentials removed for ${serviceId}` });
}, 'delete-service-creds'));
// Check credential status for a service (what's stored)
router.get('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => {
try {
const { serviceId } = req.params;
const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
res.json({
success: true,
hasApiKey: !!(arrKey || svcKey),
hasBasicAuth: !!username,
username: username || null
});
} catch (error) {
res.json({ success: true, hasApiKey: false, hasBasicAuth: false });
}
}, 'service-creds'));
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
// Store seedhost credentials (shared username + per-service passwords)
router.post('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
const { username, password, serviceId } = req.body;
if (!username) {
return ctx.errorResponse(res, 400, 'Username required');
}
await ctx.credentialManager.store('seedhost.username', username);
if (password) {
if (serviceId) {
await ctx.credentialManager.store(`seedhost.password.${serviceId}`, password);
} else {
await ctx.credentialManager.store('seedhost.password', password);
}
}
res.json({ success: true, message: 'Seedhost credentials stored' });
}, 'store-seedhost-creds'));
// Get seedhost credential status
router.get('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
try {
const username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
const serviceId = req.query.serviceId;
let hasPassword = false;
if (serviceId) {
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
hasPassword = !!svcPass;
}
// Fall back to checking shared password
if (!hasPassword) {
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
hasPassword = !!sharedPass;
}
res.json({ success: true, hasCredentials: !!username && hasPassword, username: username || null, hasPassword });
} catch (error) {
res.json({ success: true, hasCredentials: false });
}
}, 'seedhost-creds'));
// Delete seedhost credentials
router.delete('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
const serviceId = req.query.serviceId;
if (serviceId) {
await ctx.credentialManager.delete(`seedhost.password.${serviceId}`);
res.json({ success: true, message: `Password for ${serviceId} removed` });
} else {
await ctx.credentialManager.delete('seedhost.username');
await ctx.credentialManager.delete('seedhost.password');
res.json({ success: true, message: 'Seedhost credentials removed' });
}
}, 'delete-seedhost-creds'));
// ===== SERVICE CRUD ENDPOINTS =====
// List all services
router.get('/services', ctx.asyncHandler(async (req, res) => {
if (!await exists(ctx.SERVICES_FILE)) {
return res.json([]);
}
const services = await ctx.servicesStateManager.read();
const paginationParams = parsePaginationParams(req.query);
const result = paginate(services, paginationParams);
if (paginationParams) {
res.json({ success: true, services: result.data, pagination: result.pagination });
} else {
res.json(result.data);
}
}, 'services-list'));
// Add a new service
router.post('/services', ctx.asyncHandler(async (req, res) => {
try {
const { id, name, logo } = req.body;
if (!id || !name) {
return ctx.errorResponse(res, 400, 'id and name are required');
}
// Validate service configuration
try {
validateServiceConfig({ id, name });
} catch (validationErr) {
return ctx.errorResponse(res, 400, validationErr.message, { errors: validationErr.errors });
}
await ctx.servicesStateManager.update(services => {
// Check if service already exists
if (services.find(s => s.id === id)) {
throw new Error(`Service "${id}" already exists`);
}
services.push({ id, name, logo: logo || `/assets/${id}.png` });
return services;
});
res.json({ success: true, message: `Service "${name}" added to dashboard` });
} catch (error) {
ctx.log.error('deploy', 'Error adding service', { error: error.message });
if (error.message.includes('already exists')) {
ctx.errorResponse(res, 409, ctx.safeErrorMessage(error));
} else {
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
}
}
}, 'services-update'));
// Bulk import/replace services (for dashboard import feature)
router.put('/services', ctx.asyncHandler(async (req, res) => {
const services = req.body;
if (!Array.isArray(services)) {
return ctx.errorResponse(res, 400, 'Request body must be an array of services');
}
for (const service of services) {
if (!service.id || !service.name) {
return ctx.errorResponse(res, 400, 'Each service must have id and name fields');
}
try {
validateServiceConfig(service);
} catch (validationErr) {
return ctx.errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors });
}
}
await ctx.servicesStateManager.write(services);
res.json({
success: true,
message: `Successfully imported ${services.length} services`,
count: services.length
});
}, 'services-import'));
// Delete a service
router.delete('/services/:id', ctx.asyncHandler(async (req, res) => {
const { id } = req.params;
if (!await exists(ctx.SERVICES_FILE)) {
return ctx.errorResponse(res, 404, 'No services found');
}
let found = false;
await ctx.servicesStateManager.update(services => {
const initialLength = services.length;
const filtered = services.filter(s => s.id !== id);
found = filtered.length !== initialLength;
return filtered;
});
if (!found) {
return ctx.errorResponse(res, 404, `Service "${id}" not found`);
}
res.json({ success: true, message: `Service "${id}" removed from dashboard` });
}, 'services-delete'));
// Update service configuration (subdomain, port, IP, tailscale)
router.post('/services/update', ctx.asyncHandler(async (req, res) => {
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly } = req.body;
if (!oldSubdomain || !newSubdomain) {
return ctx.errorResponse(res, 400, 'oldSubdomain and newSubdomain are required');
}
if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
}
if (port && !isValidPort(port)) {
return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)');
}
if (ip && !validatorLib.isIP(ip)) {
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address');
}
const results = { dns: null, caddy: null, services: null };
const oldDomain = ctx.buildDomain(oldSubdomain);
const newDomain = ctx.buildDomain(newSubdomain);
let content = await ctx.caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(
`${escapedOldDomain}\\s*\\{[^{}]*(?:\\{[^{}]*(?:\\{[^{}]*\\}[^{}]*)*\\}[^{}]*)*\\}`,
's'
);
const oldBlockMatch = content.match(siteBlockRegex);
if (oldBlockMatch) {
const proxyMatch = oldBlockMatch[0].match(/reverse_proxy\s+([^\s\n]+)/);
const existingTarget = proxyMatch ? proxyMatch[1] : null;
const [existingIp, existingPort] = existingTarget ? existingTarget.split(':') : ['localhost', '80'];
const finalIp = ip || existingIp;
const finalPort = port || existingPort;
const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, {
tailscaleOnly: tailscaleOnly || false
});
const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig));
results.caddy = caddyResult.success ? 'updated' : `config saved, reload failed: ${caddyResult.error}`;
} else {
results.caddy = 'old config not found';
}
if (oldSubdomain !== newSubdomain) {
try {
const dnsToken = ctx.dns.getToken();
await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
results.dns = 'updated';
} catch (e) {
results.dns = `failed: ${e.message}`;
}
} else {
results.dns = 'unchanged';
}
if (await exists(ctx.SERVICES_FILE)) {
await ctx.servicesStateManager.update(services => {
const serviceIndex = services.findIndex(s => s.id === oldSubdomain);
if (serviceIndex !== -1) {
services[serviceIndex] = {
...services[serviceIndex],
id: newSubdomain,
port: port || services[serviceIndex].port,
ip: ip || services[serviceIndex].ip,
tailscaleOnly: tailscaleOnly || false
};
results.services = 'updated';
} else {
results.services = 'not found';
}
return services;
});
}
res.json({
success: true,
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
results
});
}, 'services-update'));
return router;
};