The dashboard uses /api/v1/services/status (not /api/health/services) for live status cards. This endpoint was missing pylon relay fallback, so services unreachable from the Docker container showed as OFF even when the pylon was running. Also adds Windows VBS startup wrapper for pylon persistence across reboots. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
510 lines
17 KiB
JavaScript
510 lines
17 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs');
|
|
const http = require('http');
|
|
const https = require('https');
|
|
const tls = require('tls');
|
|
const validatorLib = require('validator');
|
|
const { APP, REGEX, TIMEOUTS } = require('../constants');
|
|
const { validateServiceConfig, isValidPort } = require('../input-validator');
|
|
const { exists } = require('../fs-helpers');
|
|
const { paginate, parsePaginationParams } = require('../pagination');
|
|
const { resolveServiceUrl } = require('../url-resolver');
|
|
|
|
module.exports = function(ctx) {
|
|
const router = express.Router();
|
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
|
|
const PROBE_CONCURRENCY = 6;
|
|
let probeHttpsAgent;
|
|
|
|
try {
|
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
|
probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] });
|
|
} catch (_) {
|
|
probeHttpsAgent = new https.Agent();
|
|
}
|
|
|
|
function isServiceUp(statusCode) {
|
|
return (statusCode >= 200 && statusCode < 400) || statusCode === 401 || statusCode === 403;
|
|
}
|
|
|
|
async function loadServicesList() {
|
|
if (!await exists(ctx.SERVICES_FILE)) return [];
|
|
const data = await ctx.servicesStateManager.read();
|
|
return Array.isArray(data) ? data : data.services || [];
|
|
}
|
|
|
|
function resolveProbeUrl(id, service) {
|
|
return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl);
|
|
}
|
|
|
|
function requestStatusCode(url, method) {
|
|
const parsed = new URL(url);
|
|
const isHttps = parsed.protocol === 'https:';
|
|
const lib = isHttps ? https : http;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const req = lib.request({
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || (isHttps ? 443 : 80),
|
|
path: parsed.pathname + parsed.search,
|
|
method,
|
|
timeout: TIMEOUTS.HTTP_DEFAULT,
|
|
agent: isHttps ? probeHttpsAgent : undefined,
|
|
headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
|
|
}, (response) => {
|
|
response.resume();
|
|
resolve(response.statusCode || 0);
|
|
});
|
|
|
|
req.on('error', reject);
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
reject(new Error('Timeout'));
|
|
});
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
async function probeViaPylon(targetUrl) {
|
|
const pylonConfig = ctx.siteConfig?.pylon;
|
|
if (!pylonConfig?.url) return null;
|
|
try {
|
|
const probeUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(targetUrl)}`;
|
|
const headers = {};
|
|
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 12000);
|
|
const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
|
|
clearTimeout(timeout);
|
|
if (!response.ok) return null;
|
|
const data = await response.json();
|
|
return data;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function probeServiceStatus(id, service) {
|
|
const startedAt = process.hrtime.bigint();
|
|
let url = resolveProbeUrl(id, service);
|
|
let statusCode = 502;
|
|
let error = null;
|
|
|
|
try {
|
|
statusCode = await requestStatusCode(url, 'HEAD');
|
|
if (statusCode === 405 || statusCode === 501) {
|
|
statusCode = await requestStatusCode(url, 'GET');
|
|
}
|
|
} catch (primaryError) {
|
|
error = primaryError;
|
|
if (id !== 'internet') {
|
|
const fallbackUrl = ctx.buildServiceUrl(id);
|
|
if (fallbackUrl !== url) {
|
|
try {
|
|
statusCode = await requestStatusCode(fallbackUrl, 'GET');
|
|
url = fallbackUrl;
|
|
error = null;
|
|
} catch (fallbackError) {
|
|
error = fallbackError;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pylon relay fallback — if direct probes failed, try through the pylon
|
|
if (error && ctx.siteConfig?.pylon) {
|
|
const pylonResult = await probeViaPylon(url);
|
|
if (pylonResult && pylonResult.status) {
|
|
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
|
|
return {
|
|
id,
|
|
isUp: pylonResult.status === 'healthy',
|
|
statusCode: pylonResult.statusCode || 0,
|
|
responseTime,
|
|
url,
|
|
via: 'pylon'
|
|
};
|
|
}
|
|
}
|
|
|
|
const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n);
|
|
if (error) {
|
|
return {
|
|
id,
|
|
isUp: false,
|
|
statusCode: 502,
|
|
responseTime,
|
|
error: error.message
|
|
};
|
|
}
|
|
|
|
return {
|
|
id,
|
|
isUp: isServiceUp(statusCode),
|
|
statusCode,
|
|
responseTime,
|
|
url
|
|
};
|
|
}
|
|
|
|
async function mapWithConcurrency(items, limit, worker) {
|
|
const results = new Array(items.length);
|
|
let cursor = 0;
|
|
|
|
async function next() {
|
|
while (true) {
|
|
const index = cursor++;
|
|
if (index >= items.length) return;
|
|
results[index] = await worker(items[index], index);
|
|
}
|
|
}
|
|
|
|
const workers = Array.from({ length: Math.min(limit, items.length || 1) }, () => next());
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|
|
|
|
// ===== 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 =====
|
|
|
|
// Batched live status for dashboard cards
|
|
router.get('/services/status', ctx.asyncHandler(async (req, res) => {
|
|
const services = await loadServicesList();
|
|
const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s]));
|
|
const ids = [];
|
|
const seen = new Set();
|
|
|
|
function addId(id) {
|
|
if (!id || seen.has(id)) return;
|
|
seen.add(id);
|
|
ids.push(id);
|
|
}
|
|
|
|
addId('internet');
|
|
Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId);
|
|
services.forEach(service => addId(service.id));
|
|
|
|
const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) =>
|
|
probeServiceStatus(id, serviceMap.get(id))
|
|
);
|
|
|
|
const statuses = {};
|
|
statusResults.forEach((result) => {
|
|
statuses[result.id] = result;
|
|
});
|
|
|
|
res.set('Cache-Control', 'no-store');
|
|
res.json({
|
|
success: true,
|
|
checkedAt: new Date().toISOString(),
|
|
statuses
|
|
});
|
|
}, 'services-status'));
|
|
|
|
// 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;
|
|
});
|
|
|
|
ctx.resyncHealthChecker?.().catch(() => {});
|
|
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);
|
|
ctx.resyncHealthChecker?.().catch(() => {});
|
|
|
|
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`);
|
|
}
|
|
|
|
ctx.resyncHealthChecker?.().catch(() => {});
|
|
res.json({ success: true, message: `Service "${id}" removed from dashboard` });
|
|
}, 'services-delete'));
|
|
|
|
// Update service configuration (subdomain, port, IP, tailscale, name, logo)
|
|
router.post('/services/update', ctx.asyncHandler(async (req, res) => {
|
|
const { oldSubdomain, newSubdomain, port, ip, tailscaleOnly, name, logo } = 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) {
|
|
const existing = services[serviceIndex];
|
|
const finalPort = port || existing.port;
|
|
const finalIp = ip || existing.ip;
|
|
|
|
services[serviceIndex] = {
|
|
...existing,
|
|
id: newSubdomain,
|
|
port: finalPort,
|
|
ip: finalIp,
|
|
tailscaleOnly: tailscaleOnly || false,
|
|
url: ctx.buildServiceUrl(newSubdomain)
|
|
};
|
|
if (name) services[serviceIndex].name = name;
|
|
if (logo) services[serviceIndex].logo = logo;
|
|
results.services = 'updated';
|
|
} else {
|
|
results.services = 'not found';
|
|
}
|
|
return services;
|
|
});
|
|
}
|
|
|
|
ctx.resyncHealthChecker?.().catch(() => {});
|
|
res.json({
|
|
success: true,
|
|
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,
|
|
results
|
|
});
|
|
}, 'services-update'));
|
|
|
|
return router;
|
|
};
|