refactor(routes): Phase 3.1 - standardize services.js with explicit dependencies
- Replaced god object ctx with explicit dependency injection - Added JSDoc documenting all required dependencies - Updated response calls to use response-helpers (success/error) - Maintained all existing functionality - Self-documenting: you can see exactly what this route needs - Easier testing: mock only what's actually used (14 deps vs 50+ ctx properties)
This commit is contained in:
@@ -9,8 +9,41 @@ const { validateServiceConfig, isValidPort } = require('../input-validator');
|
|||||||
const { exists } = require('../fs-helpers');
|
const { exists } = require('../fs-helpers');
|
||||||
const { paginate, parsePaginationParams } = require('../pagination');
|
const { paginate, parsePaginationParams } = require('../pagination');
|
||||||
const { resolveServiceUrl } = require('../url-resolver');
|
const { resolveServiceUrl } = require('../url-resolver');
|
||||||
|
const { success, error: errorResponse } = require('../response-helpers');
|
||||||
|
|
||||||
module.exports = function(ctx) {
|
/**
|
||||||
|
* Services route factory
|
||||||
|
* @param {Object} deps - Explicit dependencies
|
||||||
|
* @param {Object} deps.servicesStateManager - State manager for services.json
|
||||||
|
* @param {Object} deps.credentialManager - Credential storage manager
|
||||||
|
* @param {Object} deps.siteConfig - Site configuration
|
||||||
|
* @param {Function} deps.buildServiceUrl - URL builder function
|
||||||
|
* @param {Function} deps.buildDomain - Domain builder function
|
||||||
|
* @param {Function} deps.fetchT - Fetch wrapper with timeout
|
||||||
|
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||||
|
* @param {string} deps.SERVICES_FILE - Path to services.json
|
||||||
|
* @param {Object} deps.log - Logger instance
|
||||||
|
* @param {Function} deps.safeErrorMessage - Safe error message extractor
|
||||||
|
* @param {Function} deps.resyncHealthChecker - Health checker resync function
|
||||||
|
* @param {Object} deps.caddy - Caddy management interface
|
||||||
|
* @param {Object} deps.dns - DNS management interface
|
||||||
|
* @returns {express.Router}
|
||||||
|
*/
|
||||||
|
module.exports = function({
|
||||||
|
servicesStateManager,
|
||||||
|
credentialManager,
|
||||||
|
siteConfig,
|
||||||
|
buildServiceUrl,
|
||||||
|
buildDomain,
|
||||||
|
fetchT,
|
||||||
|
asyncHandler,
|
||||||
|
SERVICES_FILE,
|
||||||
|
log,
|
||||||
|
safeErrorMessage,
|
||||||
|
resyncHealthChecker,
|
||||||
|
caddy,
|
||||||
|
dns
|
||||||
|
}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
||||||
const PROBE_CONCURRENCY = 6;
|
const PROBE_CONCURRENCY = 6;
|
||||||
@@ -28,13 +61,13 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadServicesList() {
|
async function loadServicesList() {
|
||||||
if (!await exists(ctx.SERVICES_FILE)) return [];
|
if (!await exists(SERVICES_FILE)) return [];
|
||||||
const data = await ctx.servicesStateManager.read();
|
const data = await servicesStateManager.read();
|
||||||
return Array.isArray(data) ? data : data.services || [];
|
return Array.isArray(data) ? data : data.services || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProbeUrl(id, service) {
|
function resolveProbeUrl(id, service) {
|
||||||
return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl);
|
return resolveServiceUrl(id, service, siteConfig, buildServiceUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PROBE_TIMEOUT = 3000; // 3s — covers DNS + connect + response
|
const PROBE_TIMEOUT = 3000; // 3s — covers DNS + connect + response
|
||||||
@@ -72,7 +105,7 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function probeViaPylon(targetUrl) {
|
async function probeViaPylon(targetUrl) {
|
||||||
const pylonConfig = ctx.siteConfig?.pylon;
|
const pylonConfig = siteConfig?.pylon;
|
||||||
if (!pylonConfig?.url) return null;
|
if (!pylonConfig?.url) return null;
|
||||||
try {
|
try {
|
||||||
const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(targetUrl)}`;
|
const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(targetUrl)}`;
|
||||||
@@ -80,7 +113,7 @@ module.exports = function(ctx) {
|
|||||||
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
|
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
|
const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -106,7 +139,7 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Pylon relay fallback — if direct probe failed, try through the pylon
|
// Pylon relay fallback — if direct probe failed, try through the pylon
|
||||||
if (error && ctx.siteConfig?.pylon) {
|
if (error && siteConfig?.pylon) {
|
||||||
const pylonResult = await probeViaPylon(url);
|
const pylonResult = await probeViaPylon(url);
|
||||||
if (pylonResult && pylonResult.status) {
|
if (pylonResult && pylonResult.status) {
|
||||||
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
|
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
|
||||||
@@ -161,100 +194,99 @@ module.exports = function(ctx) {
|
|||||||
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
// ===== SERVICE CREDENTIAL ENDPOINTS =====
|
||||||
|
|
||||||
// Store credentials for a service
|
// Store credentials for a service
|
||||||
router.post('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => {
|
router.post('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||||
const { serviceId } = req.params;
|
const { serviceId } = req.params;
|
||||||
const { apiKey, username, password } = req.body;
|
const { apiKey, username, password } = req.body;
|
||||||
|
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
await ctx.credentialManager.store(`service.${serviceId}.apikey`, apiKey);
|
await credentialManager.store(`service.${serviceId}.apikey`, apiKey);
|
||||||
}
|
}
|
||||||
if (username) {
|
if (username) {
|
||||||
await ctx.credentialManager.store(`service.${serviceId}.username`, username);
|
await credentialManager.store(`service.${serviceId}.username`, username);
|
||||||
}
|
}
|
||||||
if (password) {
|
if (password) {
|
||||||
await ctx.credentialManager.store(`service.${serviceId}.password`, password);
|
await credentialManager.store(`service.${serviceId}.password`, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ success: true, message: `Credentials stored for ${serviceId}` });
|
success(res, { message: `Credentials stored for ${serviceId}` });
|
||||||
}, 'store-service-creds'));
|
}, 'store-service-creds'));
|
||||||
|
|
||||||
// Delete credentials for a service
|
// Delete credentials for a service
|
||||||
router.delete('/services/:serviceId/credentials', ctx.asyncHandler(async (req, res) => {
|
router.delete('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||||
const { serviceId } = req.params;
|
const { serviceId } = req.params;
|
||||||
await ctx.credentialManager.delete(`service.${serviceId}.apikey`);
|
await credentialManager.delete(`service.${serviceId}.apikey`);
|
||||||
await ctx.credentialManager.delete(`service.${serviceId}.username`);
|
await credentialManager.delete(`service.${serviceId}.username`);
|
||||||
await ctx.credentialManager.delete(`service.${serviceId}.password`);
|
await credentialManager.delete(`service.${serviceId}.password`);
|
||||||
res.json({ success: true, message: `Credentials removed for ${serviceId}` });
|
success(res, { message: `Credentials removed for ${serviceId}` });
|
||||||
}, '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', ctx.asyncHandler(async (req, res) => {
|
router.get('/services/:serviceId/credentials', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { serviceId } = req.params;
|
const { serviceId } = req.params;
|
||||||
const arrKey = await ctx.credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
const arrKey = await credentialManager.retrieve(`arr.${serviceId}.apikey`).catch(() => null);
|
||||||
const svcKey = await ctx.credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
const svcKey = await credentialManager.retrieve(`service.${serviceId}.apikey`).catch(() => null);
|
||||||
const username = await ctx.credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
const username = await credentialManager.retrieve(`service.${serviceId}.username`).catch(() => null);
|
||||||
res.json({
|
success(res, {
|
||||||
success: true,
|
|
||||||
hasApiKey: !!(arrKey || svcKey),
|
hasApiKey: !!(arrKey || svcKey),
|
||||||
hasBasicAuth: !!username,
|
hasBasicAuth: !!username,
|
||||||
username: username || null
|
username: username || null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: true, hasApiKey: false, hasBasicAuth: false });
|
success(res, { hasApiKey: false, hasBasicAuth: false });
|
||||||
}
|
}
|
||||||
}, 'service-creds'));
|
}, 'service-creds'));
|
||||||
|
|
||||||
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
// ===== SEEDHOST CREDENTIAL ENDPOINTS =====
|
||||||
|
|
||||||
// Store seedhost credentials (shared username + per-service passwords)
|
// Store seedhost credentials (shared username + per-service passwords)
|
||||||
router.post('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
|
router.post('/seedhost-creds', asyncHandler(async (req, res) => {
|
||||||
const { username, password, serviceId } = req.body;
|
const { username, password, serviceId } = req.body;
|
||||||
if (!username) {
|
if (!username) {
|
||||||
return ctx.errorResponse(res, 400, 'Username required');
|
return errorResponse(res, 'Username required', 400);
|
||||||
}
|
}
|
||||||
await ctx.credentialManager.store('seedhost.username', username);
|
await credentialManager.store('seedhost.username', username);
|
||||||
if (password) {
|
if (password) {
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await ctx.credentialManager.store(`seedhost.password.${serviceId}`, password);
|
await credentialManager.store(`seedhost.password.${serviceId}`, password);
|
||||||
} else {
|
} else {
|
||||||
await ctx.credentialManager.store('seedhost.password', password);
|
await credentialManager.store('seedhost.password', password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({ success: true, message: 'Seedhost credentials stored' });
|
success(res, { message: 'Seedhost credentials stored' });
|
||||||
}, 'store-seedhost-creds'));
|
}, 'store-seedhost-creds'));
|
||||||
|
|
||||||
// Get seedhost credential status
|
// Get seedhost credential status
|
||||||
router.get('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
|
router.get('/seedhost-creds', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const username = await ctx.credentialManager.retrieve('seedhost.username').catch(() => null);
|
const username = await credentialManager.retrieve('seedhost.username').catch(() => null);
|
||||||
const serviceId = req.query.serviceId;
|
const serviceId = req.query.serviceId;
|
||||||
let hasPassword = false;
|
let hasPassword = false;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
const svcPass = await ctx.credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
const svcPass = await credentialManager.retrieve(`seedhost.password.${serviceId}`).catch(() => null);
|
||||||
hasPassword = !!svcPass;
|
hasPassword = !!svcPass;
|
||||||
}
|
}
|
||||||
// Fall back to checking shared password
|
// Fall back to checking shared password
|
||||||
if (!hasPassword) {
|
if (!hasPassword) {
|
||||||
const sharedPass = await ctx.credentialManager.retrieve('seedhost.password').catch(() => null);
|
const sharedPass = await credentialManager.retrieve('seedhost.password').catch(() => null);
|
||||||
hasPassword = !!sharedPass;
|
hasPassword = !!sharedPass;
|
||||||
}
|
}
|
||||||
res.json({ success: true, hasCredentials: !!username && hasPassword, username: username || null, hasPassword });
|
success(res, { hasCredentials: !!username && hasPassword, username: username || null, hasPassword });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: true, hasCredentials: false });
|
success(res, { hasCredentials: false });
|
||||||
}
|
}
|
||||||
}, 'seedhost-creds'));
|
}, 'seedhost-creds'));
|
||||||
|
|
||||||
// Delete seedhost credentials
|
// Delete seedhost credentials
|
||||||
router.delete('/seedhost-creds', ctx.asyncHandler(async (req, res) => {
|
router.delete('/seedhost-creds', asyncHandler(async (req, res) => {
|
||||||
const serviceId = req.query.serviceId;
|
const serviceId = req.query.serviceId;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await ctx.credentialManager.delete(`seedhost.password.${serviceId}`);
|
await credentialManager.delete(`seedhost.password.${serviceId}`);
|
||||||
res.json({ success: true, message: `Password for ${serviceId} removed` });
|
success(res, { message: `Password for ${serviceId} removed` });
|
||||||
} else {
|
} else {
|
||||||
await ctx.credentialManager.delete('seedhost.username');
|
await credentialManager.delete('seedhost.username');
|
||||||
await ctx.credentialManager.delete('seedhost.password');
|
await credentialManager.delete('seedhost.password');
|
||||||
res.json({ success: true, message: 'Seedhost credentials removed' });
|
success(res, { message: 'Seedhost credentials removed' });
|
||||||
}
|
}
|
||||||
}, 'delete-seedhost-creds'));
|
}, 'delete-seedhost-creds'));
|
||||||
|
|
||||||
@@ -263,7 +295,7 @@ module.exports = function(ctx) {
|
|||||||
// Batched live status for dashboard cards
|
// Batched live status for dashboard cards
|
||||||
const STATUS_DEADLINE = 10000; // 10s — return whatever we have by then
|
const STATUS_DEADLINE = 10000; // 10s — return whatever we have by then
|
||||||
|
|
||||||
router.get('/services/status', ctx.asyncHandler(async (req, res) => {
|
router.get('/services/status', asyncHandler(async (req, res) => {
|
||||||
const services = await loadServicesList();
|
const services = await loadServicesList();
|
||||||
const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s]));
|
const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s]));
|
||||||
const ids = [];
|
const ids = [];
|
||||||
@@ -276,7 +308,7 @@ module.exports = function(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addId('internet');
|
addId('internet');
|
||||||
Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId);
|
Object.keys(siteConfig?.dnsServers || {}).forEach(addId);
|
||||||
services.forEach(service => addId(service.id));
|
services.forEach(service => addId(service.id));
|
||||||
|
|
||||||
// Collect results as they arrive; deadline returns whatever we have
|
// Collect results as they arrive; deadline returns whatever we have
|
||||||
@@ -300,8 +332,7 @@ module.exports = function(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
res.set('Cache-Control', 'no-store');
|
res.set('Cache-Control', 'no-store');
|
||||||
res.json({
|
success(res, {
|
||||||
success: true,
|
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
partial,
|
partial,
|
||||||
statuses
|
statuses
|
||||||
@@ -309,37 +340,37 @@ module.exports = function(ctx) {
|
|||||||
}, 'services-status'));
|
}, 'services-status'));
|
||||||
|
|
||||||
// List all services
|
// List all services
|
||||||
router.get('/services', ctx.asyncHandler(async (req, res) => {
|
router.get('/services', asyncHandler(async (req, res) => {
|
||||||
if (!await exists(ctx.SERVICES_FILE)) {
|
if (!await exists(SERVICES_FILE)) {
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
}
|
}
|
||||||
const services = await ctx.servicesStateManager.read();
|
const services = await servicesStateManager.read();
|
||||||
const paginationParams = parsePaginationParams(req.query);
|
const paginationParams = parsePaginationParams(req.query);
|
||||||
const result = paginate(services, paginationParams);
|
const result = paginate(services, paginationParams);
|
||||||
if (paginationParams) {
|
if (paginationParams) {
|
||||||
res.json({ success: true, services: result.data, pagination: result.pagination });
|
success(res, { services: result.data, pagination: result.pagination });
|
||||||
} else {
|
} else {
|
||||||
res.json(result.data);
|
res.json(result.data);
|
||||||
}
|
}
|
||||||
}, 'services-list'));
|
}, 'services-list'));
|
||||||
|
|
||||||
// Add a new service
|
// Add a new service
|
||||||
router.post('/services', ctx.asyncHandler(async (req, res) => {
|
router.post('/services', asyncHandler(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id, name, logo } = req.body;
|
const { id, name, logo } = req.body;
|
||||||
|
|
||||||
if (!id || !name) {
|
if (!id || !name) {
|
||||||
return ctx.errorResponse(res, 400, 'id and name are required');
|
return errorResponse(res, 'id and name are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate service configuration
|
// Validate service configuration
|
||||||
try {
|
try {
|
||||||
validateServiceConfig({ id, name });
|
validateServiceConfig({ id, name });
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, validationErr.message, { errors: validationErr.errors });
|
return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.servicesStateManager.update(services => {
|
await servicesStateManager.update(services => {
|
||||||
// Check if service already exists
|
// Check if service already exists
|
||||||
if (services.find(s => s.id === id)) {
|
if (services.find(s => s.id === id)) {
|
||||||
throw new Error(`Service "${id}" already exists`);
|
throw new Error(`Service "${id}" already exists`);
|
||||||
@@ -349,57 +380,56 @@ module.exports = function(ctx) {
|
|||||||
return services;
|
return services;
|
||||||
});
|
});
|
||||||
|
|
||||||
ctx.resyncHealthChecker?.().catch(() => {});
|
resyncHealthChecker?.().catch(() => {});
|
||||||
res.json({ success: true, message: `Service "${name}" added to dashboard` });
|
success(res, { message: `Service "${name}" added to dashboard` });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.log.error('deploy', 'Error adding service', { error: error.message });
|
log.error('deploy', 'Error adding service', { error: error.message });
|
||||||
if (error.message.includes('already exists')) {
|
if (error.message.includes('already exists')) {
|
||||||
ctx.errorResponse(res, 409, ctx.safeErrorMessage(error));
|
errorResponse(res, safeErrorMessage(error), 409);
|
||||||
} else {
|
} else {
|
||||||
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, safeErrorMessage(error), 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 'services-update'));
|
}, 'services-update'));
|
||||||
|
|
||||||
// Bulk import/replace services (for dashboard import feature)
|
// Bulk import/replace services (for dashboard import feature)
|
||||||
router.put('/services', ctx.asyncHandler(async (req, res) => {
|
router.put('/services', asyncHandler(async (req, res) => {
|
||||||
const services = req.body;
|
const services = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(services)) {
|
if (!Array.isArray(services)) {
|
||||||
return ctx.errorResponse(res, 400, 'Request body must be an array of services');
|
return errorResponse(res, 'Request body must be an array of services', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const service of services) {
|
for (const service of services) {
|
||||||
if (!service.id || !service.name) {
|
if (!service.id || !service.name) {
|
||||||
return ctx.errorResponse(res, 400, 'Each service must have id and name fields');
|
return errorResponse(res, 'Each service must have id and name fields', 400);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
validateServiceConfig(service);
|
validateServiceConfig(service);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return ctx.errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors });
|
return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.servicesStateManager.write(services);
|
await servicesStateManager.write(services);
|
||||||
ctx.resyncHealthChecker?.().catch(() => {});
|
resyncHealthChecker?.().catch(() => {});
|
||||||
|
|
||||||
res.json({
|
success(res, {
|
||||||
success: true,
|
|
||||||
message: `Successfully imported ${services.length} services`,
|
message: `Successfully imported ${services.length} services`,
|
||||||
count: services.length
|
count: services.length
|
||||||
});
|
});
|
||||||
}, 'services-import'));
|
}, 'services-import'));
|
||||||
|
|
||||||
// Delete a service
|
// Delete a service
|
||||||
router.delete('/services/:id', ctx.asyncHandler(async (req, res) => {
|
router.delete('/services/:id', asyncHandler(async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
if (!await exists(ctx.SERVICES_FILE)) {
|
if (!await exists(SERVICES_FILE)) {
|
||||||
return ctx.errorResponse(res, 404, 'No services found');
|
return errorResponse(res, 'No services found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = false;
|
let found = false;
|
||||||
await ctx.servicesStateManager.update(services => {
|
await servicesStateManager.update(services => {
|
||||||
const initialLength = services.length;
|
const initialLength = services.length;
|
||||||
const filtered = services.filter(s => s.id !== id);
|
const filtered = services.filter(s => s.id !== id);
|
||||||
found = filtered.length !== initialLength;
|
found = filtered.length !== initialLength;
|
||||||
@@ -407,39 +437,39 @@ module.exports = function(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return ctx.errorResponse(res, 404, `Service "${id}" not found`);
|
return errorResponse(res, `Service "${id}" not found`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.resyncHealthChecker?.().catch(() => {});
|
resyncHealthChecker?.().catch(() => {});
|
||||||
res.json({ success: true, message: `Service "${id}" removed from dashboard` });
|
success(res, { message: `Service "${id}" removed from dashboard` });
|
||||||
}, 'services-delete'));
|
}, 'services-delete'));
|
||||||
|
|
||||||
// Update service configuration (subdomain, port, IP, tailscale, name, logo)
|
// Update service configuration (subdomain, port, IP, tailscale, name, logo)
|
||||||
router.post('/services/update', ctx.asyncHandler(async (req, res) => {
|
router.post('/services/update', asyncHandler(async (req, res) => {
|
||||||
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body;
|
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = req.body;
|
||||||
|
|
||||||
if (!oldSubdomain || !newSubdomain) {
|
if (!oldSubdomain || !newSubdomain) {
|
||||||
return ctx.errorResponse(res, 400, 'oldSubdomain and newSubdomain are required');
|
return errorResponse(res, 'oldSubdomain and newSubdomain are required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) {
|
if (!REGEX.SUBDOMAIN.test(oldSubdomain) || !REGEX.SUBDOMAIN.test(newSubdomain)) {
|
||||||
return ctx.errorResponse(res, 400, '[DC-301] Invalid subdomain format');
|
return errorResponse(res, '[DC-301] Invalid subdomain format', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (port && !isValidPort(port)) {
|
if (port && !isValidPort(port)) {
|
||||||
return ctx.errorResponse(res, 400, 'Invalid port number (must be 1-65535)');
|
return errorResponse(res, 'Invalid port number (must be 1-65535)', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) {
|
if (ip && ip !== 'localhost' && !validatorLib.isIP(ip)) {
|
||||||
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address');
|
return errorResponse(res, '[DC-210] Invalid IP address', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = { dns: null, caddy: null, services: null };
|
const results = { dns: null, caddy: null, services: null };
|
||||||
|
|
||||||
const oldDomain = ctx.buildDomain(oldSubdomain);
|
const oldDomain = buildDomain(oldSubdomain);
|
||||||
const newDomain = ctx.buildDomain(newSubdomain);
|
const newDomain = buildDomain(newSubdomain);
|
||||||
|
|
||||||
let content = await ctx.caddy.read();
|
let content = await caddy.read();
|
||||||
|
|
||||||
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const siteBlockRegex = new RegExp(
|
const siteBlockRegex = new RegExp(
|
||||||
@@ -456,11 +486,11 @@ module.exports = function(ctx) {
|
|||||||
const finalIp = ip || existingIp;
|
const finalIp = ip || existingIp;
|
||||||
const finalPort = port || existingPort;
|
const finalPort = port || existingPort;
|
||||||
|
|
||||||
const newConfig = ctx.caddy.generateConfig(newSubdomain, finalIp, finalPort, {
|
const newConfig = caddy.generateConfig(newSubdomain, finalIp, finalPort, {
|
||||||
tailscaleOnly: tailscaleOnly || false
|
tailscaleOnly: tailscaleOnly || false
|
||||||
});
|
});
|
||||||
|
|
||||||
const caddyResult = await ctx.caddy.modify(c => c.replace(siteBlockRegex, newConfig));
|
const caddyResult = await caddy.modify(c => c.replace(siteBlockRegex, newConfig));
|
||||||
results.caddy = caddyResult.success ? 'updated' : `config saved, reload failed: ${caddyResult.error}`;
|
results.caddy = caddyResult.success ? 'updated' : `config saved, reload failed: ${caddyResult.error}`;
|
||||||
} else {
|
} else {
|
||||||
results.caddy = 'old config not found';
|
results.caddy = 'old config not found';
|
||||||
@@ -468,9 +498,9 @@ module.exports = function(ctx) {
|
|||||||
|
|
||||||
if (oldSubdomain !== newSubdomain) {
|
if (oldSubdomain !== newSubdomain) {
|
||||||
try {
|
try {
|
||||||
const dnsToken = ctx.dns.getToken();
|
const dnsToken = dns.getToken();
|
||||||
await ctx.dns.call(ctx.siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
|
await dns.call(siteConfig.dnsServerIp, '/api/zones/records/delete', { token: dnsToken, domain: oldDomain, type: 'A' });
|
||||||
await ctx.dns.createRecord(newSubdomain, ip || 'localhost');
|
await dns.createRecord(newSubdomain, ip || 'localhost');
|
||||||
results.dns = 'updated';
|
results.dns = 'updated';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.dns = `failed: ${e.message}`;
|
results.dns = `failed: ${e.message}`;
|
||||||
@@ -479,8 +509,8 @@ module.exports = function(ctx) {
|
|||||||
results.dns = 'unchanged';
|
results.dns = 'unchanged';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await exists(ctx.SERVICES_FILE)) {
|
if (await exists(SERVICES_FILE)) {
|
||||||
await ctx.servicesStateManager.update(services => {
|
await servicesStateManager.update(services => {
|
||||||
const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.url?.includes(oldSubdomain));
|
const serviceIndex = services.findIndex(s => s.id === oldSubdomain || s.url?.includes(oldSubdomain));
|
||||||
if (serviceIndex !== -1) {
|
if (serviceIndex !== -1) {
|
||||||
const existing = services[serviceIndex];
|
const existing = services[serviceIndex];
|
||||||
@@ -493,7 +523,7 @@ module.exports = function(ctx) {
|
|||||||
port: finalPort,
|
port: finalPort,
|
||||||
ip: finalIp,
|
ip: finalIp,
|
||||||
tailscaleOnly: tailscaleOnly || false,
|
tailscaleOnly: tailscaleOnly || false,
|
||||||
url: ctx.buildServiceUrl(newSubdomain)
|
url: buildServiceUrl(newSubdomain)
|
||||||
};
|
};
|
||||||
if (name) services[serviceIndex].name = name;
|
if (name) services[serviceIndex].name = name;
|
||||||
if (logo) services[serviceIndex].logo = logo;
|
if (logo) services[serviceIndex].logo = logo;
|
||||||
@@ -505,9 +535,8 @@ module.exports = function(ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.resyncHealthChecker?.().catch(() => {});
|
resyncHealthChecker?.().catch(() => {});
|
||||||
res.json({
|
success(res, {
|
||||||
success: true,
|
|
||||||
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
|
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1188,7 +1188,21 @@ apiRouter.use(configRoutes(ctx));
|
|||||||
apiRouter.use('/dns', dnsRoutes(ctx));
|
apiRouter.use('/dns', dnsRoutes(ctx));
|
||||||
apiRouter.use('/notifications', notificationRoutes(ctx));
|
apiRouter.use('/notifications', notificationRoutes(ctx));
|
||||||
apiRouter.use('/containers', containerRoutes(ctx));
|
apiRouter.use('/containers', containerRoutes(ctx));
|
||||||
apiRouter.use(serviceRoutes(ctx));
|
apiRouter.use(serviceRoutes({
|
||||||
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
credentialManager: ctx.credentialManager,
|
||||||
|
siteConfig: ctx.siteConfig,
|
||||||
|
buildServiceUrl: ctx.buildServiceUrl,
|
||||||
|
buildDomain: ctx.buildDomain,
|
||||||
|
fetchT: ctx.fetchT,
|
||||||
|
asyncHandler: ctx.asyncHandler,
|
||||||
|
SERVICES_FILE: ctx.SERVICES_FILE,
|
||||||
|
log: ctx.log,
|
||||||
|
safeErrorMessage: ctx.safeErrorMessage,
|
||||||
|
resyncHealthChecker: ctx.resyncHealthChecker,
|
||||||
|
caddy: ctx.caddy,
|
||||||
|
dns: ctx.dns
|
||||||
|
}));
|
||||||
apiRouter.use(healthRoutes(ctx));
|
apiRouter.use(healthRoutes(ctx));
|
||||||
apiRouter.use(monitoringRoutes(ctx));
|
apiRouter.use(monitoringRoutes(ctx));
|
||||||
apiRouter.use(updatesRoutes(ctx));
|
apiRouter.use(updatesRoutes(ctx));
|
||||||
|
|||||||
Reference in New Issue
Block a user