]> git.proxmox.com Git - pve-http-server.git/blobdiff - PVE/APIServer/AnyEvent.pm
allow 'download' to be passed from API handler
[pve-http-server.git] / PVE / APIServer / AnyEvent.pm
index c94e7349a628a807725fe615afcb998ff1475098..60a2a1c5f64628acd0cb1ea6beadc07cc565de18 100644 (file)
@@ -44,8 +44,9 @@ use HTTP::Headers;
 use HTTP::Request;
 use HTTP::Response;
 use Data::Dumper;
+use JSON;
 
-my $limit_max_headers = 30;
+my $limit_max_headers = 64;
 my $limit_max_header_size = 8*1024;
 my $limit_max_post = 64*1024;
 
@@ -65,6 +66,16 @@ my $split_abs_uri = sub {
     return wantarray ? ($rel_uri, $format) : $rel_uri;
 };
 
+sub dprint {
+    my ($self, $message) = @_;
+
+    return if !$self->{debug};
+
+    my ($pkg, $pkgfile, $line, $sub) = caller(1);
+    $sub =~ s/^(?:.+::)+//;
+    print "worker[$$]: $pkg +$line: $sub: $message\n";
+}
+
 sub log_request {
     my ($self, $reqstate) = @_;
 
@@ -142,13 +153,15 @@ sub client_do_disconnect {
        return;
     }
 
-    print "close connection $hdl\n" if $self->{debug};
+    $self->dprint("close connection $hdl");
 
     &$shutdown_hdl($hdl);
 
+    warn "connection count <= 0!\n" if $self->{conn_count} <= 0;
+
     $self->{conn_count}--;
 
-    print "$$: CLOSE FH" .  $hdl->{fh}->fileno() . " CONN$self->{conn_count}\n" if $self->{debug};
+    $self->dprint("CLOSE FH" .  $hdl->{fh}->fileno() . " CONN$self->{conn_count}");
 }
 
 sub finish_response {
@@ -293,7 +306,7 @@ my $file_extension_info = {
 };
 
 sub send_file_start {
-    my ($self, $reqstate, $filename) = @_;
+    my ($self, $reqstate, $download) = @_;
 
     eval {
        # print "SEND FILE $filename\n";
@@ -302,8 +315,26 @@ sub send_file_start {
 
            my $r = $reqstate->{request};
 
-           my $fh = IO::File->new($filename, '<') ||
-               die "$!\n";
+           my $fh;
+           my $nocomp;
+           my $mime;
+
+           if (ref($download) eq 'HASH') {
+               $fh = $download->{fh};
+               $mime = $download->{'content-type'};
+           } else {
+               my $filename = $download;
+               $fh = IO::File->new($filename, '<') ||
+                   die "unable to open file '$filename' - $!\n";
+
+               my ($ext) = $filename =~ m/\.([^.]*)$/;
+               my $ext_info = $file_extension_info->{$ext};
+
+               die "unable to detect content type" if !$ext_info;
+               $mime = $ext_info->{ct};
+               $nocomp = $ext_info->{nocomp};
+           }
+
            my $stat = File::stat::stat($fh) ||
                die "$!\n";
 
@@ -322,14 +353,9 @@ sub send_file_start {
            my $len = sysread($fh, $data,  $stat->size);
            die "got short file\n" if !defined($len) || $len != $stat->size;
 
-           my ($ext) = $filename =~ m/\.([^.]*)$/;
-           my $ext_info = $file_extension_info->{$ext};
-
-           die "unable to detect content type" if !$ext_info;
-
-           my $header = HTTP::Headers->new(Content_Type => $ext_info->{ct});
+           my $header = HTTP::Headers->new(Content_Type => $mime);
            my $resp = HTTP::Response->new(200, "OK", $header, $data);
-           $self->response($reqstate, $resp, $mtime, $ext_info->{nocomp});
+           $self->response($reqstate, $resp, $mtime, $nocomp);
        };
        if (my $err = $@) {
            $self->error($reqstate, 501, $err);
@@ -367,11 +393,38 @@ sub websocket_proxy {
            die "websocket_proxy: missing port or socket\n";
        }
 
+       my $encode = sub {
+           my ($data, $opcode) = @_;
+
+           my $string;
+           my $payload;
+           if ($binary) {
+               $string = $opcode ? $opcode : "\x82"; # binary frame
+               $payload = $$data;
+           } else {
+               $string = $opcode ? $opcode : "\x81"; # text frame
+               $payload = encode_base64($$data, '');
+           }
+
+           my $payload_len = length($payload);
+           if ($payload_len <= 125) {
+               $string .= pack 'C', $payload_len;
+           } elsif ($payload_len <= 0xffff) {
+               $string .= pack 'C', 126;
+               $string .= pack 'n', $payload_len;
+           } else {
+               $string .= pack 'C', 127;
+               $string .= pack 'Q>', $payload_len;
+           }
+           $string .= $payload;
+           return $string;
+       };
+
        tcp_connect $remhost, $remport, sub {
            my ($fh) = @_
                or die "connect to '$remhost:$remport' failed: $!";
 
-           print "$$: CONNECTed to '$remhost:$remport'\n" if $self->{debug};
+           $self->dprint("CONNECTed to '$remhost:$remport'");
 
            $reqstate->{proxyhdl} = AnyEvent::Handle->new(
                fh => $fh,
@@ -401,28 +454,7 @@ sub websocket_proxy {
                my $len = length($hdl->{rbuf});
                my $data = substr($hdl->{rbuf}, 0, $len > $max_payload_size ? $max_payload_size : $len, '');
 
-               my $string;
-               my $payload;
-
-               if ($binary) {
-                   $string = "\x82"; # binary frame
-                   $payload = $data;
-               } else {
-                   $string = "\x81"; # text frame
-                   $payload = encode_base64($data, '');
-               }
-
-               my $payload_len = length($payload);
-               if ($payload_len <= 125) {
-                   $string .= pack 'C', $payload_len;
-               } elsif ($payload_len <= 0xffff) {
-                   $string .= pack 'C', 126;
-                   $string .= pack 'n', $payload_len;
-               } else {
-                   $string .= pack 'C', 127;
-                   $string .= pack 'Q>', $payload_len;
-               }
-               $string .= $payload;
+               my $string = $encode->(\$data);
 
                $reqstate->{hdl}->push_write($string) if $reqstate->{hdl};
            };
@@ -466,19 +498,19 @@ sub websocket_proxy {
 
                    my $data = substr($hdl->{rbuf}, 0, $offset + 4 + $payload_len, ''); # now consume data
 
-                   my @mask = (unpack('C', substr($data, $offset+0, 1)),
-                       unpack('C', substr($data, $offset+1, 1)),
-                       unpack('C', substr($data, $offset+2, 1)),
-                       unpack('C', substr($data, $offset+3, 1)));
-
+                   my $mask = substr($data, $offset, 4);
                    $offset += 4;
 
                    my $payload = substr($data, $offset, $payload_len);
 
-                   for (my $i = 0; $i < $payload_len; $i++) {
-                       my $d = unpack('C', substr($payload, $i, 1));
-                       my $n = $d ^ $mask[$i % 4];
-                       substr($payload, $i, 1, pack('C', $n));
+                   # NULL-mask might be used over TLS, skip to increase performance
+                   if ($mask ne pack('N', 0)) {
+                       # repeat 4 byte mask to payload length + up to 4 byte
+                       $mask = $mask x (int($payload_len / 4) + 1);
+                       # truncate mask to payload length
+                       substr($mask, $payload_len) = "";
+                       # (un-)apply mask
+                       $payload ^= $mask;
                    }
 
                    $payload = decode_base64($payload) if !$binary;
@@ -487,11 +519,16 @@ sub websocket_proxy {
                        $reqstate->{proxyhdl}->push_write($payload) if $reqstate->{proxyhdl};
                    } elsif ($opcode == 8) {
                        my $statuscode = unpack ("n", $payload);
-                       print "websocket received close. status code: '$statuscode'\n" if $self->{debug};
-                   if ($reqstate->{proxyhdl}) {
-                               $reqstate->{proxyhdl}->push_shutdown();
-                   }
+                       $self->dprint("websocket received close. status code: '$statuscode'");
+                       if ($reqstate->{proxyhdl}) {
+                           $reqstate->{proxyhdl}->push_shutdown();
+                       }
                        $hdl->push_shutdown();
+                   } elsif ($opcode == 9) {
+                       # ping received, schedule pong
+                       $reqstate->{hdl}->push_write($encode->(\$payload, "\x8A")) if $reqstate->{hdl};
+                   } elsif ($opcode == 0xA) {
+                       # pong received, continue
                    } else {
                        die "received unhandled websocket opcode $opcode\n";
                    }
@@ -513,7 +550,7 @@ sub websocket_proxy {
                "Sec-WebSocket-Protocol: $wsproto\015\012" .
                "\015\012";
 
-           print $res if $self->{debug};
+           $self->dprint($res);
 
            $reqstate->{hdl}->push_write($res);
 
@@ -531,7 +568,7 @@ sub websocket_proxy {
 }
 
 sub proxy_request {
-    my ($self, $reqstate, $clientip, $host, $node, $method, $uri, $ticket, $token, $params) = @_;
+    my ($self, $reqstate, $clientip, $host, $node, $method, $uri, $auth, $params) = @_;
 
     eval {
        my $target;
@@ -551,8 +588,12 @@ sub proxy_request {
            PVEClientIP => $clientip,
        };
 
-       $headers->{'cookie'} = PVE::APIServer::Formatter::create_auth_cookie($ticket, $self->{cookie_name}) if $ticket;
-       $headers->{'CSRFPreventionToken'} = $token if $token;
+       $headers->{'cookie'} = PVE::APIServer::Formatter::create_auth_cookie($auth->{ticket}, $self->{cookie_name})
+           if $auth->{ticket};
+       $headers->{'Authorization'} = PVE::APIServer::Formatter::create_auth_header($auth->{api_token}, $self->{apitoken_name})
+           if $auth->{api_token};
+       $headers->{'CSRFPreventionToken'} = $auth->{token}
+           if $auth->{token};
        $headers->{'Accept-Encoding'} = 'gzip' if ($reqstate->{accept_gzip} && $self->{compression});
 
        if (defined(my $host = $reqstate->{request}->header('Host'))) {
@@ -578,9 +619,6 @@ sub proxy_request {
            sslv2 => 0,
            sslv3 => 0,
            verify => 1,
-           # be compatible with openssl 1.1, fix for debian bug #923615
-           # remove once libanyeven-perl with this fix transitions to buster
-           dh => 'schmorp2048',
            verify_cb => sub {
                my (undef, undef, undef, $depth, undef, undef, $cert) = @_;
                # we don't care about intermediate or root certificates
@@ -645,16 +683,19 @@ sub decode_urlencoded {
        my ($k, $v) = split(/=/, $kv);
        $k =~s/\+/ /g;
        $k =~ s/%([0-9a-fA-F][0-9a-fA-F])/chr(hex($1))/eg;
-       $v =~s/\+/ /g;
-       $v =~ s/%([0-9a-fA-F][0-9a-fA-F])/chr(hex($1))/eg;
 
-       $v = Encode::decode('utf8', $v);
+       if (defined($v)) {
+           $v =~s/\+/ /g;
+           $v =~ s/%([0-9a-fA-F][0-9a-fA-F])/chr(hex($1))/eg;
 
-       if (defined(my $old = $res->{$k})) {
-           $res->{$k} = "$old\0$v";
-       } else {
-           $res->{$k} = $v;
+           $v = Encode::decode('utf8', $v);
+
+           if (defined(my $old = $res->{$k})) {
+               $v = "$old\0$v";
+           }
        }
+
+       $res->{$k} = $v;
     }
     return $res;
 }
@@ -665,7 +706,15 @@ sub extract_params {
     my $params = {};
 
     if ($method eq 'PUT' || $method eq 'POST') {
-       $params = decode_urlencoded($r->content);
+       my $ct;
+       if (my $ctype = $r->header('Content-Type')) {
+           $ct = parse_content_type($ctype);
+       }
+       if (defined($ct) && $ct eq 'application/json')  {
+           $params = decode_json($r->content);
+       } else {
+           $params = decode_urlencoded($r->content);
+       }
     }
 
     my $query_params = decode_urlencoded($r->url->query());
@@ -731,7 +780,7 @@ sub handle_api2_request {
            $res->{proxy_params}->{tmpfilename} = $reqstate->{tmpfilename} if $upload_state;
 
            $self->proxy_request($reqstate, $clientip, $host, $res->{proxynode}, $method,
-                                $r->uri, $auth->{ticket}, $auth->{token}, $res->{proxy_params}, $res->{proxynode});
+                                $r->uri, $auth, $res->{proxy_params});
            return;
 
        } elsif ($upgrade && ($method eq 'GET') && ($path =~ m|websocket$|)) {
@@ -763,10 +812,11 @@ sub handle_api2_request {
            $delay = 0 if $delay < 0;
        }
 
-       if (defined(my $filename = $res->{download})) {
-           my $fh = IO::File->new($filename) ||
-               die "unable to open file '$filename' - $!\n";
-           send_file_start($self, $reqstate, $filename);
+       my $download = $res->{download};
+       $download //= $res->{data}->{download}
+            if defined($res->{data}) && ref($res->{data}) eq 'HASH';
+       if (defined($download)) {
+           send_file_start($self, $reqstate, $download);
            return;
        }
 
@@ -793,7 +843,10 @@ sub handle_spice_proxy_request {
 
     eval {
 
-        die "Port $spiceport is not allowed" if ($spiceport < 61000 || $spiceport > 61099);
+       my ($minport, $maxport) = PVE::Tools::spice_port_range();
+       if ($spiceport < $minport || $spiceport > $maxport) {
+           die "SPICE Port $spiceport is not in allowed range ($minport, $maxport)\n";
+       }
 
        my $clientip = $reqstate->{peer_host};
        my $r = $reqstate->{request};
@@ -802,9 +855,9 @@ sub handle_spice_proxy_request {
 
         if ($node ne 'localhost' && PVE::INotify::nodename() !~ m/^$node$/i) {
             $remip = $self->remote_node_ip($node);
-           print "REMOTE CONNECT $vmid, $remip, $connect_str\n" if $self->{debug};
+           $self->dprint("REMOTE CONNECT $vmid, $remip, $connect_str");
         } else {
-           print "$$: CONNECT $vmid, $node, $spiceport\n" if $self->{debug};
+           $self->dprint("CONNECT $vmid, $node, $spiceport");
        }
 
        if ($remip && $r->header('PVEDisableProxy')) {
@@ -822,7 +875,7 @@ sub handle_spice_proxy_request {
            my ($fh) = @_
                or die "connect to '$remhost:$remport' failed: $!";
 
-           print "$$: CONNECTed to '$remhost:$remport'\n" if $self->{debug};
+           $self->dprint("CONNECTed to '$remhost:$remport'");
            $reqstate->{proxyhdl} = AnyEvent::Handle->new(
                fh => $fh,
                rbuf_max => 64*1024,
@@ -1146,7 +1199,7 @@ sub unshift_read_header {
        eval {
            # print "$$: got header: $line\n" if $self->{debug};
 
-           die "to many http header lines\n" if ++$state->{count} >= $limit_max_headers;
+           die "too many http header lines (> $limit_max_headers)\n" if ++$state->{count} >= $limit_max_headers;
            die "http header too large\n" if ($state->{size} += length($line)) >= $limit_max_header_size;
 
            my $r = $reqstate->{request};
@@ -1199,8 +1252,9 @@ sub unshift_read_header {
                my $len = $r->header('Content-Length');
 
                my $host_header = $r->header('Host');
-               my $rpcenv = $self->{rpcenv};
-               $rpcenv->set_request_host($host_header);
+               if (my $rpcenv = $self->{rpcenv}) {
+                   $rpcenv->set_request_host($host_header);
+               }
 
                # header processing complete - authenticate now
 
@@ -1217,7 +1271,19 @@ sub unshift_read_header {
                } elsif ($path =~ m/^\Q$base_uri\E/) {
                    my $token = $r->header('CSRFPreventionToken');
                    my $cookie = $r->header('Cookie');
-                   my $ticket = PVE::APIServer::Formatter::extract_auth_cookie($cookie, $self->{cookie_name});
+                   my $auth_header = $r->header('Authorization');
+
+                   # prefer actual cookie
+                   my $ticket = PVE::APIServer::Formatter::extract_auth_value($cookie, $self->{cookie_name});
+
+                   # fallback to cookie in 'Authorization' header
+                   $ticket = PVE::APIServer::Formatter::extract_auth_value($auth_header, $self->{cookie_name})
+                       if !$ticket;
+
+                   # finally, fallback to API token if no ticket has been provided so far
+                   my $api_token;
+                   $api_token = PVE::APIServer::Formatter::extract_auth_value($auth_header, $self->{apitoken_name})
+                       if !$ticket;
 
                    my ($rel_uri, $format) = &$split_abs_uri($path, $self->{base_uri});
                    if (!$format) {
@@ -1226,7 +1292,7 @@ sub unshift_read_header {
                    }
 
                    eval {
-                       $auth = $self->auth_handler($method, $rel_uri, $ticket, $token,
+                       $auth = $self->auth_handler($method, $rel_uri, $ticket, $token, $api_token,
                                                    $reqstate->{peer_host});
                    };
                    if (my $err = $@) {
@@ -1271,7 +1337,8 @@ sub unshift_read_header {
                    }
 
                    my $ctype = $r->header('Content-Type');
-                   my ($ct, $boundary) = parse_content_type($ctype) if $ctype;
+                   my ($ct, $boundary);
+                   ($ct, $boundary)= parse_content_type($ctype) if $ctype;
 
                    if ($auth->{isUpload} && !$self->{trusted_env}) {
                        die "upload 'Content-Type '$ctype' not implemented\n"
@@ -1281,7 +1348,7 @@ sub unshift_read_header {
 
                        die "upload without content length header not supported" if !$len;
 
-                       print "start upload $path $ct $boundary\n" if $self->{debug};
+                       $self->dprint("start upload $path $ct $boundary");
 
                        my $tmpfilename = get_upload_filename();
                        my $outfh = IO::File->new($tmpfilename, O_RDWR|O_CREAT|O_EXCL, 0600) ||
@@ -1314,7 +1381,7 @@ sub unshift_read_header {
                        return;
                    }
 
-                   if (!$ct || $ct eq 'application/x-www-form-urlencoded') {
+                   if (!$ct || $ct eq 'application/x-www-form-urlencoded' || $ct eq 'application/json') {
                        $reqstate->{hdl}->unshift_read(chunk => $len, sub {
                            my ($hdl, $data) = @_;
                            $r->content($data);
@@ -1427,8 +1494,6 @@ sub accept {
 
     fh_nonblocking $clientfh, 1;
 
-    $self->{conn_count}++;
-
     return $clientfh;
 }
 
@@ -1470,6 +1535,11 @@ sub check_host_access {
 
     my $cip = Net::IP->new($clientip);
 
+    if (!$cip) {
+       $self->dprint("client IP not parsable: $@");
+       return 0;
+    }
+
     my $match_allow = 0;
     my $match_deny = 0;
 
@@ -1477,6 +1547,7 @@ sub check_host_access {
        foreach my $t (@{$self->{allow_from}}) {
            if ($t->overlaps($cip)) {
                $match_allow = 1;
+               $self->dprint("client IP allowed: ". $t->prefix());
                last;
            }
        }
@@ -1485,6 +1556,7 @@ sub check_host_access {
     if ($self->{deny_from}) {
        foreach my $t (@{$self->{deny_from}}) {
            if ($t->overlaps($cip)) {
+               $self->dprint("client IP denied: ". $t->prefix());
                $match_deny = 1;
                last;
            }
@@ -1502,29 +1574,40 @@ sub check_host_access {
 sub accept_connections {
     my ($self) = @_;
 
+    my ($clientfh, $handle_creation);
     eval {
 
-       while (my $clientfh = $self->accept()) {
+       while ($clientfh = $self->accept()) {
 
            my $reqstate = { keep_alive => $self->{keep_alive} };
 
            # stop keep-alive when there are many open connections
-           if ($self->{conn_count} >= $self->{max_conn_soft_limit}) {
+           if ($self->{conn_count} + 1 >= $self->{max_conn_soft_limit}) {
                $reqstate->{keep_alive} = 0;
            }
 
            if (my $sin = getpeername($clientfh)) {
                my ($pfamily, $pport, $phost) = PVE::Tools::unpack_sockaddr_in46($sin);
                ($reqstate->{peer_port}, $reqstate->{peer_host}) = ($pport,  Socket::inet_ntop($pfamily, $phost));
+           } else {
+               $self->dprint("getpeername failed: $!");
+               close($clientfh);
+               next;
            }
 
            if (!$self->{trusted_env} && !$self->check_host_access($reqstate->{peer_host})) {
-               print "$$: ABORT request from $reqstate->{peer_host} - access denied\n" if $self->{debug};
+               $self->dprint("ABORT request from $reqstate->{peer_host} - access denied");
                $reqstate->{log}->{code} = 403;
                $self->log_request($reqstate);
+               close($clientfh);
                next;
            }
 
+           # Increment conn_count before creating new handle, since creation
+           # triggers callbacks, which can potentialy decrement (e.g.
+           # on_error) conn_count before AnyEvent::Handle->new() returns.
+           $handle_creation = 1;
+           $self->{conn_count}++;
            $reqstate->{hdl} = AnyEvent::Handle->new(
                fh => $clientfh,
                rbuf_max => 64*1024,
@@ -1547,8 +1630,9 @@ sub accept_connections {
                    if (my $err = $@) { syslog('err', "$err"); }
                },
                ($self->{tls_ctx} ? (tls => "accept", tls_ctx => $self->{tls_ctx}) : ()));
+           $handle_creation = 0;
 
-           print "$$: ACCEPT FH" .  $clientfh->fileno() . " CONN$self->{conn_count}\n" if $self->{debug};
+           $self->dprint("ACCEPT FH" .  $clientfh->fileno() . " CONN$self->{conn_count}");
 
            $self->push_request_header($reqstate);
        }
@@ -1556,6 +1640,15 @@ sub accept_connections {
 
     if (my $err = $@) {
        syslog('err', $err);
+       $self->dprint("connection accept error: $err");
+       close($clientfh);
+       if ($handle_creation) {
+           if ($self->{conn_count} <= 0) {
+               warn "connection count <= 0 not decrementing!\n";
+           } else {
+               $self->{conn_count}--;
+           }
+       }
        $self->{end_loop} = 1;
     }
 
@@ -1620,6 +1713,7 @@ sub new {
     my $self = bless { %args }, $class;
 
     $self->{cookie_name} //= 'PVEAuthCookie';
+    $self->{apitoken_name} //= 'PVEAPIToken';
     $self->{base_uri} //= "/api2";
     $self->{dirs} //= {};
     $self->{title} //= 'API Inspector';
@@ -1627,17 +1721,18 @@ sub new {
 
     # formatter_config: we pass some configuration values to the Formatter
     $self->{formatter_config} = {};
-    foreach my $p (qw(cookie_name base_uri title)) {
+    foreach my $p (qw(apitoken_name cookie_name base_uri title)) {
        $self->{formatter_config}->{$p} = $self->{$p};
     }
     $self->{formatter_config}->{csrfgen_func} =
        $self->can('generate_csrf_prevention_token');
 
     # add default dirs which includes jquery and bootstrap
-    my $base = '/usr/share/libpve-http-server-perl';
-    add_dirs($self->{dirs}, '/css/' => "$base/css/");
-    add_dirs($self->{dirs}, '/js/' => "$base/js/");
-    add_dirs($self->{dirs}, '/fonts/' => "$base/fonts/");
+    my $jsbase = '/usr/share/javascript';
+    add_dirs($self->{dirs}, '/js/' => "$jsbase/");
+    # libjs-bootstrap uses symlinks for this, which we do not want to allow..
+    my $glyphicons = '/usr/share/fonts/truetype/glyphicons/';
+    add_dirs($self->{dirs}, '/js/bootstrap/fonts/' => "$glyphicons");
 
     # init inotify
     PVE::INotify::inotify_init();
@@ -1761,7 +1856,7 @@ sub generate_csrf_prevention_token {
 }
 
 sub auth_handler {
-    my ($self, $method, $rel_uri, $ticket, $token, $peer_host) = @_;
+    my ($self, $method, $rel_uri, $ticket, $token, $api_token, $peer_host) = @_;
 
     die "implement me";
 
@@ -1771,6 +1866,7 @@ sub auth_handler {
     #    userid => $username,
     #    age => $age,
     #    isUpload => $isUpload,
+    #    api_token => $api_token,
     #};
 }