File Coverage

blib/lib/SignalWire/Agents/Server/AgentServer.pm
Criterion Covered Total %
statement 76 116 65.5
branch 18 48 37.5
condition 5 33 15.1
subroutine 14 16 87.5
pod 0 7 0.0
total 113 220 51.3


line stmt bran cond sub pod time code
1             package SignalWire::Agents::Server::AgentServer;
2             # Copyright (c) 2025 SignalWire
3             # Licensed under the MIT License.
4              
5 1     1   364201 use strict;
  1         2  
  1         32  
6 1     1   5 use warnings;
  1         2  
  1         85  
7 1     1   452 use Moo;
  1         6300  
  1         4  
8 1     1   1258 use JSON qw(encode_json decode_json);
  1         2  
  1         36  
9 1     1   171 use Carp qw(croak);
  1         3  
  1         75  
10 1     1   4 use File::Spec;
  1         2  
  1         1404  
11              
12             has host => (is => 'rw', default => sub { '0.0.0.0' });
13             has port => (is => 'rw', default => sub { $ENV{PORT} || 3000 });
14             has log_level => (is => 'rw', default => sub { 'info' });
15             has agents => (is => 'rw', default => sub { {} });
16              
17             # SIP routing
18             has _sip_routing_enabled => (is => 'rw', default => sub { 0 });
19             has _sip_username_mapping => (is => 'rw', default => sub { {} });
20              
21             # Static file routes: { route => directory }
22             has _static_routes => (is => 'rw', default => sub { {} });
23              
24             sub register {
25 15     15 0 170 my ($self, $agent, $route) = @_;
26              
27 15   66     62 $route //= $agent->route;
28 15 100       42 $route = "/$route" unless $route =~ m{^/};
29 15 50       52 $route =~ s{/+$}{} unless $route eq '/';
30              
31 15 100       39 if (exists $self->agents->{$route}) {
32 1         218 croak("Route '$route' is already registered");
33             }
34              
35 14         32 $agent->route($route);
36 14         34 $self->agents->{$route} = $agent;
37 14         24 return $self;
38             }
39              
40             sub unregister {
41 1     1 0 333 my ($self, $route) = @_;
42 1 50       5 $route = "/$route" unless $route =~ m{^/};
43 1 50       6 $route =~ s{/+$}{} unless $route eq '/';
44 1         3 delete $self->agents->{$route};
45 1         2 return $self;
46             }
47              
48             sub list_agents {
49 1     1 0 8 my ($self) = @_;
50 1         2 return [ sort keys %{ $self->agents } ];
  1         7  
51             }
52              
53             sub get_agent {
54 2     2 0 779 my ($self, $route) = @_;
55 2         6 return $self->agents->{$route};
56             }
57              
58             sub serve_static_files {
59 0     0 0 0 my ($self, $directory, $route) = @_;
60              
61 0 0       0 croak("serve_static_files requires a directory") unless defined $directory;
62 0 0       0 croak("serve_static_files requires a route") unless defined $route;
63 0 0       0 croak("Static directory '$directory' does not exist") unless -d $directory;
64              
65 0 0       0 $route = "/$route" unless $route =~ m{^/};
66 0 0       0 $route =~ s{/+$}{} unless $route eq '/';
67              
68             # Resolve the directory to an absolute path for security
69 0         0 $self->_static_routes->{$route} = File::Spec->rel2abs($directory);
70 0         0 return $self;
71             }
72              
73             sub psgi_app {
74 7     7 0 27 my ($self) = @_;
75 7         17 return $self->_build_psgi_app;
76             }
77              
78             sub _build_psgi_app {
79 7     7   13 my ($self) = @_;
80 7         480 require Plack::Request;
81              
82 7         58595 my $server = $self;
83              
84             # MIME type mapping for static files
85 7         68 my %mime_types = (
86             html => 'text/html',
87             htm => 'text/html',
88             css => 'text/css',
89             js => 'application/javascript',
90             json => 'application/json',
91             png => 'image/png',
92             jpg => 'image/jpeg',
93             jpeg => 'image/jpeg',
94             gif => 'image/gif',
95             svg => 'image/svg+xml',
96             ico => 'image/x-icon',
97             txt => 'text/plain',
98             pdf => 'application/pdf',
99             xml => 'application/xml',
100             woff => 'font/woff',
101             woff2 => 'font/woff2',
102             ttf => 'font/ttf',
103             eot => 'application/vnd.ms-fontobject',
104             );
105              
106             # Build a plain PSGI app with route dispatch
107             my $core_app = sub {
108 7     7   10 my $env = shift;
109 7   50     17 my $path = $env->{PATH_INFO} // '/';
110 7 50       27 $path =~ s{/+$}{} unless $path eq '/';
111              
112             # Health/ready (no auth)
113 7 100       14 if ($path eq '/health') {
114 1         6 my @agent_names = map { $server->agents->{$_}->name }
115 2         3 sort keys %{ $server->agents };
  2         16  
116 2         21 return [200, ['Content-Type' => 'application/json'],
117             [encode_json({ status => 'healthy', agents => \@agent_names })]];
118             }
119 5 100       11 if ($path eq '/ready') {
120 1         8 return [200, ['Content-Type' => 'application/json'],
121             [encode_json({ status => 'ready' })]];
122             }
123              
124             # Check static file routes (longest prefix match)
125 4         6 for my $static_route (sort { length($b) <=> length($a) } keys %{ $server->_static_routes }) {
  0         0  
  4         17  
126 0 0       0 my $prefix = $static_route eq '/' ? '' : $static_route;
127 0 0 0     0 if ($path eq $static_route || index($path, "$prefix/") == 0 || ($static_route eq '/' && $path =~ m{^/})) {
      0        
      0        
128 0 0 0     0 next if $static_route eq '/' && $path eq '/';
129 0         0 my $rel_path = substr($path, length($prefix));
130 0         0 $rel_path =~ s{^/}{};
131              
132             # Path traversal protection: reject ".." components
133 0 0       0 if ($rel_path =~ m{(?:^|/)\.\.(?:/|$)}) {
134 0         0 return [403, [
135             'Content-Type' => 'text/plain',
136             'X-Content-Type-Options' => 'nosniff',
137             'X-Frame-Options' => 'DENY',
138             'Cache-Control' => 'no-store',
139             ], ['Forbidden']];
140             }
141              
142 0         0 my $base_dir = $server->_static_routes->{$static_route};
143 0         0 my $file_path = File::Spec->catfile($base_dir, split(m{/}, $rel_path));
144              
145             # Resolve to absolute and verify it's within the base directory
146 0         0 my $abs_path = File::Spec->rel2abs($file_path);
147 0 0       0 unless (index($abs_path, $base_dir) == 0) {
148 0         0 return [403, [
149             'Content-Type' => 'text/plain',
150             'X-Content-Type-Options' => 'nosniff',
151             'X-Frame-Options' => 'DENY',
152             'Cache-Control' => 'no-store',
153             ], ['Forbidden']];
154             }
155              
156 0 0 0     0 if (-f $abs_path && -r $abs_path) {
157             # Determine MIME type from extension
158 0         0 my ($ext) = ($abs_path =~ /\.(\w+)$/);
159 0   0     0 $ext = lc($ext // '');
160 0   0     0 my $content_type = $mime_types{$ext} // 'application/octet-stream';
161              
162 0 0       0 open my $fh, '<:raw', $abs_path or
163             return [500, ['Content-Type' => 'text/plain'], ['Internal Server Error']];
164 0         0 local $/;
165 0         0 my $content = <$fh>;
166 0         0 close $fh;
167              
168 0         0 return [200, [
169             'Content-Type' => $content_type,
170             'Content-Length' => length($content),
171             'X-Content-Type-Options' => 'nosniff',
172             'X-Frame-Options' => 'DENY',
173             'Cache-Control' => 'no-store',
174             ], [$content]];
175             }
176              
177             # Static route matched but file not found - fall through
178             }
179             }
180              
181             # Find matching agent by longest prefix
182 4         6 my $matched_route;
183 4         5 for my $route (sort { length($b) <=> length($a) } keys %{ $server->agents }) {
  2         5  
  4         15  
184 4 50       9 if ($route eq '/') {
185 0         0 $matched_route = $route;
186 0         0 last;
187             }
188 4 100 66     11 if ($path eq $route || index($path, "$route/") == 0) {
189 3         6 $matched_route = $route;
190 3         4 last;
191             }
192             }
193              
194 4 100       9 if (defined $matched_route) {
195 3         6 my $agent = $server->agents->{$matched_route};
196 3         10 my $agent_app = $agent->psgi_app;
197 3         21 return $agent_app->($env);
198             }
199              
200 1         10 return [404, ['Content-Type' => 'application/json'],
201             [encode_json({ error => 'Not Found' })]];
202 7         42 };
203              
204             # Wrap with security headers
205             return sub {
206 7     7   767 my $env = shift;
207 7         17 my $res = $core_app->($env);
208 7 50       19 if (ref $res eq 'ARRAY') {
209 7         8 push @{ $res->[1] },
  7         20  
210             'X-Content-Type-Options' => 'nosniff',
211             'X-Frame-Options' => 'DENY',
212             'Cache-Control' => 'no-store';
213             }
214 7         14 return $res;
215 7         30 };
216             }
217              
218             sub run {
219 0     0 0   my ($self, %opts) = @_;
220 0           my $app = $self->psgi_app;
221 0   0       my $host = $opts{host} // $self->host;
222 0   0       my $port = $opts{port} // $self->port;
223              
224 0           require Plack::Runner;
225 0           my $runner = Plack::Runner->new;
226 0           $runner->parse_options(
227             '--host' => $host,
228             '--port' => $port,
229             '--server' => 'HTTP::Server::PSGI',
230             );
231 0           $runner->run($app);
232             }
233              
234             1;