From 7d192322f5e5c6438bc805704967a07c39f4d850 Mon Sep 17 00:00:00 2001 From: Cory Kringlen Date: Sat, 20 Jun 2026 23:43:37 -0700 Subject: [PATCH] reolink(nvr): add HTTPS option for the api.cgi control connection The NVR/Hub client hardcodes http:// for every api.cgi request. The Reolink Home Hub and Home Hub Pro only serve their API over HTTPS, so the plugin cannot connect to them (login, GetEvents polling, snapshots, PTZ, etc. all fail against http://host:443). Add a per-device "Use HTTPS" toggle (Advanced), surfaced both on the NVR add-device form and on the NVR device settings, and thread it through ReolinkNvrClient as a constructor flag that selects the URL scheme. Defaults to HTTP, so existing cameras and NVRs are unaffected; the add-device port default switches to 443 when HTTPS is selected. Co-Authored-By: Claude Opus 4.8 --- plugins/reolink/src/main.ts | 17 +++++++-- plugins/reolink/src/nvr/api.ts | 64 ++++++++++++++++++++-------------- plugins/reolink/src/nvr/nvr.ts | 41 +++++++++++++++++----- plugins/reolink/src/probe.ts | 23 ++++++------ 4 files changed, 95 insertions(+), 50 deletions(-) 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');