448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
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 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 countServersStmt = db.prepare('SELECT COUNT(*) as count FROM servers');
|
||
const selectFirstServerStmt = db.prepare('SELECT * FROM servers ORDER BY id ASC LIMIT 1');
|
||
|
||
function listServers() {
|
||
return selectAllServers.all();
|
||
}
|
||
|
||
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 getServerById(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;
|
||
}
|
||
|
||
return server;
|
||
}
|
||
|
||
function updateServerDetails(serverId, { name, url }) {
|
||
const existing = getServerById(serverId);
|
||
const normalizedUrl = normalizeUrl(url || existing.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 other = selectServerByUrl.get(normalizedUrl);
|
||
if (other && other.id !== existing.id) {
|
||
const error = new Error('SERVER_URL_TAKEN');
|
||
error.code = 'SERVER_URL_TAKEN';
|
||
throw error;
|
||
}
|
||
|
||
updateServer.run(normalizedName, normalizedUrl, existing.id);
|
||
const updated = selectServerById.get(existing.id);
|
||
console.log(`ℹ️ [Setup] Server aktualisiert: ${updated.name} (${updated.id})`);
|
||
return updated;
|
||
}
|
||
|
||
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 getServerConnection(serverId) {
|
||
const server = getServerById(serverId);
|
||
const apiKeyRow = selectApiKeyByServerId.get(server.id);
|
||
const apiKey = decryptApiKey(apiKeyRow);
|
||
return { server, apiKey };
|
||
}
|
||
|
||
function getActiveApiKey() {
|
||
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 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 existingApiKey = selectApiKeyByServerId.get(id);
|
||
|
||
deleteServerStmt.run(id);
|
||
|
||
if (existingApiKey) {
|
||
console.log(`🗑️ [Setup] API-Key entfernt: Server ${server.name} (${server.id})`);
|
||
}
|
||
|
||
console.log(`🗑️ [Setup] Server entfernt: ${server.name} (${server.id})`);
|
||
|
||
return {
|
||
server,
|
||
removed: true
|
||
};
|
||
}
|
||
|
||
function hasApiKey() {
|
||
const { count } = countApiKeysStmt.get();
|
||
return count > 0;
|
||
}
|
||
|
||
function hasCompleteSetup() {
|
||
return hasSuperuser() && hasServer() && hasApiKey();
|
||
}
|
||
|
||
function ensureDefaultsFromEnv() {
|
||
const envServerUrlRaw = process.env.PORTAINER_URL;
|
||
const envServerName = process.env.PORTAINER_SERVER_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 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 getServerStatus(serverId) {
|
||
const server = getServerById(serverId);
|
||
const apiKeyMeta = selectApiKeyByServerId.get(server.id) || null;
|
||
return {
|
||
server,
|
||
apiKey: {
|
||
hasKey: Boolean(apiKeyMeta),
|
||
updatedAt: apiKeyMeta?.updated_at ?? null
|
||
}
|
||
};
|
||
}
|
||
|
||
function getSetupStatus() {
|
||
const servers = selectAllServers.all();
|
||
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 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 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 && !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)
|
||
},
|
||
apiKeys: {
|
||
count: apiKeyCount,
|
||
items: apiKeyItems,
|
||
requireInput: apiKeyRequired,
|
||
envProvided: envApiKeyProvided,
|
||
envValue: rawEnvApiKey
|
||
},
|
||
requirements: {
|
||
superuser: !superuserExists,
|
||
server: serverRequired,
|
||
apiKey: apiKeyRequired
|
||
},
|
||
setupComplete,
|
||
envDefaults: {
|
||
serverName: envServerName || (envServerUrl ? deriveServerName(envServerUrl) : ''),
|
||
serverNameFromEnv: rawEnvServerName,
|
||
serverUrl: envServerUrl,
|
||
apiKeyProvided: envApiKeyProvided,
|
||
apiKeyValue: rawEnvApiKey,
|
||
superuserUsername: envSuperuserUsernameRaw,
|
||
superuserEmail: envSuperuserEmailRaw,
|
||
superuserPassword: envSuperuserPasswordRaw
|
||
}
|
||
};
|
||
}
|
||
|
||
function completeSetup({ server: serverInput }) {
|
||
let serverRecord = null;
|
||
|
||
if (serverInput && typeof serverInput.url === 'string' && serverInput.url.trim()) {
|
||
serverRecord = ensureServer(serverInput);
|
||
} else {
|
||
serverRecord = selectFirstServerStmt.get();
|
||
}
|
||
|
||
if (!serverRecord) {
|
||
const error = new Error('SERVER_DETAILS_REQUIRED');
|
||
error.code = 'SERVER_DETAILS_REQUIRED';
|
||
throw error;
|
||
}
|
||
|
||
return {
|
||
server: serverRecord
|
||
};
|
||
}
|
||
|
||
export {
|
||
ensureDefaultsFromEnv,
|
||
ensureServer,
|
||
getServerById,
|
||
listServers,
|
||
setServerApiKey,
|
||
getServerConnection,
|
||
updateServerDetails,
|
||
getServerStatus,
|
||
getActiveApiKey,
|
||
getActiveServerUrl,
|
||
hasServer,
|
||
hasApiKey,
|
||
hasCompleteSetup,
|
||
getSetupStatus,
|
||
completeSetup,
|
||
removeServer
|
||
};
|