]>
Commit | Line | Data |
---|---|---|
9ae947dd DM |
1 | package PVE::APIClient::LWP; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
5 | use URI; | |
6 | use IO::Socket::SSL; # important for SSL_verify_callback | |
7 | use LWP::UserAgent; | |
8 | use URI::Escape; | |
9 | use Net::SSLeay; | |
10 | use JSON; | |
11 | use Data::Dumper; # fixme: remove | |
12 | use HTTP::Request::Common; | |
13 | use Carp; | |
097484f4 | 14 | use PVE::APIClient::Exception qw(raise); |
9ae947dd DM |
15 | |
16 | my $extract_data = sub { | |
17 | my ($res) = @_; | |
18 | ||
19 | croak "undefined result" if !defined($res); | |
20 | croak "undefined result data" if !exists($res->{data}); | |
21 | ||
22 | return $res->{data}; | |
23 | }; | |
24 | ||
25 | sub get_raw { | |
26 | my ($self, $path, $param) = @_; | |
27 | ||
28 | return $self->call('GET', $path, $param); | |
29 | } | |
30 | ||
31 | sub get { | |
32 | my ($self, $path, $param) = @_; | |
33 | ||
34 | return $extract_data->($self->call('GET', $path, $param)); | |
35 | } | |
36 | ||
37 | sub post_raw { | |
38 | my ($self, $path, $param) = @_; | |
39 | ||
40 | return $self->call('POST', $path, $param); | |
41 | } | |
42 | ||
43 | sub post { | |
44 | my ($self, $path, $param) = @_; | |
45 | ||
46 | return $extract_data->($self->call('POST', $path, $param)); | |
47 | } | |
48 | ||
49 | sub put_raw { | |
50 | my ($self, $path, $param) = @_; | |
51 | ||
52 | return $self->call('PUT', $path, $param); | |
53 | } | |
54 | ||
55 | sub put { | |
56 | my ($self, $path, $param) = @_; | |
57 | ||
58 | return $extract_data->($self->call('PUT', $path, $param)); | |
59 | } | |
60 | ||
61 | sub delete_raw { | |
62 | my ($self, $path, $param) = @_; | |
63 | ||
64 | return $self->call('DELETE', $path, $param); | |
65 | } | |
66 | ||
67 | sub delete { | |
68 | my ($self, $path, $param) = @_; | |
69 | ||
70 | return $extract_data->($self->call('DELETE', $path, $param)); | |
71 | } | |
72 | ||
73 | sub update_csrftoken { | |
74 | my ($self, $csrftoken) = @_; | |
75 | ||
76 | $self->{csrftoken} = $csrftoken; | |
77 | ||
78 | my $agent = $self->{useragent}; | |
79 | ||
80 | $agent->default_header('CSRFPreventionToken', $self->{csrftoken}); | |
81 | } | |
82 | ||
83 | sub update_ticket { | |
84 | my ($self, $ticket) = @_; | |
85 | ||
86 | my $agent = $self->{useragent}; | |
87 | ||
88 | $self->{ticket} = $ticket; | |
89 | ||
90 | my $encticket = uri_escape($ticket); | |
444d6419 | 91 | my $cookie = "$self->{cookie_name}=$encticket; path=/; secure;"; |
9ae947dd DM |
92 | $agent->default_header('Cookie', $cookie); |
93 | } | |
94 | ||
f1956672 OB |
95 | sub two_factor_auth_login { |
96 | my ($self, $type, $challenge) = @_; | |
97 | ||
98 | if ($type eq 'PVE:tfa') { | |
99 | raise("TFA-enabled login currently works only with a TTY.") if !-t STDIN; | |
100 | print "\nEnter OTP code for user $self->{username}: "; | |
101 | my $tfa_response = <STDIN>; | |
102 | chomp $tfa_response; | |
103 | return $self->post('/api2/json/access/tfa', {response => $tfa_response}); | |
104 | } elsif ($type eq 'PVE:u2f') { | |
105 | # TODO: implement u2f-enabled join | |
106 | raise("U2F-enabled login is currently not implemented."); | |
107 | } else { | |
108 | raise("Authentication type '$type' not recognized, aborting!"); | |
109 | } | |
110 | } | |
111 | ||
9ae947dd DM |
112 | sub login { |
113 | my ($self) = @_; | |
114 | ||
115 | my $uri = URI->new(); | |
116 | $uri->scheme($self->{protocol}); | |
117 | $uri->host($self->{host}); | |
118 | $uri->port($self->{port}); | |
119 | $uri->path('/api2/json/access/ticket'); | |
120 | ||
121 | my $ua = $self->{useragent}; | |
8bc98506 | 122 | my $username = $self->{username} // 'unknown', |
9ae947dd DM |
123 | |
124 | delete $self->{last_unknown_fingerprint}; | |
125 | ||
126 | my $exec_login = sub { | |
127 | return $ua->post($uri, { | |
8bc98506 | 128 | username => $username, |
9ae947dd DM |
129 | password => $self->{password} || ''}); |
130 | }; | |
131 | ||
132 | my $response = $exec_login->(); | |
133 | ||
134 | if (!$response->is_success) { | |
135 | if (my $fp = delete($self->{last_unknown_fingerprint})) { | |
136 | if ($self->manual_verify_fingerprint($fp)) { | |
137 | $response = $exec_login->(); # try again | |
138 | } | |
139 | } | |
140 | } | |
141 | ||
142 | if (!$response->is_success) { | |
097484f4 | 143 | raise($response->status_line ."\n", code => $response->code) |
9ae947dd DM |
144 | } |
145 | ||
146 | my $res = from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1}); | |
147 | ||
148 | my $data = $extract_data->($res); | |
9ae947dd DM |
149 | $self->update_ticket($data->{ticket}); |
150 | $self->update_csrftoken($data->{CSRFPreventionToken}); | |
151 | ||
f1956672 OB |
152 | # handle two-factor login |
153 | my $tfa_ticket_re = qr/^([^\s!]+)![^!]*(!([0-9a-zA-Z\/.=_\-+]+))?$/; | |
154 | if ($data->{ticket} =~ m/$tfa_ticket_re/) { | |
155 | my ($type, $challenge) = ($1, $2); | |
156 | $data = $self->two_factor_auth_login($type, $challenge); | |
157 | $self->update_ticket($data->{ticket}); | |
158 | } | |
159 | ||
9ae947dd DM |
160 | return $data; |
161 | } | |
162 | ||
163 | sub manual_verify_fingerprint { | |
164 | my ($self, $fingerprint) = @_; | |
165 | ||
166 | if (!$self->{manual_verification}) { | |
8153e671 | 167 | raise("fingerprint '$fingerprint' not verified, abort!\n"); |
9ae947dd DM |
168 | } |
169 | ||
170 | print "The authenticity of host '$self->{host}' can't be established.\n" . | |
171 | "X509 SHA256 key fingerprint is $fingerprint.\n" . | |
172 | "Are you sure you want to continue connecting (yes/no)? "; | |
173 | ||
ff8ba9c9 | 174 | my $answer = <STDIN>; |
9ae947dd DM |
175 | |
176 | my $valid = ($answer =~ m/^\s*yes\s*$/i) ? 1 : 0; | |
177 | ||
178 | $self->{cached_fingerprints}->{$fingerprint} = $valid; | |
179 | ||
8153e671 TL |
180 | raise("Fingerprint not verified, abort!\n") if !$valid; |
181 | ||
9ae947dd DM |
182 | if (my $cb = $self->{register_fingerprint_cb}) { |
183 | $cb->($fingerprint) if $valid; | |
184 | } | |
185 | ||
186 | return $valid; | |
187 | } | |
188 | ||
189 | sub call { | |
190 | my ($self, $method, $path, $param) = @_; | |
191 | ||
192 | delete $self->{last_unknown_fingerprint}; | |
193 | ||
194 | my $ticket = $self->{ticket}; | |
7b6f8f1d | 195 | my $apitoken = $self->{apitoken}; |
9ae947dd DM |
196 | |
197 | my $ua = $self->{useragent}; | |
198 | ||
199 | # fixme: check ticket lifetime? | |
200 | ||
7b6f8f1d | 201 | if (!$ticket && !$apitoken && $self->{username} && $self->{password}) { |
9ae947dd DM |
202 | $self->login(); |
203 | } | |
204 | ||
205 | my $uri = URI->new(); | |
206 | $uri->scheme($self->{protocol}); | |
207 | $uri->host($self->{host}); | |
208 | $uri->port($self->{port}); | |
209 | ||
210 | $path =~ s!^/+!!; | |
211 | ||
212 | if ($path !~ m!^api2/!) { | |
213 | $uri->path("api2/json/$path"); | |
214 | } else { | |
215 | $uri->path($path); | |
216 | } | |
217 | ||
218 | #print "CALL $method : " . $uri->as_string() . "\n"; | |
219 | ||
220 | my $exec_method = sub { | |
221 | ||
222 | my $response; | |
223 | if ($method eq 'GET') { | |
224 | $uri->query_form($param); | |
225 | $response = $ua->request(HTTP::Request::Common::GET($uri)); | |
226 | } elsif ($method eq 'POST') { | |
227 | $response = $ua->request(HTTP::Request::Common::POST($uri, Content => $param)); | |
228 | } elsif ($method eq 'PUT') { | |
229 | # We use another temporary URI object to format | |
230 | # the application/x-www-form-urlencoded content. | |
231 | ||
232 | my $tmpurl = URI->new('http:'); | |
233 | $tmpurl->query_form(%$param); | |
234 | my $content = $tmpurl->query; | |
235 | ||
236 | $response = $ua->request(HTTP::Request::Common::PUT($uri, 'Content-Type' => 'application/x-www-form-urlencoded', Content => $content)); | |
237 | ||
238 | } elsif ($method eq 'DELETE') { | |
239 | $response = $ua->request(HTTP::Request::Common::DELETE($uri)); | |
240 | } else { | |
097484f4 | 241 | raise("method $method not implemented\n"); |
9ae947dd DM |
242 | } |
243 | return $response; | |
244 | }; | |
245 | ||
246 | my $response = $exec_method->(); | |
247 | ||
248 | if (my $fp = delete($self->{last_unknown_fingerprint})) { | |
249 | if ($self->manual_verify_fingerprint($fp)) { | |
250 | $response = $exec_method->(); # try again | |
251 | } | |
252 | } | |
253 | ||
254 | #print "RESP: " . Dumper($response) . "\n"; | |
255 | ||
256 | my $ct = $response->header('Content-Type') || ''; | |
257 | ||
258 | if ($response->is_success) { | |
259 | ||
097484f4 TL |
260 | raise("got unexpected content type", code => $response->code) |
261 | if $ct !~ m|application/json|; | |
9ae947dd DM |
262 | |
263 | return from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1}); | |
264 | ||
265 | } else { | |
266 | ||
097484f4 TL |
267 | my $msg = $response->message; |
268 | my $errors = eval { | |
9ae947dd DM |
269 | return if $ct !~ m|application/json|; |
270 | my $res = from_json($response->decoded_content, {utf8 => 1, allow_nonref => 1}); | |
097484f4 | 271 | return $res->{errors}; |
9ae947dd | 272 | }; |
9ae947dd | 273 | |
097484f4 | 274 | raise("$msg\n", code => $response->code, errors => $errors); |
9ae947dd DM |
275 | } |
276 | } | |
277 | ||
278 | my $verify_cert_callback = sub { | |
279 | my ($self, $cert) = @_; | |
280 | ||
281 | # check server certificate against cache of pinned FPs | |
282 | # get fingerprint of server certificate | |
1d40f3c3 | 283 | my $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256'); |
38eb3479 | 284 | return 0 if !defined($fp) || $fp eq ''; # error |
9ae947dd DM |
285 | |
286 | my $valid = $self->{cached_fingerprints}->{$fp}; | |
287 | return $valid if defined($valid); # return cached result | |
288 | ||
289 | if (my $cb = $self->{verify_fingerprint_cb}) { | |
290 | $valid = $cb->($cert); | |
291 | $self->{cached_fingerprints}->{$fp} = $valid; | |
292 | return $valid; | |
293 | } | |
294 | ||
295 | $self->{last_unknown_fingerprint} = $fp; | |
296 | ||
297 | return 0; | |
298 | }; | |
299 | ||
300 | sub new { | |
301 | my ($class, %param) = @_; | |
302 | ||
303 | my $ssl_default_opts = { verify_hostname => 0 }; | |
304 | my $ssl_opts = $param{ssl_opts} || $ssl_default_opts; | |
305 | ||
306 | my $self = { | |
307 | username => $param{username}, | |
308 | password => $param{password}, | |
309 | host => $param{host} || 'localhost', | |
310 | port => $param{port}, | |
311 | protocol => $param{protocol}, | |
444d6419 | 312 | cookie_name => $param{cookie_name} // 'PVEAuthCookie', |
9ae947dd DM |
313 | manual_verification => $param{manual_verification}, |
314 | cached_fingerprints => $param{cached_fingerprints} || {}, | |
315 | verify_fingerprint_cb => $param{verify_fingerprint_cb}, | |
316 | register_fingerprint_cb => $param{register_fingerprint_cb}, | |
317 | ssl_opts => $ssl_opts, | |
318 | timeout => $param{timeout} || 60, | |
319 | }; | |
320 | bless $self; | |
321 | ||
322 | if (!$ssl_opts->{SSL_verify_callback}) { | |
323 | $ssl_opts->{'SSL_verify_mode'} = SSL_VERIFY_PEER; | |
324 | $ssl_opts->{'SSL_verify_callback'} = sub { | |
325 | my (undef, undef, undef, undef, $cert, $depth) = @_; | |
326 | ||
327 | # we don't care about intermediate or root certificates | |
328 | return 1 if $depth != 0; | |
329 | ||
330 | return $verify_cert_callback->($self, $cert); | |
331 | } | |
332 | } | |
333 | ||
334 | if (!$self->{port}) { | |
335 | $self->{port} = $self->{host} eq 'localhost' ? 85 : 8006; | |
336 | } | |
337 | if (!$self->{protocol}) { | |
338 | $self->{protocol} = $self->{host} eq 'localhost' ? 'http' : 'https'; | |
339 | } | |
340 | ||
341 | $self->{useragent} = LWP::UserAgent->new( | |
342 | protocols_allowed => [ 'http', 'https'], | |
343 | ssl_opts => $ssl_opts, | |
344 | timeout => $self->{timeout}, | |
345 | keep_alive => $param{keep_alive} // 50, | |
346 | ); | |
347 | ||
348 | $self->{useragent}->default_header('Accept-Encoding' => 'gzip'); # allow gzip | |
349 | ||
7b6f8f1d FG |
350 | if ($param{apitoken} && $param{password}) { |
351 | warn "password will be ignored in favor of API token\n"; | |
352 | delete $self->{password}; | |
353 | } | |
354 | if ($param{ticket}) { | |
355 | if ($param{apitoken}) { | |
356 | warn "ticket will be ignored in favor of API token\n"; | |
357 | } else { | |
358 | $self->update_ticket($param{ticket}); | |
359 | } | |
360 | } | |
9ae947dd DM |
361 | $self->update_csrftoken($param{csrftoken}) if $param{csrftoken}; |
362 | ||
7b6f8f1d FG |
363 | if ($param{apitoken}) { |
364 | my $agent = $self->{useragent}; | |
365 | ||
366 | $self->{apitoken} = $param{apitoken}; | |
367 | ||
368 | $agent->default_header('Authorization', $param{apitoken}); | |
369 | } | |
9ae947dd DM |
370 | |
371 | return $self; | |
372 | } | |
373 | ||
374 | 1; |