Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
CI / test (push) Successful in 9m58s
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:
@@ -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.
|
||||
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`.
|
||||
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:
|
||||
|
||||
|
||||
+37
-17
@@ -11,9 +11,11 @@
|
||||
| **A — Platform / Engine / security (Phases 0–6)** | ✅ Complete |
|
||||
| **B — Product UI & operator experience (Phases 7–12)** | ✅ Complete |
|
||||
| **C — UX polish & full-app experience (Phases 13–16)** | ✅ Complete |
|
||||
| **D — Holesail tunnels** | ✅ Complete |
|
||||
| **Optional future items** | ✅ Complete |
|
||||
|
||||
```bash
|
||||
npm test # 81+ tests green
|
||||
npm test # full suite green
|
||||
npm run server
|
||||
npm run dev
|
||||
```
|
||||
@@ -30,7 +32,10 @@ npm run dev
|
||||
| Error model / quiet RPC noise | `client/errors.js` |
|
||||
| Job stepper + live log follow | `client/jobs.js`, `#job-drawer` |
|
||||
| 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 ✅
|
||||
|
||||
- [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] Quiet/dedupe background RPC noise
|
||||
- [x] Destructive confirm respects Settings
|
||||
@@ -90,8 +95,30 @@ npm run dev
|
||||
- [x] First-connect “what next” checklist
|
||||
- [x] Job tray live log follows output
|
||||
- [x] Modern dashboard redesign
|
||||
- [ ] Optional: Swarm screens when `ENABLE_SWARM=1` (server ready; deferred)
|
||||
- [ ] Optional: virtualized tables for 500+ rows (deferred)
|
||||
- [x] Swarm screens when `ENABLE_SWARM=1` (`#swarm-view` services/nodes/tasks)
|
||||
- [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 |
|
||||
| Offline | Banner + reconnect |
|
||||
| Multi-peer | Fleet + active chip + disk peer cache |
|
||||
| Feedback | Toast stack + notification tray |
|
||||
| Feedback | Job tray + notification history |
|
||||
| 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
|
||||
7. ✅ Peers persist across restarts (`peers.json`)
|
||||
8. ✅ Automated tests green (`npm test`)
|
||||
9. ✅ Optional roadmap items completed
|
||||
|
||||
---
|
||||
|
||||
## Optional future (not blocking roadmap complete)
|
||||
|
||||
- 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.
|
||||
**Roadmap closed.** Tracks A–D and previously optional items are implemented.
|
||||
|
||||
@@ -789,6 +789,8 @@ function navigateToView(viewName, opts = {}) {
|
||||
loadVolumes();
|
||||
} else if (viewName === 'stacks') {
|
||||
loadStacks();
|
||||
} else if (viewName === 'swarm') {
|
||||
window.peardockOps?.loadSwarmView?.();
|
||||
} else if (viewName === 'deploy') {
|
||||
loadDeployView();
|
||||
} else if (viewName === 'fleet') {
|
||||
@@ -799,6 +801,8 @@ function navigateToView(viewName, opts = {}) {
|
||||
window.peardockOps?.loadEventsView?.();
|
||||
} else if (viewName === 'host') {
|
||||
window.peardockOps?.loadHostView?.();
|
||||
} else if (viewName === 'tunnels') {
|
||||
window.peardockOps?.loadTunnelsView?.();
|
||||
} else if (viewName === 'settings') {
|
||||
window.peardockOps?.loadSettingsView?.();
|
||||
}
|
||||
@@ -2022,11 +2026,9 @@ function setupDeployStackHandler() {
|
||||
}
|
||||
|
||||
showStatusIndicator(`Deploying stack "${stackName}"...`);
|
||||
sendCommand('deployStack', { stackName, composeContent });
|
||||
|
||||
try {
|
||||
const response = await waitForPeerResponse(`Stack "${stackName}" deployed successfully`);
|
||||
showAlert('success', response.message);
|
||||
const response = await manager.request(Methods.deployStack, { stackName, composeContent });
|
||||
showAlert('success', response?.message || `Stack "${stackName}" deployed`);
|
||||
|
||||
// Close modal and reset form
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deploy-stack-modal'));
|
||||
@@ -2038,12 +2040,48 @@ function setupDeployStackHandler() {
|
||||
loadStacks();
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to deploy stack:', error);
|
||||
showAlert('danger', error.message || 'Failed to deploy stack');
|
||||
presentError(error, 'deployStack', { showAlert });
|
||||
} finally {
|
||||
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
|
||||
@@ -2462,7 +2500,7 @@ function populateContainerDetails(config, container) {
|
||||
// Stats Tab - will be updated by stats updates
|
||||
updateContainerDetailsStats(container);
|
||||
|
||||
// Attach copy button event listeners after content is populated
|
||||
// Attach copy / tunnel button event listeners after content is populated
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.copy-btn').forEach(btn => {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3126,9 +3219,21 @@ function populateNetworkingTab(config) {
|
||||
<td><span class="protocol-badge ${binding.protocol}">${binding.protocol}</span></td>
|
||||
<td>
|
||||
${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>
|
||||
</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>'}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -6244,59 +6349,39 @@ function formatImageName(imageName) {
|
||||
}
|
||||
|
||||
// Render the container list with optimized DOM manipulation
|
||||
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;
|
||||
}
|
||||
/** Virtualized container table state (windowed rows for 500+ containers) */
|
||||
const containerVirt = {
|
||||
rows: [],
|
||||
topicId: null,
|
||||
scrollEl: null,
|
||||
bound: false,
|
||||
rowHeight: 52,
|
||||
overscan: 8,
|
||||
};
|
||||
|
||||
console.log(`[INFO] Rendering ${containers.length} containers for topic: ${topicId}`);
|
||||
|
||||
// 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
|
||||
function buildContainerRow(container) {
|
||||
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown';
|
||||
const image = formatImageName(container.Image || '-');
|
||||
const containerId = container.Id;
|
||||
const ipAddress = container.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 });
|
||||
}
|
||||
|
||||
const row = document.createElement('tr');
|
||||
row.dataset.containerId = containerId; // Store container ID for reference
|
||||
row.dataset.containerId = containerId;
|
||||
const state = container.State || 'Unknown';
|
||||
const stateLower = state.toLowerCase();
|
||||
const statusClass = stateLower === 'running' ? 'status-running' :
|
||||
stateLower === 'exited' || stateLower === 'stopped' ? 'status-exited' :
|
||||
stateLower === 'created' ? 'status-created' :
|
||||
stateLower === 'restarting' ? 'status-restarting' : '';
|
||||
const statusClass =
|
||||
stateLower === 'running'
|
||||
? 'status-running'
|
||||
: stateLower === 'exited' || stateLower === 'stopped'
|
||||
? 'status-exited'
|
||||
: stateLower === 'created'
|
||||
? 'status-created'
|
||||
: stateLower === 'restarting'
|
||||
? 'status-restarting'
|
||||
: '';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>
|
||||
@@ -6364,16 +6449,10 @@ function renderContainers(containers, topicId) {
|
||||
</td>
|
||||
`;
|
||||
|
||||
fragment.appendChild(row);
|
||||
// Add event listener for checkbox
|
||||
const checkbox = row.querySelector('.container-checkbox');
|
||||
if (checkbox) {
|
||||
checkbox.addEventListener('change', () => updateBulkActionsToolbar());
|
||||
}
|
||||
// Add event listener for duplicate button
|
||||
if (checkbox) checkbox.addEventListener('change', () => updateBulkActionsToolbar());
|
||||
const duplicateBtn = row.querySelector('.action-duplicate');
|
||||
duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
|
||||
// Add event listener for clickable container name (hidden link for details view)
|
||||
if (duplicateBtn) duplicateBtn.addEventListener('click', () => openDuplicateModal(container));
|
||||
const nameLink = row.querySelector('.container-name-link');
|
||||
if (nameLink) {
|
||||
nameLink.addEventListener('click', (e) => {
|
||||
@@ -6381,8 +6460,6 @@ function renderContainers(containers, topicId) {
|
||||
showContainerDetails(container);
|
||||
});
|
||||
}
|
||||
|
||||
// Add event listener for container name display (single click to view details)
|
||||
const nameDisplay = row.querySelector('.container-name-display');
|
||||
if (nameDisplay) {
|
||||
nameDisplay.addEventListener('click', (e) => {
|
||||
@@ -6390,11 +6467,114 @@ function renderContainers(containers, topicId) {
|
||||
showContainerDetails(container);
|
||||
});
|
||||
}
|
||||
// Add event listeners for action buttons
|
||||
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.appendChild(fragment);
|
||||
}
|
||||
|
||||
@@ -372,6 +372,27 @@ export const api = {
|
||||
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) ——
|
||||
swarmInspect(connection) {
|
||||
return connOrActive(connection).request(Methods.swarmInspect, {})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 peardock’s `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.
|
||||
@@ -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_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_BROWSE_OPEN=1` | Legacy open host FS browse (discouraged) |
|
||||
|
||||
|
||||
+2
-1
@@ -27,6 +27,7 @@ See `package.json` / `package-lock.json`. Major surface:
|
||||
| graceful-goodbye | Shutdown |
|
||||
| hypercore-crypto | Key material |
|
||||
| pear-electron / pear-bridge | Desktop shell |
|
||||
| holesail | Optional P2P TCP/UDP tunnels (`ENABLE_HOLESAIL=1`) — **AGPL-3.0** |
|
||||
|
||||
## Known-sensitive native deps
|
||||
|
||||
@@ -34,4 +35,4 @@ See `package.json` / `package-lock.json`. Major surface:
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
- **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.
|
||||
- **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`.
|
||||
|
||||
---
|
||||
@@ -56,7 +57,8 @@
|
||||
| Rate limit | Per-peer limiter on RPC |
|
||||
| Registry secrets | AES-256-GCM vault (`registry-vault.js`) |
|
||||
| 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
@@ -148,6 +148,12 @@
|
||||
<span class="nav-label">Stacks</span>
|
||||
</a>
|
||||
</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-item">
|
||||
<a href="#" class="nav-link" data-view="images" title="Images">
|
||||
@@ -174,6 +180,12 @@
|
||||
<span class="nav-label">Host</span>
|
||||
</a>
|
||||
</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">
|
||||
<a href="#" class="nav-link" data-view="access" title="Access">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
@@ -422,6 +434,13 @@
|
||||
<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>
|
||||
<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 & ports</span></div>
|
||||
<div class="deploy-step" data-step="3"><span class="deploy-step-num">3</span><span class="deploy-step-label">Volumes & env</span></div>
|
||||
<div class="deploy-step" data-step="4"><span class="deploy-step-num">4</span><span class="deploy-step-label">Security & runtime</span></div>
|
||||
<div class="deploy-step" data-step="5"><span class="deploy-step-num">5</span><span class="deploy-step-label">Review & deploy</span></div>
|
||||
</div>
|
||||
<form id="deploy-view-form" onsubmit="return false;">
|
||||
<!-- Basic Settings (Always Visible) -->
|
||||
<div class="mb-4">
|
||||
@@ -1355,6 +1374,49 @@
|
||||
</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 -->
|
||||
<div class="modal fade" id="deploy-stack-modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -1371,7 +1433,7 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<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:
|
||||
web:
|
||||
image: nginx
|
||||
@@ -1382,6 +1444,26 @@ services:
|
||||
environment:
|
||||
POSTGRES_PASSWORD: password"></textarea>
|
||||
</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 & deploy
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -1551,6 +1633,81 @@ services:
|
||||
</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 <url></code> (binds a local port).</li>
|
||||
<li>Open <code>http://127.0.0.1:<local-port></code> for HTTP services.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div id="settings-view" class="view hidden">
|
||||
<div class="container-fluid">
|
||||
|
||||
Generated
+400
-10
@@ -14,6 +14,7 @@
|
||||
"dockerode": "^5.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"graceful-goodbye": "^1.3.3",
|
||||
"holesail": "^2.4.1",
|
||||
"hypercore-crypto": "^3.7.0",
|
||||
"hyperdht": "^6.33.0",
|
||||
"js-yaml": "^4.3.0",
|
||||
@@ -86,6 +87,16 @@
|
||||
"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": {
|
||||
"version": "6.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@hyperswarm/secret-stream/-/secret-stream-6.9.1.tgz",
|
||||
@@ -235,6 +246,11 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
@@ -352,7 +368,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-bundle/-/bare-bundle-1.10.0.tgz",
|
||||
"integrity": "sha512-4LVlnJAHr00Hh6Vu6ZUJS38rcEtJT3b3vChXSsBsJ2mk1TN0lQ+gzd+Dw5L0aV7uqDZv84smuwW+O02X7PfDlw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*",
|
||||
@@ -403,7 +418,6 @@
|
||||
"version": "1.15.3",
|
||||
"resolved": "https://registry.npmjs.org/bare-crypto/-/bare-crypto-1.15.3.tgz",
|
||||
"integrity": "sha512-macV9lbyJTsLPRXJkBtz8ivTGEo3LCyJInLT9IB/PWJ7pRXwvHs/FP4bx/fWw+HZkiepIYCAV2cuU5CR92XWCw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-assert": "^1.2.0",
|
||||
@@ -433,12 +447,20 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-debug-log/-/bare-debug-log-2.0.0.tgz",
|
||||
"integrity": "sha512-Vi42PkMQsNV9PUpx2Gl1hikshx5O9FzMJ6o9Nnopseg7qLBBK7Nl31d0RHcfwLEAfmcPApytpc0ZFfq68u22FQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-dns/-/bare-dns-2.1.4.tgz",
|
||||
@@ -452,7 +474,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bare-encoding/-/bare-encoding-1.0.3.tgz",
|
||||
"integrity": "sha512-Kqf+t/azs13lUeyK4Tb7ha4wdLRXKWCXQ8w1rVmt7KtoPCPdHD/Xwt7LBIsCSwwGglrcmblo5VOLa5avkJqULA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
@@ -609,7 +630,6 @@
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-module/-/bare-module-6.4.0.tgz",
|
||||
"integrity": "sha512-Yn4V5g5EqGQL4LYUOmt7fjKzj2JPWyJOqE3lPoeZwfUH5rk4CKUfZj6JhDwbzhBYCqqmUgjgQ5aY8cihAPILLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-bundle": "^1.3.0",
|
||||
@@ -635,7 +655,6 @@
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-module-lexer/-/bare-module-lexer-1.6.2.tgz",
|
||||
"integrity": "sha512-KcbnmqEo4Zu4p2g8PTiFFNWf8KmtTR5Uc7I1mWD/p9A8OM/02qnXweemCqA3ZIrc+6SDLTMEtxnCk2lt9Q+aGA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"require-addon": "^1.0.2"
|
||||
@@ -670,7 +689,6 @@
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-net/-/bare-net-2.3.2.tgz",
|
||||
"integrity": "sha512-I+yz+pqbYsBkxDsnu5vkKvy7RSNY9CcAvu2jZT6PsmdXJQG1i3dmD5V7xc3334OVp2absgtUEYLmmuNFlphBzg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-events": "^2.2.2",
|
||||
@@ -729,6 +747,11 @@
|
||||
"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": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-readline/-/bare-readline-1.3.1.tgz",
|
||||
@@ -815,7 +838,6 @@
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-stylize/-/bare-stylize-0.0.1.tgz",
|
||||
"integrity": "sha512-l3MjmIl476bWijYWf3RbE+osl4iuXSOMudzp0vAqzIK7gPgn/+G3oAxp8Oin9CFF911KBP0LO9kts8Ci8mGZaQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"bare-ansi-escapes": "^2.2.3",
|
||||
@@ -912,7 +934,6 @@
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-type-stripper/-/bare-type-stripper-0.1.4.tgz",
|
||||
"integrity": "sha512-FdZhp9XEnQpj8AWFmIft/sVUyKS9XSmB6PhcxBHhuEDxxZM5Kkt8+kFS7eEpLXR7TkaRkNpSENoGH/8lpAmtkA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"require-addon": "^1.0.2"
|
||||
@@ -939,7 +960,6 @@
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-utils/-/bare-utils-1.6.0.tgz",
|
||||
"integrity": "sha512-WhQEIkkAxkSnW7u1QgrI0AfNm5JpMruETXeYsb5qnkBJ0TTfNKygZmsh6rkoHBANaV+C/7Jed7bJP9OmEHG7rQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -1165,6 +1193,30 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
@@ -1177,6 +1229,18 @@
|
||||
"integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==",
|
||||
"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": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
@@ -1209,6 +1273,14 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"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": {
|
||||
"version": "3.3.0",
|
||||
"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": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz",
|
||||
@@ -1433,6 +1513,30 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
@@ -1461,6 +1565,14 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz",
|
||||
@@ -1514,6 +1626,139 @@
|
||||
"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": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz",
|
||||
@@ -1628,12 +1873,22 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-valid-variable/-/is-valid-variable-1.0.1.tgz",
|
||||
"integrity": "sha512-ucsrYzt8kcgu2CAOFFWSfOTwa+vPNzQHK1RL6RooSkwozJ92e9wl4cyG/GLDeuNEVj56kvuNZGCR7eSkzrhudg==",
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/localdrive/-/localdrive-1.12.2.tgz",
|
||||
@@ -1704,6 +1970,14 @@
|
||||
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||
"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": {
|
||||
"version": "1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/mirror-drive/-/mirror-drive-1.14.2.tgz",
|
||||
@@ -1725,6 +1999,15 @@
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1759,6 +2042,18 @@
|
||||
"integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/noise-curve-ed/-/noise-curve-ed-2.1.0.tgz",
|
||||
@@ -1781,6 +2076,11 @@
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -1796,6 +2096,15 @@
|
||||
"integrity": "sha512-viyQI64VIja0Za3njIzhoEP8ZVkgPowhZPuG0E96NwBfYJ6ZIyrrlhWGtFPkdN7eYLl2L8CTWL+wla0evm1KKQ==",
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pear-aliases/-/pear-aliases-1.0.7.tgz",
|
||||
@@ -2246,6 +2555,44 @@
|
||||
"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": {
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
@@ -2317,6 +2664,23 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
|
||||
@@ -2799,6 +3163,14 @@
|
||||
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
|
||||
"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": {
|
||||
"version": "1.20.7",
|
||||
"resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.20.7.tgz",
|
||||
@@ -2814,6 +3186,15 @@
|
||||
"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": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
@@ -2835,6 +3216,15 @@
|
||||
"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": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/url-file-url/-/url-file-url-1.0.5.tgz",
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"dockerode": "^5.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"graceful-goodbye": "^1.3.3",
|
||||
"holesail": "^2.4.1",
|
||||
"hypercore-crypto": "^3.7.0",
|
||||
"hyperdht": "^6.33.0",
|
||||
"js-yaml": "^4.3.0",
|
||||
|
||||
@@ -23,7 +23,10 @@ const AUDIT_METHODS = new Set([
|
||||
'removeImage',
|
||||
'removeStack',
|
||||
'deployStack',
|
||||
'syncStackFromGit',
|
||||
'deployContainer',
|
||||
'createTunnel',
|
||||
'closeTunnel',
|
||||
'createContainer',
|
||||
'buildImage',
|
||||
'pushImage',
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as composeManager from '../utils/composeManager.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { docker } from '../services/docker.js'
|
||||
import { broadcastContainers } from './containers.js'
|
||||
import { fetchComposeFromGit } from '../utils/gitops.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
export function registerStackHandlers(session) {
|
||||
session.respond('deployStack', async (args) => {
|
||||
@@ -70,4 +72,45 @@ export function registerStackHandlers(session) {
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { registerPeerHandlers } from '../handlers/peers.js'
|
||||
import { registerVaultHandlers } from '../handlers/vault.js'
|
||||
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
||||
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
||||
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
||||
|
||||
/**
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
@@ -40,6 +41,7 @@ export function registerAllHandlers(session) {
|
||||
registerVaultHandlers(session)
|
||||
registerBinaryStreamHandlers(session)
|
||||
registerSuggestionHandlers(session)
|
||||
registerTunnelHandlers(session)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,12 @@ import { startDockerEventStream, stopDockerEventStream } from './services/events
|
||||
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
|
||||
import { isPeerRevoked } from './core/peer-policy.js'
|
||||
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
|
||||
import {
|
||||
closeAllTunnels,
|
||||
isHolesailEnabled,
|
||||
holesailStatus,
|
||||
restoreTunnelsFromDisk,
|
||||
} from './services/holesail-tunnels.js'
|
||||
import logger from './utils/logger.js'
|
||||
|
||||
const { keyPair, publicKeyHex } = loadOrCreateKeyPair()
|
||||
@@ -64,16 +70,36 @@ console.log('══════════════════════
|
||||
console.log(' peardock server ready')
|
||||
console.log(` Public key (paste into the client):`)
|
||||
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('')
|
||||
|
||||
startDockerEventStream()
|
||||
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() {
|
||||
console.log('[INFO] Server shutting down…')
|
||||
stopStatsBroadcast()
|
||||
stopDockerEventStream()
|
||||
try {
|
||||
await closeAllTunnels()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
peers.clear()
|
||||
try {
|
||||
await server.close()
|
||||
|
||||
@@ -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 1–65535'), { 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,
|
||||
}
|
||||
@@ -89,6 +89,8 @@ export function getMetricsSnapshot(extra = {}) {
|
||||
features: {
|
||||
swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true',
|
||||
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
|
||||
holesail:
|
||||
process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true',
|
||||
peerAllowlist:
|
||||
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
|
||||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
|
||||
|
||||
@@ -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 }
|
||||
@@ -73,6 +73,10 @@ export const MethodRoles = Object.freeze({
|
||||
suggestResourceName: Roles.viewer,
|
||||
suggestFromImage: Roles.viewer,
|
||||
suggestDefaults: Roles.viewer,
|
||||
// Holesail tunnels (gated by ENABLE_HOLESAIL)
|
||||
listTunnels: Roles.viewer,
|
||||
getTunnel: Roles.viewer,
|
||||
getHolesailStatus: Roles.viewer,
|
||||
|
||||
// operator
|
||||
startContainer: Roles.operator,
|
||||
@@ -107,7 +111,10 @@ export const MethodRoles = Object.freeze({
|
||||
connectNetwork: Roles.operator,
|
||||
disconnectNetwork: Roles.operator,
|
||||
deployContainer: Roles.operator,
|
||||
createTunnel: Roles.operator,
|
||||
closeTunnel: Roles.operator,
|
||||
deployStack: Roles.operator,
|
||||
syncStackFromGit: Roles.operator,
|
||||
browseDirectory: Roles.operator,
|
||||
waitContainer: Roles.operator,
|
||||
stackPull: Roles.operator,
|
||||
@@ -256,6 +263,7 @@ export const Methods = Object.freeze({
|
||||
stackPs: 'stackPs',
|
||||
stackLogs: 'stackLogs',
|
||||
stackPull: 'stackPull',
|
||||
syncStackFromGit: 'syncStackFromGit',
|
||||
|
||||
// System / host
|
||||
getSystemInfo: 'getSystemInfo',
|
||||
@@ -345,6 +353,13 @@ export const Methods = Object.freeze({
|
||||
removePlugin: 'removePlugin',
|
||||
inspectPlugin: 'inspectPlugin',
|
||||
configurePlugin: 'configurePlugin',
|
||||
|
||||
// Holesail tunnels (gated by ENABLE_HOLESAIL)
|
||||
listTunnels: 'listTunnels',
|
||||
getTunnel: 'getTunnel',
|
||||
createTunnel: 'createTunnel',
|
||||
closeTunnel: 'closeTunnel',
|
||||
getHolesailStatus: 'getHolesailStatus',
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -88,6 +88,7 @@ export function startListAutoRefresh(seconds) {
|
||||
else if (view === 'volumes') send('listVolumes', {}, quiet)
|
||||
else if (view === 'stacks') send('listStacks', {}, quiet)
|
||||
else if (view === 'host') loadHostView({ silent: true })
|
||||
else if (view === 'tunnels') loadTunnelsView({ silent: true })
|
||||
else if (view === 'events') loadEventsView({ silent: true })
|
||||
}, 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>Latency p50/p99: ${metrics.rpc?.latencyMs?.p50 ?? 0} / ${metrics.rpc?.latencyMs?.p99 ?? 0} ms</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) {
|
||||
@@ -526,6 +527,278 @@ export function appendLiveEvent(evt) {
|
||||
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 (1–65535)')
|
||||
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 */
|
||||
let templateUrlsDraft = []
|
||||
|
||||
@@ -650,10 +923,12 @@ export function openPalette(navigateToView) {
|
||||
{ label: 'Networks', icon: 'fa-diagram-project', view: 'networks' },
|
||||
{ label: 'Volumes', icon: 'fa-hard-drive', view: 'volumes' },
|
||||
{ label: 'Stacks', icon: 'fa-boxes-stacked', view: 'stacks' },
|
||||
{ label: 'Swarm', icon: 'fa-project-diagram', view: 'swarm' },
|
||||
{ label: 'Deploy', icon: 'fa-rocket', view: 'deploy' },
|
||||
{ label: 'Fleet', icon: 'fa-server', view: 'fleet' },
|
||||
{ label: 'Events', icon: 'fa-bolt', view: 'events' },
|
||||
{ label: 'Host', icon: 'fa-microchip', view: 'host' },
|
||||
{ label: 'Tunnels', icon: 'fa-network-wired', view: 'tunnels' },
|
||||
{ label: 'Access', icon: 'fa-user-shield', view: 'access' },
|
||||
{ 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('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('events-refresh-btn')?.addEventListener('click', () => loadEventsView())
|
||||
document.getElementById('events-pause-btn')?.addEventListener('click', (e) => {
|
||||
@@ -876,7 +1193,12 @@ export function initOpsApp({ navigateToView, sendCommand }) {
|
||||
fetchMergedTemplates,
|
||||
loadHostView,
|
||||
loadEventsView,
|
||||
loadTunnelsView,
|
||||
loadSwarmView,
|
||||
loadSettingsView,
|
||||
createTunnelFromForm,
|
||||
closeTunnelById,
|
||||
connectLocalTunnel,
|
||||
warmSnapshot,
|
||||
appendLiveEvent,
|
||||
dismissJobDrawer,
|
||||
@@ -905,6 +1227,7 @@ function escape(s) {
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
|
||||
+39
@@ -1,5 +1,44 @@
|
||||
/* 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 {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
|
||||
Reference in New Issue
Block a user