| 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; |