diff --git a/cloudflare-tunnel-setup.md b/cloudflare-tunnel-setup.md new file mode 100644 index 0000000..2c0097c --- /dev/null +++ b/cloudflare-tunnel-setup.md @@ -0,0 +1,217 @@ +# Cloudflare Tunnel Setup for Incus Host + +## Overview + +Single routing point for all services across Incus instances. +Services self-declare by dropping a file into a shared directory. +No port forwarding, no OPNsense reverse proxy config, no DDNS. + +## Prerequisites + +- Domain: ampbox.io (registrar: Namecheap) +- Cloudflare account (free tier) with ampbox.io DNS delegated + - At Namecheap: set nameservers to the ones Cloudflare assigns + - Verify domain is active in Cloudflare dashboard + +## 1. Install cloudflared on the Incus host + +```bash +curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ + | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \ + https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ + | sudo tee /etc/apt/sources.list.d/cloudflared.list +sudo apt update && sudo apt install -y cloudflared +``` + +## 2. Authenticate and create the tunnel + +```bash +cloudflared tunnel login # opens browser, authorize ampbox.io +cloudflared tunnel create ampbox # creates tunnel, saves credentials JSON +cloudflared tunnel route dns ampbox "*.ampbox.io" # wildcard DNS route +``` + +Note the credentials file path printed (typically +`~/.cloudflared/.json`). You need it for step 4. + +## 3. Create the service declaration directory + +```bash +sudo mkdir -p /etc/cloudflared/services.d +``` + +## 4. Write the main tunnel config + +```bash +# Replace with your actual tunnel ID from step 2 +sudo tee /etc/cloudflared/config.yml << 'EOF' +tunnel: ampbox +credentials-file: /root/.cloudflared/.json + +ingress: + - service: http_status:404 +EOF +``` + +## 5. Share the declaration directory into all instances + +```bash +incus profile device add default svc-declare disk \ + source=/etc/cloudflared/services.d \ + path=/mnt/services +``` + +## 6. Helper scripts (on the host) + +### /usr/local/bin/svc-rebuild + +Reads all service declarations and rebuilds the cloudflared ingress config. + +```bash +#!/usr/bin/env bash +set -euo pipefail + +CONFIG="/etc/cloudflared/config.yml" +SERVICES_DIR="/etc/cloudflared/services.d" +TUNNEL_CREDS="/root/.cloudflared/.json" # fix this + +cat > "$CONFIG" <
> "$CONFIG" + done < "$f" +done + +echo " - service: http_status:404" >> "$CONFIG" +sudo systemctl restart cloudflared +echo "Rebuilt with $(ls "$SERVICES_DIR"/*.yml 2>/dev/null | wc -l) service(s)" +``` + +```bash +sudo install -m 755 /dev/stdin /usr/local/bin/svc-rebuild < svc-rebuild.sh +``` + +### /usr/local/bin/svc-watch + +Auto-rebuilds on file changes (optional but recommended). + +```bash +#!/usr/bin/env bash +set -euo pipefail + +SERVICES_DIR="/etc/cloudflared/services.d" +echo "Watching $SERVICES_DIR for changes..." +while inotifywait -q -e create,delete,modify "$SERVICES_DIR"; do + sleep 1 # debounce + /usr/local/bin/svc-rebuild +done +``` + +```bash +sudo apt install -y inotify-tools +sudo install -m 755 /dev/stdin /usr/local/bin/svc-watch < svc-watch.sh +``` + +## 7. Systemd services (on the host) + +### /etc/systemd/system/cloudflared.service + +```ini +[Unit] +Description=Cloudflare Tunnel +After=network-online.target +Wants=network-online.target + +[Service] +ExecStart=/usr/local/bin/cloudflared tunnel run ampbox +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +### /etc/systemd/system/svc-watch.service + +```ini +[Unit] +Description=Service declaration watcher +After=cloudflared.service + +[Service] +ExecStart=/usr/local/bin/svc-watch +Restart=always + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now cloudflared svc-watch +``` + +## 8. Declaring services FROM INSIDE an instance + +### Expose an HTTP service + +```bash +cat > /mnt/services/myapp.yml << EOF +- hostname: myapp.ampbox.io + service: http://$(hostname -I | awk '{print $1}'):8080 +EOF +``` + +### Expose SSH + +```bash +cat > /mnt/services/ssh-$(hostname).yml << EOF +- hostname: ssh-$(hostname).ampbox.io + service: ssh://$(hostname -I | awk '{print $1}'):22 +EOF +``` + +### Remove a service + +```bash +rm /mnt/services/myapp.yml +# svc-watch picks it up and rebuilds automatically +``` + +## 9. Client-side SSH config (on laptops / remote machines) + +``` +# ~/.ssh/config +Host *.ampbox.io + ProxyCommand cloudflared access ssh --hostname %h +``` + +Then: `ssh user@ssh-myinstance.ampbox.io` just works. + +## 10. OPNsense cleanup + +Once the tunnel is verified working, remove from OPNsense: +- All per-service reverse proxy rules in os-caddy +- Port forward / NAT rules for 80 and 443 +- DDNS configuration (no longer needed) + +OPNsense goes back to just being your firewall. + +## Quick Reference + +| Action | From inside instance | +|-------------------------|----------------------------------------------------------| +| Expose HTTP | `echo "- hostname: X.ampbox.io\n service: http://IP:PORT" > /mnt/services/X.yml` | +| Expose SSH | Same pattern with `service: ssh://IP:22` | +| Expose raw TCP | Same pattern with `service: tcp://IP:PORT` | +| Remove service | `rm /mnt/services/X.yml` | +| List registered | `ls /mnt/services/` | +| Check tunnel status | `sudo systemctl status cloudflared` (from host) | \ No newline at end of file diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 74cf9ac..9eb9669 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1,5 +1,20 @@ // Phase 2b implementation — app.js +/** Inline SVG icon strings used throughout the UI. */ +var _icons = { + statusOk: '', + statusWarn: '', + statusErr: '', + kebab: '', + chevronLeft: '', + chevronRight: '', + chevronUp: '', + chevronDown: '', + check: '', + bell: '', + close: '', +}; + /** * Format a Unix timestamp (seconds) into a relative time string. * @param {number|null|undefined} ts - Unix timestamp in seconds @@ -324,13 +339,13 @@ function setConnectionStatus(level) { const el = $('connection-status'); if (!el) return; const map = { - ok: { text: '●', cls: 'connection-status--ok' }, - warn: { text: '◌ slow', cls: 'connection-status--warn' }, - err: { text: '✕ offline', cls: 'connection-status--err' }, + ok: { text: _icons.statusOk, cls: 'connection-status--ok' }, + warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' }, + err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' }, }; const s = map[level]; if (!s) return; - el.textContent = s.text; + el.innerHTML = s.text; el.className = s.cls; } @@ -543,7 +558,7 @@ function buildTileHTML(session, index, mobile) { `${escapeHtml(name)}` + `${badgeHtml}` + `${escapeHtml(timeStr)}` + - `` + + `` + `` + `
${ansiToHtml(lastLines)}
` + `` @@ -601,7 +616,7 @@ function buildSidebarHTML(session, currentSession, currentRemoteId) { `` + `` + `` @@ -959,7 +974,7 @@ function toggleSidebar() { patchServerSetting('sidebarOpen', isOpen); var collapseBtn = $('sidebar-collapse-btn'); - if (collapseBtn) collapseBtn.textContent = isOpen ? '\u2039' : '\u203a'; + if (collapseBtn) collapseBtn.innerHTML = isOpen ? _icons.chevronLeft : _icons.chevronRight; } /** @@ -1437,7 +1452,7 @@ function renderViewsSettingsTab() { // Up button var upBtn = document.createElement('button'); upBtn.className = 'views-settings-btn'; - upBtn.textContent = '\u25b2'; + upBtn.innerHTML = _icons.chevronUp; upBtn.title = 'Move up'; upBtn.setAttribute('data-action', 'move-up'); upBtn.setAttribute('data-idx', String(idx)); @@ -1446,7 +1461,7 @@ function renderViewsSettingsTab() { // Down button var downBtn = document.createElement('button'); downBtn.className = 'views-settings-btn'; - downBtn.textContent = '\u25bc'; + downBtn.innerHTML = _icons.chevronDown; downBtn.title = 'Move down'; downBtn.setAttribute('data-action', 'move-down'); downBtn.setAttribute('data-idx', String(idx)); @@ -2038,7 +2053,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) { var v = views[i]; var isIn = (v.sessions || []).indexOf(sessionKey) !== -1; html += ''; } @@ -2082,7 +2097,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) { // Update checkmark immediately for responsiveness var checkEl = viewBtn.querySelector('span'); - if (checkEl) checkEl.textContent = nowIn ? '\u2713' : '\u00a0\u00a0'; + if (checkEl) checkEl.innerHTML = nowIn ? _icons.check : '\u00a0\u00a0'; api('PATCH', '/api/settings', patch) .then(function() { @@ -2095,7 +2110,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) { .catch(function(err) { showToast('Couldn\u2019t save \u2014 try again'); // Revert checkmark - if (checkEl) checkEl.textContent = nowIn ? '\u00a0\u00a0' : '\u2713'; + if (checkEl) checkEl.innerHTML = nowIn ? '\u00a0\u00a0' : _icons.check; console.warn('[_openMobileViewPicker] PATCH failed:', err); }); }); @@ -2170,7 +2185,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) { var v = views[i]; var isIn = (v.sessions || []).indexOf(sessionKey) !== -1; html += ''; } @@ -2283,7 +2298,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) { var checkEl = checkItems[ci].querySelector('.flyout-submenu__check'); var updViews = (_serverSettings && _serverSettings.views) || []; if (checkEl && updViews[vi]) { - checkEl.textContent = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : ''; + checkEl.innerHTML = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? _icons.check : ''; } } } @@ -2474,7 +2489,7 @@ function openManageViewPanel() { nameRow.innerHTML = '

' + escapeHtml(_activeView) + '

' + ''; + 'title="Delete this view" aria-label="Delete view">' + _icons.close + ''; } renderManageViewList(); @@ -3193,7 +3208,7 @@ function _buildRemoteInstanceRow(url, name, key) { keyInput.setAttribute('aria-label', 'Federation key for remote instance'); var removeBtn = document.createElement('button'); removeBtn.className = 'settings-remote-remove'; - removeBtn.textContent = '\u00d7'; + removeBtn.innerHTML = _icons.close; removeBtn.setAttribute('aria-label', 'Remove remote instance'); row.appendChild(urlInput); row.appendChild(nameInput); @@ -3731,7 +3746,7 @@ function renderSheetList() { return '
  • ' + '' + escapedName + '' + - (hasBell ? '\uD83D\uDD14' : '') + + (hasBell ? '' + _icons.bell + '' : '') + '' + formatTimestamp(s.bell && s.bell.last_fired_at) + '' + '
  • '; }).join(''); @@ -4013,26 +4028,30 @@ async function createNewSession(name, remoteId) { // Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format) var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName; - // Poll until the session appears in _currentSessions (max 30s, every 2s) + // Poll until the session appears in _currentSessions. + // Check immediately first (session may already be in cache), then retry + // at short intervals. This eliminates the old 2-second dead-wait. var attempts = 0; - var maxAttempts = 15; - var pollForSession = setInterval(async function() { + var maxAttempts = 30; + async function checkForSession() { attempts++; await pollSessions(); var found = _currentSessions && _currentSessions.find(function(s) { return (s.sessionKey || s.name) === expectedKey; }); if (found) { - clearInterval(pollForSession); removeLoadingTile(); showToast('Session \'' + sessionName + '\' ready'); openSession(sessionName, { remoteId: deviceId }); } else if (attempts >= maxAttempts) { - clearInterval(pollForSession); removeLoadingTile(); showToast('Session \'' + sessionName + '\' is taking longer than expected'); + } else { + // Short retry: 500ms for first few attempts, then 2s + setTimeout(checkForSession, attempts <= 5 ? 500 : 2000); } - }, 2000); + } + checkForSession(); } catch (err) { showToast(err.message || 'Failed to create session'); } diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index 2d84050..d88e5a0 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -22,14 +22,14 @@
    - - - + + +
    @@ -41,10 +41,10 @@