From a8e95ecd6c53bf451791a26ab0bd4733ea3b0bad Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:44:10 +0200 Subject: [PATCH 1/7] feat: add telemetry state and payload builder --- src/server.js | 76 +++++++++++++++++++++++++- src/store.js | 13 ++++- src/telemetry.js | 136 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 src/telemetry.js diff --git a/src/server.js b/src/server.js index 0daa599..3d9201d 100644 --- a/src/server.js +++ b/src/server.js @@ -13,6 +13,7 @@ import { configuredRegistries } from './registry-auth.js'; import { applyWatchtowerImport, watchtowerImportPreview } from './watchtower-import.js'; import { requireCsrf, sameOrigin } from './http-security.js'; import { loadTlsOptions } from './tls.js'; +import { buildTelemetryPayload, enableTelemetry, ensureTelemetryState, incrementTelemetryCounter, nextAutomaticReport, randomJitter, resetTelemetryIdentity, sendTelemetry, telemetryUrl, REPORT_INTERVAL_MS } from './telemetry.js'; loadStore(); const sourceDir = path.dirname(fileURLToPath(import.meta.url)); @@ -36,6 +37,8 @@ const LOGIN_WINDOW_MS = 15 * 60 * 1_000; const LOGIN_MAX_ATTEMPTS = 10; let selfUpdateCheck = null; let selfUpdateCheckedAt = 0; +let telemetryTimer = null; +let nextTelemetryAt = null; async function currentSelfUpdate(force = false) { if (!force && selfUpdateCheck && Date.now() - selfUpdateCheckedAt < 5 * 60_000) return selfUpdateCheck; @@ -77,10 +80,36 @@ function publicSettings(session) { function healthFailure(error) { return /healthcheck|unhealthy|startprüfung|startup|während der start/i.test(error.message); } function recordActionFailure(type, session, container, image, error) { + if (type === 'update-failed') { + incrementTelemetryCounter(getStore(), 'failed_updates'); + if (/Update zurückgerollt:/i.test(error.message)) incrementTelemetryCounter(getStore(), 'automatic_rollbacks'); + saveStore(); + } addEvent({ type, actor: session?.username || null, container, image, result: 'error', message: error.message }); if (healthFailure(error)) addEvent({ type: 'healthcheck-failed', actor: session?.username || null, container, image, result: 'error', message: error.message }); } +const telemetryPayload = () => buildTelemetryPayload({ store: getStore(), version: appVersion, nativeHttps: Boolean(tlsOptions) }); +async function reportTelemetry() { + const state = ensureTelemetryState(getStore()); + if (!state.enabled) return { ok: false, error: 'disabled' }; + const result = await sendTelemetry({ store: getStore(), buildPayload: telemetryPayload }); + saveStore(); + return result; +} +function scheduleTelemetry(startup = false) { + if (telemetryTimer) clearTimeout(telemetryTimer); + telemetryTimer = null; + nextTelemetryAt = null; + const state = ensureTelemetryState(getStore()); + if (!state.enabled) return; + const last = state.last_attempt ? new Date(state.last_attempt).getTime() : 0; + const remaining = Math.max(0, last + REPORT_INTERVAL_MS - Date.now()); + const delay = remaining || (startup ? randomJitter() : REPORT_INTERVAL_MS); + nextTelemetryAt = new Date(Date.now() + delay).toISOString(); + telemetryTimer = setTimeout(async () => { await reportTelemetry(); scheduleTelemetry(); }, delay); +} + function scheduleNextScan(delayMinutes = getStore().settings.intervalMinutes) { if (scanTimer) clearTimeout(scanTimer); scanTimer = null; @@ -152,6 +181,7 @@ async function scanAll(trigger = 'scheduled', actor = null) { result.installed += 1; addEvent({ type: 'auto-update', container: c.name, image: c.image, result: 'success' }); addEvent({ type: 'update-successful', container: c.name, image: c.image, result: 'success', message: 'Automatic update completed' }); + incrementTelemetryCounter(store, 'successful_updates'); saveStore(); }); } } catch (error) { @@ -190,14 +220,15 @@ async function api(req, res, url, session) { } if (!session) return json(res, 401, { error: 'Anmeldung erforderlich' }); if (req.method === 'GET' && url.pathname === '/api/session') return json(res, 200, { user: { username: session.username, role: session.role }, csrf: session.csrf, version: appVersion }); - if (req.method === 'POST') requireCsrf(req, session); + if (['POST', 'DELETE'].includes(req.method)) requireCsrf(req, session); if (req.method === 'POST' && url.pathname === '/api/logout') { destroySession(session.token); return json(res, 200, { ok: true }, { 'set-cookie': clearSessionCookie() }); } if (req.method === 'GET' && url.pathname === '/api/status') { const containers = await listContainers(); const store = getStore(); const watchtowerImport = session.role === 'admin' ? watchtowerImportPreview(containers, store.policies) : null; - return json(res, 200, { version: appVersion, lastScan: store.lastScan, lastScanResult: store.lastScanResult, nextScanAt, scanRunning, settings: publicSettings(session), registryAuth: { configured: session.role === 'admin' ? configuredRegistries() : [] }, watchtowerImport: watchtowerImport ? { detected: watchtowerImport.detected, changes: watchtowerImport.changes } : null, selfUpdate: readSelfUpdateStatus(), events: store.events, containers: containers.map(c => ({ + const telemetry = ensureTelemetryState(store); + return json(res, 200, { version: appVersion, lastScan: store.lastScan, lastScanResult: store.lastScanResult, nextScanAt, scanRunning, settings: publicSettings(session), telemetry: { enabled: telemetry.enabled, installationId: telemetry.installation_id ? `${telemetry.installation_id.slice(0, 8)}…` : null, lastSuccessfulReport: telemetry.last_successful_report, lastAttempt: telemetry.last_attempt, lastStatus: telemetry.last_status, nextAutomaticReport: telemetry.enabled ? nextTelemetryAt || nextAutomaticReport(telemetry) : null }, registryAuth: { configured: session.role === 'admin' ? configuredRegistries() : [] }, watchtowerImport: watchtowerImport ? { detected: watchtowerImport.detected, changes: watchtowerImport.changes } : null, selfUpdate: readSelfUpdateStatus(), events: store.events, containers: containers.map(c => ({ ...c, parsed: parseImage(c.image), policy: store.policies[c.name] || { auto: process.env.CP_AUTO_DEFAULT === 'true' }, @@ -242,6 +273,43 @@ async function api(req, res, url, session) { addEvent({ type: 'settings-changed', actor: session.username, result: 'success', message: `Intervall ${intervalMinutes} min` }); return json(res, 200, { settings: getStore().settings, nextScanAt }); } + if (req.method === 'GET' && url.pathname === '/api/telemetry/preview') { + try { + if (!ensureTelemetryState(getStore()).installation_id) return json(res, 409, { error: 'Telemetrie muss zuerst aktiviert werden' }); + return json(res, 200, { payload: await telemetryPayload() }); + } catch { return json(res, 503, { error: 'Telemetrie-Vorschau konnte nicht erstellt werden' }); } + } + if (req.method === 'POST' && url.pathname === '/api/telemetry/settings') { + if (session.role !== 'admin') return json(res, 403, { error: 'Adminrechte erforderlich' }); + const data = await body(req); const state = ensureTelemetryState(getStore()); + if (data.enabled === true) enableTelemetry(getStore()); else state.enabled = false; + saveStore(); scheduleTelemetry(data.enabled === true); + return json(res, 200, { enabled: ensureTelemetryState(getStore()).enabled }); + } + if (req.method === 'POST' && url.pathname === '/api/telemetry/send') { + if (session.role !== 'admin') return json(res, 403, { error: 'Adminrechte erforderlich' }); + if (!ensureTelemetryState(getStore()).enabled) return json(res, 409, { error: 'Telemetrie ist deaktiviert' }); + const result = await reportTelemetry(); scheduleTelemetry(); + return json(res, result.ok ? 200 : 502, result.ok ? { status: 'successful' } : { error: result.error }); + } + if (req.method === 'POST' && url.pathname === '/api/telemetry/reset') { + if (session.role !== 'admin') return json(res, 403, { error: 'Adminrechte erforderlich' }); + resetTelemetryIdentity(getStore()); saveStore(); scheduleTelemetry(true); + return json(res, 200, { ok: true }); + } + if (req.method === 'DELETE' && url.pathname === '/api/telemetry/data') { + if (session.role !== 'admin') return json(res, 403, { error: 'Adminrechte erforderlich' }); + const state = ensureTelemetryState(getStore()); + if (!state.installation_id || !state.delete_token) return json(res, 404, { error: 'Keine Telemetrie-Identität vorhanden' }); + try { + const endpoint = new URL(telemetryUrl()); + endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${state.installation_id}`; + const response = await fetch(endpoint, { method: 'DELETE', headers: { authorization: `Bearer ${state.delete_token}` }, signal: AbortSignal.timeout(8_000) }); + if (!response.ok && response.status !== 404) throw new Error('delete_failed'); + resetTelemetryIdentity(getStore()); saveStore(); scheduleTelemetry(); + return json(res, 200, { ok: true }); + } catch { return json(res, 502, { error: 'Serverdaten konnten nicht gelöscht werden' }); } + } if (req.method === 'GET' && url.pathname === '/api/users') { if (session.role !== 'admin') return json(res, 403, { error: 'Adminrechte erforderlich' }); return json(res, 200, { users: Object.entries(getStore().users).map(([name, value]) => publicUser(name, value)) }); @@ -259,6 +327,7 @@ async function api(req, res, url, session) { const known = new Set(preview.entries.map(entry => entry.id)); if (data.selectedIds.some(id => !known.has(id))) return json(res, 400, { error: 'Auswahl enthält unbekannte Container' }); const imported = applyWatchtowerImport(preview, data.selectedIds, getStore().policies); + if (imported.length) ensureTelemetryState(getStore()).watchtower_import_used = true; saveStore(); for (const entry of imported) addEvent({ type: 'watchtower-policy-imported', actor: session.username, container: entry.name, image: entry.image, result: 'success', message: `Automatic updates ${entry.proposedAuto ? 'enabled' : 'disabled'} (${entry.reason})` }); addEvent({ type: 'watchtower-import-complete', actor: session.username, result: 'success', message: `${imported.length} policies imported` }); @@ -319,6 +388,7 @@ async function api(req, res, url, session) { getStore().lastUpdates[current.name] = { at: new Date().toISOString(), mode: 'manual', type: updateType, actor: session.username }; addEvent({ type: updateType, actor: session.username, container: current.name, image: target, result: 'success' }); addEvent({ type: 'update-successful', actor: session.username, container: current.name, image: target, result: 'success', message: 'Manual update completed' }); + incrementTelemetryCounter(getStore(), 'successful_updates'); saveStore(); return replaced; }); return json(res, 200, result); @@ -340,6 +410,7 @@ async function api(req, res, url, session) { getStore().lastUpdates[current.name] = { at: new Date().toISOString(), mode: 'manual', type: 'rollback', actor: session.username }; addEvent({ type: 'rollback', actor: session.username, container: current.name, image: checkpoint.image, result: 'success', message: `Wiederhergestellt: ${checkpoint.displayImage}` }); addEvent({ type: 'rollback-successful', actor: session.username, container: current.name, image: checkpoint.image, result: 'success', message: `Restored ${checkpoint.displayImage}` }); + incrementTelemetryCounter(getStore(), 'manual_rollbacks'); saveStore(); return replaced; }); return json(res, 200, result); @@ -383,4 +454,5 @@ server.listen(port, '0.0.0.0', () => console.log(`Container Pilot lauscht per ${ setTimeout(async () => { if (getStore().settings.enabled) await scanAll(); scheduleNextScan(); + scheduleTelemetry(true); }, 3_000); diff --git a/src/store.js b/src/store.js index e512e35..8bef911 100644 --- a/src/store.js +++ b/src/store.js @@ -2,11 +2,20 @@ import fs from 'node:fs'; import path from 'node:path'; const file = process.env.CP_STORE_FILE || '/data/state.json'; -let state = { policies: {}, scans: {}, events: [], users: {}, lastScan: null, lastScanResult: null, lastUpdates: {}, rollbacks: {}, settings: null }; +export const defaultTelemetryState = () => ({ + enabled: false, installation_id: null, delete_token: null, + last_successful_report: null, last_attempt: null, last_status: null, + successful_updates: 0, failed_updates: 0, automatic_rollbacks: 0, + manual_rollbacks: 0, watchtower_import_used: false, +}); +let state = { policies: {}, scans: {}, events: [], users: {}, lastScan: null, lastScanResult: null, lastUpdates: {}, rollbacks: {}, settings: null, telemetry: defaultTelemetryState() }; let eventListener = null; export function loadStore() { - try { state = { ...state, ...JSON.parse(fs.readFileSync(file, 'utf8')) }; } catch (e) { + try { + const loaded = JSON.parse(fs.readFileSync(file, 'utf8')); + state = { ...state, ...loaded, telemetry: { ...defaultTelemetryState(), ...(loaded.telemetry || {}) } }; + } catch (e) { if (e.code !== 'ENOENT') console.error('Store konnte nicht geladen werden', e); } return state; diff --git a/src/telemetry.js b/src/telemetry.js new file mode 100644 index 0000000..ea03661 --- /dev/null +++ b/src/telemetry.js @@ -0,0 +1,136 @@ +import crypto from 'node:crypto'; +import { dockerRequest, inspectContainer, listContainers, parseImage } from './docker.js'; +import { configuredRegistries } from './registry-auth.js'; +import { defaultTelemetryState } from './store.js'; + +export const TELEMETRY_SCHEMA_VERSION = 1; +export const DEFAULT_TELEMETRY_URL = 'https://cp-track.noisens.de/api/v1/telemetry'; +export const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1_000; +export const JITTER_MIN_MS = 2 * 60_000; +export const JITTER_MAX_MS = 15 * 60_000; + +export function ensureTelemetryState(store) { + store.telemetry = { ...defaultTelemetryState(), ...(store.telemetry || {}) }; + return store.telemetry; +} + +export function enableTelemetry(store) { + const telemetry = ensureTelemetryState(store); + if (!telemetry.installation_id) telemetry.installation_id = crypto.randomUUID(); + if (!telemetry.delete_token) telemetry.delete_token = crypto.randomBytes(32).toString('base64url'); + telemetry.enabled = true; + return telemetry; +} + +export function resetTelemetryIdentity(store) { + store.telemetry = defaultTelemetryState(); + return store.telemetry; +} + +export function incrementTelemetryCounter(store, name) { + const telemetry = ensureTelemetryState(store); + if (!['successful_updates', 'failed_updates', 'automatic_rollbacks', 'manual_rollbacks'].includes(name)) throw new Error('Unknown telemetry counter'); + telemetry[name] = Math.max(0, Number(telemetry[name]) || 0) + 1; +} + +function architecture(value) { + const normalized = String(value || '').toLowerCase(); + return ({ x86_64: 'amd64', aarch64: 'arm64', i386: '386', i686: '386' })[normalized] + || (['amd64', 'arm64', 'arm', '386'].includes(normalized) ? normalized : 'other'); +} +function shortKernel(value) { return String(value || '').match(/^\d+\.\d+/)?.[0] || 'other'; } +function registryCategories(containers) { + const values = { docker_hub: false, ghcr: false, gitlab: false, generic_oci: false }; + for (const container of containers) { + const registry = parseImage(container.image).registry.toLowerCase(); + if (registry === 'docker.io') values.docker_hub = true; + else if (registry === 'ghcr.io') values.ghcr = true; + else if (registry.includes('gitlab')) values.gitlab = true; + else values.generic_oci = true; + } + return values; +} +function releaseChannel(version) { + if (version.includes('-rc.')) return 'rc'; + if (version.includes('-')) return 'prerelease'; + return 'stable'; +} + +export async function buildTelemetryPayload({ store, version, nativeHttps = false, dockerInfo, dockerVersion, containers, inspect = inspectContainer, registries = configuredRegistries() }) { + const telemetry = ensureTelemetryState(store); + if (!telemetry.installation_id) throw new Error('Telemetry identity is not initialized'); + const safeContainers = containers || await listContainers(); + const info = dockerInfo || await dockerRequest('GET', '/info'); + const engine = dockerVersion || (dockerInfo ? dockerInfo : await dockerRequest('GET', '/version')); + const health = await Promise.all(safeContainers.map(async container => { + try { return Boolean((await inspect(container.id)).Config?.Healthcheck); } catch { return false; } + })); + const configured = Array.isArray(registries) && registries.length > 0; + return { + schema_version: TELEMETRY_SCHEMA_VERSION, + installation_id: telemetry.installation_id, + delete_token_hash: crypto.createHash('sha256').update(telemetry.delete_token).digest('hex'), + container_pilot: { version, channel: releaseChannel(version) }, + system: { + architecture: architecture(info.Architecture), docker_version: String(engine.Version || info.ServerVersion || 'unknown').slice(0, 64), + docker_api_version: String(engine.ApiVersion || info.ApiVersion || 'unknown').slice(0, 32), os: String(info.OperatingSystem || info.OSType || 'unknown').slice(0, 128), + kernel: shortKernel(info.KernelVersion), + }, + containers: { + total: safeContainers.length, running: safeContainers.filter(item => item.state === 'running').length, + stopped: safeContainers.filter(item => item.state !== 'running').length, with_healthcheck: health.filter(Boolean).length, + automatic_updates_enabled: safeContainers.filter(item => (store.policies[item.name] || { auto: process.env.CP_AUTO_DEFAULT === 'true' }).auto).length, + }, + features: { + watchtower_import_used: telemetry.watchtower_import_used === true, native_https_enabled: nativeHttps, + private_registry_configured: configured, webhook_configured: store.settings?.webhook?.enabled === true, + }, + registries: registryCategories(safeContainers), + updates: { + successful: telemetry.successful_updates, failed: telemetry.failed_updates, + automatic_rollbacks: telemetry.automatic_rollbacks, manual_rollbacks: telemetry.manual_rollbacks, + }, + }; +} + +export function telemetryUrl(value = process.env.CP_TELEMETRY_URL || DEFAULT_TELEMETRY_URL) { + const url = new URL(value); + const localHttp = url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname); + if (url.protocol !== 'https:' && !localHttp) throw new Error('Telemetry endpoint must use HTTPS'); + return url.toString(); +} + +export async function sendTelemetry({ store, buildPayload, fetchImpl = fetch, url, timeoutMs = 8_000, now = () => new Date() }) { + const telemetry = ensureTelemetryState(store); + telemetry.last_attempt = now().toISOString(); + try { + const payload = await buildPayload(); + const response = await fetchImpl(telemetryUrl(url), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs) }); + if (!response.ok) throw new Error(`http_${response.status}`); + let result; + try { result = await response.json(); } catch { throw new Error('invalid_response'); } + if (result?.status !== 'accepted') throw new Error('invalid_response'); + telemetry.last_successful_report = telemetry.last_attempt; + telemetry.last_status = 'successful'; + return { ok: true, payload }; + } catch (error) { + telemetry.last_status = safeTelemetryError(error); + return { ok: false, error: telemetry.last_status }; + } +} + +export function safeTelemetryError(error) { + if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout'; + if (/^http_\d{3}$/.test(error?.message || '')) return error.message; + if (/invalid_response/.test(error?.message || '')) return 'invalid_response'; + if (/certificate|tls|ssl|cert_/i.test(`${error?.message || ''} ${error?.cause?.code || ''}`)) return 'tls_error'; + return 'connection_failed'; +} + +export function nextAutomaticReport(telemetry, now = Date.now()) { + if (!telemetry?.enabled) return null; + const due = telemetry.last_successful_report ? new Date(telemetry.last_successful_report).getTime() + REPORT_INTERVAL_MS : now; + return new Date(Math.max(now, due)).toISOString(); +} + +export function randomJitter(random = Math.random) { return JITTER_MIN_MS + Math.floor(random() * (JITTER_MAX_MS - JITTER_MIN_MS + 1)); } From 21dd9bfe63d43d5496b167a16374948fd2052498 Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:44:10 +0200 Subject: [PATCH 2/7] feat: add telemetry settings UI --- src/public/app.css | 1 + src/public/app.js | 16 ++++++++++++++++ src/public/i18n.js | 2 ++ src/public/index.html | 10 +++++++++- 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/public/app.css b/src/public/app.css index 6aefb5b..baf899a 100644 --- a/src/public/app.css +++ b/src/public/app.css @@ -9,3 +9,4 @@ header,main,footer{max-width:1350px;margin:auto}.appHeader{padding:24px 24px 20p .warningBox{margin:0;padding:12px 14px;border:1px solid #8b632d;border-radius:9px;background:#392b17;color:#ffdca8;font-size:13px;line-height:1.5} .language{margin-top:18px}.language select,[data-language]{padding:7px 9px;border:1px solid #35506e;border-radius:8px;background:#0b1421;color:#fff} .importRow{display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:center;padding:12px 0;border-bottom:1px solid #22334a}.importRow small{display:block;margin-top:3px;word-break:break-all} +.telemetryStatus{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:18px 0;padding:14px;border:1px solid #29425f;border-radius:10px;background:#0d1724;font-size:13px}.telemetryStatus span{color:#8da1bd}.payloadPreview{max-height:340px;overflow:auto;padding:14px;border:1px solid #29425f;border-radius:10px;background:#080e17;color:#b9d8ff;font:12px/1.55 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word} diff --git a/src/public/app.js b/src/public/app.js index e20730f..3accb13 100644 --- a/src/public/app.js +++ b/src/public/app.js @@ -10,6 +10,7 @@ let authRevision = 0; let manualScanPending = false; let selfUpdateData = null; let watchtowerPreview = null; +const displayTime = (value) => value ? new Date(value).toLocaleString(locale()) : t('notAvailable'); async function api(path, options = {}) { const requestAuthRevision = authRevision; @@ -54,6 +55,21 @@ $('#scan').onclick = async () => { $('#containersButton').onclick = () => showView('containers'); $('#eventsButton').onclick = async () => { await load(); showView('events'); }; $('#refreshEvents').onclick = load; +function renderTelemetry() { + const state = currentStatus.telemetry; + $('#telemetryEnabled').checked = state.enabled; + $('#telemetryToggleRow').hidden = currentUser.role !== 'admin'; + $('#sendTelemetry').hidden = currentUser.role !== 'admin'; $('#resetTelemetry').hidden = currentUser.role !== 'admin'; $('#deleteTelemetry').hidden = currentUser.role !== 'admin'; + $('#sendTelemetry').disabled = !state.enabled; + $('#telemetryStatus').innerHTML = `${esc(t('telemetryStatus'))}${esc(t(state.enabled ? 'telemetryEnabled' : 'telemetryDisabled'))}${esc(t('installationId'))}${esc(state.installationId || t('notAvailable'))}${esc(t('lastReport'))}${esc(displayTime(state.lastSuccessfulReport))}${esc(t('lastAttempt'))}${esc(displayTime(state.lastAttempt))}${state.lastStatus ? ` · ${esc(state.lastStatus)}` : ''}${esc(t('nextReport'))}${esc(displayTime(state.nextAutomaticReport))}`; +} +$('#telemetryButton').onclick = () => { renderTelemetry(); $('#telemetryPreview').hidden = true; $('#telemetryNotice').textContent = ''; $('#telemetryDialog').showModal(); }; +document.querySelector('[data-close-telemetry]').onclick = () => $('#telemetryDialog').close(); +$('#telemetryEnabled').onchange = async (event) => { try { await api('/api/telemetry/settings', { method: 'POST', body: JSON.stringify({ enabled: event.target.checked }) }); await load(); renderTelemetry(); $('#telemetryNotice').textContent = t('telemetrySaved'); } catch (error) { $('#telemetryNotice').textContent = t('error', { message: error.message }); } }; +$('#previewTelemetry').onclick = async () => { try { const data = await api('/api/telemetry/preview'); $('#telemetryPreview').textContent = JSON.stringify(data.payload, null, 2); $('#telemetryPreview').hidden = false; } catch (error) { $('#telemetryNotice').textContent = t('error', { message: error.message }); } }; +$('#sendTelemetry').onclick = async () => { try { await api('/api/telemetry/send', { method: 'POST', body: '{}' }); await load(); renderTelemetry(); $('#telemetryNotice').textContent = t('actionSuccess'); } catch (error) { await load(); renderTelemetry(); $('#telemetryNotice').textContent = t('error', { message: error.message }); } }; +$('#resetTelemetry').onclick = () => confirmAction(t('telemetryResetTitle'), t('telemetryResetConfirm'), async () => { await api('/api/telemetry/reset', { method: 'POST', body: '{}' }); $('#telemetryDialog').close(); await load(); }); +$('#deleteTelemetry').onclick = () => confirmAction(t('telemetryDeleteTitle'), t('telemetryDeleteConfirm'), async () => { await api('/api/telemetry/data', { method: 'DELETE', body: '{}' }); $('#telemetryDialog').close(); await load(); }); $('#confirmGo').onclick = async () => { if (!pending) return; const fn = pending; pending = null; try { $('#notice').textContent = t('actionRunning'); await fn(); $('#notice').textContent = t('actionSuccess'); setTimeout(load, 1000); } diff --git a/src/public/i18n.js b/src/public/i18n.js index fd63981..036456d 100644 --- a/src/public/i18n.js +++ b/src/public/i18n.js @@ -8,6 +8,7 @@ export const messages = { }; Object.assign(messages.en, { + telemetry: 'Usage statistics', anonymousStatistics: 'Anonymous Usage Statistics', telemetryHelp: 'Help improve Container Pilot by sharing anonymous technical statistics about your installation.', telemetryExcludes: 'Container Pilot never sends container names, image names, IP addresses, hostnames, credentials, environment variables, mount paths or application data.', shareStatistics: 'Share anonymous usage statistics', previewData: 'Preview data', sendNow: 'Send now', resetTelemetryIdentity: 'Reset telemetry identity', deleteServerData: 'Delete server data', telemetryEnabled: 'Enabled', telemetryDisabled: 'Disabled', installationId: 'Installation ID', lastReport: 'Last successful report', lastAttempt: 'Last attempt', nextReport: 'Next automatic report', telemetryStatus: 'Status', notAvailable: 'Not available', telemetrySaved: 'Telemetry setting saved.', telemetryResetTitle: 'Reset telemetry identity', telemetryResetConfirm: 'The local installation ID, delete token and report status are reset. A new identity is generated when telemetry is enabled again.', telemetryDeleteTitle: 'Delete server data', telemetryDeleteConfirm: 'All reports for this telemetry identity are permanently deleted from the tracker. This requires the local delete token.', watchtowerImport: 'Watchtower import', watchtowerHelp: 'Detected labels are shown as a preview. Nothing changes until selected rules are imported.', watchtowerDetected: '{detected} labelled containers detected · {changes} policy changes proposed', @@ -16,6 +17,7 @@ Object.assign(messages.en, { importComplete: '{count} policies imported.', selectionRequired: 'Select at least one policy.', ambiguousLabel: 'No safe mapping', }); Object.assign(messages.de, { + telemetry: 'Nutzungsstatistiken', anonymousStatistics: 'Anonyme Nutzungsstatistiken', telemetryHelp: 'Hilf dabei, Container Pilot zu verbessern, indem anonyme technische Nutzungsstatistiken geteilt werden.', telemetryExcludes: 'Container Pilot überträgt keine Container-Namen, Image-Namen, IP-Adressen, Hostnamen, Zugangsdaten, Umgebungsvariablen, Mount-Pfade oder Anwendungsdaten.', shareStatistics: 'Anonyme Nutzungsstatistiken teilen', previewData: 'Daten anzeigen', sendNow: 'Jetzt senden', resetTelemetryIdentity: 'Telemetrie-ID zurücksetzen', deleteServerData: 'Serverdaten löschen', telemetryEnabled: 'Aktiviert', telemetryDisabled: 'Deaktiviert', installationId: 'Installations-ID', lastReport: 'Letzter erfolgreicher Report', lastAttempt: 'Letzter Versuch', nextReport: 'Nächster automatischer Report', telemetryStatus: 'Status', notAvailable: 'Nicht verfügbar', telemetrySaved: 'Telemetrie-Einstellung gespeichert.', telemetryResetTitle: 'Telemetrie-ID zurücksetzen', telemetryResetConfirm: 'Lokale Installations-ID, Lösch-Token und Reportstatus werden zurückgesetzt. Bei erneuter Aktivierung wird eine neue Identität erzeugt.', telemetryDeleteTitle: 'Serverdaten löschen', telemetryDeleteConfirm: 'Alle Reports dieser Telemetrie-Identität werden unwiderruflich vom Tracker gelöscht. Dafür wird das lokale Lösch-Token benötigt.', watchtowerImport: 'Watchtower-Import', watchtowerHelp: 'Erkannte Labels werden nur als Vorschau angezeigt. Erst ausgewählte Regeln werden übernommen.', watchtowerDetected: '{detected} markierte Container erkannt · {changes} Richtlinienänderungen vorgeschlagen', diff --git a/src/public/index.html b/src/public/index.html index be4255f..3a26db8 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -27,7 +27,7 @@

Container Pilot

@@ -57,6 +57,14 @@

Container Pilot

+ +

+

+
+ +
+

+

From d79c511ad88ceaebc6f574db0ab0a9f2329f8930 Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:44:10 +0200 Subject: [PATCH 3/7] feat: add telemetry collector and internal dashboard --- tracker/.dockerignore | 7 ++ tracker/.env.example | 10 ++ tracker/Dockerfile | 11 ++ tracker/compose.yml | 55 ++++++++++ tracker/migrations/001_initial.sql | 19 ++++ tracker/package-lock.json | 164 +++++++++++++++++++++++++++++ tracker/package.json | 9 ++ tracker/public/app.css | 1 + tracker/public/app.js | 8 ++ tracker/public/index.html | 1 + tracker/public/login.html | 1 + tracker/public/login.js | 1 + tracker/src/auth.js | 17 +++ tracker/src/dashboard-data.js | 34 ++++++ tracker/src/db.js | 43 ++++++++ tracker/src/public-api.js | 26 +++++ tracker/src/server.js | 51 +++++++++ tracker/src/validation.js | 26 +++++ 18 files changed, 484 insertions(+) create mode 100644 tracker/.dockerignore create mode 100644 tracker/.env.example create mode 100644 tracker/Dockerfile create mode 100644 tracker/compose.yml create mode 100644 tracker/migrations/001_initial.sql create mode 100644 tracker/package-lock.json create mode 100644 tracker/package.json create mode 100644 tracker/public/app.css create mode 100644 tracker/public/app.js create mode 100644 tracker/public/index.html create mode 100644 tracker/public/login.html create mode 100644 tracker/public/login.js create mode 100644 tracker/src/auth.js create mode 100644 tracker/src/dashboard-data.js create mode 100644 tracker/src/db.js create mode 100644 tracker/src/public-api.js create mode 100644 tracker/src/server.js create mode 100644 tracker/src/validation.js diff --git a/tracker/.dockerignore b/tracker/.dockerignore new file mode 100644 index 0000000..f269aca --- /dev/null +++ b/tracker/.dockerignore @@ -0,0 +1,7 @@ +node_modules +tests +secrets +.env +*.log +README.md +compose.yml diff --git a/tracker/.env.example b/tracker/.env.example new file mode 100644 index 0000000..8ae51c8 --- /dev/null +++ b/tracker/.env.example @@ -0,0 +1,10 @@ +TRACKER_API_BIND=0.0.0.0 +TRACKER_API_PORT=3090 +TRACKER_DASHBOARD_BIND=0.0.0.0 +TRACKER_DASHBOARD_PORT=3091 +TRACKER_DASHBOARD_HOST=127.0.0.1 +TRACKER_ADMIN_USER=admin +TRACKER_ADMIN_PASSWORD_FILE=/run/secrets/admin_password +TRACKER_SESSION_MINUTES=60 +TRACKER_SECURE_COOKIE=false +TRACKER_RETENTION_DAYS=90 diff --git a/tracker/Dockerfile b/tracker/Dockerfile new file mode 100644 index 0000000..c76c251 --- /dev/null +++ b/tracker/Dockerfile @@ -0,0 +1,11 @@ +FROM node:24-alpine +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY migrations ./migrations +COPY public ./public +COPY src ./src +USER node +EXPOSE 3090 3091 +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:3090/healthz || exit 1 +CMD ["node", "src/server.js"] diff --git a/tracker/compose.yml b/tracker/compose.yml new file mode 100644 index 0000000..11a39b2 --- /dev/null +++ b/tracker/compose.yml @@ -0,0 +1,55 @@ +services: + tracker: + build: . + restart: unless-stopped + init: true + read_only: true + env_file: .env + environment: + TRACKER_API_BIND: ${TRACKER_API_BIND:-0.0.0.0} + TRACKER_DASHBOARD_BIND: ${TRACKER_DASHBOARD_BIND:-0.0.0.0} + PGHOST: postgres + PGDATABASE: container_pilot + PGUSER: tracker + TRACKER_POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + TRACKER_ADMIN_PASSWORD_FILE: /run/secrets/admin_password + ports: + - "127.0.0.1:3090:3090" + - "${TRACKER_DASHBOARD_HOST:-127.0.0.1}:3091:3091" + secrets: + - admin_password + - postgres_password + tmpfs: + - /tmp:size=16m,mode=1777 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:3090/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: container_pilot + POSTGRES_USER: tracker + POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password + secrets: + - postgres_password + volumes: + - tracker-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tracker -d container_pilot"] + interval: 10s + timeout: 5s + retries: 5 +volumes: + tracker-postgres: +secrets: + admin_password: + file: ./secrets/admin_password + postgres_password: + file: ./secrets/postgres_password diff --git a/tracker/migrations/001_initial.sql b/tracker/migrations/001_initial.sql new file mode 100644 index 0000000..3cab3a6 --- /dev/null +++ b/tracker/migrations/001_initial.sql @@ -0,0 +1,19 @@ +CREATE TABLE installations ( + installation_id uuid PRIMARY KEY, first_seen timestamptz NOT NULL, last_seen timestamptz NOT NULL, schema_version integer NOT NULL, + container_pilot_version varchar(64) NOT NULL, channel varchar(16) NOT NULL, architecture varchar(16) NOT NULL, + docker_version varchar(64) NOT NULL, docker_api_version varchar(32) NOT NULL, operating_system varchar(128) NOT NULL, kernel_version varchar(16) NOT NULL, + containers_total integer NOT NULL, containers_running integer NOT NULL, containers_stopped integer NOT NULL, containers_with_healthcheck integer NOT NULL, containers_auto_update integer NOT NULL, + watchtower_import_used boolean NOT NULL, native_https_enabled boolean NOT NULL, private_registry_configured boolean NOT NULL, webhook_configured boolean NOT NULL, + registry_docker_hub boolean NOT NULL, registry_ghcr boolean NOT NULL, registry_gitlab boolean NOT NULL, registry_generic_oci boolean NOT NULL, + successful_updates integer NOT NULL, failed_updates integer NOT NULL, automatic_rollbacks integer NOT NULL, manual_rollbacks integer NOT NULL, + delete_token_hash char(64) NOT NULL +); +CREATE INDEX installations_last_seen_idx ON installations(last_seen); +CREATE TABLE reports ( + id bigserial PRIMARY KEY, installation_id uuid NOT NULL REFERENCES installations(installation_id) ON DELETE CASCADE, received_at timestamptz NOT NULL DEFAULT now(), + container_pilot_version varchar(64) NOT NULL, containers_total integer NOT NULL, containers_running integer NOT NULL, containers_stopped integer NOT NULL, + containers_with_healthcheck integer NOT NULL, containers_auto_update integer NOT NULL, successful_updates integer NOT NULL, failed_updates integer NOT NULL, + automatic_rollbacks integer NOT NULL, manual_rollbacks integer NOT NULL +); +CREATE INDEX reports_installation_received_idx ON reports(installation_id, received_at DESC); +CREATE INDEX reports_received_idx ON reports(received_at); diff --git a/tracker/package-lock.json b/tracker/package-lock.json new file mode 100644 index 0000000..3be27e1 --- /dev/null +++ b/tracker/package-lock.json @@ -0,0 +1,164 @@ +{ + "name": "container-pilot-telemetry-tracker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "container-pilot-telemetry-tracker", + "version": "0.1.0", + "dependencies": { + "pg": "^8.16.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/pg": { + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/tracker/package.json b/tracker/package.json new file mode 100644 index 0000000..bdc9302 --- /dev/null +++ b/tracker/package.json @@ -0,0 +1,9 @@ +{ + "name": "container-pilot-telemetry-tracker", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { "start": "node src/server.js", "test": "node --test tests/*.test.js", "test:integration": "TRACKER_RUN_POSTGRES_INTEGRATION=1 node --test tests/integration/*.test.js" }, + "engines": { "node": ">=22" }, + "dependencies": { "pg": "^8.16.3" } +} diff --git a/tracker/public/app.css b/tracker/public/app.css new file mode 100644 index 0000000..804f461 --- /dev/null +++ b/tracker/public/app.css @@ -0,0 +1 @@ +:root{font-family:Inter,system-ui,sans-serif;color:#e8eef7;background:#09111c;color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 90% 0,#17375c,transparent 35%),#09111c;min-height:100vh}header,main,footer{max-width:1500px;margin:auto;padding:24px}header{display:flex;justify-content:space-between;align-items:center}header h1{margin:0}header p,.muted{color:#8da1bd}button,select,input{font:inherit;color:#eef5ff;background:#15263b;border:1px solid #35506e;border-radius:8px;padding:10px}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}.cards div,article{background:#101b2a;border:1px solid #263c58;border-radius:14px;padding:18px;box-shadow:0 14px 38px #0005}.cards strong{display:block;font-size:27px}.cards span{font-size:12px;color:#8da1bd}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:20px 0}.grid article{min-height:250px}h2{font-size:16px;margin:0 0 18px}.bars{display:grid;gap:12px}.bars>div{display:grid;grid-template-columns:150px 1fr 100px;gap:10px;align-items:center;font-size:12px}.bars>div>div{height:8px;background:#20334b;border-radius:8px;overflow:hidden}.bars i{display:block;height:100%;background:#2784ff}.bars strong{text-align:right}.timeline{height:175px;display:flex;align-items:end;gap:4px;border-bottom:1px solid #35506e}.timeline div{height:100%;flex:1;display:flex;flex-direction:column;justify-content:end;align-items:center}.timeline i{display:block;width:100%;min-width:3px;background:#2784ff;border-radius:3px 3px 0 0}.timeline small{font-size:8px;writing-mode:vertical-rl;color:#8da1bd}.table{overflow:auto}table{border-collapse:collapse;width:100%;min-width:1300px;font-size:12px}th,td{text-align:left;border-bottom:1px solid #263c58;padding:10px}tbody tr{cursor:pointer}tbody tr:hover{background:#17273b}footer{color:#71849e;font-size:12px}dialog{width:min(1000px,95vw);max-height:90vh;background:#101b2a;color:#e8eef7;border:1px solid #35506e;border-radius:12px}pre{overflow:auto;white-space:pre-wrap}.login{display:grid;place-items:center}.login form{width:min(400px,90vw);padding:30px;background:#101b2a;border:1px solid #35506e;border-radius:14px}.login label{display:grid;gap:6px;margin:16px 0}.login input,.login button{width:100%}.error{color:#ff8989}@media(max-width:900px){.grid{grid-template-columns:1fr}header{align-items:flex-start;gap:15px}.bars>div{grid-template-columns:110px 1fr 85px}} diff --git a/tracker/public/app.js b/tracker/public/app.js new file mode 100644 index 0000000..e0f995f --- /dev/null +++ b/tracker/public/app.js @@ -0,0 +1,8 @@ +const $ = selector => document.querySelector(selector); let csrf; +const esc = value => String(value ?? '').replace(/[&<>"']/g, character => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' })[character]); +async function api(path, options={}) { const response = await fetch(path, { ...options, headers: { 'content-type':'application/json', ...(csrf ? {'x-csrf-token':csrf}:{}), ...(options.headers||{}) } }); if (response.status===401) location.href='/login.html'; const data=await response.json(); if(!response.ok) throw new Error(data.error); return data; } +const number = value => Number.isFinite(Number(value)) ? Number(value||0).toLocaleString() : String(value); const percent = (value,total) => total ? `${Math.round(value/total*100)}%` : '–'; +function bars(rows,total) { return `
${rows.map(row=>`
${esc(row.value)}
${number(row.count)} · ${percent(row.count,total)}
`).join('')}
`; } +function timeline(rows) { if(!rows.length) return '

No reports in this range.

'; const max=Math.max(...rows.map(row=>row.active),1); return `
${rows.map(row=>`
${esc(String(row.day).slice(5,10))}
`).join('')}
`; } +async function load() { const days=$('#range').value; const [data,list]=await Promise.all([api(`/api/dashboard/summary?days=${days}`),api('/api/dashboard/installations')]); const attempts=Number(data.successful_updates)+Number(data.failed_updates); const cards=[['Active 24h',data.active_24h],['Active 7d',data.active_7d],['Active 30d',data.active_30d],['Known installations',data.total_known],['Managed containers',data.managed_containers],['Average containers',Number(data.average_containers).toFixed(1)],['Average running',Number(data.average_running).toFixed(1)],['Average stopped',Number(data.average_stopped).toFixed(1)],['Healthcheck adoption',percent(data.healthchecks,data.managed_containers)],['Auto-update adoption',percent(data.auto_updates,data.managed_containers)],['Successful updates',data.successful_updates],['Failed updates',data.failed_updates],['Update success rate',percent(data.successful_updates,attempts)],['Automatic rollbacks',data.automatic_rollbacks],['Auto recovery / failures',percent(data.automatic_rollbacks,data.failed_updates)],['Manual rollbacks',data.manual_rollbacks]]; $('#kpis').innerHTML=cards.map(([label,value])=>`
${number(value)}${label}
`).join(''); $('#timeline').innerHTML=timeline(data.timeline); $('#timelineStats').innerHTML=data.timeline.map(row=>`
${esc(String(row.day).slice(0,10))}${number(row.active)} active${number(row.reports)} reports${number(row.containers)} containers${number(row.successful_updates)} successful${number(row.failed_updates)} failed${number(row.automatic_rollbacks)} auto rollbacks
`).join(''); $('#architectures').innerHTML=bars(data.architectures,data.total_known); $('#versions').innerHTML=bars(data.versions,data.total_known); $('#docker').innerHTML=bars(data.dockerVersions,data.total_known)+`

Exact versions

`+bars(data.dockerVersionDetails,data.total_known); $('#os').innerHTML=bars(data.operatingSystems,data.total_known); const features={...data.features}; $('#features').innerHTML=bars(Object.entries(features).map(([value,count])=>({value:value.replaceAll('_',' '),count})),data.total_known); $('#installations').innerHTML=list.installations.map(item=>`${esc(item.short_id)}${new Date(item.last_seen).toLocaleString()}${esc(item.container_pilot_version)}${esc(item.architecture)}${esc(item.docker_version)}${esc(item.operating_system)}${number(item.containers_total)}${number(item.containers_with_healthcheck)}${number(item.containers_auto_update)}${number(item.successful_updates)}${number(item.failed_updates)}${number(item.automatic_rollbacks)}`).join(''); document.querySelectorAll('[data-id]').forEach(row=>row.onclick=async()=>{ const data=await api(`/api/dashboard/installations/${row.dataset.id}`); $('#detailData').textContent=JSON.stringify(data,null,2); $('#detail').showModal(); }); } +csrf=(await api('/api/session')).csrf; $('#range').onchange=load; $('#close').onclick=()=>$('#detail').close(); $('#logout').onclick=async()=>{await api('/api/logout',{method:'POST',body:'{}'}); location.href='/login.html';}; load(); diff --git a/tracker/public/index.html b/tracker/public/index.html new file mode 100644 index 0000000..6289d8b --- /dev/null +++ b/tracker/public/index.html @@ -0,0 +1 @@ +Container Pilot Telemetry

Container Pilot Telemetry

Internal statistics · no host, container, image, or IP identities

Activity and update time series

Architecture

Container Pilot versions

Docker versions

Operating systems

Feature & registry adoption

Installations

IDLast seenVersionArchitectureDockerOSContainersHealthchecksAuto updatesUpdatesFailuresAuto rollbacks

Installation details

© 2026 NoiSens Media · Internal dashboard
diff --git a/tracker/public/login.html b/tracker/public/login.html new file mode 100644 index 0000000..9bc72fe --- /dev/null +++ b/tracker/public/login.html @@ -0,0 +1 @@ +Container Pilot Telemetry – Login

Telemetry Tracker

Internal administration dashboard

diff --git a/tracker/public/login.js b/tracker/public/login.js new file mode 100644 index 0000000..7335aa5 --- /dev/null +++ b/tracker/public/login.js @@ -0,0 +1 @@ +document.querySelector('#login').onsubmit = async event => { event.preventDefault(); const response = await fetch('/login', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(Object.fromEntries(new FormData(event.target))) }); if (response.ok) location.href = '/'; else document.querySelector('#error').textContent = (await response.json()).error || 'Login failed'; }; diff --git a/tracker/src/auth.js b/tracker/src/auth.js new file mode 100644 index 0000000..d930f2b --- /dev/null +++ b/tracker/src/auth.js @@ -0,0 +1,17 @@ +import crypto from 'node:crypto'; + +const sessions = new Map(); +const attempts = new Map(); +const SESSION_MS = Math.max(5, Number(process.env.TRACKER_SESSION_MINUTES || 60)) * 60_000; +const WINDOW_MS = 15 * 60_000; +export function loginAllowed(key, now = Date.now()) { const recent = (attempts.get(key) || []).filter(at => now - at < WINDOW_MS); attempts.set(key, recent); return recent.length < 10; } +export function loginFailed(key) { attempts.set(key, [...(attempts.get(key) || []), Date.now()]); } +export function loginSucceeded(key) { attempts.delete(key); } +export function createSession() { const token = crypto.randomBytes(32).toString('base64url'); const csrf = crypto.randomBytes(24).toString('base64url'); sessions.set(token, { csrf, expires: Date.now() + SESSION_MS }); return { token, csrf }; } +export function readSession(cookie = '') { const token = cookie.match(/(?:^|;\s*)tracker_session=([^;]+)/)?.[1]; const session = token && sessions.get(token); if (!session || session.expires <= Date.now()) { if (token) sessions.delete(token); return null; } return { token, ...session }; } +export function destroySession(token) { sessions.delete(token); } +export function cleanupSessions(now = Date.now()) { for (const [token, session] of sessions) if (session.expires <= now) sessions.delete(token); } +export function cookie(token, secure = false) { return `tracker_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(SESSION_MS / 1000)}${secure ? '; Secure' : ''}`; } +export const clearCookie = () => 'tracker_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0'; +export function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); } +export function validCredentials(username, password, expectedUser, expectedPassword) { return safeEqual(username, expectedUser) && safeEqual(password, expectedPassword); } diff --git a/tracker/src/dashboard-data.js b/tracker/src/dashboard-data.js new file mode 100644 index 0000000..0ffda63 --- /dev/null +++ b/tracker/src/dashboard-data.js @@ -0,0 +1,34 @@ +import { pool } from './db.js'; + +export async function summary(days = 30, db = pool) { + const range = [7, 30, 90].includes(Number(days)) ? Number(days) : 30; + const current = await db.query(`SELECT + count(*)::int total_known, + count(*) FILTER (WHERE last_seen >= now()-interval '24 hours')::int active_24h, + count(*) FILTER (WHERE last_seen >= now()-interval '7 days')::int active_7d, + count(*) FILTER (WHERE last_seen >= now()-interval '30 days')::int active_30d, + coalesce(sum(containers_total),0)::int managed_containers, coalesce(avg(containers_total),0)::float average_containers, + coalesce(avg(containers_running),0)::float average_running, coalesce(avg(containers_stopped),0)::float average_stopped, + coalesce(sum(containers_with_healthcheck),0)::int healthchecks, coalesce(sum(containers_auto_update),0)::int auto_updates, + coalesce(sum(successful_updates),0)::int successful_updates, coalesce(sum(failed_updates),0)::int failed_updates, + coalesce(sum(automatic_rollbacks),0)::int automatic_rollbacks, coalesce(sum(manual_rollbacks),0)::int manual_rollbacks + FROM installations`); + const group = async (column) => (await db.query(`SELECT ${column} value,count(*)::int count FROM installations GROUP BY ${column} ORDER BY count DESC`)).rows; + const feature = await db.query(`SELECT count(*) FILTER(WHERE watchtower_import_used)::int watchtower_import_used,count(*) FILTER(WHERE native_https_enabled)::int native_https_enabled,count(*) FILTER(WHERE private_registry_configured)::int private_registry_configured,count(*) FILTER(WHERE webhook_configured)::int webhook_configured,count(*) FILTER(WHERE registry_docker_hub)::int docker_hub,count(*) FILTER(WHERE registry_ghcr)::int ghcr,count(*) FILTER(WHERE registry_gitlab)::int gitlab,count(*) FILTER(WHERE registry_generic_oci)::int generic_oci FROM installations`); + const timeline = await db.query(`WITH raw AS ( + SELECT *,greatest(successful_updates-lag(successful_updates,1,successful_updates) OVER(PARTITION BY installation_id ORDER BY received_at),0) success_delta, + greatest(failed_updates-lag(failed_updates,1,failed_updates) OVER(PARTITION BY installation_id ORDER BY received_at),0) failed_delta, + greatest(automatic_rollbacks-lag(automatic_rollbacks,1,automatic_rollbacks) OVER(PARTITION BY installation_id ORDER BY received_at),0) rollback_delta + FROM reports WHERE received_at >= now()-($1*interval '1 day') + ), changes AS ( + SELECT received_at::date day,count(*)::int reports,sum(success_delta)::int successful_updates,sum(failed_delta)::int failed_updates,sum(rollback_delta)::int automatic_rollbacks FROM raw GROUP BY received_at::date + ), daily AS ( + SELECT DISTINCT ON (received_at::date,installation_id) received_at::date day,installation_id,containers_total FROM reports WHERE received_at >= now()-($1*interval '1 day') ORDER BY received_at::date,installation_id,received_at DESC + ), totals AS ( + SELECT day,count(*)::int active,sum(containers_total)::int containers FROM daily GROUP BY day + ) SELECT totals.*,changes.reports,changes.successful_updates,changes.failed_updates,changes.automatic_rollbacks FROM totals JOIN changes USING(day) ORDER BY day`, [range]); + return { ...current.rows[0], features: feature.rows[0], architectures: await group('architecture'), versions: await group('container_pilot_version'), dockerVersions: await group("split_part(docker_version,'.',1)||'.x'"), dockerVersionDetails: await group('docker_version'), operatingSystems: await group('operating_system'), timeline: timeline.rows, days: range }; +} + +export async function installations(db = pool) { return (await db.query(`SELECT installation_id,left(installation_id::text,8)||'…' short_id,first_seen,last_seen,container_pilot_version,architecture,docker_version,operating_system,containers_total,containers_with_healthcheck,containers_auto_update,successful_updates,failed_updates,automatic_rollbacks FROM installations ORDER BY last_seen DESC LIMIT 1000`)).rows; } +export async function installation(id, db = pool) { const item = (await db.query(`SELECT installation_id,first_seen,last_seen,schema_version,container_pilot_version,channel,architecture,docker_version,docker_api_version,operating_system,kernel_version,containers_total,containers_running,containers_stopped,containers_with_healthcheck,containers_auto_update,watchtower_import_used,native_https_enabled,private_registry_configured,webhook_configured,registry_docker_hub,registry_ghcr,registry_gitlab,registry_generic_oci,successful_updates,failed_updates,automatic_rollbacks,manual_rollbacks FROM installations WHERE installation_id=$1`, [id])).rows[0]; if (!item) return null; const reports = (await db.query('SELECT id,received_at,container_pilot_version,containers_total,containers_running,containers_stopped,containers_with_healthcheck,containers_auto_update,successful_updates,failed_updates,automatic_rollbacks,manual_rollbacks FROM reports WHERE installation_id=$1 ORDER BY received_at DESC LIMIT 100', [id])).rows; return { item, reports }; } diff --git a/tracker/src/db.js b/tracker/src/db.js new file mode 100644 index 0000000..6cf49f0 --- /dev/null +++ b/tracker/src/db.js @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import pg from 'pg'; + +const migrationDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'migrations'); +const password = process.env.TRACKER_POSTGRES_PASSWORD_FILE ? fs.readFileSync(process.env.TRACKER_POSTGRES_PASSWORD_FILE, 'utf8').trim() : undefined; +export const pool = new pg.Pool(process.env.DATABASE_URL ? { connectionString: process.env.DATABASE_URL } : { + host: process.env.PGHOST || 'postgres', port: Number(process.env.PGPORT || 5432), database: process.env.PGDATABASE || 'container_pilot', user: process.env.PGUSER || 'tracker', password, +}); + +export async function migrate(db = pool) { + await db.query('CREATE TABLE IF NOT EXISTS schema_migrations (version text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())'); + const applied = new Set((await db.query('SELECT version FROM schema_migrations')).rows.map(row => row.version)); + for (const name of fs.readdirSync(migrationDir).filter(file => file.endsWith('.sql')).sort()) { + if (applied.has(name)) continue; + const client = await db.connect(); + try { await client.query('BEGIN'); await client.query(fs.readFileSync(path.join(migrationDir, name), 'utf8')); await client.query('INSERT INTO schema_migrations(version) VALUES($1)', [name]); await client.query('COMMIT'); } + catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } + } +} + +export async function saveReport(payload, db = pool) { + const p = payload; const client = await db.connect(); + try { + await client.query('BEGIN'); + await client.query(`INSERT INTO installations (installation_id,first_seen,last_seen,schema_version,container_pilot_version,channel,architecture,docker_version,docker_api_version,operating_system,kernel_version,containers_total,containers_running,containers_stopped,containers_with_healthcheck,containers_auto_update,watchtower_import_used,native_https_enabled,private_registry_configured,webhook_configured,registry_docker_hub,registry_ghcr,registry_gitlab,registry_generic_oci,successful_updates,failed_updates,automatic_rollbacks,manual_rollbacks,delete_token_hash) + VALUES ($1,now(),now(),$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27) + ON CONFLICT (installation_id) DO UPDATE SET last_seen=now(),schema_version=EXCLUDED.schema_version,container_pilot_version=EXCLUDED.container_pilot_version,channel=EXCLUDED.channel,architecture=EXCLUDED.architecture,docker_version=EXCLUDED.docker_version,docker_api_version=EXCLUDED.docker_api_version,operating_system=EXCLUDED.operating_system,kernel_version=EXCLUDED.kernel_version,containers_total=EXCLUDED.containers_total,containers_running=EXCLUDED.containers_running,containers_stopped=EXCLUDED.containers_stopped,containers_with_healthcheck=EXCLUDED.containers_with_healthcheck,containers_auto_update=EXCLUDED.containers_auto_update,watchtower_import_used=EXCLUDED.watchtower_import_used,native_https_enabled=EXCLUDED.native_https_enabled,private_registry_configured=EXCLUDED.private_registry_configured,webhook_configured=EXCLUDED.webhook_configured,registry_docker_hub=EXCLUDED.registry_docker_hub,registry_ghcr=EXCLUDED.registry_ghcr,registry_gitlab=EXCLUDED.registry_gitlab,registry_generic_oci=EXCLUDED.registry_generic_oci,successful_updates=EXCLUDED.successful_updates,failed_updates=EXCLUDED.failed_updates,automatic_rollbacks=EXCLUDED.automatic_rollbacks,manual_rollbacks=EXCLUDED.manual_rollbacks`, + [p.installation_id,p.schema_version,p.container_pilot.version,p.container_pilot.channel,p.system.architecture,p.system.docker_version,p.system.docker_api_version,p.system.os,p.system.kernel,p.containers.total,p.containers.running,p.containers.stopped,p.containers.with_healthcheck,p.containers.automatic_updates_enabled,p.features.watchtower_import_used,p.features.native_https_enabled,p.features.private_registry_configured,p.features.webhook_configured,p.registries.docker_hub,p.registries.ghcr,p.registries.gitlab,p.registries.generic_oci,p.updates.successful,p.updates.failed,p.updates.automatic_rollbacks,p.updates.manual_rollbacks,p.delete_token_hash]); + await client.query(`INSERT INTO reports (installation_id,container_pilot_version,containers_total,containers_running,containers_stopped,containers_with_healthcheck,containers_auto_update,successful_updates,failed_updates,automatic_rollbacks,manual_rollbacks) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, [p.installation_id,p.container_pilot.version,p.containers.total,p.containers.running,p.containers.stopped,p.containers.with_healthcheck,p.containers.automatic_updates_enabled,p.updates.successful,p.updates.failed,p.updates.automatic_rollbacks,p.updates.manual_rollbacks]); + await client.query('COMMIT'); + } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } +} + +export async function deleteInstallation(id, tokenHash, db = pool) { + const result = await db.query('DELETE FROM installations WHERE installation_id=$1 AND delete_token_hash=$2', [id, tokenHash]); + return result.rowCount === 1; +} + +export async function cleanup(db = pool, days = Number(process.env.TRACKER_RETENTION_DAYS || 90)) { + return db.query("DELETE FROM reports WHERE received_at < now() - ($1 * interval '1 day')", [Math.max(1, Math.min(days, 3650))]); +} diff --git a/tracker/src/public-api.js b/tracker/src/public-api.js new file mode 100644 index 0000000..9d1356f --- /dev/null +++ b/tracker/src/public-api.js @@ -0,0 +1,26 @@ +import crypto from 'node:crypto'; +import { UUID_V4, validatePayload } from './validation.js'; + +const MAX_BODY = 16 * 1024; +const security = { 'cache-control':'no-store','content-type':'application/json; charset=utf-8','x-content-type-options':'nosniff','x-frame-options':'DENY','content-security-policy':"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",'referrer-policy':'no-referrer' }; +function json(res,status,value){const body=JSON.stringify(value);res.writeHead(status,{...security,'content-length':Buffer.byteLength(body)});res.end(body);} +async function parseBody(req){const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>MAX_BODY)throw Object.assign(new Error('payload_too_large'),{status:413});chunks.push(chunk);}try{return JSON.parse(Buffer.concat(chunks).toString()||'{}');}catch{throw Object.assign(new Error('invalid_json'),{status:400});}} +function allow(map,key,max,windowMs){const now=Date.now();const recent=(map.get(key)||[]).filter(at=>now-at=max)return false;recent.push(now);map.set(key,recent);return true;} + +export function createPublicHandler({ query, saveReport, deleteInstallation, log = console }) { + const installRates=new Map();const ipRates=new Map(); + return async function publicHandler(req,res){ + try{const url=new URL(req.url,'http://tracker'); + if(req.method==='GET'&&url.pathname==='/healthz'){await query('SELECT 1');return json(res,200,{status:'ok'});} + if(req.method==='POST'&&url.pathname==='/api/v1/telemetry'){ + if(!allow(ipRates,req.socket.remoteAddress||'unknown',60,3600000))return json(res,429,{error:'rate_limited'}); + const payload=await parseBody(req);const invalid=validatePayload(payload);if(invalid)return json(res,400,{error:invalid}); + if(!allow(installRates,payload.installation_id,10,3600000))return json(res,429,{error:'rate_limited'}); + await saveReport(payload);log.info(`telemetry report accepted ${payload.installation_id.slice(0,8)}…`);return json(res,202,{status:'accepted'}); + } + const deletion=url.pathname.match(/^\/api\/v1\/telemetry\/([0-9a-f-]+)$/i); + if(req.method==='DELETE'&&deletion&&UUID_V4.test(deletion[1])){const token=req.headers.authorization?.match(/^Bearer (.{20,200})$/)?.[1];if(!token)return json(res,401,{error:'unauthorized'});const hash=crypto.createHash('sha256').update(token).digest('hex');return await deleteInstallation(deletion[1],hash)?json(res,200,{status:'deleted'}):json(res,403,{error:'unauthorized'});} + return json(res,404,{error:'not_found'}); + }catch(error){log.warn(`telemetry report rejected ${error.status||500}`);return json(res,error.status||500,{error:error.status?error.message:'internal_error'});} + }; +} diff --git a/tracker/src/server.js b/tracker/src/server.js new file mode 100644 index 0000000..e25dc95 --- /dev/null +++ b/tracker/src/server.js @@ -0,0 +1,51 @@ +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { cleanup, deleteInstallation, migrate, pool, saveReport } from './db.js'; +import { createSession, cookie, clearCookie, destroySession, loginAllowed, loginFailed, loginSucceeded, readSession, validCredentials, cleanupSessions } from './auth.js'; +import { installation, installations, summary } from './dashboard-data.js'; +import { UUID_V4 } from './validation.js'; +import { createPublicHandler } from './public-api.js'; + +const publicDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public'); +const apiBind = process.env.TRACKER_API_BIND || '127.0.0.1'; const apiPort = Number(process.env.TRACKER_API_PORT || 3090); +const dashboardBind = process.env.TRACKER_DASHBOARD_BIND || '127.0.0.1'; const dashboardPort = Number(process.env.TRACKER_DASHBOARD_PORT || 3091); +const adminUser = process.env.TRACKER_ADMIN_USER || 'admin'; +const passwordFile = process.env.TRACKER_ADMIN_PASSWORD_FILE; if (!passwordFile) throw new Error('TRACKER_ADMIN_PASSWORD_FILE is required'); +const adminPassword = fs.readFileSync(passwordFile, 'utf8').trim(); if (adminPassword.length < 12) throw new Error('Tracker admin password must contain at least 12 characters'); +const MAX_BODY = 16 * 1024; + +function headers(extra = {}) { return { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", 'permissions-policy': 'camera=(), microphone=(), geolocation=()', 'referrer-policy': 'no-referrer', ...extra }; } +function json(res, status, value, extra = {}) { const body = JSON.stringify(value); res.writeHead(status, headers({ 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), ...extra })); res.end(body); } +function text(res, status, value, type = 'text/plain; charset=utf-8', extra = {}) { res.writeHead(status, headers({ 'content-type': type, ...extra })); res.end(value); } +async function parseBody(req) { const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY) throw Object.assign(new Error('payload_too_large'), { status: 413 }); chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString() || '{}'); } catch { throw Object.assign(new Error('invalid_json'), { status: 400 }); } } +function remoteKey(req) { return req.socket.remoteAddress || 'unknown'; } +export const publicHandler = createPublicHandler({ query: (...args) => pool.query(...args), saveReport, deleteInstallation }); + +function sameOrigin(req) { const origin = req.headers.origin; if (!origin) return true; try { return new URL(origin).host === req.headers.host; } catch { return false; } } +export async function dashboardHandler(req, res) { + try { + const url = new URL(req.url, 'http://dashboard'); const session = readSession(req.headers.cookie); + if (req.method === 'GET' && url.pathname === '/healthz') { await pool.query('SELECT 1'); return json(res, 200, { status: 'ok' }); } + if (req.method === 'POST' && url.pathname === '/login') { + if (!sameOrigin(req)) return json(res, 403, { error: 'invalid_origin' }); const key = remoteKey(req); if (!loginAllowed(key)) return json(res, 429, { error: 'rate_limited' }); + const data = await parseBody(req); if (!validCredentials(data.username, data.password, adminUser, adminPassword)) { loginFailed(key); return json(res, 401, { error: 'invalid_credentials' }); } + loginSucceeded(key); const created = createSession(); return json(res, 200, { csrf: created.csrf }, { 'set-cookie': cookie(created.token, process.env.TRACKER_SECURE_COOKIE === 'true') }); + } + if (!session && url.pathname.startsWith('/api/')) return json(res, 401, { error: 'authentication_required' }); + if (!session && !['/login.html', '/app.css', '/login.js'].includes(url.pathname)) return text(res, 302, '', 'text/plain', { location: '/login.html' }); + if (req.method === 'POST' && url.pathname === '/api/logout') { if (!sameOrigin(req) || req.headers['x-csrf-token'] !== session.csrf) return json(res, 403, { error: 'csrf' }); destroySession(session.token); return json(res, 200, { status: 'ok' }, { 'set-cookie': clearCookie() }); } + if (req.method === 'GET' && url.pathname === '/api/session') return json(res, 200, { csrf: session.csrf }); + if (req.method === 'GET' && url.pathname === '/api/dashboard/summary') return json(res, 200, await summary(url.searchParams.get('days'))); + if (req.method === 'GET' && url.pathname === '/api/dashboard/installations') return json(res, 200, { installations: await installations() }); + const detail = url.pathname.match(/^\/api\/dashboard\/installations\/([0-9a-f-]+)$/i); if (req.method === 'GET' && detail && UUID_V4.test(detail[1])) { const value = await installation(detail[1]); return value ? json(res, 200, value) : json(res, 404, { error: 'not_found' }); } + const requested = url.pathname === '/' ? 'index.html' : url.pathname.slice(1); const file = path.resolve(publicDir, requested); if (!file.startsWith(`${publicDir}${path.sep}`)) return json(res, 403, { error: 'forbidden' }); + const data = fs.readFileSync(file); const type = file.endsWith('.css') ? 'text/css; charset=utf-8' : file.endsWith('.js') ? 'text/javascript; charset=utf-8' : 'text/html; charset=utf-8'; return text(res, 200, data, type); + } catch (error) { if (error.code === 'ENOENT') return json(res, 404, { error: 'not_found' }); console.error('dashboard request failed'); return json(res, error.status || 500, { error: error.status ? error.message : 'internal_error' }); } +} + +await migrate(); +http.createServer(publicHandler).listen(apiPort, apiBind, () => console.info(`public telemetry listener ready on ${apiBind}:${apiPort}`)); +http.createServer(dashboardHandler).listen(dashboardPort, dashboardBind, () => console.info(`internal dashboard listener ready on ${dashboardBind}:${dashboardPort}`)); +setInterval(() => { cleanup().then(result => console.info(`report cleanup completed ${result.rowCount}`)).catch(() => console.error('report cleanup failed')); cleanupSessions(); }, 6 * 60 * 60_000).unref(); diff --git a/tracker/src/validation.js b/tracker/src/validation.js new file mode 100644 index 0000000..49aefc0 --- /dev/null +++ b/tracker/src/validation.js @@ -0,0 +1,26 @@ +const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key)); +const text = (value, max, pattern = /^[\x20-\x7e]+$/) => typeof value === 'string' && value.length > 0 && value.length <= max && pattern.test(value); +const count = value => Number.isInteger(value) && value >= 0 && value <= 1_000_000; +const bools = (value, keys) => exact(value, keys) && keys.every(key => typeof value[key] === 'boolean'); +const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/; + +export function validatePayload(payload) { + const top = ['schema_version', 'installation_id', 'delete_token_hash', 'container_pilot', 'system', 'containers', 'features', 'registries', 'updates']; + if (!exact(payload, top)) return 'unknown_or_missing_field'; + if (payload.schema_version !== 1) return 'invalid_schema_version'; + if (!UUID_V4.test(payload.installation_id || '')) return 'invalid_installation_id'; + if (!/^[0-9a-f]{64}$/.test(payload.delete_token_hash || '')) return 'invalid_delete_token_hash'; + if (!exact(payload.container_pilot, ['version', 'channel']) || !text(payload.container_pilot.version, 64, VERSION) || !['stable', 'rc', 'prerelease'].includes(payload.container_pilot.channel)) return 'invalid_container_pilot'; + if (!exact(payload.system, ['architecture', 'docker_version', 'docker_api_version', 'os', 'kernel'])) return 'invalid_system'; + if (!['amd64', 'arm64', 'arm', '386', 'other'].includes(payload.system.architecture) || !text(payload.system.docker_version, 64) || !text(payload.system.docker_api_version, 32) || !text(payload.system.os, 128) || !/^(?:\d+\.\d+|other)$/.test(payload.system.kernel)) return 'invalid_system'; + const containerKeys = ['total', 'running', 'stopped', 'with_healthcheck', 'automatic_updates_enabled']; + if (!exact(payload.containers, containerKeys) || !containerKeys.every(key => count(payload.containers[key]))) return 'invalid_containers'; + if (payload.containers.running + payload.containers.stopped !== payload.containers.total || payload.containers.with_healthcheck > payload.containers.total || payload.containers.automatic_updates_enabled > payload.containers.total) return 'invalid_container_totals'; + if (!bools(payload.features, ['watchtower_import_used', 'native_https_enabled', 'private_registry_configured', 'webhook_configured'])) return 'invalid_features'; + if (!bools(payload.registries, ['docker_hub', 'ghcr', 'gitlab', 'generic_oci'])) return 'invalid_registries'; + if (!exact(payload.updates, ['successful', 'failed', 'automatic_rollbacks', 'manual_rollbacks']) || !Object.values(payload.updates).every(count)) return 'invalid_updates'; + return null; +} + +export { UUID_V4 }; From b64bca2e538503f1f1f59d6fa8c2916779b7635d Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:44:10 +0200 Subject: [PATCH 4/7] test: add telemetry privacy and integration coverage --- .github/workflows/ci.yml | 46 +++++++++++++++++++++- tests/telemetry.test.js | 39 ++++++++++++++++++ tracker/test-support/payload.js | 1 + tracker/tests/auth.test.js | 7 ++++ tracker/tests/integration/postgres.test.js | 16 ++++++++ tracker/tests/public-api.test.js | 11 ++++++ tracker/tests/validation.test.js | 9 +++++ 7 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 tests/telemetry.test.js create mode 100644 tracker/test-support/payload.js create mode 100644 tracker/tests/auth.test.js create mode 100644 tracker/tests/integration/postgres.test.js create mode 100644 tracker/tests/public-api.test.js create mode 100644 tracker/tests/validation.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6ecaf5..b12bc30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,15 @@ jobs: with: node-version: 24 cache: npm + cache-dependency-path: | + package-lock.json + tracker/package-lock.json - run: npm ci + - run: npm ci --prefix tracker - name: Check JavaScript syntax - run: find src tests -name '*.js' -print0 | xargs -0 -n1 node --check + run: find src tests tracker/src tracker/public tracker/tests -name '*.js' -print0 | xargs -0 -n1 node --check - name: Parse YAML - run: ruby -e 'require "yaml"; Dir[".github/**/*.yml", "compose*.yml"].flatten.each { |file| YAML.load_file(file) }' + run: ruby -e 'require "yaml"; Dir[".github/**/*.yml", "compose*.yml", "tracker/compose.yml"].flatten.each { |file| YAML.load_file(file) }' - run: git diff --check test: @@ -34,21 +38,47 @@ jobs: with: node-version: 24 cache: npm + cache-dependency-path: | + package-lock.json + tracker/package-lock.json - run: npm ci + - run: npm ci --prefix tracker - run: npm test + - run: npm test --prefix tracker integration-test: needs: test runs-on: ubuntu-latest timeout-minutes: 10 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: container_pilot + POSTGRES_USER: tracker + POSTGRES_PASSWORD: tracker-integration-password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U tracker -d container_pilot" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v7 with: node-version: 24 cache: npm + cache-dependency-path: | + package-lock.json + tracker/package-lock.json - run: npm ci + - run: npm ci --prefix tracker - run: npm run test:integration + - run: npm run test:integration --prefix tracker + env: + DATABASE_URL: postgresql://tracker:tracker-integration-password@127.0.0.1:5432/container_pilot security-check: needs: lint @@ -60,8 +90,13 @@ jobs: with: node-version: 24 cache: npm + cache-dependency-path: | + package-lock.json + tracker/package-lock.json - run: npm ci + - run: npm ci --prefix tracker - run: npm audit --omit=dev --audit-level=high + - run: npm audit --prefix tracker --omit=dev --audit-level=high docker-build: needs: [integration-test, security-check] @@ -78,3 +113,10 @@ jobs: platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max + - uses: docker/build-push-action@v6 + with: + context: ./tracker + push: false + platforms: linux/amd64,linux/arm64 + cache-from: type=gha,scope=tracker + cache-to: type=gha,mode=max,scope=tracker diff --git a/tests/telemetry.test.js b/tests/telemetry.test.js new file mode 100644 index 0000000..3d9d6e0 --- /dev/null +++ b/tests/telemetry.test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildTelemetryPayload, enableTelemetry, incrementTelemetryCounter, resetTelemetryIdentity, sendTelemetry, telemetryUrl } from '../src/telemetry.js'; +import { defaultTelemetryState } from '../src/store.js'; + +const baseStore = () => ({ telemetry: defaultTelemetryState(), policies: { 'secret-container': { auto: true } }, settings: { webhook: { enabled: true } } }); +const containers = [{ id: 'a', name: 'secret-container', image: 'private.example/team/secret:prod', state: 'running' }, { id: 'b', name: 'other', image: 'ghcr.io/org/app:1', state: 'exited' }]; +const dockerInfo = { Architecture: 'x86_64', ServerVersion: '28.3.0', ApiVersion: '1.51', OperatingSystem: 'Debian GNU/Linux 13', KernelVersion: '6.12.38-custom-host' }; +const inspect = async ({}) => ({ Config: { Healthcheck: {} } }); + +test('telemetry is disabled by default and identity is random, persistent, and resettable', () => { + const store = baseStore(); assert.equal(store.telemetry.enabled, false); assert.equal(store.telemetry.installation_id, null); + enableTelemetry(store); const first = store.telemetry.installation_id; assert.match(first, /^[0-9a-f-]{36}$/); assert.ok(store.telemetry.delete_token); enableTelemetry(store); assert.equal(store.telemetry.installation_id, first); + resetTelemetryIdentity(store); assert.equal(store.telemetry.installation_id, null); enableTelemetry(store); assert.notEqual(store.telemetry.installation_id, first); +}); + +test('payload contains only aggregated allow-listed data', async () => { + const store = baseStore(); enableTelemetry(store); + const payload = await buildTelemetryPayload({ store, version: '0.9.0-rc.10', nativeHttps: true, dockerInfo, containers, inspect, registries: ['generic'] }); + assert.deepEqual(payload.containers, { total: 2, running: 1, stopped: 1, with_healthcheck: 2, automatic_updates_enabled: 1 }); + assert.equal(payload.system.kernel, '6.12'); assert.equal(payload.registries.generic_oci, true); + const serialized = JSON.stringify(payload).toLowerCase(); + for (const forbidden of ['secret-container','private.example','team/secret','prod','container_id','image_name','hostname','mac_address','environment','mount','volume','label','compose_project','registry_domain']) assert.equal(serialized.includes(forbidden), false, forbidden); +}); + +test('preview builder output exactly equals transmitted payload', async () => { + const store = baseStore(); enableTelemetry(store); const preview = await buildTelemetryPayload({ store, version: '1.0.0', dockerInfo, containers, inspect, registries: [] }); let transmitted; + const result = await sendTelemetry({ store, buildPayload: async () => preview, url: 'http://localhost:3090/api/v1/telemetry', fetchImpl: async (_url, options) => { transmitted = JSON.parse(options.body); return { ok: true, json: async () => ({ status: 'accepted' }) }; } }); + assert.equal(result.ok, true); assert.deepEqual(transmitted, preview); +}); + +test('tracker failures are fail-open and reduced to safe statuses', async () => { + for (const [response, expected] of [[{ ok:false,status:500 },'http_500'],[{ ok:false,status:503 },'http_503'],[{ ok:false,status:429 },'http_429']]) { const store=baseStore();enableTelemetry(store);const result=await sendTelemetry({store,buildPayload:async()=>({}),url:'http://localhost/test',fetchImpl:async()=>response});assert.equal(result.ok,false);assert.equal(result.error,expected); } + const store=baseStore();enableTelemetry(store);const dns=await sendTelemetry({store,buildPayload:async()=>({}),url:'http://localhost/test',fetchImpl:async()=>{throw new Error('getaddrinfo ENOTFOUND')}});assert.deepEqual(dns,{ok:false,error:'connection_failed'}); + for (const error of [Object.assign(new Error('aborted'),{name:'TimeoutError'}),new Error('TLS certificate failure')]) { const state=baseStore();enableTelemetry(state);const result=await sendTelemetry({store:state,buildPayload:async()=>({}),url:'http://localhost/test',fetchImpl:async()=>{throw error}});assert.equal(result.ok,false); } + const invalid=baseStore();enableTelemetry(invalid);assert.equal((await sendTelemetry({store:invalid,buildPayload:async()=>({}),url:'http://localhost/test',fetchImpl:async()=>({ok:true,json:async()=>({wrong:true})})})).error,'invalid_response'); +}); + +test('counters are cumulative and endpoint policy rejects public HTTP', () => { const store=baseStore(); incrementTelemetryCounter(store,'successful_updates'); incrementTelemetryCounter(store,'successful_updates'); incrementTelemetryCounter(store,'failed_updates'); incrementTelemetryCounter(store,'automatic_rollbacks'); incrementTelemetryCounter(store,'manual_rollbacks'); assert.deepEqual({s:store.telemetry.successful_updates,f:store.telemetry.failed_updates,a:store.telemetry.automatic_rollbacks,m:store.telemetry.manual_rollbacks},{s:2,f:1,a:1,m:1}); assert.throws(()=>telemetryUrl('http://example.com/telemetry'),/HTTPS/); }); diff --git a/tracker/test-support/payload.js b/tracker/test-support/payload.js new file mode 100644 index 0000000..c57f55a --- /dev/null +++ b/tracker/test-support/payload.js @@ -0,0 +1 @@ +export const validPayload = () => ({ schema_version:1, installation_id:'5f1776a8-5ca6-44e6-bc81-f7804681ed80', delete_token_hash:'a'.repeat(64), container_pilot:{version:'0.9.0-rc.10',channel:'rc'}, system:{architecture:'amd64',docker_version:'28.3.0',docker_api_version:'1.51',os:'Debian GNU/Linux 13',kernel:'6.12'}, containers:{total:18,running:16,stopped:2,with_healthcheck:11,automatic_updates_enabled:5}, features:{watchtower_import_used:true,native_https_enabled:false,private_registry_configured:true,webhook_configured:false}, registries:{docker_hub:true,ghcr:true,gitlab:false,generic_oci:false}, updates:{successful:23,failed:1,automatic_rollbacks:2,manual_rollbacks:1} }); diff --git a/tracker/tests/auth.test.js b/tracker/tests/auth.test.js new file mode 100644 index 0000000..78b34c5 --- /dev/null +++ b/tracker/tests/auth.test.js @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { cleanupSessions, cookie, createSession, loginAllowed, loginFailed, readSession, safeEqual, validCredentials } from '../src/auth.js'; + +test('dashboard sessions use hardened cookies and expire', () => { const session=createSession();const value=cookie(session.token,true);assert.match(value,/HttpOnly/);assert.match(value,/SameSite=Strict/);assert.match(value,/Secure/);assert.equal(readSession(value).csrf,session.csrf);cleanupSessions(Date.now()+2*60*60_000);assert.equal(readSession(value),null); }); +test('dashboard authentication accepts valid and rejects wrong credentials',()=>{assert.equal(safeEqual('correct','correct'),true);assert.equal(validCredentials('admin','correct','admin','correct'),true);assert.equal(validCredentials('admin','wrong','admin','correct'),false);assert.equal(validCredentials('viewer','correct','admin','correct'),false);}); +test('login rate limiting rejects repeated failures',()=>{const key=`test-${Date.now()}`;for(let i=0;i<10;i++){assert.equal(loginAllowed(key),true);loginFailed(key);}assert.equal(loginAllowed(key),false);}); diff --git a/tracker/tests/integration/postgres.test.js b/tracker/tests/integration/postgres.test.js new file mode 100644 index 0000000..7374cfc --- /dev/null +++ b/tracker/tests/integration/postgres.test.js @@ -0,0 +1,16 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import test from 'node:test'; + +test('PostgreSQL integration upserts, preserves cumulative counters, aggregates, and cascades deletion', { skip: process.env.TRACKER_RUN_POSTGRES_INTEGRATION !== '1' }, async () => { + const { migrate, pool, saveReport, deleteInstallation } = await import('../../src/db.js'); + const { summary } = await import('../../src/dashboard-data.js'); + const { createPublicHandler } = await import('../../src/public-api.js'); const { buildTelemetryPayload, enableTelemetry } = await import('../../../src/telemetry.js'); const { defaultTelemetryState } = await import('../../../src/store.js'); + const store={telemetry:defaultTelemetryState(),policies:{},settings:{webhook:{enabled:false}}};enableTelemetry(store);store.telemetry.successful_updates=20; + const build=()=>buildTelemetryPayload({store,version:'0.9.0-rc.10',nativeHttps:true,dockerInfo:{Architecture:'amd64',ServerVersion:'28.3.0',ApiVersion:'1.51',OperatingSystem:'Debian GNU/Linux 13',KernelVersion:'6.12.1'},containers:[],registries:[]}); + const handler=createPublicHandler({query:(...args)=>pool.query(...args),saveReport,deleteInstallation,log:{info(){},warn(){}}}); + const request=async({method='POST',url='/api/v1/telemetry',headers={},payload})=>{const req=Readable.from(payload?[Buffer.from(JSON.stringify(payload))]:[]);Object.assign(req,{method,url,headers,socket:{remoteAddress:'127.0.0.1'}});let status;const res={writeHead(value){status=value;return this;},end(){}};await handler(req,res);return status;}; + await migrate();const first=await build();await pool.query('DELETE FROM installations WHERE installation_id=$1',[first.installation_id]);assert.equal(await request({payload:first}),202);store.telemetry.successful_updates=24;assert.equal(await request({payload:await build()}),202); + const installation=await pool.query('SELECT successful_updates FROM installations WHERE installation_id=$1',[first.installation_id]);const reports=await pool.query('SELECT count(*)::int count FROM reports WHERE installation_id=$1',[first.installation_id]);assert.equal(installation.rows[0].successful_updates,24);assert.equal(reports.rows[0].count,2); + const data=await summary(7);assert.ok(data.timeline.some(row=>row.successful_updates===4));assert.equal(await request({method:'DELETE',url:`/api/v1/telemetry/${first.installation_id}`,headers:{authorization:`Bearer ${store.telemetry.delete_token}`}}),200);assert.equal((await pool.query('SELECT count(*)::int count FROM reports WHERE installation_id=$1',[first.installation_id])).rows[0].count,0);await pool.end(); +}); diff --git a/tracker/tests/public-api.test.js b/tracker/tests/public-api.test.js new file mode 100644 index 0000000..6f1ae0f --- /dev/null +++ b/tracker/tests/public-api.test.js @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import { Readable } from 'node:stream'; +import test from 'node:test'; +import { createPublicHandler } from '../src/public-api.js'; +import { validPayload } from '../test-support/payload.js'; + +function fixture() { const saved=[]; return { saved, handler:createPublicHandler({query:async()=>({}),saveReport:async p=>saved.push(p),deleteInstallation:async(_id,hash)=>hash.length===64,log:{info(){},warn(){}}}) }; } +async function request(handler,{method='GET',url='/',headers={},body=''}){const req=Readable.from(body?[Buffer.from(body)]:[]);Object.assign(req,{method,url,headers,socket:{remoteAddress:'127.0.0.1'}});let status,output='';const responseHeaders={};const res={writeHead(value,next={}){status=value;Object.assign(responseHeaders,next);return this;},end(value=''){output+=value;}};await handler(req,res);return {status,headers:responseHeaders,json:()=>JSON.parse(output)};} +test('public listener accepts reports but exposes no dashboard routes',async()=>{const f=fixture();const accepted=await request(f.handler,{method:'POST',url:'/api/v1/telemetry',body:JSON.stringify(validPayload())});assert.equal(accepted.status,202);assert.equal(f.saved.length,1);for(const url of ['/','/dashboard','/admin','/stats','/api/dashboard/summary'])assert.equal((await request(f.handler,{url})).status,404);}); +test('oversized and invalid payloads are rejected',async()=>{const f=fixture();assert.equal((await request(f.handler,{method:'POST',url:'/api/v1/telemetry',body:'x'.repeat(17*1024)})).status,413);assert.equal((await request(f.handler,{method:'POST',url:'/api/v1/telemetry',body:JSON.stringify({...validPayload(),hostname:'secret'})})).status,400);}); +test('delete endpoint requires a bearer token',async()=>{const f=fixture();const url='/api/v1/telemetry/5f1776a8-5ca6-44e6-bc81-f7804681ed80';assert.equal((await request(f.handler,{method:'DELETE',url})).status,401);assert.equal((await request(f.handler,{method:'DELETE',url,headers:{authorization:'Bearer this-is-a-long-delete-token-value'}})).status,200);}); diff --git a/tracker/tests/validation.test.js b/tracker/tests/validation.test.js new file mode 100644 index 0000000..654e5b2 --- /dev/null +++ b/tracker/tests/validation.test.js @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { validatePayload } from '../src/validation.js'; + +import { validPayload } from '../test-support/payload.js'; +test('valid payload is accepted',()=>assert.equal(validatePayload(validPayload()),null)); +test('unknown fields and invalid schema are rejected',()=>{const unknown=validPayload();unknown.hostname='secret';assert.ok(validatePayload(unknown));const schema=validPayload();schema.schema_version=2;assert.ok(validatePayload(schema));}); +test('invalid UUID, architecture, version, and negative values are rejected',()=>{for(const mutate of [p=>p.installation_id='bad',p=>p.system.architecture='mips-host',p=>p.container_pilot.version='x'.repeat(65),p=>p.updates.failed=-1]){const p=validPayload();mutate(p);assert.ok(validatePayload(p));}}); +test('cross-field container totals are validated',()=>{const p=validPayload();p.containers.running=20;assert.equal(validatePayload(p),'invalid_container_totals');}); From 00f62987f4187fe5fcc6f12e3a5bef0c836cb97f Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:44:10 +0200 Subject: [PATCH 5/7] docs: document anonymous telemetry --- CHANGELOG.md | 3 ++ README.de.md | 2 ++ README.md | 2 ++ docs/telemetry.md | 37 ++++++++++++++++++++++ tracker/README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+) create mode 100644 docs/telemetry.md create mode 100644 tracker/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 47543a7..613f7df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Added explicit opt-in anonymous telemetry with a live payload preview, daily jittered reporting, fail-open delivery, local reset/server deletion controls, and persistent update and rollback counters. +- Added a separately deployable PostgreSQL telemetry tracker with a write-only public listener, authenticated internal dashboard, strict schema/rate/body limits, retention, migrations, and hardened Compose/reverse-proxy examples. + ## 0.9.0-rc.10 - Restored an anonymized product dashboard preview to the English and German README files. diff --git a/README.de.md b/README.de.md index 8c40151..ac12fcb 100644 --- a/README.de.md +++ b/README.de.md @@ -131,6 +131,8 @@ Sitzungen bleiben zwölf Stunden gültig und werden beim Ändern des eigenen Ken ## Sicherheit im Docker-Betrieb +Anonyme Nutzungsstatistiken sind optional und standardmäßig deaktiviert. Nach ausdrücklicher Aktivierung sendet Container Pilot ausschließlich aggregierte technische Nutzungsdaten – niemals Container- oder Image-Namen, IP-Adressen, Hostnamen, Zugangsdaten oder Anwendungsdaten. Details stehen unter [Telemetrie](docs/telemetry.md). + Der eingebundene Docker-Socket ermöglicht weitreichende Kontrolle über den Docker-Host. Container Pilot gehört deshalb ausschließlich in eine vertrauenswürdige Verwaltungsumgebung. - Weboberfläche nicht ungeschützt im öffentlichen Internet bereitstellen diff --git a/README.md b/README.md index 0f3e532..8533417 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,8 @@ npm run test:integration ## Contributing and security +Anonymous usage statistics are optional and disabled by default. When enabled, Container Pilot sends only aggregated technical usage statistics—never container or image names, IP addresses, hostnames, credentials, or application data. See [Telemetry](docs/telemetry.md). + Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. Do not publish credentials, tokens, private image names, internal addresses, or complete state files in issues. Report vulnerabilities according to [SECURITY.md](SECURITY.md). diff --git a/docs/telemetry.md b/docs/telemetry.md new file mode 100644 index 0000000..a3ad8f1 --- /dev/null +++ b/docs/telemetry.md @@ -0,0 +1,37 @@ +# Anonymous usage statistics + +Container Pilot telemetry is optional, transparent, and disabled by default. It starts only after an administrator explicitly enables **Anonymous Usage Statistics** in the web interface. Existing state files are migrated with telemetry switched off. + +## Data sent + +When enabled, Container Pilot sends schema version 1 to `POST https://cp-track.noisens.de/api/v1/telemetry` at most once every 24 hours. Startup uses a random delay of two to fifteen minutes. The endpoint can be overridden with `CP_TELEMETRY_URL`; production endpoints must use HTTPS, while HTTP is accepted only for localhost tests. + +The report contains: + +- a random UUID v4 installation ID and a SHA-256 hash of a random deletion token; +- Container Pilot version and release channel; +- normalized architecture, Docker/API version, general OS name, and kernel major/minor; +- aggregate container counts: total, running, stopped, healthcheck, and automatic-update policy counts; +- Boolean feature adoption for Watchtower migration, native HTTPS, private registry configuration, and webhooks; +- Boolean registry categories: Docker Hub, GHCR, GitLab, and generic OCI; +- cumulative successful/failed update and automatic/manual rollback counters. + +The raw delete token remains only in local Container Pilot state. The tracker stores only its hash. Counters are cumulative; the tracker keeps the latest value per installation and derives changes between reports instead of adding cumulative totals repeatedly. + +## Data never sent + +Container Pilot does not send hostnames, Docker host names, IP or MAC addresses, machine IDs, hardware serials, container names or IDs, images, tags, digests, repository names, registry domains or URLs, labels, Compose metadata, networks, volumes, mounts, paths, ports, environment variables, usernames, passwords, tokens, secrets, certificates, keys, browser information, application data, or file contents. + +The tracker does not persist remote IP addresses and never logs complete payloads. The public listener exposes only ingest, installation deletion, and health routes. Statistics and installation data are available only through a separately bound internal dashboard listener with authentication. + +## Controls and transparency + +The telemetry dialog shows enabled state, shortened installation ID, last successful report, last attempt, safe status, and next scheduled report. **Preview data** builds the live payload with the exact same function used by **Send now**; it contains no example or hidden fields. + +Disabling telemetry stops future requests without deleting local counters. **Reset telemetry identity** removes the local installation ID, delete token, timestamps, and status. Enabling telemetry again creates a new cryptographically random identity. **Delete server data** authenticates to the public deletion endpoint with the local delete token, deletes the installation and all reports, and then resets the local identity. + +## Failure behavior + +Telemetry has an eight-second timeout and is fail-open. DNS, connection, timeout, TLS, HTTP, rate-limit, invalid-response, and tracker errors are reduced to a non-sensitive local status. They never stop or delay scans, updates, rollbacks, UI actions, or the Container Pilot process. + +Privacy principles: opt-in instead of opt-out, transparent instead of hidden, aggregated instead of detailed, and minimal instead of curious. diff --git a/tracker/README.md b/tracker/README.md new file mode 100644 index 0000000..3604363 --- /dev/null +++ b/tracker/README.md @@ -0,0 +1,80 @@ +# Container Pilot Telemetry Tracker + +An independently deployable Node.js 24 and PostgreSQL 16 service for Container Pilot's optional anonymous statistics. It is not part of the Container Pilot runtime image. + +## Architecture + +The tracker opens two independent listeners: public ingest on port `3090` and the authenticated internal dashboard on `3091`. The public listener implements only `POST /api/v1/telemetry`, `DELETE /api/v1/telemetry/:installation_id`, and `GET /healthz`; dashboard and statistics routes do not exist there. PostgreSQL has no published host port. + +## Installation and secrets + +```bash +mkdir -p secrets +openssl rand -base64 36 > secrets/admin_password +openssl rand -base64 36 > secrets/postgres_password +cp .env.example .env +chmod 600 secrets/* .env +docker compose up -d --build +``` + +Secrets and `.env` are excluded from Git and the Docker build context. Never place passwords in Compose. The dashboard defaults to `127.0.0.1:3091`. For a private management LAN, set `TRACKER_DASHBOARD_HOST` to the host's private address and restrict it with a firewall. Set `TRACKER_SECURE_COOKIE=true` when the dashboard uses internal HTTPS. + +## Database, retention, and migrations + +Versioned SQL migrations are transactional and recorded in `schema_migrations`. Each accepted payload stores one historical report and upserts one installation summary. Cumulative counters replace previous summary values; report-to-report differences supply update time-series values and are never double-counted. Raw reports are retained for 90 days by default and cleaned every six hours; configure `TRACKER_RETENTION_DAYS` as needed. + +## Public reverse proxy + +Only port 3090 belongs behind `cp-track.noisens.de`. Never proxy port 3091 on the public virtual host. + +nginx: + +```nginx +server { + listen 443 ssl; + server_name cp-track.noisens.de; + location = /healthz { proxy_pass http://127.0.0.1:3090; } + location = /api/v1/telemetry { limit_except POST { deny all; } proxy_pass http://127.0.0.1:3090; } + location ~ ^/api/v1/telemetry/[0-9a-f-]+$ { limit_except DELETE { deny all; } proxy_pass http://127.0.0.1:3090; } + location / { return 404; } +} +``` + +Caddy: + +```caddy +cp-track.noisens.de { + @ingest method POST + @ingest path /api/v1/telemetry + @delete method DELETE + @delete path_regexp delete ^/api/v1/telemetry/[0-9a-f-]+$ + @health method GET + @health path /healthz + handle @ingest { reverse_proxy 127.0.0.1:3090 } + handle @delete { reverse_proxy 127.0.0.1:3090 } + handle @health { reverse_proxy 127.0.0.1:3090 } + respond 404 +} +``` + +## API and security + +Payloads are limited to 16 KiB and validated against an exact, bounded schema; unknown fields are rejected. Rate limits allow ten reports per installation per hour plus a short-lived in-memory address limit. Remote addresses are neither logged nor persisted. Full payloads and full installation IDs are not logged. + +Deletion requires `Authorization: Bearer `. The token is hashed and compared with the stored SHA-256 hash using a prepared query. Cascading foreign keys remove all reports. + +The internal dashboard requires login and uses rate limiting, expiring server-side sessions, `HttpOnly`/`SameSite=Strict` cookies, optional `Secure`, origin and CSRF checks, security headers, and local assets only. It shows active installations (24h/7d/30d), versions, architectures, Docker versions, operating systems, container aggregates, feature/registry adoption, update/rollback counters, 7/30/90-day time series, an installation list, details, and report history. + +## Backup, update, and troubleshooting + +```bash +docker compose exec -T postgres pg_dump -U tracker -d container_pilot -Fc > container-pilot-telemetry.dump +``` + +Protect and test backups. Restore into an empty database with `pg_restore -U tracker -d container_pilot --clean --if-exists`. Before updates, back up PostgreSQL, pull changes, review migrations and `.env.example`, then run `docker compose build --pull tracker` and `docker compose up -d`. + +- `GET /healthz` is healthy only when PostgreSQL responds. +- Verify `https://cp-track.noisens.de/dashboard` and `/api/dashboard/summary` return 404. +- Verify PostgreSQL has no host mapping and dashboard port 3091 is only locally or privately reachable. +- For login errors, check the mounted password file; rate limiting clears after 15 minutes. +- For rejected reports, check schema compatibility, body size, and rate limits without logging request bodies. From 613b83c7a12b6c4f583a7e5501c99c03e25f68e9 Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:47:56 +0200 Subject: [PATCH 6/7] fix: use explicit telemetry timeline aliases --- tracker/src/dashboard-data.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tracker/src/dashboard-data.js b/tracker/src/dashboard-data.js index 0ffda63..01cba29 100644 --- a/tracker/src/dashboard-data.js +++ b/tracker/src/dashboard-data.js @@ -21,12 +21,12 @@ export async function summary(days = 30, db = pool) { greatest(automatic_rollbacks-lag(automatic_rollbacks,1,automatic_rollbacks) OVER(PARTITION BY installation_id ORDER BY received_at),0) rollback_delta FROM reports WHERE received_at >= now()-($1*interval '1 day') ), changes AS ( - SELECT received_at::date day,count(*)::int reports,sum(success_delta)::int successful_updates,sum(failed_delta)::int failed_updates,sum(rollback_delta)::int automatic_rollbacks FROM raw GROUP BY received_at::date + SELECT received_at::date AS report_day,count(*)::int AS reports,sum(success_delta)::int AS successful_updates,sum(failed_delta)::int AS failed_updates,sum(rollback_delta)::int AS automatic_rollbacks FROM raw GROUP BY received_at::date ), daily AS ( - SELECT DISTINCT ON (received_at::date,installation_id) received_at::date day,installation_id,containers_total FROM reports WHERE received_at >= now()-($1*interval '1 day') ORDER BY received_at::date,installation_id,received_at DESC + SELECT DISTINCT ON (received_at::date,installation_id) received_at::date AS report_day,installation_id,containers_total FROM reports WHERE received_at >= now()-($1*interval '1 day') ORDER BY received_at::date,installation_id,received_at DESC ), totals AS ( - SELECT day,count(*)::int active,sum(containers_total)::int containers FROM daily GROUP BY day - ) SELECT totals.*,changes.reports,changes.successful_updates,changes.failed_updates,changes.automatic_rollbacks FROM totals JOIN changes USING(day) ORDER BY day`, [range]); + SELECT report_day,count(*)::int AS active,sum(containers_total)::int AS containers FROM daily GROUP BY report_day + ) SELECT totals.report_day AS day,totals.active,totals.containers,changes.reports,changes.successful_updates,changes.failed_updates,changes.automatic_rollbacks FROM totals JOIN changes USING(report_day) ORDER BY report_day`, [range]); return { ...current.rows[0], features: feature.rows[0], architectures: await group('architecture'), versions: await group('container_pilot_version'), dockerVersions: await group("split_part(docker_version,'.',1)||'.x'"), dockerVersionDetails: await group('docker_version'), operatingSystems: await group('operating_system'), timeline: timeline.rows, days: range }; } From 69a9bd1654aefa0f4948c3de4b4d7fedbf653808 Mon Sep 17 00:00:00 2001 From: Norman Sens Date: Fri, 21 Aug 2026 14:53:41 +0200 Subject: [PATCH 7/7] release: prepare Container Pilot v0.9.0-rc.11 --- CHANGELOG.md | 2 ++ README.de.md | 15 ++++++++++++--- README.md | 16 +++++++++++++--- compose.yml | 2 +- docs/installation.md | 2 +- docs/releases.md | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- src/public/index.html | 10 +++++----- tracker/test-support/payload.js | 2 +- tracker/tests/integration/postgres.test.js | 2 +- 11 files changed, 41 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 613f7df..a21c05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.9.0-rc.11 + - Added explicit opt-in anonymous telemetry with a live payload preview, daily jittered reporting, fail-open delivery, local reset/server deletion controls, and persistent update and rollback counters. - Added a separately deployable PostgreSQL telemetry tracker with a write-only public listener, authenticated internal dashboard, strict schema/rate/body limits, retention, migrations, and hardened Compose/reverse-proxy examples. diff --git a/README.de.md b/README.de.md index ac12fcb..59d4a3e 100644 --- a/README.de.md +++ b/README.de.md @@ -5,7 +5,7 @@

Docker Update Manager – Container Pilot

Moderne deutsche Weboberfläche zur kontrollierten Prüfung und Installation von Docker-Image-Updates.

- Version 0.9.0 RC10 + Version 0.9.0 RC11 Node.js 24 Docker Oberfläche Deutsch @@ -18,7 +18,7 @@ Der Schwerpunkt liegt auf einem nachvollziehbaren Update-Workflow mit **konfigurierbaren Prüfintervallen, Freigabe pro Container, optionaler Sofortinstallation und automatischem Rollback**. Bei Images mit einem festen Tag prüft Container Pilot zusätzlich, ob ein `latest`-Tag vorhanden ist. In der Weboberfläche kann bewusst zwischen einem Update des bestehenden Tags und einem Wechsel auf `latest` entschieden werden. Die Automatik wechselt niemals selbstständig den Tag. -> **Projektstatus:** Version 0.9.0-rc.10 ist ein Release Candidate. Die Update-Engine besitzt Healthcheck-Validierung, Aktionssperren, Self-Updates und Docker-Integrationstests. Vor dem Stable-Release ist ein kontrollierter Praxistest mit den eigenen Stacks vorgesehen. +> **Projektstatus:** Version 0.9.0-rc.11 ist ein Release Candidate. Die Update-Engine besitzt Healthcheck-Validierung, Aktionssperren, Self-Updates und Docker-Integrationstests. Vor dem Stable-Release ist ein kontrollierter Praxistest mit den eigenen Stacks vorgesehen. ## Einblick @@ -51,6 +51,15 @@ Der Schwerpunkt liegt auf einem nachvollziehbaren Update-Workflow mit **konfigur | **Benutzer** | Administrator- und Viewer-Konten über die Weboberfläche verwalten | | **Ereignisse** | Prüf-, Update-, Fehler-, Benutzer- und Anmeldeereignisse in einem eigenen Menüpunkt nachvollziehen | | **Systemupdate** | GitHub Releases prüfen und Container Pilot über einen getrennten Helfer mit Healthcheck und automatischer Wiederherstellung aktualisieren | +| **Anonyme Nutzungsstatistiken** | Freiwillig und standardmäßig deaktiviert; exakte Daten vor dem Versand anzeigen, sofort senden, deaktivieren, Identität zurücksetzen oder Serverdaten löschen | + +## Freiwillige anonyme Nutzungsstatistiken + +Container Pilot enthält eine freiwillige und besonders datensparsame Nutzungsstatistik. Sie soll uns helfen zu verstehen, wie sich das Projekt in realen Installationen und unterschiedlichen Einsatzszenarien verhält. **Die Funktion ist standardmäßig ausgeschaltet und überträgt nichts, bis ein Administrator sie ausdrücklich aktiviert.** + +Nach der Aktivierung wird nur das kleinste sinnvoll nutzbare Maß an zusammengefassten technischen Daten übertragen, beispielsweise Container-Pilot- und Docker-Version, Architektur, allgemeines Betriebssystem, aggregierte Containerzahlen, verwendete Funktionskategorien und kumulierte Update-Ergebnisse. Container- oder Image-Namen, IP-Adressen, Hostnamen, Registry-Domains, Zugangsdaten, Umgebungsvariablen, Mount-Pfade und Anwendungsdaten werden niemals übertragen. Die Weboberfläche zeigt vor dem Versand exakt den aktuellen Payload; die Funktion kann jederzeit deaktiviert und die zugehörigen Daten können gelöscht werden. + +Wir freuen uns über jeden Administrator, der diese Funktion freiwillig aktiviert. Die anonymen Erkenntnisse aus dem praktischen Einsatz helfen uns, Kompatibilität, Zuverlässigkeit und Zugänglichkeit gezielt zu verbessern, damit Container Pilot in möglichst vielen Umgebungen und für möglichst viele Menschen gut funktioniert. Alle Einzelheiten stehen unter [Telemetrie und Datenschutz](docs/telemetry.md). ## Architektur: Weboberfläche, Docker API und Registry @@ -131,7 +140,7 @@ Sitzungen bleiben zwölf Stunden gültig und werden beim Ändern des eigenen Ken ## Sicherheit im Docker-Betrieb -Anonyme Nutzungsstatistiken sind optional und standardmäßig deaktiviert. Nach ausdrücklicher Aktivierung sendet Container Pilot ausschließlich aggregierte technische Nutzungsdaten – niemals Container- oder Image-Namen, IP-Adressen, Hostnamen, Zugangsdaten oder Anwendungsdaten. Details stehen unter [Telemetrie](docs/telemetry.md). +Anonyme Nutzungsstatistiken sind freiwillig, standardmäßig deaktiviert und transparent unter [Telemetrie und Datenschutz](docs/telemetry.md) dokumentiert. Der eingebundene Docker-Socket ermöglicht weitreichende Kontrolle über den Docker-Host. Container Pilot gehört deshalb ausschließlich in eine vertrauenswürdige Verwaltungsumgebung. diff --git a/README.md b/README.md index 8533417..d3d9079 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

A Docker update manager with a Web UI, per-container policies, health checks, and automatic rollback.

A safer Watchtower alternative focused on control and recovery.

- Version 0.9.0 RC10 + Version 0.9.0 RC11 Docker Compose AMD64 and ARM64 MIT License @@ -72,6 +72,15 @@ Detect → Decide → Update → Verify → Recover - Container configuration reconstruction, including mounts, networks, ports, environment, and restart policy - Safe self-update flow through a separate helper container - Opt-in Watchtower policy import with a read-only preview and per-rule confirmation +- Optional anonymous usage statistics with an exact payload preview and full administrator control + +## Optional anonymous usage statistics + +Container Pilot includes voluntary, privacy-minimizing usage statistics to help us understand how the project behaves across real installations and different deployment scenarios. **The feature is disabled by default and sends nothing until an administrator explicitly enables it.** + +When enabled, only the smallest useful set of aggregated technical data is reported, such as the Container Pilot and Docker versions, architecture, general operating system, aggregate container counts, enabled feature categories, and cumulative update results. Container Pilot never reports container or image names, IP addresses, hostnames, registry domains, credentials, environment variables, mount paths, or application data. The Web UI shows the exact payload before it is sent, and reporting can be disabled or deleted at any time. + +We appreciate every administrator who voluntarily enables this feature. These anonymous field insights help us prioritize compatibility, reliability, and accessibility work so Container Pilot can serve as many environments and users as possible. Full details are available in [Telemetry and privacy](docs/telemetry.md). ## Important safety boundary @@ -97,6 +106,7 @@ Read [Security](docs/security.md), [Updates](docs/updates.md), and [Rollback](do - [Release-candidate testing](docs/testing.md) - [Webhook notifications](docs/notifications.md) - [Private registries](docs/private-registries.md) +- [Telemetry and privacy](docs/telemetry.md) - [Project website](https://deepzone.github.io/container-pilot/) - [Roadmap](ROADMAP.md) - [Changelog](CHANGELOG.md) @@ -109,7 +119,7 @@ Published multi-architecture images are available from: ghcr.io/deepzone/container-pilot ``` -- Complete version tags such as `0.9.0-rc.10` are fixed release references and must never be reused. +- Complete version tags such as `0.9.0-rc.11` are fixed release references and must never be reused. - Release candidates use the `rc` channel once published by the release workflow. - `latest`, major, and minor aliases are reserved for stable releases. - Successful builds from `main` use the moving `edge` tag and a commit-specific `sha-*` tag. They are development builds, not releases. @@ -140,7 +150,7 @@ npm run test:integration ## Contributing and security -Anonymous usage statistics are optional and disabled by default. When enabled, Container Pilot sends only aggregated technical usage statistics—never container or image names, IP addresses, hostnames, credentials, or application data. See [Telemetry](docs/telemetry.md). +Anonymous usage statistics are optional, disabled by default, and documented transparently in [Telemetry and privacy](docs/telemetry.md). Contributions are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. diff --git a/compose.yml b/compose.yml index 0b7a1e9..bb2efd2 100644 --- a/compose.yml +++ b/compose.yml @@ -1,6 +1,6 @@ services: container-pilot: - image: ${CP_IMAGE:-ghcr.io/deepzone/container-pilot:0.9.0-rc.10} + image: ${CP_IMAGE:-ghcr.io/deepzone/container-pilot:0.9.0-rc.11} container_name: container-pilot restart: unless-stopped init: true diff --git a/docs/installation.md b/docs/installation.md index e5b96ce..caa5e48 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,7 +33,7 @@ Then open `https://YOUR-DOCKER-HOST:3080`. See [HTTPS and reverse proxy](reverse The Compose file defaults to the current release candidate until the first stable release exists. To select an explicit published image without editing the file: ```bash -CP_IMAGE=ghcr.io/deepzone/container-pilot:0.9.0-rc.10 docker compose up -d +CP_IMAGE=ghcr.io/deepzone/container-pilot:0.9.0-rc.11 docker compose up -d ``` After a stable release is published, select the stable channel with `CP_IMAGE=ghcr.io/deepzone/container-pilot:latest` and `CP_SELF_UPDATE_CHANNEL=stable`. The `latest` tag is never published for a release candidate. diff --git a/docs/releases.md b/docs/releases.md index 3285ec4..23a049e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -2,13 +2,13 @@ [Documentation index](../README.md) · [Installation](installation.md) -Container Pilot uses [Semantic Versioning](https://semver.org/). Release candidates use tags such as `v0.9.0-rc.10`; stable releases use tags such as `v1.0.0`. +Container Pilot uses [Semantic Versioning](https://semver.org/). Release candidates use tags such as `v0.9.0-rc.11`; stable releases use tags such as `v1.0.0`. ## Channels | Git tag | Published image tags | Intended use | | --- | --- | --- | -| `v0.9.0-rc.10` | `0.9.0-rc.10`, `rc` | controlled release-candidate testing | +| `v0.9.0-rc.11` | `0.9.0-rc.11`, `rc` | controlled release-candidate testing | | `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest`, `stable` | stable installations | | push to `main` | `edge`, `sha-` | development testing only | diff --git a/package-lock.json b/package-lock.json index 0d3bf81..051ce4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "container-pilot", - "version": "0.9.0-rc.10", + "version": "0.9.0-rc.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "container-pilot", - "version": "0.9.0-rc.10", + "version": "0.9.0-rc.11", "engines": { "node": ">=22" } diff --git a/package.json b/package.json index 65dcd67..84eb753 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "container-pilot", - "version": "0.9.0-rc.10", + "version": "0.9.0-rc.11", "private": true, "type": "module", "scripts": { diff --git a/src/public/index.html b/src/public/index.html index 3a26db8..80f4b3f 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -4,13 +4,13 @@ Container Pilot - - + +

- + diff --git a/tracker/test-support/payload.js b/tracker/test-support/payload.js index c57f55a..18c7c99 100644 --- a/tracker/test-support/payload.js +++ b/tracker/test-support/payload.js @@ -1 +1 @@ -export const validPayload = () => ({ schema_version:1, installation_id:'5f1776a8-5ca6-44e6-bc81-f7804681ed80', delete_token_hash:'a'.repeat(64), container_pilot:{version:'0.9.0-rc.10',channel:'rc'}, system:{architecture:'amd64',docker_version:'28.3.0',docker_api_version:'1.51',os:'Debian GNU/Linux 13',kernel:'6.12'}, containers:{total:18,running:16,stopped:2,with_healthcheck:11,automatic_updates_enabled:5}, features:{watchtower_import_used:true,native_https_enabled:false,private_registry_configured:true,webhook_configured:false}, registries:{docker_hub:true,ghcr:true,gitlab:false,generic_oci:false}, updates:{successful:23,failed:1,automatic_rollbacks:2,manual_rollbacks:1} }); +export const validPayload = () => ({ schema_version:1, installation_id:'5f1776a8-5ca6-44e6-bc81-f7804681ed80', delete_token_hash:'a'.repeat(64), container_pilot:{version:'0.9.0-rc.11',channel:'rc'}, system:{architecture:'amd64',docker_version:'28.3.0',docker_api_version:'1.51',os:'Debian GNU/Linux 13',kernel:'6.12'}, containers:{total:18,running:16,stopped:2,with_healthcheck:11,automatic_updates_enabled:5}, features:{watchtower_import_used:true,native_https_enabled:false,private_registry_configured:true,webhook_configured:false}, registries:{docker_hub:true,ghcr:true,gitlab:false,generic_oci:false}, updates:{successful:23,failed:1,automatic_rollbacks:2,manual_rollbacks:1} }); diff --git a/tracker/tests/integration/postgres.test.js b/tracker/tests/integration/postgres.test.js index 7374cfc..f131026 100644 --- a/tracker/tests/integration/postgres.test.js +++ b/tracker/tests/integration/postgres.test.js @@ -7,7 +7,7 @@ test('PostgreSQL integration upserts, preserves cumulative counters, aggregates, const { summary } = await import('../../src/dashboard-data.js'); const { createPublicHandler } = await import('../../src/public-api.js'); const { buildTelemetryPayload, enableTelemetry } = await import('../../../src/telemetry.js'); const { defaultTelemetryState } = await import('../../../src/store.js'); const store={telemetry:defaultTelemetryState(),policies:{},settings:{webhook:{enabled:false}}};enableTelemetry(store);store.telemetry.successful_updates=20; - const build=()=>buildTelemetryPayload({store,version:'0.9.0-rc.10',nativeHttps:true,dockerInfo:{Architecture:'amd64',ServerVersion:'28.3.0',ApiVersion:'1.51',OperatingSystem:'Debian GNU/Linux 13',KernelVersion:'6.12.1'},containers:[],registries:[]}); + const build=()=>buildTelemetryPayload({store,version:'0.9.0-rc.11',nativeHttps:true,dockerInfo:{Architecture:'amd64',ServerVersion:'28.3.0',ApiVersion:'1.51',OperatingSystem:'Debian GNU/Linux 13',KernelVersion:'6.12.1'},containers:[],registries:[]}); const handler=createPublicHandler({query:(...args)=>pool.query(...args),saveReport,deleteInstallation,log:{info(){},warn(){}}}); const request=async({method='POST',url='/api/v1/telemetry',headers={},payload})=>{const req=Readable.from(payload?[Buffer.from(JSON.stringify(payload))]:[]);Object.assign(req,{method,url,headers,socket:{remoteAddress:'127.0.0.1'}});let status;const res={writeHead(value){status=value;return this;},end(){}};await handler(req,res);return status;}; await migrate();const first=await build();await pool.query('DELETE FROM installations WHERE installation_id=$1',[first.installation_id]);assert.equal(await request({payload:first}),202);store.telemetry.successful_updates=24;assert.equal(await request({payload:await build()}),202);