diff --git a/plugins/cloud/package-lock.json b/plugins/cloud/package-lock.json index 8a9f4d5094..86d205417f 100644 --- a/plugins/cloud/package-lock.json +++ b/plugins/cloud/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scrypted/cloud", - "version": "0.2.33", + "version": "0.2.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@scrypted/cloud", - "version": "0.2.33", + "version": "0.2.34", "dependencies": { "@eneris/push-receiver": "^4.1.6", "@scrypted/common": "file:../../common", diff --git a/plugins/cloud/package.json b/plugins/cloud/package.json index 7804970fa0..79783f8a59 100644 --- a/plugins/cloud/package.json +++ b/plugins/cloud/package.json @@ -30,10 +30,9 @@ "realfs": true, "interfaces": [ "SystemSettings", - "BufferConverter", + "MediaConverter", "OauthClient", "Settings", - "DeviceProvider", "HttpRequestHandler" ] }, @@ -54,5 +53,5 @@ "@types/node": "^22.1.0", "ts-node": "^10.9.2" }, - "version": "0.2.33" + "version": "0.2.34" } diff --git a/plugins/cloud/src/cloudflared-local-managed.ts b/plugins/cloud/src/cloudflared-local-managed.ts index a643b11acc..5d4c11c72c 100644 --- a/plugins/cloud/src/cloudflared-local-managed.ts +++ b/plugins/cloud/src/cloudflared-local-managed.ts @@ -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', () => { @@ -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')); @@ -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:(?.*?)Leave/s); + if (match) { + const url = match.groups.url.trim(); + if (url) + urlCallback(url); + } + }); } async function createTunnel(bin: string, domain: string) { @@ -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); diff --git a/plugins/cloud/src/main.ts b/plugins/cloud/src/main.ts index 6f99d5a5b7..aec87b0e79 100644 --- a/plugins/cloud/src/main.ts +++ b/plugins/cloud/src/main.ts @@ -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'; @@ -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; @@ -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 { - 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; @@ -60,7 +42,6 @@ class ScryptedCloud extends ScryptedDeviceBase implements OauthClient, Settings, server: http.Server; secureServer: https.Server; proxy: HttpProxy; - push: ScryptedPush; whitelisted = new Map(); reregisterTimer: NodeJS.Timeout; storageSettings = new StorageSettings(this, { @@ -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', @@ -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); } @@ -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 = () => { @@ -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 { if (request.url.endsWith('/testPortForward')) { response.send(this.randomBytes); @@ -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 { } @@ -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 { - // 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 { + 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 { @@ -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 = ``; + 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();