File Coverage

blib/lib/SignalWire/Agents/SWML/Service.pm
Criterion Covered Total %
statement 100 115 86.9
branch 22 36 61.1
condition 20 36 55.5
subroutine 21 25 84.0
pod 0 3 0.0
total 163 215 75.8


line stmt bran cond sub pod time code
1             package SignalWire::Agents::SWML::Service;
2 1     1   519 use strict;
  1         1  
  1         29  
3 1     1   3 use warnings;
  1         1  
  1         34  
4 1     1   4 use Moo;
  1         1  
  1         9  
5 1     1   260 use JSON ();
  1         1  
  1         23  
6 1     1   491 use Digest::SHA qw(hmac_sha256_hex);
  1         3364  
  1         85  
7 1     1   430 use MIME::Base64 ();
  1         551  
  1         26  
8 1     1   5 use SignalWire::Agents::SWML::Document;
  1         2  
  1         16  
9 1     1   3 use SignalWire::Agents::SWML::Schema;
  1         1  
  1         13  
10 1     1   421 use SignalWire::Agents::Logging;
  1         3  
  1         1182  
11              
12             has 'route' => (
13             is => 'rw',
14             default => sub { '/' },
15             );
16              
17             has 'host' => (
18             is => 'rw',
19             default => sub { $ENV{SWML_HOST} // '0.0.0.0' },
20             );
21              
22             has 'port' => (
23             is => 'rw',
24             default => sub { $ENV{SWML_PORT} // 3000 },
25             );
26              
27             has 'basic_auth_user' => (
28             is => 'rw',
29             default => sub { $ENV{SWML_BASIC_AUTH_USER} // _random_hex(16) },
30             );
31              
32             has 'basic_auth_password' => (
33             is => 'rw',
34             default => sub { $ENV{SWML_BASIC_AUTH_PASSWORD} // _random_hex(32) },
35             );
36              
37             has 'document' => (
38             is => 'rw',
39             default => sub { SignalWire::Agents::SWML::Document->new() },
40             );
41              
42             has '_logger' => (
43             is => 'ro',
44             default => sub { SignalWire::Agents::Logging->get_logger('signalwire.swml_service') },
45             );
46              
47             # Schema-driven verb auto-vivification via AUTOLOAD
48             our $AUTOLOAD;
49             my $_schema;
50              
51             sub _get_schema {
52 6   66 6   34 $_schema //= SignalWire::Agents::SWML::Schema->instance();
53 6         15 return $_schema;
54             }
55              
56             sub AUTOLOAD {
57 8     8   8926 my $self = shift;
58 8         20 my $method = $AUTOLOAD;
59 8         62 $method =~ s/.*:://; # strip package name
60              
61 8 100       64 return if $method eq 'DESTROY';
62              
63 4         11 my $schema = _get_schema();
64 4 100       21 if ($schema->has_verb($method)) {
65             # For 'sleep' verb: takes an integer (milliseconds), not a hashref
66 3   50     12 my $section = shift // 'main';
67 3         7 my $data;
68 3 100       13 if ($method eq 'sleep') {
69 1   50     6 $data = shift // 0;
70             # Ensure it is a numeric value
71 1         3 $data = int($data);
72             } else {
73 2   50     8 $data = shift // {};
74             }
75 3         22 $self->document->add_verb($section, $method, $data);
76 3         10 return $self;
77             }
78              
79 1         15 die "Can't locate method \"$method\" via package \"" . ref($self) . "\"";
80             }
81              
82             # Provide can() that knows about schema verbs
83             sub can {
84 10     10 0 13618 my ($self, $method) = @_;
85             # Check if it is a regular method first
86 10         67 my $code = $self->SUPER::can($method);
87 10 100       67 return $code if $code;
88             # Check schema verbs
89 2         7 my $schema = _get_schema();
90 2 50 33     16 if ($schema && $schema->has_verb($method)) {
91 0     0   0 return sub { $self->$method(@_) };
  0         0  
92             }
93 2         7 return undef;
94             }
95              
96             sub _random_hex {
97 4     4   12 my ($len) = @_;
98             # Use /dev/urandom for cryptographically secure random bytes.
99             # Die on failure rather than falling back to weak randomness.
100 4 50       278 if (open my $fh, '<:raw', '/dev/urandom') {
101 4         10 my $bytes;
102 4         336 my $read = read($fh, $bytes, $len);
103 4         52 close $fh;
104 4 50 33     31 if (defined $read && $read == $len) {
105 4         188 return unpack('H*', $bytes);
106             }
107             }
108 0         0 die "FATAL: Cannot generate secure random bytes - /dev/urandom unavailable or read failed. "
109             . "Set SWML_BASIC_AUTH_USER and SWML_BASIC_AUTH_PASSWORD environment variables instead.\n";
110             }
111              
112             sub _timing_safe_compare {
113 8     8   4724 my ($a, $b) = @_;
114             # Compare HMAC of both values with a fixed key for constant-time comparison
115 8         17 my $key = 'timing-safe-comparison-key';
116 8         118 my $hmac_a = hmac_sha256_hex($a, $key);
117 8         104 my $hmac_b = hmac_sha256_hex($b, $key);
118 8         58 return $hmac_a eq $hmac_b;
119             }
120              
121             sub _check_basic_auth {
122 3     3   8 my ($self, $env) = @_;
123 3   100     14 my $auth = $env->{HTTP_AUTHORIZATION} // '';
124 3 100       35 return 0 unless $auth =~ /^Basic\s+(.+)$/i;
125 2         18 my $decoded = MIME::Base64::decode_base64($1);
126 2         12 my ($user, $pass) = split(/:/, $decoded, 2);
127 2 50 33     14 return 0 unless defined $user && defined $pass;
128 2   66     14 return _timing_safe_compare($user, $self->basic_auth_user)
129             && _timing_safe_compare($pass, $self->basic_auth_password);
130             }
131              
132             sub _security_headers {
133             return (
134 4     4   25 'X-Content-Type-Options' => 'nosniff',
135             'X-Frame-Options' => 'DENY',
136             'X-XSS-Protection' => '1; mode=block',
137             'Cache-Control' => 'no-store, no-cache, must-revalidate',
138             'Pragma' => 'no-cache',
139             'Content-Type' => 'application/json',
140             );
141             }
142              
143             sub _json_response {
144 4     4   10 my ($status, $data) = @_;
145 4         10 my @headers = _security_headers();
146 4         35 my $body = JSON::encode_json($data);
147 4         34 return [$status, \@headers, [$body]];
148             }
149              
150             sub _read_body {
151 0     0   0 my ($env) = @_;
152 0         0 my $input = $env->{'psgi.input'};
153 0 0       0 return '' unless $input;
154 0         0 local $/;
155 0         0 my $body = <$input>;
156 0   0     0 return $body // '';
157             }
158              
159             sub to_psgi_app {
160 1     1 0 15 my ($self) = @_;
161              
162             return sub {
163 6     6   9303 my ($env) = @_;
164 6         21 my $method = $env->{REQUEST_METHOD};
165 6   50     22 my $path = $env->{PATH_INFO} // '/';
166              
167             # Health/ready endpoints (no auth)
168 6 100 100     40 if ($path eq '/health' || $path eq '/ready') {
169 2         11 return _json_response(200, { status => 'ok' });
170             }
171              
172             # Normalize route for matching
173 4         18 my $route = $self->route;
174 4         16 $route =~ s{/$}{}; # strip trailing slash
175 4         10 $path =~ s{/$}{}; # strip trailing slash
176 4 50       12 $route = '' if $route eq '/';
177 4 50       14 $path = '' if $path eq '/';
178              
179             # Check if this request matches our routes
180 4         9 my $is_swml_route = ($path eq $route);
181 4         11 my $is_swaig_route = ($path eq "$route/swaig");
182 4         8 my $is_post_prompt = ($path eq "$route/post_prompt");
183              
184 4 50 66     25 if ($is_swml_route || $is_swaig_route || $is_post_prompt) {
      66        
185             # Require basic auth for protected routes
186 3 100       16 unless ($self->_check_basic_auth($env)) {
187             return [
188 2         24 401,
189             ['Content-Type' => 'text/plain', 'WWW-Authenticate' => 'Basic realm="SignalWire"'],
190             ['Authentication required'],
191             ];
192             }
193              
194 1 50       5 if ($is_swml_route) {
    0          
    0          
195 1         6 return $self->_handle_swml_request($env);
196             } elsif ($is_swaig_route) {
197 0         0 return $self->_handle_swaig_request($env);
198             } elsif ($is_post_prompt) {
199 0         0 return $self->_handle_post_prompt($env);
200             }
201             }
202              
203 1         10 return _json_response(404, { error => 'Not found' });
204 1         9 };
205             }
206              
207             sub _handle_swml_request {
208 1     1   5 my ($self, $env) = @_;
209 1         5 my $doc = $self->render_swml($env);
210 1         5 return _json_response(200, $doc);
211             }
212              
213             sub render_swml {
214 1     1 0 3 my ($self, $env) = @_;
215 1         12 return $self->document->to_hash;
216             }
217              
218             sub _handle_swaig_request {
219 0     0     my ($self, $env) = @_;
220 0           return _json_response(200, { response => 'SWAIG endpoint' });
221             }
222              
223             sub _handle_post_prompt {
224 0     0     my ($self, $env) = @_;
225 0           return _json_response(200, { response => 'Post prompt endpoint' });
226             }
227              
228             1;