feat(virtual-hosts): add TLS option for HTTPS/443 backends
CI / Build & Test (push) Failing after 2m44s
CI / Build & Test (push) Failing after 2m44s
- Add "Use TLS (secure connection)" checkbox in Add Virtual Host modal - Persist and restore useTls in state; show TLS badge in table - When enabled, HTTPS proxy connects to tunnel backend over TLS (SNI = hostname) for HTTP and WebSocket; supports services on port 443
This commit is contained in:
@@ -820,6 +820,13 @@
|
||||
<label class="form-label" for="addVhostHsUrl">hs:// URL</label>
|
||||
<input type="text" id="addVhostHsUrl" class="input mono" placeholder="hs://s000..." autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-checkbox">
|
||||
<input type="checkbox" id="addVhostUseTls" autocomplete="off">
|
||||
<span>Use TLS (secure connection)</span>
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--text4);margin-top:4px;">Enable if the service behind the tunnel uses HTTPS (port 443).</div>
|
||||
</div>
|
||||
<div class="modal-error" id="addVhostError"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
|
||||
@@ -54,6 +54,7 @@ function updateConnectionsTable(state) {
|
||||
tbody.innerHTML = virtualHosts.map(v => {
|
||||
const hostname = v.hostname || v.id || '';
|
||||
const hsUrl = v.hsUrl || '';
|
||||
const useTls = v.useTls === true;
|
||||
const backend = (v.localHost && v.localPort != null) ? v.localHost + ':' + v.localPort : '—';
|
||||
const openUrl = `https://${hostname}`;
|
||||
const safeHostname = hostname.replace(/"/g, '"');
|
||||
@@ -63,6 +64,7 @@ function updateConnectionsTable(state) {
|
||||
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
|
||||
<td>
|
||||
<span class="mono-chip">${escapeHtml(hostname)}</span>
|
||||
${useTls ? '<span class="badge badge-cyan" style="margin-left:6px;font-size:10px;">TLS</span>' : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div style="display:flex;align-items:center;gap:6px;">
|
||||
@@ -84,7 +86,7 @@ function updateConnectionsTable(state) {
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15,3 21,3 21,9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Open
|
||||
</a>
|
||||
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" title="Reconnect tunnel">
|
||||
${needsReconnect ? `<button class="btn btn-secondary btn-sm" data-reconnect-vhost="${safeHostname}" data-hs-url="${escapeHtml(hsUrl)}" data-use-tls="${useTls ? '1' : '0'}" title="Reconnect tunnel">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-.07-8.13"/></svg>
|
||||
Reconnect
|
||||
</button>` : ''}
|
||||
@@ -119,10 +121,11 @@ function updateConnectionsTable(state) {
|
||||
btn.addEventListener('click', () => {
|
||||
const hostname = btn.dataset.reconnectVhost;
|
||||
const hsUrl = btn.dataset.hsUrl;
|
||||
const useTls = btn.dataset.useTls === '1';
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Reconnecting…';
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl, useTls } } },
|
||||
(response) => {
|
||||
if (response?.ok) {
|
||||
showToast('Tunnel reconnecting…', 'success');
|
||||
@@ -177,9 +180,11 @@ function setupVirtualHostEvents() {
|
||||
$('addVhostSubmit')?.addEventListener('click', () => {
|
||||
const hostnameEl = $('addVhostHostname');
|
||||
const hsUrlEl = $('addVhostHsUrl');
|
||||
const useTlsEl = $('addVhostUseTls');
|
||||
let hostname = (hostnameEl?.value || '').trim();
|
||||
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
||||
const hsUrl = (hsUrlEl?.value || '').trim();
|
||||
const useTls = useTlsEl ? useTlsEl.checked : false;
|
||||
const hostnameValidation = isValidVhostHostname(hostname);
|
||||
if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
|
||||
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
|
||||
@@ -188,12 +193,13 @@ function setupVirtualHostEvents() {
|
||||
|
||||
function doAddVhost() {
|
||||
chrome.runtime.sendMessage(
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl } } },
|
||||
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname, hsUrl, useTls } } },
|
||||
(response) => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = 'Add Host'; }
|
||||
if (response?.ok) {
|
||||
if (hostnameEl) hostnameEl.value = '';
|
||||
if (hsUrlEl) hsUrlEl.value = '';
|
||||
if (useTlsEl) useTlsEl.checked = false;
|
||||
closeModal('modal-addVhost');
|
||||
showToast('Virtual host added', 'success');
|
||||
refresh();
|
||||
|
||||
@@ -30,7 +30,7 @@ function saveState() {
|
||||
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
|
||||
}));
|
||||
const virtualHostsList = vhostsModule.getVirtualHosts()
|
||||
.map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl }));
|
||||
.map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl, useTls: v.useTls === true }));
|
||||
const serviceTunnelsList = svcModule.getServiceTunnels().map(t => ({
|
||||
id: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort
|
||||
}));
|
||||
|
||||
@@ -19,7 +19,7 @@ function debugLog(...args) {
|
||||
if (process.stderr) process.stderr.write(msg + '\n');
|
||||
}
|
||||
|
||||
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt, reconnectTimer, reconnectDelay }
|
||||
const virtualHosts = new Map(); // hostname -> { hsUrl, useTls, holesail, localHost, localPort, state, createdAt, reconnectTimer, reconnectDelay }
|
||||
let _saveState = null;
|
||||
let _emit = null;
|
||||
let _getReadyTimeoutMs = null;
|
||||
@@ -57,7 +57,7 @@ function _scheduleVhostReconnect(hostname) {
|
||||
const cur = virtualHosts.get(hostname);
|
||||
if (!cur || cur.state === 'ready') return;
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' auto-reconnect attempt\n');
|
||||
await setVirtualHost({ hostname, hsUrl: cur.hsUrl });
|
||||
await setVirtualHost({ hostname, hsUrl: cur.hsUrl, useTls: cur.useTls });
|
||||
}, delay);
|
||||
}
|
||||
|
||||
@@ -78,13 +78,15 @@ function readyWithTimeout(hs, label) {
|
||||
* @param {object} payload
|
||||
* @param {string} payload.hostname - The virtual hostname (e.g. `myapp.hs`). Normalised to lowercase.
|
||||
* @param {string} payload.hsUrl - The hs:// key of the remote peer.
|
||||
* @param {boolean} [payload.useTls] - If true, proxy connects to backend over TLS (for HTTPS/443 backends).
|
||||
* @returns {Promise<{ok: boolean, hostname?: string, localHost?: string, localPort?: number, state?: string, error?: string}>}
|
||||
*/
|
||||
async function setVirtualHost(payload) {
|
||||
let hostname = (payload.hostname || payload.hostName || '').trim();
|
||||
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
||||
const hsUrl = payload.hsUrl || payload.url;
|
||||
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl);
|
||||
const useTls = payload.useTls === true;
|
||||
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl, 'useTls=', useTls);
|
||||
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
|
||||
if (!hostname || !hsUrl) return { ok: false, error: 'hostname and hsUrl required' };
|
||||
|
||||
@@ -113,7 +115,8 @@ async function setVirtualHost(payload) {
|
||||
});
|
||||
}
|
||||
await readyWithTimeout(hs, 'vhost:' + hostname);
|
||||
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
|
||||
const entryUseTls = existing ? (payload.useTls === true || existing.useTls === true) : useTls;
|
||||
virtualHosts.set(hostname, { hsUrl, useTls: entryUseTls, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
|
||||
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
|
||||
if (_saveState) _saveState();
|
||||
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
|
||||
@@ -123,7 +126,8 @@ async function setVirtualHost(payload) {
|
||||
try { hs.removeAllListeners(); } catch (_) {}
|
||||
releaseTunnelPort(localPort);
|
||||
const existingCreatedAt = existing ? existing.createdAt : undefined;
|
||||
virtualHosts.set(hostname, { hsUrl, holesail: null, localHost: null, localPort: null, state: 'error', createdAt: existingCreatedAt || Date.now() });
|
||||
const entryUseTls = existing ? (payload.useTls === true || existing.useTls === true) : useTls;
|
||||
virtualHosts.set(hostname, { hsUrl, useTls: entryUseTls, holesail: null, localHost: null, localPort: null, state: 'error', createdAt: existingCreatedAt || Date.now() });
|
||||
if (_emit) _emit('tunnelError', { hostname, error: e.message });
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
@@ -151,12 +155,12 @@ async function removeVirtualHost(payload) {
|
||||
|
||||
/**
|
||||
* Return a snapshot of all virtual hosts (including errored/closed ones).
|
||||
* @returns {Array<{hostname: string, hsUrl: string, localHost: string|null, localPort: number|null, state: string, createdAt: number}>}
|
||||
* @returns {Array<{hostname: string, hsUrl: string, useTls: boolean, localHost: string|null, localPort: number|null, state: string, createdAt: number}>}
|
||||
*/
|
||||
function getVirtualHosts() {
|
||||
const list = [];
|
||||
for (const [hostname, v] of virtualHosts) {
|
||||
list.push({ hostname, hsUrl: v.hsUrl, localHost: v.localHost ?? null, localPort: v.localPort ?? null, state: v.state || 'unknown', createdAt: v.createdAt });
|
||||
list.push({ hostname, hsUrl: v.hsUrl, useTls: v.useTls === true, localHost: v.localHost ?? null, localPort: v.localPort ?? null, state: v.state || 'unknown', createdAt: v.createdAt });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
@@ -172,14 +176,20 @@ function getLocalPortForHostname(hostname) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the `{ host, port }` backend object for the HTTPS proxy SNI resolver,
|
||||
* Return the `{ host, port, tls? }` backend object for the HTTPS proxy SNI resolver,
|
||||
* or null if the virtual host does not exist or is not yet ready.
|
||||
* When useTls is true, the proxy should connect to the backend over TLS.
|
||||
* @param {string} hostname
|
||||
* @returns {{host: string, port: number}|null}
|
||||
* @returns {{host: string, port: number, tls?: boolean}|null}
|
||||
*/
|
||||
function getLocalBackend(hostname) {
|
||||
const v = virtualHosts.get(hostname);
|
||||
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
|
||||
if (!v || v.localPort == null) {
|
||||
debugLog('getLocalBackend: hostname=', hostname, 'result=', null, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
|
||||
return null;
|
||||
}
|
||||
const out = { host: v.localHost ?? '127.0.0.1', port: v.localPort };
|
||||
if (v.useTls === true) out.tls = true;
|
||||
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function restorePersistedTunnels(holesailManager, restored) {
|
||||
}
|
||||
let vhostOk = 0, vhostFail = 0;
|
||||
for (const v of savedVhosts) {
|
||||
const result = await holesailManager.setVirtualHost({ hostname: v.hostname, hsUrl: v.hsUrl });
|
||||
const result = await holesailManager.setVirtualHost({ hostname: v.hostname, hsUrl: v.hsUrl, useTls: v.useTls === true });
|
||||
if (result.ok) vhostOk++; else vhostFail++;
|
||||
}
|
||||
let svcOk = 0, svcFail = 0;
|
||||
|
||||
+49
-13
@@ -58,7 +58,7 @@ let getBackendForHostname = null;
|
||||
/**
|
||||
* Register the function used to resolve a virtual hostname to its local backend.
|
||||
* Must be called before `start`. Called by message-router with `holesailManager.getLocalBackend`.
|
||||
* @param {Function} fn - Called as `fn(hostname)` and should return `{host, port}` or null.
|
||||
* @param {Function} fn - Called as `fn(hostname)` and should return `{host, port, tls?: boolean}` or null.
|
||||
*/
|
||||
function setHostnameResolver (fn) {
|
||||
getBackendForHostname = fn;
|
||||
@@ -248,6 +248,7 @@ function onRequest (req, res) {
|
||||
const backend = getBackendForHostname(hostname);
|
||||
let targetHost = '127.0.0.1';
|
||||
let targetPort = null;
|
||||
const backendTls = backend != null && typeof backend === 'object' && backend.tls === true;
|
||||
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
|
||||
targetHost = backend.host ?? '127.0.0.1';
|
||||
targetPort = backend.port;
|
||||
@@ -306,11 +307,25 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
|
||||
method: req.method || 'GET',
|
||||
headers: reqHeaders
|
||||
};
|
||||
if (backendTls) {
|
||||
opts.backendTls = true;
|
||||
opts.servername = hostname;
|
||||
const tlsAgent = new http1.Agent();
|
||||
tlsAgent.createConnection = (o) => {
|
||||
if (o.backendTls && o.servername) {
|
||||
const tcp = net.createConnection(o.port, o.host);
|
||||
return new tls.Socket(tcp, { host: o.servername });
|
||||
}
|
||||
return net.createConnection(o);
|
||||
};
|
||||
opts.agent = tlsAgent;
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
let proxyReq;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proxyReq.destroy();
|
||||
if (proxyReq) proxyReq.destroy();
|
||||
if (!res.writableEnded) {
|
||||
res.statusCode = 504;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
@@ -318,7 +333,7 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
|
||||
}
|
||||
}, BACKEND_TIMEOUT_MS);
|
||||
|
||||
const proxyReq = http1.request(opts, (proxyRes) => {
|
||||
proxyReq = http1.request(opts, (proxyRes) => {
|
||||
clearTimeout(timeoutId);
|
||||
if (timedOut) return;
|
||||
res.statusCode = proxyRes.statusCode;
|
||||
@@ -359,6 +374,7 @@ function onUpgrade (req, socket, head) {
|
||||
const backend = getBackendForHostname(hostname);
|
||||
let targetHost = '127.0.0.1';
|
||||
let targetPort = null;
|
||||
const backendTls = backend != null && typeof backend === 'object' && backend.tls === true;
|
||||
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
|
||||
targetHost = backend.host ?? '127.0.0.1';
|
||||
targetPort = backend.port;
|
||||
@@ -376,8 +392,35 @@ function onUpgrade (req, socket, head) {
|
||||
try { bareTcp = require('bare-tcp'); } catch (_) {}
|
||||
if (!bareTcp) { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); return; }
|
||||
|
||||
// Register error handler synchronously before the connect callback can fire,
|
||||
// so an immediate ECONNREFUSED is never an unhandled error event.
|
||||
function writeUpgradeAndPipe(upstream) {
|
||||
const headers = Object.entries(req.headers).map(([k, v]) => k + ': ' + v).join('\r\n');
|
||||
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
|
||||
upstream.write(requestLine + headers + '\r\n\r\n');
|
||||
if (head && head.length > 0) upstream.write(head);
|
||||
socket.on('data', (c) => { trafficStats.bytesIn += c.length; });
|
||||
upstream.on('data', (c) => { trafficStats.bytesOut += c.length; });
|
||||
socket.pipe(upstream);
|
||||
upstream.pipe(socket);
|
||||
}
|
||||
|
||||
if (backendTls) {
|
||||
const tcpSocket = bareTcp.connect(targetPort, targetHost);
|
||||
const tlsSocket = new tls.Socket(tcpSocket, { host: hostname });
|
||||
function _preConnectError() {
|
||||
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
|
||||
try { tlsSocket.destroy(); } catch (_) {}
|
||||
}
|
||||
tlsSocket.on('error', _preConnectError);
|
||||
socket.on('error', () => { try { tlsSocket.destroy(); } catch (_) {} });
|
||||
tlsSocket.on('connect', () => {
|
||||
tlsSocket.removeListener('error', _preConnectError);
|
||||
tlsSocket.on('error', () => { try { socket.destroy(); } catch (_) {} });
|
||||
writeUpgradeAndPipe(tlsSocket);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Plain TCP backend
|
||||
const upstream = bareTcp.connect(targetPort, targetHost);
|
||||
function _preConnectError() {
|
||||
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
|
||||
@@ -388,14 +431,7 @@ function onUpgrade (req, socket, head) {
|
||||
upstream.on('connect', () => {
|
||||
upstream.removeListener('error', _preConnectError);
|
||||
upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
|
||||
const headers = Object.entries(req.headers).map(([k, v]) => k + ': ' + v).join('\r\n');
|
||||
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
|
||||
upstream.write(requestLine + headers + '\r\n\r\n');
|
||||
if (head && head.length > 0) upstream.write(head);
|
||||
socket.on('data', (c) => { trafficStats.bytesIn += c.length; });
|
||||
upstream.on('data', (c) => { trafficStats.bytesOut += c.length; });
|
||||
socket.pipe(upstream);
|
||||
upstream.pipe(socket);
|
||||
writeUpgradeAndPipe(upstream);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user