Server-side batched /api/v1/services/status endpoint replaces N individual browser probes with a single API call (HEAD-first with GET fallback, concurrency-limited, CA-aware HTTPS agent). Frontend: clock reuses DOM instead of rebuilding innerHTML every second with drift-correcting timer that pauses on hidden tabs. Card animations use CSS transitionDelay + requestAnimationFrame. Internet dot blink moved from JS intervals to CSS keyframes with prefers-reduced-motion support. Service worker rewritten with network-first navigation, stale-while-revalidate assets, and navigation preload. Font faces drop TTF fallbacks, use font-display swap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
466 lines
16 KiB
JavaScript
466 lines
16 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');
|
|
|
|
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) {
|
|
if (id === 'internet') return 'https://www.google.com';
|
|
if (service?.isExternal && service.externalUrl) return service.externalUrl;
|
|
if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`;
|
|
return ctx.buildServiceUrl(id);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
});
|
|
|
|
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;
|
|
};
|