view release on metacpan or search on metacpan
lib/Protocol/HAP/Pairing.pm view on Meta::CPAN
}
# $self->get_failed_attempts:
# Get the current failed attempt count (for testing).
sub get_failed_attempts ($self)
{
return $self->{failed_auth_attempts};
}
# $self->_decode_request($body, $label):
# The decode-and-check preamble of both pairing endpoints.
# Return the request TLV as a hash reference and the state, or
# the empty list for a malformed TLV or a missing State
# ([HAP-TLV8 §10]).
sub _decode_request ( $self, $body, $label )
{
my %request = Protocol::HAP::TLV::decode($body);
unless ( defined $request{ kTLVType_State() } ) {
$self->{logger}
->warning( '%s rejected: malformed TLV request', $label );
lib/Protocol/HAP/Server.pm view on Meta::CPAN
use Protocol::HAP::Bridge;
use Protocol::HAP::Characteristic;
use Protocol::HAP::SetupCode qw(normalize_setup_code);
# Protocol::HAP::Server - the sans-IO HAP accessory-server engine.
#
# The engine consumes bytes and emits bytes. The host owns sockets,
# timers, logging, and persistence, injected through the contracts
# that Protocol/HAP.pod documents. The engine owns everything that is
# protocol: the read buffer and its bound, decryption, HTTP parsing,
# endpoint dispatch, the pairing state machines, the accessory
# database, and event delivery.
# The largest request the engine accepts: the header block plus the
# body that Content-Length declares. An unpaired client reaches
# /pair-setup, so the buffer of an unauthenticated connection needs a
# bound of its own. A HAP request is a small TLV or a short JSON
# document, so 64 KB is far above anything a controller sends.
use constant MAX_REQUEST_SIZE => 65536;
# The HAP status code for a request that arrives on an unverified
lib/Protocol/HAP/Server.pm view on Meta::CPAN
{
return _response(
status => 200,
headers => { 'Content-Type' => 'application/pairing+tlv8' },
body => $body,
);
}
# _char_status($aid, $iid, $code):
# One per-characteristic result entry for the characteristics
# endpoints [HAP-HTTP].
sub _char_status ( $aid, $iid, $code )
{
return { aid => $aid + 0, iid => $iid + 0, status => $code };
}
# $class->new(%args):
# name, pin, setup_id, category - the accessory identity.
# store, logger, output, after, cancel, on_pairing_changed - the
# host contracts of Protocol/HAP.pod. store and output are
# required; after and cancel are optional, and without them the
lib/Protocol/HAP/Server.pm view on Meta::CPAN
my $paired = $self->is_paired ? 1 : 0;
return if $self->{last_paired_state} == $paired;
$self->{last_paired_state} = $paired;
$self->{on_pairing_changed}->($paired)
if $self->{on_pairing_changed};
return;
}
# --- endpoint dispatch ----------------------------------------------------
sub _dispatch ( $self, $request, $session )
{
my $path = $request->{path};
my $method = $request->{method};
# Pairing endpoints. These need no verified session.
if ( $path eq '/pair-setup' && $method eq 'POST' ) {
return $self->_handle_pair_setup( $request, $session );
}
if ( $path eq '/pair-verify' && $method eq 'POST' ) {
return $self->_handle_pair_verify( $request, $session );
}
# Identify endpoint. It is for unpaired accessories only.
if ( $path eq '/identify' && $method eq 'POST' ) {
return $self->_handle_identify( $request, $session );
}
# All other endpoints need a verified session. The 470 code
# carries a reason phrase that only HAP defines, so the codec
# does not know it.
unless ( $session->is_verified ) {
return _response(
status => STATUS_INSUFFICIENT_PRIVILEGES,
status_text => 'Connection Authorization Required',
headers => { 'Content-Type' => 'application/hap+json' },
);
}
# Pairings management
if ( $path eq '/pairings' && $method eq 'POST' ) {
return $self->_handle_pairings( $request, $session );
}
# Accessory endpoints
if ( $path eq '/accessories' && $method eq 'GET' ) {
return $self->_handle_accessories( $request, $session );
}
# Remove the query string for path matching
my $base_path = $path;
$base_path =~ s/\?.*//;
if ( $base_path eq '/characteristics' && $method eq 'GET' ) {
return $self->_handle_characteristics_get( $request, $session );
lib/Protocol/HAP/Server.pm view on Meta::CPAN
return _response(
status => 200,
headers => { 'Content-Type' => 'application/hap+json' },
body => $json,
);
}
# $self->_resolve_char($aid, $iid):
# The accessory-then-characteristic lookup that both
# characteristics endpoints perform.
sub _resolve_char ( $self, $aid, $iid )
{
my $accessory = $self->{bridge}->get_accessory($aid);
return unless $accessory;
return $accessory->get_characteristic($iid);
}
sub _handle_characteristics_get ( $self, $request, $ )
{
lib/Protocol/HAP/Server.pm view on Meta::CPAN
elsif ( $method == 5 ) {
return $self->_handle_list_pairings( \%tlv, $session );
}
# Unknown method
return $self->_pairings_error(
Protocol::HAP::Pairing::kTLVError_Unknown() );
}
# $self->_pairings_error($code):
# One failure response for the pairings endpoints.
sub _pairings_error ( $self, $code )
{
return _tlv_response(
Protocol::HAP::TLV::encode(
Protocol::HAP::Pairing::kTLVType_State(),
pack( 'C', 2 ),
Protocol::HAP::Pairing::kTLVType_Error(),
pack( 'C', $code ),
) );
}
# $self->_require_admin($session):
# The admin check of the pairings endpoints (HAP-Pairing.md §7).
# Return the loaded pairings when the session's controller is an
# admin, or undef.
sub _require_admin ( $self, $session )
{
my $pairings = $self->{store}->load_pairings;
my $current = $pairings->{ $session->controller_id };
return unless $current && $current->{permissions};
return $pairings;
}
lib/Protocol/HAP/Server.pod view on Meta::CPAN
# On disconnect
$engine->session_close($session);
=head1 DESCRIPTION
This module is the HAP accessory server as a sans-IO engine: it
consumes bytes and emits bytes. The host owns sockets, timers,
logging, and persistence, injected through the contracts that
L<Protocol::HAP> documents. The engine owns everything that is
protocol: the read buffer and its 64 KB bound, decryption, HTTP
parsing, the endpoint dispatch, the pairing state machines, the
accessory database, and event delivery.
The endpoints are C</pair-setup>, C</pair-verify>, C</identify>,
C</pairings> (add, remove, list), C</accessories>,
C</characteristics> GET and PUT, and C</prepare>.
=head1 CONSTRUCTOR
C<new> takes the identity arguments C<name>, C<pin>, C<setup_id>, and
C<category> (default 2, a bridge), and the host contracts:
=over 4
t/conformance/hap-http.t view on Meta::CPAN
for my $svc ( @{ $acc->{services} } ) {
for my $char ( @{ $svc->{characteristics} } ) {
return ( $acc->{aid}, $char->{iid} )
if $char->{type} eq $type;
}
}
}
return;
}
subtest '[HAP-HTTP §1] endpoints require a verified session' => sub {
my $hap = make_hap();
my $unverified = $hap->session_open;
for my $probe (
[ 'POST', '/pairings' ],
[ 'GET', '/accessories' ],
[ 'GET', '/characteristics?id=1.1' ],
[ 'PUT', '/characteristics' ],
[ 'PUT', '/prepare' ],
)
{
my ( $status, undef, undef ) =
dispatch( $hap, @$probe, undef, $unverified );
is( $status, 470,
"[HAP-HTTP §13.4] @$probe returns 470 "
. 'without pair-verify' );
}
# Pairing endpoints do not need a verified session
my $m1 = Protocol::HAP::TLV::encode(
Protocol::HAP::Pairing::kTLVType_State(), pack( 'C', 1 ),
Protocol::HAP::Pairing::kTLVType_Method(), pack( 'C', 0 ),
);
my ( $status, undef, undef ) =
dispatch( $hap, 'POST', '/pair-setup', $m1, $unverified );
is( $status, 200,
'[HAP-HTTP §4] POST /pair-setup open to unverified sessions'
);
t/conformance/hap-http.t view on Meta::CPAN
my $m1 = Protocol::HAP::TLV::encode(
Protocol::HAP::Pairing::kTLVType_State(), pack( 'C', 1 ),
Protocol::HAP::Pairing::kTLVType_Method(), pack( 'C', 0 ),
);
my ( undef, $headers, undef ) =
dispatch( $hap, 'POST', '/pair-setup', $m1,
$hap->session_open );
is( $headers->{'content-type'},
'application/pairing+tlv8',
'pairing endpoints use application/pairing+tlv8' );
( undef, $headers, undef ) = dispatch( $hap, 'GET', '/accessories' );
is( $headers->{'content-type'},
'application/hap+json',
'accessory endpoints use application/hap+json' );
};
subtest '[HAP-HTTP §3] POST /identify paired vs unpaired' => sub {
my $hap = make_hap();
my $unverified = $hap->session_open;
my ( $status, undef, undef ) =
dispatch( $hap, 'POST', '/identify', undef, $unverified );
is( $status, 204, 'unpaired identify returns 204 No Content' );
t/conformance/hap-http.t view on Meta::CPAN
is( $json->decode($body)->{status}, 0, 'prepare status 0' );
# Missing ttl/pid -> -70410 invalid value
( $status, undef, $body ) =
dispatch( $hap, 'PUT', '/prepare', '{}', $session );
is( $status, 400, 'missing ttl/pid rejected' );
is( $json->decode($body)->{status},
-70410, 'missing ttl/pid has status -70410' );
# The server also accepts POST. The spec shows POST in the
# endpoint table.
( $status, undef, undef ) =
dispatch( $hap, 'POST', '/prepare', $prepare, $session );
is( $status, 200, 'POST /prepare also accepted' );
};
subtest '[HAP-HTTP §6][HAP-Pairing §7][HAP-Pairing §7.1] add pairing' =>
sub {
my $hap = make_hap();
$hap->{store}->save_pairing( 'admin-ctrl', 'A' x 32, 1 );
$hap->{store}->save_pairing( 'user-ctrl', 'U' x 32, 0 );
t/conformance/hap-http.t view on Meta::CPAN
);
( $status, undef, $body ) = dispatch( $hap, 'POST', '/pairings',
$remove_admin, verified_session( $hap, 'admin-ctrl' ) );
%tlv = Protocol::HAP::TLV::decode($body);
ok( !exists $tlv{ Protocol::HAP::Pairing::kTLVType_Error() },
'removing last admin succeeds' );
is( scalar keys %{ $hap->{store}->load_pairings() },
0, 'all pairings removed with the last admin' );
};
subtest '[HAP-HTTP §13][HAP-HTTP §13.2] unknown endpoint returns 404' =>
sub {
my $hap = make_hap();
my ( $status, undef, undef ) =
dispatch( $hap, 'GET', '/no-such-endpoint' );
is( $status, 404, 'unknown endpoint returns 404' );
};
subtest '[HAP-HTTP §15][HAP-HTTP §15.1] JSON value encoding' => sub {
my $hap = make_hap();
my ( undef, undef, $acc_body ) =
dispatch( $hap, 'GET', '/accessories' );
my ( $aid, $iid ) = find_char( $json->decode($acc_body), '25' );
# A bool encodes as JSON true/false, not 1/0
my ( undef, undef, $body ) =
t/openhap/integration/accessories.t view on Meta::CPAN
#!/usr/bin/env perl
# ex:ts=8 sw=4:
# Integration test: Accessory endpoints gate on pairing state
use v5.36;
use Test::More tests => 8;
use FindBin qw($RealBin);
use lib "$RealBin/../../../lib";
use App::OpenHAP::Test::Integration;
my $env = App::OpenHAP::Test::Integration->new;
$env->setup;
t/openhap/integration/accessories.t view on Meta::CPAN
# The daemon is running, thus hapctl asks it over the control socket
# and reports "Loaded devices". With no daemon it reads the file and
# reports "Configured devices". Accept either word: the count is the
# assertion, and it must match the file whichever source answered.
my ($count) = $devices_output =~ /(?:Loaded|Configured) devices:\s*(\d+)/;
my @device_topics = $env->get_device_topics;
is($count // 0, scalar @device_topics,
'the daemon loaded every configured device');
# The daemon starts unpaired, because the integration files own their
# pairing lifecycle. Thus the data-plane endpoints must return HTTP
# 470. The file t/openhap/integration/characteristics.t covers paired
# access.
# Test 3: GET /accessories requires pairing
my $response = $env->http_request('GET', '/accessories');
my $status = App::OpenHAP::Test::Integration::status($response);
is($status, 470,
'[HAP-HTTP §7] GET /accessories returns 470 when unpaired');
# Test 4: GET /characteristics requires pairing
t/openhap/integration/hap-protocol.t view on Meta::CPAN
#!/usr/bin/env perl
# ex:ts=8 sw=4:
# Integration test: HAP protocol endpoints and HTTP functionality
use v5.36;
use Test::More tests => 16;
use FindBin qw($RealBin);
use lib "$RealBin/../../../lib";
use App::OpenHAP::Test::Integration;
my $env = App::OpenHAP::Test::Integration->new;
$env->setup;
# Test 1: The HAP server is reachable over HTTP
my $response = $env->http_request('GET', '/');
ok(defined $response && $response =~ /^HTTP\/1\.[01]/, 'server reachable');
# Test 2: /accessories endpoint responds
$response = $env->http_request('GET', '/accessories');
my $status = App::OpenHAP::Test::Integration::status($response);
ok(defined $status, '/accessories endpoint responds');
# Test 3: /accessories has correct Content-Type
my $has_content_type = $response =~ /Content-Type:\s*application\/hap\+json/i;
ok($has_content_type || $status == 470,
'/accessories uses application/hap+json');
# Test 4: /pair-setup endpoint accepts POST
$response = $env->http_request('POST', '/pair-setup', "\x00\x01\x00",
{'Content-Type' => 'application/pairing+tlv8'});
ok(defined $response && $response =~ /HTTP\/1\.[01]\s+200/,
'/pair-setup accepts POST');
# Test 5: /pair-setup uses correct Content-Type
$has_content_type = $response =~ /Content-Type:\s*application\/pairing\+tlv8/i;
ok($has_content_type, '/pair-setup uses application/pairing+tlv8');
# Test 6: /pair-verify endpoint accepts POST
$response = $env->http_request('POST', '/pair-verify', "\x00\x01\x00",
{'Content-Type' => 'application/pairing+tlv8'});
ok(defined $response && $response =~ /HTTP\/1\.[01]\s+200/,
'/pair-verify accepts POST');
# Test 7: /pair-verify uses correct Content-Type
$has_content_type = $response =~ /Content-Type:\s*application\/pairing\+tlv8/i;
ok($has_content_type, '/pair-verify uses application/pairing+tlv8');
# Test 8: Server uses HTTP/1.x protocol
t/openhap/integration/pairing.t view on Meta::CPAN
or diag('pair_setup error: ' . ($controller->last_error // 'none'));
ok(defined $controller->{accessory_ltpk},
'[HAP-Pairing §2.8] accessory LTPK received in M6');
# Test 4: Pair-verify establishes an encrypted session
ok($controller->pair_verify,
'[HAP-Pairing §3] pair-verify M1-M4 completes')
or diag('pair_verify error: ' . ($controller->last_error // 'none'));
ok($controller->is_encrypted, 'session switched to encrypted framing');
# Test 5: Authenticated endpoint works over the session
my $result = $controller->request('GET', '/accessories');
is($result->{status}, 200,
'[HAP-HTTP §7] paired GET /accessories returns 200');
# Test 6: Add, list, and remove an additional pairing over the wire
ok($controller->add_pairing('extra-ctrl', 'X' x 32, 0),
'[HAP-Pairing §7.1] add pairing accepted');
my $pairings = $controller->list_pairings;
is(scalar @$pairings, 2, '[HAP-Pairing §7.3] both pairings listed');
ok($controller->remove_pairing('extra-ctrl'),