+23
-54
@@ -1,75 +1,44 @@
|
|||||||
# Device sync
|
# Device sync
|
||||||
|
|
||||||
Holesail Browser can sync its state across two devices using [autopass](https://www.npmjs.com/package/autopass). Once linked, changes on one device (virtual hosts, server tunnels, service tunnels, SSH/RDP connection lists, settings) are replicated to the other and applied automatically.
|
Holesail Browser and the Holesail desktop (Pear/Electron) app sync state **peer-to-peer** using HMAC `hs1.` invites over HyperDHT + [protomux-rpc](https://github.com/holepunchto/protomux-rpc). There is no Autopass/Corestore and no master server — every linked device is equal.
|
||||||
|
|
||||||
## What is synced
|
## What is synced
|
||||||
|
|
||||||
- **Included:** All persistent state stored in `state.json`: settings, virtual hosts, server tunnels, service tunnels, SSH connections (including saved passwords), RDP connections (including saved passwords), and ID counters.
|
- **Included:** Persistent state in `state.json`: settings, virtual hosts, server tunnels (when Sync Servers is on), service tunnels, SSH/RDP connection lists, ID counters, device display names.
|
||||||
- **Excluded:** CA certificates and wildcard TLS certificates. Each device keeps its own certs. After linking, if you use the same browser profile on the second device for virtual hosts, install the root CA there (Dashboard → Proxy & CA → Install Root CA).
|
- **Excluded:** CA / TLS certificates. Install the root CA on each device that needs HTTPS virtual hosts.
|
||||||
|
|
||||||
## How to link two devices
|
## How to link two devices
|
||||||
|
|
||||||
1. **First device (Master):** Open the dashboard → **Sync**. Click **Create invite**. This device creates the sync group and becomes the **Master**. Copy the invite string (or use **Copy invite**).
|
1. On device A open **Sync** → **Create invite**. Copy the `hs1.…` string.
|
||||||
2. **Second device:** Open the dashboard → **Sync**. Paste the invite into the text field and click **Link device**. Wait for “Device linked. State has been synced.”
|
2. On device B paste the invite → **Link device**. Confirm that linking replaces local synced state with the peer’s document.
|
||||||
3. Both devices are now linked. The second device’s state is replaced by the first device’s state at link time; thereafter changes on either device sync to the other.
|
3. Both devices listen on HyperDHT and dial each other. Changes on either side push the full sync document; peers apply last-write-wins and rewrite `state.json`.
|
||||||
|
|
||||||
**Replace-state warning:** When you click **Link device**, a confirmation modal appears. It explains that linking will replace all current state on this device (virtual hosts, tunnels, SSH/RDP connections, settings) with the state from the other device, and suggests creating a backup first if you want to keep a copy. Click **Replace state and link** to confirm, or **Cancel** to abort.
|
## Equal peers
|
||||||
|
|
||||||
## Linked devices table
|
- Any linked device can **Create invite** for the same group.
|
||||||
|
- **Leave group** removes only this device (delink) and resets local tunnels to defaults.
|
||||||
|
- **Dissolve group** signals all peers to drop sync metadata (no Autopass master privilege).
|
||||||
|
|
||||||
When linked, the Sync page shows a **Linked devices** table:
|
## Offline and conflicts
|
||||||
|
|
||||||
- **Device name** — Each device is shown by its **hostname** (the OS hostname of the machine, e.g. `macbook-pro`, `work-laptop`). The Master device has a **MASTER** label next to its name. Hostnames are synced in state so once a device has replicated at least once, other peers will see its hostname in the table. If a hostname is not yet known, the table falls back to “This device” for the current machine or “Device 2”, “Device 3”, etc. for others.
|
- While offline, local edits still update `state.json`. On reconnect, peers exchange documents and keep the winner by `(stateRevision, updatedAt, deviceId)`.
|
||||||
- **Device ID** — The short hex ID (fingerprint of the device’s autopass writer key) is shown next to each name. Other peers appear in the table after they have replicated at least once; if the other device is offline or has not yet synced, you may only see this device until replication runs.
|
- Avoid editing the same lists on two devices at once.
|
||||||
- **Sync group** — A shared ID (fingerprint of the discovery key) that is the same on every device in the group. You can confirm both devices are in the same group by checking that the Sync group ID matches on each.
|
|
||||||
|
|
||||||
## More than two devices
|
## Storage (Browser)
|
||||||
|
|
||||||
You can link more than two devices to the same sync group. From any device that is already linked, open the dashboard → **Sync** → **Create invite**. On the new device, paste the invite and click **Link device**. Each invite links one additional device; repeat as needed. All linked devices stay in sync with each other.
|
Under `holesail-browser-storage/`:
|
||||||
|
|
||||||
**Create invite when already linked:** Clicking **Create invite** on any linked device (Master or not) generates a new invite for the **same** sync group — it does not create a new group or leave the current one. Share that invite with another device to add it to the group. Any peer can create invites (e.g. when the Master is offline). All peers have read and write access and stay in sync. To start a new sync group, unlink first (remove `autopass/` and `autopass-identity.json`), then click **Create invite**.
|
- `state.json` — full app state (always written on local save and accepted remote apply)
|
||||||
|
- `sync-identity.json` — HyperDHT keypair for this device
|
||||||
|
- `sync-group.json` — group secret, roster, spent invite JTIs
|
||||||
|
- `device-display-name.json` — optional local display name
|
||||||
|
|
||||||
## Master and persistence
|
Legacy `autopass/` and `autopass-identity.json` are removed automatically; create a new `hs1` invite after upgrade.
|
||||||
|
|
||||||
The device that creates the sync group (first to click **Create invite** when not linked) is the **Master** and is shown with a **MASTER** label in the Linked devices table. The same sync group is reused when the native host restarts: identity is stored in `autopass-identity.json` and the autopass instance is reopened so this device remains in the same group and stays writable.
|
## Storage (Desktop / Pear)
|
||||||
|
|
||||||
## Offline and back online
|
Same files under `~/.holesail/storage` (never shared with the extension storage directory).
|
||||||
|
|
||||||
When a linked device goes offline (e.g. browser closed, machine asleep), it remains linked: the identity is stored in `autopass-identity.json`. When the device comes back and the native host starts again (e.g. you open the browser and the extension connects), it reopens the same sync group and will replicate with other peers when the network connects them. Any changes made on other devices while this one was offline will sync in once both are online.
|
|
||||||
|
|
||||||
## Behaviour after linking
|
|
||||||
|
|
||||||
- **Push:** Any change on a device (add/remove virtual host, edit settings, etc.) is saved to disk and pushed to the sync group. Other linked devices receive the update and apply it (only changed tunnels are started or restarted).
|
|
||||||
- **Pull:** When an update is received from another device, the native host diffs the synced state against the current state and applies only the changes: tunnels that were removed are stopped, new or changed tunnels are started or restarted, and unchanged tunnels keep running. The merged state is written to `state.json`. The dashboard refreshes when it receives the `syncApplied` event. **All** remote changes are applied (adds, edits, and deletions), including clearing the last synced tunnel. The only exception: if the incoming snapshot is **empty and unversioned** (`stateRevision` missing/0) while this device already has synced content, the update is skipped so a cold peer or bad merge cannot wipe real state.
|
|
||||||
- **Persistence:** The link is stored in `holesail-browser-storage/autopass-identity.json`. After closing the browser or restarting the native host, both devices remain linked and continue syncing when the host runs.
|
|
||||||
|
|
||||||
## At link time (pairing)
|
|
||||||
|
|
||||||
When you **Link device** with an invite, the joining device waits briefly (a few seconds) for the master’s state to replicate. It only applies state when the snapshot has content (servers, virtual hosts, or service tunnels). This ensures the master’s state is preserved and the new device receives it; if replication is slow, the peer retries automatically. After that, live changes on either device sync in both directions.
|
|
||||||
|
|
||||||
## Conflict semantics
|
|
||||||
|
|
||||||
Sync uses a **single state blob** per sync group. Updates are **last-write-wins**: the most recent write overwrites the whole state. If you edit different things on both devices at the same time, one device’s changes can overwrite the other’s. For best results, avoid editing the same lists on both devices simultaneously.
|
|
||||||
|
|
||||||
## Storage location
|
|
||||||
|
|
||||||
- **Autopass data:** `~/.holesail-browser/holesail-browser-storage/autopass/`
|
|
||||||
- **Identity (key material):** `~/.holesail-browser/holesail-browser-storage/autopass-identity.json`
|
|
||||||
|
|
||||||
Do not share or back up `autopass-identity.json` to an untrusted location; it allows access to the synced state.
|
|
||||||
|
|
||||||
## Unlinking
|
|
||||||
|
|
||||||
To stop syncing, remove or rename the `autopass` directory and `autopass-identity.json` under `holesail-browser-storage`. The other device remains linked until it is restarted or its identity is removed; it will no longer receive updates from this device.
|
|
||||||
|
|
||||||
## Backups and sync
|
## Backups and sync
|
||||||
|
|
||||||
Backups (Dashboard → Backups) **include** `autopass-identity.json` when linked (so sync identity is never lost) but **do not** include the `autopass/` directory. Creating a backup does not pause sync; restoring briefly pauses sync then reconnects on next use. Use backups for full local or cross-machine snapshots; use sync for live replication across devices (state only, no certs). See [Backups](BACKUP.md).
|
Backups include sync identity/group files when present so a restore can rejoin the mesh. Creating a backup briefly closes the DHT mesh; it restarts on the next sync warmup.
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
**Not linked after restart** — After restarting the browser or native host, the Sync page may show "Not linked" if the sync data could not be reopened (e.g. the host was stopped before sync closed cleanly). You can **Create invite** to start a new sync group (this device will have a fresh identity; share the new invite with other devices to re-link them), or paste an invite from another device to re-join the existing group.
|
|
||||||
|
|
||||||
**State stays at 0 after linking** — The joining device retries for a few seconds to fetch the master’s state. Ensure the device that created the invite (Master) has state (e.g. at least one virtual host or tunnel) before linking. If both devices are online and the master had content, state should sync within a few seconds; if it does not, try creating a fresh invite on the master and linking again.
|
|
||||||
|
|
||||||
**"Invalid Device File, was modified"** — This can appear if you try to create or use sync after the sync storage was replaced or corrupted. Click **Create invite** to clear local sync data and create a new group, or **Link device** with an invite from another device to join that group.
|
|
||||||
|
|||||||
@@ -629,7 +629,7 @@
|
|||||||
<div class="card" style="margin-bottom:16px;">
|
<div class="card" style="margin-bottom:16px;">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="section-heading">Device sync</div>
|
<div class="section-heading">Device sync</div>
|
||||||
<p style="margin:0 0 14px 0;font-size:13px;color:var(--text3);">Sync your Holesail state (virtual hosts, servers, tunnels, SSH/RDP connections, settings) to another device. CA certificates are not synced; install the root CA on the other device if needed.</p>
|
<p style="margin:0 0 14px 0;font-size:13px;color:var(--text3);">Peer-to-peer sync via <code>hs1</code> HMAC invites (HyperDHT + protomux-rpc). Every linked device is equal — create an invite on any peer, or leave/dissolve from any peer. CA certificates are not synced.</p>
|
||||||
<div id="syncStatus" class="sync-status-live" role="status" aria-live="polite" aria-atomic="true" style="display:flex;align-items:center;gap:8px;margin-bottom:14px;">
|
<div id="syncStatus" class="sync-status-live" role="status" aria-live="polite" aria-atomic="true" style="display:flex;align-items:center;gap:8px;margin-bottom:14px;">
|
||||||
<div class="status-dot" id="syncStatusDot" aria-hidden="true"></div>
|
<div class="status-dot" id="syncStatusDot" aria-hidden="true"></div>
|
||||||
<span id="syncStatusText">Checking…</span>
|
<span id="syncStatusText">Checking…</span>
|
||||||
@@ -643,7 +643,7 @@
|
|||||||
Copy invite
|
Copy invite
|
||||||
</button>
|
</button>
|
||||||
<div id="syncLinkDeviceForm" style="display:flex;align-items:center;gap:8px;flex:1;min-width:200px;">
|
<div id="syncLinkDeviceForm" style="display:flex;align-items:center;gap:8px;flex:1;min-width:200px;">
|
||||||
<input type="text" id="syncInviteInput" class="input" placeholder="Paste invite from other device" style="flex:1;" />
|
<input type="text" id="syncInviteInput" class="input" placeholder="Paste hs1.… invite from other device" style="flex:1;" />
|
||||||
<button class="btn btn-primary" id="btnPairWithInvite">Link device</button>
|
<button class="btn btn-primary" id="btnPairWithInvite">Link device</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,8 +670,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
|
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
<button class="btn btn-secondary" id="btnDelink">Delink</button>
|
<button class="btn btn-secondary" id="btnDelink">Leave group</button>
|
||||||
<button class="btn btn-secondary" id="btnDisband" style="display:none;">Disband group</button>
|
<button class="btn btn-secondary" id="btnDisband" style="display:none;">Dissolve group</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1445,7 +1445,7 @@
|
|||||||
<div class="modal-backdrop" id="modal-disband">
|
<div class="modal-backdrop" id="modal-disband">
|
||||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-disband-title" style="max-width:420px;">
|
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-disband-title" style="max-width:420px;">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span class="modal-title" id="modal-disband-title">Disband sync group</span>
|
<span class="modal-title" id="modal-disband-title">Dissolve sync group</span>
|
||||||
<button class="btn-icon" data-close-modal="modal-disband" aria-label="Close">
|
<button class="btn-icon" data-close-modal="modal-disband" aria-label="Close">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -64,12 +64,15 @@ function setupLogsEvents() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = filtered.map(entry => {
|
container.innerHTML = filtered.map(entry => {
|
||||||
const time = new Date(entry.timestamp).toLocaleTimeString();
|
const rawTs = entry && entry.timestamp;
|
||||||
|
const d = new Date(typeof rawTs === 'number' ? rawTs : (typeof rawTs === 'string' ? rawTs : Date.now()));
|
||||||
|
const time = Number.isNaN(d.getTime()) ? '--:--:--' : d.toLocaleTimeString();
|
||||||
const level = normalizeLogLevel(entry.level);
|
const level = normalizeLogLevel(entry.level);
|
||||||
const cls = getLogClass(level);
|
const cls = getLogClass(level);
|
||||||
|
const msg = typeof entry === 'string' ? entry : (entry && entry.message != null ? entry.message : String(entry));
|
||||||
return `<div class="log-entry">
|
return `<div class="log-entry">
|
||||||
<span class="log-time">${time}</span>
|
<span class="log-time">${time}</span>
|
||||||
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
|
<span class="log-msg ${cls}">${escapeHtml(msg)}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
if (autoScroll) container.scrollTop = container.scrollHeight;
|
if (autoScroll) container.scrollTop = container.scrollHeight;
|
||||||
@@ -105,9 +108,12 @@ function setupLogsEvents() {
|
|||||||
$('btnDownloadLogs')?.addEventListener('click', () => {
|
$('btnDownloadLogs')?.addEventListener('click', () => {
|
||||||
if (!logs.length) { showToast('No logs to download', 'default'); return; }
|
if (!logs.length) { showToast('No logs to download', 'default'); return; }
|
||||||
const lines = logs.map(e => {
|
const lines = logs.map(e => {
|
||||||
const time = new Date(e.timestamp).toISOString();
|
const rawTs = e && e.timestamp;
|
||||||
const level = normalizeLogLevel(e.level).toUpperCase();
|
const d = new Date(typeof rawTs === 'number' ? rawTs : (typeof rawTs === 'string' ? rawTs : Date.now()));
|
||||||
return `[${time}] [${level}] ${e.message}`;
|
const time = Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
||||||
|
const level = normalizeLogLevel(e && e.level).toUpperCase();
|
||||||
|
const msg = typeof e === 'string' ? e : (e && e.message != null ? e.message : String(e));
|
||||||
|
return `[${time}] [${level}] ${msg}`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
const blob = new Blob([lines], { type: 'text/plain' });
|
const blob = new Blob([lines], { type: 'text/plain' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Sync page — device linking via autopass; create invite, pair with invite, show status.
|
* Sync page — P2P device linking via hs1 HMAC invites + HyperDHT/protomux-rpc.
|
||||||
* Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast)
|
* Depends: core/utils.js ($), ui/modal.js (openModal, closeModal, showModalError), ui/toast.js (showToast)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -53,7 +53,6 @@ function sortLinkedDevices(devices) {
|
|||||||
const arr = devices.slice();
|
const arr = devices.slice();
|
||||||
arr.sort((a, b) => {
|
arr.sort((a, b) => {
|
||||||
if (!!a.isCurrent !== !!b.isCurrent) return a.isCurrent ? -1 : 1;
|
if (!!a.isCurrent !== !!b.isCurrent) return a.isCurrent ? -1 : 1;
|
||||||
if (!!a.isMaster !== !!b.isMaster) return a.isMaster ? -1 : 1;
|
|
||||||
const na = (a.name || a.id || '').toString();
|
const na = (a.name || a.id || '').toString();
|
||||||
const nb = (b.name || b.id || '').toString();
|
const nb = (b.name || b.id || '').toString();
|
||||||
const c = na.localeCompare(nb);
|
const c = na.localeCompare(nb);
|
||||||
@@ -99,11 +98,10 @@ function buildLinkedDevicesTableBodyHtml(response) {
|
|||||||
} else {
|
} else {
|
||||||
filtered.forEach(({ d, i }) => {
|
filtered.forEach(({ d, i }) => {
|
||||||
const label = computeLinkedDeviceLabel(d, response, i);
|
const label = computeLinkedDeviceLabel(d, response, i);
|
||||||
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
|
|
||||||
const youBadge = d.isCurrent ? ' <span class="sync-you-badge">This device</span>' : '';
|
const youBadge = d.isCurrent ? ' <span class="sync-you-badge">This device</span>' : '';
|
||||||
const rowClass = d.isCurrent ? ' class="sync-linked-row sync-linked-row--current"' : ' class="sync-linked-row"';
|
const rowClass = d.isCurrent ? ' class="sync-linked-row sync-linked-row--current"' : ' class="sync-linked-row"';
|
||||||
const typeCell = syncRuntimeBadgeCellHtml(d.runtime);
|
const typeCell = syncRuntimeBadgeCellHtml(d.runtime);
|
||||||
rows += '<tr' + rowClass + '><td>' + escapeHtml(label) + masterBadge + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td><td class="sync-linked-type-cell">' + typeCell + '</td></tr>';
|
rows += '<tr' + rowClass + '><td>' + escapeHtml(label) + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(d.id || '—') + '</td><td class="sync-linked-type-cell">' + typeCell + '</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -111,17 +109,15 @@ function buildLinkedDevicesTableBodyHtml(response) {
|
|||||||
id: response.deviceId,
|
id: response.deviceId,
|
||||||
name: response.deviceName,
|
name: response.deviceName,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
isMaster: response.isMaster,
|
|
||||||
runtime: response.runtimeKind
|
runtime: response.runtimeKind
|
||||||
};
|
};
|
||||||
if (q && !linkedDeviceMatchesFilter(d, response, q, 0)) {
|
if (q && !linkedDeviceMatchesFilter(d, response, q, 0)) {
|
||||||
rows = '<tr><td colspan="3" class="empty-cell sync-linked-filter-empty">No devices match your search</td></tr>';
|
rows = '<tr><td colspan="3" class="empty-cell sync-linked-filter-empty">No devices match your search</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
const label = computeLinkedDeviceLabel(d, response, 0);
|
const label = computeLinkedDeviceLabel(d, response, 0);
|
||||||
const masterBadge = d.isMaster ? ' <span class="sync-master-badge">MASTER</span>' : '';
|
|
||||||
const youBadge = ' <span class="sync-you-badge">This device</span>';
|
const youBadge = ' <span class="sync-you-badge">This device</span>';
|
||||||
const rt = syncRuntimeBadgeCellHtml(response.runtimeKind);
|
const rt = syncRuntimeBadgeCellHtml(response.runtimeKind);
|
||||||
rows = '<tr class="sync-linked-row sync-linked-row--current"><td>' + escapeHtml(label) + masterBadge + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td><td class="sync-linked-type-cell">' + rt + '</td></tr>';
|
rows = '<tr class="sync-linked-row sync-linked-row--current"><td>' + escapeHtml(label) + youBadge + '</td><td class="mono" style="font-size:12px;">' + escapeHtml(response.deviceId || '—') + '</td><td class="sync-linked-type-cell">' + rt + '</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows += '<tr class="sync-linked-row sync-linked-row--meta"><td colspan="3"><span class="sync-table-meta-label">Sync group</span> <span class="mono sync-table-meta-id">' + escapeHtml(syncGroupId) + '</span></td></tr>';
|
rows += '<tr class="sync-linked-row sync-linked-row--meta"><td colspan="3"><span class="sync-table-meta-label">Sync group</span> <span class="mono sync-table-meta-id">' + escapeHtml(syncGroupId) + '</span></td></tr>';
|
||||||
@@ -218,7 +214,6 @@ function submitDeviceDisplayName() {
|
|||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'setDeviceDisplayName', payload: { displayName: name } } },
|
||||||
(res) => {
|
(res) => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
log('setDeviceDisplayName runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -251,10 +246,7 @@ function updateSyncStatus() {
|
|||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'getSyncStatus' } },
|
||||||
(response) => {
|
(response) => {
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) return;
|
||||||
log('getSyncStatus runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applySyncDeviceNamePanel(response);
|
applySyncDeviceNamePanel(response);
|
||||||
const dot = $('syncStatusDot');
|
const dot = $('syncStatusDot');
|
||||||
const text = $('syncStatusText');
|
const text = $('syncStatusText');
|
||||||
@@ -294,8 +286,13 @@ function updateSyncStatus() {
|
|||||||
const btnDelink = $('btnDelink');
|
const btnDelink = $('btnDelink');
|
||||||
const btnDisband = $('btnDisband');
|
const btnDisband = $('btnDisband');
|
||||||
if (card) card.style.display = 'block';
|
if (card) card.style.display = 'block';
|
||||||
if (btnDelink) btnDelink.style.display = response.isMaster ? 'none' : '';
|
// Equal peers: Leave and Dissolve are always available when linked.
|
||||||
if (btnDisband) btnDisband.style.display = response.isMaster ? '' : 'none';
|
if (btnDelink) btnDelink.style.display = '';
|
||||||
|
if (btnDisband) {
|
||||||
|
btnDisband.style.display = '';
|
||||||
|
btnDisband.textContent = 'Dissolve group';
|
||||||
|
}
|
||||||
|
if (response.migrationNotice) showToast(response.migrationNotice, 'default');
|
||||||
if (tbody) {
|
if (tbody) {
|
||||||
_lastLinkedDevicesContext = response;
|
_lastLinkedDevicesContext = response;
|
||||||
tbody.innerHTML = buildLinkedDevicesTableBodyHtml(response);
|
tbody.innerHTML = buildLinkedDevicesTableBodyHtml(response);
|
||||||
@@ -374,7 +371,6 @@ function setupSyncEvents() {
|
|||||||
btnCreate.disabled = false;
|
btnCreate.disabled = false;
|
||||||
btnCreate.textContent = 'Create invite';
|
btnCreate.textContent = 'Create invite';
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
log('createSyncInvite runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -396,7 +392,6 @@ function setupSyncEvents() {
|
|||||||
navigator.clipboard.writeText(inviteDisplay.textContent).then(() => {
|
navigator.clipboard.writeText(inviteDisplay.textContent).then(() => {
|
||||||
showToast('Invite copied to clipboard', 'success');
|
showToast('Invite copied to clipboard', 'success');
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
log('Invite clipboard copy failed');
|
|
||||||
showToast('Copy failed', 'error');
|
showToast('Copy failed', 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -426,7 +421,6 @@ function setupSyncEvents() {
|
|||||||
(response) => {
|
(response) => {
|
||||||
if (btnPair) { btnPair.disabled = false; btnPair.textContent = 'Link device'; }
|
if (btnPair) { btnPair.disabled = false; btnPair.textContent = 'Link device'; }
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
log('pairWithInvite runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -457,7 +451,6 @@ function setupSyncEvents() {
|
|||||||
(response) => {
|
(response) => {
|
||||||
if (btnDelink) { btnDelink.disabled = false; btnDelink.textContent = 'Delink'; }
|
if (btnDelink) { btnDelink.disabled = false; btnDelink.textContent = 'Delink'; }
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
log('delink runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -479,13 +472,12 @@ function setupSyncEvents() {
|
|||||||
});
|
});
|
||||||
$('disbandConfirm')?.addEventListener('click', () => {
|
$('disbandConfirm')?.addEventListener('click', () => {
|
||||||
closeModal('modal-disband');
|
closeModal('modal-disband');
|
||||||
if (btnDisband) { btnDisband.disabled = true; btnDisband.textContent = 'Disbanding…'; }
|
if (btnDisband) { btnDisband.disabled = true; btnDisband.textContent = 'Dissolving…'; }
|
||||||
chrome.runtime.sendMessage(
|
chrome.runtime.sendMessage(
|
||||||
{ target: 'holesail-native', action: 'send', payload: { type: 'disband' } },
|
{ target: 'holesail-native', action: 'send', payload: { type: 'disband' } },
|
||||||
(response) => {
|
(response) => {
|
||||||
if (btnDisband) { btnDisband.disabled = false; btnDisband.textContent = 'Disband group'; }
|
if (btnDisband) { btnDisband.disabled = false; btnDisband.textContent = 'Dissolve group'; }
|
||||||
if (chrome.runtime.lastError) {
|
if (chrome.runtime.lastError) {
|
||||||
log('disband runtime error:', chrome.runtime.lastError.message || 'unknown');
|
|
||||||
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
showToast('Failed: ' + (chrome.runtime.lastError.message || 'unknown'), 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ function register(deps) {
|
|||||||
{
|
{
|
||||||
type: 'getState',
|
type: 'getState',
|
||||||
handle: async (payload, reply) => {
|
handle: async (payload, reply) => {
|
||||||
if (syncManager && typeof syncManager.warmupSyncAtStartup === 'function') {
|
|
||||||
await syncManager.warmupSyncAtStartup().catch(() => {});
|
|
||||||
}
|
|
||||||
const proxiesReadyPromise = getProxiesReadyPromise();
|
const proxiesReadyPromise = getProxiesReadyPromise();
|
||||||
const tunnelsRestoredPromise = getTunnelsRestoredPromise();
|
const tunnelsRestoredPromise = getTunnelsRestoredPromise();
|
||||||
if (proxiesReadyPromise) await proxiesReadyPromise;
|
if (proxiesReadyPromise) await proxiesReadyPromise;
|
||||||
@@ -32,6 +29,10 @@ function register(deps) {
|
|||||||
new Promise((r) => { _fallbackTimer = setTimeout(r, 15000); })
|
new Promise((r) => { _fallbackTimer = setTimeout(r, 15000); })
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
// Warm Autopass after restore so startup tunnel saves do not hit a closing core.
|
||||||
|
if (syncManager && typeof syncManager.warmupSyncAtStartup === 'function') {
|
||||||
|
await syncManager.warmupSyncAtStartup().catch(() => {});
|
||||||
|
}
|
||||||
const servers = holesailManager.getServers();
|
const servers = holesailManager.getServers();
|
||||||
const virtualHosts = holesailManager.getVirtualHosts();
|
const virtualHosts = holesailManager.getVirtualHosts();
|
||||||
const serviceTunnels = holesailManager.getServiceTunnels();
|
const serviceTunnels = holesailManager.getServiceTunnels();
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function log(...args) {
|
|||||||
process.stderr.write('[' + new Date().toISOString() + '] ' + args.join(' ') + '\n');
|
process.stderr.write('[' + new Date().toISOString() + '] ' + args.join(' ') + '\n');
|
||||||
}
|
}
|
||||||
if (typeof logEmitter === 'function') {
|
if (typeof logEmitter === 'function') {
|
||||||
try { logEmitter({ level: 'info', message: line, source: 'native-host' }); } catch (_) {}
|
try { logEmitter({ level: 'info', message: line, source: 'native-host', timestamp: Date.now() }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ function debugLog(...args) {
|
|||||||
const stream = getLogStream();
|
const stream = getLogStream();
|
||||||
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
if (typeof logEmitter === 'function') {
|
if (typeof logEmitter === 'function') {
|
||||||
try { logEmitter({ level: 'debug', message: msg, source: 'native-host' }); } catch (_) {}
|
try { logEmitter({ level: 'debug', message: msg, source: 'native-host', timestamp: Date.now() }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ function logWarn(...args) {
|
|||||||
const stream = getLogStream();
|
const stream = getLogStream();
|
||||||
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
|
||||||
if (typeof logEmitter === 'function') {
|
if (typeof logEmitter === 'function') {
|
||||||
try { logEmitter({ level: 'warn', message: msg, source: 'native-host' }); } catch (_) {}
|
try { logEmitter({ level: 'warn', message: msg, source: 'native-host', timestamp: Date.now() }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,12 +55,20 @@ const rdpManager = require('../managers/rdp-manager.js');
|
|||||||
|
|
||||||
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
|
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
|
||||||
|
|
||||||
void syncManager.warmupSyncAtStartup().catch((err) => {
|
|
||||||
debugLog('sync warmup at startup failed (continuing):', err && err.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
|
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
|
||||||
|
|
||||||
|
// Open Autopass only after tunnel restore finishes — avoids SESSION_CLOSED races while
|
||||||
|
// setVirtualHost/saveState storms run during startup.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const tunnelsP = getTunnelsRestoredPromise();
|
||||||
|
if (tunnelsP) await tunnelsP.catch(() => {});
|
||||||
|
await syncManager.warmupSyncAtStartup();
|
||||||
|
} catch (err) {
|
||||||
|
debugLog('sync warmup at startup failed (continuing):', err && err.message);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
|
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
|
||||||
let _scheduledBackupTimer = null;
|
let _scheduledBackupTimer = null;
|
||||||
function scheduleNextAutoBackup() {
|
function scheduleNextAutoBackup() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Generated
+52
-12
@@ -23,16 +23,19 @@
|
|||||||
"bare-tls": "^3.1.7",
|
"bare-tls": "^3.1.7",
|
||||||
"bare-ws": "^3.0.0",
|
"bare-ws": "^3.0.0",
|
||||||
"child_process": "npm:bare-subprocess@^5.2.3",
|
"child_process": "npm:bare-subprocess@^5.2.3",
|
||||||
|
"compact-encoding": "^2.19.2",
|
||||||
"corestore": "^7.11.1",
|
"corestore": "^7.11.1",
|
||||||
"crypto": "npm:bare-node-crypto@^1.0.0",
|
"crypto": "npm:bare-node-crypto@^1.0.0",
|
||||||
"events": "npm:bare-node-events@^1.0.1",
|
"events": "npm:bare-node-events@^1.0.1",
|
||||||
"fs": "npm:bare-node-fs@^1.0.2",
|
"fs": "npm:bare-node-fs@^1.0.2",
|
||||||
"holesail": "^2.4.1",
|
"holesail": "^2.4.1",
|
||||||
|
"hyperdht": "^6.33.0",
|
||||||
"net": "npm:bare-node-net@^1.0.0",
|
"net": "npm:bare-node-net@^1.0.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"node-rdpjs-2": "^0.3.5",
|
"node-rdpjs-2": "^0.3.5",
|
||||||
"os": "npm:bare-node-os@^1.0.1",
|
"os": "npm:bare-node-os@^1.0.1",
|
||||||
"path": "npm:bare-node-path@^1.0.1",
|
"path": "npm:bare-node-path@^1.0.1",
|
||||||
|
"protomux-rpc": "^1.10.0",
|
||||||
"stream": "npm:bare-node-stream@^1.0.0",
|
"stream": "npm:bare-node-stream@^1.0.0",
|
||||||
"tls": "npm:bare-node-tls@^1.0.0",
|
"tls": "npm:bare-node-tls@^1.0.0",
|
||||||
"tt-native": "^1.1.1",
|
"tt-native": "^1.1.1",
|
||||||
@@ -965,9 +968,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/compact-encoding": {
|
"node_modules/compact-encoding": {
|
||||||
"version": "2.19.0",
|
"version": "2.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz",
|
||||||
"integrity": "sha512-bPQlzwxgzsuOp0wB6G9TVoZ2tGFANJckQfM10xLKUS2fbLe+fFZG6Gi1wYehcMMTcjvtil8oOJSsToMOlCSu4g==",
|
"integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"b4a": "^1.3.0"
|
"b4a": "^1.3.0"
|
||||||
@@ -1502,9 +1505,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hyperdht": {
|
"node_modules/hyperdht": {
|
||||||
"version": "6.29.0",
|
"version": "6.33.0",
|
||||||
"resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.33.0.tgz",
|
||||||
"integrity": "sha512-okIpelpIhRTBDCCe2CNWfLHbVSQ5DPbl1Npb1zet8SqrkCsYF27RHJ04IaYqiigHI9y0KZgjOHdZbejalbFVoQ==",
|
"integrity": "sha512-veSvVKptjPTu2di3q5IWI62ZDFzEmTj1QSTAkiXW/cg0+lgMUlwfVWB4dX+WI14xNb54vogVIjA/Ph3KiVuRJQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hyperswarm/secret-stream": "^6.6.2",
|
"@hyperswarm/secret-stream": "^6.6.2",
|
||||||
@@ -1512,11 +1515,11 @@
|
|||||||
"bare-events": "^2.2.0",
|
"bare-events": "^2.2.0",
|
||||||
"blind-relay": "^1.3.0",
|
"blind-relay": "^1.3.0",
|
||||||
"bogon": "^1.0.0",
|
"bogon": "^1.0.0",
|
||||||
"compact-encoding": "^2.4.1",
|
"compact-encoding": "^3.0.0",
|
||||||
"compact-encoding-net": "^1.0.1",
|
|
||||||
"dht-rpc": "^6.15.1",
|
"dht-rpc": "^6.15.1",
|
||||||
"hypercore-crypto": "^3.3.0",
|
"hypercore-crypto": "^3.3.0",
|
||||||
"hypercore-id-encoding": "^1.2.0",
|
"hypercore-id-encoding": "^1.2.0",
|
||||||
|
"hyperdht-address": "^1.0.1",
|
||||||
"noise-curve-ed": "^2.0.0",
|
"noise-curve-ed": "^2.0.0",
|
||||||
"noise-handshake": "^4.0.0",
|
"noise-handshake": "^4.0.0",
|
||||||
"record-cache": "^1.1.1",
|
"record-cache": "^1.1.1",
|
||||||
@@ -1531,6 +1534,34 @@
|
|||||||
"hyperdht": "bin.js"
|
"hyperdht": "bin.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hyperdht-address": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Mu/+7SW2cwvHxMXswa5mOrhrS5BJyntViAuB25k8d8wNtA8eAPs4LaIq6TwN1SS/KQY02h1r+gM8cQeN/k360w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"compact-encoding": "^3.0.0",
|
||||||
|
"hyperschema": "^1.20.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hyperdht-address/node_modules/compact-encoding": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hyperdht/node_modules/compact-encoding": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hyperdispatch": {
|
"node_modules/hyperdispatch": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/hyperdispatch/-/hyperdispatch-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/hyperdispatch/-/hyperdispatch-1.5.1.tgz",
|
||||||
@@ -1972,13 +2003,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protomux-rpc": {
|
"node_modules/protomux-rpc": {
|
||||||
"version": "1.9.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/protomux-rpc/-/protomux-rpc-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/protomux-rpc/-/protomux-rpc-1.10.0.tgz",
|
||||||
"integrity": "sha512-+nOXXIDKZL849F6adj3R1SEi7PfPmvS6Y9HAArVC/RpONepRFq/Ot1LHVA+vyDKQMxBd8xTCHfy2fc3tchE1gA==",
|
"integrity": "sha512-+m5It9gRlnE1GfKyUrEMilujV5FFn56qmSprsDgA7YHNDMqpoyiMJLjnoFsOMLMFNxq5B5l3GASuZm9ZpjreKg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-to-bytes": "^1.0.0",
|
"bits-to-bytes": "^1.0.0",
|
||||||
"compact-encoding": "^2.6.1",
|
"compact-encoding": "^3.0.0",
|
||||||
"compact-encoding-bitfield": "^1.0.0",
|
"compact-encoding-bitfield": "^1.0.0",
|
||||||
"protomux": "^3.7.0",
|
"protomux": "^3.7.0",
|
||||||
"safety-catch": "^1.0.2"
|
"safety-catch": "^1.0.2"
|
||||||
@@ -1987,6 +2018,15 @@
|
|||||||
"bare-events": "^2.2.0"
|
"bare-events": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protomux-rpc/node_modules/compact-encoding": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"b4a": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/protomux-wakeup": {
|
"node_modules/protomux-wakeup": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/protomux-wakeup/-/protomux-wakeup-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/protomux-wakeup/-/protomux-wakeup-2.9.0.tgz",
|
||||||
|
|||||||
@@ -24,16 +24,19 @@
|
|||||||
"bare-tls": "^3.1.7",
|
"bare-tls": "^3.1.7",
|
||||||
"bare-ws": "^3.0.0",
|
"bare-ws": "^3.0.0",
|
||||||
"child_process": "npm:bare-subprocess@^5.2.3",
|
"child_process": "npm:bare-subprocess@^5.2.3",
|
||||||
|
"compact-encoding": "^2.19.2",
|
||||||
"corestore": "^7.11.1",
|
"corestore": "^7.11.1",
|
||||||
"crypto": "npm:bare-node-crypto@^1.0.0",
|
"crypto": "npm:bare-node-crypto@^1.0.0",
|
||||||
"events": "npm:bare-node-events@^1.0.1",
|
"events": "npm:bare-node-events@^1.0.1",
|
||||||
"fs": "npm:bare-node-fs@^1.0.2",
|
"fs": "npm:bare-node-fs@^1.0.2",
|
||||||
"holesail": "^2.4.1",
|
"holesail": "^2.4.1",
|
||||||
|
"hyperdht": "^6.33.0",
|
||||||
"net": "npm:bare-node-net@^1.0.0",
|
"net": "npm:bare-node-net@^1.0.0",
|
||||||
"node-forge": "^1.3.1",
|
"node-forge": "^1.3.1",
|
||||||
"node-rdpjs-2": "^0.3.5",
|
"node-rdpjs-2": "^0.3.5",
|
||||||
"os": "npm:bare-node-os@^1.0.1",
|
"os": "npm:bare-node-os@^1.0.1",
|
||||||
"path": "npm:bare-node-path@^1.0.1",
|
"path": "npm:bare-node-path@^1.0.1",
|
||||||
|
"protomux-rpc": "^1.10.0",
|
||||||
"stream": "npm:bare-node-stream@^1.0.0",
|
"stream": "npm:bare-node-stream@^1.0.0",
|
||||||
"tls": "npm:bare-node-tls@^1.0.0",
|
"tls": "npm:bare-node-tls@^1.0.0",
|
||||||
"tt-native": "^1.1.1",
|
"tt-native": "^1.1.1",
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* HMAC capability grants + hs1 invite packages for Holesail P2P sync.
|
||||||
|
* PearDock-inspired (no Autopass / Corestore).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const SALT = Buffer.from('holesail-sync-hmac-v1', 'utf8');
|
||||||
|
const INFO_CAPABILITY = Buffer.from('capability', 'utf8');
|
||||||
|
const HOLESAIL_INVITE_PREFIX = 'hs1.';
|
||||||
|
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf)
|
||||||
|
.toString('base64')
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s) {
|
||||||
|
const str = String(s || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const pad = str.length % 4 === 0 ? '' : '='.repeat(4 - (str.length % 4));
|
||||||
|
return Buffer.from(str + pad, 'base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeedBuffer(seedHexOrBuf) {
|
||||||
|
if (Buffer.isBuffer(seedHexOrBuf) || seedHexOrBuf instanceof Uint8Array) {
|
||||||
|
const buf = Buffer.from(seedHexOrBuf);
|
||||||
|
if (buf.length !== 32) throw new Error('Seed must be 32 bytes');
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
const hex = String(seedHexOrBuf || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(hex)) {
|
||||||
|
throw new Error('Seed must be 64 hex characters (32 bytes)');
|
||||||
|
}
|
||||||
|
return Buffer.from(hex, 'hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveMacKey(seedHexOrBuf) {
|
||||||
|
const ikm = toSeedBuffer(seedHexOrBuf);
|
||||||
|
if (typeof crypto.hkdfSync === 'function') {
|
||||||
|
return Buffer.from(crypto.hkdfSync('sha256', ikm, SALT, INFO_CAPABILITY, 32));
|
||||||
|
}
|
||||||
|
const prk = crypto.createHmac('sha256', SALT).update(ikm).digest();
|
||||||
|
const info = Buffer.concat([INFO_CAPABILITY, Buffer.from([0x01])]);
|
||||||
|
return crypto.createHmac('sha256', prk).update(info).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMacKey(macKeyOrSeed) {
|
||||||
|
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
|
||||||
|
const buf = Buffer.from(macKeyOrSeed);
|
||||||
|
if (buf.length === 32) return buf;
|
||||||
|
}
|
||||||
|
if (typeof macKeyOrSeed === 'string' && /^[0-9a-fA-F]{64}$/.test(macKeyOrSeed.trim())) {
|
||||||
|
return deriveMacKey(macKeyOrSeed.trim());
|
||||||
|
}
|
||||||
|
if (Buffer.isBuffer(macKeyOrSeed) || macKeyOrSeed instanceof Uint8Array) {
|
||||||
|
return deriveMacKey(macKeyOrSeed);
|
||||||
|
}
|
||||||
|
throw new Error('Invalid mac key or seed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
if (!Buffer.isBuffer(a)) a = Buffer.from(a);
|
||||||
|
if (!Buffer.isBuffer(b)) b = Buffer.from(b);
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
if (typeof crypto.timingSafeEqual === 'function') return crypto.timingSafeEqual(a, b);
|
||||||
|
let out = 0;
|
||||||
|
for (let i = 0; i < a.length; i++) out |= a[i] ^ b[i];
|
||||||
|
return out === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalizePayload(payload) {
|
||||||
|
const ordered = {
|
||||||
|
v: payload.v,
|
||||||
|
groupId: payload.groupId,
|
||||||
|
peerId: payload.peerId ?? null,
|
||||||
|
exp: payload.exp,
|
||||||
|
jti: payload.jti,
|
||||||
|
iat: payload.iat
|
||||||
|
};
|
||||||
|
return Buffer.from(JSON.stringify(ordered), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signCapability(macKeyOrSeed, opts = {}) {
|
||||||
|
const macKey = resolveMacKey(macKeyOrSeed);
|
||||||
|
const groupId = String(opts.groupId || '');
|
||||||
|
if (!groupId) throw new Error('groupId required');
|
||||||
|
const now = Date.now();
|
||||||
|
let exp = null;
|
||||||
|
if (opts.forever === true || opts.ttlMs === 0 || opts.ttlMs == null) {
|
||||||
|
exp = null;
|
||||||
|
} else {
|
||||||
|
const ttlMs = Math.min(Math.max(Number(opts.ttlMs) || 72 * 3600 * 1000, 60_000), 100 * 365 * 24 * 3600 * 1000);
|
||||||
|
exp = now + ttlMs;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
v: 1,
|
||||||
|
groupId,
|
||||||
|
peerId: opts.peerId ? String(opts.peerId).toLowerCase() : null,
|
||||||
|
exp,
|
||||||
|
jti: opts.jti || crypto.randomBytes(16).toString('hex'),
|
||||||
|
iat: now
|
||||||
|
};
|
||||||
|
const body = canonicalizePayload(payload);
|
||||||
|
const mac = crypto.createHmac('sha256', macKey).update(body).digest();
|
||||||
|
return { token: `${b64url(body)}.${b64url(mac)}`, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyCapability(macKeyOrSeed, token, opts = {}) {
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return { ok: false, error: 'Missing capability token', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return { ok: false, error: 'Malformed capability token', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
let body;
|
||||||
|
let mac;
|
||||||
|
try {
|
||||||
|
body = b64urlDecode(parts[0]);
|
||||||
|
mac = b64urlDecode(parts[1]);
|
||||||
|
} catch (_) {
|
||||||
|
return { ok: false, error: 'Malformed capability encoding', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
if (mac.length !== 32) {
|
||||||
|
return { ok: false, error: 'Invalid capability MAC length', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
const macKey = resolveMacKey(macKeyOrSeed);
|
||||||
|
const expected = crypto.createHmac('sha256', macKey).update(body).digest();
|
||||||
|
if (!safeEqual(mac, expected)) {
|
||||||
|
return { ok: false, error: 'Capability MAC verification failed', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(body.toString('utf8'));
|
||||||
|
} catch (_) {
|
||||||
|
return { ok: false, error: 'Capability payload not JSON', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
if (payload.v !== 1) {
|
||||||
|
return { ok: false, error: 'Unsupported capability version', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
if (opts.groupId && payload.groupId !== opts.groupId) {
|
||||||
|
return { ok: false, error: 'Capability group mismatch', code: 'CAPABILITY_INVALID' };
|
||||||
|
}
|
||||||
|
const now = opts.now ?? Date.now();
|
||||||
|
if (payload.exp != null && (typeof payload.exp !== 'number' || payload.exp < now)) {
|
||||||
|
return { ok: false, error: 'Capability expired', code: 'CAPABILITY_EXPIRED' };
|
||||||
|
}
|
||||||
|
if (payload.peerId) {
|
||||||
|
const want = String(payload.peerId).toLowerCase();
|
||||||
|
const have = String(opts.peerId || '').toLowerCase();
|
||||||
|
if (!have || want !== have) {
|
||||||
|
return { ok: false, error: 'Capability bound to a different peer', code: 'CAPABILITY_PEER_MISMATCH' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof opts.allowSpentCheck === 'function' && !opts.allowSpentCheck(payload.jti)) {
|
||||||
|
return { ok: false, error: 'Capability already used or revoked', code: 'CAPABILITY_SPENT' };
|
||||||
|
}
|
||||||
|
return { ok: true, payload };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeHs1Invite(pkg) {
|
||||||
|
const body = {
|
||||||
|
v: 1,
|
||||||
|
groupId: String(pkg.groupId || ''),
|
||||||
|
publicKeyHex: String(pkg.publicKeyHex || '').toLowerCase(),
|
||||||
|
capability: String(pkg.capability || ''),
|
||||||
|
jti: pkg.jti || null,
|
||||||
|
name: pkg.name || null,
|
||||||
|
expiresAt: pkg.expiresAt ?? null
|
||||||
|
};
|
||||||
|
if (!body.groupId) throw new Error('encodeHs1Invite: groupId required');
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(body.publicKeyHex)) throw new Error('encodeHs1Invite: invalid publicKeyHex');
|
||||||
|
if (!body.capability || !body.capability.includes('.')) throw new Error('encodeHs1Invite: invalid capability');
|
||||||
|
return HOLESAIL_INVITE_PREFIX + b64url(Buffer.from(JSON.stringify(body), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeHs1Invite(input) {
|
||||||
|
const t = String(input || '')
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.trim();
|
||||||
|
if (!t.toLowerCase().startsWith(HOLESAIL_INVITE_PREFIX)) return null;
|
||||||
|
try {
|
||||||
|
const raw = b64urlDecode(t.slice(HOLESAIL_INVITE_PREFIX.length)).toString('utf8');
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
const publicKeyHex = String(pkg.publicKeyHex || '').toLowerCase();
|
||||||
|
const capability = String(pkg.capability || '');
|
||||||
|
const groupId = String(pkg.groupId || '');
|
||||||
|
if (!groupId || !/^[0-9a-f]{64}$/.test(publicKeyHex) || !capability.includes('.')) return null;
|
||||||
|
return {
|
||||||
|
groupId,
|
||||||
|
publicKeyHex,
|
||||||
|
capability,
|
||||||
|
jti: pkg.jti || null,
|
||||||
|
name: pkg.name || null,
|
||||||
|
expiresAt: pkg.expiresAt ?? null
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyInviteInput(input) {
|
||||||
|
const t = String(input || '').trim();
|
||||||
|
if (!t) return 'unknown';
|
||||||
|
const collapsed = t.replace(/\s+/g, '');
|
||||||
|
if (collapsed.toLowerCase().startsWith(HOLESAIL_INVITE_PREFIX) && decodeHs1Invite(collapsed)) {
|
||||||
|
return 'holesailInvite';
|
||||||
|
}
|
||||||
|
if (collapsed.length >= 80 && /^[0-9a-z]+$/i.test(collapsed) && !/^[0-9a-f]+$/i.test(collapsed)) {
|
||||||
|
return 'legacyAutopassInvite';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomGroupSecret() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomGroupId() {
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
HOLESAIL_INVITE_PREFIX,
|
||||||
|
deriveMacKey,
|
||||||
|
signCapability,
|
||||||
|
verifyCapability,
|
||||||
|
encodeHs1Invite,
|
||||||
|
decodeHs1Invite,
|
||||||
|
classifyInviteInput,
|
||||||
|
randomGroupSecret,
|
||||||
|
randomGroupId,
|
||||||
|
safeEqual,
|
||||||
|
b64url,
|
||||||
|
b64urlDecode
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const c = require('compact-encoding');
|
||||||
|
|
||||||
|
const encodings = {
|
||||||
|
valueEncoding: c.json,
|
||||||
|
requestEncoding: c.json,
|
||||||
|
responseEncoding: c.json
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { encodings };
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Persist sync group roster + secret (atomic JSON).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('bare-path');
|
||||||
|
const fs = require('bare-fs');
|
||||||
|
|
||||||
|
const GROUP_FILENAME = 'sync-group.json';
|
||||||
|
|
||||||
|
function atomicWriteJson(file, obj) {
|
||||||
|
const json = JSON.stringify(obj, null, 2);
|
||||||
|
const dir = path.dirname(file);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, json, 'utf8');
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} catch (_) {}
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupPath(storageDir) {
|
||||||
|
return path.join(storageDir, GROUP_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyGroup() {
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
groupId: null,
|
||||||
|
groupSecret: null,
|
||||||
|
peers: [],
|
||||||
|
revoked: [],
|
||||||
|
spentJtis: [],
|
||||||
|
lastInvite: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGroup(storageDir) {
|
||||||
|
const file = getGroupPath(storageDir);
|
||||||
|
if (!file || !fs.existsSync(file)) return emptyGroup();
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
if (!data || typeof data !== 'object') return emptyGroup();
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
groupId: data.groupId || null,
|
||||||
|
groupSecret: data.groupSecret || null,
|
||||||
|
peers: Array.isArray(data.peers) ? data.peers : [],
|
||||||
|
revoked: Array.isArray(data.revoked) ? data.revoked : [],
|
||||||
|
spentJtis: Array.isArray(data.spentJtis) ? data.spentJtis : [],
|
||||||
|
lastInvite: data.lastInvite || null
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return emptyGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGroup(storageDir, group) {
|
||||||
|
if (!storageDir) throw new Error('storageDir required');
|
||||||
|
atomicWriteJson(getGroupPath(storageDir), {
|
||||||
|
v: 1,
|
||||||
|
groupId: group.groupId || null,
|
||||||
|
groupSecret: group.groupSecret || null,
|
||||||
|
peers: Array.isArray(group.peers) ? group.peers : [],
|
||||||
|
revoked: Array.isArray(group.revoked) ? group.revoked : [],
|
||||||
|
spentJtis: Array.isArray(group.spentJtis) ? group.spentJtis.slice(-200) : [],
|
||||||
|
lastInvite: group.lastInvite || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGroup(storageDir) {
|
||||||
|
const file = getGroupPath(storageDir);
|
||||||
|
if (file && fs.existsSync(file)) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertPeer(group, peer) {
|
||||||
|
const publicKey = String(peer.publicKey || '').toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(publicKey)) return group;
|
||||||
|
const id = publicKey.slice(0, 12);
|
||||||
|
const next = { ...group, peers: [...(group.peers || [])] };
|
||||||
|
const idx = next.peers.findIndex((p) => String(p.publicKey || '').toLowerCase() === publicKey);
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
publicKey,
|
||||||
|
name: peer.name || null,
|
||||||
|
runtime: peer.runtime || null
|
||||||
|
};
|
||||||
|
if (idx >= 0) next.peers[idx] = { ...next.peers[idx], ...entry };
|
||||||
|
else next.peers.push(entry);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePeer(group, publicKeyHex) {
|
||||||
|
const pk = String(publicKeyHex || '').toLowerCase();
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
peers: (group.peers || []).filter((p) => String(p.publicKey || '').toLowerCase() !== pk)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRevoked(group, publicKeyHex) {
|
||||||
|
const pk = String(publicKeyHex || '').toLowerCase();
|
||||||
|
return (group.revoked || []).some((r) => String(r).toLowerCase() === pk || String(r.publicKey || '').toLowerCase() === pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJtiSpent(group, jti) {
|
||||||
|
if (!jti) return false;
|
||||||
|
return (group.spentJtis || []).includes(jti);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markJtiSpent(group, jti) {
|
||||||
|
if (!jti) return group;
|
||||||
|
const spentJtis = [...(group.spentJtis || [])];
|
||||||
|
if (!spentJtis.includes(jti)) spentJtis.push(jti);
|
||||||
|
return { ...group, spentJtis };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GROUP_FILENAME,
|
||||||
|
loadGroup,
|
||||||
|
saveGroup,
|
||||||
|
clearGroup,
|
||||||
|
emptyGroup,
|
||||||
|
upsertPeer,
|
||||||
|
removePeer,
|
||||||
|
isRevoked,
|
||||||
|
isJtiSpent,
|
||||||
|
markJtiSpent,
|
||||||
|
getGroupPath
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Stable HyperDHT identity for P2P sync (per storage root).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('bare-path');
|
||||||
|
const fs = require('bare-fs');
|
||||||
|
const b4a = require('b4a');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const DHT = require('hyperdht');
|
||||||
|
|
||||||
|
const IDENTITY_FILENAME = 'sync-identity.json';
|
||||||
|
|
||||||
|
function atomicWriteJson(file, obj) {
|
||||||
|
const json = JSON.stringify(obj, null, 2);
|
||||||
|
const dir = path.dirname(file);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, json, 'utf8');
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} catch (_) {}
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIdentityPath(storageDir) {
|
||||||
|
return path.join(storageDir, IDENTITY_FILENAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} storageDir
|
||||||
|
* @returns {{ seedHex: string, publicKeyHex: string, keyPair: { publicKey: Uint8Array, secretKey: Uint8Array }, peerId: string }}
|
||||||
|
*/
|
||||||
|
function loadOrCreateIdentity(storageDir) {
|
||||||
|
if (!storageDir) throw new Error('storageDir required');
|
||||||
|
const file = getIdentityPath(storageDir);
|
||||||
|
let seedHex = null;
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
if (data && typeof data.seedHex === 'string' && /^[0-9a-fA-F]{64}$/.test(data.seedHex)) {
|
||||||
|
seedHex = data.seedHex.toLowerCase();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!seedHex) {
|
||||||
|
seedHex = crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
const seed = b4a.from(seedHex, 'hex');
|
||||||
|
const keyPair = DHT.keyPair(seed);
|
||||||
|
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex');
|
||||||
|
atomicWriteJson(file, { seedHex, publicKeyHex, v: 1 });
|
||||||
|
return {
|
||||||
|
seedHex,
|
||||||
|
publicKeyHex,
|
||||||
|
keyPair,
|
||||||
|
peerId: publicKeyHex
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortPeerId(publicKeyHex) {
|
||||||
|
return String(publicKeyHex || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
IDENTITY_FILENAME,
|
||||||
|
loadOrCreateIdentity,
|
||||||
|
shortPeerId,
|
||||||
|
getIdentityPath
|
||||||
|
};
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/**
|
||||||
|
* HyperDHT mesh: each peer listens and dials every other peer in the group.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const DHT = require('hyperdht');
|
||||||
|
const b4a = require('b4a');
|
||||||
|
const { PeerSession } = require('./peer-session.js');
|
||||||
|
const { Methods, Pushes, PROTOCOL_VERSION } = require('./protocol.js');
|
||||||
|
const {
|
||||||
|
verifyCapability,
|
||||||
|
deriveMacKey
|
||||||
|
} = require('./crypto-auth.js');
|
||||||
|
const groupStore = require('./group-store.js');
|
||||||
|
|
||||||
|
const DIAL_RETRY_MS = 5000;
|
||||||
|
const HANDSHAKE_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
class SyncMesh {
|
||||||
|
/**
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} opts.storageDir
|
||||||
|
* @param {{ keyPair: any, publicKeyHex: string, peerId: string }} opts.identity
|
||||||
|
* @param {() => object} opts.getGroup
|
||||||
|
* @param {(g: object) => void} opts.setGroup
|
||||||
|
* @param {() => string} opts.getDeviceName
|
||||||
|
* @param {() => string} opts.getRuntime
|
||||||
|
* @param {() => object} opts.getLocalDoc
|
||||||
|
* @param {(doc: object, fromPeerId: string) => Promise<{ accepted: boolean, reason?: string }>} opts.onRemoteDoc
|
||||||
|
* @param {(fn: string, ...args: any[]) => void} [opts.log]
|
||||||
|
*/
|
||||||
|
constructor(opts) {
|
||||||
|
this.storageDir = opts.storageDir;
|
||||||
|
this.identity = opts.identity;
|
||||||
|
this.getGroup = opts.getGroup;
|
||||||
|
this.setGroup = opts.setGroup;
|
||||||
|
this.getDeviceName = opts.getDeviceName;
|
||||||
|
this.getRuntime = opts.getRuntime;
|
||||||
|
this.getLocalDoc = opts.getLocalDoc;
|
||||||
|
this.onRemoteDoc = opts.onRemoteDoc;
|
||||||
|
this.log = opts.log || (() => {});
|
||||||
|
|
||||||
|
this.dht = null;
|
||||||
|
this.server = null;
|
||||||
|
/** @type {Map<string, PeerSession>} */
|
||||||
|
this.sessions = new Map();
|
||||||
|
this._dialTimers = new Map();
|
||||||
|
this._running = false;
|
||||||
|
this._handlers = this._buildHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHandlers() {
|
||||||
|
return {
|
||||||
|
[Methods.handshake]: (args, session) => this._onHandshake(args, session),
|
||||||
|
[Methods.ping]: async () => ({ ok: true, t: Date.now() }),
|
||||||
|
[Methods.getState]: async (_args, session) => {
|
||||||
|
if (!session.authed) throw Object.assign(new Error('Not authenticated'), { code: 'UNAUTHORIZED' });
|
||||||
|
return { ok: true, doc: this.getLocalDoc() };
|
||||||
|
},
|
||||||
|
[Methods.pushState]: async (args, session) => {
|
||||||
|
if (!session.authed) throw Object.assign(new Error('Not authenticated'), { code: 'UNAUTHORIZED' });
|
||||||
|
const doc = args && args.doc;
|
||||||
|
if (!doc || typeof doc !== 'object') return { ok: false, accepted: false, error: 'Missing doc' };
|
||||||
|
const result = await this.onRemoteDoc(doc, session.id);
|
||||||
|
return { ok: true, accepted: !!result.accepted, reason: result.reason || null };
|
||||||
|
},
|
||||||
|
[Methods.listPeers]: async (_args, session) => {
|
||||||
|
if (!session.authed) throw Object.assign(new Error('Not authenticated'), { code: 'UNAUTHORIZED' });
|
||||||
|
const g = this.getGroup();
|
||||||
|
return { ok: true, peers: g.peers || [], groupId: g.groupId };
|
||||||
|
},
|
||||||
|
[Methods.leave]: async (_args, session) => {
|
||||||
|
if (!session.authed) throw Object.assign(new Error('Not authenticated'), { code: 'UNAUTHORIZED' });
|
||||||
|
let g = this.getGroup();
|
||||||
|
g = groupStore.removePeer(g, session.id);
|
||||||
|
this.setGroup(g);
|
||||||
|
this._dropSession(session.id);
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
[Methods.dissolve]: async (_args, session) => {
|
||||||
|
if (!session.authed) throw Object.assign(new Error('Not authenticated'), { code: 'UNAUTHORIZED' });
|
||||||
|
// Signal handled by façade via onRemoteDoc with syncGroupDisbanded
|
||||||
|
await this.onRemoteDoc({ syncGroupDisbanded: true, stateRevision: Number.MAX_SAFE_INTEGER, updatedAt: Date.now() }, session.id);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async _onHandshake(args, session) {
|
||||||
|
const group = this.getGroup();
|
||||||
|
if (!group.groupId || !group.groupSecret) {
|
||||||
|
throw Object.assign(new Error('No sync group'), { code: 'NO_GROUP' });
|
||||||
|
}
|
||||||
|
if (groupStore.isRevoked(group, session.id)) {
|
||||||
|
throw Object.assign(new Error('Peer revoked'), { code: 'PEER_REVOKED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const remotePk = String(session.id || '').toLowerCase();
|
||||||
|
const capability = args && args.capability;
|
||||||
|
const registered = (group.peers || []).some((p) => String(p.publicKey || '').toLowerCase() === remotePk);
|
||||||
|
|
||||||
|
if (capability) {
|
||||||
|
const macKey = deriveMacKey(group.groupSecret);
|
||||||
|
const verified = verifyCapability(macKey, capability, {
|
||||||
|
groupId: group.groupId,
|
||||||
|
peerId: remotePk,
|
||||||
|
allowSpentCheck: (jti) => !groupStore.isJtiSpent(group, jti)
|
||||||
|
});
|
||||||
|
if (!verified.ok) {
|
||||||
|
throw Object.assign(new Error(verified.error || 'Capability invalid'), { code: verified.code || 'CAPABILITY_INVALID' });
|
||||||
|
}
|
||||||
|
let g = group;
|
||||||
|
if (verified.payload.jti) g = groupStore.markJtiSpent(g, verified.payload.jti);
|
||||||
|
g = groupStore.upsertPeer(g, {
|
||||||
|
publicKey: remotePk,
|
||||||
|
name: (args && args.deviceName) || null,
|
||||||
|
runtime: (args && args.runtime) || null
|
||||||
|
});
|
||||||
|
// Ensure we are on the roster returned to the joiner
|
||||||
|
g = groupStore.upsertPeer(g, {
|
||||||
|
publicKey: this.identity.publicKeyHex,
|
||||||
|
name: this.getDeviceName(),
|
||||||
|
runtime: this.getRuntime()
|
||||||
|
});
|
||||||
|
this.setGroup(g);
|
||||||
|
session.authed = true;
|
||||||
|
session.deviceName = (args && args.deviceName) || null;
|
||||||
|
session.runtime = (args && args.runtime) || null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
peerId: this.identity.publicKeyHex,
|
||||||
|
groupId: g.groupId,
|
||||||
|
groupSecret: g.groupSecret,
|
||||||
|
peers: g.peers,
|
||||||
|
deviceName: this.getDeviceName(),
|
||||||
|
runtime: this.getRuntime(),
|
||||||
|
authMode: 'capability',
|
||||||
|
doc: this.getLocalDoc()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registered) {
|
||||||
|
let g = groupStore.upsertPeer(group, {
|
||||||
|
publicKey: remotePk,
|
||||||
|
name: (args && args.deviceName) || null,
|
||||||
|
runtime: (args && args.runtime) || null
|
||||||
|
});
|
||||||
|
this.setGroup(g);
|
||||||
|
session.authed = true;
|
||||||
|
session.deviceName = (args && args.deviceName) || null;
|
||||||
|
session.runtime = (args && args.runtime) || null;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
peerId: this.identity.publicKeyHex,
|
||||||
|
groupId: g.groupId,
|
||||||
|
peers: g.peers,
|
||||||
|
deviceName: this.getDeviceName(),
|
||||||
|
runtime: this.getRuntime(),
|
||||||
|
authMode: 'registered',
|
||||||
|
doc: this.getLocalDoc()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Object.assign(new Error('Unauthorized peer'), { code: 'UNAUTHORIZED' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this._running) return;
|
||||||
|
this._running = true;
|
||||||
|
if (!this.dht) this.dht = new DHT({ keyPair: this.identity.keyPair });
|
||||||
|
if (!this.server) {
|
||||||
|
this.server = this.dht.createServer();
|
||||||
|
this.server.on('connection', (socket) => this._onIncoming(socket));
|
||||||
|
this.server.on('error', (err) => {
|
||||||
|
this.log('Sync mesh server error:', err && err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.server.listen(this.identity.keyPair);
|
||||||
|
this.log('Sync mesh listening as', this.identity.publicKeyHex.slice(0, 12));
|
||||||
|
this.reconcileDials();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onIncoming(socket) {
|
||||||
|
const remoteHex = socket.remotePublicKey ? b4a.toString(socket.remotePublicKey, 'hex') : null;
|
||||||
|
if (!remoteHex) {
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (groupStore.isRevoked(this.getGroup(), remoteHex)) {
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Prefer keeping an existing healthy session
|
||||||
|
const existing = this.sessions.get(remoteHex);
|
||||||
|
if (existing && !existing.closed) {
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = new PeerSession(socket, {
|
||||||
|
localPublicKey: this.identity.keyPair.publicKey,
|
||||||
|
handlers: this._handlers,
|
||||||
|
onClose: (s) => this._dropSession(s.id)
|
||||||
|
});
|
||||||
|
void session.ready();
|
||||||
|
session.respondPush(Pushes.state, async (payload) => {
|
||||||
|
if (payload && payload.doc) await this.onRemoteDoc(payload.doc, remoteHex);
|
||||||
|
});
|
||||||
|
this.sessions.set(remoteHex, session);
|
||||||
|
this.log('Sync peer connected (inbound)', remoteHex.slice(0, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileDials() {
|
||||||
|
if (!this._running) return;
|
||||||
|
const group = this.getGroup();
|
||||||
|
const me = this.identity.publicKeyHex.toLowerCase();
|
||||||
|
const want = new Set(
|
||||||
|
(group.peers || [])
|
||||||
|
.map((p) => String(p.publicKey || '').toLowerCase())
|
||||||
|
.filter((pk) => pk && pk !== me && !groupStore.isRevoked(group, pk))
|
||||||
|
);
|
||||||
|
for (const pk of want) {
|
||||||
|
this._ensureDial(pk);
|
||||||
|
}
|
||||||
|
for (const [pk, session] of this.sessions) {
|
||||||
|
if (!want.has(pk) && pk !== me) {
|
||||||
|
session.close().catch(() => {});
|
||||||
|
this.sessions.delete(pk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureDial(publicKeyHex) {
|
||||||
|
const pk = publicKeyHex.toLowerCase();
|
||||||
|
const existing = this.sessions.get(pk);
|
||||||
|
if (existing && !existing.closed) return;
|
||||||
|
if (this._dialTimers.has(pk)) return;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this._dialTimers.delete(pk);
|
||||||
|
this._dialPeer(pk).catch(() => {
|
||||||
|
// retry later
|
||||||
|
if (this._running) {
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
this._dialTimers.delete(pk);
|
||||||
|
this._ensureDial(pk);
|
||||||
|
}, DIAL_RETRY_MS);
|
||||||
|
this._dialTimers.set(pk, t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 50);
|
||||||
|
this._dialTimers.set(pk, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _dialPeer(publicKeyHex) {
|
||||||
|
if (!this.dht || !this._running) return;
|
||||||
|
const pk = publicKeyHex.toLowerCase();
|
||||||
|
if (this.sessions.has(pk) && !this.sessions.get(pk).closed) return;
|
||||||
|
const remoteKey = b4a.from(pk, 'hex');
|
||||||
|
const socket = this.dht.connect(remoteKey);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
reject(new Error('Dial timeout'));
|
||||||
|
}
|
||||||
|
}, HANDSHAKE_TIMEOUT_MS);
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('open', () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Some versions emit connect instead of open
|
||||||
|
socket.on('connect', () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = new PeerSession(socket, {
|
||||||
|
localPublicKey: this.identity.keyPair.publicKey,
|
||||||
|
handlers: this._handlers,
|
||||||
|
onClose: (s) => {
|
||||||
|
this._dropSession(s.id);
|
||||||
|
if (this._running) this._ensureDial(s.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await session.ready();
|
||||||
|
this.sessions.set(pk, session);
|
||||||
|
|
||||||
|
const hs = await session.request(
|
||||||
|
Methods.handshake,
|
||||||
|
{
|
||||||
|
deviceName: this.getDeviceName(),
|
||||||
|
runtime: this.getRuntime(),
|
||||||
|
clientVersion: PROTOCOL_VERSION
|
||||||
|
},
|
||||||
|
HANDSHAKE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
if (!hs || !hs.ok) {
|
||||||
|
await session.close();
|
||||||
|
this.sessions.delete(pk);
|
||||||
|
throw new Error((hs && hs.error) || 'Handshake failed');
|
||||||
|
}
|
||||||
|
session.authed = true;
|
||||||
|
session.deviceName = hs.deviceName || null;
|
||||||
|
session.runtime = hs.runtime || null;
|
||||||
|
|
||||||
|
// Merge roster from peer
|
||||||
|
if (Array.isArray(hs.peers)) {
|
||||||
|
let g = this.getGroup();
|
||||||
|
for (const p of hs.peers) g = groupStore.upsertPeer(g, p);
|
||||||
|
this.setGroup(g);
|
||||||
|
this.reconcileDials();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hs.doc) {
|
||||||
|
await this.onRemoteDoc(hs.doc, pk);
|
||||||
|
}
|
||||||
|
|
||||||
|
session.respondPush(Pushes.state, async (payload) => {
|
||||||
|
if (payload && payload.doc) await this.onRemoteDoc(payload.doc, pk);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.log('Sync peer connected (outbound)', pk.slice(0, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dial inviter for first-time pair with capability; returns handshake result (includes groupSecret).
|
||||||
|
*/
|
||||||
|
async pairDial(inviterPublicKeyHex, capability) {
|
||||||
|
if (!this.dht) {
|
||||||
|
this.dht = new DHT({ keyPair: this.identity.keyPair });
|
||||||
|
}
|
||||||
|
const pk = inviterPublicKeyHex.toLowerCase();
|
||||||
|
const socket = this.dht.connect(b4a.from(pk, 'hex'));
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
socket.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
reject(new Error('Pair dial timeout'));
|
||||||
|
}
|
||||||
|
}, HANDSHAKE_TIMEOUT_MS);
|
||||||
|
const done = () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('open', done);
|
||||||
|
socket.on('connect', done);
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = new PeerSession(socket, {
|
||||||
|
localPublicKey: this.identity.keyPair.publicKey,
|
||||||
|
handlers: this._handlers,
|
||||||
|
onClose: (s) => this._dropSession(s.id)
|
||||||
|
});
|
||||||
|
await session.ready();
|
||||||
|
this.sessions.set(pk, session);
|
||||||
|
|
||||||
|
const hs = await session.request(
|
||||||
|
Methods.handshake,
|
||||||
|
{
|
||||||
|
capability,
|
||||||
|
deviceName: this.getDeviceName(),
|
||||||
|
runtime: this.getRuntime(),
|
||||||
|
clientVersion: PROTOCOL_VERSION
|
||||||
|
},
|
||||||
|
HANDSHAKE_TIMEOUT_MS
|
||||||
|
);
|
||||||
|
if (!hs || !hs.ok || !hs.groupSecret) {
|
||||||
|
await session.close();
|
||||||
|
this.sessions.delete(pk);
|
||||||
|
throw new Error((hs && hs.error) || 'Pair handshake failed');
|
||||||
|
}
|
||||||
|
session.authed = true;
|
||||||
|
session.deviceName = hs.deviceName || null;
|
||||||
|
session.runtime = hs.runtime || null;
|
||||||
|
session.respondPush(Pushes.state, async (payload) => {
|
||||||
|
if (payload && payload.doc) await this.onRemoteDoc(payload.doc, pk);
|
||||||
|
});
|
||||||
|
return hs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastPushState(doc) {
|
||||||
|
const jobs = [];
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.closed || !session.authed) continue;
|
||||||
|
jobs.push(
|
||||||
|
session
|
||||||
|
.request(Methods.pushState, { doc })
|
||||||
|
.catch(() => {
|
||||||
|
try {
|
||||||
|
session.event(Pushes.state, { doc });
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastDissolve() {
|
||||||
|
const jobs = [];
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.closed || !session.authed) continue;
|
||||||
|
jobs.push(session.request(Methods.dissolve, {}).catch(() => {}));
|
||||||
|
}
|
||||||
|
await Promise.all(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async broadcastLeave() {
|
||||||
|
const jobs = [];
|
||||||
|
for (const session of this.sessions.values()) {
|
||||||
|
if (session.closed || !session.authed) continue;
|
||||||
|
jobs.push(session.request(Methods.leave, {}).catch(() => {}));
|
||||||
|
}
|
||||||
|
await Promise.all(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedPeerIds() {
|
||||||
|
const out = [];
|
||||||
|
for (const [id, s] of this.sessions) {
|
||||||
|
if (!s.closed && s.authed) out.push(id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dropSession(id) {
|
||||||
|
const pk = String(id || '').toLowerCase();
|
||||||
|
const s = this.sessions.get(pk);
|
||||||
|
if (s) {
|
||||||
|
this.sessions.delete(pk);
|
||||||
|
s.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
this._running = false;
|
||||||
|
for (const t of this._dialTimers.values()) clearTimeout(t);
|
||||||
|
this._dialTimers.clear();
|
||||||
|
for (const s of this.sessions.values()) {
|
||||||
|
try {
|
||||||
|
await s.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
this.sessions.clear();
|
||||||
|
if (this.server) {
|
||||||
|
try {
|
||||||
|
await this.server.close();
|
||||||
|
} catch (_) {}
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
if (this.dht) {
|
||||||
|
try {
|
||||||
|
await this.dht.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
this.dht = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SyncMesh };
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* ProtomuxRPC session on a HyperDHT secret stream (inbound or outbound).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ProtomuxRPC = require('protomux-rpc');
|
||||||
|
const b4a = require('b4a');
|
||||||
|
const { PROTOCOL, Methods } = require('./protocol.js');
|
||||||
|
const { encodings } = require('./encodings.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protomux channel id must be identical on both ends of the stream.
|
||||||
|
* Use the lexicographically smaller of the two public keys.
|
||||||
|
*/
|
||||||
|
function sharedChannelId(localPublicKey, remotePublicKey) {
|
||||||
|
if (!remotePublicKey) return localPublicKey;
|
||||||
|
const a = b4a.toString(localPublicKey, 'hex');
|
||||||
|
const b = b4a.toString(remotePublicKey, 'hex');
|
||||||
|
return a <= b ? localPublicKey : remotePublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerSession {
|
||||||
|
/**
|
||||||
|
* @param {import('stream').Duplex} stream
|
||||||
|
* @param {{ localPublicKey: Uint8Array, onClose?: (s: PeerSession) => void, handlers?: object }} opts
|
||||||
|
*/
|
||||||
|
constructor(stream, opts = {}) {
|
||||||
|
this.stream = stream;
|
||||||
|
this.remotePublicKey = stream.remotePublicKey || null;
|
||||||
|
this.id = this.remotePublicKey ? b4a.toString(this.remotePublicKey, 'hex') : `anon-${Date.now()}`;
|
||||||
|
this.shortId = this.id.slice(0, 12);
|
||||||
|
this.closed = false;
|
||||||
|
this.authed = false;
|
||||||
|
this.deviceName = null;
|
||||||
|
this.runtime = null;
|
||||||
|
this.onClose = opts.onClose || null;
|
||||||
|
this._handlers = opts.handlers || {};
|
||||||
|
|
||||||
|
this.rpc = new ProtomuxRPC(stream, {
|
||||||
|
id: sharedChannelId(opts.localPublicKey, this.remotePublicKey),
|
||||||
|
protocol: PROTOCOL,
|
||||||
|
...encodings
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rpc.on('close', () => this._handleClose());
|
||||||
|
this.rpc.on('destroy', () => this._handleClose());
|
||||||
|
stream.on('close', () => this._handleClose());
|
||||||
|
stream.on('error', () => this._handleClose());
|
||||||
|
|
||||||
|
this._registerHandlers();
|
||||||
|
this._ready = typeof this.rpc.fullyOpened === 'function' ? this.rpc.fullyOpened().catch(() => {}) : Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ready() {
|
||||||
|
await this._ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerHandlers() {
|
||||||
|
const h = this._handlers;
|
||||||
|
for (const method of Object.values(Methods)) {
|
||||||
|
if (typeof h[method] !== 'function') continue;
|
||||||
|
this.rpc.respond(method, encodings, async (args) => {
|
||||||
|
return h[method](args || {}, this);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method, args = {}, timeoutMs = 30000) {
|
||||||
|
if (this.closed || !this.rpc) throw new Error('Session closed');
|
||||||
|
return this.rpc.request(method, args, encodings, { timeout: timeoutMs });
|
||||||
|
}
|
||||||
|
|
||||||
|
event(name, data) {
|
||||||
|
if (this.closed || !this.rpc) return;
|
||||||
|
try {
|
||||||
|
this.rpc.event(name, data, encodings);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a fire-and-forget push handler (protomux-rpc uses respond for events too). */
|
||||||
|
respondPush(name, fn) {
|
||||||
|
if (!this.rpc) return;
|
||||||
|
this.rpc.respond(name, encodings, async (args) => {
|
||||||
|
await fn(args || {});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleClose() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
if (typeof this.onClose === 'function') {
|
||||||
|
try {
|
||||||
|
this.onClose(this);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.closed) return;
|
||||||
|
this.closed = true;
|
||||||
|
try {
|
||||||
|
if (this.rpc) await this.rpc.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
if (this.stream) this.stream.destroy();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PeerSession };
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Holesail P2P sync protocol constants (protomux-rpc over HyperDHT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PROTOCOL = 'holesail/sync';
|
||||||
|
const PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
const Methods = Object.freeze({
|
||||||
|
handshake: 'handshake',
|
||||||
|
ping: 'ping',
|
||||||
|
getState: 'getState',
|
||||||
|
pushState: 'pushState',
|
||||||
|
listPeers: 'listPeers',
|
||||||
|
leave: 'leave',
|
||||||
|
dissolve: 'dissolve'
|
||||||
|
});
|
||||||
|
|
||||||
|
const Pushes = Object.freeze({
|
||||||
|
state: 'push:state',
|
||||||
|
peers: 'push:peers'
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
PROTOCOL,
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
Methods,
|
||||||
|
Pushes
|
||||||
|
};
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
/**
|
||||||
|
* Build / LWW-compare / apply sync documents to local tunnels + state.json.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('bare-path');
|
||||||
|
const fs = require('bare-fs');
|
||||||
|
|
||||||
|
const STATE_FILENAME = 'state.json';
|
||||||
|
|
||||||
|
function atomicWriteStateFile(file, obj) {
|
||||||
|
const json = JSON.stringify(obj, null, 2);
|
||||||
|
const dir = path.dirname(file);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const tmp = file + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, json, 'utf8');
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(file);
|
||||||
|
} catch (_) {}
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeDeviceMaps(base, overlay) {
|
||||||
|
const out = base && typeof base === 'object' ? { ...base } : {};
|
||||||
|
if (overlay && typeof overlay === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(overlay)) {
|
||||||
|
if (v != null && v !== '') out[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStateRevisionFromDisk(storageDir) {
|
||||||
|
const statePath = path.join(storageDir, STATE_FILENAME);
|
||||||
|
if (!fs.existsSync(statePath)) return 0;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
return typeof data.stateRevision === 'number' ? data.stateRevision : 0;
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUpdatedAtFromDisk(storageDir) {
|
||||||
|
const statePath = path.join(storageDir, STATE_FILENAME);
|
||||||
|
if (!fs.existsSync(statePath)) return 0;
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
return typeof data.updatedAt === 'number' ? data.updatedAt : 0;
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStateRevision(storageDir) {
|
||||||
|
return Math.max(readStateRevisionFromDisk(storageDir), 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareDocs(a, b) {
|
||||||
|
const ar = typeof a.stateRevision === 'number' ? a.stateRevision : 0;
|
||||||
|
const br = typeof b.stateRevision === 'number' ? b.stateRevision : 0;
|
||||||
|
if (ar !== br) return ar - br;
|
||||||
|
const at = typeof a.updatedAt === 'number' ? a.updatedAt : 0;
|
||||||
|
const bt = typeof b.updatedAt === 'number' ? b.updatedAt : 0;
|
||||||
|
if (at !== bt) return at - bt;
|
||||||
|
const aid = String(a.deviceId || '');
|
||||||
|
const bid = String(b.deviceId || '');
|
||||||
|
if (aid === bid) return 0;
|
||||||
|
return aid < bid ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the wire sync document from live holesail-manager state.
|
||||||
|
*/
|
||||||
|
function buildSyncDocument(holesailManager, opts = {}) {
|
||||||
|
const live =
|
||||||
|
holesailManager && typeof holesailManager.getStateSnapshot === 'function'
|
||||||
|
? holesailManager.getStateSnapshot()
|
||||||
|
: {};
|
||||||
|
const doc = { ...live };
|
||||||
|
if (holesailManager && typeof holesailManager.getSettings === 'function' && !holesailManager.getSettings().syncServers) {
|
||||||
|
delete doc.servers;
|
||||||
|
}
|
||||||
|
if (opts.deviceId) {
|
||||||
|
doc.deviceId = opts.deviceId;
|
||||||
|
doc.deviceNames = mergeDeviceMaps(doc.deviceNames, {
|
||||||
|
[opts.deviceId.slice(0, 12)]: opts.deviceName || opts.deviceId.slice(0, 12)
|
||||||
|
});
|
||||||
|
doc.deviceRuntimes = mergeDeviceMaps(doc.deviceRuntimes, {
|
||||||
|
[opts.deviceId.slice(0, 12)]: opts.runtime || 'unknown'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof opts.stateRevision === 'number') doc.stateRevision = opts.stateRevision;
|
||||||
|
if (typeof opts.updatedAt === 'number') doc.updatedAt = opts.updatedAt;
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist full state.json (keep local servers when syncServers is off).
|
||||||
|
*/
|
||||||
|
function writeFullState(storageDir, holesailManager, doc) {
|
||||||
|
const statePath = path.join(storageDir, STATE_FILENAME);
|
||||||
|
const live =
|
||||||
|
holesailManager && typeof holesailManager.getStateSnapshot === 'function'
|
||||||
|
? holesailManager.getStateSnapshot()
|
||||||
|
: {};
|
||||||
|
const onDisk = { ...doc };
|
||||||
|
const syncServers = !!(holesailManager && holesailManager.getSettings && holesailManager.getSettings().syncServers);
|
||||||
|
if (!syncServers || !Array.isArray(onDisk.servers)) {
|
||||||
|
onDisk.servers = live.servers || [];
|
||||||
|
onDisk.nextServerId = live.nextServerId != null ? live.nextServerId : onDisk.nextServerId;
|
||||||
|
}
|
||||||
|
atomicWriteStateFile(statePath, onDisk);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a remote sync document incrementally (reuse holesail-manager APIs).
|
||||||
|
* @returns {Promise<boolean>} true if applied
|
||||||
|
*/
|
||||||
|
async function applyRemoteDocument(holesailManager, storageDir, snapshot, opts = {}) {
|
||||||
|
if (!holesailManager || !snapshot || typeof snapshot !== 'object') return false;
|
||||||
|
if (snapshot.syncGroupDisbanded === true) {
|
||||||
|
if (typeof opts.onDissolve === 'function') await opts.onDissolve();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStateSnapshot = holesailManager.getStateSnapshot;
|
||||||
|
const applySnapshotData = holesailManager.applySnapshotData;
|
||||||
|
const setStateSaveSuppressed = holesailManager.setStateSaveSuppressed;
|
||||||
|
if (typeof getStateSnapshot !== 'function' || typeof applySnapshotData !== 'function' || typeof setStateSaveSuppressed !== 'function') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStateSaveSuppressed(true);
|
||||||
|
try {
|
||||||
|
const current = getStateSnapshot();
|
||||||
|
const settings = holesailManager.getSettings ? holesailManager.getSettings() : {};
|
||||||
|
const syncServers = settings.syncServers === true;
|
||||||
|
const hasServers = Array.isArray(snapshot.servers);
|
||||||
|
const hasVhosts = Array.isArray(snapshot.virtualHosts);
|
||||||
|
const hasSvc = Array.isArray(snapshot.serviceTunnels);
|
||||||
|
const snapServers = syncServers && hasServers ? snapshot.servers : null;
|
||||||
|
const snapVhosts = hasVhosts ? snapshot.virtualHosts : null;
|
||||||
|
const snapSvc = hasSvc ? snapshot.serviceTunnels : null;
|
||||||
|
|
||||||
|
snapshot.deviceNames = mergeDeviceMaps(current.deviceNames, snapshot.deviceNames);
|
||||||
|
snapshot.deviceRuntimes = mergeDeviceMaps(current.deviceRuntimes, snapshot.deviceRuntimes);
|
||||||
|
if (opts.localDeviceId) {
|
||||||
|
const sid = opts.localDeviceId.slice(0, 12);
|
||||||
|
if (opts.localDeviceName) snapshot.deviceNames[sid] = opts.localDeviceName;
|
||||||
|
if (opts.localRuntime) snapshot.deviceRuntimes[sid] = opts.localRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = snapVhosts ? new Map(snapVhosts.map((v) => [String(v.hostname || '').toLowerCase(), v])) : null;
|
||||||
|
const snapServersById = snapServers ? new Map(snapServers.map((s) => [s.id, s])) : null;
|
||||||
|
const snapSvcById = snapSvc ? new Map(snapSvc.map((t) => [t.id, t])) : null;
|
||||||
|
|
||||||
|
const snapshotToApply = { ...snapshot };
|
||||||
|
if (!syncServers || !hasServers) {
|
||||||
|
delete snapshotToApply.servers;
|
||||||
|
snapshotToApply.nextServerId = current.nextServerId;
|
||||||
|
}
|
||||||
|
applySnapshotData(snapshotToApply);
|
||||||
|
|
||||||
|
if (snapVhostsByHost) {
|
||||||
|
for (const [hostname] of curVhosts) {
|
||||||
|
if (!snapVhostsByHost.has(hostname)) {
|
||||||
|
await holesailManager.removeVirtualHost({ hostname }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapServersById) {
|
||||||
|
for (const [id] of curServers) {
|
||||||
|
if (!snapServersById.has(id)) {
|
||||||
|
await holesailManager.stopServer({ serverId: id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapSvcById) {
|
||||||
|
for (const [id] of curSvc) {
|
||||||
|
if (!snapSvcById.has(id)) {
|
||||||
|
await holesailManager.stopServiceTunnel({ tunnelId: id }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapVhosts) {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapServers) {
|
||||||
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (snapSvc) {
|
||||||
|
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 live = getStateSnapshot();
|
||||||
|
const toWrite = {
|
||||||
|
...snapshot,
|
||||||
|
...live,
|
||||||
|
deviceNames: snapshot.deviceNames,
|
||||||
|
deviceRuntimes: snapshot.deviceRuntimes,
|
||||||
|
stateRevision: typeof snapshot.stateRevision === 'number' ? snapshot.stateRevision : readStateRevisionFromDisk(storageDir),
|
||||||
|
updatedAt: typeof snapshot.updatedAt === 'number' ? snapshot.updatedAt : Date.now(),
|
||||||
|
deviceId: snapshot.deviceId || undefined
|
||||||
|
};
|
||||||
|
if (!syncServers) {
|
||||||
|
toWrite.servers = live.servers || current.servers || [];
|
||||||
|
toWrite.nextServerId = live.nextServerId != null ? live.nextServerId : current.nextServerId;
|
||||||
|
}
|
||||||
|
atomicWriteStateFile(path.join(storageDir, STATE_FILENAME), toWrite);
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
setStateSaveSuppressed(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STATE_FILENAME,
|
||||||
|
mergeDeviceMaps,
|
||||||
|
readStateRevisionFromDisk,
|
||||||
|
readUpdatedAtFromDisk,
|
||||||
|
nextStateRevision,
|
||||||
|
compareDocs,
|
||||||
|
buildSyncDocument,
|
||||||
|
writeFullState,
|
||||||
|
applyRemoteDocument,
|
||||||
|
atomicWriteStateFile
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user