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:
@@ -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';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user