Apply sync incrementally so unchanged tunnels stay running
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:
Raven Scott
2026-03-15 03:28:47 -04:00
parent cce71920e9
commit 0f4b82681d
2 changed files with 177 additions and 4 deletions
+56
View File
@@ -26,6 +26,16 @@ function emit(event, payload) { if (eventEmit) eventEmit(event, payload); }
// Collects current state from all sub-modules and persists it.
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.
@@ -37,6 +47,7 @@ function setOnStateSaved(fn) {
}
function saveState() {
if (stateSaveSuppressed) return;
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 || ''
}));
@@ -60,6 +71,48 @@ function saveState() {
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
settingsModule.init(saveState);
connectionsModule.init(saveState);
@@ -127,6 +180,9 @@ module.exports = {
setStoragePath,
ensureStorageDir,
setOnStateSaved,
setStateSaveSuppressed,
getStateSnapshot,
applySnapshotData,
// Settings
getSettings: settingsModule.getSettings,
updateSettings: settingsModule.updateSettings,
+121 -4
View File
@@ -222,11 +222,120 @@ async function applyRemoteUpdate() {
}
/**
* Write state snapshot to state.json and run the same restore flow as backup restore.
* 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.
* Compare two objects by relevant keys (for tunnel configs).
*/
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;
const statePath = getStateFilePath();
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).
*/