Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
CI / test (push) Successful in 9m58s

Add tunnel persistence and container one-click tunnels with local
Holesail client bind, Swarm services/nodes/tasks view, GitOps stack
sync from git, deploy wizard step chrome, virtualized container lists,
and structure regression tests. Mark Tracks A–D and optional future done.
This commit is contained in:
2026-07-10 22:53:30 -04:00
parent 573298b836
commit 02f25e0981
26 changed files with 2458 additions and 121 deletions
+1
View File
@@ -114,6 +114,7 @@ Defined in `shared/protocol.js` (`PROTOCOL_VERSION` negotiated on connect). See
4. **Secrets** — Back up `SERVER_SEED`. Rotating seed changes the public key; clients must reconnect. 4. **Secrets** — Back up `SERVER_SEED`. Rotating seed changes the public key; clients must reconnect.
5. **Pear** — Stage/release the desktop app separately from the control-plane server. 5. **Pear** — Stage/release the desktop app separately from the control-plane server.
6. **Security** — Connections are E2E encrypted (Noise). Rate limits apply per peer. RPC methods are gated by role (`viewer` / `operator` / `admin`; default admin). Privileged actions append to an audit log. Docker CLI is allow-listed to read-only style commands. Optional env: `PEARDOCK_DEFAULT_ROLE`, `PEARDOCK_ADMIN_KEYS`, `PEARDOCK_BROWSE_ROOTS`, `PEARDOCK_AUDIT`. 6. **Security** — Connections are E2E encrypted (Noise). Rate limits apply per peer. RPC methods are gated by role (`viewer` / `operator` / `admin`; default admin). Privileged actions append to an audit log. Docker CLI is allow-listed to read-only style commands. Optional env: `PEARDOCK_DEFAULT_ROLE`, `PEARDOCK_ADMIN_KEYS`, `PEARDOCK_BROWSE_ROOTS`, `PEARDOCK_AUDIT`.
7. **Holesail tunnels (optional)** — Set `ENABLE_HOLESAIL=1` to expose published container/host ports over [Holesail](https://github.com/holesail/holesail) `hs://` keys (separate from control-plane RPC). See [docs/HOLESAIL.md](docs/HOLESAIL.md). Note: the `holesail` dependency is AGPL-3.0.
Example systemd unit: Example systemd unit:
+37 -17
View File
@@ -11,9 +11,11 @@
| **A — Platform / Engine / security (Phases 06)** | ✅ Complete | | **A — Platform / Engine / security (Phases 06)** | ✅ Complete |
| **B — Product UI & operator experience (Phases 712)** | ✅ Complete | | **B — Product UI & operator experience (Phases 712)** | ✅ Complete |
| **C — UX polish & full-app experience (Phases 1316)** | ✅ Complete | | **C — UX polish & full-app experience (Phases 1316)** | ✅ Complete |
| **D — Holesail tunnels** | ✅ Complete |
| **Optional future items** | ✅ Complete |
```bash ```bash
npm test # 81+ tests green npm test # full suite green
npm run server npm run server
npm run dev npm run dev
``` ```
@@ -30,7 +32,10 @@ npm run dev
| Error model / quiet RPC noise | `client/errors.js` | | Error model / quiet RPC noise | `client/errors.js` |
| Job stepper + live log follow | `client/jobs.js`, `#job-drawer` | | Job stepper + live log follow | `client/jobs.js`, `#job-drawer` |
| Ops shell | `ui/ops-app.js`, `ui/components.js`, `ui/ops.css` | | Ops shell | `ui/ops-app.js`, `ui/components.js`, `ui/ops.css` |
| Toasts | `libs/uiUtils.js`, `#toast-stack` | | Feedback | `libs/uiUtils.js` (job tray primary; toast opt-in) |
| Holesail tunnels | `server/services/holesail-tunnels.js`, `docs/HOLESAIL.md` |
| GitOps stack sync | `server/utils/gitops.js`, `syncStackFromGit` |
| Virtualized container list | `renderContainers` windowing in `app.js` |
--- ---
@@ -61,7 +66,7 @@ npm run dev
## Phase 13 — Trustworthy feedback ✅ ## Phase 13 — Trustworthy feedback ✅
- [x] Floating toast stack + tray history - [x] Floating toast stack + tray history (toasts opt-in; job tray primary)
- [x] `presentError` single path, unwrap REQUEST_ERROR causes - [x] `presentError` single path, unwrap REQUEST_ERROR causes
- [x] Quiet/dedupe background RPC noise - [x] Quiet/dedupe background RPC noise
- [x] Destructive confirm respects Settings - [x] Destructive confirm respects Settings
@@ -90,8 +95,30 @@ npm run dev
- [x] First-connect “what next” checklist - [x] First-connect “what next” checklist
- [x] Job tray live log follows output - [x] Job tray live log follows output
- [x] Modern dashboard redesign - [x] Modern dashboard redesign
- [ ] Optional: Swarm screens when `ENABLE_SWARM=1` (server ready; deferred) - [x] Swarm screens when `ENABLE_SWARM=1` (`#swarm-view` services/nodes/tasks)
- [ ] Optional: virtualized tables for 500+ rows (deferred) - [x] Virtualized tables for 500+ rows (container list windowing >80 rows)
---
# Track D — Holesail tunnels ✅
- [x] Design: Holesail as L4 data plane beside protomux control plane (`docs/HOLESAIL.md`)
- [x] Server tunnel manager (`server/services/holesail-tunnels.js`) + RPC handlers
- [x] Feature flag `ENABLE_HOLESAIL=1`, host allowlist, max tunnels, audit create/close
- [x] Client **Tunnels** view + API helpers
- [x] Container-details “Tunnel this port” one-click
- [x] Pear-side HolesailClient local bind + open browser (`client/holesailLocal.js`)
- [x] Persist/recreate tunnels across server restart (`peardock-tunnels.json`)
---
## Former “optional future” items ✅
- [x] Multi-step container deploy wizard chrome (step strip on deploy form)
- [x] Visual structure regression suite (`test/visual-structure.test.js`)
- [x] GitOps stack sync (`syncStackFromGit` + UI in deploy-stack modal)
- [x] Virtualized 500+ row tables (containers)
- [x] Swarm-specific screens when `ENABLE_SWARM=1`
--- ---
@@ -106,8 +133,10 @@ npm run dev
| Dynamism | Warm cache, live events, dashboard, auto-refresh | | Dynamism | Warm cache, live events, dashboard, auto-refresh |
| Offline | Banner + reconnect | | Offline | Banner + reconnect |
| Multi-peer | Fleet + active chip + disk peer cache | | Multi-peer | Fleet + active chip + disk peer cache |
| Feedback | Toast stack + notification tray | | Feedback | Job tray + notification history |
| Templates | Multi-list merge + dedupe | | Templates | Multi-list merge + dedupe |
| Port sharing | Holesail tunnels + local client |
| GitOps | Shallow clone + compose deploy |
--- ---
@@ -121,17 +150,8 @@ npm run dev
6. ✅ Create network auto-subnet; multi template lists 6. ✅ Create network auto-subnet; multi template lists
7. ✅ Peers persist across restarts (`peers.json`) 7. ✅ Peers persist across restarts (`peers.json`)
8. ✅ Automated tests green (`npm test`) 8. ✅ Automated tests green (`npm test`)
9. ✅ Optional roadmap items completed
--- ---
## Optional future (not blocking roadmap complete) **Roadmap closed.** Tracks AD and previously optional items are implemented.
- Full multi-step container wizard redesign
- Visual regression suite
- GitOps stack sync
- Virtualized 500+ row tables
- Swarm-specific screens when `ENABLE_SWARM=1`
---
**Roadmap closed.** Tracks A, B, and C are implemented; optional items remain continuous improvement.
+241 -61
View File
@@ -789,6 +789,8 @@ function navigateToView(viewName, opts = {}) {
loadVolumes(); loadVolumes();
} else if (viewName === 'stacks') { } else if (viewName === 'stacks') {
loadStacks(); loadStacks();
} else if (viewName === 'swarm') {
window.peardockOps?.loadSwarmView?.();
} else if (viewName === 'deploy') { } else if (viewName === 'deploy') {
loadDeployView(); loadDeployView();
} else if (viewName === 'fleet') { } else if (viewName === 'fleet') {
@@ -799,6 +801,8 @@ function navigateToView(viewName, opts = {}) {
window.peardockOps?.loadEventsView?.(); window.peardockOps?.loadEventsView?.();
} else if (viewName === 'host') { } else if (viewName === 'host') {
window.peardockOps?.loadHostView?.(); window.peardockOps?.loadHostView?.();
} else if (viewName === 'tunnels') {
window.peardockOps?.loadTunnelsView?.();
} else if (viewName === 'settings') { } else if (viewName === 'settings') {
window.peardockOps?.loadSettingsView?.(); window.peardockOps?.loadSettingsView?.();
} }
@@ -2022,11 +2026,9 @@ function setupDeployStackHandler() {
} }
showStatusIndicator(`Deploying stack "${stackName}"...`); showStatusIndicator(`Deploying stack "${stackName}"...`);
sendCommand('deployStack', { stackName, composeContent });
try { try {
const response = await waitForPeerResponse(`Stack "${stackName}" deployed successfully`); const response = await manager.request(Methods.deployStack, { stackName, composeContent });
showAlert('success', response.message); showAlert('success', response?.message || `Stack "${stackName}" deployed`);
// Close modal and reset form // Close modal and reset form
const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal')); const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal'));
@@ -2038,12 +2040,48 @@ function setupDeployStackHandler() {
loadStacks(); loadStacks();
} catch (error) { } catch (error) {
console.error('[ERROR] Failed to deploy stack:', error); console.error('[ERROR] Failed to deploy stack:', error);
showAlert('danger', error.message || 'Failed to deploy stack'); presentError(error, 'deployStack', { showAlert });
} finally { } finally {
hideStatusIndicator(); hideStatusIndicator();
} }
}); });
} }
document.getElementById('stack-gitops-btn')?.addEventListener('click', async () => {
const stackName = document.getElementById('stack-name')?.value?.trim();
const repoUrl = document.getElementById('stack-git-url')?.value?.trim();
const ref = document.getElementById('stack-git-ref')?.value?.trim() || 'main';
const composePath =
document.getElementById('stack-git-path')?.value?.trim() || 'docker-compose.yml';
if (!stackName || !repoUrl) {
showAlert('warning', 'Stack name and repository URL are required for GitOps sync');
return;
}
showStatusIndicator(`GitOps sync ${stackName} from ${repoUrl}`);
try {
const response = await manager.request(Methods.syncStackFromGit, {
stackName,
repoUrl,
ref,
composePath,
});
if (response?.composeContent == null && response?.success) {
// deployed server-side
}
showAlert(
'success',
response?.message || `Stack "${stackName}" synced from git`
);
const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal'));
if (modal) modal.hide();
navigateToView('stacks');
loadStacks();
} catch (error) {
presentError(error, 'syncStackFromGit', { showAlert });
} finally {
hideStatusIndicator();
}
});
} }
// Subscription for volumes store to auto-update UI // Subscription for volumes store to auto-update UI
@@ -2462,7 +2500,7 @@ function populateContainerDetails(config, container) {
// Stats Tab - will be updated by stats updates // Stats Tab - will be updated by stats updates
updateContainerDetailsStats(container); updateContainerDetailsStats(container);
// Attach copy button event listeners after content is populated // Attach copy / tunnel button event listeners after content is populated
setTimeout(() => { setTimeout(() => {
document.querySelectorAll('.copy-btn').forEach(btn => { document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {
@@ -2472,6 +2510,61 @@ function populateContainerDetails(config, container) {
} }
}); });
}); });
document.querySelectorAll('.action-tunnel-port').forEach((btn) => {
btn.addEventListener('click', async function () {
const containerId = this.getAttribute('data-container-id');
const containerPort = Number(this.getAttribute('data-container-port'));
const hostPort = Number(this.getAttribute('data-host-port'));
const protocol = this.getAttribute('data-protocol') || 'tcp';
const containerName = this.getAttribute('data-container-name') || '';
if (!containerId || !containerPort) {
showAlert('warning', 'Missing container port info for tunnel');
return;
}
try {
this.disabled = true;
const res = await manager.request(Methods.createTunnel, {
containerId,
containerPort,
protocol,
name: containerName
? `${containerName}:${containerPort}`
: `port-${hostPort || containerPort}`,
secure: true,
});
const url = res?.tunnel?.url;
if (url && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(url);
} catch {
// ignore
}
}
showAlert(
'success',
url
? `Holesail tunnel created — URL copied. Connect with: npx holesail '${url.slice(0, 24)}…'`
: 'Holesail tunnel created'
);
if (typeof window.peardockOps?.loadTunnelsView === 'function') {
// warm tunnels view cache
}
// Offer local connect
if (url && window.peardockOps?.connectLocalTunnel) {
const open = window.confirm(
'Tunnel created. Open a local Holesail client and browser now?'
);
if (open) {
await window.peardockOps.connectLocalTunnel(url, { openBrowser: true });
}
}
} catch (err) {
presentError(err, 'createTunnel', { showAlert });
} finally {
this.disabled = false;
}
});
});
}, 100); }, 100);
} }
@@ -3126,9 +3219,21 @@ function populateNetworkingTab(config) {
<td><span class="protocol-badge ${binding.protocol}">${binding.protocol}</span></td> <td><span class="protocol-badge ${binding.protocol}">${binding.protocol}</span></td>
<td> <td>
${binding.hostPort ? ` ${binding.hostPort ? `
<button class="copy-btn" data-copy="${binding.hostIp || '0.0.0.0'}:${binding.hostPort}" title="Copy host address"> <div class="btn-group btn-group-sm">
<button class="copy-btn btn btn-sm btn-outline-secondary" data-copy="${binding.hostIp || '0.0.0.0'}:${binding.hostPort}" title="Copy host address">
<i class="fas fa-copy"></i> <i class="fas fa-copy"></i>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary action-tunnel-port"
data-min-role="operator"
data-container-id="${config.Id || ''}"
data-container-name="${(config.Name || '').replace(/^\//, '')}"
data-container-port="${binding.containerPort}"
data-host-port="${binding.hostPort}"
data-protocol="${binding.protocol || 'tcp'}"
title="Tunnel this port via Holesail">
<i class="fas fa-network-wired"></i>
</button>
</div>
` : '<span style="color: var(--text-muted);">-</span>'} ` : '<span style="color: var(--text-muted);">-</span>'}
</td> </td>
</tr> </tr>
@@ -6244,59 +6349,39 @@ function formatImageName(imageName) {
} }
// Render the container list with optimized DOM manipulation // Render the container list with optimized DOM manipulation
function renderContainers(containers, topicId) { /** Virtualized container table state (windowed rows for 500+ containers) */
if (!window.activePeer || !connections[topicId] || window.activePeer !== connections[topicId].peer) { const containerVirt = {
console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.'); rows: [],
return; topicId: null,
} scrollEl: null,
bound: false,
rowHeight: 52,
overscan: 8,
};
console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`); function buildContainerRow(container) {
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown';
// Get current container IDs before clearing
const currentContainerIds = new Set(containers.map(c => c.Id));
// Clean up smoothedStats for containers that no longer exist
Object.keys(smoothedStats).forEach(containerId => {
if (!currentContainerIds.has(containerId)) {
delete smoothedStats[containerId];
}
});
// Filter and sort containers
const filteredContainers = filterAndSortContainers(containers);
// Use DocumentFragment for batch DOM updates
const fragment = document.createDocumentFragment();
const listElement = domCache.containerList || containerList;
if (!filteredContainers.length) {
const hasAny = (containers || []).length > 0;
listElement.innerHTML = emptyTableRow(
8,
hasAny ? 'No matching containers' : 'No containers yet',
hasAny ? 'Clear filters or search to see more.' : 'Deploy a container from the Deploy view.'
);
return;
}
filteredContainers.forEach((container) => {
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown'; // Avoid undefined Names
const image = formatImageName(container.Image || '-'); const image = formatImageName(container.Image || '-');
const containerId = container.Id; const containerId = container.Id;
const ipAddress = container.ipAddress || 'No IP Assigned'; const ipAddress = container.ipAddress || 'No IP Assigned';
if (ipAddress === 'No IP Assigned') { if (ipAddress === 'No IP Assigned') {
console.warn(`[WARN] IP address missing for container ${container.Id}. Retrying...`);
sendCommand('inspectContainer', { id: container.Id }); sendCommand('inspectContainer', { id: container.Id });
} }
const row = document.createElement('tr'); const row = document.createElement('tr');
row.dataset.containerId = containerId; // Store container ID for reference row.dataset.containerId = containerId;
const state = container.State || 'Unknown'; const state = container.State || 'Unknown';
const stateLower = state.toLowerCase(); const stateLower = state.toLowerCase();
const statusClass = stateLower === 'running' ? 'status-running' : const statusClass =
stateLower === 'exited' || stateLower === 'stopped' ? 'status-exited' : stateLower === 'running'
stateLower === 'created' ? 'status-created' : ? 'status-running'
stateLower === 'restarting' ? 'status-restarting' : ''; : stateLower === 'exited' || stateLower === 'stopped'
? 'status-exited'
: stateLower === 'created'
? 'status-created'
: stateLower === 'restarting'
? 'status-restarting'
: '';
row.innerHTML = ` row.innerHTML = `
<td> <td>
@@ -6364,16 +6449,10 @@ function renderContainers(containers, topicId) {
</td> </td>
`; `;
fragment.appendChild(row);
// Add event listener for checkbox
const checkbox = row.querySelector('.container-checkbox'); const checkbox = row.querySelector('.container-checkbox');
if (checkbox) { if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
checkbox.addEventListener('change', () => updateBulkActionsToolbar());
}
// Add event listener for duplicate button
const duplicateBtn = row.querySelector('.action-duplicate'); const duplicateBtn = row.querySelector('.action-duplicate');
duplicateBtn.addEventListener('click', () => openDuplicateModal(container)); if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
// Add event listener for clickable container name (hidden link for details view)
const nameLink = row.querySelector('.container-name-link'); const nameLink = row.querySelector('.container-name-link');
if (nameLink) { if (nameLink) {
nameLink.addEventListener('click', (e) => { nameLink.addEventListener('click', (e) => {
@@ -6381,8 +6460,6 @@ function renderContainers(containers, topicId) {
showContainerDetails(container); showContainerDetails(container);
}); });
} }
// Add event listener for container name display (single click to view details)
const nameDisplay = row.querySelector('.container-name-display'); const nameDisplay = row.querySelector('.container-name-display');
if (nameDisplay) { if (nameDisplay) {
nameDisplay.addEventListener('click', (e) => { nameDisplay.addEventListener('click', (e) => {
@@ -6390,11 +6467,114 @@ function renderContainers(containers, topicId) {
showContainerDetails(container); showContainerDetails(container);
}); });
} }
// Add event listeners for action buttons
addActionListeners(row, container); addActionListeners(row, container);
return row;
}
function paintVirtualContainers() {
const listElement = domCache.containerList || containerList;
if (!listElement) return;
const rows = containerVirt.rows;
if (!rows.length) return;
const scrollParent =
listElement.closest('.table-responsive') ||
listElement.closest('.view') ||
listElement.parentElement;
const scrollTop = scrollParent?.scrollTop || 0;
const viewH = scrollParent?.clientHeight || 600;
const rh = containerVirt.rowHeight;
const total = rows.length;
const start = Math.max(0, Math.floor(scrollTop / rh) - containerVirt.overscan);
const visible = Math.ceil(viewH / rh) + containerVirt.overscan * 2;
const end = Math.min(total, start + visible);
const fragment = document.createDocumentFragment();
if (start > 0) {
const topPad = document.createElement('tr');
topPad.className = 'virt-pad-top';
topPad.innerHTML = `<td colspan="8" style="height:${start * rh}px;padding:0;border:0;"></td>`;
fragment.appendChild(topPad);
}
for (let i = start; i < end; i++) {
fragment.appendChild(buildContainerRow(rows[i]));
}
if (end < total) {
const botPad = document.createElement('tr');
botPad.className = 'virt-pad-bot';
botPad.innerHTML = `<td colspan="8" style="height:${(total - end) * rh}px;padding:0;border:0;"></td>`;
fragment.appendChild(botPad);
}
listElement.innerHTML = '';
listElement.appendChild(fragment);
}
function bindContainerVirtualScroll() {
if (containerVirt.bound) return;
const listElement = domCache.containerList || containerList;
const scrollParent =
listElement?.closest('.table-responsive') ||
listElement?.closest('.view') ||
listElement?.parentElement;
if (!scrollParent) return;
containerVirt.scrollEl = scrollParent;
let ticking = false;
scrollParent.addEventListener(
'scroll',
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
ticking = false;
if (containerVirt.rows.length > 80) paintVirtualContainers();
});
},
{ passive: true }
);
containerVirt.bound = true;
}
function renderContainers(containers, topicId) {
if (!window.activePeer || !connections[topicId] || window.activePeer !== connections[topicId].peer) {
console.warn('[WARN] Active peer mismatch or invalid connection. Skipping container rendering.');
return;
}
console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`);
const currentContainerIds = new Set(containers.map((c) => c.Id));
Object.keys(smoothedStats).forEach((containerId) => {
if (!currentContainerIds.has(containerId)) delete smoothedStats[containerId];
}); });
// Clear and append fragment in one operation const filteredContainers = filterAndSortContainers(containers);
const listElement = domCache.containerList || containerList;
if (!filteredContainers.length) {
const hasAny = (containers || []).length > 0;
listElement.innerHTML = emptyTableRow(
8,
hasAny ? 'No matching containers' : 'No containers yet',
hasAny ? 'Clear filters or search to see more.' : 'Deploy a container from the Deploy view.'
);
containerVirt.rows = [];
return;
}
containerVirt.rows = filteredContainers;
containerVirt.topicId = topicId;
bindContainerVirtualScroll();
// Virtualize only for large lists; small lists render fully for simplicity
if (filteredContainers.length > 80) {
paintVirtualContainers();
return;
}
const fragment = document.createDocumentFragment();
filteredContainers.forEach((container) => {
fragment.appendChild(buildContainerRow(container));
});
listElement.innerHTML = ''; listElement.innerHTML = '';
listElement.appendChild(fragment); listElement.appendChild(fragment);
} }
+21
View File
@@ -372,6 +372,27 @@ export const api = {
return connOrActive(connection).request(Methods.listInvites, {}) return connOrActive(connection).request(Methods.listInvites, {})
}, },
syncStackFromGit(args, connection) {
return connOrActive(connection).request(Methods.syncStackFromGit, args)
},
// —— Holesail tunnels (requires ENABLE_HOLESAIL) ——
listTunnels(connection) {
return connOrActive(connection).request(Methods.listTunnels, {})
},
getHolesailStatus(connection) {
return connOrActive(connection).request(Methods.getHolesailStatus, {})
},
createTunnel(args, connection) {
return connOrActive(connection).request(Methods.createTunnel, args)
},
closeTunnel(id, connection) {
return connOrActive(connection).request(Methods.closeTunnel, { id })
},
// —— Swarm (requires ENABLE_SWARM) —— // —— Swarm (requires ENABLE_SWARM) ——
swarmInspect(connection) { swarmInspect(connection) {
return connOrActive(connection).request(Methods.swarmInspect, {}) return connOrActive(connection).request(Methods.swarmInspect, {})
+133
View File
@@ -0,0 +1,133 @@
/**
* Pear-side Holesail client: bind a local port that proxies to a remote hs:// tunnel.
* Optional — requires the `holesail` package (already a peardock dependency).
*/
import { createRequire } from 'module'
import net from 'net'
const require = createRequire(import.meta.url)
/** @type {Map<string, { instance: any, info: object, localPort: number }>} */
const localClients = new Map()
let HolesailCtor = null
function loadHolesail() {
if (HolesailCtor) return HolesailCtor
HolesailCtor = require('holesail')
return HolesailCtor
}
/**
* Pick a free TCP port on 127.0.0.1
* @returns {Promise<number>}
*/
export function findFreePort() {
return new Promise((resolve, reject) => {
const s = net.createServer()
s.listen(0, '127.0.0.1', () => {
const addr = s.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
s.close((err) => (err ? reject(err) : resolve(port)))
})
s.on('error', reject)
})
}
/**
* Connect to an hs:// URL and listen locally.
* @param {string} urlOrKey
* @param {{ localPort?: number, host?: string, openBrowser?: boolean }} [opts]
*/
export async function connectLocalHolesail(urlOrKey, opts = {}) {
const url = String(urlOrKey || '').trim()
if (!url) throw new Error('Holesail URL required')
if (localClients.has(url)) {
return localClients.get(url)
}
const Holesail = loadHolesail()
const localPort = opts.localPort || (await findFreePort())
const host = opts.host || '127.0.0.1'
const instance = new Holesail({
client: true,
key: url,
port: localPort,
host,
log: false,
})
await instance.ready()
const info = instance.info || {}
const entry = {
instance,
info,
localPort: info.port || localPort,
host: info.host || host,
url,
}
localClients.set(url, entry)
if (opts.openBrowser !== false) {
tryOpenBrowser(`http://${entry.host}:${entry.localPort}`)
}
return entry
}
/**
* @param {string} urlOrKey
*/
export async function disconnectLocalHolesail(urlOrKey) {
const url = String(urlOrKey || '').trim()
const entry = localClients.get(url)
if (!entry) return false
localClients.delete(url)
try {
await entry.instance.close()
} catch {
// ignore
}
return true
}
export function listLocalHolesail() {
return [...localClients.values()].map((e) => ({
url: e.url,
localPort: e.localPort,
host: e.host,
state: e.info?.state,
}))
}
function tryOpenBrowser(href) {
try {
// Pear / Electron
if (typeof window !== 'undefined' && window.open) {
window.open(href, '_blank')
return
}
} catch {
// ignore
}
try {
const { exec } = require('child_process')
const platform = process.platform
const cmd =
platform === 'darwin'
? `open "${href}"`
: platform === 'win32'
? `start "" "${href}"`
: `xdg-open "${href}"`
exec(cmd)
} catch {
// ignore
}
}
export default {
connectLocalHolesail,
disconnectLocalHolesail,
listLocalHolesail,
findFreePort,
}
+84
View File
@@ -0,0 +1,84 @@
# Holesail integration
[Holesail](https://github.com/holesail/holesail) is a peer-to-peer **TCP/UDP reverse proxy** on HyperDHT. peardock uses HyperDHT + protomux-rpc for the **Docker control plane**; Holesail is integrated **beside** that path so operators can share **published ports / local services** via `hs://` keys without opening firewall ports.
## Architecture (do not conflate the two planes)
| Plane | Technology | Purpose |
|-------|------------|---------|
| **Control** | HyperDHT + protomux-rpc | peardock RPC: containers, deploy, logs, ACL |
| **Data / tunnels** | Holesail (`holesail` package) | L4 proxy of `host:port` ↔ remote peer via `hs://` |
```
[Pear client] --protomux-rpc / HyperDHT--> [peardock server] --dockerode--> dockerd
|
+-- HolesailServer instances (per tunnel)
|
pipes to 127.0.0.1:<published-port>
|
[remote user] --holesail client--> local bind --P2P--> that tunnel
```
**Why not replace peardock RPC with Holesail?**
Holesail is not an RPC framework. It tunnels bytes between TCP/UDP sockets. peardock needs structured methods, roles, audit, and pushes (stats, logs). Keep both.
## Enable
```bash
# on the peardock server host
export ENABLE_HOLESAIL=1
npm run server
```
Optional env:
| Variable | Default | Meaning |
|----------|---------|---------|
| `ENABLE_HOLESAIL` | off | Must be `1` / `true` |
| `PEARDOCK_MAX_TUNNELS` | `20` | Concurrent tunnel cap |
| `PEARDOCK_TUNNEL_HOSTS` | `127.0.0.1,localhost,::1,0.0.0.0` | Allowed tunnel targets (SSRF guard) |
## RPC
| Method | Role | Description |
|--------|------|-------------|
| `getHolesailStatus` | viewer | Feature flag + availability |
| `listTunnels` | viewer | Active tunnels (+ `hs://` URLs) |
| `getTunnel` | viewer | One tunnel by id |
| `createTunnel` | operator | Start tunnel (`host`/`port` or `containerId`+`containerPort`) |
| `closeTunnel` | operator | Stop tunnel |
`createTunnel` always uses **secure** mode by default (`secure: true`) so the DHT capability is not the raw listen key.
## UI
Sidebar → **Tunnels**: create by host/port, list active tunnels, copy `hs://` URL, close.
Remote connect (outside peardock):
```bash
npx holesail 'hs://s000…'
# then open http://127.0.0.1:<bound-port>
```
## License note
`holesail` is **AGPL-3.0**. peardock remains Apache-2.0; enabling Holesail adds an AGPL dependency for servers that set `ENABLE_HOLESAIL=1`. Operators distributing a combined binary should review AGPL obligations. See `docs/SBOM.md`.
## Security
- Treat `hs://` URLs as **secrets** (capability to reach the service).
- Default targets are loopback / published Docker binds only.
- Create/close are audited when `PEARDOCK_AUDIT` is on.
- Max tunnel count limits resource exhaustion.
- Do **not** reuse peardocks `SERVER_SEED` for tunnel keypairs — each tunnel gets its own Holesail seed.
## Implemented extensions
1. **Container UI action** — “Tunnel this port” on container details port table.
2. **Client-side HolesailClient** — Pear binds a local port and opens the browser (`client/holesailLocal.js`, Tunnels → plug icon).
3. **Persist tunnel definitions**`peardock-tunnels.json` (mode 600); restored on server boot with the same `hs://` keys when possible.
## Optional later
- QR codes in the Tunnels view for mobile Holesail Go.
+3
View File
@@ -61,6 +61,9 @@ Vault file: `peardock-vault.json` (mode 600). Override path with `PEARDOCK_VAULT
|-----|--------| |-----|--------|
| `ENABLE_SWARM=1` | Swarm / services / secrets / configs RPC | | `ENABLE_SWARM=1` | Swarm / services / secrets / configs RPC |
| `ENABLE_PLUGINS=1` | Plugin install/enable/remove | | `ENABLE_PLUGINS=1` | Plugin install/enable/remove |
| `ENABLE_HOLESAIL=1` | Holesail P2P port tunnels (`hs://` keys) — see [HOLESAIL.md](./HOLESAIL.md) |
| `PEARDOCK_MAX_TUNNELS` | Max concurrent Holesail tunnels (default 20) |
| `PEARDOCK_TUNNEL_HOSTS` | Extra allowed tunnel target hosts (comma-separated) |
| `PEARDOCK_UNRESTRICTED_CLI=1` | Broader `docker` CLI for **admin** | | `PEARDOCK_UNRESTRICTED_CLI=1` | Broader `docker` CLI for **admin** |
| `PEARDOCK_BROWSE_OPEN=1` | Legacy open host FS browse (discouraged) | | `PEARDOCK_BROWSE_OPEN=1` | Legacy open host FS browse (discouraged) |
+2 -1
View File
@@ -27,6 +27,7 @@ See `package.json` / `package-lock.json`. Major surface:
| graceful-goodbye | Shutdown | | graceful-goodbye | Shutdown |
| hypercore-crypto | Key material | | hypercore-crypto | Key material |
| pear-electron / pear-bridge | Desktop shell | | pear-electron / pear-bridge | Desktop shell |
| holesail | Optional P2P TCP/UDP tunnels (`ENABLE_HOLESAIL=1`) — **AGPL-3.0** |
## Known-sensitive native deps ## Known-sensitive native deps
@@ -34,4 +35,4 @@ See `package.json` / `package-lock.json`. Major surface:
## License ## License
Apache-2.0 (project). Review transitive licenses before redistribution. Apache-2.0 (project). Optional Holesail integration pulls **AGPL-3.0** (`holesail` / `holesail-server` / `holesail-client`). Review transitive licenses before redistribution; servers that enable tunnels should document AGPL obligations.
+3 -1
View File
@@ -31,6 +31,7 @@
- **Anyone with the server public key** can *attempt* a DHT connection. - **Anyone with the server public key** can *attempt* a DHT connection.
- **Default role is admin** unless `PEARDOCK_DEFAULT_ROLE` / `PEARDOCK_ADMIN_KEYS` / peer policy tighten it. - **Default role is admin** unless `PEARDOCK_DEFAULT_ROLE` / `PEARDOCK_ADMIN_KEYS` / peer policy tighten it.
- **Swarm / plugins** are off unless `ENABLE_SWARM` / `ENABLE_PLUGINS`. - **Swarm / plugins** are off unless `ENABLE_SWARM` / `ENABLE_PLUGINS`.
- **Holesail tunnels** are off unless `ENABLE_HOLESAIL=1` — each `hs://` URL is a capability to the target port.
- **Host FS browse** is **default-deny** unless `PEARDOCK_BROWSE_ROOTS` or `PEARDOCK_BROWSE_OPEN=1`. - **Host FS browse** is **default-deny** unless `PEARDOCK_BROWSE_ROOTS` or `PEARDOCK_BROWSE_OPEN=1`.
--- ---
@@ -56,7 +57,8 @@
| Rate limit | Per-peer limiter on RPC | | Rate limit | Per-peer limiter on RPC |
| Registry secrets | AES-256-GCM vault (`registry-vault.js`) | | Registry secrets | AES-256-GCM vault (`registry-vault.js`) |
| Browse FS | Root allowlist / default deny | | Browse FS | Root allowlist / default deny |
| Feature gates | `ENABLE_SWARM`, `ENABLE_PLUGINS`, `PEARDOCK_UNRESTRICTED_CLI` | | Feature gates | `ENABLE_SWARM`, `ENABLE_PLUGINS`, `ENABLE_HOLESAIL`, `PEARDOCK_UNRESTRICTED_CLI` |
| Tunnel targets | Loopback / allowlisted hosts only (`PEARDOCK_TUNNEL_HOSTS`) |
--- ---
+158 -1
View File
@@ -148,6 +148,12 @@
<span class="nav-label">Stacks</span> <span class="nav-label">Stacks</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="swarm" title="Swarm">
<i class="fas fa-project-diagram"></i>
<span class="nav-label">Swarm</span>
</a>
</li>
<li class="nav-group-label">Build & storage</li> <li class="nav-group-label">Build & storage</li>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="nav-link" data-view="images" title="Images"> <a href="#" class="nav-link" data-view="images" title="Images">
@@ -174,6 +180,12 @@
<span class="nav-label">Host</span> <span class="nav-label">Host</span>
</a> </a>
</li> </li>
<li class="nav-item">
<a href="#" class="nav-link" data-view="tunnels" title="Tunnels">
<i class="fas fa-network-wired"></i>
<span class="nav-label">Tunnels</span>
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a href="#" class="nav-link" data-view="access" title="Access"> <a href="#" class="nav-link" data-view="access" title="Access">
<i class="fas fa-user-shield"></i> <i class="fas fa-user-shield"></i>
@@ -422,6 +434,13 @@
<div class="info-card" id="deploy-form-section" style="display: none;"> <div class="info-card" id="deploy-form-section" style="display: none;">
<h5 class="info-card-title"><i class="fas fa-cog me-2"></i>Container Configuration</h5> <h5 class="info-card-title"><i class="fas fa-cog me-2"></i>Container Configuration</h5>
<div class="info-card-body"> <div class="info-card-body">
<div class="deploy-wizard-steps mb-4" id="deploy-wizard-steps" aria-label="Deploy steps">
<div class="deploy-step active" data-step="1"><span class="deploy-step-num">1</span><span class="deploy-step-label">Basics</span></div>
<div class="deploy-step" data-step="2"><span class="deploy-step-num">2</span><span class="deploy-step-label">Network &amp; ports</span></div>
<div class="deploy-step" data-step="3"><span class="deploy-step-num">3</span><span class="deploy-step-label">Volumes &amp; env</span></div>
<div class="deploy-step" data-step="4"><span class="deploy-step-num">4</span><span class="deploy-step-label">Security &amp; runtime</span></div>
<div class="deploy-step" data-step="5"><span class="deploy-step-num">5</span><span class="deploy-step-label">Review &amp; deploy</span></div>
</div>
<form id="deploy-view-form" onsubmit="return false;"> <form id="deploy-view-form" onsubmit="return false;">
<!-- Basic Settings (Always Visible) --> <!-- Basic Settings (Always Visible) -->
<div class="mb-4"> <div class="mb-4">
@@ -1355,6 +1374,49 @@
</div> </div>
</div> </div>
<!-- Swarm view (ENABLE_SWARM=1 on server) -->
<div id="swarm-view" class="view hidden">
<div class="container-fluid">
<div class="page-header">
<div>
<h2><i class="fas fa-project-diagram"></i>Swarm</h2>
<p class="page-subtitle">Services, nodes, and tasks (requires ENABLE_SWARM=1)</p>
</div>
<button class="btn btn-outline-primary" type="button" id="swarm-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button>
</div>
<div id="swarm-status-banner" class="alert alert-secondary small mb-3">Checking Swarm…</div>
<ul class="nav nav-tabs mb-3" id="swarm-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-swarm-tab="services" type="button">Services</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="nodes" type="button">Nodes</button></li>
<li class="nav-item"><button class="nav-link" data-swarm-tab="tasks" type="button">Tasks</button></li>
</ul>
<div id="swarm-panel-services" class="swarm-panel">
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Name</th><th>Image</th><th>Replicas</th><th>ID</th></tr></thead>
<tbody id="swarm-services-body"><tr><td colspan="4" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
<div id="swarm-panel-nodes" class="swarm-panel hidden">
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Hostname</th><th>Role</th><th>Status</th><th>Availability</th><th>ID</th></tr></thead>
<tbody id="swarm-nodes-body"><tr><td colspan="5" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
<div id="swarm-panel-tasks" class="swarm-panel hidden">
<div class="table-responsive">
<table class="table table-dark table-hover table-sm">
<thead><tr><th>Service</th><th>Node</th><th>Desired</th><th>Current</th><th>ID</th></tr></thead>
<tbody id="swarm-tasks-body"><tr><td colspan="5" class="text-muted"></td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Deploy Stack Modal --> <!-- Deploy Stack Modal -->
<div class="modal fade" id="deploy-stack-modal" tabindex="-1"> <div class="modal fade" id="deploy-stack-modal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
@@ -1371,7 +1433,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="compose-content" class="form-label">Docker Compose YAML</label> <label for="compose-content" class="form-label">Docker Compose YAML</label>
<textarea class="form-control bg-dark text-white" id="compose-content" rows="15" required placeholder="version: '3' <textarea class="form-control bg-dark text-white" id="compose-content" rows="12" required placeholder="version: '3'
services: services:
web: web:
image: nginx image: nginx
@@ -1382,6 +1444,26 @@ services:
environment: environment:
POSTGRES_PASSWORD: password"></textarea> POSTGRES_PASSWORD: password"></textarea>
</div> </div>
<hr class="border-secondary">
<h6 class="text-muted">GitOps sync (optional)</h6>
<p class="small text-muted">Clone a public/private git repo and deploy its compose file. Requires <code>git</code> on the server host.</p>
<div class="row g-2 mb-2">
<div class="col-md-8">
<label class="form-label" for="stack-git-url">Repository URL</label>
<input type="url" class="form-control bg-dark text-white font-monospace" id="stack-git-url" placeholder="https://github.com/org/repo.git" spellcheck="false">
</div>
<div class="col-md-4">
<label class="form-label" for="stack-git-ref">Branch / tag</label>
<input type="text" class="form-control bg-dark text-white" id="stack-git-ref" placeholder="main" value="main">
</div>
</div>
<div class="mb-2">
<label class="form-label" for="stack-git-path">Compose path in repo</label>
<input type="text" class="form-control bg-dark text-white font-monospace" id="stack-git-path" placeholder="docker-compose.yml" value="docker-compose.yml">
</div>
<button type="button" class="btn btn-outline-info btn-sm" id="stack-gitops-btn" data-min-role="operator">
<i class="fas fa-code-branch me-1"></i>Sync from Git &amp; deploy
</button>
</form> </form>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@@ -1551,6 +1633,81 @@ services:
</div> </div>
</div> </div>
<!-- Holesail tunnels -->
<div id="tunnels-view" class="view hidden">
<div class="container-fluid">
<div class="page-header">
<div>
<h2><i class="fas fa-network-wired"></i>Tunnels</h2>
<p class="page-subtitle">Share local ports over Holesail (P2P) without opening the firewall</p>
</div>
<div class="page-header-actions">
<button class="btn btn-outline-primary" type="button" id="tunnels-refresh-btn"><i class="fas fa-sync me-1"></i>Refresh</button>
</div>
</div>
<div id="tunnels-status-banner" class="alert alert-secondary small mb-3" role="status">
Connect a peer to manage tunnels. Server needs <code>ENABLE_HOLESAIL=1</code>.
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="settings-section">
<h3>Create tunnel</h3>
<p class="small text-muted">Exposes a host port via an <code>hs://</code> key. Anyone with the key can reach that service through Holesail.</p>
<div class="mb-2">
<label class="form-label" for="tunnel-name">Name</label>
<input type="text" id="tunnel-name" class="form-control bg-dark text-white" placeholder="web-ui" maxlength="80">
</div>
<div class="row g-2 mb-2">
<div class="col-7">
<label class="form-label" for="tunnel-host">Host</label>
<input type="text" id="tunnel-host" class="form-control bg-dark text-white font-monospace" value="127.0.0.1" spellcheck="false">
</div>
<div class="col-5">
<label class="form-label" for="tunnel-port">Port</label>
<input type="number" id="tunnel-port" class="form-control bg-dark text-white" min="1" max="65535" placeholder="8080">
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label class="form-label" for="tunnel-protocol">Protocol</label>
<select id="tunnel-protocol" class="form-select bg-dark text-white">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
</div>
<div class="col-6 d-flex align-items-end">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="tunnel-secure" checked>
<label class="form-check-label" for="tunnel-secure">Secure key</label>
</div>
</div>
</div>
<button type="button" class="btn btn-primary" id="tunnel-create-btn" data-min-role="operator">
<i class="fas fa-plus me-1"></i>Create tunnel
</button>
</div>
</div>
<div class="col-lg-7">
<div class="settings-section">
<h3>Active tunnels</h3>
<div id="tunnels-list" class="list-group list-group-flush">
<div class="text-muted small p-2">No tunnels yet</div>
</div>
</div>
<div class="settings-section mt-3">
<h3>How to connect</h3>
<ol class="small text-muted mb-0 ps-3">
<li>Create a tunnel to a published container port (or any allowed host port).</li>
<li>Copy the <code>hs://</code> URL.</li>
<li>On another machine: <code>npx holesail &lt;url&gt;</code> (binds a local port).</li>
<li>Open <code>http://127.0.0.1:&lt;local-port&gt;</code> for HTTP services.</li>
</ol>
</div>
</div>
</div>
</div>
</div>
<!-- Settings --> <!-- Settings -->
<div id="settings-view" class="view hidden"> <div id="settings-view" class="view hidden">
<div class="container-fluid"> <div class="container-fluid">
+400 -10
View File
@@ -14,6 +14,7 @@
"dockerode": "^5.0.1", "dockerode": "^5.0.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"graceful-goodbye": "^1.3.3", "graceful-goodbye": "^1.3.3",
"holesail": "^2.4.1",
"hypercore-crypto": "^3.7.0", "hypercore-crypto": "^3.7.0",
"hyperdht": "^6.33.0", "hyperdht": "^6.33.0",
"js-yaml": "^4.3.0", "js-yaml": "^4.3.0",
@@ -86,6 +87,16 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@holesail/hyper-cmd-lib-net": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@holesail/hyper-cmd-lib-net/-/hyper-cmd-lib-net-1.1.2.tgz",
"integrity": "sha512-4SDQ6p+lVxap8F3RfEQPeY+bk/YQ8CxZis7AeUbDnspy9TPV/tyUrrx1elcjtAO/3b1AsVgFV8o0rouhz23/9w==",
"dependencies": {
"bare-dgram": "^1.0.1",
"bare-net": "^2.2.0",
"net": "npm:bare-net@^2.2.0"
}
},
"node_modules/@hyperswarm/secret-stream": { "node_modules/@hyperswarm/secret-stream": {
"version": "6.9.1", "version": "6.9.1",
"resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz", "resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz",
@@ -235,6 +246,11 @@
"xache": "^1.2.1" "xache": "^1.2.1"
} }
}, },
"node_modules/ansi-parser": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/ansi-parser/-/ansi-parser-3.2.11.tgz",
"integrity": "sha512-HeEwCVf/pI9qQUVaTkoDBl9U/QU4RvWJ+Trg1jNSNvDzFsBAId6fHv6VLiC7HzwrOiL8uOEkO5T1eTgpnTFQfQ=="
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -352,7 +368,6 @@
"version": "1.10.0", "version": "1.10.0",
"resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz", "resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz",
"integrity": "sha512-4LVlnJAHr00Hh6Vu6ZUJS38rcEtJT3b3vChXSsBsJ2mk1TN0lQ+gzd+Dw5L0aV7uqDZv84smuwW+O02X7PfDlw==", "integrity": "sha512-4LVlnJAHr00Hh6Vu6ZUJS38rcEtJT3b3vChXSsBsJ2mk1TN0lQ+gzd+Dw5L0aV7uqDZv84smuwW+O02X7PfDlw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"bare-buffer": "*", "bare-buffer": "*",
@@ -403,7 +418,6 @@
"version": "1.15.3", "version": "1.15.3",
"resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.3.tgz", "resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.3.tgz",
"integrity": "sha512-macV9lbyJTsLPRXJkBtz8ivTGEo3LCyJInLT9IB/PWJ7pRXwvHs/FP4bx/fWw+HZkiepIYCAV2cuU5CR92XWCw==", "integrity": "sha512-macV9lbyJTsLPRXJkBtz8ivTGEo3LCyJInLT9IB/PWJ7pRXwvHs/FP4bx/fWw+HZkiepIYCAV2cuU5CR92XWCw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-assert": "^1.2.0", "bare-assert": "^1.2.0",
@@ -433,12 +447,20 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz", "resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz",
"integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==", "integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-os": "^3.0.1" "bare-os": "^3.0.1"
} }
}, },
"node_modules/bare-dgram": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/bare-dgram/-/bare-dgram-1.0.1.tgz",
"integrity": "sha512-EdsyRErrkWgN8fENdrDdXFEE9HAuJ/m6ehXz13fVj9JhdCaLWIA+L8o5aYNRLt66x08RlyG2vbrRAZoxGfcdlg==",
"dependencies": {
"bare-events": "^2.5.0",
"udx-native": "^1.11.2"
}
},
"node_modules/bare-dns": { "node_modules/bare-dns": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz", "resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz",
@@ -452,7 +474,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz", "resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz",
"integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==", "integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peerDependencies": { "peerDependencies": {
"bare-buffer": "*" "bare-buffer": "*"
@@ -609,7 +630,6 @@
"version": "6.4.0", "version": "6.4.0",
"resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.4.0.tgz", "resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.4.0.tgz",
"integrity": "sha512-Yn4V5g5EqGQL4LYUOmt7fjKzj2JPWyJOqE3lPoeZwfUH5rk4CKUfZj6JhDwbzhBYCqqmUgjgQ5aY8cihAPILLA==", "integrity": "sha512-Yn4V5g5EqGQL4LYUOmt7fjKzj2JPWyJOqE3lPoeZwfUH5rk4CKUfZj6JhDwbzhBYCqqmUgjgQ5aY8cihAPILLA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-bundle": "^1.3.0", "bare-bundle": "^1.3.0",
@@ -635,7 +655,6 @@
"version": "1.6.2", "version": "1.6.2",
"resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.6.2.tgz", "resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.6.2.tgz",
"integrity": "sha512-KcbnmqEo4Zu4p2g8PTiFFNWf8KmtTR5Uc7I1mWD/p9A8OM/02qnXweemCqA3ZIrc+6SDLTMEtxnCk2lt9Q+aGA==", "integrity": "sha512-KcbnmqEo4Zu4p2g8PTiFFNWf8KmtTR5Uc7I1mWD/p9A8OM/02qnXweemCqA3ZIrc+6SDLTMEtxnCk2lt9Q+aGA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"require-addon": "^1.0.2" "require-addon": "^1.0.2"
@@ -670,7 +689,6 @@
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.2.tgz", "resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.2.tgz",
"integrity": "sha512-I+yz+pqbYsBkxDsnu5vkKvy7RSNY9CcAvu2jZT6PsmdXJQG1i3dmD5V7xc3334OVp2absgtUEYLmmuNFlphBzg==", "integrity": "sha512-I+yz+pqbYsBkxDsnu5vkKvy7RSNY9CcAvu2jZT6PsmdXJQG1i3dmD5V7xc3334OVp2absgtUEYLmmuNFlphBzg==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-events": "^2.2.2", "bare-events": "^2.2.2",
@@ -729,6 +747,11 @@
"bare-stdio": "^1.0.1" "bare-stdio": "^1.0.1"
} }
}, },
"node_modules/bare-querystring": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/bare-querystring/-/bare-querystring-1.1.0.tgz",
"integrity": "sha512-pUtEM6JrX53MbEJFwO92F0Ch7BwZ67KD7LyglcB8/tvkkVdwTgN1f7oIklRe+NTT/WCYZgwjDFYS9efBxDSq8g=="
},
"node_modules/bare-readline": { "node_modules/bare-readline": {
"version": "1.3.1", "version": "1.3.1",
"resolved": "https://registry.npmjs.org/bare-readline/-/bare-readline-1.3.1.tgz", "resolved": "https://registry.npmjs.org/bare-readline/-/bare-readline-1.3.1.tgz",
@@ -815,7 +838,6 @@
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz", "resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz",
"integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==", "integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-ansi-escapes": "^2.2.3", "bare-ansi-escapes": "^2.2.3",
@@ -912,7 +934,6 @@
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/bare-type-stripper/-/bare-type-stripper-0.1.4.tgz", "resolved": "https://registry.npmjs.org/bare-type-stripper/-/bare-type-stripper-0.1.4.tgz",
"integrity": "sha512-FdZhp9XEnQpj8AWFmIft/sVUyKS9XSmB6PhcxBHhuEDxxZM5Kkt8+kFS7eEpLXR7TkaRkNpSENoGH/8lpAmtkA==", "integrity": "sha512-FdZhp9XEnQpj8AWFmIft/sVUyKS9XSmB6PhcxBHhuEDxxZM5Kkt8+kFS7eEpLXR7TkaRkNpSENoGH/8lpAmtkA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"require-addon": "^1.0.2" "require-addon": "^1.0.2"
@@ -939,7 +960,6 @@
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz", "resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz",
"integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==", "integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"bare-debug-log": "^2.0.0", "bare-debug-log": "^2.0.0",
@@ -994,6 +1014,14 @@
} }
} }
}, },
"node_modules/barely-colours": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/barely-colours/-/barely-colours-1.0.0.tgz",
"integrity": "sha512-kzOuBDHD3O4BmVwq7yuD62bqc7WnF8YG648W3nQ2lRjRr7psv5MWrpWkUTTUoCbv6V3WjsWJMuACDz4eSNa6jQ==",
"dependencies": {
"bare-ansi-escapes": "^2.2.3"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1165,6 +1193,30 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/child_process": {
"name": "bare-subprocess",
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.3.tgz",
"integrity": "sha512-07wwswlV7M3sC9IykbZRZ/jHAkrXFWVLqdBWGv1y0ojCimtRD9hGwxdHmR5FUFmDUZLNsBmTYJNQqgio5+A85Q==",
"dependencies": {
"bare-env": "^3.0.0",
"bare-events": "^2.5.4",
"bare-os": "^3.0.1",
"bare-pipe": "^4.0.0",
"bare-url": "^2.2.2"
},
"engines": {
"bare": ">=1.7.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/chownr": { "node_modules/chownr": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -1177,6 +1229,18 @@
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cli-box": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/cli-box/-/cli-box-6.0.10.tgz",
"integrity": "sha512-6jjSF6G1gOXaCyBQJKo3L3lZKxP8jzdECG7FBiA4m2w7K8jBxXXwsMb75fBLwoVwOtKwvgKjLJurw8HU5XNciw==",
"dependencies": {
"ansi-parser": "^3.2.1",
"deffy": "^2.2.1",
"is-undefined": "^1.0.0",
"is-win": "^1.0.0",
"ul": "^5.2.1"
}
},
"node_modules/cliui": { "node_modules/cliui": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1209,6 +1273,14 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
"engines": {
"node": ">=0.1.90"
}
},
"node_modules/compact-encoding": { "node_modules/compact-encoding": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz", "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
@@ -1293,6 +1365,14 @@
} }
} }
}, },
"node_modules/deffy": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/deffy/-/deffy-2.2.5.tgz",
"integrity": "sha512-6TX2cfIo97eKqWmqgMDAUulCwnveAe3K+4VGsTGPJsL3NtSEnSBFZ3sUXdS4EBhZ8GbdaZBzXQ04ton18dJrug==",
"dependencies": {
"typpy": "^2.0.0"
}
},
"node_modules/dht-rpc": { "node_modules/dht-rpc": {
"version": "6.27.0", "version": "6.27.0",
"resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz", "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz",
@@ -1433,6 +1513,30 @@
"streamx": "^2.13.0" "streamx": "^2.13.0"
} }
}, },
"node_modules/fs": {
"name": "bare-fs",
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz",
"integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -1461,6 +1565,14 @@
"resource-on-exit": "^1.0.0" "resource-on-exit": "^1.0.0"
} }
}, },
"node_modules/function.name": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/function.name/-/function.name-1.0.14.tgz",
"integrity": "sha512-s99L814NRuLxwF2sJMIcLhkQhueGXb3oKyvorzrUKKwlVB0SBbWrgZt4+EwKAo3ujCXnT7vshmCvXgZA09kCMw==",
"dependencies": {
"noop6": "^1.0.1"
}
},
"node_modules/generate-object-property": { "node_modules/generate-object-property": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz", "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz",
@@ -1514,6 +1626,139 @@
"safety-catch": "^1.0.2" "safety-catch": "^1.0.2"
} }
}, },
"node_modules/holesail": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/holesail/-/holesail-2.4.1.tgz",
"integrity": "sha512-u6Ta1LaEYsgvT447JG18OLeEpjWERx7Iy/icpCeR1BYT43wkpisWoZR9ydz3j9yk6PlAJDNCssEKZXm/Q9BQ7Q==",
"dependencies": {
"bare-crypto": "^1.12.0",
"bare-module": "^6.1.2",
"bare-path": "^3.0.0",
"bare-process": "^4.2.2",
"bare-querystring": "^1.0.0",
"barely-colours": "^1.0.0",
"child_process": "npm:bare-subprocess@^5.1.5",
"cli-box": "6.0.10",
"colors": "^1.4.0",
"graceful-goodbye": "^1.3.3",
"holesail-client": "^2.3.0",
"holesail-logger": "^1.1.0",
"holesail-server": "^2.4.0",
"hyper-cmd-lib-keys": "^0.1.1",
"livefiles": "^1.1.0",
"minimist": "^1.2.8",
"module": "npm:bare-node-module@^1.0.0",
"path": "npm:bare-node-path@^1.0.1",
"prettier": "^3.6.2",
"prettier-config-holepunch": "^2.0.0",
"process": "npm:bare-process@^4.2.1",
"qrcode-terminal": "^0.12.0",
"querystring": "npm:bare-node-querystring@^1.0.0",
"ready-resource": "^1.2.0",
"url": "npm:bare-url@^2.3.2",
"which-runtime": "^1.3.2",
"z32": "^1.1.0"
},
"bin": {
"holesail": "src/bin/holesail.mjs"
}
},
"node_modules/holesail-client": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/holesail-client/-/holesail-client-2.3.0.tgz",
"integrity": "sha512-tI0YBr6//eAcfir2+Idikukl9YdTFBt/VrWXNsLSFomQgQi/XonsXF2V+FJ9H6cWFvkH5SJivVsPohaP6bQD8w==",
"dependencies": {
"@holesail/hyper-cmd-lib-net": "^1.1.1",
"b4a": "^1.7.3",
"hyperdht": "^6.27.0",
"z32": "^1.1.0"
}
},
"node_modules/holesail-logger": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/holesail-logger/-/holesail-logger-1.1.0.tgz",
"integrity": "sha512-iYxRLlApyGn94moP48vx8+Fg8q4rniK8LoqsHJWcUkQNgW2HOa7e/xdWWxl0BmNMPV8iIJHsTuIYNj4Yq5D8Cg==",
"dependencies": {
"barely-colours": "^1.0.0"
}
},
"node_modules/holesail-server": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/holesail-server/-/holesail-server-2.4.0.tgz",
"integrity": "sha512-4iTSK0EVGKGWFoS5aA6g6ErgI8zgPhjnso1swXaPT8GCEbAZmecgetRhfb5nwoF7uHXS6ZJJWVfaFLF3xngZbw==",
"dependencies": {
"@holesail/hyper-cmd-lib-net": "^1.1.2",
"b4a": "^1.7.3",
"holesail-logger": "^1.1.0",
"hyper-cmd-lib-keys": "^0.2.0",
"hyperdht": "^6.27.0",
"z32": "^1.1.0"
}
},
"node_modules/holesail-server/node_modules/hyper-cmd-lib-keys": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/hyper-cmd-lib-keys/-/hyper-cmd-lib-keys-0.2.0.tgz",
"integrity": "sha512-uCo4Mlbq/Proez5c5BlpmRqn9OnDlnniIuM5KO/DlJq5diGFZHBNeNxon8OiUft7xSk4Gh+VKUsRLVKJVPW8NQ==",
"dependencies": {
"sodium-universal": "^5.0.1"
}
},
"node_modules/http": {
"name": "bare-http1",
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/bare-http1/-/bare-http1-4.5.7.tgz",
"integrity": "sha512-PRuzs9ywt4vUvrC3mnHhQyaQfLYzdFy8XKOH0oKeKmYfyYYUqXBkdbJE2csESLBxJEbKDBjxPGLU+Qa0l1ds4A==",
"dependencies": {
"bare-events": "^2.6.0",
"bare-http-parser": "^1.1.1",
"bare-stream": "^2.10.0",
"bare-tcp": "^2.2.0"
},
"peerDependencies": {
"bare-buffer": "*",
"bare-url": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
},
"bare-url": {
"optional": true
}
}
},
"node_modules/hyper-cmd-lib-keys": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/hyper-cmd-lib-keys/-/hyper-cmd-lib-keys-0.1.1.tgz",
"integrity": "sha512-K1RfDEGD2WaXlSaXJTjR0dAUJ0KLts6e+IMWStkBfW7+uRjz4mCfwdgk6hysenrZerBqXlsy3ArDmnj41e0lNg==",
"dependencies": {
"sodium-universal": "^4.0.1"
}
},
"node_modules/hyper-cmd-lib-keys/node_modules/sodium-native": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz",
"integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==",
"dependencies": {
"require-addon": "^1.1.0"
}
},
"node_modules/hyper-cmd-lib-keys/node_modules/sodium-universal": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-4.0.1.tgz",
"integrity": "sha512-sNp13PrxYLaUFHTGoDKkSDFvoEu51bfzE12RwGlqU1fcrkpAOK0NvizaJzOWV0Omtk9me2+Pnbjcf/l0efxuGQ==",
"dependencies": {
"sodium-native": "^4.0.0"
},
"peerDependencies": {
"sodium-javascript": "~0.8.0"
},
"peerDependenciesMeta": {
"sodium-javascript": {
"optional": true
}
}
},
"node_modules/hypercore-crypto": { "node_modules/hypercore-crypto": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz", "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz",
@@ -1628,12 +1873,22 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-undefined": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/is-undefined/-/is-undefined-1.0.12.tgz",
"integrity": "sha512-qaX2mymwUhMq+NQPnx5iR/u2PgqhL6jLzDunMmonOgVofqoFhxzd6kOmiL0DLYZUkN/RvNWYPenoANVn5phlaA=="
},
"node_modules/is-valid-variable": { "node_modules/is-valid-variable": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-valid-variable/-/is-valid-variable-1.0.1.tgz", "resolved": "https://registry.npmjs.org/is-valid-variable/-/is-valid-variable-1.0.1.tgz",
"integrity": "sha512-ucsrYzt8kcgu2CAOFFWSfOTwa+vPNzQHK1RL6RooSkwozJ92e9wl4cyG/GLDeuNEVj56kvuNZGCR7eSkzrhudg==", "integrity": "sha512-ucsrYzt8kcgu2CAOFFWSfOTwa+vPNzQHK1RL6RooSkwozJ92e9wl4cyG/GLDeuNEVj56kvuNZGCR7eSkzrhudg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-win": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/is-win/-/is-win-1.0.11.tgz",
"integrity": "sha512-+XpgpizPqNzohXiqme7pfhAhpoG0Eo+CtuSx/XYW4enarERuheDbNbFrm4+XYylpV1w/eI+si5itFA0RfCWjog=="
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
@@ -1671,6 +1926,17 @@
"integrity": "sha512-i8x+jPmM3YaeU+5zL+NWMwzPT0/WV0q23rviQuu9EkdPuAYjnnsa5LDL406hJ/QNNkziKpnJ1T+3VxJQELskqw==", "integrity": "sha512-i8x+jPmM3YaeU+5zL+NWMwzPT0/WV0q23rviQuu9EkdPuAYjnnsa5LDL406hJ/QNNkziKpnJ1T+3VxJQELskqw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/livefiles": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/livefiles/-/livefiles-1.1.0.tgz",
"integrity": "sha512-2yVGglq9w7GWPYFy9yUVp1iK2dcod6n/BKLt9t+N2AhYTS+YWXjlh8TSC6ACYeLE7V+PAIbDjCtrVsQfFRk6Qg==",
"dependencies": {
"bare-utils": "^1.5.1",
"fs": "npm:bare-fs@^4.2.1",
"http": "npm:bare-http1@^4.0.2",
"ready-resource": "^1.1.2"
}
},
"node_modules/localdrive": { "node_modules/localdrive": {
"version": "1.12.2", "version": "1.12.2",
"resolved": "https://registry.npmjs.org/localdrive/-/localdrive-1.12.2.tgz", "resolved": "https://registry.npmjs.org/localdrive/-/localdrive-1.12.2.tgz",
@@ -1704,6 +1970,14 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mirror-drive": { "node_modules/mirror-drive": {
"version": "1.14.2", "version": "1.14.2",
"resolved": "https://registry.npmjs.org/mirror-drive/-/mirror-drive-1.14.2.tgz", "resolved": "https://registry.npmjs.org/mirror-drive/-/mirror-drive-1.14.2.tgz",
@@ -1725,6 +1999,15 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/module": {
"name": "bare-node-module",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/bare-node-module/-/bare-node-module-1.0.0.tgz",
"integrity": "sha512-7ZTCfExhk24aNqEGKgLOb3HsApNFdlcxTxeR8POsHyoQQeOy1pA3i8JxS97djNxunCnsfHAMJ5HeKo+55YsY5g==",
"dependencies": {
"bare-module": "*"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1759,6 +2042,18 @@
"integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==", "integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/net": {
"name": "bare-net",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.2.tgz",
"integrity": "sha512-I+yz+pqbYsBkxDsnu5vkKvy7RSNY9CcAvu2jZT6PsmdXJQG1i3dmD5V7xc3334OVp2absgtUEYLmmuNFlphBzg==",
"dependencies": {
"bare-events": "^2.2.2",
"bare-pipe": "^4.0.0",
"bare-stream": "^2.0.0",
"bare-tcp": "^2.0.0"
}
},
"node_modules/noise-curve-ed": { "node_modules/noise-curve-ed": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz", "resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz",
@@ -1781,6 +2076,11 @@
"sodium-universal": "^5.0.0" "sodium-universal": "^5.0.0"
} }
}, },
"node_modules/noop6": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/noop6/-/noop6-1.0.10.tgz",
"integrity": "sha512-WZvuCILZFZHK+WuqCQwxLBGllkBK1ct8s8Mu9FMDbEsBE6/bqNxyFGbX7Xky+6bYFL8X2Ou4Cis4CJyrwXLvQA=="
},
"node_modules/once": { "node_modules/once": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -1796,6 +2096,15 @@
"integrity": "sha512-viyQI64VIja0Za3njIzhoEP8ZVkgPowhZPuG0E96NwBfYJ6ZIyrrlhWGtFPkdN7eYLl2L8CTWL+wla0evm1KKQ==", "integrity": "sha512-viyQI64VIja0Za3njIzhoEP8ZVkgPowhZPuG0E96NwBfYJ6ZIyrrlhWGtFPkdN7eYLl2L8CTWL+wla0evm1KKQ==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/path": {
"name": "bare-node-path",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/bare-node-path/-/bare-node-path-1.0.1.tgz",
"integrity": "sha512-44YiNN/ofG1cEOWwdRVbG9ZFyskZKXPJnSUOyUpdeRm+MeSXiY+pSjSa7SZ56C/XpPVxE0OEl/AV8u+3FKsclw==",
"dependencies": {
"bare-path": "*"
}
},
"node_modules/pear-aliases": { "node_modules/pear-aliases": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/pear-aliases/-/pear-aliases-1.0.7.tgz", "resolved": "https://registry.npmjs.org/pear-aliases/-/pear-aliases-1.0.7.tgz",
@@ -2246,6 +2555,44 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-config-holepunch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/prettier-config-holepunch/-/prettier-config-holepunch-2.0.0.tgz",
"integrity": "sha512-yuskcdPRfQYLFPkOGvxS4TFmCb7QvAowjO+YaNLPLBWRev+qu6HXoKkPWH9lfNsL9gUThV7IeZ6t/B1QSu/jng==",
"peerDependencies": {
"prettier": "^3.6.2"
}
},
"node_modules/process": {
"name": "bare-process",
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/bare-process/-/bare-process-4.5.1.tgz",
"integrity": "sha512-CaAvy1trputD49mtwfJ6G75vydhnirLrW/F3Sznp4H556e1uZn8YMo9ELicBTrGYy7RBNUgPl9bpB/ERzRCiDw==",
"dependencies": {
"bare-abort": "^2.0.13",
"bare-env": "^3.0.0",
"bare-events": "^2.3.1",
"bare-hrtime": "^2.0.0",
"bare-os": "^3.7.1",
"bare-posix": "^1.0.1",
"bare-signals": "^5.0.0",
"bare-stdio": "^1.0.1"
}
},
"node_modules/protobufjs": { "node_modules/protobufjs": {
"version": "7.6.5", "version": "7.6.5",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
@@ -2317,6 +2664,23 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode-terminal": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
"integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
"bin": {
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/querystring": {
"name": "bare-node-querystring",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/bare-node-querystring/-/bare-node-querystring-1.0.0.tgz",
"integrity": "sha512-IjSuYNNxWRzUTHb0bLXJkMaUahMVX74BfCy/Rva/V+3J07zLMpXAWXYPMiCCZHUAxCeIhqfkcIenUgg7UXCV4A==",
"dependencies": {
"bare-querystring": "*"
}
},
"node_modules/queue-tick": { "node_modules/queue-tick": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
@@ -2799,6 +3163,14 @@
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense" "license": "Unlicense"
}, },
"node_modules/typpy": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/typpy/-/typpy-2.4.0.tgz",
"integrity": "sha512-a16Uv5doNtvHzaG4wZCHmXN+l9xxmTMpyODtPz7B3DSTsDVNXilTSJGuNw68sUh0Un4bf+ghRMbEcJCI6r06mQ==",
"dependencies": {
"function.name": "^1.0.3"
}
},
"node_modules/udx-native": { "node_modules/udx-native": {
"version": "1.20.7", "version": "1.20.7",
"resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.20.7.tgz", "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.20.7.tgz",
@@ -2814,6 +3186,15 @@
"bare": ">=1.17.4" "bare": ">=1.17.4"
} }
}, },
"node_modules/ul": {
"version": "5.2.16",
"resolved": "https://registry.npmjs.org/ul/-/ul-5.2.16.tgz",
"integrity": "sha512-v1YrSEsJZpJsywzF/MKgsQwMdOwBlwwmNiUOJh/yX6FHrq7dYjeua1YOhLV0q0KioqEFZC4P7MsKmpEsGdZz3w==",
"dependencies": {
"deffy": "^2.2.2",
"typpy": "^2.3.4"
}
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
@@ -2835,6 +3216,15 @@
"b4a": "^1.6.6" "b4a": "^1.6.6"
} }
}, },
"node_modules/url": {
"name": "bare-url",
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz",
"integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/url-file-url": { "node_modules/url-file-url": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/url-file-url/-/url-file-url-1.0.5.tgz", "resolved": "https://registry.npmjs.org/url-file-url/-/url-file-url-1.0.5.tgz",
+1
View File
@@ -42,6 +42,7 @@
"dockerode": "^5.0.1", "dockerode": "^5.0.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"graceful-goodbye": "^1.3.3", "graceful-goodbye": "^1.3.3",
"holesail": "^2.4.1",
"hypercore-crypto": "^3.7.0", "hypercore-crypto": "^3.7.0",
"hyperdht": "^6.33.0", "hyperdht": "^6.33.0",
"js-yaml": "^4.3.0", "js-yaml": "^4.3.0",
+3
View File
@@ -23,7 +23,10 @@ const AUDIT_METHODS = new Set([
'removeImage', 'removeImage',
'removeStack', 'removeStack',
'deployStack', 'deployStack',
'syncStackFromGit',
'deployContainer', 'deployContainer',
'createTunnel',
'closeTunnel',
'createContainer', 'createContainer',
'buildImage', 'buildImage',
'pushImage', 'pushImage',
+43
View File
@@ -5,6 +5,8 @@ import * as composeManager from '../utils/composeManager.js'
import * as validation from '../utils/validation.js' import * as validation from '../utils/validation.js'
import { docker } from '../services/docker.js' import { docker } from '../services/docker.js'
import { broadcastContainers } from './containers.js' import { broadcastContainers } from './containers.js'
import { fetchComposeFromGit } from '../utils/gitops.js'
import logger from '../utils/logger.js'
export function registerStackHandlers(session) { export function registerStackHandlers(session) {
session.respond('deployStack', async (args) => { session.respond('deployStack', async (args) => {
@@ -70,4 +72,45 @@ export function registerStackHandlers(session) {
composeContent: args.composeContent, composeContent: args.composeContent,
}) })
}) })
/**
* GitOps: fetch compose from git and deploy.
* args: { stackName, repoUrl, ref?, composePath?, build?, rollback? }
*/
session.respond('syncStackFromGit', async (args = {}) => {
const stackName = validation.sanitizeString(args.stackName, 63)
if (!stackName || !validation.isValidContainerName(stackName)) {
throw new Error('Valid stackName required')
}
if (!args.repoUrl) throw new Error('repoUrl required')
logger.info('GitOps sync starting', {
stackName,
repo: String(args.repoUrl).slice(0, 80),
ref: args.ref || 'main',
})
const fetched = await fetchComposeFromGit({
repoUrl: args.repoUrl,
ref: args.ref,
composePath: args.composePath,
})
composeManager.validateComposeFile(fetched.composeContent)
const result = await composeManager.deployComposeStack(
docker,
fetched.composeContent,
stackName,
{
build: Boolean(args.build),
rollback: args.rollback !== false,
}
)
await broadcastContainers()
return {
success: true,
message: `Stack "${stackName}" deployed from git${fetched.commit ? ` @ ${fetched.commit.slice(0, 8)}` : ''}`,
commit: fetched.commit || null,
composePath: fetched.path,
...result,
}
})
} }
+158
View File
@@ -0,0 +1,158 @@
/**
* Holesail tunnel RPC handlers.
* Gated by ENABLE_HOLESAIL=1.
*/
import {
isHolesailEnabled,
isHolesailAvailable,
listTunnels,
getTunnel,
createTunnel,
closeTunnel,
holesailStatus,
} from '../services/holesail-tunnels.js'
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import logger from '../utils/logger.js'
function assertHolesail() {
if (!isHolesailEnabled()) {
const err = new Error('Holesail tunnels disabled. Set ENABLE_HOLESAIL=1 to enable.')
err.code = 'FEATURE_DISABLED'
throw err
}
if (!isHolesailAvailable()) {
const err = new Error(
'Holesail package is not available on this server. Install dependency "holesail".'
)
err.code = 'FEATURE_DISABLED'
throw err
}
}
/**
* Resolve a published container port to host bind address.
* @param {string} containerId
* @param {number} containerPort
* @param {'tcp'|'udp'} [protocol]
*/
async function resolveContainerPublish(containerId, containerPort, protocol = 'tcp') {
const id = validation.sanitizeString(containerId, 128)
if (!id) throw Object.assign(new Error('containerId required'), { code: 'INVALID_ARGS' })
const c = docker.getContainer(id)
const inspect = await c.inspect()
const ports = inspect?.NetworkSettings?.Ports || {}
const key = `${containerPort}/${protocol}`
const bindings = ports[key]
if (!bindings?.length) {
throw Object.assign(
new Error(
`Container has no published binding for ${key}. Publish the port (e.g. -p 8080:${containerPort}) first.`
),
{ code: 'INVALID_ARGS' }
)
}
const b = bindings[0]
let host = b.HostIp || '127.0.0.1'
// Docker often uses 0.0.0.0 for "all interfaces" — tunnel to loopback is safer/default
if (!host || host === '0.0.0.0' || host === '::') host = '127.0.0.1'
const hostPort = parseInt(b.HostPort, 10)
if (!hostPort) {
throw Object.assign(new Error(`Invalid host port mapping for ${key}`), {
code: 'INVALID_ARGS',
})
}
const name = (inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
return {
host,
port: hostPort,
containerId: inspect.Id,
containerName: name,
}
}
export function registerTunnelHandlers(session) {
// Always register so clients get FEATURE_DISABLED when off.
session.respond('listTunnels', async () => {
assertHolesail()
return {
success: true,
tunnels: listTunnels(),
status: holesailStatus(),
}
})
session.respond('getHolesailStatus', async () => {
return {
success: true,
status: holesailStatus(),
}
})
session.respond('createTunnel', async (args = {}) => {
assertHolesail()
const protocol = args.protocol === 'udp' ? 'udp' : 'tcp'
let host = args.host
let port = args.port
let containerId = args.containerId || null
let containerName = args.containerName || null
if (args.containerId && args.containerPort != null) {
const resolved = await resolveContainerPublish(
args.containerId,
Number(args.containerPort),
protocol
)
host = resolved.host
port = resolved.port
containerId = resolved.containerId
containerName = resolved.containerName
}
if (port == null) {
throw Object.assign(new Error('port is required (or containerId + containerPort)'), {
code: 'INVALID_ARGS',
})
}
const tunnel = await createTunnel({
name: args.name,
host,
port: Number(port),
protocol,
secure: args.secure !== false,
containerId,
containerName,
})
logger.info('Tunnel created via RPC', {
id: tunnel.id,
peerId: session.id?.slice?.(0, 12),
name: tunnel.name,
})
return { success: true, tunnel }
})
session.respond('closeTunnel', async (args = {}) => {
assertHolesail()
const id = validation.sanitizeString(args.id || args.tunnelId, 64)
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const existing = getTunnel(id)
if (!existing) {
throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' })
}
await closeTunnel(id)
return { success: true, id, closed: true }
})
session.respond('getTunnel', async (args = {}) => {
assertHolesail()
const id = validation.sanitizeString(args.id || args.tunnelId, 64)
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const tunnel = getTunnel(id)
if (!tunnel) {
throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' })
}
return { success: true, tunnel }
})
}
+2
View File
@@ -18,6 +18,7 @@ import { registerPeerHandlers } from '../handlers/peers.js'
import { registerVaultHandlers } from '../handlers/vault.js' import { registerVaultHandlers } from '../handlers/vault.js'
import { registerBinaryStreamHandlers } from './binary-stream.js' import { registerBinaryStreamHandlers } from './binary-stream.js'
import { registerSuggestionHandlers } from '../handlers/suggestions.js' import { registerSuggestionHandlers } from '../handlers/suggestions.js'
import { registerTunnelHandlers } from '../handlers/tunnels.js'
/** /**
* @param {import('./session.js').PeerSession} session * @param {import('./session.js').PeerSession} session
@@ -40,6 +41,7 @@ export function registerAllHandlers(session) {
registerVaultHandlers(session) registerVaultHandlers(session)
registerBinaryStreamHandlers(session) registerBinaryStreamHandlers(session)
registerSuggestionHandlers(session) registerSuggestionHandlers(session)
registerTunnelHandlers(session)
} }
/** /**
+26
View File
@@ -16,6 +16,12 @@ import { startDockerEventStream, stopDockerEventStream } from './services/events
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js' import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
import { isPeerRevoked } from './core/peer-policy.js' import { isPeerRevoked } from './core/peer-policy.js'
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js' import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
import {
closeAllTunnels,
isHolesailEnabled,
holesailStatus,
restoreTunnelsFromDisk,
} from './services/holesail-tunnels.js'
import logger from './utils/logger.js' import logger from './utils/logger.js'
const { keyPair, publicKeyHex } = loadOrCreateKeyPair() const { keyPair, publicKeyHex } = loadOrCreateKeyPair()
@@ -64,16 +70,36 @@ console.log('══════════════════════
console.log(' peardock server ready') console.log(' peardock server ready')
console.log(` Public key (paste into the client):`) console.log(` Public key (paste into the client):`)
console.log(` ${publicKeyHex}`) console.log(` ${publicKeyHex}`)
if (isHolesailEnabled()) {
const hs = holesailStatus()
console.log(
` Holesail tunnels: ${hs.available ? 'enabled' : 'enabled but package missing'} (max ${hs.max})`
)
} else {
console.log(' Holesail tunnels: off (set ENABLE_HOLESAIL=1 to enable)')
}
console.log('═══════════════════════════════════════════════════════════') console.log('═══════════════════════════════════════════════════════════')
console.log('') console.log('')
startDockerEventStream() startDockerEventStream()
startStatsBroadcast() startStatsBroadcast()
// Recreate persisted Holesail tunnels (same hs:// keys when possible)
if (isHolesailEnabled()) {
restoreTunnelsFromDisk().catch((err) => {
logger.warn('Tunnel restore failed', { error: err.message })
})
}
async function shutdown() { async function shutdown() {
console.log('[INFO] Server shutting down…') console.log('[INFO] Server shutting down…')
stopStatsBroadcast() stopStatsBroadcast()
stopDockerEventStream() stopDockerEventStream()
try {
await closeAllTunnels()
} catch {
// ignore
}
peers.clear() peers.clear()
try { try {
await server.close() await server.close()
+430
View File
@@ -0,0 +1,430 @@
/**
* Holesail tunnel manager with optional disk persistence.
*
* peardock control plane: HyperDHT + protomux-rpc
* Data plane tunnels: Holesail L4 TCP/UDP reverse proxy (hs:// keys)
*
* Gated by ENABLE_HOLESAIL=1. Package: holesail (AGPL-3.0).
*
* @see https://github.com/holesail/holesail
* @see docs/HOLESAIL.md
*/
import { createRequire } from 'module'
import { randomBytes } from 'crypto'
import fs from 'fs'
import path from 'path'
import logger from '../utils/logger.js'
const require = createRequire(import.meta.url)
/** @typedef {{
* id: string,
* name: string,
* host: string,
* port: number,
* protocol: 'tcp'|'udp',
* secure: boolean,
* url: string,
* key: string,
* publicKey?: string,
* containerId?: string|null,
* containerName?: string|null,
* createdAt: string,
* state: string,
* persist?: boolean,
* }} TunnelInfo */
const MAX_TUNNELS = Math.max(1, Number(process.env.PEARDOCK_MAX_TUNNELS || 20))
const DEFAULT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0'])
function persistPath() {
return (
process.env.PEARDOCK_TUNNELS_PATH || path.join(process.cwd(), 'peardock-tunnels.json')
)
}
/** @type {Map<string, { instance: any, info: TunnelInfo }>} */
const tunnels = new Map()
let HolesailCtor = null
let holesailLoadError = null
let restoreDone = false
function loadHolesail() {
if (HolesailCtor) return HolesailCtor
if (holesailLoadError) throw holesailLoadError
try {
HolesailCtor = require('holesail')
return HolesailCtor
} catch (err) {
holesailLoadError = err
throw err
}
}
export function isHolesailEnabled() {
return process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true'
}
export function isHolesailAvailable() {
try {
loadHolesail()
return true
} catch {
return false
}
}
export function getTunnelsPersistPath() {
return persistPath()
}
/**
* @returns {Set<string>}
*/
export function allowedTunnelHosts() {
const set = new Set(DEFAULT_HOSTS)
const extra = process.env.PEARDOCK_TUNNEL_HOSTS || ''
for (const part of extra.split(/[,\s]+/)) {
const h = part.trim().toLowerCase()
if (h) set.add(h)
}
return set
}
/**
* @param {string} host
* @param {number} port
*/
export function assertTunnelTarget(host, port) {
const h = String(host || '127.0.0.1').trim().toLowerCase()
const p = Number(port)
if (!Number.isInteger(p) || p < 1 || p > 65535) {
throw Object.assign(new Error('port must be an integer 165535'), { code: 'INVALID_ARGS' })
}
const allowed = allowedTunnelHosts()
if (!allowed.has(h)) {
throw Object.assign(
new Error(
`Host "${host}" is not allowed for tunnels. Allowed: ${[...allowed].join(', ')}. ` +
'Extend with PEARDOCK_TUNNEL_HOSTS=host1,host2'
),
{ code: 'PERMISSION_DENIED' }
)
}
return { host: h === 'localhost' ? '127.0.0.1' : host, port: p }
}
function newId() {
return `tnl_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}`
}
/**
* Persistable definitions (no live sockets). Keys are capabilities file mode 600.
* @returns {object[]}
*/
function buildPersistPayload() {
return [...tunnels.values()]
.filter((t) => t.info.persist !== false)
.map((t) => ({
id: t.info.id,
name: t.info.name,
host: t.info.host,
port: t.info.port,
protocol: t.info.protocol,
secure: t.info.secure,
key: t.info.key,
containerId: t.info.containerId || null,
containerName: t.info.containerName || null,
createdAt: t.info.createdAt,
persist: true,
}))
}
export function saveTunnelsToDisk() {
if (!isHolesailEnabled()) return false
try {
const payload = {
version: 1,
updatedAt: new Date().toISOString(),
tunnels: buildPersistPayload(),
}
const file = persistPath()
const tmp = `${file}.${process.pid}.tmp`
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 })
fs.renameSync(tmp, file)
try {
fs.chmodSync(file, 0o600)
} catch {
// ignore
}
return true
} catch (err) {
logger.warn('Failed to persist tunnels', { error: err.message })
return false
}
}
/**
* Read definitions from disk (does not start tunnels).
* @returns {object[]}
*/
export function loadTunnelDefsFromDisk() {
try {
const file = persistPath()
if (!fs.existsSync(file)) return []
const raw = JSON.parse(fs.readFileSync(file, 'utf8'))
const list = Array.isArray(raw?.tunnels) ? raw.tunnels : Array.isArray(raw) ? raw : []
return list.filter((t) => t && t.port)
} catch (err) {
logger.warn('Failed to read tunnel persist file', { error: err.message })
return []
}
}
/**
* Public list shape (includes connection URL treat as secret capability).
* @returns {TunnelInfo[]}
*/
export function listTunnels() {
return [...tunnels.values()].map((t) => ({ ...t.info }))
}
/**
* @param {string} id
* @returns {TunnelInfo|null}
*/
export function getTunnel(id) {
const t = tunnels.get(id)
return t ? { ...t.info } : null
}
/**
* Start a Holesail server tunnel to host:port.
* @param {{
* id?: string,
* name?: string,
* host?: string,
* port: number,
* protocol?: 'tcp'|'udp',
* secure?: boolean,
* key?: string,
* containerId?: string|null,
* containerName?: string|null,
* persist?: boolean,
* createdAt?: string,
* skipPersistWrite?: boolean,
* }} opts
* @returns {Promise<TunnelInfo>}
*/
export async function createTunnel(opts = {}) {
if (!isHolesailEnabled()) {
const err = new Error('Holesail tunnels disabled. Set ENABLE_HOLESAIL=1 to enable.')
err.code = 'FEATURE_DISABLED'
throw err
}
if (tunnels.size >= MAX_TUNNELS) {
throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), {
code: 'RATE_LIMIT_EXCEEDED',
})
}
const Holesail = loadHolesail()
const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp'
const secure = opts.secure !== false
const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port)
const name =
String(opts.name || `${protocol}-${host}:${port}`).slice(0, 80) || `tunnel-${port}`
const persist = opts.persist !== false
// Avoid duplicate host:port:protocol
for (const t of tunnels.values()) {
if (
t.info.host === host &&
t.info.port === port &&
t.info.protocol === protocol &&
t.info.state === 'listening'
) {
throw Object.assign(
new Error(`Tunnel already active for ${protocol}://${host}:${port} (${t.info.id})`),
{ code: 'DOCKER_CONFLICT' }
)
}
}
const id = opts.id && String(opts.id).startsWith('tnl_') ? String(opts.id) : newId()
logger.info('Starting Holesail tunnel', { id, host, port, protocol, secure, persist })
const ctorOpts = {
server: true,
secure,
port,
host,
udp: protocol === 'udp',
log: false,
}
// Reuse connection key so restarts keep the same hs:// URL
if (opts.key) ctorOpts.key = String(opts.key)
const instance = new Holesail(ctorOpts)
try {
await instance.ready()
} catch (err) {
try {
await instance.close()
} catch {
// ignore
}
throw Object.assign(new Error(`Failed to start Holesail tunnel: ${err.message || err}`), {
code: 'DOCKER_ERROR',
cause: err,
})
}
const raw = instance.info || {}
/** @type {TunnelInfo} */
const info = {
id,
name,
host,
port,
protocol,
secure: Boolean(raw.secure ?? secure),
url: String(raw.url || ''),
key: String(raw.key || opts.key || ''),
publicKey: raw.publicKey ? String(raw.publicKey) : undefined,
containerId: opts.containerId || null,
containerName: opts.containerName || null,
createdAt: opts.createdAt || new Date().toISOString(),
state: String(raw.state || 'listening'),
persist,
}
if (!info.url) {
await safeClose(instance)
throw new Error('Holesail started but returned no connection URL')
}
tunnels.set(id, { instance, info })
if (!opts.skipPersistWrite) saveTunnelsToDisk()
logger.info('Holesail tunnel ready', {
id,
name: info.name,
target: `${protocol}://${host}:${port}`,
urlPrefix: info.url.slice(0, 12) + '…',
})
return { ...info }
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function closeTunnel(id) {
const entry = tunnels.get(id)
if (!entry) return false
tunnels.delete(id)
await safeClose(entry.instance)
saveTunnelsToDisk()
logger.info('Holesail tunnel closed', { id, name: entry.info.name })
return true
}
export async function closeAllTunnels() {
const ids = [...tunnels.keys()]
for (const id of ids) {
try {
const entry = tunnels.get(id)
tunnels.delete(id)
if (entry) await safeClose(entry.instance)
} catch (err) {
logger.warn('Failed to close tunnel on shutdown', { id, error: err.message })
}
}
// Keep persist file so next boot restores
}
/**
* Recreate tunnels from peardock-tunnels.json (call once at server boot).
* @returns {Promise<{ restored: number, failed: number }>}
*/
export async function restoreTunnelsFromDisk() {
if (restoreDone) return { restored: 0, failed: 0 }
restoreDone = true
if (!isHolesailEnabled()) return { restored: 0, failed: 0 }
const defs = loadTunnelDefsFromDisk()
if (!defs.length) return { restored: 0, failed: 0 }
let restored = 0
let failed = 0
for (const def of defs) {
try {
await createTunnel({
id: def.id,
name: def.name,
host: def.host,
port: def.port,
protocol: def.protocol,
secure: def.secure !== false,
key: def.key,
containerId: def.containerId,
containerName: def.containerName,
createdAt: def.createdAt,
persist: true,
skipPersistWrite: true,
})
restored += 1
} catch (err) {
failed += 1
logger.warn('Failed to restore tunnel', {
id: def.id,
port: def.port,
error: err.message,
})
}
}
saveTunnelsToDisk()
if (restored || failed) {
logger.info('Holesail tunnel restore complete', { restored, failed })
}
return { restored, failed }
}
async function safeClose(instance) {
if (!instance) return
try {
if (typeof instance.close === 'function') await instance.close()
else if (typeof instance.destroy === 'function') await instance.destroy()
} catch {
// ignore
}
}
export function holesailStatus() {
return {
enabled: isHolesailEnabled(),
available: isHolesailAvailable(),
active: tunnels.size,
max: MAX_TUNNELS,
allowedHosts: [...allowedTunnelHosts()],
persistPath: persistPath(),
}
}
export default {
isHolesailEnabled,
isHolesailAvailable,
listTunnels,
getTunnel,
createTunnel,
closeTunnel,
closeAllTunnels,
restoreTunnelsFromDisk,
saveTunnelsToDisk,
loadTunnelDefsFromDisk,
holesailStatus,
assertTunnelTarget,
getTunnelsPersistPath,
}
+2
View File
@@ -89,6 +89,8 @@ export function getMetricsSnapshot(extra = {}) {
features: { features: {
swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true', swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true',
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true', plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
holesail:
process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true',
peerAllowlist: peerAllowlist:
process.env.PEARDOCK_PEER_ALLOWLIST === '1' || process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true', process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
+129
View File
@@ -0,0 +1,129 @@
/**
* Minimal GitOps helper: shallow-clone a repo and read a compose file.
* Requires `git` on PATH on the peardock server host.
*/
import { spawn } from 'child_process'
import fs from 'fs'
import path from 'path'
import os from 'os'
import { randomBytes } from 'crypto'
/**
* @param {string} cmd
* @param {string[]} args
* @param {{ cwd?: string, timeoutMs?: number }} [opts]
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
*/
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
cwd: opts.cwd || process.cwd(),
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`Command timed out: ${cmd} ${args.join(' ')}`))
}, opts.timeoutMs || 120_000)
child.stdout?.on('data', (d) => {
stdout += d.toString()
})
child.stderr?.on('data', (d) => {
stderr += d.toString()
})
child.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
child.on('close', (code) => {
clearTimeout(timer)
resolve({ code: code ?? 1, stdout, stderr })
})
})
}
/**
* Shallow clone and read compose YAML.
* @param {{
* repoUrl: string,
* ref?: string,
* composePath?: string,
* }} opts
* @returns {Promise<{ composeContent: string, commit?: string, path: string }>}
*/
export async function fetchComposeFromGit(opts) {
const repoUrl = String(opts.repoUrl || '').trim()
if (!repoUrl) throw Object.assign(new Error('repoUrl required'), { code: 'INVALID_ARGS' })
if (!/^https?:\/\//i.test(repoUrl) && !/^git@/i.test(repoUrl)) {
throw Object.assign(
new Error('repoUrl must be http(s) or git@ URL'),
{ code: 'INVALID_ARGS' }
)
}
const ref = String(opts.ref || 'main').trim() || 'main'
let composePath = String(opts.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml'
// Path traversal guard
if (composePath.includes('..') || path.isAbsolute(composePath)) {
throw Object.assign(new Error('composePath must be a relative path without ..'), {
code: 'INVALID_ARGS',
})
}
const tmpRoot = path.join(os.tmpdir(), `peardock-gitops-${randomBytes(6).toString('hex')}`)
fs.mkdirSync(tmpRoot, { recursive: true })
try {
const clone = await run(
'git',
['clone', '--depth', '1', '--branch', ref, '--single-branch', repoUrl, tmpRoot],
{ timeoutMs: 180_000 }
)
if (clone.code !== 0) {
// Retry without branch if ref is a tag/sha that needs full history hint
const clone2 = await run('git', ['clone', '--depth', '1', repoUrl, tmpRoot], {
timeoutMs: 180_000,
})
if (clone2.code !== 0) {
throw new Error(
`git clone failed: ${(clone.stderr || clone2.stderr || clone.stdout).slice(0, 400)}`
)
}
if (ref && ref !== 'main' && ref !== 'master') {
await run('git', ['checkout', ref], { cwd: tmpRoot, timeoutMs: 60_000 })
}
}
const full = path.join(tmpRoot, composePath)
if (!fs.existsSync(full)) {
// try compose.yaml
const alt = path.join(tmpRoot, 'compose.yaml')
if (composePath === 'docker-compose.yml' && fs.existsSync(alt)) {
composePath = 'compose.yaml'
} else {
throw new Error(`Compose file not found in repo: ${composePath}`)
}
}
const filePath = path.join(tmpRoot, composePath)
const composeContent = fs.readFileSync(filePath, 'utf8')
if (!composeContent.trim()) throw new Error('Compose file is empty')
let commit = ''
try {
const rev = await run('git', ['rev-parse', 'HEAD'], { cwd: tmpRoot, timeoutMs: 10_000 })
if (rev.code === 0) commit = rev.stdout.trim()
} catch {
// ignore
}
return { composeContent, commit, path: composePath }
} finally {
try {
fs.rmSync(tmpRoot, { recursive: true, force: true })
} catch {
// ignore
}
}
}
export default { fetchComposeFromGit }
+15
View File
@@ -73,6 +73,10 @@ export const MethodRoles = Object.freeze({
suggestResourceName: Roles.viewer, suggestResourceName: Roles.viewer,
suggestFromImage: Roles.viewer, suggestFromImage: Roles.viewer,
suggestDefaults: Roles.viewer, suggestDefaults: Roles.viewer,
// Holesail tunnels (gated by ENABLE_HOLESAIL)
listTunnels: Roles.viewer,
getTunnel: Roles.viewer,
getHolesailStatus: Roles.viewer,
// operator // operator
startContainer: Roles.operator, startContainer: Roles.operator,
@@ -107,7 +111,10 @@ export const MethodRoles = Object.freeze({
connectNetwork: Roles.operator, connectNetwork: Roles.operator,
disconnectNetwork: Roles.operator, disconnectNetwork: Roles.operator,
deployContainer: Roles.operator, deployContainer: Roles.operator,
createTunnel: Roles.operator,
closeTunnel: Roles.operator,
deployStack: Roles.operator, deployStack: Roles.operator,
syncStackFromGit: Roles.operator,
browseDirectory: Roles.operator, browseDirectory: Roles.operator,
waitContainer: Roles.operator, waitContainer: Roles.operator,
stackPull: Roles.operator, stackPull: Roles.operator,
@@ -256,6 +263,7 @@ export const Methods = Object.freeze({
stackPs: 'stackPs', stackPs: 'stackPs',
stackLogs: 'stackLogs', stackLogs: 'stackLogs',
stackPull: 'stackPull', stackPull: 'stackPull',
syncStackFromGit: 'syncStackFromGit',
// System / host // System / host
getSystemInfo: 'getSystemInfo', getSystemInfo: 'getSystemInfo',
@@ -345,6 +353,13 @@ export const Methods = Object.freeze({
removePlugin: 'removePlugin', removePlugin: 'removePlugin',
inspectPlugin: 'inspectPlugin', inspectPlugin: 'inspectPlugin',
configurePlugin: 'configurePlugin', configurePlugin: 'configurePlugin',
// Holesail tunnels (gated by ENABLE_HOLESAIL)
listTunnels: 'listTunnels',
getTunnel: 'getTunnel',
createTunnel: 'createTunnel',
closeTunnel: 'closeTunnel',
getHolesailStatus: 'getHolesailStatus',
}) })
/** /**
+13
View File
@@ -0,0 +1,13 @@
import test from 'brittle'
import { fetchComposeFromGit } from '../server/utils/gitops.js'
test('fetchComposeFromGit rejects bad URLs', async (t) => {
await t.exception(() => fetchComposeFromGit({ repoUrl: '' }))
await t.exception(() => fetchComposeFromGit({ repoUrl: 'ftp://evil' }))
await t.exception(() =>
fetchComposeFromGit({
repoUrl: 'https://example.com/x.git',
composePath: '../etc/passwd',
})
)
})
+107
View File
@@ -0,0 +1,107 @@
import test from 'brittle'
import {
isHolesailEnabled,
assertTunnelTarget,
allowedTunnelHosts,
holesailStatus,
listTunnels,
} from '../server/services/holesail-tunnels.js'
test('isHolesailEnabled respects ENABLE_HOLESAIL', (t) => {
const prev = process.env.ENABLE_HOLESAIL
process.env.ENABLE_HOLESAIL = '1'
t.ok(isHolesailEnabled())
process.env.ENABLE_HOLESAIL = '0'
t.absent(isHolesailEnabled())
if (prev === undefined) delete process.env.ENABLE_HOLESAIL
else process.env.ENABLE_HOLESAIL = prev
})
test('assertTunnelTarget allows loopback and rejects arbitrary hosts', (t) => {
const ok = assertTunnelTarget('127.0.0.1', 8080)
t.is(ok.port, 8080)
t.exception(() => assertTunnelTarget('127.0.0.1', 0))
t.exception(() => assertTunnelTarget('10.0.0.5', 80))
})
test('PEARDOCK_TUNNEL_HOSTS extends allowlist', (t) => {
const prev = process.env.PEARDOCK_TUNNEL_HOSTS
process.env.PEARDOCK_TUNNEL_HOSTS = '10.0.0.5, app.local'
const hosts = allowedTunnelHosts()
t.ok(hosts.has('10.0.0.5'))
t.ok(hosts.has('app.local'))
const resolved = assertTunnelTarget('10.0.0.5', 3000)
t.is(resolved.port, 3000)
if (prev === undefined) delete process.env.PEARDOCK_TUNNEL_HOSTS
else process.env.PEARDOCK_TUNNEL_HOSTS = prev
})
test('holesailStatus reports structure', (t) => {
const st = holesailStatus()
t.ok(typeof st.enabled === 'boolean')
t.ok(typeof st.available === 'boolean')
t.ok(typeof st.active === 'number')
t.ok(Array.isArray(st.allowedHosts))
})
test('listTunnels starts empty', (t) => {
t.ok(Array.isArray(listTunnels()))
})
test('createTunnel end-to-end when ENABLE_HOLESAIL=1', async (t) => {
const prev = process.env.ENABLE_HOLESAIL
const prevPath = process.env.PEARDOCK_TUNNELS_PATH
const fs = await import('fs')
const os = await import('os')
const path = await import('path')
const tmpFile = path.join(os.tmpdir(), `peardock-tunnels-test-${Date.now()}.json`)
process.env.ENABLE_HOLESAIL = '1'
process.env.PEARDOCK_TUNNELS_PATH = tmpFile
t.teardown(async () => {
if (prev === undefined) delete process.env.ENABLE_HOLESAIL
else process.env.ENABLE_HOLESAIL = prev
if (prevPath === undefined) delete process.env.PEARDOCK_TUNNELS_PATH
else process.env.PEARDOCK_TUNNELS_PATH = prevPath
try {
fs.unlinkSync(tmpFile)
} catch {
// ignore
}
const { closeAllTunnels } = await import('../server/services/holesail-tunnels.js')
await closeAllTunnels()
})
// Free ephemeral local listener so tunnel has something to bind to
const net = await import('net')
const local = net.createServer((c) => c.end('ok'))
await new Promise((resolve) => local.listen(0, '127.0.0.1', resolve))
const port = local.address().port
t.teardown(
() =>
new Promise((resolve) => {
local.close(() => resolve())
})
)
const { createTunnel, closeTunnel, listTunnels: list, loadTunnelDefsFromDisk } = await import(
'../server/services/holesail-tunnels.js'
)
const tunnel = await createTunnel({
name: 'test-http',
host: '127.0.0.1',
port,
secure: true,
persist: true,
})
t.ok(tunnel.id)
t.ok(tunnel.url.startsWith('hs://'))
t.ok(tunnel.secure)
t.is(list().length, 1)
// Persisted to disk
const defs = loadTunnelDefsFromDisk()
t.ok(defs.some((d) => d.port === port))
t.ok(await closeTunnel(tunnel.id))
t.is(list().length, 0)
})
+54
View File
@@ -0,0 +1,54 @@
/**
* Lightweight visual/structure regression: ensure critical UI landmarks stay in index.html.
* Complements full screenshot suites (optional future) without browser automation deps.
*/
import test from 'brittle'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const html = fs.readFileSync(path.join(root, 'index.html'), 'utf8')
const REQUIRED_IDS = [
'sidebar',
'content',
'job-drawer',
'toast-stack',
'containers-view',
'deploy-view',
'tunnels-view',
'swarm-view',
'host-view',
'settings-view',
'deploy-wizard-steps',
'stack-git-url',
'tunnel-create-btn',
'swarm-services-body',
]
const REQUIRED_SNIPPETS = [
'data-view="tunnels"',
'data-view="swarm"',
'ENABLE_HOLESAIL',
'GitOps',
'collapse-sidebar-btn',
]
test('index.html retains critical view landmarks', (t) => {
for (const id of REQUIRED_IDS) {
t.ok(html.includes(`id="${id}"`), `missing #${id}`)
}
})
test('index.html retains critical feature snippets', (t) => {
for (const s of REQUIRED_SNIPPETS) {
t.ok(html.includes(s), `missing snippet: ${s}`)
}
})
test('modern.css includes collapsed sidebar rail rules', (t) => {
const css = fs.readFileSync(path.join(root, 'ui/modern.css'), 'utf8')
t.ok(css.includes('#sidebar.collapsed'))
t.ok(css.includes('icon') || css.includes('nav-link'))
})
+324 -1
View File
@@ -88,6 +88,7 @@ export function startListAutoRefresh(seconds) {
else if (view === 'volumes') send('listVolumes', {}, quiet) else if (view === 'volumes') send('listVolumes', {}, quiet)
else if (view === 'stacks') send('listStacks', {}, quiet) else if (view === 'stacks') send('listStacks', {}, quiet)
else if (view === 'host') loadHostView({ silent: true }) else if (view === 'host') loadHostView({ silent: true })
else if (view === 'tunnels') loadTunnelsView({ silent: true })
else if (view === 'events') loadEventsView({ silent: true }) else if (view === 'events') loadEventsView({ silent: true })
}, sec * 1000) }, sec * 1000)
} }
@@ -472,7 +473,7 @@ export async function loadHostView(opts = {}) {
<div>RPC total: ${metrics.rpc?.total ?? 0} · errors: ${metrics.rpc?.errors ?? 0}</div> <div>RPC total: ${metrics.rpc?.total ?? 0} · errors: ${metrics.rpc?.errors ?? 0}</div>
<div>Latency p50/p99: ${metrics.rpc?.latencyMs?.p50 ?? 0} / ${metrics.rpc?.latencyMs?.p99 ?? 0} ms</div> <div>Latency p50/p99: ${metrics.rpc?.latencyMs?.p50 ?? 0} / ${metrics.rpc?.latencyMs?.p99 ?? 0} ms</div>
<div>RSS: ${formatBytes(metrics.process?.rss)}</div> <div>RSS: ${formatBytes(metrics.process?.rss)}</div>
<div>Features: swarm=${metrics.features?.swarm} plugins=${metrics.features?.plugins}</div> <div>Features: swarm=${metrics.features?.swarm} plugins=${metrics.features?.plugins} holesail=${metrics.features?.holesail}</div>
` `
} }
if (raw) { if (raw) {
@@ -526,6 +527,278 @@ export function appendLiveEvent(evt) {
host.prepend(row) host.prepend(row)
} }
/**
* Holesail tunnels view list / create / close hs:// port tunnels.
*/
export async function loadTunnelsView(opts = {}) {
const silent = opts.silent === true
const banner = document.getElementById('tunnels-status-banner')
const list = document.getElementById('tunnels-list')
if (!manager.active?.connected) {
if (banner) {
banner.className = 'alert alert-warning small mb-3'
banner.innerHTML = 'Connect a peer to manage tunnels.'
}
if (list) list.innerHTML = '<div class="text-muted small p-2">Not connected</div>'
return
}
try {
const statusRes = await manager.request(Methods.getHolesailStatus, {}).catch(() => null)
const st = statusRes?.status || {}
if (!st.enabled) {
if (banner) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML =
'Holesail is <strong>off</strong> on this peer. Start the server with <code>ENABLE_HOLESAIL=1</code>.'
}
if (list) {
list.innerHTML =
'<div class="text-muted small p-2">Feature disabled on server</div>'
}
return
}
if (!st.available) {
if (banner) {
banner.className = 'alert alert-danger small mb-3'
banner.innerHTML =
'Holesail is enabled but the <code>holesail</code> package is not available on the server.'
}
return
}
const res = await manager.request(Methods.listTunnels, {})
const tunnels = res?.tunnels || []
if (banner) {
banner.className = 'alert alert-success small mb-3'
banner.innerHTML = `Holesail ready · <strong>${tunnels.length}</strong> active / max ${st.max || '—'} · allowed hosts: ${(st.allowedHosts || []).map(escape).join(', ') || '127.0.0.1'}`
}
if (!list) return
if (!tunnels.length) {
list.innerHTML = '<div class="text-muted small p-2">No active tunnels</div>'
return
}
list.innerHTML = tunnels
.map((t) => {
const target = `${t.protocol || 'tcp'}://${t.host}:${t.port}`
const url = t.url || ''
const meta = [
t.containerName ? `container ${t.containerName}` : null,
t.state || null,
t.persist === false ? 'ephemeral' : 'persisted',
t.createdAt ? new Date(t.createdAt).toLocaleString() : null,
]
.filter(Boolean)
.join(' · ')
return `<div class="list-group-item list-group-item-dark border-secondary" data-tunnel-id="${escape(t.id)}">
<div class="d-flex justify-content-between align-items-start gap-2 flex-wrap">
<div class="min-w-0">
<div class="fw-semibold">${escape(t.name || t.id)}</div>
<div class="small text-muted">${escape(target)}${meta ? ` · ${escape(meta)}` : ''}</div>
<code class="small d-block mt-1 text-break user-select-all">${escape(url)}</code>
</div>
<div class="btn-group btn-group-sm flex-shrink-0">
<button type="button" class="btn btn-outline-success tunnel-local-btn" data-url="${escape(url)}" title="Connect locally (Holesail client + browser)">
<i class="fas fa-plug"></i>
</button>
<button type="button" class="btn btn-outline-primary tunnel-copy-btn" data-url="${escape(url)}" title="Copy hs:// URL">
<i class="fas fa-copy"></i>
</button>
<button type="button" class="btn btn-outline-danger tunnel-close-btn" data-id="${escape(t.id)}" data-min-role="operator" title="Close tunnel">
<i class="fas fa-xmark"></i>
</button>
</div>
</div>
</div>`
})
.join('')
} catch (err) {
presentError(err, 'listTunnels', { showAlert, silent })
if (banner) {
banner.className = 'alert alert-danger small mb-3'
banner.textContent = err.message || 'Failed to load tunnels'
}
}
}
export async function createTunnelFromForm() {
const name = document.getElementById('tunnel-name')?.value?.trim()
const host = document.getElementById('tunnel-host')?.value?.trim() || '127.0.0.1'
const port = Number(document.getElementById('tunnel-port')?.value)
const protocol = document.getElementById('tunnel-protocol')?.value || 'tcp'
const secure = document.getElementById('tunnel-secure')?.checked !== false
if (!port || port < 1 || port > 65535) {
showAlert('warning', 'Enter a valid port (165535)')
return
}
try {
let createdUrl = ''
const job = await runJob(`Tunnel ${host}:${port}`, [
{
id: 'create',
label: 'Start Holesail tunnel',
run: async ({ log }) => {
log(`Target ${protocol}://${host}:${port} secure=${secure}`)
const res = await manager.request(Methods.createTunnel, {
name: name || undefined,
host,
port,
protocol,
secure,
})
const url = res?.tunnel?.url
if (url) {
createdUrl = url
log(`URL: ${url}`)
}
return res
},
},
])
showJob(job)
markFeedbackShown('success', `Tunnel created for ${host}:${port}`)
if (createdUrl && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(createdUrl)
showAlert('success', 'Tunnel created — hs:// URL copied')
} catch {
showAlert('success', 'Tunnel created')
}
} else {
showAlert('success', 'Tunnel created')
}
await loadTunnelsView()
} catch (err) {
if (!err?.viaJob) presentError(err, 'createTunnel', { showAlert })
}
}
export async function closeTunnelById(id) {
if (!id) return
try {
await manager.request(Methods.closeTunnel, { id })
markFeedbackShown('success', `Tunnel ${id} closed`)
await loadTunnelsView()
} catch (err) {
presentError(err, 'closeTunnel', { showAlert })
}
}
/**
* Bind a local Holesail client to an hs:// URL and optionally open the browser.
* @param {string} url
* @param {{ openBrowser?: boolean, localPort?: number }} [opts]
*/
/**
* Swarm services / nodes / tasks (ENABLE_SWARM=1).
*/
export async function loadSwarmView(opts = {}) {
const silent = opts.silent === true
const banner = document.getElementById('swarm-status-banner')
if (!manager.active?.connected) {
if (banner) {
banner.className = 'alert alert-warning small mb-3'
banner.textContent = 'Connect a peer to view Swarm.'
}
return
}
try {
const inspect = await manager.request(Methods.swarmInspect, {})
if (banner) {
banner.className = 'alert alert-success small mb-3'
const id = inspect?.data?.ID || inspect?.data?.id || '—'
banner.innerHTML = `Swarm active · ID <code>${escape(String(id).slice(0, 16))}</code>`
}
const [services, nodes, tasks] = await Promise.all([
manager.request(Methods.listServices, {}).catch(() => ({ data: [] })),
manager.request(Methods.listNodes, {}).catch(() => ({ data: [] })),
manager.request(Methods.listTasks, {}).catch(() => ({ data: [] })),
])
const svcBody = document.getElementById('swarm-services-body')
const nodeBody = document.getElementById('swarm-nodes-body')
const taskBody = document.getElementById('swarm-tasks-body')
const svcs = services?.data || []
if (svcBody) {
svcBody.innerHTML = svcs.length
? svcs
.map((s) => {
const name = s.Spec?.Name || s.Spec?.Labels?.['com.docker.stack.namespace'] || '—'
const image = s.Spec?.TaskTemplate?.ContainerSpec?.Image || '—'
const replicas = s.Spec?.Mode?.Replicated?.Replicas ?? s.Spec?.Mode?.Global ? 'global' : '—'
return `<tr><td>${escape(name)}</td><td class="small font-monospace">${escape(image)}</td><td>${escape(replicas)}</td><td class="small font-monospace">${escape(String(s.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="4" class="text-muted">No services</td></tr>'
}
const nds = nodes?.data || []
if (nodeBody) {
nodeBody.innerHTML = nds.length
? nds
.map((n) => {
const hostname = n.Description?.Hostname || '—'
const role = n.Spec?.Role || '—'
const status = n.Status?.State || '—'
const avail = n.Spec?.Availability || '—'
return `<tr><td>${escape(hostname)}</td><td>${escape(role)}</td><td>${escape(status)}</td><td>${escape(avail)}</td><td class="small font-monospace">${escape(String(n.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No nodes</td></tr>'
}
const tks = tasks?.data || []
if (taskBody) {
taskBody.innerHTML = tks.length
? tks
.slice(0, 200)
.map((t) => {
const svc = t.ServiceID ? String(t.ServiceID).slice(0, 12) : '—'
const node = t.NodeID ? String(t.NodeID).slice(0, 12) : '—'
return `<tr><td class="small font-monospace">${escape(svc)}</td><td class="small font-monospace">${escape(node)}</td><td>${escape(t.DesiredState || '—')}</td><td>${escape(t.Status?.State || '—')}</td><td class="small font-monospace">${escape(String(t.ID || '').slice(0, 12))}</td></tr>`
})
.join('')
: '<tr><td colspan="5" class="text-muted">No tasks</td></tr>'
}
} catch (err) {
const code = err?.code || ''
const msg = err?.message || String(err)
if (banner) {
if (code === 'FEATURE_DISABLED' || /ENABLE_SWARM|disabled/i.test(msg)) {
banner.className = 'alert alert-secondary small mb-3'
banner.innerHTML =
'Swarm APIs are <strong>off</strong>. Start the server with <code>ENABLE_SWARM=1</code>.'
} else {
banner.className = 'alert alert-danger small mb-3'
banner.textContent = msg
}
}
if (!silent) presentError(err, 'swarmInspect', { showAlert, silent: /FEATURE_DISABLED|disabled/i.test(msg) })
}
}
export async function connectLocalTunnel(url, opts = {}) {
if (!url) {
showAlert('warning', 'No tunnel URL')
return null
}
try {
showActivity(`Connecting Holesail client…`)
const { connectLocalHolesail } = await import('../client/holesailLocal.js')
const entry = await connectLocalHolesail(url, {
openBrowser: opts.openBrowser !== false,
localPort: opts.localPort,
})
completeActivity(true, `Local proxy on ${entry.host}:${entry.localPort}`)
showAlert(
'success',
`Local Holesail proxy listening on ${entry.host}:${entry.localPort}`
)
return entry
} catch (err) {
completeActivity(false, err.message || 'Local Holesail failed')
presentError(err, 'holesailLocal', { showAlert })
return null
}
}
/** @type {string[]} in-memory editor state for template list URLs */ /** @type {string[]} in-memory editor state for template list URLs */
let templateUrlsDraft = [] let templateUrlsDraft = []
@@ -650,10 +923,12 @@ export function openPalette(navigateToView) {
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks' }, { label: 'Networks', icon: 'fa-diagram-project', view: 'networks' },
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes' }, { label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes' },
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks' }, { label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks' },
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm' },
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy' }, { label: 'Deploy', icon: 'fa-rocket', view: 'deploy' },
{ label: 'Fleet', icon: 'fa-server', view: 'fleet' }, { label: 'Fleet', icon: 'fa-server', view: 'fleet' },
{ label: 'Events', icon: 'fa-bolt', view: 'events' }, { label: 'Events', icon: 'fa-bolt', view: 'events' },
{ label: 'Host', icon: 'fa-microchip', view: 'host' }, { label: 'Host', icon: 'fa-microchip', view: 'host' },
{ label: 'Tunnels', icon: 'fa-network-wired', view: 'tunnels' },
{ label: 'Access', icon: 'fa-user-shield', view: 'access' }, { label: 'Access', icon: 'fa-user-shield', view: 'access' },
{ label: 'Settings', icon: 'fa-gear', view: 'settings' }, { label: 'Settings', icon: 'fa-gear', view: 'settings' },
{ {
@@ -722,6 +997,48 @@ export function initOpsApp({ navigateToView, sendCommand }) {
}) })
document.getElementById('smart-net-create-btn')?.addEventListener('click', () => createSmartNetwork()) document.getElementById('smart-net-create-btn')?.addEventListener('click', () => createSmartNetwork())
document.getElementById('tunnels-refresh-btn')?.addEventListener('click', () => loadTunnelsView())
document.getElementById('tunnel-create-btn')?.addEventListener('click', () => createTunnelFromForm())
document.getElementById('swarm-refresh-btn')?.addEventListener('click', () => loadSwarmView())
document.getElementById('swarm-tabs')?.addEventListener('click', (e) => {
const btn = e.target?.closest?.('[data-swarm-tab]')
if (!btn) return
const tab = btn.getAttribute('data-swarm-tab')
document.querySelectorAll('#swarm-tabs .nav-link').forEach((el) => {
el.classList.toggle('active', el === btn)
})
document.querySelectorAll('.swarm-panel').forEach((panel) => {
panel.classList.toggle('hidden', panel.id !== `swarm-panel-${tab}`)
})
})
document.getElementById('tunnels-list')?.addEventListener('click', async (e) => {
const t = e.target
const copyBtn = t?.closest?.('.tunnel-copy-btn')
if (copyBtn) {
const url = copyBtn.getAttribute('data-url') || ''
if (url && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(url)
showAlert('success', 'hs:// URL copied')
} catch {
showAlert('warning', 'Could not copy — select the URL manually')
}
}
return
}
const localBtn = t?.closest?.('.tunnel-local-btn')
if (localBtn) {
const url = localBtn.getAttribute('data-url') || ''
if (url) await connectLocalTunnel(url, { openBrowser: true })
return
}
const closeBtn = t?.closest?.('.tunnel-close-btn')
if (closeBtn) {
const id = closeBtn.getAttribute('data-id')
if (id) await closeTunnelById(id)
}
})
document.getElementById('host-refresh-btn')?.addEventListener('click', () => loadHostView()) document.getElementById('host-refresh-btn')?.addEventListener('click', () => loadHostView())
document.getElementById('events-refresh-btn')?.addEventListener('click', () => loadEventsView()) document.getElementById('events-refresh-btn')?.addEventListener('click', () => loadEventsView())
document.getElementById('events-pause-btn')?.addEventListener('click', (e) => { document.getElementById('events-pause-btn')?.addEventListener('click', (e) => {
@@ -876,7 +1193,12 @@ export function initOpsApp({ navigateToView, sendCommand }) {
fetchMergedTemplates, fetchMergedTemplates,
loadHostView, loadHostView,
loadEventsView, loadEventsView,
loadTunnelsView,
loadSwarmView,
loadSettingsView, loadSettingsView,
createTunnelFromForm,
closeTunnelById,
connectLocalTunnel,
warmSnapshot, warmSnapshot,
appendLiveEvent, appendLiveEvent,
dismissJobDrawer, dismissJobDrawer,
@@ -905,6 +1227,7 @@ function escape(s) {
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
.replace(/</g, '&lt;') .replace(/</g, '&lt;')
.replace(/>/g, '&gt;') .replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
} }
function formatBytes(n) { function formatBytes(n) {
+39
View File
@@ -1,5 +1,44 @@
/* peardock Track B ops UI additions */ /* peardock Track B ops UI additions */
/* Deploy multi-step wizard chrome */
.deploy-wizard-steps {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.deploy-step {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.65rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text-muted, #9aa8bc);
font-size: 0.8rem;
font-weight: 600;
}
.deploy-step.active {
color: #ecfdf5;
border-color: rgba(52, 211, 153, 0.45);
background: rgba(52, 211, 153, 0.12);
}
.deploy-step-num {
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
background: rgba(255, 255, 255, 0.08);
}
.deploy-step.active .deploy-step-num {
background: rgba(52, 211, 153, 0.35);
color: #ecfdf5;
}
.sidebar-section-label { .sidebar-section-label {
font-size: 0.7rem; font-size: 0.7rem;
text-transform: uppercase; text-transform: uppercase;