]>
Commit | Line | Data |
---|---|---|
aff192e6 DM |
1 | package PVE::REST; |
2 | ||
3 | use warnings; | |
4 | use strict; | |
aff192e6 DM |
5 | use PVE::Cluster; |
6 | use PVE::SafeSyslog; | |
7 | use PVE::Tools; | |
8 | use PVE::API2; | |
aff192e6 | 9 | use JSON; |
aff192e6 DM |
10 | use LWP::UserAgent; |
11 | use HTTP::Request::Common; | |
12 | use HTTP::Status qw(:constants :is status_message); | |
13 | use HTML::Entities; | |
e4d554ba | 14 | use PVE::Exception qw(raise raise_perm_exc); |
aff192e6 DM |
15 | use PVE::JSONSchema; |
16 | use PVE::AccessControl; | |
17 | use PVE::RPCEnvironment; | |
e771ec50 | 18 | use URI::Escape; |
aff192e6 DM |
19 | |
20 | use Data::Dumper; # fixme: remove | |
21 | ||
6c980a67 DM |
22 | # my $MaxRequestsPerChild = 200; |
23 | ||
aff192e6 DM |
24 | my $cookie_name = 'PVEAuthCookie'; |
25 | ||
26 | my $baseuri = "/api2"; | |
27 | ||
282184f5 DM |
28 | my $debug_enabled; |
29 | sub enable_debug { | |
30 | $debug_enabled = 1; | |
31 | } | |
32 | ||
33 | sub debug_msg { | |
34 | return if !$debug_enabled; | |
35 | syslog('info', @_); | |
36 | } | |
37 | ||
aff192e6 DM |
38 | sub extract_auth_cookie { |
39 | my ($cookie) = @_; | |
40 | ||
41 | return undef if !$cookie; | |
42 | ||
e771ec50 DM |
43 | my $ticket = ($cookie =~ /(?:^|\s)$cookie_name=([^;]*)/)[0]; |
44 | ||
45 | if ($ticket && $ticket =~ m/^PVE%3A/) { | |
46 | $ticket = uri_unescape($ticket); | |
47 | } | |
48 | ||
49 | return $ticket; | |
aff192e6 DM |
50 | } |
51 | ||
52 | sub create_auth_cookie { | |
53 | my ($ticket) = @_; | |
54 | ||
55 | return "${cookie_name}=$ticket; path=/; secure;"; | |
56 | } | |
57 | ||
58 | sub format_response_data { | |
59 | my($format, $res, $uri) = @_; | |
60 | ||
61 | my $data = $res->{data}; | |
62 | my $info = $res->{info}; | |
63 | ||
64 | my ($ct, $raw); | |
65 | ||
66 | if ($format eq 'json') { | |
d25e7431 | 67 | $ct = 'application/json;charset=UTF-8'; |
aff192e6 DM |
68 | $raw = to_json($data, {utf8 => 1, allow_nonref => 1}); |
69 | } elsif ($format eq 'html') { | |
d25e7431 | 70 | $ct = 'text/html;charset=UTF-8'; |
aff192e6 DM |
71 | $raw = "<html><body>"; |
72 | if (!is_success($res->{status})) { | |
73 | my $msg = $res->{message} || ''; | |
74 | $raw .= "<h1>ERROR $res->{status} $msg</h1>"; | |
75 | } | |
76 | my $lnk = PVE::JSONSchema::method_get_child_link($info); | |
77 | if ($lnk && $data && $data->{data} && is_success($res->{status})) { | |
78 | ||
79 | my $href = $lnk->{href}; | |
80 | if ($href =~ m/^\{(\S+)\}$/) { | |
81 | my $prop = $1; | |
82 | $uri =~ s/\/+$//; # remove trailing slash | |
83 | foreach my $elem (sort {$a->{$prop} cmp $b->{$prop}} @{$data->{data}}) { | |
84 | next if !ref($elem); | |
85 | ||
86 | if (defined(my $value = $elem->{$prop})) { | |
87 | if ($value ne '') { | |
88 | if (scalar(keys %$elem) > 1) { | |
89 | my $tv = to_json($elem, {allow_nonref => 1, canonical => 1}); | |
90 | $raw .= "<a href='$uri/$value'>$value</a> <pre>$tv</pre><br>"; | |
91 | } else { | |
92 | $raw .= "<a href='$uri/$value'>$value</a><br>"; | |
93 | } | |
94 | } | |
95 | } | |
96 | } | |
97 | } | |
98 | } else { | |
99 | $raw .= "<pre>"; | |
d25e7431 | 100 | $raw .= encode_entities(to_json($data, {allow_nonref => 1, pretty => 1})); |
aff192e6 DM |
101 | $raw .= "</pre>"; |
102 | } | |
103 | $raw .= "</body></html>"; | |
104 | ||
105 | } elsif ($format eq 'png') { | |
106 | $ct = 'image/png'; | |
107 | ||
108 | # fixme: better to revove that whole png thing ? | |
109 | ||
110 | my $filename; | |
111 | $raw = ''; | |
112 | ||
113 | if ($data && ref($data) && ref($data->{data}) && | |
0ba26fb5 | 114 | $data->{data}->{filename} && defined($data->{data}->{image})) { |
aff192e6 | 115 | $filename = $data->{data}->{filename}; |
0ba26fb5 | 116 | $raw = $data->{data}->{image}; |
aff192e6 DM |
117 | } |
118 | ||
119 | } elsif ($format eq 'extjs') { | |
d25e7431 | 120 | $ct = 'application/json;charset=UTF-8'; |
aff192e6 DM |
121 | $raw = to_json($data, {utf8 => 1, allow_nonref => 1}); |
122 | } elsif ($format eq 'htmljs') { | |
123 | # we use this for extjs file upload forms | |
d25e7431 DM |
124 | $ct = 'text/html;charset=UTF-8'; |
125 | $raw = encode_entities(to_json($data, {allow_nonref => 1})); | |
aff192e6 | 126 | } else { |
d25e7431 | 127 | $ct = 'text/plain;charset=UTF-8'; |
aff192e6 DM |
128 | $raw = to_json($data, {utf8 => 1, allow_nonref => 1, pretty => 1}); |
129 | } | |
130 | ||
131 | return wantarray ? ($raw, $ct) : $raw; | |
132 | } | |
133 | ||
134 | sub prepare_response_data { | |
135 | my ($format, $res) = @_; | |
136 | ||
137 | my $success = 1; | |
138 | my $new = { | |
139 | data => $res->{data}, | |
140 | }; | |
141 | if (scalar(keys %{$res->{errors}})) { | |
142 | $success = 0; | |
143 | $new->{errors} = $res->{errors}; | |
144 | } | |
145 | ||
146 | if ($format eq 'extjs' || $format eq 'htmljs') { | |
147 | # HACK: extjs wants 'success' property instead of useful HTTP status codes | |
148 | if (is_error($res->{status})) { | |
149 | $success = 0; | |
150 | $new->{message} = $res->{message} || status_message($res->{status}); | |
0c308a03 | 151 | $new->{status} = $res->{status} || 200; |
aff192e6 | 152 | $res->{message} = undef; |
0c308a03 | 153 | $res->{status} = 200; |
aff192e6 DM |
154 | } |
155 | $new->{success} = $success; | |
156 | } | |
157 | ||
158 | if ($success && $res->{total}) { | |
159 | $new->{total} = $res->{total}; | |
160 | } | |
161 | ||
3dff4a9f DM |
162 | if ($success && $res->{changes}) { |
163 | $new->{changes} = $res->{changes}; | |
164 | } | |
165 | ||
aff192e6 DM |
166 | $res->{data} = $new; |
167 | } | |
168 | ||
169 | sub create_http_request { | |
170 | my ($uri, $method, $params) = @_; | |
171 | ||
172 | # NOTE: HTTP::Request::Common::PUT is crap - so we use our own code | |
173 | # borrowed from HTTP::Request::Common::POST | |
174 | ||
175 | if ($method eq 'POST' || $method eq 'PUT') { | |
176 | ||
177 | my $req = HTTP::Request->new($method => $uri); | |
178 | $req->header('Content-Type' => 'application/x-www-form-urlencoded'); | |
179 | ||
180 | # We use a temporary URI object to format | |
181 | # the application/x-www-form-urlencoded content. | |
182 | my $url = URI->new('http:'); | |
183 | $url->query_form(%$params); | |
184 | my $content = $url->query; | |
185 | if (defined($content)) { | |
186 | $req->header('Content-Length' => length($content)); | |
187 | $req->content($content); | |
188 | } else { | |
189 | $req->header('Content-Length' => 0); | |
190 | } | |
191 | ||
192 | return $req; | |
193 | } | |
194 | ||
195 | die "unknown method '$method'"; | |
196 | } | |
197 | ||
198 | sub proxy_handler { | |
199 | my($r, $clientip, $host, $method, $abs_uri, $ticket, $token, $params) = @_; | |
200 | ||
282184f5 | 201 | debug_msg("proxy start $method $host:$abs_uri"); |
aff192e6 DM |
202 | |
203 | my $ua = LWP::UserAgent->new( | |
c4113f6c | 204 | # keep it simple - we are on internal network, and use tickets |
15d29886 | 205 | ssl_opts => { verify_hostname => 0 }, |
c4113f6c DM |
206 | # using the pve root CA file would be another option |
207 | # ssl_opts => { verify_hostname => 1 , SSL_ca_file => "/etc/pve/pve-root-ca.pem }, | |
aff192e6 DM |
208 | protocols_allowed => [ 'http', 'https' ], |
209 | timeout => 30, | |
210 | ); | |
211 | ||
212 | $ua->default_header('cookie' => "${cookie_name}=$ticket") if $ticket; | |
213 | $ua->default_header('CSRFPreventionToken' => $token) if $token; | |
214 | $ua->default_header('PVEDisableProxy' => 'true'); | |
215 | $ua->default_header('PVEClientIP' => $clientip); | |
216 | ||
217 | my $uri = URI->new(); | |
218 | ||
219 | if ($host eq 'localhost') { | |
220 | $uri->scheme('http'); | |
221 | $uri->host('localhost'); | |
222 | $uri->port(85); | |
223 | } else { | |
224 | $uri->scheme('https'); | |
225 | $uri->host($host); | |
226 | $uri->port(8006); | |
227 | } | |
228 | ||
229 | $uri->path($abs_uri); | |
230 | ||
231 | my $response; | |
232 | if ($method eq 'GET') { | |
233 | $uri->query_form($params); | |
234 | $response = $ua->request(HTTP::Request::Common::GET($uri)); | |
235 | } elsif ($method eq 'POST' || $method eq 'PUT') { | |
236 | $response = $ua->request(create_http_request($uri, $method, $params)); | |
237 | } elsif ($method eq 'DELETE') { | |
238 | $response = $ua->request(HTTP::Request::Common::DELETE($uri)); | |
239 | } else { | |
240 | my $code = HTTP_NOT_IMPLEMENTED; | |
241 | $r->status_line("$code proxy method '$method' not implemented"); | |
242 | return $code; | |
243 | } | |
244 | ||
aff192e6 DM |
245 | if (my $cookie = $response->header("Set-Cookie")) { |
246 | $r->err_headers_out()->add("Set-Cookie" => $cookie); | |
247 | } | |
248 | ||
249 | my $ct = $response->header('Content-Type'); | |
250 | ||
251 | my $code = $response->code; | |
252 | $r->status($code); | |
253 | ||
254 | if (my $message = $response->message) { | |
255 | $r->status_line("$code $message"); | |
256 | } | |
257 | ||
258 | $r->content_type($ct) if $ct; | |
259 | my $raw = $response->decoded_content; | |
260 | ||
261 | # note: do not use err_headers_out(), because mod_deflate has a bug, | |
262 | # resulting in dup length (for exampe 'content-length: 89, 75') | |
263 | $r->headers_out()->add('Content-Length' , length($raw)); | |
264 | $r->print($raw); | |
265 | ||
282184f5 | 266 | debug_msg("proxy end $method $host:$abs_uri ($code)"); |
aff192e6 | 267 | |
0c308a03 | 268 | return HTTP_OK; |
aff192e6 DM |
269 | } |
270 | ||
f797e949 DM |
271 | my $exc_to_res = sub { |
272 | my ($err, $status) = @_; | |
273 | ||
274 | $status = $status || HTTP_INTERNAL_SERVER_ERROR; | |
275 | ||
276 | my $resp = {}; | |
277 | if (ref($err) eq "PVE::Exception") { | |
278 | $resp->{status} = $err->{code} || $status; | |
279 | $resp->{errors} = $err->{errors} if $err->{errors}; | |
280 | $resp->{message} = $err->{msg}; | |
281 | } else { | |
282 | $resp->{status} = $status; | |
283 | $resp->{message} = $err; | |
284 | } | |
285 | ||
286 | return $resp; | |
287 | }; | |
288 | ||
aff192e6 | 289 | sub rest_handler { |
97ab5760 | 290 | my ($rpcenv, $clientip, $method, $abs_uri, $rel_uri, $ticket, $token) = @_; |
aff192e6 | 291 | |
97ab5760 DM |
292 | # set environment variables |
293 | $rpcenv->set_language('C'); # fixme: | |
294 | $rpcenv->set_client_ip($clientip); | |
aff192e6 | 295 | |
aff192e6 DM |
296 | my $euid = $>; |
297 | ||
298 | my $require_auth = 1; | |
299 | ||
300 | # explicitly allow some calls without auth | |
301 | if (($rel_uri eq '/access/domains' && $method eq 'GET') || | |
302 | ($rel_uri eq '/access/ticket' && $method eq 'POST')) { | |
303 | $require_auth = 0; | |
304 | } | |
305 | ||
306 | my ($username, $age); | |
307 | ||
97ab5760 DM |
308 | my $isUpload = 0; |
309 | ||
aff192e6 DM |
310 | if ($require_auth) { |
311 | ||
312 | eval { | |
313 | die "No ticket\n" if !$ticket; | |
314 | ||
315 | ($username, $age) = PVE::AccessControl::verify_ticket($ticket); | |
316 | ||
97ab5760 DM |
317 | $rpcenv->set_user($username); |
318 | ||
319 | if ($method eq 'POST' && $rel_uri =~ m|^/nodes/([^/]+)/storage/([^/]+)/upload$|) { | |
320 | my ($node, $storeid) = ($1, $2); | |
e084c774 DM |
321 | # we disable CSRF checks if $isUpload is set, |
322 | # to improve security we check user upload permission here | |
323 | my $perm = { check => ['perm', "/storage/$storeid", ['Datastore.AllocateTemplate']] }; | |
40bbaac1 | 324 | $rpcenv->check_api2_permissions($perm, $username, {}); |
97ab5760 DM |
325 | $isUpload = 1; |
326 | } | |
327 | ||
328 | # we skip CSRF check for file upload, because it is | |
329 | # difficult to pass CSRF HTTP headers with native html forms, | |
330 | # and it should not be necessary at all. | |
aff192e6 | 331 | PVE::AccessControl::verify_csrf_prevention_token($username, $token) |
97ab5760 | 332 | if !$isUpload && ($euid != 0) && ($method ne 'GET'); |
aff192e6 DM |
333 | }; |
334 | if (my $err = $@) { | |
f797e949 | 335 | return &$exc_to_res($err, HTTP_UNAUTHORIZED); |
aff192e6 DM |
336 | } |
337 | } | |
97ab5760 DM |
338 | |
339 | # we are authenticated now | |
340 | ||
aff192e6 DM |
341 | my $uri_param = {}; |
342 | my ($handler, $info) = PVE::API2->find_handler($method, $rel_uri, $uri_param); | |
343 | if (!$handler || !$info) { | |
344 | return { | |
345 | status => HTTP_NOT_IMPLEMENTED, | |
346 | message => "Method '$method $abs_uri' not implemented", | |
347 | }; | |
348 | } | |
349 | ||
97ab5760 DM |
350 | # Note: we need to delay CGI parameter parsing until |
351 | # we are authenticated (avoid DOS (file upload) attacs) | |
352 | ||
353 | my $params; | |
354 | eval { $params = $rpcenv->parse_params($isUpload); }; | |
355 | if (my $err = $@) { | |
356 | return { | |
357 | status => HTTP_BAD_REQUEST, | |
358 | message => "parameter parser failed: $err", | |
359 | }; | |
360 | } | |
361 | ||
aff192e6 DM |
362 | delete $params->{_dc}; # remove disable cache parameter |
363 | ||
364 | foreach my $p (keys %{$params}) { | |
365 | if (defined($uri_param->{$p})) { | |
366 | return { | |
367 | status => HTTP_BAD_REQUEST, | |
368 | message => "Parameter verification failed - duplicate parameter '$p'", | |
369 | }; | |
370 | } | |
371 | $uri_param->{$p} = $params->{$p}; | |
372 | } | |
373 | ||
374 | # check access permissions | |
40bbaac1 | 375 | eval { $rpcenv->check_api2_permissions($info->{permissions}, $username, $uri_param); }; |
aff192e6 | 376 | if (my $err = $@) { |
f797e949 | 377 | return &$exc_to_res($err, HTTP_FORBIDDEN); |
aff192e6 DM |
378 | } |
379 | ||
380 | if ($info->{proxyto}) { | |
381 | my $remip; | |
382 | eval { | |
383 | my $pn = $info->{proxyto}; | |
384 | my $node = $uri_param->{$pn}; | |
385 | die "proxy parameter '$pn' does not exists" if !$node; | |
386 | ||
97ab5760 DM |
387 | if ($node ne 'localhost' && $node ne PVE::INotify::nodename()) { |
388 | die "unable to proxy file uploads" if $isUpload; | |
aff192e6 DM |
389 | $remip = PVE::Cluster::remote_node_ip($node); |
390 | } | |
391 | }; | |
392 | if (my $err = $@) { | |
f797e949 | 393 | return &$exc_to_res($err); |
aff192e6 DM |
394 | } |
395 | if ($remip) { | |
97ab5760 | 396 | return { proxy => $remip, proxy_params => $params }; |
aff192e6 DM |
397 | } |
398 | } | |
399 | ||
97ab5760 DM |
400 | if ($info->{protected} && ($euid != 0)) { |
401 | if ($isUpload) { | |
402 | my $uinfo = $rpcenv->get_upload_info('filename'); | |
403 | $params->{tmpfilename} = $uinfo->{tmpfilename}; | |
404 | } | |
405 | return { proxy => 'localhost' , proxy_params => $params } | |
406 | } | |
aff192e6 DM |
407 | |
408 | my $resp = { | |
409 | info => $info, # useful to format output | |
410 | status => HTTP_OK, | |
411 | }; | |
412 | ||
413 | eval { | |
414 | $resp->{data} = $handler->handle($info, $uri_param); | |
415 | ||
e09058af | 416 | if (my $count = $rpcenv->get_result_attrib('total')) { |
aff192e6 DM |
417 | $resp->{total} = $count; |
418 | } | |
e09058af | 419 | if (my $diff = $rpcenv->get_result_attrib('changes')) { |
3dff4a9f DM |
420 | $resp->{changes} = $diff; |
421 | } | |
aff192e6 | 422 | }; |
f797e949 DM |
423 | if (my $err = $@) { |
424 | return &$exc_to_res($err); | |
aff192e6 DM |
425 | } |
426 | ||
aff192e6 DM |
427 | return $resp; |
428 | } | |
429 | ||
430 | sub split_abs_uri { | |
431 | my ($abs_uri) = @_; | |
432 | ||
d25e7431 | 433 | my ($format, $rel_uri) = $abs_uri =~ m/^\Q$baseuri\E\/+(html|text|json|extjs|png|htmljs)(\/.*)?$/; |
aff192e6 DM |
434 | $rel_uri = '/' if !$rel_uri; |
435 | ||
436 | return wantarray ? ($rel_uri, $format) : $rel_uri; | |
437 | } | |
438 | ||
0c308a03 DM |
439 | 1; |
440 | ||
441 | __END__ | |
442 | ||
aff192e6 DM |
443 | my $known_methods = { |
444 | GET => 1, | |
445 | POST => 1, | |
446 | PUT => 1, | |
447 | DELETE => 1, | |
448 | }; | |
449 | ||
6c980a67 DM |
450 | my $request_count = 0; |
451 | ||
aff192e6 DM |
452 | sub handler { |
453 | my($r) = @_; | |
454 | ||
0c308a03 DM |
455 | die "we do not use this any longer"; |
456 | ||
282184f5 | 457 | debug_msg("perl handler called"); |
aff192e6 | 458 | |
6c980a67 DM |
459 | $request_count++; |
460 | # we do not use KeepAlive, so this is not necessary | |
461 | # $r->child_terminate() if $request_count >= $MaxRequestsPerChild; | |
462 | ||
aff192e6 DM |
463 | my $method = $r->method; |
464 | my $clientip = $r->connection->remote_ip(); | |
465 | ||
466 | return HTTP_NOT_IMPLEMENTED | |
467 | if !$known_methods->{$method}; | |
468 | ||
aff192e6 DM |
469 | my $cookie = $r->headers_in->{Cookie}; |
470 | my $token = $r->headers_in->{CSRFPreventionToken}; | |
471 | ||
472 | my $ticket = extract_auth_cookie($cookie); | |
473 | ||
474 | $r->no_cache (1); | |
475 | ||
476 | my $abs_uri = $r->uri; | |
477 | my ($rel_uri, $format) = split_abs_uri($abs_uri); | |
478 | return HTTP_NOT_IMPLEMENTED if !$format; | |
479 | ||
97ab5760 DM |
480 | my $rpcenv; |
481 | my $res; | |
482 | ||
483 | eval { | |
484 | $rpcenv = PVE::RPCEnvironment::get(); | |
485 | $rpcenv->init_request(request_rec => $r); | |
486 | }; | |
487 | if (my $err = $@) { | |
488 | syslog('err', $err); | |
489 | $res = { status => HTTP_INTERNAL_SERVER_ERROR, message => $err }; | |
490 | } else { | |
491 | $res = rest_handler($rpcenv, $clientip, $method, $abs_uri, $rel_uri, | |
492 | $ticket, $token); | |
bd733366 | 493 | $rpcenv->set_user(undef); # clear after request |
97ab5760 | 494 | } |
aff192e6 DM |
495 | |
496 | if ($res->{proxy}) { | |
497 | if (($res->{proxy} ne 'localhost') && $r->headers_in->{'PVEDisableProxy'}) { | |
498 | my $code = FORBIDDEN; | |
499 | $r->status($code); | |
500 | $r->status_line("$code proxy loop detected - aborted "); | |
501 | return $res->{status}; | |
502 | } | |
503 | return proxy_handler($r, $clientip, $res->{proxy}, $method, | |
97ab5760 | 504 | $abs_uri, $ticket, $token, $res->{proxy_params}); |
aff192e6 DM |
505 | } |
506 | ||
507 | prepare_response_data($format, $res); | |
508 | ||
aff192e6 DM |
509 | $r->status($res->{status} || HTTP_OK); |
510 | ||
511 | if ($res->{message}) { | |
512 | my ($firstline) = $res->{message} =~ m/\A(.*)$/m; | |
513 | $r->status_line("$res->{status} $firstline"); | |
514 | } | |
515 | ||
516 | my ($raw, $ct) = format_response_data($format, $res, $abs_uri); | |
517 | $r->content_type ($ct); | |
518 | ||
519 | # note: do not use err_headers_out(), because mod_deflate has a bug, | |
520 | # resulting in dup length (for exampe 'content-length: 89, 75') | |
521 | $r->headers_out()->add('Content-Length', length($raw)); | |
522 | $r->print($raw); | |
523 | ||
282184f5 | 524 | debug_msg("perl handler end $res->{status}"); |
aff192e6 DM |
525 | |
526 | return OK; | |
527 | } | |
528 |