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.
|
||||
|
||||
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,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user