Skip to content

Commit

Permalink
cloud: cleanups
Browse files Browse the repository at this point in the history
  • Loading branch information
koush committed Sep 2, 2024
1 parent 40b7b62 commit b5593d6
Show file tree
Hide file tree
Showing 4 changed files with 88 additions and 67 deletions.
4 changes: 2 additions & 2 deletions plugins/cloud/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions plugins/cloud/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@
"realfs": true,
"interfaces": [
"SystemSettings",
"BufferConverter",
"MediaConverter",
"OauthClient",
"Settings",
"DeviceProvider",
"HttpRequestHandler"
]
},
Expand All @@ -54,5 +53,5 @@
"@types/node": "^22.1.0",
"ts-node": "^10.9.2"
},
"version": "0.2.33"
"version": "0.2.34"
}
19 changes: 14 additions & 5 deletions plugins/cloud/src/cloudflared-local-managed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function runLog(bin: string, args: string[]) {
return cp;
}

async function runLogWait(bin: string, args: string[], timeout: number, signal?: AbortSignal) {
async function runLogWait(bin: string, args: string[], timeout: number, signal?: AbortSignal, outputChanged?: (output: string) => void) {
const cp = runLog(bin, args);

signal?.addEventListener('abort', () => {
Expand All @@ -37,9 +37,11 @@ async function runLogWait(bin: string, args: string[], timeout: number, signal?:
let output: string = '';
cp.stdio[1].on('data', (data) => {
output += data.toString();
outputChanged?.(output);
});
cp.stdio[2].on('data', (data) => {
output += data.toString();
outputChanged?.(output);
});

await timeoutPromise(timeout, once(cp, 'exit'));
Expand All @@ -49,12 +51,19 @@ async function runLogWait(bin: string, args: string[], timeout: number, signal?:
return output;
}

async function login(bin: string, signal?: AbortSignal) {
async function login(bin: string, signal?: AbortSignal, urlCallback?: (url: string) => void) {
const userHome = process.env.HOME || process.env.USERPROFILE;
const certPem = path.join(userHome, '.cloudflared', 'cert.pem');
rmSync(certPem, { force: true, recursive: true });

await runLogWait(bin, ['tunnel', 'login'], 300000, signal);
await runLogWait(bin, ['tunnel', 'login'], 300000, signal, output => {
const match = output.match(/Please open the following URL and log in with your Cloudflare account:(?<url>.*?)Leave/s);
if (match) {
const url = match.groups.url.trim();
if (url)
urlCallback(url);
}
});
}

async function createTunnel(bin: string, domain: string) {
Expand Down Expand Up @@ -104,10 +113,10 @@ async function ensureBin(bin: string) {
return bin;
}

export async function createLocallyManagedTunnel(domain: string, bin?: string, signal?: AbortSignal) {
export async function createLocallyManagedTunnel(domain: string, bin?: string, signal?: AbortSignal, urlCallback?: (url: string) => void) {
bin = await ensureBin(bin);

await login(bin, signal);
await login(bin, signal, urlCallback);
const createOutput = await createTunnel(bin, domain);
const jsonFilePath = extractJsonFilePath(createOutput);

Expand Down
127 changes: 70 additions & 57 deletions plugins/cloud/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Deferred } from "@scrypted/common/src/deferred";
import sdk, { BufferConverter, DeviceProvider, HttpRequest, HttpRequestHandler, HttpResponse, OauthClient, PushHandler, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings } from "@scrypted/sdk";
import sdk, { HttpRequest, HttpRequestHandler, HttpResponse, MediaConverter, MediaObject, MediaObjectOptions, OauthClient, PushHandler, ScryptedDeviceBase, ScryptedInterface, ScryptedMimeTypes, Setting, Settings } from "@scrypted/sdk";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import bpmux from 'bpmux';
import { ChildProcess } from "child_process";
import * as cloudflared from 'cloudflared';
import crypto from 'crypto';
import { once } from 'events';
Expand All @@ -20,10 +21,9 @@ import { sleep } from '../../../common/src/sleep';
import { createSelfSignedCertificate } from '../../../server/src/cert';
import { httpFetch } from '../../../server/src/fetch/http-fetch';
import { installCloudflared } from "./cloudflared-install";
import { createLocallyManagedTunnel, runLocallyManagedTunnel } from "./cloudflared-local-managed";
import { PushManager } from './push';
import { qsparse, qsstringify } from "./qs";
import { createLocallyManagedTunnel, runLocallyManagedTunnel } from "./cloudflared-local-managed";
import { ChildProcess } from "child_process";

const { deviceManager, endpointManager, systemManager } = sdk;

Expand All @@ -32,25 +32,7 @@ const SCRYPTED_SERVER = localStorage.getItem('scrypted-server') || 'home.scrypte

const SCRYPTED_CLOUD_MESSAGE_PATH = '/_punch/cloudmessage';

class ScryptedPush extends ScryptedDeviceBase implements BufferConverter {
constructor(public cloud: ScryptedCloud) {
super('push');

this.fromMimeType = ScryptedMimeTypes.PushEndpoint;
this.toMimeType = ScryptedMimeTypes.Url;
}

async convert(data: Buffer | string, fromMimeType: string): Promise<Buffer> {
const validDomain = this.cloud.getSSLHostname();
if (validDomain)
return Buffer.from(`https://${validDomain}${await this.cloud.getCloudMessagePath()}/${data}`);

const url = `http://127.0.0.1/push/${data}`;
return this.cloud.whitelist(url, 10 * 365 * 24 * 60 * 60 * 1000, `https://${this.cloud.getHostname()}${SCRYPTED_CLOUD_MESSAGE_PATH}`);
}
}

class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings, BufferConverter, DeviceProvider, HttpRequestHandler {
class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings, MediaConverter, HttpRequestHandler {
cloudflareTunnel: string;
cloudflared: {
url: Promise<string>;
Expand All @@ -60,7 +42,6 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
server: http.Server;
secureServer: https.Server;
proxy: HttpProxy;
push: ScryptedPush;
whitelisted = new Map<string, string>();
reregisterTimer: NodeJS.Timeout;
storageSettings = new StorageSettings(this, {
Expand Down Expand Up @@ -188,10 +169,27 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
title: 'Cloudflare Tunnel Custom Domain',
placeholder: 'scrypted.example.com',
description: 'Optional: Host a custom domain with Cloudflare. After setting the domain, complete the Cloudflare browser login link shown in Scrypted Cloud Plugin Console.',
mapPut: (ov, nv) => {
try {
const url = new URL(nv);
return url.hostname;
}
catch (e) {
return nv;
}
},
onPut: (_, nv) => {
if (!nv)
this.storageSettings.values.cloudflaredTunnelCredentials = undefined;
this.doCloudflaredLogin(nv);
},
},
cloudflaredTunnelLoginUrl: {
group: 'Cloudflare',
type: 'html',
title: 'Cloudflare Tunnel Login',
hide: true,
},
cloudflaredTunnelUrl: {
group: 'Cloudflare',
title: 'Cloudflare Tunnel URL',
Expand Down Expand Up @@ -257,6 +255,17 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
constructor() {
super();

this.converters = [
[ScryptedMimeTypes.LocalUrl, ScryptedMimeTypes.Url],
[ScryptedMimeTypes.PushEndpoint, ScryptedMimeTypes.Url],
];
// legacy cleanup
this.fromMimeType = undefined;
this.toMimeType = undefined;
deviceManager.onDevicesChanged({
devices: [],
});

this.storageSettings.settings.register.onPut = async () => {
await this.sendRegistrationId(await this.manager.registrationId);
}
Expand Down Expand Up @@ -303,21 +312,21 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
};

this.log.clearAlerts();

this.storageSettings.settings.securePort.onPut = (ov, nv) => {
if (ov && ov !== nv)
this.log.a('Reload the Scrypted Cloud Plugin to apply the port change.');
};

this.fromMimeType = ScryptedMimeTypes.LocalUrl;
this.toMimeType = ScryptedMimeTypes.Url;

if (!this.storageSettings.values.certificate)
this.storageSettings.values.certificate = createSelfSignedCertificate();

if (this.storageSettings.values.cloudflaredTunnelCustomDomain && !this.storageSettings.values.cloudflaredTunnelCredentials)
this.storageSettings.values.cloudflaredTunnelCustomDomain = undefined;

this.log.clearAlerts();

const proxy = this.setupProxyServer();
this.setupCloudPush();
this.updateCors();

const observeRegistrations = () => {
Expand Down Expand Up @@ -678,18 +687,6 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
}

async setupCloudPush() {
await deviceManager.onDeviceDiscovered(
{
name: 'Cloud Push Endpoint',
type: ScryptedDeviceType.API,
nativeId: 'push',
interfaces: [ScryptedInterface.BufferConverter],
},
);
this.push = new ScryptedPush(this);
}

async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
if (request.url.endsWith('/testPortForward')) {
response.send(this.randomBytes);
Expand All @@ -716,10 +713,6 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
}
}

async getDevice(nativeId: string) {
return this.push;
}

async releaseDevice(id: string, nativeId: string): Promise<void> {
}

Expand All @@ -735,19 +728,34 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
return this.getSSLHostname() || SCRYPTED_SERVER;
}

async convert(data: Buffer, fromMimeType: string, toMimeType: string): Promise<Buffer> {
// if cloudflare is enabled and the plugin isn't set up as a custom domain, try to use the cloudflare url for
// short lived urls.
if (this.cloudflareTunnel && this.storageSettings.values.forwardingMode !== 'Custom Domain') {
const params = new URLSearchParams(toMimeType.split(';')[1] || '');
if (params.get('short-lived') === 'true') {
const u = new URL(data.toString(), this.cloudflareTunnel);
u.host = this.cloudflareTunnelHost;
u.port = '';
return Buffer.from(u.toString());
async convertMedia(data: string | Buffer | any, fromMimeType: string, toMimeType: string, options?: MediaObjectOptions): Promise<MediaObject | Buffer | any> {
if (toMimeType !== ScryptedMimeTypes.Url)
throw new Error('unsupported cloud url conversion');

if (fromMimeType === ScryptedMimeTypes.LocalUrl) {
// if cloudflare is enabled and the plugin isn't set up as a custom domain, try to use the cloudflare url for
// short lived urls.
if (this.cloudflareTunnel && this.storageSettings.values.forwardingMode !== 'Custom Domain') {
const params = new URLSearchParams(toMimeType.split(';')[1] || '');
if (params.get('short-lived') === 'true') {
const u = new URL(data.toString(), this.cloudflareTunnel);
u.host = this.cloudflareTunnelHost;
u.port = '';
return Buffer.from(u.toString());
}
}
return this.whitelist(data.toString(), 10 * 365 * 24 * 60 * 60 * 1000, `https://${this.getHostname()}`);
}
return this.whitelist(data.toString(), 10 * 365 * 24 * 60 * 60 * 1000, `https://${this.getHostname()}`);
else if (fromMimeType === ScryptedMimeTypes.PushEndpoint) {
const validDomain = this.getSSLHostname();
if (validDomain)
return Buffer.from(`https://${validDomain}${await this.getCloudMessagePath()}/${data}`);

const url = `http://127.0.0.1/push/${data}`;
return this.whitelist(url, 10 * 365 * 24 * 60 * 60 * 1000, `https://${this.getHostname()}${SCRYPTED_CLOUD_MESSAGE_PATH}`);
}

throw new Error('unsupported cloud url conversion');
}

async getSettings(): Promise<Setting[]> {
Expand Down Expand Up @@ -1207,13 +1215,18 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings,
return;
}

this.log.a('Visit the URL printed in the Scrypted Cloud plugin console to log into Cloudflare.');
// this.log.a('Visit the URL printed in the Scrypted Cloud plugin console to log into Cloudflare.');
const customDomain = this.storageSettings.values.cloudflaredTunnelCustomDomain;
try {
this.cloudflaredLoginController?.abort();
this.cloudflaredLoginController = new AbortController();
const { bin } = await installCloudflared();
const jsonContents = await createLocallyManagedTunnel(domain, bin, this.cloudflaredLoginController.signal);
const jsonContents = await createLocallyManagedTunnel(domain, bin, this.cloudflaredLoginController.signal, url => {
this.console.warn('Cloudflare login URL:', url);
this.storageSettings.values.cloudflaredTunnelLoginUrl = `<div style="padding-bottom: 16px"><a href="${url}" target="_blank" >Click here to log into Cloudflare</a></div>`;
this.storageSettings.settings.cloudflaredTunnelLoginUrl.hide = false;
this.onDeviceEvent(ScryptedInterface.Settings, undefined);
});
this.storageSettings.values.cloudflaredTunnelCredentials = jsonContents;
this.storageSettings.values.cloudflaredTunnelToken = undefined;
this.cloudflared?.child.kill();
Expand Down

0 comments on commit b5593d6

Please sign in to comment.