Files
zoneminder/scripts/ZoneMinder/lib/ZoneMinder/Control/Grandstream.pm
T
Isaac ConnorandClaude Opus 4.8 ee29134bb3 fix: parse Grandstream control address with guess_credentials
The hand-rolled regex in open() mis-parsed a stream Path that carried no
credentials, e.g. rtsp://10.0.0.4:554/cam: the greedy [^:@]+ consumed the host
as the username, 554 as the password, and ADDRESS backtracked to the single
digit 4. The control daemon then dialled http://...@4, producing
"Can't connect to 4:80 (Connection timed out)".

Replace the regex with the shared URI-based Control.pm guess_credentials(),
which handles ControlAddress and Path, converts rtsp to http, and falls back to
the Monitor User/Pass. Create the LWP::UserAgent before parsing since
guess_credentials() sets credentials on it. The Grandstream login wire protocol
(challenge/authcode and old-style fallback) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 20:57:23 -04:00

270 lines
9.2 KiB
Perl

# ==========================================================================
#
# ZoneMinder GrandSteam Control Protocol Module
# Copyright (C) 2021 ZoneMinder Inc
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
#
# ==========================================================================
#
# This module contains the implementation of the Vivotek ePTZ camera control
# protocol
#
package ZoneMinder::Control::Grandstream;
use 5.006;
use strict;
use warnings;
require ZoneMinder::Base;
require ZoneMinder::Control;
our @ISA = qw(ZoneMinder::Control);
# ==========================================================================
#
# Vivotek ePTZ Control Protocol
#
# ==========================================================================
use ZoneMinder::Logger qw(:all);
use ZoneMinder::Config qw(:all);
use ZoneMinder::General qw(:all);
use Time::HiRes qw( usleep );
use URI::Encode qw(uri_encode);
use XML::LibXML;
use Digest::MD5 qw(md5 md5_hex md5_base64);
our $REALM = '';
our $PROTOCOL = 'https://';
our $USERNAME = 'admin';
our $PASSWORD = '';
our $ADDRESS = '';
our $BASE_URL = '';
my %config_types = (
upgrade => {
P6767 => { default_value=>1, desc=>'Firmware Upgrade Method http' },
P192 => { desc=>'Firmware Server Path' },
},
date => {
P64 => { desc=>'Timezone' },
P5006 => { default_value=>1, desc=>'Enable NTP' },
P30 => { desc=>'NTP Server', },
},
access => {
P12053 => {default_value=>1, desc=>'Enable UPnP Search' },
},
cmos => {
#P12314=> { value=>0, desc=>'Power Frequency' },
},
video => {
#P12306 => { value=>'26', desc=>'primary codec' },# 26: h264, 96: mjpeg, 98: h265
P12313 => { desc=>'primary profile' },# 0: baseline, 1: main, 2: high
P12307 => { desc=>'primary resolution' }, # 1025: 1920x1080 1023: 1280x960, 1022: 1280x720
P12904 => { desc=>'primary fps', }, # fps 5,10,15,20,25,30
P12311 => { desc=>'Image quality', }, # 0 very high, 4 very low
P12312 => { desc=>'Iframe interval', }, # i-frame interval 5-100
},
osd => {
P10044 => { default_value=> 1, desc=>'Display Time' },
#P10045 => { value=> 1, desc=>'Display Text' },
P10001 => { default_value=> 1, desc=>'OSD Date Format' },
#P10040 => { value=>'', desc=> 'OSD Text' },
},
audio => {
P14000 => { default_value=>1, desc=>'Audio codec' }, # 1,2
P14003 => { default_value=>0, desc=>'Audio out volume' }, # 0-6
P14002 => { default_value=>0, desc=>'Audio in volume' }, # 0-6
},
debug => {
P8042 => { default_value=>0, desc=>'Debug log protocol' }, # 0: UDP 1: SSL/TLS
P207 => { desc=>'Debug Log Server' },
P208 => { desc=>'Debug Log Level' },
},
);
sub open {
my $self = shift;
$self->loadMonitor();
use LWP::UserAgent;
$self->{ua} = LWP::UserAgent->new;
$self->{ua}->agent('ZoneMinder Control Agent/'.ZoneMinder::Base::ZM_VERSION);
$self->{ua}->ssl_opts(verify_hostname => 0, SSL_verify_mode => 0x00);
$self->{ua}->cookie_jar( {} );
# Use the shared URI-based parser (Control.pm) instead of a hand-rolled regex.
# The old regex mis-parsed stream Paths such as rtsp://host:554/path: the host
# was consumed as the username, the port as the password, and ADDRESS
# backtracked to a single digit, producing failures like "Can't connect to 4:80".
if (!$self->guess_credentials()) {
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Failed to parse connection details from ControlAddress '".($self->{Monitor}{ControlAddress} // '')."' or Path '".($self->{Monitor}{Path} // '')."'");
return 0;
}
$USERNAME = $self->{username} if $self->{username};
$PASSWORD = $self->{password} if defined $self->{password};
$ADDRESS = $self->{host};
# guess_credentials sets $self->{host} for ping. Keep the https:// default for
# the control API; the camera's HTTP/HTTPS port is independent of the stream port.
$BASE_URL = $PROTOCOL.$ADDRESS;
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): base url $BASE_URL user $USERNAME");
my $url = $BASE_URL.'/goform/login?cmd=login&type=0&user='.$USERNAME;
my $response = $self->get($url);
if ($response->is_success()) {
my $dom = XML::LibXML->load_xml(string => $response->content);
my $challengeString = $dom->getElementsByTagName('ChallengeCode')->string_value();
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): challengestring: ".$challengeString);
my $authcode = md5_hex($challengeString.':GSC36XXlZpRsFzCbM:'.$PASSWORD);
$url .= '&authcode='.$authcode;
$response = $self->get($url);
if (!$response->is_success()) {
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream login authcode request failed: ".$response->status_line());
return 0;
}
my $dom2 = XML::LibXML->load_xml(string => $response->content);
my $rescode = $dom2->getElementsByTagName('ResCode')->string_value();
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream login ResCode: $rescode");
} else {
# Initial probe failed; fall back to the old basic-auth-in-URL style and
# test that too, so we only report success if we can actually reach the camera.
Warning("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Falling back to old style");
$PROTOCOL = 'http://';
$BASE_URL = $PROTOCOL.$USERNAME.':'.$PASSWORD.'@'.$ADDRESS;
$response = $self->get($BASE_URL.'/goform/login?cmd=login&type=0&user='.$USERNAME);
if (!$response->is_success()) {
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Grandstream old-style connection failed: ".$response->status_line());
return 0;
}
}
$self->{state} = 'open';
return 1;
}
sub get {
my $self = shift;
my $url = shift;
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Getting $url");
my $response = $self->{ua}->get($url);
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Response: ".$response->status_line.' '.$response->content);
return $response;
}
sub close {
my $self = shift;
$self->{state} = 'closed';
}
sub sendCmd {
my ($self, $cmd, $speedcmd) = @_;
$self->printMsg( $speedcmd, 'Tx' );
$self->printMsg( $cmd, 'Tx' );
my $req = HTTP::Request->new( GET => $BASE_URL."/cgi-bin/camctrl/eCamCtrl.cgi?stream=0&$speedcmd&$cmd");
my $res = $self->{ua}->request($req);
if (!$res->is_success) {
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Request failed: ".$res->status_line().' (URI: '.$req->as_string().')');
}
return $res->is_success;
}
sub get_config {
my $self = shift;
my %config;
foreach my $category ( @_ ? @_ : keys %config_types ) {
my $response = $self->get($BASE_URL.'/goform/config?cmd=get&type='.$category);
my $dom = XML::LibXML->load_xml(string => $response->content);
if (!$dom) {
Error("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): No document from :".$response->content());
return;
}
Debug("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): ".$dom->toString(1));
$config{$category} = {};
my $Configuration = $dom->getElementsByTagName('Configuration');
my $xml = $Configuration->get_node(0);
if (!$xml) {
Warning("Monitor $self->{Monitor}{Id} ($self->{Monitor}{Name}): Unable to get Configuration node from ".$response->content());
return \%config;
}
foreach my $node ($xml->childNodes()) {
$config{$category}{$node->nodeName} = {
value=>$node->textContent
};
}
} # end foreach category
return \%config;
} # end sub get_config
sub set_config {
my $self = shift;
my $updates = shift;
my $url = join('&', $BASE_URL.'/goform/config?cmd=set',
map { $_.'='.uri_encode(uri_encode($$updates{$_}{value}, { encode_reserved=>1} )) } keys %$updates );
my $response = $self->get($url);
return 0 if !$response->is_success();
return 0 if ($response->content !~ /Successful/i);
return 1;
}
sub reboot {
my $self = shift;
$self->get($BASE_URL.'/goform/config?cmd=reboot');
}
1;
__END__
=head1 NAME
ZoneMinder::Control::Grandstream - ZoneMinder Perl extension for Grandstream
camera control protocol
=head1 SYNOPSIS
use ZoneMinder::Control::Grandstream;
=head1 DESCRIPTION
This module implements the protocol used in various Grandstream IP cameras.
=head2 EXPORT
None.
=head1 SEE ALSO
I would say, see ZoneMinder::Control documentation. But it is a stub.
=head1 AUTHOR
Isaac Connor E<lt>isaac@zoneminder.comE<gt>
=head1 COPYRIGHT AND LICENSE
Copyright (C) 2021 by ZoneMinder Inc
Licensed under the GNU General Public License v2 or later; see the COPYING
file distributed with ZoneMinder for the full text.
=cut