/** * ACL push engine — DC-148 (the I/O half of the compiler). * * Owns every interaction with the Tailscale coordination plane plus the * local last-known-good state file (data/network-acl-state.json, atomic * write). Dependencies are injectable: a `coord` client (anything exposing * request/_request-style 'POST path' + getAcl(), e.g. TailscaleCoordClient) * or a raw fetch stub, and a logger. * * Safety sequence (spec §4, hard rules): * previewPolicy POST /tailnet/{t}/acl/preview — ANY error refuses the push * pushPolicy POST /tailnet/{t}/acl with the COMPLETE policy object, * then atomically store last-known-good body + hash * pushIfNoDrift compare stored hash vs remote acls hash first; drift ⇒ * {drifted:true, pushed:false} — NEVER a silent overwrite * rollback re-push the stored last-known-good policy */ 'use strict'; const fs = require('fs'); const path = require('path'); const { atomicWriteJSON } = require('../utils/atomic-write'); const { hashAcls, detectDrift } = require('./acl-compiler'); const ACL_STATE_FILE = 'network-acl-state.json'; /** Error thrown for mapped coordination-plane failures; carries `code`. */ class AclPushError extends Error { constructor(message, { code = 'acl_push_failed', cause } = {}) { super(message); this.name = 'AclPushError'; this.code = code; this.cause = cause; this.isOperational = true; } } function _readState(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (err) { if (err && err.code === 'ENOENT') return null; throw new AclPushError('failed to read network-acl-state.json', { code: 'state_read_failed', cause: err }); } } /** * createAclPusher({ coord, fetchImpl, dataDir, log, tailnet }) * * coord client with .request(method, path, {body}) — the primary path * fetchImpl fallback when no coord: async (method, path, {body}) → {status, body} * tailnet tailnet name for URLs ('-' default) */ function createAclPusher(opts = {}) { const coord = opts.coord || null; const fetchImpl = opts.fetchImpl || null; const dataDir = opts.dataDir || (global.__DC_ACL_STATE_DIR__ || process.cwd()); const log = opts.log || { info() {}, warn() {}, error() {} }; const tailnet = opts.tailnet || '-'; const stateFile = path.join(dataDir, ACL_STATE_FILE); if (!coord && !fetchImpl) { throw new TypeError('createAclPusher requires coord or fetchImpl'); } // The coord client prefixes /api/v2 itself (_request); a raw fetch stub // gets the full control-plane path. const API_BASE = '/api/v2'; async function _call(method, apiPath, { body } = {}) { if (coord && typeof coord.request === 'function') { try { return await coord.request(method, apiPath, { body }); } catch (err) { throw _mapError(`coord ${method} ${apiPath} failed`, err); } } const fullPath = API_BASE + apiPath; let res; try { res = await fetchImpl(method, fullPath, { body }); } catch (err) { throw _mapError(`fetch ${method} ${fullPath} failed`, err); } if (res.status >= 400) { throw new AclPushError(`${method} ${fullPath} returned ${res.status}`, { code: res.status === 400 ? 'acl_rejected' : `http_${res.status}`, cause: res.body, }); } // Match the coord client contract: parsed JSON when parseable, else raw. if (typeof res.body === 'string') { try { return JSON.parse(res.body); } catch (_) { return res.body; } } return res.body; } function _mapError(message, err) { if (err && err.name === 'AclPushError') return err; let code = 'coord_error'; if (err && typeof err.code === 'string' && err.code) { code = err.code === 'bad_input' || err.status === 400 ? 'acl_rejected' : err.code; } return new AclPushError(`${message}: ${err && err.message}`, { code, cause: err }); } /** * Dry-run the policy against the tailnet. ANY preview error throws * AclPushError — callers MUST refuse to push when this rejects. * Returns the preview response body (Tailscale: { feasible, errors[] }). */ async function previewPolicy(policy) { const body = await _call('POST', `/tailnet/${tailnet}/acl/preview`, { body: policy }); if (body && Array.isArray(body.errors) && body.errors.length > 0) { throw new AclPushError('ACL preview reported errors', { code: 'acl_rejected', cause: body.errors, }); } return body; } /** Read the remote policy (GET /tailnet/{t}/acl). */ async function getRemotePolicy() { return await _call('GET', `/tailnet/${tailnet}/acl`); } function _storeLastKnownGood(policy, hash) { const state = { policy, hash, managedSection: 'acls', pushedAt: new Date().toISOString(), }; atomicWriteJSON(stateFile, state); log.info('network-acl', 'stored last-known-good policy', { hash }); return state; } /** * Push the COMPLETE policy (never partial — caller merges baseline before * compile). On success the full body + managed-section hash are atomically * persisted as last-known-good. */ async function pushPolicy(policy) { await _call('POST', `/tailnet/${tailnet}/acl`, { body: policy }); const hash = hashAcls(policy.acls); _storeLastKnownGood(policy, hash); return { pushed: true, hash }; } /** Read local last-known-good state (null when never pushed). */ function getLastKnownGood() { return _readState(stateFile); } /** Re-push the stored last-known-good policy (one-click rollback). */ async function rollback() { const state = _readState(stateFile); if (!state || !state.policy) { throw new AclPushError('no last-known-good policy stored — cannot rollback', { code: 'no_last_known_good' }); } await _call('POST', `/tailnet/${tailnet}/acl`, { body: state.policy }); return { rolledBack: true, hash: state.hash }; } /** * Compare stored hash vs remote acls before overwriting. Outside edits ⇒ * { drifted: true, pushed: false } — the caller shows a drift banner and * must NEVER silently overwrite. Only a clean (or first) push proceeds: * preview → push → store. */ async function pushIfNoDrift(policy) { const stored = _readState(stateFile); if (stored && stored.hash) { let remote; try { remote = await getRemotePolicy(); } catch (err) { throw _mapError('drift check failed (cannot read remote ACL)', err); } const { drifted } = detectDrift(stored.hash, remote); if (drifted) { log.warn('network-acl', 'drift detected — refusing to overwrite remote ACL', {}); return { drifted: true, pushed: false }; } } await previewPolicy(policy); // refuses (throws) on any preview error return await pushPolicy(policy); } return { previewPolicy, pushPolicy, pushIfNoDrift, rollback, getRemotePolicy, getLastKnownGood, AclPushError, }; } module.exports = { createAclPusher, AclPushError, ACL_STATE_FILE, };