import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs'; import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs'; import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs'; import { PlatformError } from '../../kernel/errors.mjs'; const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json'); const PROVIDERS = new Set(['none', 'coolify']); const MODES = new Set(['webhook', 'api']); const CHANNELS = new Set(['stable', 'beta']); const METHODS = new Set(['GET', 'POST']); function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) { throw new PlatformError(message, { code, status, expose: true, details }); } function clean(value, max = 1000) { const text = String(value ?? '').trim(); if (text.length > max) fail('Deployment-Einstellung ist zu lang.'); if (/\r|\n|\0/.test(text)) fail('Deployment-Einstellung enthält unzulässige Steuerzeichen.'); return text; } function cleanUuid(value) { const text = clean(value, 120); if (text && !/^[a-zA-Z0-9_-]{6,120}$/.test(text)) fail('Coolify Resource UUID besitzt ein ungültiges Format.'); return text; } function normalizeBaseUrl(value) { const text = clean(value, 1000).replace(/\/$/, ''); if (!text) return ''; let url; try { url = new URL(text); } catch { fail('Coolify URL ist ungültig.'); } if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify URL muss HTTP oder HTTPS verwenden.'); if (url.username || url.password || url.search || url.hash) fail('Coolify URL darf keine Zugangsdaten, Query oder Fragment enthalten.'); if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) { fail('In Produktion muss die Coolify URL HTTPS verwenden.'); } return url.toString().replace(/\/$/, ''); } function normalizeWebhookUrl(value) { const text = clean(value, 2000); if (!text) return ''; let url; try { url = new URL(text); } catch { fail('Coolify Deploy-Webhook ist ungültig.'); } if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify Deploy-Webhook muss HTTP oder HTTPS verwenden.'); if (url.username || url.password || url.hash) fail('Coolify Deploy-Webhook darf keine URL-Zugangsdaten oder Fragmente enthalten.'); if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) { fail('In Produktion muss der Coolify Deploy-Webhook HTTPS verwenden.'); } return url.toString(); } function readLastDeployment() { try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; } catch { return null; } } function writeLastDeployment(value) { mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true }); const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`; writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); renameSync(temporary, LAST_DEPLOYMENT_PATH); } function currentSettings() { const provider = PROVIDERS.has(process.env.VENDOO_DEPLOY_PROVIDER) ? process.env.VENDOO_DEPLOY_PROVIDER : 'none'; const mode = MODES.has(process.env.COOLIFY_TRIGGER_MODE) ? process.env.COOLIFY_TRIGGER_MODE : 'webhook'; const channel = CHANNELS.has(process.env.VENDOO_UPDATE_CHANNEL) ? process.env.VENDOO_UPDATE_CHANNEL : 'stable'; const method = METHODS.has(process.env.COOLIFY_DEPLOY_METHOD) ? process.env.COOLIFY_DEPLOY_METHOD : 'GET'; return { provider, coolify_url: String(process.env.COOLIFY_API_URL || '').replace(/\/$/, ''), resource_uuid: String(process.env.COOLIFY_RESOURCE_UUID || ''), trigger_mode: mode, deploy_method: method, deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'), github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'), github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'), ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'), update_channel: channel, backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')), backup_uploads: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')), api_token: getSecretMetadata('COOLIFY_API_TOKEN'), deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'), }; } function publicSettings() { const settings = currentSettings(); const configured = settings.provider === 'coolify' && ( (settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) || (settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured) ); return { ...settings, configured, api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider }, deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider }, current_version: APP_VERSION, last_deployment: readLastDeployment(), docker_socket_access: false, }; } function buildApiDeployUrl(settings, force) { const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}'; if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.'); if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.'); const path = endpoint .replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid)) .replaceAll('{force}', force ? 'true' : 'false'); return `${settings.coolify_url}${path}`; } async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); } catch (error) { if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504); fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502); } finally { clearTimeout(timeout); } } async function readSafeResponse(response) { const text = (await response.text()).slice(0, 4000); try { return text ? JSON.parse(text) : null; } catch { return text || null; } } export function createDeploymentService({ createBackup, checkForUpdates } = {}) { if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500); async function testConnection() { const settings = currentSettings(); if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.'); if (!settings.coolify_url) fail('Coolify URL fehlt.'); const response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000); const body = await readSafeResponse(response); if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body }); return { ok: true, status: response.status, response: body, url: settings.coolify_url }; } async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) { if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED'); const settings = currentSettings(); if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.'); let backup = null; if (settings.backup_before_deploy) { backup = await createBackup({ kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true, label: `pre-deploy-${APP_VERSION}`, }); } let url; const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` }; let method = settings.deploy_method; if (settings.trigger_mode === 'webhook') { url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL')); if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.'); } else { const token = getSecret('COOLIFY_API_TOKEN'); if (!settings.coolify_url || !settings.resource_uuid || !token) fail('Coolify API URL, Resource UUID oder API Token fehlt.'); url = buildApiDeployUrl(settings, Boolean(force)); headers.Authorization = `Bearer ${token}`; } const requestedAt = new Date().toISOString(); const response = await fetchWithTimeout(url, { method, headers }, 20000); const body = await readSafeResponse(response); const record = { requested_at: requestedAt, provider: 'coolify', trigger_mode: settings.trigger_mode, method, resource_uuid: settings.resource_uuid || null, github_repository: settings.github_repository, github_branch: settings.github_branch, version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120), accepted: response.ok, http_status: response.status, backup_id: backup?.id || null, backup_file: backup?.filename || null, response: body, }; writeLastDeployment(record); if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null }); return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' }; } function update(input = {}) { const provider = clean(input.provider || 'none', 20); const mode = clean(input.trigger_mode || 'webhook', 20); const channel = clean(input.update_channel || 'stable', 20); const method = clean(input.deploy_method || 'GET', 10).toUpperCase(); if (!PROVIDERS.has(provider)) fail('Unbekannter Deployment-Provider.'); if (!MODES.has(mode)) fail('Unbekannter Coolify Trigger-Modus.'); if (!CHANNELS.has(channel)) fail('Unbekannter Update-Kanal.'); if (!METHODS.has(method)) fail('Coolify Deploy-Methode muss GET oder POST sein.'); const values = { VENDOO_DEPLOY_PROVIDER: provider, COOLIFY_API_URL: normalizeBaseUrl(input.coolify_url || ''), COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''), COOLIFY_TRIGGER_MODE: mode, COOLIFY_DEPLOY_METHOD: method, COOLIFY_DEPLOY_ENDPOINT: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500), VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200), VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120), VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300), VENDOO_UPDATE_CHANNEL: channel, VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true', VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false', }; let content = readRuntimeConfig(); for (const [key, value] of Object.entries(values)) { content = setConfigValue(content, key, value); process.env[key] = value; } writeRuntimeConfig(content); if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== '••••••••') { setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' }); } if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== '••••••••') { setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' }); } return publicSettings(); } return Object.freeze({ status: publicSettings, update, testConnection, trigger, async checkUpdates() { if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' }; return checkForUpdates(); }, health() { const status = publicSettings(); return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false }; }, }); }