Files
stackpulse/backend/setup/index.js
T
2025-10-27 14:14:57 +00:00

650 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import crypto from 'crypto';
import { db } from '../db/index.js';
import { hasSuperuser } from '../auth/superuser.js';
const selectAllServers = db.prepare('SELECT * FROM servers ORDER BY id ASC');
const selectServerById = db.prepare('SELECT * FROM servers WHERE id = ?');
const selectServerByUrl = db.prepare('SELECT * FROM servers WHERE url = ?');
const insertServer = db.prepare(`
INSERT INTO servers (name, url)
VALUES (?, ?)
`);
const updateServer = db.prepare(`
UPDATE servers
SET name = ?, url = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const selectAllEndpoints = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
ORDER BY e.id ASC
`);
const selectEndpointById = db.prepare('SELECT * FROM endpoints WHERE id = ?');
const selectEndpointByExternalId = db.prepare('SELECT * FROM endpoints WHERE external_id = ?');
const selectEndpointByServerAndExternal = db.prepare('SELECT * FROM endpoints WHERE server_id = ? AND external_id = ?');
const selectEndpointsByServerId = db.prepare('SELECT * FROM endpoints WHERE server_id = ?');
const insertEndpoint = db.prepare(`
INSERT INTO endpoints (server_id, name, external_id, is_default)
VALUES (?, ?, ?, ?)
`);
const updateEndpoint = db.prepare(`
UPDATE endpoints
SET name = ?, external_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const deleteEndpointStmt = db.prepare('DELETE FROM endpoints WHERE id = ?');
const deleteServerStmt = db.prepare('DELETE FROM servers WHERE id = ?');
const selectApiKeyByServerId = db.prepare('SELECT * FROM server_api_keys WHERE server_id = ?');
const countApiKeysStmt = db.prepare('SELECT COUNT(*) as count FROM server_api_keys');
const selectAllApiKeys = db.prepare('SELECT server_id, created_at, updated_at FROM server_api_keys');
const upsertApiKey = db.prepare(`
INSERT INTO server_api_keys (server_id, key_cipher, key_iv, key_tag, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
ON CONFLICT(server_id) DO UPDATE SET
key_cipher = excluded.key_cipher,
key_iv = excluded.key_iv,
key_tag = excluded.key_tag,
updated_at = CURRENT_TIMESTAMP
`);
const deleteApiKeyByServerId = db.prepare('DELETE FROM server_api_keys WHERE server_id = ?');
const clearDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 0 WHERE is_default != 0');
const setDefaultEndpointStmt = db.prepare('UPDATE endpoints SET is_default = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?');
const selectDefaultEndpointStmt = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
WHERE e.is_default = 1
LIMIT 1
`);
const countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
const countEndpointsStmt = db.prepare('SELECT COUNT(*) as count FROM endpoints');
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
const selectFirstEndpointStmt = db.prepare(`
SELECT e.*, s.name as server_name, s.url as server_url
FROM endpoints e
INNER JOIN servers s ON s.id = e.server_id
ORDER BY e.id ASC
LIMIT 1
`);
const transactionalSetDefaultEndpoint = db.transaction((endpointId) => {
clearDefaultEndpointStmt.run();
setDefaultEndpointStmt.run(endpointId);
});
const API_KEY_SECRET = crypto.createHash('sha256')
.update(process.env.PORTAINER_API_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-api-key')
.digest();
function encryptApiKey(apiKey) {
if (!apiKey || typeof apiKey !== 'string') return null;
const trimmed = apiKey.trim();
if (!trimmed) return null;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', API_KEY_SECRET, iv);
const encrypted = Buffer.concat([cipher.update(trimmed, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
iv: iv.toString('base64'),
content: encrypted.toString('base64'),
tag: tag.toString('base64')
};
}
function decryptApiKey(row) {
if (!row) return '';
try {
const iv = Buffer.from(row.key_iv, 'base64');
const content = Buffer.from(row.key_cipher, 'base64');
const tag = Buffer.from(row.key_tag, 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', API_KEY_SECRET, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([decipher.update(content), decipher.final()]);
return decrypted.toString('utf8');
} catch (error) {
console.warn('⚠️ [Setup] API-Key konnte nicht entschlüsselt werden:', error.message);
return '';
}
}
function normalizeUrl(url) {
if (!url || typeof url !== 'string') return null;
const trimmed = url.trim();
if (!trimmed) return null;
try {
const hasProtocol = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed);
const candidate = hasProtocol ? trimmed : `https://${trimmed}`;
const normalized = new URL(candidate);
const pathname = normalized.pathname.replace(/\/+$/, '');
normalized.pathname = pathname || '/';
normalized.hash = '';
return normalized.toString().replace(/\/$/, '');
} catch (err) {
return '';
}
}
function deriveServerName(url) {
try {
const parsed = new URL(url);
return parsed.hostname || url;
} catch (err) {
return url;
}
}
function ensureServer({ name, url }) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) {
const error = new Error('SERVER_URL_REQUIRED');
error.code = 'SERVER_URL_REQUIRED';
throw error;
}
const normalizedName = (name || deriveServerName(normalizedUrl)).trim();
if (!normalizedName) {
const error = new Error('SERVER_NAME_REQUIRED');
error.code = 'SERVER_NAME_REQUIRED';
throw error;
}
const existing = selectServerByUrl.get(normalizedUrl);
if (existing) {
if (existing.name !== normalizedName) {
updateServer.run(normalizedName, normalizedUrl, existing.id);
const updated = selectServerById.get(existing.id);
console.log(`️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
return updated;
}
return existing;
}
const result = insertServer.run(normalizedName, normalizedUrl);
const created = selectServerById.get(result.lastInsertRowid);
console.log(`✅ [Setup] Server angelegt: ${created.name} (${created.id})`);
return created;
}
function ensureEndpoint({ serverId, name, externalId, makeDefault = false }) {
if (!serverId) {
const error = new Error('SERVER_REQUIRED');
error.code = 'SERVER_REQUIRED';
throw error;
}
const server = selectServerById.get(serverId);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const trimmedExternal = String(externalId ?? '').trim();
if (!trimmedExternal) {
const error = new Error('ENDPOINT_EXTERNAL_ID_REQUIRED');
error.code = 'ENDPOINT_EXTERNAL_ID_REQUIRED';
throw error;
}
const normalizedName = String(name || `Endpoint ${trimmedExternal}`).trim();
if (!normalizedName) {
const error = new Error('ENDPOINT_NAME_REQUIRED');
error.code = 'ENDPOINT_NAME_REQUIRED';
throw error;
}
let existing = selectEndpointByServerAndExternal.get(serverId, trimmedExternal);
if (existing) {
if (existing.name !== normalizedName || existing.external_id !== trimmedExternal) {
updateEndpoint.run(normalizedName, trimmedExternal, existing.id);
existing = selectEndpointById.get(existing.id);
console.log(`️ [Setup] Endpoint aktualisiert: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
}
} else {
const isDefault = makeDefault || countEndpointsStmt.get().count === 0 ? 1 : 0;
const result = insertEndpoint.run(serverId, normalizedName, trimmedExternal, isDefault);
existing = selectEndpointById.get(result.lastInsertRowid);
console.log(`✅ [Setup] Endpoint angelegt: ${existing.name} (${existing.id}) für Server ${server.name} (${server.id})`);
}
if (makeDefault && existing && !existing.is_default) {
transactionalSetDefaultEndpoint(existing.id);
existing = selectEndpointById.get(existing.id);
}
return existing;
}
function removeEndpoint(endpointId) {
const id = Number(endpointId);
if (!Number.isFinite(id)) {
const error = new Error('ENDPOINT_ID_INVALID');
error.code = 'ENDPOINT_ID_INVALID';
throw error;
}
const endpoint = selectEndpointById.get(id);
if (!endpoint) {
const error = new Error('ENDPOINT_NOT_FOUND');
error.code = 'ENDPOINT_NOT_FOUND';
throw error;
}
const wasDefault = Boolean(endpoint.is_default);
deleteEndpointStmt.run(id);
if (wasDefault) {
const fallback = selectFirstEndpointStmt.get();
if (fallback) {
transactionalSetDefaultEndpoint(fallback.id);
console.log(`️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
}
}
console.log(`🗑️ [Setup] Endpoint entfernt: ${endpoint.name} (${endpoint.id}) für Server ${endpoint.server_id}`);
return {
endpoint,
removed: true
};
}
function setServerApiKey({ serverId, apiKey }) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = selectServerById.get(id);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const normalizedKey = typeof apiKey === 'string' ? apiKey.trim() : '';
if (!normalizedKey) {
const error = new Error('API_KEY_REQUIRED');
error.code = 'API_KEY_REQUIRED';
throw error;
}
const encrypted = encryptApiKey(normalizedKey);
if (!encrypted) {
const error = new Error('API_KEY_ENCRYPT_FAILED');
error.code = 'API_KEY_ENCRYPT_FAILED';
throw error;
}
const existing = selectApiKeyByServerId.get(id);
upsertApiKey.run(id, encrypted.content, encrypted.iv, encrypted.tag);
console.log(`${existing ? '️' : '🔐'} [Setup] API-Key ${existing ? 'aktualisiert' : 'angelegt'}: Server ${server.name} (${server.id})`);
return {
serverId: server.id,
updatedAt: new Date().toISOString()
};
}
function setDefaultEndpoint(endpointId) {
if (!endpointId) return null;
const endpoint = selectEndpointById.get(endpointId);
if (!endpoint) {
const error = new Error('ENDPOINT_NOT_FOUND');
error.code = 'ENDPOINT_NOT_FOUND';
throw error;
}
transactionalSetDefaultEndpoint(endpointId);
return selectEndpointById.get(endpointId);
}
function getDefaultEndpoint() {
let endpoint = selectDefaultEndpointStmt.get();
if (!endpoint) {
endpoint = selectFirstEndpointStmt.get();
if (endpoint) {
transactionalSetDefaultEndpoint(endpoint.id);
endpoint = selectDefaultEndpointStmt.get();
}
}
return endpoint || null;
}
function getActiveEndpointExternalId() {
const endpoint = getDefaultEndpoint();
return endpoint ? endpoint.external_id : null;
}
function getActiveApiKey() {
const defaultEndpoint = getDefaultEndpoint();
if (defaultEndpoint) {
const row = selectApiKeyByServerId.get(defaultEndpoint.server_id);
const key = decryptApiKey(row);
if (key) return key;
}
const firstServer = selectFirstServerStmt.get();
if (firstServer) {
const row = selectApiKeyByServerId.get(firstServer.id);
const key = decryptApiKey(row);
if (key) return key;
}
const envKey = process.env.PORTAINER_API_KEY ? process.env.PORTAINER_API_KEY.trim() : '';
return envKey || '';
}
function getActiveServerUrl() {
const endpoint = getDefaultEndpoint();
if (endpoint) {
const normalizedEndpointUrl = endpoint.server_url ? normalizeUrl(endpoint.server_url) : '';
if (normalizedEndpointUrl) {
return normalizedEndpointUrl;
}
const server = selectServerById.get(endpoint.server_id);
if (server?.url) {
const normalizedServerUrl = normalizeUrl(server.url);
if (normalizedServerUrl) {
return normalizedServerUrl;
}
}
}
const firstServer = selectFirstServerStmt.get();
if (firstServer?.url) {
const normalizedUrl = normalizeUrl(firstServer.url);
if (normalizedUrl) {
return normalizedUrl;
}
}
const envUrl = process.env.PORTAINER_URL ? process.env.PORTAINER_URL.trim() : '';
const normalizedEnvUrl = envUrl ? normalizeUrl(envUrl) : '';
return normalizedEnvUrl || '';
}
function hasServer() {
const { count } = countServersStmt.get();
return count > 0;
}
function removeServer(serverId) {
const id = Number(serverId);
if (!Number.isFinite(id)) {
const error = new Error('SERVER_ID_INVALID');
error.code = 'SERVER_ID_INVALID';
throw error;
}
const server = selectServerById.get(id);
if (!server) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
const relatedEndpoints = selectEndpointsByServerId.all(id);
const hadDefaultEndpoint = relatedEndpoints.some((endpoint) => endpoint.is_default);
const existingApiKey = selectApiKeyByServerId.get(id);
deleteServerStmt.run(id);
if (existingApiKey) {
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
}
if (hadDefaultEndpoint) {
const fallback = getDefaultEndpoint();
if (fallback) {
console.log(`️ [Setup] Neuer Standard-Endpoint gesetzt: ${fallback.name} (${fallback.id})`);
}
}
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id}) entfernte Endpoints: ${relatedEndpoints.length}`);
return {
server,
removed: true,
endpointsRemoved: relatedEndpoints.length
};
}
function hasEndpoint() {
const { count } = countEndpointsStmt.get();
return count > 0;
}
function hasApiKey() {
const { count } = countApiKeysStmt.get();
return count > 0;
}
function hasCompleteSetup() {
return hasSuperuser() && hasServer() && hasEndpoint() && hasApiKey();
}
function ensureDefaultsFromEnv() {
const envServerUrlRaw = process.env.PORTAINER_URL;
const envServerName = process.env.PORTAINER_SERVER_NAME;
const envEndpointIdRaw = process.env.PORTAINER_ENDPOINT_ID;
const envEndpointName = process.env.PORTAINER_ENDPOINT_NAME;
let server = null;
if (envServerUrlRaw) {
try {
server = ensureServer({ name: envServerName, url: envServerUrlRaw });
} catch (error) {
console.error('⚠️ [Setup] Konnte Server aus Umgebungsvariablen nicht anlegen:', error.message);
}
}
const trimmedEndpointId = typeof envEndpointIdRaw === 'string' ? envEndpointIdRaw.trim() : '';
if (trimmedEndpointId) {
const existingEndpoint = selectEndpointByExternalId.get(trimmedEndpointId);
if (!existingEndpoint) {
const targetServer = server || selectFirstServerStmt.get();
if (targetServer) {
try {
const endpointName = envEndpointName || `Endpoint ${trimmedEndpointId}`;
ensureEndpoint({
serverId: targetServer.id,
name: endpointName,
externalId: trimmedEndpointId,
makeDefault: true
});
} catch (error) {
console.error('⚠️ [Setup] Konnte Endpoint aus Umgebungsvariablen nicht anlegen:', error.message);
}
} else {
console.warn('⚠️ [Setup] Endpoint aus Umgebungsvariablen benötigt einen vorhandenen Server.');
}
} else if (!existingEndpoint.is_default) {
transactionalSetDefaultEndpoint(existingEndpoint.id);
}
}
const envApiKeyRaw = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
if (envApiKeyRaw.trim()) {
const targetServer = server || selectFirstServerStmt.get();
if (targetServer) {
try {
setServerApiKey({ serverId: targetServer.id, apiKey: envApiKeyRaw.trim() });
} catch (error) {
console.error('⚠️ [Setup] Konnte API-Key aus Umgebungsvariablen nicht speichern:', error.message);
}
}
}
}
function getSetupStatus() {
const servers = selectAllServers.all();
const endpoints = selectAllEndpoints.all();
const defaultEndpoint = getDefaultEndpoint();
const apiKeyRecords = selectAllApiKeys.all();
const apiKeyMap = new Map(apiKeyRecords.map((entry) => [entry.server_id, entry]));
const rawEnvServerName = typeof process.env.PORTAINER_SERVER_NAME === 'string' ? process.env.PORTAINER_SERVER_NAME : '';
const envServerName = rawEnvServerName.trim();
const rawEnvServerUrl = typeof process.env.PORTAINER_URL === 'string' ? process.env.PORTAINER_URL : '';
const envServerUrl = rawEnvServerUrl.trim();
const rawEnvEndpointName = typeof process.env.PORTAINER_ENDPOINT_NAME === 'string' ? process.env.PORTAINER_ENDPOINT_NAME : '';
const envEndpointName = rawEnvEndpointName.trim();
const rawEnvEndpointId = typeof process.env.PORTAINER_ENDPOINT_ID === 'string' ? process.env.PORTAINER_ENDPOINT_ID : '';
const envEndpointId = rawEnvEndpointId.trim();
const rawEnvApiKey = typeof process.env.PORTAINER_API_KEY === 'string' ? process.env.PORTAINER_API_KEY : '';
const envApiKeyTrimmed = rawEnvApiKey.trim();
const envApiKeyProvided = Boolean(envApiKeyTrimmed);
const envSuperuserUsernameRaw = typeof process.env.SUPERUSER_USERNAME === 'string' ? process.env.SUPERUSER_USERNAME : '';
const envSuperuserEmailRaw = typeof process.env.SUPERUSER_EMAIL === 'string' ? process.env.SUPERUSER_EMAIL : '';
const envSuperuserPasswordRaw = typeof process.env.SUPERUSER_PASSWORD === 'string' ? process.env.SUPERUSER_PASSWORD : '';
const envSuperuserUsername = envSuperuserUsernameRaw.trim();
const envSuperuserEmail = envSuperuserEmailRaw.trim();
const serverRequired = servers.length === 0;
const endpointRequired = endpoints.length === 0;
const apiKeyItems = servers.map((server) => {
const keyMeta = apiKeyMap.get(server.id) || null;
return {
serverId: server.id,
serverName: server.name,
hasKey: Boolean(keyMeta),
updatedAt: keyMeta?.updated_at ?? null
};
});
const apiKeyCount = apiKeyItems.filter((item) => item.hasKey).length;
const apiKeyRequired = servers.length > 0 && apiKeyCount === 0;
const superuserExists = hasSuperuser();
const setupComplete = superuserExists && !serverRequired && !endpointRequired && !apiKeyRequired;
return {
superuser: {
exists: superuserExists,
envProvided: Boolean(envSuperuserUsername || envSuperuserEmail || envSuperuserPasswordRaw),
env: {
username: envSuperuserUsernameRaw,
email: envSuperuserEmailRaw,
password: envSuperuserPasswordRaw
}
},
servers: {
count: servers.length,
items: servers,
requireInput: serverRequired,
envProvided: Boolean(envServerUrl)
},
endpoints: {
count: endpoints.length,
items: endpoints,
requireInput: endpointRequired,
envProvided: Boolean(envEndpointId),
default: defaultEndpoint
},
apiKeys: {
count: apiKeyCount,
items: apiKeyItems,
requireInput: apiKeyRequired,
envProvided: envApiKeyProvided,
envValue: rawEnvApiKey
},
requirements: {
superuser: !superuserExists,
server: serverRequired,
endpoint: endpointRequired,
apiKey: apiKeyRequired
},
setupComplete,
envDefaults: {
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
serverNameFromEnv: rawEnvServerName,
serverUrl: envServerUrl,
endpointName: envEndpointName || (envEndpointId ? `Endpoint ${envEndpointId}` : ''),
endpointNameFromEnv: rawEnvEndpointName,
endpointExternalId: envEndpointId,
apiKeyProvided: envApiKeyProvided,
apiKeyValue: rawEnvApiKey,
superuserUsername: envSuperuserUsernameRaw,
superuserEmail: envSuperuserEmailRaw,
superuserPassword: envSuperuserPasswordRaw
}
};
}
const createEndpointWithDefaultTransaction = db.transaction(({ serverInput, endpointInput }) => {
let server = null;
if (endpointInput?.serverId) {
const byId = selectServerById.get(endpointInput.serverId);
if (!byId) {
const error = new Error('SERVER_NOT_FOUND');
error.code = 'SERVER_NOT_FOUND';
throw error;
}
server = byId;
}
if (serverInput) {
server = ensureServer(serverInput);
}
if (!server) {
server = selectFirstServerStmt.get();
}
if (!server) {
const error = new Error('SERVER_DETAILS_REQUIRED');
error.code = 'SERVER_DETAILS_REQUIRED';
throw error;
}
if (!endpointInput) {
const error = new Error('ENDPOINT_DETAILS_REQUIRED');
error.code = 'ENDPOINT_DETAILS_REQUIRED';
throw error;
}
const endpoint = ensureEndpoint({
serverId: server.id,
name: endpointInput.name,
externalId: endpointInput.externalId,
makeDefault: true
});
return {
server,
endpoint
};
});
function completeSetup({ server: serverInput, endpoint: endpointInput }) {
const result = createEndpointWithDefaultTransaction({ serverInput, endpointInput });
return {
server: result.server,
endpoint: result.endpoint,
defaultEndpoint: getDefaultEndpoint()
};
}
export {
ensureDefaultsFromEnv,
ensureServer,
ensureEndpoint,
setServerApiKey,
setDefaultEndpoint,
getDefaultEndpoint,
getActiveEndpointExternalId,
getActiveApiKey,
getActiveServerUrl,
hasServer,
hasEndpoint,
hasApiKey,
hasCompleteSetup,
getSetupStatus,
completeSetup,
removeEndpoint,
removeServer
};