209 lines
7.0 KiB
JavaScript
209 lines
7.0 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|