diff --git a/plugins/reolink/src/main.ts b/plugins/reolink/src/main.ts index 59a62cce1f..1a03a577d3 100644 --- a/plugins/reolink/src/main.ts +++ b/plugins/reolink/src/main.ts @@ -9,7 +9,7 @@ import { OnvifIntercom } from './onvif-intercom'; import { DevInfo } from './probe'; import { AIState, Enc, isDeviceHomeHub, isDeviceNvr, ReolinkCameraClient } from './reolink-api'; import { ReolinkNvrDevice } from './nvr/nvr'; -import { ReolinkNvrClient } from './nvr/api'; +import { HTTPS_API_SETTING_DESCRIPTION, ReolinkNvrClient } from './nvr/api'; class ReolinkCameraSiren extends ScryptedDeviceBase implements OnOff { sirenTimeout: NodeJS.Timeout; @@ -1278,6 +1278,13 @@ class ReolinkProvider extends RtspProvider { description: 'Optional: Override the HTTP Port from the default value of 80.', placeholder: '80', }, + { + subgroup: 'Advanced', + key: 'https', + title: 'Use HTTPS', + description: `Only applies when "Is NVR" is enabled. ${HTTPS_API_SETTING_DESCRIPTION}`, + type: 'boolean', + }, { subgroup: 'Advanced', key: 'skipValidate', @@ -1298,9 +1305,12 @@ class ReolinkProvider extends RtspProvider { const ip = settings.ip?.toString(); const httpPort = settings.httpPort; const rtspPort = settings.rtspPort; - const httpAddress = `${ip}:${httpPort || 80}`; + // Creator-form boolean settings arrive as strings ('true'/'false'), so a plain + // truthiness check would treat 'false' as enabled. Coerce like isNvr/skipValidate. + const https = settings.https?.toString() === 'true'; + const httpAddress = `${ip}:${httpPort || (https ? 443 : 80)}`; - const client = new ReolinkNvrClient(httpAddress, username, password, this.console); + const client = new ReolinkNvrClient(httpAddress, username, password, this.console, undefined, https); const { devInfo } = await client.getHubInfo(); if (!devInfo) { @@ -1329,6 +1339,7 @@ class ReolinkProvider extends RtspProvider { nvrDevice.storageSettings.values.password = password; nvrDevice.storageSettings.values.httpPort = httpPort; nvrDevice.storageSettings.values.rtspPort = rtspPort; + nvrDevice.storageSettings.values.https = https; nvrDevice.updateDeviceInfo(devInfo); diff --git a/plugins/reolink/src/nvr/api.ts b/plugins/reolink/src/nvr/api.ts index 68dd52c301..221167db62 100644 --- a/plugins/reolink/src/nvr/api.ts +++ b/plugins/reolink/src/nvr/api.ts @@ -2,9 +2,14 @@ import { AuthFetchCredentialState, authHttpFetch, HttpFetchOptions } from '@scry import { PassThrough, Readable } from 'stream'; import { sleep } from "@scrypted/common/src/sleep"; import { PanTiltZoomCommand, VideoClipOptions } from "@scrypted/sdk"; -import { DevInfo, getLoginParameters } from '../probe'; +import { DevInfo, getLoginParameters, ReolinkProtocol } from '../probe'; import { ReolinkNvrDevice } from './nvr'; +// Single source for the "Use HTTPS" setting copy so the creator-form (main.ts) and the +// device-settings (nvr.ts) descriptions can't drift. The creator form prepends an +// "Is NVR"-only caveat since that form is shared with the standalone camera flow. +export const HTTPS_API_SETTING_DESCRIPTION = 'Use HTTPS for the api.cgi control connection. Required for the Reolink Home Hub / Home Hub Pro, which only serve the API over HTTPS (typically port 443).'; + type StoredLoginSession = { host: string; username: string; @@ -119,23 +124,29 @@ export class ReolinkNvrClient { connectionTime = Date.now(); console: Console; host: string; + protocol: ReolinkProtocol; maxSessionsCount = 0; loginFirstCount = 0; constructor( - httpAddress: string, + address: string, username: string, password: string, console: Console, - public nvrDevice?: ReolinkNvrDevice + public nvrDevice?: ReolinkNvrDevice, + https = false, ) { this.credential = { username, password, }; - this.host = httpAddress; + this.host = address; this.console = console; + // Reolink Home Hub / Home Hub Pro serve the api.cgi endpoint over HTTPS only. + // Standalone cameras and most NVRs use plain HTTP. Default to HTTP for backwards + // compatibility; opt into HTTPS via the device's "Use HTTPS" setting. + this.protocol = https ? 'https' : 'http'; } private async request(options: HttpFetchOptions, body?: Readable) { @@ -177,7 +188,7 @@ export class ReolinkNvrClient { } private async validateExistingSession(parameters: Record) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'GetDevInfo'); for (const [k, v] of Object.entries(parameters)) { @@ -258,7 +269,8 @@ export class ReolinkNvrClient { this.host, this.credential.username, this.credential.password, - true + true, + this.protocol, ); this.parameters = parameters; @@ -328,7 +340,7 @@ export class ReolinkNvrClient { } async reboot() { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'Reboot'); this.rebooting = true; @@ -351,7 +363,7 @@ export class ReolinkNvrClient { } async logout() { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [ { @@ -376,7 +388,7 @@ export class ReolinkNvrClient { } async getOsd(channel: number): Promise { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [ { @@ -401,7 +413,7 @@ export class ReolinkNvrClient { } async setOsd(channel: number, osd: Osd) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [ { @@ -436,7 +448,7 @@ export class ReolinkNvrClient { } async getHubInfo() { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [ { cmd: "GetAbility", @@ -471,7 +483,7 @@ export class ReolinkNvrClient { } async jpegSnapshot(channel: number, timeout = 10000) { - const url = new URL(`http://${this.host}/cgi-bin/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/cgi-bin/api.cgi`); const params = url.searchParams; params.set('cmd', 'Snap'); params.set('channel', String(channel)); @@ -486,7 +498,7 @@ export class ReolinkNvrClient { } async getEncoderConfiguration(channel: number): Promise { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'GetEnc'); params.set('channel', String(channel)); @@ -499,7 +511,7 @@ export class ReolinkNvrClient { } private async ptzOp(channel: number, op: string, speed: number, id?: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'PtzCtrl'); @@ -540,7 +552,7 @@ export class ReolinkNvrClient { } private async presetOp(channel: number, speed: number, id: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'PtzCtrl'); @@ -596,7 +608,7 @@ export class ReolinkNvrClient { } async getSiren(channel: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [{ cmd: 'GetAudioAlarmV20', @@ -621,7 +633,7 @@ export class ReolinkNvrClient { } async setSiren(channel: number, on: boolean, duration?: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'AudioAlarmPlay'); @@ -660,7 +672,7 @@ export class ReolinkNvrClient { } async setWhiteLedState(channel: number, on?: boolean, brightness?: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const settings: any = { channel }; @@ -690,7 +702,7 @@ export class ReolinkNvrClient { } async getStatusInfo(channelsMap: Map) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const chanelIndex: Record = {}; const body: any[] = []; @@ -793,7 +805,7 @@ export class ReolinkNvrClient { } async getBatteryInfo(channelsMap: Map) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const chanelIndex: Record = {}; const body: any[] = [ @@ -848,7 +860,7 @@ export class ReolinkNvrClient { } async getChannels() { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const channelsBody = [{ cmd: 'GetChannelstatus' }]; @@ -869,7 +881,7 @@ export class ReolinkNvrClient { } async getEvents(channelsMap: Map) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = []; const chanelIndex: Record = {}; @@ -954,7 +966,7 @@ export class ReolinkNvrClient { } async getDevicesInfo() { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const { channels, channelsResponse } = await this.getChannels(); @@ -1017,7 +1029,7 @@ export class ReolinkNvrClient { } async getPirState(channel: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [{ cmd: 'GetPirInfo', @@ -1043,7 +1055,7 @@ export class ReolinkNvrClient { } async setPirState(channel: number, on: boolean) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const currentPir = await this.getPirState(channel); const newState = on ? 1 : 0; @@ -1077,7 +1089,7 @@ export class ReolinkNvrClient { } async getLocalLink(channel: number) { - const url = new URL(`http://${this.host}/api.cgi`); + const url = new URL(`${this.protocol}://${this.host}/api.cgi`); const body = [ { diff --git a/plugins/reolink/src/nvr/nvr.ts b/plugins/reolink/src/nvr/nvr.ts index cb513ff201..64b40d5938 100644 --- a/plugins/reolink/src/nvr/nvr.ts +++ b/plugins/reolink/src/nvr/nvr.ts @@ -3,7 +3,7 @@ import ReolinkProvider from "../main"; import { StorageSettings } from "@scrypted/sdk/storage-settings"; import { DevInfo } from "../probe"; import { ReolinkNvrCamera } from "./camera"; -import { DeviceInputData, ReolinkNvrClient } from "./api"; +import { DeviceInputData, HTTPS_API_SETTING_DESCRIPTION, ReolinkNvrClient } from "./api"; export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, DeviceDiscovery, DeviceProvider, Reboot { storageSettings = new StorageSettings(this, { @@ -29,11 +29,30 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De type: 'password', onPut: async () => await this.reinit() }, + https: { + title: 'Use HTTPS', + subgroup: 'Advanced', + description: HTTPS_API_SETTING_DESCRIPTION, + type: 'boolean', + defaultValue: false, + onPut: async (ov, nv) => { + // A device created before the scheme-aware port default may have the old + // HTTP default (80) persisted; an explicit 443 likewise lingers when HTTPS + // is turned off. Clear a stored value that only matches the other scheme's + // default so the scheme-aware fallback in getClient() applies, keeping + // behavior consistent with the "Defaults to 80/443" copy. A genuinely + // custom port is preserved. + const port = this.storageSettings.values.httpPort; + if ((nv && port === 80) || (!nv && port === 443)) + this.storageSettings.values.httpPort = undefined; + await this.reinit(); + } + }, httpPort: { - title: 'HTTP Port', + title: 'API Port', subgroup: 'Advanced', - defaultValue: 80, - placeholder: '80', + description: 'Optional override for the api.cgi port. Defaults to 80 for HTTP and 443 for HTTPS.', + placeholder: '80 / 443', type: 'number', onPut: async () => await this.reinit() }, @@ -244,14 +263,18 @@ export class ReolinkNvrDevice extends ScryptedDeviceBase implements Settings, De getClient() { if (!this.client) { - const { ipAddress, httpPort, password, username } = this.storageSettings.values; - const address = `${ipAddress}:${httpPort}`; + const { ipAddress, httpPort, password, username, https } = this.storageSettings.values; + // Default the port to match the scheme when unset: 443 for HTTPS + // (Home Hub), 80 for HTTP. An explicit port override always wins. + const port = httpPort || (https ? 443 : 80); + const address = `${ipAddress}:${port}`; this.client = new ReolinkNvrClient( - address, - username, - password, + address, + username, + password, this.console, this, + https, ); } return this.client; diff --git a/plugins/reolink/src/probe.ts b/plugins/reolink/src/probe.ts index 33a8d089a4..06b595ea54 100644 --- a/plugins/reolink/src/probe.ts +++ b/plugins/reolink/src/probe.ts @@ -1,9 +1,6 @@ -import https from 'https'; import { httpFetch } from '../../../server/src/fetch/http-fetch'; -export const reolinkHttpsAgent = new https.Agent({ - rejectUnauthorized: false, -}); +export type ReolinkProtocol = 'http' | 'https'; export interface DevInfo { B485: number; @@ -27,8 +24,8 @@ export interface DevInfo { wifi: number; } -async function getDeviceInfoInternal(host: string, parameters: Record): Promise { - const url = new URL(`http://${host}/api.cgi`); +async function getDeviceInfoInternal(host: string, parameters: Record, protocol: ReolinkProtocol = 'http'): Promise { + const url = new URL(`${protocol}://${host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'GetDevInfo'); for (const [key, value] of Object.entries(parameters)) { @@ -38,6 +35,8 @@ async function getDeviceInfoInternal(host: string, parameters: Record { - const parameters = await getLoginParameters(host, username, password); - return getDeviceInfoInternal(host, parameters.parameters); +export async function getDeviceInfo(host: string, username: string, password: string, protocol: ReolinkProtocol = 'http'): Promise { + const parameters = await getLoginParameters(host, username, password, undefined, protocol); + return getDeviceInfoInternal(host, parameters.parameters, protocol); } -export async function getLoginParameters(host: string, username: string, password: string, forceToken?: boolean) { +export async function getLoginParameters(host: string, username: string, password: string, forceToken?: boolean, protocol: ReolinkProtocol = 'http') { if (!forceToken) { try { await getDeviceInfoInternal(host, { user: username, password, - }); + }, protocol); return { parameters: { user: username, @@ -75,7 +74,7 @@ export async function getLoginParameters(host: string, username: string, passwor } try { - const url = new URL(`http://${host}/api.cgi`); + const url = new URL(`${protocol}://${host}/api.cgi`); const params = url.searchParams; params.set('cmd', 'Login');