feat(experimental)!: multi-master one ledger (Option B)

Remove the last layer of soft centralization: a lone `--master` on empty
storage could still implicitly genesis a separate Autopass and split the
network on the same TOPIC_SEED. Genesis is now explicit (`--master
--genesis`); secondary masters pair via invite into the same dnsPass as
joiners, with a shared network manifest, split-brain diagnostics, and
writer-aware quorum.

- Genesis vs secondary master boot paths; auto-adopt manifest on upgrade
- Masters without dnsPass accept invites; masters with pass ignore them
- NETWORK_MANIFEST_FILE, P2NS_GENESIS, MASTER_LOAD_DOMAINS, MASTER_INVITE_ONLY
- core.status networkId; admin diagnostics; docs and multi-master tests

BREAKING: operators must run one genesis per network; additional masters
use `node p2ns.js --master` (not `--genesis`) on empty storage.
This commit is contained in:
Raven Scott
2026-05-29 05:34:50 -04:00
parent 9e931b1f4d
commit b1a02e2e84
19 changed files with 920 additions and 55 deletions
@@ -947,12 +947,21 @@ function displayInviteDiagnostics(diagnostics) {
const contentDiv = document.createElement('div');
contentDiv.className = 'space-y-2';
const networkIdShort = diagnostics.networkId
? `${String(diagnostics.networkId).slice(0, 16)}`
: 'n/a';
const statusItems = [
{ label: 'Node', value: diagnostics.nodeType, color: 'text-white' },
{ label: 'Network ID', value: networkIdShort, color: diagnostics.networkId ? 'text-indigo-300' : 'text-gray-400' },
{ label: 'DNS Pass', value: diagnostics.dnsPassInitialized ? 'Ready' : 'Missing', color: diagnostics.dnsPassInitialized ? 'text-green-400' : 'text-red-400' },
{ label: 'Peers', value: String(totalPeers), color: 'text-white' },
{ label: 'Control', value: protocol.control || 'p2ns.core-request-rpc', color: 'text-indigo-300' }
];
if (diagnostics.masterPendingPass) {
statusItems.splice(1, 0, { label: 'Role', value: 'Secondary master (awaiting invite)', color: 'text-yellow-400' });
} else if (diagnostics.isGenesis) {
statusItems.splice(1, 0, { label: 'Role', value: 'Genesis master', color: 'text-green-400' });
}
contentDiv.innerHTML += `
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 text-sm">
@@ -1010,6 +1019,12 @@ function displayInviteDiagnostics(diagnostics) {
if (inFlight > 0) {
alerts.push(`<div class="text-yellow-300 text-xs">⚙️ ${inFlight} in-flight invite.request handler(s)</div>`);
}
if ((diagnostics.splitBrainWarnings || []).length > 0) {
alerts.push(`<div class="text-red-300 text-xs">🚨 ${diagnostics.splitBrainWarnings.length} split-network warning(s) — peer networkId mismatch</div>`);
}
if (diagnostics.masterPendingPass) {
alerts.push(`<div class="text-yellow-300 text-xs">⏳ Secondary master: waiting for invite to join the network ledger</div>`);
}
if ((diagnostics.consecutiveInviteFailures || 0) > 0) {
const n = diagnostics.consecutiveInviteFailures;
const severity = n >= 3 ? 'text-red-300' : 'text-orange-300';
+12
View File
@@ -274,6 +274,9 @@ function renderCoreStats(core) {
if (protocolEl) {
const parts = [
core.nodeType ? `node=${core.nodeType}` : null,
core.networkId ? `network=${String(core.networkId).slice(0, 12)}` : null,
core.isGenesis ? 'genesis' : null,
core.masterPendingPass ? 'pending-pass' : null,
protocol.control ? `control=${protocol.control}` : null,
protocol.inviteWire ? `wire=${protocol.inviteWire}` : null,
core.schemaVersion ? `schema=v${core.schemaVersion}` : null
@@ -289,6 +292,15 @@ function renderCoreStats(core) {
const peerEntries = core.peers ? Object.entries(core.peers) : [];
let html = '';
if ((core.splitBrainWarnings || []).length > 0) {
html += `
<div class="theme-glass rounded-lg p-3 text-sm border border-red-500/40">
<span class="text-red-400 font-semibold">Split network:</span>
<span class="theme-text-secondary"> ${core.splitBrainWarnings.length} peer(s) report a different networkId</span>
</div>
`;
}
if (masterQueue > 0 || inFlight > 0) {
html += `
<div class="theme-glass rounded-lg p-3 text-sm">
+42 -7
View File
@@ -4,6 +4,8 @@
const crypto = require('crypto');
const { logDebug, logError, logInfo, logWarn } = require('../infrastructure/logger');
const { resolveMasterInviteOnly } = require('../infrastructure/master-mode');
const { getLocalNetworkSummary } = require('../infrastructure/network-manifest');
const { INVITE_STATUS, METHODS } = require('./core-rpc-contract');
function newInviteId() {
@@ -36,6 +38,7 @@ function createCoreSwarmHandlers(ctx) {
getCurrentPairOperation,
setCurrentPairOperation,
setupDomainsWatcher,
shouldLoadDomains,
listDomains,
doAutoVotes,
setupListeners,
@@ -48,8 +51,8 @@ function createCoreSwarmHandlers(ctx) {
const label = meta.label || 'invite';
const ackPeerId = meta.ackPeerId || peerId;
if (isMaster) {
logWarn('Swarm', `Ignoring ${label} as this is the master`);
if (isMaster && getDnsPass()) {
logWarn('Swarm', `Ignoring ${label} — master already has dnsPass`);
return;
}
if (getDnsPass()) {
@@ -87,7 +90,14 @@ function createCoreSwarmHandlers(ctx) {
await currentDnsPass.ready();
}
setupDomainsWatcher();
const loadDomainsFile = isMaster
? (typeof shouldLoadDomains === 'function' && shouldLoadDomains())
: true;
if (loadDomainsFile) {
setupDomainsWatcher();
} else {
logInfo('Swarm', 'Skipping domains.json watcher (secondary master; set MASTER_LOAD_DOMAINS=true to enable)');
}
await listDomains();
doAutoVotes();
@@ -96,6 +106,10 @@ function createCoreSwarmHandlers(ctx) {
}
setupListeners();
if (isMaster) {
state.masterPendingPass = false;
logInfo('Swarm', 'Secondary master paired — dnsPass ready, master invite policy active');
}
state.consecutiveInviteFailures = 0;
const requestChannelInfo = channelManager.getChannelInfo(CORE_DOMAIN, 'request');
@@ -228,7 +242,14 @@ function createCoreSwarmHandlers(ctx) {
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'joiner' };
}
if (!isMaster && process.env.ALLOW_ANY_WRITER_INVITES !== 'true') {
const masterInviteOnly = resolveMasterInviteOnly();
const allowAnyWriter = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
if (masterInviteOnly && !isMaster) {
logWarn('Swarm', `Rejecting invite.request from ${peerId} (MASTER_INVITE_ONLY)`);
coreRpc.inviteUnavailable(peerId, 'not_master');
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
}
if (!isMaster && !allowAnyWriter) {
logWarn('Swarm', `Rejecting invite.request from ${peerId} (not master)`);
coreRpc.inviteUnavailable(peerId, 'not_master');
return { status: INVITE_STATUS.UNAVAILABLE, reason: 'not_master' };
@@ -321,7 +342,11 @@ function createCoreSwarmHandlers(ctx) {
const pass = getDnsPass();
const { isDnsPassUsable } = require('./core');
if (pass && isDnsPassUsable(pass) && (isMaster || process.env.ALLOW_ANY_WRITER_INVITES === 'true')) {
const canRelayInvite =
pass &&
isDnsPassUsable(pass) &&
(isMaster || (!resolveMasterInviteOnly() && process.env.ALLOW_ANY_WRITER_INVITES === 'true'));
if (canRelayInvite) {
try {
const { createInvite, inviteToWire, whenDnsPassIdle } = require('./dns-pass-queue');
await whenDnsPassIdle();
@@ -391,26 +416,36 @@ function createCoreSwarmHandlers(ctx) {
const pass = getDnsPass();
const dnsPassInitialized = !!pass;
const allowAnyWriterInvites = process.env.ALLOW_ANY_WRITER_INVITES === 'true';
const masterInviteOnly = resolveMasterInviteOnly();
let canProvideInvite = false;
if (dnsPassInitialized) {
if (isMaster) {
canProvideInvite = true;
} else if (allowAnyWriterInvites) {
} else if (!masterInviteOnly && allowAnyWriterInvites) {
canProvideInvite = true;
}
}
const network = getLocalNetworkSummary();
const baseCoreKey = pass?.base?.key ? pass.base.key.toString('hex').slice(0, 16) + '...' : null;
return {
nodeType: isMaster ? 'master' : 'joiner',
dnsPassInitialized,
canProvideInvite,
allowAnyWriterInvites,
masterInviteOnly,
isProcessingInvite: isProcessingInvite(),
consecutiveInviteFailures: state.consecutiveInviteFailures || 0,
connectedPeers: connectedPeers.size,
masterQueueSize: state.pendingInviteRequests?.size || 0,
inFlightInviteHandlers: pendingInviteRequestHandlers.size,
failedInvitePeerCount: failedInvitePeers.size
failedInvitePeerCount: failedInvitePeers.size,
networkId: network.networkId,
manifestPresent: network.manifestPresent,
isGenesis: network.isGenesis,
masterPendingPass: network.masterPendingPass,
baseCoreKey
};
}
+7 -2
View File
@@ -223,9 +223,13 @@ async function updateClaimClients(domain, claimant, clients) {
}
}
// Get active peer count
// Get active peer count for quorum (writers on the network, not raw swarm connections)
function getActivePeerCount() {
return (state.connectedPeers?.size || 0) + 1; // +1 for local node
const writers = state.networkWriterPeers;
if (writers && writers.size > 0) {
return writers.size + 1; // +1 for local node
}
return (state.connectedPeers?.size || 0) + 1;
}
// Validate vote references existing claim
@@ -929,6 +933,7 @@ module.exports = {
doAutoVotes,
getAllEntries,
isDnsPassUsable,
getActivePeerCount,
autoVoteForDomain,
voteForDomain,
removeDomain,
+4
View File
@@ -28,6 +28,10 @@ function validateConfig() {
// Topic seed
config.TOPIC_SEED = process.env.TOPIC_SEED || 'p2ns-dns';
config.NETWORK_MANIFEST_FILE = process.env.NETWORK_MANIFEST_FILE || 'cache/network.json';
config.MASTER_LOAD_DOMAINS = process.env.MASTER_LOAD_DOMAINS === 'true';
config.MASTER_INVITE_ONLY = process.env.MASTER_INVITE_ONLY === 'true';
// Certificates directory
config.CERTS_DIR = process.env.CERTS_DIR || './certs';
+48 -7
View File
@@ -1,14 +1,55 @@
/**
* Resolve whether this process is the Autopass master (invite authority).
* Docker/PM2 often omit argv flags; P2NS_MASTER=true is supported as an alternative to --master.
* @returns {boolean}
* Master / genesis mode resolution for multi-master Option B.
*/
function resolveIsMaster() {
if (process.argv.includes('--master')) return true;
const v = process.env.P2NS_MASTER ?? process.env.MASTER;
function envTruthy(v) {
if (v === undefined || v === '') return false;
const normalized = String(v).trim().toLowerCase();
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
module.exports = { resolveIsMaster };
/**
* Operational master: invites, reconnect policy, optional domains.json (genesis only by default).
* @returns {boolean}
*/
function resolveIsMaster() {
if (process.argv.includes('--master')) return true;
const v = process.env.P2NS_MASTER ?? process.env.MASTER;
return envTruthy(v);
}
/**
* Genesis: create a new Autopass universe and network manifest.
* @returns {boolean}
*/
function resolveIsGenesis() {
if (process.argv.includes('--genesis')) return true;
return envTruthy(process.env.P2NS_GENESIS);
}
/**
* Whether this master should watch/import domains.json.
* @param {boolean} isGenesisRun
* @returns {boolean}
*/
function shouldMasterLoadDomains(isGenesisRun) {
if (!resolveIsMaster()) return false;
if (isGenesisRun) return true;
return envTruthy(process.env.MASTER_LOAD_DOMAINS);
}
/**
* Only master nodes may issue invites (stricter than ALLOW_ANY_WRITER_INVITES).
* @returns {boolean}
*/
function resolveMasterInviteOnly() {
return envTruthy(process.env.MASTER_INVITE_ONLY);
}
module.exports = {
resolveIsMaster,
resolveIsGenesis,
shouldMasterLoadDomains,
resolveMasterInviteOnly,
envTruthy
};
+169
View File
@@ -0,0 +1,169 @@
/**
* Network manifest: single Autopass universe identity for multi-master Option B.
*/
const fs = require('fs').promises;
const path = require('path');
const { logInfo, logWarn, logError } = require('./logger');
const MANIFEST_VERSION = 1;
function getManifestPath(configPath) {
const raw = configPath || process.env.NETWORK_MANIFEST_FILE || 'cache/network.json';
return path.isAbsolute(raw) ? raw : path.resolve(process.cwd(), raw);
}
/**
* @param {import('autopass')} pass
* @param {string} [genesisPublicKey]
*/
function extractNetworkIdentity(pass, genesisPublicKey) {
if (!pass || !pass.base) {
throw new Error('Autopass not ready — cannot extract network identity');
}
const discoveryKey = pass.discoveryKey;
const networkId = discoveryKey.toString('hex');
return {
networkId,
autopassDiscoveryKey: networkId,
genesisPublicKey: genesisPublicKey || pass.writerKey?.toString('hex') || pass.base?.key?.toString('hex') || null,
baseCoreKey: pass.base.key.toString('hex')
};
}
/**
* @param {object} manifest
* @param {import('autopass')} pass
* @param {string} topicSeed
*/
function validateManifestAgainstPass(manifest, pass, topicSeed) {
if (!manifest) return { ok: false, error: 'manifest_missing' };
const seed = topicSeed || process.env.TOPIC_SEED || 'p2ns-dns';
if (manifest.topicSeed !== seed) {
return { ok: false, error: 'topic_seed_mismatch', expected: seed, got: manifest.topicSeed };
}
const identity = extractNetworkIdentity(pass);
if (manifest.networkId !== identity.networkId) {
return {
ok: false,
error: 'network_id_mismatch',
expected: manifest.networkId,
got: identity.networkId
};
}
return { ok: true };
}
async function storageDirHasCorestoreData(storageDir) {
try {
const entries = await fs.readdir(storageDir);
return entries.some((e) => e !== '.' && e !== '..');
} catch (err) {
if (err.code === 'ENOENT') return false;
throw err;
}
}
async function readManifest(manifestPath) {
const filePath = getManifestPath(manifestPath);
try {
const raw = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(raw);
if (data.version !== MANIFEST_VERSION) {
logWarn('NetworkManifest', `Manifest version ${data.version} (expected ${MANIFEST_VERSION})`);
}
return data;
} catch (err) {
if (err.code === 'ENOENT') return null;
throw err;
}
}
async function writeManifest(manifestPath, fields) {
const filePath = getManifestPath(manifestPath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
const doc = {
version: MANIFEST_VERSION,
createdAt: new Date().toISOString(),
...fields
};
await fs.writeFile(filePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
logInfo('NetworkManifest', `Wrote network manifest: ${filePath} (networkId=${doc.networkId?.slice(0, 16)}...)`);
return doc;
}
async function adoptManifestFromPass(pass, topicSeed, manifestPath, options = {}) {
const { genesisPublicKey, isGenesisRun = false } = options;
const identity = extractNetworkIdentity(pass, genesisPublicKey);
const doc = await writeManifest(manifestPath, {
...identity,
topicSeed: topicSeed || process.env.TOPIC_SEED || 'p2ns-dns',
isGenesis: isGenesisRun
});
return doc;
}
function recordPeerNetworkStatus(peerId, status) {
if (!peerId || !status) return;
const state = require('./state');
if (!state.peerNetworkStatus) {
state.peerNetworkStatus = new Map();
}
state.peerNetworkStatus.set(peerId, {
networkId: status.networkId || null,
dnsPassInitialized: !!status.dnsPassInitialized,
nodeType: status.nodeType || null,
lastSeen: Date.now()
});
if (!state.networkWriterPeers) {
state.networkWriterPeers = new Set();
}
if (status.dnsPassInitialized) {
state.networkWriterPeers.add(peerId);
} else {
state.networkWriterPeers.delete(peerId);
}
checkSplitBrain(peerId, status);
}
function checkSplitBrain(peerId, remoteStatus) {
const state = require('./state');
const localId = state.networkManifest?.networkId;
if (!localId || !state.dnsPass) return;
if (!remoteStatus?.dnsPassInitialized || !remoteStatus?.networkId) return;
if (remoteStatus.networkId === localId) return;
const msg = `Split network detected: peer ${peerId.slice(0, 16)}... has networkId ${remoteStatus.networkId.slice(0, 16)}... but local is ${localId.slice(0, 16)}...`;
if (!state.splitBrainWarnings) state.splitBrainWarnings = [];
if (!state.splitBrainWarnings.includes(msg)) {
state.splitBrainWarnings.push(msg);
logError('NetworkManifest', msg);
}
}
function getLocalNetworkSummary() {
const state = require('./state');
return {
networkId: state.networkManifest?.networkId || null,
manifestPresent: !!state.networkManifest,
isGenesis: !!state.isGenesis,
masterPendingPass: !!state.masterPendingPass,
splitBrainWarnings: state.splitBrainWarnings ? [...state.splitBrainWarnings] : []
};
}
module.exports = {
MANIFEST_VERSION,
getManifestPath,
extractNetworkIdentity,
validateManifestAgainstPass,
storageDirHasCorestoreData,
readManifest,
writeManifest,
adoptManifestFromPass,
recordPeerNetworkStatus,
checkSplitBrain,
getLocalNetworkSummary
};
+6
View File
@@ -73,6 +73,12 @@ module.exports = {
persistentConnections: new Set(), // Track which connections are persistent
localDnsRecords: [],
isMaster: require('./master-mode').resolveIsMaster(),
isGenesis: false,
masterPendingPass: false,
networkManifest: null,
peerNetworkStatus: new Map(),
networkWriterPeers: new Set(),
splitBrainWarnings: [],
sendRemovalRequest: null,
sendConsensusRequest: null,
domainsWithBoth: new Set(),