Files
dashcaddy/status/js/core/service-create.js
Sami f2f33b4b40 Make DNS servers fully dynamic from config.json
DNS server IDs (dns1, dns2, dns3) were hardcoded throughout the frontend
and backend. Now config.json's dnsServers object is the single source of
truth — adding or removing a DNS server in config automatically updates
the dashboard cards, credential modal, health checks, and probes.

- credentials.js: rebuild modal sections dynamically from SITE.dnsServers
- globals.js: add getPrimaryDnsId() helper for primary DNS lookups
- service-create.js, service-infrastructure.js: use dynamic DNS ID
- startup-validator.js: dynamic topCardServices from config
- middleware.js: add license endpoints to public routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:55:07 -07:00

649 lines
25 KiB
JavaScript

// ========== SERVICE CREATION ==========
// Add service modal, local/external service creation flows, and event wiring.
(function () {
// ===== SUBDOMAIN AUTO-DERIVE =====
function deriveSubdomain(name) {
return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
}
function getSmartSslDefault() {
return SITE.defaults?.sslType || (SITE.configurationType === 'public' ? 'letsencrypt' : 'caddy-managed');
}
// ===== SERVICE PREVIEW =====
function updateServicePreview() {
const subdomain = document.getElementById('service-subdomain-input').value || 'subdomain';
const ip = document.getElementById('service-ip-input').value || QUICK_IPS.lan || 'localhost';
const port = document.getElementById('service-port-input').value || DC.DEFAULTS.SERVICE_PORT;
const sslType = document.getElementById('ssl-type-select').value;
const caName = document.getElementById('ca-name-input').value || 'sami-ca';
const existingCa = document.getElementById('existing-ca-select').value;
const enableAuth = document.getElementById('enable-auth').checked;
const enableCors = document.getElementById('enable-cors').checked;
const customHeaders = document.getElementById('custom-headers-input').value;
const upstreamPath = document.getElementById('upstream-path-input').value || '/';
const healthCheck = document.getElementById('health-check-input').value;
const timeout = document.getElementById('timeout-input').value || 30;
const dnsPreview = document.getElementById('dns-preview');
if (dnsPreview) dnsPreview.textContent = `${buildDomain(subdomain)} \u2192 ${ip}`;
const urlPreview = document.getElementById('url-preview');
if (urlPreview) urlPreview.textContent = buildServiceUrl(subdomain);
const config = {
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout
};
const caddyConfig = window.generateCaddyConfig(config);
const configPreview = document.getElementById('caddy-config-preview');
if (configPreview) configPreview.value = caddyConfig;
}
// ===== QUICK IP CONFIGURATION =====
const QUICK_IPS = {
localhost: '127.0.0.1',
lan: '',
tailscale: ''
};
async function detectNetworkIPs() {
try {
const response = await fetch('/api/v1/network/ips', {
signal: AbortSignal.timeout(2000)
});
if (response.ok) {
const data = await response.json();
if (data.lan) QUICK_IPS.lan = data.lan;
if (data.tailscale) QUICK_IPS.tailscale = data.tailscale;
}
} catch (e) {
// API not available
}
const lanBtn = document.getElementById('quick-ip-lan');
const tsBtn = document.getElementById('quick-ip-tailscale');
if (lanBtn) {
if (QUICK_IPS.lan) {
lanBtn.dataset.ip = QUICK_IPS.lan;
lanBtn.textContent = `LAN (${QUICK_IPS.lan})`;
lanBtn.title = `LAN IP: ${QUICK_IPS.lan}`;
} else {
lanBtn.style.display = 'none';
}
}
if (tsBtn) {
if (QUICK_IPS.tailscale) {
tsBtn.dataset.ip = QUICK_IPS.tailscale;
tsBtn.textContent = `Tailscale (${QUICK_IPS.tailscale})`;
tsBtn.title = `Tailscale IP: ${QUICK_IPS.tailscale}`;
} else {
tsBtn.style.display = 'none';
}
}
const ipInput = document.getElementById('service-ip-input');
if (ipInput && !ipInput.value && QUICK_IPS.lan) ipInput.value = QUICK_IPS.lan;
}
function initQuickIPButtons() {
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.addEventListener('click', () => {
const ip = btn.dataset.ip;
if (ip) {
document.getElementById('service-ip-input').value = ip;
document.querySelectorAll('.quick-ip-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
updateServicePreview();
}
});
});
document.getElementById('service-ip-input')?.addEventListener('input', (e) => {
const currentIP = e.target.value;
document.querySelectorAll('.quick-ip-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.ip === currentIP);
});
});
}
// ===== ADD SERVICE MODAL =====
async function openAddServiceModal() {
const modal = document.getElementById('add-service-modal');
modal.classList.add('show');
const modalContent = modal.querySelector('.weather-modal-content');
if (modalContent) modalContent.scrollTop = 0;
document.body.style.overflow = 'hidden';
// Set smart SSL default
const sslSelect = document.getElementById('ssl-type-select');
if (sslSelect) sslSelect.value = getSmartSslDefault();
await detectNetworkIPs();
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
// Check Tailscale status
const tailscaleStatus = document.getElementById('manual-tailscale-status');
const tailscaleCheckbox = document.getElementById('manual-tailscale-only');
try {
const response = await fetch('/api/v1/tailscale/status');
const data = await response.json();
if (data.success && data.installed && data.connected) {
tailscaleStatus.innerHTML = `
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${data.self?.hostname} (${data.self?.ip})</span>
`;
tailscaleCheckbox.disabled = false;
} else if (data.installed) {
tailscaleStatus.innerHTML = `<span style="color: #ff9800;">\u26A0 Not connected</span>`;
tailscaleCheckbox.disabled = true;
} else {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Not available</span>`;
tailscaleCheckbox.disabled = true;
}
} catch (e) {
tailscaleStatus.innerHTML = `<span style="color: var(--muted);">Could not check</span>`;
tailscaleCheckbox.disabled = true;
}
tailscaleCheckbox.checked = false;
updateServicePreview();
}
// ===== SERVICE TYPE SWITCHING (TAB STYLE) =====
function setupServiceTypeSwitching() {
const localRadio = document.getElementById('service-type-local');
const externalRadio = document.getElementById('service-type-external');
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
function switchServiceType() {
if (localRadio.checked) {
localConfig.style.display = 'grid';
externalConfig.style.display = 'none';
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
} else {
localConfig.style.display = 'none';
externalConfig.style.display = 'block';
if (tabExternal) { tabExternal.style.background = 'var(--accent)'; tabExternal.style.color = 'var(--bg)'; }
if (tabLocal) { tabLocal.style.background = 'transparent'; tabLocal.style.color = 'var(--muted)'; }
}
}
localRadio?.addEventListener('change', switchServiceType);
externalRadio?.addEventListener('change', switchServiceType);
}
// ===== AUTO-DERIVE SUBDOMAIN FROM NAME =====
function setupAutoSubdomain() {
// Local service: name → subdomain + preview
const nameInput = document.getElementById('service-name-input');
const subdomainInput = document.getElementById('service-subdomain-input');
const subdomainPreview = document.getElementById('subdomain-preview');
let userEditedSubdomain = false;
nameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(nameInput.value);
if (!userEditedSubdomain && subdomainInput) {
subdomainInput.value = derived;
}
if (subdomainPreview) {
subdomainPreview.textContent = derived ? `\u2192 ${buildDomain(derived)}` : '';
}
updateServicePreview();
});
subdomainInput?.addEventListener('input', () => {
userEditedSubdomain = subdomainInput.value !== deriveSubdomain(nameInput?.value || '');
const sub = subdomainInput.value.trim() || deriveSubdomain(nameInput?.value || '');
if (subdomainPreview) {
subdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
updateServicePreview();
});
// External service: name → subdomain + preview
const extNameInput = document.getElementById('external-service-name');
const extSubdomainInput = document.getElementById('external-service-subdomain');
const extSubdomainPreview = document.getElementById('external-subdomain-preview');
const extDomainPreview = document.getElementById('external-domain-preview');
let userEditedExtSubdomain = false;
extNameInput?.addEventListener('input', () => {
const derived = deriveSubdomain(extNameInput.value);
if (!userEditedExtSubdomain && extSubdomainInput) {
extSubdomainInput.value = derived;
}
const sub = extSubdomainInput?.value || derived;
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
extSubdomainInput?.addEventListener('input', () => {
userEditedExtSubdomain = extSubdomainInput.value !== deriveSubdomain(extNameInput?.value || '');
const sub = extSubdomainInput.value.trim() || deriveSubdomain(extNameInput?.value || '');
if (extSubdomainPreview) {
extSubdomainPreview.textContent = sub ? `\u2192 ${buildDomain(sub)}` : '';
}
if (extDomainPreview) {
extDomainPreview.textContent = sub ? buildDomain(sub) : '';
}
});
}
// ===== CREATE EXTERNAL SERVICE =====
async function createExternalService() {
const name = document.getElementById('external-service-name').value.trim();
const externalUrl = document.getElementById('external-service-url').value.trim();
const subdomain = (document.getElementById('external-service-subdomain').value.trim() || deriveSubdomain(name)).toLowerCase();
const logo = document.getElementById('external-service-logo').value.trim();
const icon = document.getElementById('external-service-icon').value.trim();
const createDns = document.getElementById('external-create-dns').checked;
const createCaddy = document.getElementById('external-create-caddy').checked;
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
const preserveHost = document.getElementById('external-preserve-host').checked;
const followRedirects = document.getElementById('external-follow-redirects').checked;
if (!name || !externalUrl) {
showNotification('Please fill in Name and External URL', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (!externalUrl.startsWith('http://') && !externalUrl.startsWith('https://')) {
showNotification('External URL must start with http:// or https://', 'warning');
return;
}
const domain = buildDomain(subdomain);
try {
const results = { dns: null, caddy: null, dashboard: false };
if (createDns) {
const adminToken = window.getToken(getPrimaryDnsId(), 'admin');
if (adminToken) {
try {
const dnsResponse = await secureFetch('/api/v1/dns/record', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: domain,
ip: proxyIp,
ttl: DC.DEFAULTS.TTL,
server: SITE.dnsIp
})
});
const dnsResult = await dnsResponse.json();
results.dns = dnsResult.success ? 'created' : (dnsResult.error || 'failed');
} catch (e) {
results.dns = e.message;
}
} else {
results.dns = 'no admin token (configure in \uD83D\uDD11 Tokens)';
}
}
if (createCaddy) {
try {
const caddyConfig = {
subdomain: subdomain,
externalUrl: externalUrl,
preserveHost: preserveHost,
followRedirects: followRedirects,
sslType: 'caddy-managed',
caddyfilePath: DC.DEFAULTS.CADDYFILE,
reloadCaddy: true
};
const caddyResponse = await secureFetch('/api/v1/site/external', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(caddyConfig)
});
const caddyResult = await caddyResponse.json();
results.caddy = caddyResult.success ? 'created' : (caddyResult.error || 'failed');
} catch (e) {
results.caddy = e.message;
}
}
const newService = {
id: subdomain,
name: name,
url: `https://${domain}`,
externalUrl: externalUrl,
logo: logo || icon || '\uD83C\uDF10',
isExternal: true,
isCustom: true
};
window.APPS.push(newService);
results.dashboard = true;
const defaultServices = ['plex', 'router', 'chat', 'sync', 'torrent', 'radarr', 'sonarr', 'prowlarr', 'portainer', 'requests', 'jellyfin', 'emby'];
const customServices = window.APPS.filter(app => !defaultServices.includes(app.id));
safeSet('custom-services', JSON.stringify(customServices));
try {
await secureFetch('/api/v1/services', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(window.APPS)
});
} catch (e) {
console.warn('Failed to save to services.json:', e);
}
window.buildGrid();
window.refreshAll();
closeAddServiceModal();
const parts = [`External service "${name}" added!`];
if (createDns) parts.push(`DNS: ${results.dns === 'created' ? '\u2713' : '\u26A0 ' + results.dns}`);
if (createCaddy) parts.push(`Caddy: ${results.caddy === 'created' ? '\u2713' : '\u26A0 ' + results.caddy}`);
parts.push(`Access at: https://${domain}`);
showNotification(parts.join(' | '), 'success', 6000);
} catch (error) {
console.error('Failed to create external service:', error);
showNotification(`Failed to create external service: ${error.message}`, 'error');
}
}
// ===== CLOSE ADD SERVICE MODAL =====
function closeAddServiceModal() {
closeModal('add-service-modal');
document.body.style.overflow = '';
document.getElementById('service-name-input').value = '';
document.getElementById('service-subdomain-input').value = '';
document.getElementById('service-port-input').value = '';
document.getElementById('service-ip-input').value = QUICK_IPS.lan || '';
document.getElementById('service-logo-input').value = '';
document.getElementById('dns-ttl-input').value = DC.DEFAULTS.TTL;
document.getElementById('ssl-type-select').value = getSmartSslDefault();
document.getElementById('ca-name-input').value = '';
document.getElementById('enable-auth').checked = false;
document.getElementById('enable-cors').checked = false;
document.getElementById('custom-headers-input').value = '';
document.getElementById('upstream-path-input').value = '/';
document.getElementById('health-check-input').value = '';
document.getElementById('timeout-input').value = '30';
// Clear subdomain previews
const subPrev = document.getElementById('subdomain-preview');
if (subPrev) subPrev.textContent = '';
const extSubPrev = document.getElementById('external-subdomain-preview');
if (extSubPrev) extSubPrev.textContent = '';
// Clear external fields
const extName = document.getElementById('external-service-name');
if (extName) extName.value = '';
const extSub = document.getElementById('external-service-subdomain');
if (extSub) extSub.value = '';
const extUrl = document.getElementById('external-service-url');
if (extUrl) extUrl.value = '';
const extLogo = document.getElementById('external-service-logo');
if (extLogo) extLogo.value = '';
const extIcon = document.getElementById('external-service-icon');
if (extIcon) extIcon.value = '';
// Collapse options
const localOpts = document.getElementById('local-advanced-options');
if (localOpts) localOpts.removeAttribute('open');
const extOpts = document.getElementById('external-advanced-options');
if (extOpts) extOpts.removeAttribute('open');
// Reset to local tab
const localRadio = document.getElementById('service-type-local');
if (localRadio) localRadio.checked = true;
const localConfig = document.getElementById('local-service-config');
const externalConfig = document.getElementById('external-service-config');
if (localConfig) localConfig.style.display = 'grid';
if (externalConfig) externalConfig.style.display = 'none';
const tabLocal = document.getElementById('tab-local');
const tabExternal = document.getElementById('tab-external');
if (tabLocal) { tabLocal.style.background = 'var(--accent)'; tabLocal.style.color = 'var(--bg)'; }
if (tabExternal) { tabExternal.style.background = 'transparent'; tabExternal.style.color = 'var(--muted)'; }
}
// ===== CREATE NEW SERVICE =====
async function createNewService() {
const name = document.getElementById('service-name-input').value.trim();
const subdomain = (document.getElementById('service-subdomain-input').value.trim() || deriveSubdomain(name)).toLowerCase();
const port = document.getElementById('service-port-input').value.trim();
const ip = document.getElementById('service-ip-input').value.trim();
const logo = document.getElementById('service-logo-input').value.trim();
const createDns = document.getElementById('create-dns-record').checked;
const ttl = parseInt(document.getElementById('dns-ttl-input').value) || DC.DEFAULTS.TTL;
const tailscaleOnly = document.getElementById('manual-tailscale-only')?.checked || false;
const sslType = document.getElementById('ssl-type-select')?.value || 'caddy-managed';
const caName = document.getElementById('ca-name-input')?.value || '';
const existingCa = document.getElementById('existing-ca-select')?.value || '';
const enableAuth = document.getElementById('enable-auth')?.checked || false;
const enableCors = document.getElementById('enable-cors')?.checked || false;
const customHeaders = document.getElementById('custom-headers-input')?.value || '';
const upstreamPath = document.getElementById('upstream-path-input')?.value || '/';
const healthCheck = document.getElementById('health-check-input')?.value || '';
const timeout = document.getElementById('timeout-input')?.value || 30;
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
if (!name || !port || !ip) {
showNotification('Please fill in Name, Port, and IP Address', 'warning');
return;
}
if (!subdomain) {
showNotification('Could not derive subdomain from name. Please set one in Options.', 'warning');
return;
}
if (createDns && !dnsToken) {
showNotification('DNS Admin token required. Configure it in the Tokens menu first.', 'warning');
return;
}
const results = { dns: null, caddy: null, dashboard: false };
try {
if (createDns) {
try {
await window.createDnsRecord(subdomain, ip, ttl);
results.dns = 'created';
} catch (error) {
console.error('DNS creation failed:', error);
results.dns = error.message;
throw new Error(`DNS creation failed: ${error.message}`);
}
} else {
results.dns = 'skipped';
}
const caddyConfig = window.generateCaddyConfig({
subdomain, port, ip, sslType, caName, existingCa,
enableAuth, enableCors, customHeaders, upstreamPath, healthCheck, timeout, tailscaleOnly
});
try {
const caddyResponse = await secureFetch('/api/v1/site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
domain: buildDomain(subdomain),
upstream: `${ip}:${port}`,
config: caddyConfig
})
});
const caddyResult = await caddyResponse.json();
if (caddyResult.success) {
results.caddy = 'added & reloaded';
} else {
console.error('Caddy configuration failed:', caddyResult.error);
results.caddy = caddyResult.error || 'failed';
throw new Error(`Caddy configuration failed: ${caddyResult.error}`);
}
} catch (error) {
console.error('Caddy API error:', error);
results.caddy = error.message;
throw new Error(`Caddy API error: ${error.message}`);
}
const serviceConfig = {
name, subdomain, port, ip,
logo: logo || `/assets/${subdomain}.png`,
tailscaleOnly: tailscaleOnly || false
};
await window.addServiceToConfig(serviceConfig);
results.dashboard = true;
const statusParts = [
`DNS: ${results.dns === 'created' ? '\u2713' : results.dns === 'skipped' ? '\u25CB' : '\u2717'}`,
`Caddy: ${results.caddy === 'added & reloaded' ? '\u2713' : '\u2717'}`,
`Dashboard: ${results.dashboard ? '\u2713' : '\u2717'}`
];
showNotification(`Service "${name}" created! ${statusParts.join(' | ')} \u2014 ${buildServiceUrl(subdomain)}${tailscaleOnly ? ' (Tailscale)' : ''}`, 'success', 6000);
closeAddServiceModal();
window.buildGrid();
window.refreshAll();
} catch (error) {
console.error('Error creating service:', error);
showNotification(`Error creating "${name}": ${error.message}`, 'error', 6000);
}
}
// ===== EVENT LISTENERS =====
document.getElementById('add-service')?.addEventListener('click', openAddServiceModal);
document.getElementById('add-service-cancel')?.addEventListener('click', closeAddServiceModal);
document.getElementById('add-service-create')?.addEventListener('click', () => {
const serviceType = document.querySelector('input[name="service-type"]:checked')?.value;
if (serviceType === 'external') {
createExternalService();
} else {
createNewService();
}
});
setupServiceTypeSwitching();
setupAutoSubdomain();
initQuickIPButtons();
// SSL type change handler
document.getElementById('ssl-type-select')?.addEventListener('change', (e) => {
const existingCaConfig = document.getElementById('existing-ca-config');
const customCaConfig = document.getElementById('custom-ca-config');
existingCaConfig.style.display = 'none';
customCaConfig.style.display = 'none';
if (e.target.value === 'existing-ca') {
existingCaConfig.style.display = 'block';
} else if (e.target.value === 'custom-ca') {
customCaConfig.style.display = 'block';
}
updateServicePreview();
});
// Refresh CAs button
document.getElementById('refresh-cas')?.addEventListener('click', async () => {
const button = document.getElementById('refresh-cas');
const originalText = button.textContent;
button.textContent = '\u231B Loading...';
button.disabled = true;
try {
const caddyfilePath = document.getElementById('caddyfile-path-input').value || DC.DEFAULTS.CADDYFILE;
await window.loadExistingCAs(caddyfilePath);
button.textContent = '\u2705 Refreshed';
} catch (error) {
button.textContent = '\u274C Failed';
console.error('Failed to refresh CAs:', error);
}
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
}, 2000);
});
// DNS record checkbox handler
document.getElementById('create-dns-record')?.addEventListener('change', (e) => {
const dnsConfig = document.getElementById('dns-config');
dnsConfig.style.display = e.target.checked ? 'block' : 'none';
});
// Real-time preview updates
['service-subdomain-input', 'service-ip-input', 'service-port-input', 'ca-name-input',
'existing-ca-select', 'enable-auth', 'enable-cors', 'custom-headers-input', 'upstream-path-input',
'health-check-input', 'timeout-input'].forEach(id => {
const element = document.getElementById(id);
if (element) {
element.addEventListener('input', updateServicePreview);
element.addEventListener('change', updateServicePreview);
}
});
// ===== CUSTOM SERVICES FROM LOCALSTORAGE =====
function loadCustomServices() {
const customServices = safeGet('custom-services');
if (customServices) {
try {
const services = JSON.parse(customServices);
services.forEach(service => {
if (!window.APPS.find(app => app.id === service.id)) {
window.APPS.push(service);
}
});
} catch (e) {
console.warn('Failed to load custom services:', e);
}
}
}
loadCustomServices();
// ===== WINDOW EXPORTS =====
window.openAddServiceModal = openAddServiceModal;
window.closeAddServiceModal = closeAddServiceModal;
})();