Add option to use a secure connection for the daemon

This commit is contained in:
hensm
2022-09-02 09:19:23 +01:00
parent 8ecd3320f7
commit cde15cfd91
7 changed files with 131 additions and 41 deletions

View File

@@ -1,35 +1,31 @@
import http, { IncomingMessage } from "http"; import http from "http";
import WebSocket from "ws"; import https from "https";
import { spawn } from "child_process"; import { spawn } from "child_process";
import { Readable } from "stream"; import { Readable } from "stream";
import WebSocket from "ws";
import { DecodeTransform, EncodeTransform } from "./transforms.js"; import { DecodeTransform, EncodeTransform } from "./transforms.js";
interface DaemonOpts { export interface DaemonOpts {
host: string; host: string;
port: number; port: number;
password?: string; password?: string;
secure?: boolean;
key?: Buffer;
cert?: Buffer;
} }
export function init(opts: DaemonOpts) { export function init(opts: DaemonOpts) {
const server = http.createServer(); const server = !opts.secure
? http.createServer()
: https.createServer({
key: opts.key,
cert: opts.cert
});
const wss = new WebSocket.Server({ noServer: true }); const wss = new WebSocket.Server({ noServer: true });
process.stdout.write(
`Starting WebSocket server at ws://${
opts.host.includes(":") ? `[${opts.host}]` : opts.host
}:${opts.port}... `
);
server.on("listening", () => {
process.stdout.write("Done!\n");
});
server.on("error", err => {
console.error("Failed!");
console.error(err.message);
});
wss.on("connection", socket => { wss.on("connection", socket => {
// Stream for incoming WebSocket messages // Stream for incoming WebSocket messages
const messageStream = new Readable({ objectMode: true }); const messageStream = new Readable({ objectMode: true });
@@ -72,7 +68,7 @@ export function init(opts: DaemonOpts) {
* Authenticates requests by checking password URL param against * Authenticates requests by checking password URL param against
* server password specified in launch options. * server password specified in launch options.
*/ */
function authenticate(req: IncomingMessage) { function authenticate(req: http.IncomingMessage) {
if (!opts.password) return true; if (!opts.password) return true;
const password = new URL( const password = new URL(
@@ -121,5 +117,17 @@ export function init(opts: DaemonOpts) {
res.end(); res.end();
}); });
server.listen({ port: opts.port, host: opts.host }); process.stdout.write(
`Starting WebSocket server at ${opts.secure ? "wss" : "ws"}://${
opts.host.includes(":") ? `[${opts.host}]` : opts.host
}:${opts.port}... `
);
server.listen({ port: opts.port, host: opts.host }, () => {
process.stdout.write("Done!\n");
});
server.on("error", err => {
console.error("Failed!");
console.error(err.message);
});
} }

View File

@@ -1,4 +1,9 @@
import fs from "fs";
import yargs from "yargs"; import yargs from "yargs";
import type { DaemonOpts } from "./daemon";
import { applicationName, applicationVersion } from "../config.json"; import { applicationName, applicationVersion } from "../config.json";
const argv = yargs() const argv = yargs()
@@ -30,14 +35,46 @@ the port set in the extension options.`,
alias: "P", alias: "P",
describe: `Set an optional password for the daemon WebSocket server. \ describe: `Set an optional password for the daemon WebSocket server. \
This must match the password set in the extension options. This must match the password set in the extension options.
WARNING: This password is intended only as a basic access control measure and \ Note: If using this option it is highly recommended that you enable secure \
is transmitted in plain text even over remote connections!`, connections to avoid leaking plaintext passwords!`,
type: "string"
})
.option("secure", {
alias: "s",
describe: `Use a secure HTTPS server for WebSocket connections. \
Requires key/cert file options to be specified.`,
type: "boolean",
default: false
})
.option("key-file", {
alias: "k",
describe: `Path to the private key PEM file to use for the \
HTTPS server.`,
type: "string"
})
.option("cert-file", {
alias: "c",
describe: `Path to the certificate PEM file to use for the \
HTTPS server.`,
type: "string" type: "string"
}) })
.check(argv => { .check(argv => {
// Ensure valid port range
if (argv.port < 1025 || argv.port > 65535) { if (argv.port < 1025 || argv.port > 65535) {
throw new Error("Invalid port specified!"); throw new Error("Invalid port specified!");
} }
// Ensure secure options are valid
if (argv.secure) {
if (!argv["key-file"] || !argv["cert-file"]) {
throw new Error("Missing required key/cert files.");
}
if (
!fs.existsSync(argv["key-file"]) ||
!fs.existsSync(argv["cert-file"])
) {
throw new Error("Specified key/cert files do not exist.");
}
}
return true; return true;
}) })
@@ -45,11 +82,18 @@ is transmitted in plain text even over remote connections!`,
if (argv.daemon) { if (argv.daemon) {
import("./daemon").then(daemon => { import("./daemon").then(daemon => {
daemon.init({ const daemonOpts: DaemonOpts = {
host: argv.host, host: argv.host,
port: argv.port, port: argv.port,
password: argv.password password: argv.password
}); };
if (argv.secure) {
daemonOpts.secure = true;
daemonOpts.key = fs.readFileSync(argv.keyFile!);
daemonOpts.cert = fs.readFileSync(argv.certFile!);
}
daemon.init(daemonOpts);
}); });
} else { } else {
import("./bridge"); import("./bridge");

View File

@@ -309,8 +309,12 @@
"message": "If the regular bridge connection fails, attempt to connect to a bridge running in daemon mode.", "message": "If the regular bridge connection fails, attempt to connect to a bridge running in daemon mode.",
"description": "Backup daemon checkbox description." "description": "Backup daemon checkbox description."
}, },
"optionsBridgeBackupSecure": {
"message": "Use a secure daemon connection",
"description": "Daemon secure option checkbox label."
},
"optionsBridgeBackupPassword": { "optionsBridgeBackupPassword": {
"message": "...with password:", "message": "Password:",
"description": "Daemon password option label." "description": "Daemon password option label."
}, },

View File

@@ -5,6 +5,7 @@ export interface Options {
bridgeBackupEnabled: boolean; bridgeBackupEnabled: boolean;
bridgeBackupHost: string; bridgeBackupHost: string;
bridgeBackupPort: number; bridgeBackupPort: number;
bridgeBackupSecure: boolean;
bridgeBackupPassword: string; bridgeBackupPassword: string;
mediaEnabled: boolean; mediaEnabled: boolean;
mediaSyncElement: boolean; mediaSyncElement: boolean;
@@ -28,6 +29,7 @@ export default {
bridgeBackupEnabled: false, bridgeBackupEnabled: false,
bridgeBackupHost: "localhost", bridgeBackupHost: "localhost",
bridgeBackupPort: 9556, bridgeBackupPort: 9556,
bridgeBackupSecure: false,
bridgeBackupPassword: "", bridgeBackupPassword: "",
mediaEnabled: true, mediaEnabled: true,
mediaSyncElement: false, mediaSyncElement: false,

View File

@@ -10,11 +10,15 @@ type MessageListener = (message: Message) => void;
* Create backup server URL from configured options. * Create backup server URL from configured options.
*/ */
async function getBackupServerUrl() { async function getBackupServerUrl() {
const { bridgeBackupHost, bridgeBackupPort, bridgeBackupPassword } = const {
await options.getAll(); bridgeBackupHost,
bridgeBackupPort,
bridgeBackupSecure,
bridgeBackupPassword
} = await options.getAll();
const url = new URL( const url = new URL(
`ws://${ `${bridgeBackupSecure ? "wss" : "ws"}://${
// Handle IPv6 address formatting // Handle IPv6 address formatting
bridgeBackupHost.includes(":") bridgeBackupHost.includes(":")
? `[${bridgeBackupHost}]` ? `[${bridgeBackupHost}]`
@@ -168,7 +172,9 @@ export async function sendNativeMessage(application: string, message: Message) {
try { try {
return await browser.runtime.sendNativeMessage(application, message); return await browser.runtime.sendNativeMessage(application, message);
} catch { } catch {
const bridgeBackupEnabled = await options.get("bridgeBackupEnabled"); const { bridgeBackupEnabled, bridgeBackupSecure } =
await options.getAll();
if (!bridgeBackupEnabled) { if (!bridgeBackupEnabled) {
throw logger.error( throw logger.error(
"Bridge connection failed and backup not enabled." "Bridge connection failed and backup not enabled."
@@ -178,7 +184,7 @@ export async function sendNativeMessage(application: string, message: Message) {
const backupServerUrl = await getBackupServerUrl(); const backupServerUrl = await getBackupServerUrl();
const backupServerHttpUrl = new URL(backupServerUrl); const backupServerHttpUrl = new URL(backupServerUrl);
backupServerHttpUrl.protocol = "http"; backupServerHttpUrl.protocol = bridgeBackupSecure ? "https" : "http";
// Send HTTP request to check authentication // Send HTTP request to check authentication
if ((await fetch(backupServerHttpUrl)).status === 401) { if ((await fetch(backupServerHttpUrl)).status === 401) {

View File

@@ -272,24 +272,41 @@
bind:value={opts.bridgeBackupPort} bind:value={opts.bridgeBackupPort}
/> />
{backupMessageEnd} {backupMessageEnd}
</label>
<div class="option__description">
{_("optionsBridgeBackupEnabledDescription")}
</div>
</div>
{#if opts.showAdvancedOptions} {#if opts.showAdvancedOptions}
<label class="bridge__backup-password"> <fieldset class="category" disabled={!opts.bridgeBackupEnabled}>
<div class="option option--inline">
<div class="option__control">
<input
id="bridgeBackupSecure"
type="checkbox"
bind:checked={opts.bridgeBackupSecure}
/>
</div>
<label class="option__label" for="bridgeBackupSecure">
{_("optionsBridgeBackupSecure")}
</label>
</div>
<div class="option">
<label class="option__label" for="bridgeBackupPassword">
{_("optionsBridgeBackupPassword")} {_("optionsBridgeBackupPassword")}
</label>
<div class="option__control">
<input <input
id="bridgeBackupPassword" id="bridgeBackupPassword"
placeholder="Password" placeholder="Password"
type="password" type="password"
bind:value={opts.bridgeBackupPassword} bind:value={opts.bridgeBackupPassword}
/> />
</label> </div>
{/if} </div>
</label> </fieldset>
<div class="option__description"> {/if}
{_("optionsBridgeBackupEnabledDescription")}
</div>
</div>
</div> </div>
{#if !isLoadingInfo} {#if !isLoadingInfo}

View File

@@ -208,6 +208,10 @@ input:placeholder-shown {
padding: 10px 0; padding: 10px 0;
} }
.bridge__options > .category {
grid-template-columns: 100px minmax(0, 1fr);
}
.form > .category { .form > .category {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
@@ -274,6 +278,11 @@ input:placeholder-shown {
display: inline-block; display: inline-block;
} }
.category:disabled .option__label,
.category:disabled .option__description {
opacity: 0.65;
}
.option__recommended { .option__recommended {
background-color: var(--blue-60); background-color: var(--blue-60);
border-radius: 2px; border-radius: 2px;