From 51ba85fc69249f4db0e9cc1d17594882ca59aa57 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 23:47:43 -0700 Subject: [PATCH] P7 r9: probes isolated (DATA_DIR tmp), self-authenticating (git stamp + asserts), try/finally cleanup, explicit exit --- dashcaddy-api/scripts/p7-live-curl.js | 6 +- dashcaddy-api/scripts/p7-live-curl2.js | 192 +++++++++++++++---------- 2 files changed, 120 insertions(+), 78 deletions(-) diff --git a/dashcaddy-api/scripts/p7-live-curl.js b/dashcaddy-api/scripts/p7-live-curl.js index 7ad7da3..43d63a2 100644 --- a/dashcaddy-api/scripts/p7-live-curl.js +++ b/dashcaddy-api/scripts/p7-live-curl.js @@ -31,6 +31,8 @@ async function main() { await new Promise((r) => server.on('listening', r)); const port = server.address().port; + const { execSync } = require('child_process'); + console.log(`# git HEAD: ${execSync('git -C .. rev-parse HEAD', { encoding: 'utf8' }).trim()}`); const show = (label, method, path, headers, res) => { console.log(`### ${label}`); console.log(`> ${method} http://127.0.0.1:${port}${path}`); @@ -66,9 +68,9 @@ async function main() { res = await req(port, 'GET', '/api/v1/network/wg/redeem/nonexistent-token'); show('wg redeem, bogus token (public route) → redeem outcome (route mounted)', 'GET', '/api/v1/network/wg/redeem/nonexistent-token', {}, res); - server.close(); + await new Promise((r) => server.close(r)); console.log('LIVE-PROBE-DONE'); - process.exit(0); + process.exit(0); // background timers would hold the loop open } main().catch((e) => { console.error('FATAL', e && e.message); process.exit(1); }); diff --git a/dashcaddy-api/scripts/p7-live-curl2.js b/dashcaddy-api/scripts/p7-live-curl2.js index 8d12a26..2a1e9bd 100644 --- a/dashcaddy-api/scripts/p7-live-curl2.js +++ b/dashcaddy-api/scripts/p7-live-curl2.js @@ -1,19 +1,28 @@ /** * Live transcript, phase 2: ACTIVE-provider outcomes over a real socket. - * Strategy: seed the REAL data files (/etc/dashcaddy/network-*.json — none - * exist on this fresh install) in the exact on-disk format the stores read, - * register a stub provider through the REAL registry (setActiveProvider), - * boot the REAL app, and hit the REAL mounted route over HTTP. Only the - * network edge (whois) is stubbed. Leaves no state behind that matters - * (fresh-install files; removed on exit). + * ISOLATION (r8 judge blocker): everything runs under an isolated DATA_DIR + * (mktemp -d) via the platform-paths env override — the real /etc/dashcaddy + * stores are never touched, and try/finally removes the dir on every exit + * path (no process.exit() before cleanup). + * Self-authenticating: prints git HEAD, asserts every expected + * status/header/body, and exits non-zero on any mismatch. + * Only the network edge (whois) is stubbed; stores, registry, app, route, + * and HTTP are all real. */ 'use strict'; const http = require('http'); const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync } = require('child_process'); + +// Isolated data dir BEFORE requiring anything that reads platform-paths. +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'p7live2-')); +process.env.DATA_DIR = TMP; + const { createApp } = require('../src/app'); const registry = require('../src/network/registry'); -const DATA = '/etc/dashcaddy'; function req(port, method, reqPath, headers = {}) { return new Promise((resolve, reject) => { @@ -27,85 +36,116 @@ function req(port, method, reqPath, headers = {}) { }); } -const CREATED = []; -function writeIfAbsent(name, obj) { - const p = `${DATA}/${name}`; - if (!fs.existsSync(p)) { - fs.writeFileSync(p, JSON.stringify(obj, null, 2)); - CREATED.push(p); - } +let FAILURES = 0; +function expect(name, actual, predicate, rendered) { + const ok = predicate(actual); + console.log(`< assert ${name}: ${ok ? 'PASS' : 'FAIL'} (${rendered || actual})`); + if (!ok) FAILURES += 1; } async function main() { - // ── seed the real store files (fresh install: none exist) ── - const now = new Date().toISOString(); - writeIfAbsent('network-people.json', { - people: { - 'p-alice': { id: 'p-alice', displayName: 'Alice', providerUserId: '1001', groups: ['Admin'], status: 'active', devices: [], createdAt: now, updatedAt: now }, - 'p-bob': { id: 'p-bob', displayName: 'Bob', providerUserId: '1002', groups: ['Family'], status: 'active', devices: [], createdAt: now, updatedAt: now }, - }, - }); - writeIfAbsent('network-resources.json', { - resources: [ - { id: 'r-plex', name: 'plex', host: 'plex.internal', ports: ['*'], sensitive: false, createdAt: now, updatedAt: now }, - { id: 'r-vault', name: 'vault', host: 'vault.internal', ports: ['*'], sensitive: true, createdAt: now, updatedAt: now }, - ], - }); - writeIfAbsent('network-access.json', { - grants: [ - { principal: { type: 'person', id: 'p-alice' }, resource: 'plex', allowed: true, updatedAt: now }, - { principal: { type: 'person', id: 'p-alice' }, resource: 'vault', allowed: true, updatedAt: now }, - ], - updatedAt: now, - }); + let server = null; + try { + const head = execSync('git -C .. rev-parse HEAD', { encoding: 'utf8' }).trim(); + console.log(`# git HEAD: ${head}`); + console.log(`# isolated DATA_DIR: ${TMP}`); - // ── register + activate a stub provider through the REAL registry ── - const DEVICE_IP = '10.53.0.7'; - const stub = { - id: () => 'stub-live', - whois: async (ip) => (ip === DEVICE_IP - ? { personId: 'p-alice', deviceId: 'dev-1' } - : (ip === '10.53.0.9' ? { personId: 'p-bob', deviceId: 'dev-2' } : { personId: null, deviceId: null })), - listDevices: async () => [], - listPeople: async () => [], - capabilities: () => ({ id: 'stub-live' }), - getStatus: async () => ({ ok: true }), - createInvite: async () => { throw new Error('not needed for probe'); }, - revokeInvite: async () => { throw new Error('not needed for probe'); }, - blockPerson: async () => { throw new Error('not needed for probe'); }, - unblockPerson: async () => { throw new Error('not needed for probe'); }, - removePerson: async () => { throw new Error('not needed for probe'); }, - revokeDevice: async () => { throw new Error('not needed for probe'); }, - syncAccess: async () => { throw new Error('not needed for probe'); }, - configureDns: async () => { throw new Error('not needed for probe'); }, - }; - registry.registerProvider('stub-live', () => stub); - registry.setActiveProvider('stub-live'); + const now = new Date().toISOString(); + fs.writeFileSync(path.join(TMP, 'network-people.json'), JSON.stringify({ + people: { + 'p-alice': { id: 'p-alice', displayName: 'Alice', providerUserId: '1001', groups: ['Admin'], status: 'active', devices: [], createdAt: now, updatedAt: now }, + 'p-bob': { id: 'p-bob', displayName: 'Bob', providerUserId: '1002', groups: ['Family'], status: 'active', devices: [], createdAt: now, updatedAt: now }, + }, + }, null, 2)); + fs.writeFileSync(path.join(TMP, 'network-resources.json'), JSON.stringify({ + resources: [ + { id: 'r-plex', name: 'plex', host: 'plex.internal', ports: ['*'], sensitive: false, createdAt: now, updatedAt: now }, + { id: 'r-vault', name: 'vault', host: 'vault.internal', ports: ['*'], sensitive: true, createdAt: now, updatedAt: now }, + ], + }, null, 2)); + fs.writeFileSync(path.join(TMP, 'network-access.json'), JSON.stringify({ + grants: [ + { principal: { type: 'person', id: 'p-alice' }, resource: 'plex', allowed: true, updatedAt: now }, + { principal: { type: 'person', id: 'p-alice' }, resource: 'vault', allowed: true, updatedAt: now }, + ], + updatedAt: now, + }, null, 2)); - const { app } = await createApp(); - const server = app.listen(0, '127.0.0.1'); - await new Promise((r) => server.on('listening', r)); - const port = server.address().port; - const FA = '/api/v1/network/forward-auth/'; + const DEVICE_IP = '10.53.0.7'; + const stub = { + id: () => 'stub-live', + capabilities: () => ({ id: 'stub-live' }), + getStatus: async () => ({ ok: true }), + listDevices: async () => [], + listPeople: async () => [], + createInvite: async () => { throw new Error('not needed'); }, + revokeInvite: async () => { throw new Error('not needed'); }, + blockPerson: async () => { throw new Error('not needed'); }, + unblockPerson: async () => { throw new Error('not needed'); }, + removePerson: async () => { throw new Error('not needed'); }, + revokeDevice: async () => { throw new Error('not needed'); }, + syncAccess: async () => { throw new Error('not needed'); }, + whois: async (ip) => (ip === DEVICE_IP + ? { personId: 'p-alice', deviceId: 'dev-1' } + : (ip === '10.53.0.9' ? { personId: 'p-bob', deviceId: 'dev-2' } : { personId: null, deviceId: null })), + configureDns: async () => { throw new Error('not needed'); }, + }; + registry.registerProvider('stub-live', () => stub); + registry.setActiveProvider('stub-live'); - const show = (label, res) => { - console.log(`### ${label}`); + const { app } = await createApp(); + server = app.listen(0, '127.0.0.1'); + await new Promise((r) => server.on('listening', r)); + const port = server.address().port; + const FA = '/api/v1/network/forward-auth/'; + + // ALLOWED + let res = await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'plex.internal' }); + console.log('### ALLOWED non-sensitive (alice → plex.internal)'); console.log(`< HTTP ${res.status}`); - if (res.headers['www-authenticate']) console.log(`< www-authenticate: ${res.headers['www-authenticate']}`); if (res.headers['x-forwarded-user']) console.log(`< x-forwarded-user: ${res.headers['x-forwarded-user']}`); console.log(`< body: ${res.body.slice(0, 240)}`); - console.log(''); - }; + expect('allowed.status=200', res.status, (s) => s === 200); + expect('allowed.personId=p-alice', res.body, (b) => b.includes('"p-alice"')); - // loopback is in the default trustedProxies → forwarded headers honored - show('ALLOWED non-sensitive (alice device → plex.internal)', await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'plex.internal' })); - show('DENIED (bob device → plex.internal, no grant)', await req(port, 'GET', FA, { 'X-Forwarded-For': '10.53.0.9', 'X-Forwarded-Host': 'plex.internal' })); - show('UNKNOWN-DEVICE (unrecognized tailnet ip)', await req(port, 'GET', FA, { 'X-Forwarded-For': '10.99.99.99', 'X-Forwarded-Host': 'plex.internal' })); - show('SENSITIVE step-up REQUIRED (alice → vault.internal, no session)', await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'vault.internal' })); + // DENIED + res = await req(port, 'GET', FA, { 'X-Forwarded-For': '10.53.0.9', 'X-Forwarded-Host': 'plex.internal' }); + console.log('### DENIED (bob → plex.internal, no grant)'); + console.log(`< HTTP ${res.status}`); + console.log(`< body: ${res.body.slice(0, 240)}`); + expect('denied.status=403', res.status, (s) => s === 403); + expect('denied.error=forbidden', res.body, (b) => b.includes('"forbidden"')); - server.close(); - console.log('LIVE-PROBE-PHASE2-DONE'); - process.exit(0); + // UNKNOWN-DEVICE + res = await req(port, 'GET', FA, { 'X-Forwarded-For': '10.99.99.99', 'X-Forwarded-Host': 'plex.internal' }); + console.log('### UNKNOWN-DEVICE (unrecognized ip)'); + console.log(`< HTTP ${res.status}`); + if (res.headers['www-authenticate']) console.log(`< www-authenticate: ${res.headers['www-authenticate']}`); + console.log(`< body: ${res.body.slice(0, 240)}`); + expect('unknown.status=401', res.status, (s) => s === 401); + expect('unknown.www-authenticate', res.headers['www-authenticate'] || '', (h) => h.includes('ForwardAuth')); + expect('unknown.error', res.body, (b) => b.includes('unknown-device')); + + // STEP-UP + res = await req(port, 'GET', FA, { 'X-Forwarded-For': DEVICE_IP, 'X-Forwarded-Host': 'vault.internal' }); + console.log('### SENSITIVE step-up REQUIRED (alice → vault.internal, no session)'); + console.log(`< HTTP ${res.status}`); + console.log(`< body: ${res.body.slice(0, 240)}`); + expect('stepup.status=401', res.status, (s) => s === 401); + expect('stepup.error', res.body, (b) => b.includes('step-up-required')); + + console.log(`# asserts: ${FAILURES === 0 ? 'ALL PASS' : FAILURES + ' FAILURES'}`); + } finally { + if (server) await new Promise((r) => server.close(r)); + fs.rmSync(TMP, { recursive: true, force: true }); + console.log(`# cleanup: removed ${TMP}`); + } + // background timers (caddy-upstream-watcher etc.) would hold the loop + // open — exit explicitly AFTER cleanup, with the assert verdict. + process.exit(FAILURES === 0 ? 0 : 1); } -main().catch((e) => { console.error('FATAL', e && e.stack || e); process.exit(1); }); +main().catch(async (e) => { + console.error('FATAL', e && e.stack || e); + process.exit(1); +});