Apply sync incrementally so unchanged tunnels stay running
CI / Build & Test (push) Successful in 4m21s
CI / Build & Test (push) Successful in 4m21s
- Add getStateSnapshot, applySnapshotData, setStateSaveSuppressed to holesail-manager - Sync diffs snapshot vs current state and only removes/adds/updates changed tunnels - Suppress saves during apply and write state.json once at the end - Fall back to full cleanup + restore when incremental APIs are unavailable
This commit is contained in:
@@ -26,6 +26,16 @@ function emit(event, payload) { if (eventEmit) eventEmit(event, payload); }
|
|||||||
// Collects current state from all sub-modules and persists it.
|
// Collects current state from all sub-modules and persists it.
|
||||||
|
|
||||||
let onStateSaved = null;
|
let onStateSaved = null;
|
||||||
|
let stateSaveSuppressed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, saveState() is a no-op. Used during sync apply so only the final
|
||||||
|
* state is written once by the sync-manager.
|
||||||
|
* @param {boolean} v
|
||||||
|
*/
|
||||||
|
function setStateSaveSuppressed(v) {
|
||||||
|
stateSaveSuppressed = !!v;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register a callback invoked after each saveState() with the snapshot object.
|
* Register a callback invoked after each saveState() with the snapshot object.
|
||||||
@@ -37,6 +47,7 @@ function setOnStateSaved(fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveState() {
|
function saveState() {
|
||||||
|
if (stateSaveSuppressed) return;
|
||||||
const serversList = serversModule.getServers().map(s => ({
|
const serversList = serversModule.getServers().map(s => ({
|
||||||
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
|
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
|
||||||
}));
|
}));
|
||||||
@@ -60,6 +71,48 @@ function saveState() {
|
|||||||
if (onStateSaved) onStateSaved(snapshot);
|
if (onStateSaved) onStateSaved(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return current in-memory state in the same shape as saveState() snapshot.
|
||||||
|
* Used by sync-manager to diff and apply only changes.
|
||||||
|
* @returns {{ version: number, settings: object, nextServerId: number, nextServiceTunnelId: number, servers: Array, virtualHosts: Array, serviceTunnels: Array, sshConnections: Array, rdpConnections: Array }}
|
||||||
|
*/
|
||||||
|
function getStateSnapshot() {
|
||||||
|
const serversList = serversModule.getServers().map(s => ({
|
||||||
|
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
|
||||||
|
}));
|
||||||
|
const virtualHostsList = vhostsModule.getVirtualHosts()
|
||||||
|
.map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl, useTls: v.useTls === true }));
|
||||||
|
const serviceTunnelsList = svcModule.getServiceTunnels().map(t => ({
|
||||||
|
id: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
settings: settingsModule.getSettings(),
|
||||||
|
nextServerId: serversModule.getNextServerId(),
|
||||||
|
nextServiceTunnelId: svcModule.getNextServiceTunnelId(),
|
||||||
|
servers: serversList,
|
||||||
|
virtualHosts: virtualHostsList,
|
||||||
|
serviceTunnels: serviceTunnelsList,
|
||||||
|
sshConnections: connectionsModule.getSshConnections(),
|
||||||
|
rdpConnections: connectionsModule.getRdpConnections()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply non-tunnel state from a snapshot (settings, connection lists, ID counters).
|
||||||
|
* Does not start/stop tunnels; used by sync-manager before applying tunnel diffs.
|
||||||
|
* @param {object} snapshot - Snapshot with settings, sshConnections, rdpConnections, nextServerId, nextServiceTunnelId
|
||||||
|
*/
|
||||||
|
function applySnapshotData(snapshot) {
|
||||||
|
if (!snapshot || typeof snapshot !== 'object') return;
|
||||||
|
if (snapshot.settings != null) settingsModule.applyLoaded(snapshot.settings);
|
||||||
|
const ssh = Array.isArray(snapshot.sshConnections) ? snapshot.sshConnections : [];
|
||||||
|
const rdp = Array.isArray(snapshot.rdpConnections) ? snapshot.rdpConnections : [];
|
||||||
|
connectionsModule.applyLoaded(ssh, rdp);
|
||||||
|
if (typeof snapshot.nextServerId === 'number') serversModule.applyLoaded(snapshot.nextServerId);
|
||||||
|
if (typeof snapshot.nextServiceTunnelId === 'number') svcModule.applyLoaded(snapshot.nextServiceTunnelId);
|
||||||
|
}
|
||||||
|
|
||||||
// Inject the shared saveState and emit callbacks into each sub-module
|
// Inject the shared saveState and emit callbacks into each sub-module
|
||||||
settingsModule.init(saveState);
|
settingsModule.init(saveState);
|
||||||
connectionsModule.init(saveState);
|
connectionsModule.init(saveState);
|
||||||
@@ -127,6 +180,9 @@ module.exports = {
|
|||||||
setStoragePath,
|
setStoragePath,
|
||||||
ensureStorageDir,
|
ensureStorageDir,
|
||||||
setOnStateSaved,
|
setOnStateSaved,
|
||||||
|
setStateSaveSuppressed,
|
||||||
|
getStateSnapshot,
|
||||||
|
applySnapshotData,
|
||||||
// Settings
|
// Settings
|
||||||
getSettings: settingsModule.getSettings,
|
getSettings: settingsModule.getSettings,
|
||||||
updateSettings: settingsModule.updateSettings,
|
updateSettings: settingsModule.updateSettings,
|
||||||
|
|||||||
@@ -222,11 +222,120 @@ async function applyRemoteUpdate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write state snapshot to state.json and run the same restore flow as backup restore.
|
* Compare two objects by relevant keys (for tunnel configs).
|
||||||
* Does not touch certs. Keeps applyingSync true until all tunnels are restored so
|
|
||||||
* saveState() calls during restore do not push back to autopass and cause a loop.
|
|
||||||
*/
|
*/
|
||||||
async function applySyncedState(snapshot) {
|
function serverConfigEqual(a, b) {
|
||||||
|
return a && b && a.id === b.id && a.port === b.port && String(a.host) === String(b.host) &&
|
||||||
|
!!a.secure === !!b.secure && !!a.udp === !!b.udp && String(a.label || '') === String(b.label || '');
|
||||||
|
}
|
||||||
|
function vhostConfigEqual(a, b) {
|
||||||
|
return a && b && String(a.hostname || '').toLowerCase() === String(b.hostname || '').toLowerCase() &&
|
||||||
|
String(a.hsUrl || '') === String(b.hsUrl || '') && !!a.useTls === !!b.useTls;
|
||||||
|
}
|
||||||
|
function serviceTunnelConfigEqual(a, b) {
|
||||||
|
return a && b && a.id === b.id && String(a.label || '') === String(b.label || '') &&
|
||||||
|
String(a.hsUrl || '') === String(b.hsUrl || '') && a.localPort === b.localPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply synced state incrementally: only remove tunnels not in snapshot, add/update
|
||||||
|
* those in snapshot, and apply settings/connections/IDs. Unchanged tunnels stay running.
|
||||||
|
* Falls back to full cleanup + restore if holesail-manager does not support incremental apply.
|
||||||
|
*/
|
||||||
|
async function applySyncedStateIncremental(snapshot) {
|
||||||
|
if (!holesailManager) return;
|
||||||
|
const statePath = getStateFilePath();
|
||||||
|
if (!statePath) return;
|
||||||
|
const getStateSnapshot = holesailManager.getStateSnapshot;
|
||||||
|
const applySnapshotData = holesailManager.applySnapshotData;
|
||||||
|
const setStateSaveSuppressed = holesailManager.setStateSaveSuppressed;
|
||||||
|
if (typeof getStateSnapshot !== 'function' || typeof applySnapshotData !== 'function' || typeof setStateSaveSuppressed !== 'function') {
|
||||||
|
await applySyncedStateFull(snapshot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyingSync = true;
|
||||||
|
setStateSaveSuppressed(true);
|
||||||
|
try {
|
||||||
|
snapshot.deviceNames = snapshot.deviceNames || {};
|
||||||
|
if (pass && pass.writerKey) {
|
||||||
|
const myId = shortId(pass.writerKey);
|
||||||
|
if (myId) snapshot.deviceNames[myId] = typeof os.hostname === 'function' ? os.hostname() : 'device';
|
||||||
|
}
|
||||||
|
const current = getStateSnapshot();
|
||||||
|
const snapServers = Array.isArray(snapshot.servers) ? snapshot.servers : [];
|
||||||
|
const snapVhosts = Array.isArray(snapshot.virtualHosts) ? snapshot.virtualHosts : [];
|
||||||
|
const snapSvc = Array.isArray(snapshot.serviceTunnels) ? snapshot.serviceTunnels : [];
|
||||||
|
const curVhosts = new Map((current.virtualHosts || []).map(v => [String(v.hostname || '').toLowerCase(), v]));
|
||||||
|
const curServers = new Map((current.servers || []).map(s => [s.id, s]));
|
||||||
|
const curSvc = new Map((current.serviceTunnels || []).map(t => [t.id, t]));
|
||||||
|
const snapVhostsByHost = new Map(snapVhosts.map(v => [String(v.hostname || '').toLowerCase(), v]));
|
||||||
|
const snapServersById = new Map(snapServers.map(s => [s.id, s]));
|
||||||
|
const snapSvcById = new Map(snapSvc.map(t => [t.id, t]));
|
||||||
|
|
||||||
|
applySnapshotData(snapshot);
|
||||||
|
|
||||||
|
for (const [hostname] of curVhosts) {
|
||||||
|
if (!snapVhostsByHost.has(hostname)) {
|
||||||
|
await holesailManager.removeVirtualHost({ hostname }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id] of curServers) {
|
||||||
|
if (!snapServersById.has(id)) {
|
||||||
|
await holesailManager.stopServer({ serverId: id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id] of curSvc) {
|
||||||
|
if (!snapSvcById.has(id)) {
|
||||||
|
await holesailManager.stopServiceTunnel({ tunnelId: id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of snapVhosts) {
|
||||||
|
const cur = curVhosts.get(String(v.hostname || '').toLowerCase());
|
||||||
|
if (!vhostConfigEqual(cur, v)) {
|
||||||
|
await holesailManager.setVirtualHost({ hostname: v.hostname, hsUrl: v.hsUrl, useTls: !!v.useTls }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const s of snapServers) {
|
||||||
|
const cur = curServers.get(s.id);
|
||||||
|
if (!serverConfigEqual(cur, s)) {
|
||||||
|
if (cur) await holesailManager.stopServer({ serverId: s.id }).catch(() => {});
|
||||||
|
await holesailManager.startServer({
|
||||||
|
serverId: s.id,
|
||||||
|
port: s.port,
|
||||||
|
host: s.host,
|
||||||
|
secure: !!s.secure,
|
||||||
|
udp: !!s.udp,
|
||||||
|
label: s.label || ''
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const t of snapSvc) {
|
||||||
|
const cur = curSvc.get(t.id);
|
||||||
|
if (!serviceTunnelConfigEqual(cur, t)) {
|
||||||
|
if (cur) await holesailManager.stopServiceTunnel({ tunnelId: t.id }).catch(() => {});
|
||||||
|
await holesailManager.startServiceTunnel({
|
||||||
|
tunnelId: t.id,
|
||||||
|
label: t.label || '',
|
||||||
|
hsUrl: t.hsUrl,
|
||||||
|
localPort: t.localPort
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.dirname(statePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(statePath, JSON.stringify(snapshot, null, 2), 'utf8');
|
||||||
|
} finally {
|
||||||
|
setStateSaveSuppressed(false);
|
||||||
|
applyingSync = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full teardown and restore (used when incremental API is unavailable or on first pair).
|
||||||
|
*/
|
||||||
|
async function applySyncedStateFull(snapshot) {
|
||||||
if (!holesailManager || !setTunnelsRestoredPromise || !restorePersistedTunnels) return;
|
if (!holesailManager || !setTunnelsRestoredPromise || !restorePersistedTunnels) return;
|
||||||
const statePath = getStateFilePath();
|
const statePath = getStateFilePath();
|
||||||
if (!statePath) return;
|
if (!statePath) return;
|
||||||
@@ -253,6 +362,14 @@ async function applySyncedState(snapshot) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write state snapshot to state.json and apply changes incrementally so unchanged
|
||||||
|
* tunnels are not restarted. Falls back to full cleanup + restore when needed.
|
||||||
|
*/
|
||||||
|
async function applySyncedState(snapshot) {
|
||||||
|
await applySyncedStateIncremental(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called by holesail-manager after each saveState(). Merge this device's hostname into state, write to disk, then push to autopass (if linked and not applying).
|
* Called by holesail-manager after each saveState(). Merge this device's hostname into state, write to disk, then push to autopass (if linked and not applying).
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user