diff --git a/app/src/daemon.ts b/app/src/daemon.ts index ff8aa52..1b1b7bf 100644 --- a/app/src/daemon.ts +++ b/app/src/daemon.ts @@ -1,24 +1,25 @@ "use strict"; +import http, { IncomingMessage } from "http"; +import WebSocket from "ws"; + import { spawn } from "child_process"; import { Readable } from "stream"; -import WebSocket from "ws"; - import { DecodeTransform, EncodeTransform } from "./transforms"; -export function init(port: number) { +export function init(port: number, serverPassword?: string) { + const server = http.createServer(); + const wss = new WebSocket.Server({ noServer: true }); + process.stdout.write("Starting WebSocket server... "); - const wss = new WebSocket.Server({ port }, () => { - // eslint-disable-next-line no-console - console.log("Done!"); + server.on("listening", () => { + process.stdout.write("Done!\n"); }); - wss.on("error", err => { - // eslint-disable-next-line no-console - console.log("Failed!"); - console.error(err); + console.error("Failed!"); + console.error(err.message); }); wss.on("connection", socket => { @@ -42,14 +43,64 @@ export function init(port: number) { // bridge.stdout -> socket bridge.stdout.pipe(new DecodeTransform()).on("data", data => { - // Socket can be CLOSING here - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(data)); + if (socket.readyState !== WebSocket.OPEN) { + return; } + + socket.send(JSON.stringify(data)); }); // Handle termination socket.on("close", () => bridge.kill()); bridge.on("exit", () => socket.close()); }); + + /** + * Authenticates requests by checking password URL param against + * server password specified in launch options. + */ + function authenticate(req: IncomingMessage) { + if (!serverPassword) return true; + + const password = new URL( + req.url!, + `http://${req.headers.host}` + ).searchParams.get("password"); + + return password === serverPassword; + } + + server.on("upgrade", (req, socket, head) => { + if ( + // Only accept WebSocket requests from extension origins + !req.headers.origin?.startsWith("moz-extension://") || + !authenticate(req) + ) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, ws => { + wss.emit("connection", ws, req); + }); + }); + + /** + * JS WebSocket API does not allow access to connection errors, so + * provide an endpoint for feedback on invalid authentication. + */ + server.on("request", (req, res) => { + if (!authenticate(req)) { + res.writeHead(401); + res.end(); + + return; + } + + res.writeHead(200); + res.end(); + }); + + server.listen(port); } diff --git a/app/src/main.ts b/app/src/main.ts index e8368e5..33fab91 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -6,7 +6,7 @@ import { __applicationVersion } from "../package.json"; const argv = minimist(process.argv.slice(2), { boolean: ["daemon", "help", "version"], - string: ["__name", "port"], + string: ["__name", "port", "password"], alias: { d: "daemon", h: "help", @@ -36,6 +36,11 @@ Options: options. -p, --port Set port number for WebSocket server. This must match the port set in the extension options. + --password Set an optional password for the WebSocket server. This must + match the password set in the extension options. + WARNING: This password is intended only as a basic access + control measure and is transmitted in plain text even over + remote connections! ` ); } else if (argv.daemon) { @@ -46,7 +51,7 @@ Options: } import("./daemon").then(daemon => { - daemon.init(port); + daemon.init(port, argv.password); }); } else { import("./bridge"); diff --git a/ext/src/_locales/en/messages.json b/ext/src/_locales/en/messages.json index ed4a99d..3f73d6b 100755 --- a/ext/src/_locales/en/messages.json +++ b/ext/src/_locales/en/messages.json @@ -144,6 +144,14 @@ "message": "Bridge issue", "description": "Bridge error status title text." }, + "optionsBridgeIssueStatusTextTimedOut": { + "message": "Connection timed out.", + "description": "Bridge timed out issue additional description text." + }, + "optionsBridgeIssueStatusTextAuthentication": { + "message": "Failed to authenticate connection.", + "description": "Bridge authentication issue additional description text." + }, "optionsBridgeNotFoundStatusTitle": { "message": "Bridge not found", "description": "Bridge missing status title text." @@ -197,6 +205,10 @@ "message": "No action needed.", "description": "Recommended action for when both bridge and extension versions are compatible or likely compatible." }, + "optionsBridgeRefresh": { + "message": "Refresh bridge status", + "description": "Bridge status refresh button title." + }, "optionsBridgeUpdateCheck": { "message": "Check for Updates", "description": "Update check button title." @@ -241,6 +253,10 @@ "message": "If the regular bridge connection fails, attempt to connect to a bridge running in daemon mode.", "description": "Backup daemon checkbox description." }, + "optionsBridgeBackupPassword": { + "message": "...with password:", + "description": "Daemon password option label." + }, "optionsMediaCategoryName": { "message": "Media casting", diff --git a/ext/src/defaultOptions.ts b/ext/src/defaultOptions.ts index 2759693..dcba7d0 100644 --- a/ext/src/defaultOptions.ts +++ b/ext/src/defaultOptions.ts @@ -7,6 +7,7 @@ export interface Options { bridgeBackupEnabled: boolean; bridgeBackupHost: string; bridgeBackupPort: number; + bridgeBackupPassword: string; mediaEnabled: boolean; mediaSyncElement: boolean; mediaStopOnUnload: boolean; @@ -29,6 +30,7 @@ export default { bridgeBackupEnabled: false, bridgeBackupHost: "localhost", bridgeBackupPort: 9556, + bridgeBackupPassword: "", mediaEnabled: true, mediaSyncElement: false, mediaStopOnUnload: false, diff --git a/ext/src/lib/bridge.ts b/ext/src/lib/bridge.ts index 41b4700..5a007aa 100644 --- a/ext/src/lib/bridge.ts +++ b/ext/src/lib/bridge.ts @@ -44,6 +44,7 @@ export interface BridgeInfo { export class BridgeConnectionError extends Error {} export class BridgeTimedOutError extends Error {} +export class BridgeAuthenticationError extends Error {} /** * Creates a temporary bridge to query the version info, @@ -73,10 +74,14 @@ const getInfo = () => { subject: "bridge:/getInfo", data: version } ); } catch (err) { - logger.error("Bridge connection failed."); - reject(new BridgeConnectionError()); - clearTimeout(bridgeTimeoutId); + if (err === 401) { + reject(new BridgeAuthenticationError()); + } else { + logger.error("Bridge connection failed."); + reject(new BridgeConnectionError()); + } + clearTimeout(bridgeTimeoutId); return; } diff --git a/ext/src/lib/nativeMessaging.ts b/ext/src/lib/nativeMessaging.ts index 66c1dd9..bc40058 100644 --- a/ext/src/lib/nativeMessaging.ts +++ b/ext/src/lib/nativeMessaging.ts @@ -97,8 +97,12 @@ function connectNative(application: string): Port { }; port.onDisconnect.addListener(async () => { - const { bridgeBackupEnabled, bridgeBackupHost, bridgeBackupPort } = - await options.getAll(); + const { + bridgeBackupEnabled, + bridgeBackupHost, + bridgeBackupPort, + bridgeBackupPassword + } = await options.getAll(); if (!bridgeBackupEnabled) { portObject.error = { @@ -117,9 +121,12 @@ function connectNative(application: string): Port { if (port.error && !isNativeHostStatusKnown) { isNativeHostStatusKnown = true; - socket = new WebSocket( - `ws://${bridgeBackupHost}:${bridgeBackupPort}` - ); + const url = new URL(`ws://${bridgeBackupHost}:${bridgeBackupPort}`); + if (bridgeBackupPassword) { + url.searchParams.append("password", bridgeBackupPassword); + } + + socket = new WebSocket(url.href); socket.addEventListener("open", () => { // Send all messages in queue @@ -167,8 +174,12 @@ async function sendNativeMessage(application: string, message: Message) { try { return await browser.runtime.sendNativeMessage(application, message); } catch { - const { bridgeBackupEnabled, bridgeBackupHost, bridgeBackupPort } = - await options.getAll(); + const { + bridgeBackupEnabled, + bridgeBackupHost, + bridgeBackupPort, + bridgeBackupPassword + } = await options.getAll(); if (!bridgeBackupEnabled) { throw logger.error( @@ -176,11 +187,25 @@ async function sendNativeMessage(application: string, message: Message) { ); } - return await new Promise((resolve, reject) => { - const ws = new WebSocket( - `ws://${bridgeBackupHost}:${bridgeBackupPort}` + const url = new URL(`http://${bridgeBackupHost}:${bridgeBackupPort}`); + if (bridgeBackupPassword) { + url.searchParams.append("password", bridgeBackupPassword); + } + + const res = await fetch(url.href); + if (res.status === 401) { + logger.error( + "Bridge daemon connection failed due to authentication error." ); + throw 401; + } + + url.protocol = "ws"; + + return await new Promise((resolve, reject) => { + const ws = new WebSocket(url.href); + ws.addEventListener("open", () => { ws.send(JSON.stringify(message)); }); @@ -191,7 +216,7 @@ async function sendNativeMessage(application: string, message: Message) { }); ws.addEventListener("error", () => { - logger.error("No bridge application found."); + logger.error("Bridge daemon connection error."); reject(); }); }); diff --git a/ext/src/ui/options/Bridge.svelte b/ext/src/ui/options/Bridge.svelte index ba09bf9..6b89204 100644 --- a/ext/src/ui/options/Bridge.svelte +++ b/ext/src/ui/options/Bridge.svelte @@ -4,7 +4,11 @@ import LoadingIndicator from "../LoadingIndicator.svelte"; - import bridge, { BridgeInfo, BridgeTimedOutError } from "../../lib/bridge"; + import bridge, { + BridgeInfo, + BridgeTimedOutError, + BridgeAuthenticationError + } from "../../lib/bridge"; import logger from "../../lib/logger"; import { Options } from "../../lib/options"; @@ -14,42 +18,54 @@ export let opts: Options; let bridgeInfo: Nullable = null; + let bridgeInfoError: Nullable = null; let isLoadingInfo = true; - let isLoadingInfoTimedOut = false; // Status - let infoClass = "bridge__info"; let statusIcon: string; let statusTitle: string; let statusText: Nullable = null; - onMount(async () => { + async function checkBridgeStatus() { + // Reset state + bridgeInfo = null; + bridgeInfoError = null; + isLoadingInfo = true; + statusText = null; + try { bridgeInfo = await bridge.getInfo(); } catch (err) { logger.error("Failed to fetch bridge/platform info."); - if (err instanceof BridgeTimedOutError) { - isLoadingInfoTimedOut = true; + if (err instanceof Error) { + bridgeInfoError = err; } } isLoadingInfo = false; - infoClass += ` ${ - !bridgeInfo - ? isLoadingInfoTimedOut - ? "bridge__info--timed-out" - : "bridge__info--not-found" - : "bridge__info--found" - }`; - if (!bridgeInfo) { - statusIcon = "assets/icons8-cancel-120.png"; - statusTitle = _("optionsBridgeNotFoundStatusTitle"); - statusText = _("optionsBridgeNotFoundStatusText"); - } else if (isLoadingInfoTimedOut) { - statusIcon = "assets/icons8-warn-120.png"; - statusTitle = _("optionsBridgeIssueStatusTitle"); + if ( + bridgeInfoError instanceof BridgeTimedOutError || + bridgeInfoError instanceof BridgeAuthenticationError + ) { + statusIcon = "assets/icons8-warn-120.png"; + statusTitle = _("optionsBridgeIssueStatusTitle"); + + if (bridgeInfoError instanceof BridgeTimedOutError) { + statusText = _("optionsBridgeIssueStatusTextTimedOut"); + } else if ( + bridgeInfoError instanceof BridgeAuthenticationError + ) { + statusText = _( + "optionsBridgeIssueStatusTextAuthentication" + ); + } + } else { + statusIcon = "assets/icons8-cancel-120.png"; + statusTitle = _("optionsBridgeNotFoundStatusTitle"); + statusText = _("optionsBridgeNotFoundStatusText"); + } } else { if (bridgeInfo.isVersionCompatible) { statusIcon = "assets/icons8-ok-120.png"; @@ -59,6 +75,10 @@ statusTitle = _("optionsBridgeIssueStatusTitle"); } } + } + + onMount(() => { + checkBridgeStatus(); }); // Updates @@ -155,7 +175,11 @@ {:else} -
+
{statusText}

{/if} + +
{#if bridgeInfo} @@ -209,44 +242,59 @@ {/if}
+ {/if} -
-
-
- -
- -
- {_("optionsBridgeBackupEnabledDescription")} -
+
+
+
+ +
+ +
+ {_("optionsBridgeBackupEnabledDescription")}
+
+ {#if !isLoadingInfo}
{#if isUpdateAvailable}
diff --git a/ext/src/ui/options/assets/photon_refresh.svg b/ext/src/ui/options/assets/photon_refresh.svg new file mode 100644 index 0000000..c1501eb --- /dev/null +++ b/ext/src/ui/options/assets/photon_refresh.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/ext/src/ui/options/styles/index.css b/ext/src/ui/options/styles/index.css index 9a9f717..d531c63 100644 --- a/ext/src/ui/options/styles/index.css +++ b/ext/src/ui/options/styles/index.css @@ -99,16 +99,10 @@ button.ghost:not(:hover) { .bridge__info { display: flex; padding-inline-start: 25px; + position: relative; } -.bridge__status { - align-items: center; - display: flex; - flex-direction: column; - margin-inline-end: 25px; -} - -.bridge__info--not-found { +.bridge__info--error { padding-inline-end: 25px; } .bridge__info--found .bridge__status { @@ -116,13 +110,36 @@ button.ghost:not(:hover) { padding-inline-end: 25px; } -.bridge__info--timed-out .bridge__status { - display: flex; - flex-direction: row; - gap: 20px; +.bridge__info--error .bridge__status { + display: grid; + grid-template-columns: min-content 1fr; + grid-template-rows: min-content 1fr; + grid-template-areas: + "status-icon status-title" + "status-icon status-text"; + gap: 5px 20px; } -.bridge__info--timed-out .bridge__status-title { - font-size: 1.75em; +.bridge__info--found .bridge__status-icon { + margin-block-end: 5px; +} +.bridge__info--error .bridge__status-icon { + grid-area: status-icon; +} +.bridge__info--error .bridge__status-title { + grid-area: status-title; + white-space: normal; +} +.bridge__info--error .bridge__status-text { + grid-area: status-text; + margin-top: initial; + align-self: start; +} + +.bridge__status { + align-items: center; + display: flex; + flex-direction: column; + margin-inline-end: 25px; } .bridge__status-title { @@ -138,31 +155,6 @@ button.ghost:not(:hover) { font-size: 1.15em; text-align: center; } - -.bridge__info--not-found .bridge__status { - display: grid; - grid-template-columns: min-content 1fr; - grid-template-rows: min-content min-content; - grid-template-areas: - "status-icon status-title" - "status-icon status-text"; -} -.bridge__info--found .bridge__status-icon { - margin-block-end: 5px; -} -.bridge__info--not-found .bridge__status-icon { - grid-area: status-icon; - margin-inline-end: 10px; -} -.bridge__info--not-found .bridge__status-title { - grid-area: status-title; - white-space: normal; -} -.bridge__info--not-found .bridge__status-text { - grid-area: status-text; - margin-top: initial; -} - .bridge__stats { border-collapse: collapse; border-spacing: 0; @@ -176,6 +168,12 @@ button.ghost:not(:hover) { white-space: nowrap; } +.bridge__refresh { + position: absolute; + right: 0; + top: 0; +} + .bridge__options { margin-top: 30px; } @@ -215,10 +213,14 @@ button.ghost:not(:hover) { .bridge__backup-host { width: 125px; } - .bridge__backup-port { width: 75px; } +.bridge__backup-password { + display: block; + margin-left: 20px; + margin-top: 5px; +} .category { border: initial; @@ -423,6 +425,9 @@ button.ghost:not(:hover) { input[id^="customUserAgentString-"] { width: -moz-available; } +#bridgeBackupPassword { + margin-inline-start: 5px; +} @media (prefers-color-scheme: dark) { .whitelist__item:nth-child(even) {