diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b12bc30..1eea13c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,15 +17,12 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: | - package-lock.json - tracker/package-lock.json + cache-dependency-path: package-lock.json - run: npm ci - - run: npm ci --prefix tracker - name: Check JavaScript syntax - run: find src tests tracker/src tracker/public tracker/tests -name '*.js' -print0 | xargs -0 -n1 node --check + run: find src tests -name '*.js' -print0 | xargs -0 -n1 node --check - name: Parse YAML - run: ruby -e 'require "yaml"; Dir[".github/**/*.yml", "compose*.yml", "tracker/compose.yml"].flatten.each { |file| YAML.load_file(file) }' + run: ruby -e 'require "yaml"; Dir[".github/**/*.yml", "compose*.yml"].flatten.each { |file| YAML.load_file(file) }' - run: git diff --check test: @@ -38,47 +35,23 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: | - package-lock.json - tracker/package-lock.json + cache-dependency-path: 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 + cache-dependency-path: 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 @@ -90,13 +63,9 @@ jobs: with: node-version: 24 cache: npm - cache-dependency-path: | - package-lock.json - tracker/package-lock.json + cache-dependency-path: 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] @@ -113,10 +82,3 @@ 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/tracker/.dockerignore b/tracker/.dockerignore deleted file mode 100644 index 6fdda5c..0000000 --- a/tracker/.dockerignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -tests -secrets -.env -*.log -._* -**/._* -README.md -compose.yml diff --git a/tracker/.env.example b/tracker/.env.example deleted file mode 100644 index aeb33c6..0000000 --- a/tracker/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -TRACKER_API_BIND=0.0.0.0 -TRACKER_API_PORT=3090 -TRACKER_API_HOST=127.0.0.1 -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 deleted file mode 100644 index c76c251..0000000 --- a/tracker/Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -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/README.md b/tracker/README.md deleted file mode 100644 index 5863de6..0000000 --- a/tracker/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# 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. Both listeners default to the loopback interface. If a reverse proxy runs on another private host, set `TRACKER_API_HOST` and `TRACKER_DASHBOARD_HOST` to the tracker's private address and restrict both ports to the proxy at the firewall. - -## 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 700 secrets -chmod 644 secrets/* -chmod 600 .env -docker compose up -d --build -``` - -Secrets and `.env` are excluded from Git and the Docker build context. Never place passwords in Compose. The host-side `secrets` directory remains root-only while its files must be readable by the distinct unprivileged users inside the tracker and PostgreSQL containers. 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. diff --git a/tracker/compose.yml b/tracker/compose.yml deleted file mode 100644 index e42b28a..0000000 --- a/tracker/compose.yml +++ /dev/null @@ -1,55 +0,0 @@ -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: - - "${TRACKER_API_HOST:-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 deleted file mode 100644 index 3cab3a6..0000000 --- a/tracker/migrations/001_initial.sql +++ /dev/null @@ -1,19 +0,0 @@ -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 deleted file mode 100644 index 3be27e1..0000000 --- a/tracker/package-lock.json +++ /dev/null @@ -1,164 +0,0 @@ -{ - "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 deleted file mode 100644 index bdc9302..0000000 --- a/tracker/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "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 deleted file mode 100644 index 804f461..0000000 --- a/tracker/public/app.css +++ /dev/null @@ -1 +0,0 @@ -: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 deleted file mode 100644 index e0f995f..0000000 --- a/tracker/public/app.js +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6289d8b..0000000 --- a/tracker/public/index.html +++ /dev/null @@ -1 +0,0 @@ -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

diff --git a/tracker/public/login.html b/tracker/public/login.html deleted file mode 100644 index 9bc72fe..0000000 --- a/tracker/public/login.html +++ /dev/null @@ -1 +0,0 @@ -Container Pilot Telemetry – Login

Telemetry Tracker

Internal administration dashboard

diff --git a/tracker/public/login.js b/tracker/public/login.js deleted file mode 100644 index 7335aa5..0000000 --- a/tracker/public/login.js +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index d930f2b..0000000 --- a/tracker/src/auth.js +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 01cba29..0000000 --- a/tracker/src/dashboard-data.js +++ /dev/null @@ -1,34 +0,0 @@ -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 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 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 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 }; -} - -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 deleted file mode 100644 index 63a2583..0000000 --- a/tracker/src/db.js +++ /dev/null @@ -1,49 +0,0 @@ -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.startsWith('.') && file.endsWith('.sql')).sort()) { - if (applied.has(name)) continue; - const client = await db.connect(); - try { - await client.query('BEGIN'); - const statements = fs.readFileSync(path.join(migrationDir, name), 'utf8').split(/;\s*(?:\r?\n|$)/).map(statement => statement.trim()).filter(Boolean); - for (const statement of statements) await client.query({ text: statement, queryMode: 'simple' }); - 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 deleted file mode 100644 index 9d1356f..0000000 --- a/tracker/src/public-api.js +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index e25dc95..0000000 --- a/tracker/src/server.js +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 49aefc0..0000000 --- a/tracker/src/validation.js +++ /dev/null @@ -1,26 +0,0 @@ -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 }; diff --git a/tracker/test-support/payload.js b/tracker/test-support/payload.js deleted file mode 100644 index 18c7c99..0000000 --- a/tracker/test-support/payload.js +++ /dev/null @@ -1 +0,0 @@ -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/auth.test.js b/tracker/tests/auth.test.js deleted file mode 100644 index 78b34c5..0000000 --- a/tracker/tests/auth.test.js +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index f131026..0000000 --- a/tracker/tests/integration/postgres.test.js +++ /dev/null @@ -1,16 +0,0 @@ -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.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); - 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 deleted file mode 100644 index 6f1ae0f..0000000 --- a/tracker/tests/public-api.test.js +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 654e5b2..0000000 --- a/tracker/tests/validation.test.js +++ /dev/null @@ -1,9 +0,0 @@ -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');});