diff --git a/extension/background-utils.js b/extension/background-utils.js index e3c9362..bd9fb2e 100644 --- a/extension/background-utils.js +++ b/extension/background-utils.js @@ -38,8 +38,10 @@ export async function buildRelayWsUrl(host, port, gatewayToken) { "Missing relay token in extension settings (chrome.storage.local.relayToken)", ); } - // Token is already derived server-side — use it directly - return `ws://${host}:${port}/extension?token=${encodeURIComponent(token)}`; + // wss, always — the matching half of the options page forcing https. The token is on the query string + // because a WebSocket handshake from an extension cannot set headers, so plaintext here would put a + // live credential on the wire in full view. + return `wss://${host}:${port}/extension?token=${encodeURIComponent(token)}`; } export function isRetryableReconnectError(err) { diff --git a/extension/background.js b/extension/background.js index 5ecf148..f69a7bf 100644 --- a/extension/background.js +++ b/extension/background.js @@ -132,7 +132,7 @@ async function ensureRelayConnection() { const port = await getRelayPort() const host = await getRelayHost() const token = await getRelayToken() - const httpBase = `http://${host}:${port}` + const httpBase = `https://${host}:${port}` const wsUrl = await buildRelayWsUrl(host, port, token) try { diff --git a/extension/options-validation.js b/extension/options-validation.js index af4184c..548eb21 100644 --- a/extension/options-validation.js +++ b/extension/options-validation.js @@ -5,6 +5,49 @@ function hasCdpVersionShape(data) { return !!data && typeof data === 'object' && 'Browser' in data && 'Protocol-Version' in data } +/** + * Turn whatever someone typed into a bare host. + * + * People paste the address they use for Officer, which has a scheme on it and sometimes a port and a + * path. Taking it raw produced `http://https://platform.officer.dev:18792/` — a URL that cannot resolve, + * reported as "Relay not reachable", which reads as the relay being down rather than the address being + * mangled. Reported by the owner, with a screenshot, as "very very bad". + * + * Returns the host and, when the input carried one, the port — so pasting `host:1234` moves the port + * field rather than being silently dropped. + * + * ── The scheme is always https ── + * + * Whatever was typed. `http://` in the box becomes https, and nothing becomes https. The relay carries a + * pairing token on every request and drives a browser someone is signed into; sending that over plaintext + * because a field was left blank is not a default worth having. + */ +export function normalizeHost(input) { + let value = String(input || '').trim() + if (!value) return { host: '', port: null } + + value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // scheme — discarded, https is forced below + value = value.replace(/\/.*$/, '') // path + value = value.replace(/^[^@]*@/, '') // credentials, if someone pastes them + + // IPv6 in brackets keeps its colons; anything else splits on the last one. + let port = null + const bracketed = value.match(/^\[(.+)\](?::(\d+))?$/) + if (bracketed) { + value = bracketed[1] + if (bracketed[2]) port = Number(bracketed[2]) + } else if (/^[^:]+:(\d+)$/.test(value)) { + const [h, p] = value.split(':') + value = h + port = Number(p) + } + + return { host: value, port } +} + +/** The one place a relay URL is spelled. https always — see `normalizeHost`. */ +export const relayUrl = (host, port, path = '') => `https://${host}:${port}${path}` + export function classifyRelayCheckResponse(res, host, port) { if (!res) { return { action: 'throw', error: 'No response from service worker' } @@ -47,7 +90,7 @@ export function classifyRelayCheckResponse(res, host, port) { } } - return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at http://${host}:${port}/` } + return { action: 'status', kind: 'ok', message: `Relay reachable and authenticated at ${relayUrl(host, port, '/')}` } } export function classifyRelayCheckException(err, host, port) { @@ -59,8 +102,13 @@ export function classifyRelayCheckException(err, host, port) { } } + // Say what was actually tried and what usually explains it. "Verify the server address" is true and + // useless — the address is usually a real one that simply does not carry this port. return { kind: 'error', - message: `Relay not reachable at http://${host}:${port}/. Verify the server address and relay token.`, + message: + `Nothing answered at ${relayUrl(host, port, '/')}. The relay listens on the machine Officer runs on, ` + + `on a port that is normally reachable only over your private network — a public hostname will not ` + + `have it open. Try the address you use to SSH to that machine.`, } } diff --git a/extension/options.js b/extension/options.js index e14a902..286c465 100644 --- a/extension/options.js +++ b/extension/options.js @@ -1,4 +1,4 @@ -import { classifyRelayCheckException, classifyRelayCheckResponse } from './options-validation.js' +import { classifyRelayCheckException, classifyRelayCheckResponse, normalizeHost, relayUrl } from './options-validation.js' const DEFAULT_PORT = 18792 const DEFAULT_HOST = '127.0.0.1' @@ -13,7 +13,7 @@ function clampPort(value) { function updateRelayUrl(host, port) { const el = document.getElementById('relay-url') if (!el) return - el.textContent = `http://${host}:${port}/` + el.textContent = relayUrl(host, port, '/') } function setStatus(kind, message) { @@ -24,7 +24,7 @@ function setStatus(kind, message) { } async function checkRelayReachable(host, port, token) { - const url = `http://${host}:${port}/json/version` + const url = relayUrl(host, port, '/json/version') const trimmedToken = String(token || '').trim() if (!trimmedToken) { setStatus('error', 'Relay token required. Save your token to connect.') @@ -49,7 +49,7 @@ async function checkRelayReachable(host, port, token) { async function load() { const stored = await chrome.storage.local.get(['relayPort', 'relayHost', 'relayToken']) const port = clampPort(stored.relayPort) - const host = String(stored.relayHost || '').trim() || DEFAULT_HOST + const host = normalizeHost(stored.relayHost).host || DEFAULT_HOST const token = String(stored.relayToken || '').trim() document.getElementById('host').value = host document.getElementById('port').value = String(port) @@ -62,8 +62,13 @@ async function save() { const hostInput = document.getElementById('host') const portInput = document.getElementById('port') const tokenInput = document.getElementById('token') - const host = String(hostInput.value || '').trim() || DEFAULT_HOST - const port = clampPort(portInput.value) + // Normalised on the way in, and the field is rewritten with the result — so what is stored and what is + // shown are the same string, and a pasted `https://host/path` visibly becomes `host` rather than being + // quietly reinterpreted. + const parsed = normalizeHost(hostInput.value) + const host = parsed.host || DEFAULT_HOST + // A port in the address wins over a stale one in the port box: someone pasting `host:9000` means 9000. + const port = clampPort(parsed.port ?? portInput.value) const token = String(tokenInput.value || '').trim() await chrome.storage.local.set({ relayHost: host, relayPort: port, relayToken: token }) hostInput.value = host