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