public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
To: pve-devel@lists.proxmox.com
Subject: [RFC pve-http-server 01/13] anyevent : proxy a path prefix to a local http proxy
Date: Tue, 25 Aug 2026 13:34:26 +0200	[thread overview]
Message-ID: <20260825113442.947620-2-alexandre.derumier@groupe-cyllene.com> (raw)
In-Reply-To: <20260825113442.947620-1-alexandre.derumier@groupe-cyllene.com>

A handler says which prefixes go to a local backend and who may reach them,
so a service can use this server's TLS and authentication without its own port.
Requests go verbatim, not re-encoded like proxy_request; an upgrade becomes a
pipe after 101, and the relay stops reading while the far side is behind.

Signed-off-by: Alexandre Derumier <alexandre.derumier@groupe-cyllene.com>
---
 src/PVE/APIServer/AnyEvent.pm | 263 ++++++++++++++++++++++++++++++++++
 1 file changed, 263 insertions(+)

diff --git a/src/PVE/APIServer/AnyEvent.pm b/src/PVE/APIServer/AnyEvent.pm
index 915d678..dc95c12 100644
--- a/src/PVE/APIServer/AnyEvent.pm
+++ b/src/PVE/APIServer/AnyEvent.pm
@@ -731,6 +731,250 @@ sub websocket_proxy {
     }
 }
 
+# Queued for one side before the other stops being read. Smaller than
+# response_stream's 4MB: consoles are not downloads, and a backlog is only
+# latency the far end could have coalesced away.
+my $relay_buf_size = 1024 * 1024;
+
+# What a handle still owes its socket; TLS keeps a second buffer.
+sub relay_queued {
+    my ($hdl) = @_;
+    return length($hdl->{wbuf}) + length($hdl->{_tls_wbuf} // '');
+}
+
+# One direction of an upgraded connection: copy to the other side, and stop
+# reading while that side is behind, so back pressure reaches the far end
+# instead of queueing here. Same shape as response_stream, and named rather
+# than a closure over itself, which would be a cycle. The handles come from
+# callbacks because either may be gone by the time this runs.
+sub relay_reader {
+    my ($from, $to) = @_;
+
+    return sub {
+        my ($hdl) = @_;
+
+        my $writer = $to->();
+        return if !$writer;
+
+        my $data = $hdl->{rbuf};
+        $hdl->{rbuf} = '';
+        $writer->push_write($data) if length($data);
+
+        return if relay_queued($writer) < $relay_buf_size;
+
+        my $prev_on_drain = $writer->{on_drain};
+        $writer->on_drain(sub {
+            my ($wrhdl) = @_;
+            # Restored first: setting on_drain runs it on an empty buffer.
+            $wrhdl->on_drain($prev_on_drain);
+            if (my $reader = $from->()) {
+                $reader->on_read(relay_reader($from, $to));
+            }
+        });
+
+        $hdl->on_read();
+    };
+}
+
+# Hand an upgrade to the backend: the request goes out as it arrived and the
+# answer comes back untouched, so the two ends compute the accept key. After
+# 101 this is a pipe, which knows nothing of websockets.
+sub local_http_proxy_upgrade {
+    my ($self, $reqstate, $method, $target) = @_;
+
+    my $r = $reqstate->{request};
+
+    my ($remhost, $remport);
+    if ($target->{port}) {
+        $remhost = 'localhost';
+        $remport = $target->{port};
+    } else {
+        $remhost = 'unix/';
+        $remport = $target->{socket};
+    }
+    my $path = $target->{path} // '/';
+
+    # Only Host is rewritten: this is the hop being upgraded, so Connection
+    # and Upgrade stay.
+    my $headers = '';
+    $r->headers->scan(sub {
+        my ($key, $value) = @_;
+        return if lc($key) eq 'host';
+        $headers .= "$key: $value\015\012";
+    });
+    my $request = "$method $path HTTP/1.1\015\012Host: localhost\015\012$headers\015\012";
+
+    tcp_connect $remhost, $remport, sub {
+        my ($fh) = @_
+            or do {
+                $self->error($reqstate, HTTP_BAD_GATEWAY, "connect to backend failed: $!");
+                return;
+            };
+
+        $reqstate->{proxyhdl} = AnyEvent::Handle->new(
+            fh => $fh,
+            rbuf_max => 64 * 1024,
+            wbuf_max => 4 * $relay_buf_size,
+            timeout => 30,
+            on_eof => sub {
+                eval {
+                    $self->log_aborted_request($reqstate);
+                    $self->client_do_disconnect($reqstate);
+                };
+                warn $@ if $@;
+            },
+            on_error => sub {
+                my ($hdl, $fatal, $message) = @_;
+                eval {
+                    $self->log_aborted_request($reqstate, $message);
+                    $self->client_do_disconnect($reqstate);
+                };
+                warn $@ if $@;
+            },
+        );
+
+        $reqstate->{proxyhdl}->push_write($request);
+
+        $reqstate->{proxyhdl}->push_read(
+            line => "\015\012\015\012",
+            sub {
+                my ($hdl, $response) = @_;
+
+                # Only 101 means the backend stopped speaking HTTP.
+                if ($response !~ m|^HTTP/1\.1 101|) {
+                    my ($status) = $response =~ m|^(\S+ \d+[^\015]*)|;
+                    $self->log_aborted_request($reqstate,
+                        "backend refused upgrade: " . ($status // 'unparseable response'));
+                    $self->client_do_disconnect($reqstate);
+                    return;
+                }
+
+                # Verbatim: it carries the accept key for the client's key.
+                $reqstate->{hdl}->push_write($response . "\015\012\015\012");
+
+                $reqstate->{proxyhdl}->timeout(0);
+                $reqstate->{hdl}->timeout(0);
+
+                my $client = sub { $reqstate->{hdl} };
+                my $backend = sub { $reqstate->{proxyhdl} };
+
+                $reqstate->{proxyhdl}->on_read(relay_reader($backend, $client));
+                $reqstate->{hdl}->on_read(relay_reader($client, $backend));
+
+                $reqstate->{log}->{code} = 101;
+                $self->log_request($reqstate);
+            },
+        );
+    };
+
+    return;
+}
+
+# Forward a request verbatim to a service on loopback, unlike proxy_request,
+# which re-encodes parsed parameters for another PVE node. The backend is a
+# foreign HTTP server, kept behind this server's TLS and authentication.
+sub local_http_proxy_request {
+    my ($self, $reqstate, $method, $target) = @_;
+
+    my $r = $reqstate->{request};
+
+    my $port = $target->{port};
+    my $socket = $target->{socket};
+    die "local_http_proxy_request: missing port or socket\n" if !$port && !$socket;
+    my $path = $target->{path} // '/';
+    my $scheme = $target->{tls} ? 'https' : 'http';
+
+    if ($r->header('upgrade')) {
+        $self->local_http_proxy_upgrade($reqstate, $method, $target);
+        return;
+    }
+
+    # Hop-by-hop headers describe the connection they arrived on, and
+    # Accept-Encoding goes too, so this server can compress the body itself.
+    my $skip = {
+        map { $_ => 1 } qw(
+            connection keep-alive host content-length transfer-encoding
+            upgrade te trailer proxy-authorization accept-encoding
+        )
+    };
+
+    # A unix socket has no authority to name, and nothing behind here routes on
+    # Host anyway.
+    my $headers = { Host => $port ? "127.0.0.1:$port" : 'localhost' };
+    $r->headers->scan(sub {
+        my ($key, $value) = @_;
+        $headers->{$key} = $value if !$skip->{ lc($key) };
+    });
+
+    my $content = $r->content;
+    $headers->{'Content-Length'} = length($content) if length($content);
+
+    my $tls_ctx;
+    if ($target->{tls}) {
+        # Loopback, with a certificate no browser sees and no CA signed: there
+        # is nothing verification could check.
+        $tls_ctx = AnyEvent::TLS->new(method => 'any', sslv2 => 0, sslv3 => 0, verify => 0);
+    }
+
+    # AnyEvent::HTTP needs a URL to parse, so a unix backend gets a nominal
+    # authority and a tcp_connect that ignores it.
+    my $url = $port ? "$scheme://127.0.0.1:$port$path" : "$scheme://localhost$path";
+    my $tcp_connect;
+    if ($socket) {
+        $tcp_connect = sub {
+            my (undef, undef, $connect_cb, $prepare_cb) = @_;
+            return AnyEvent::Socket::tcp_connect('unix/', $socket, $connect_cb, $prepare_cb);
+        };
+    }
+
+    my $w;
+    $w = http_request(
+        $method => $url,
+        headers => $headers,
+        $tcp_connect ? (tcp_connect => $tcp_connect) : (),
+        timeout => 30,
+        proxy => undef, # avoid use of $ENV{HTTP_PROXY}
+        persistent => 0,
+        keepalive => 0,
+        body => length($content) ? $content : undef,
+        $tls_ctx ? (tls_ctx => $tls_ctx) : (),
+        sub {
+            my ($body, $hdr) = @_;
+
+            undef $w;
+
+            if (!$reqstate->{hdl}) {
+                warn "local http proxy detected vanished client connection\n";
+                return;
+            }
+
+            eval {
+                my $code = delete $hdr->{Status};
+                my $msg = delete $hdr->{Reason};
+                delete $hdr->{URL};
+                delete $hdr->{HTTPVersion};
+
+                # AnyEvent::HTTP reports its own failures in the 59x range.
+                if ($code >= 590) {
+                    $self->error($reqstate, HTTP_BAD_GATEWAY, "$msg");
+                    return;
+                }
+
+                # Set by this server for the connection it answers on.
+                delete $hdr->{$_} for qw(connection transfer-encoding content-length);
+
+                my $header = HTTP::Headers->new(%$hdr);
+                my $resp = HTTP::Response->new($code, $msg, $header, $body);
+                # Note: disable compression, the backend decides its own encoding
+                $self->response($reqstate, $resp, undef, 1);
+            };
+            warn $@ if $@;
+        },
+    );
+
+    return;
+}
+
 sub proxy_request {
     my ($self, $reqstate, $clientip, $host, $node, $method, $uri, $auth, $params) = @_;
 
@@ -1222,6 +1466,25 @@ sub handle_request {
         # we re-enable timeout in response()
         $reqstate->{hdl}->timeout(0);
 
+        # The handler says where to send it, or nothing for the usual dispatch.
+        if (my $handler = $self->{local_http_proxy_handler}) {
+            my $target = eval { $handler->($self, $reqstate, $auth, $method, $path) };
+            if (my $err = $@) {
+                # The handler's refusals are answers: a denial must stay 403.
+                my $code = HTTP_INTERNAL_SERVER_ERROR;
+                if (ref($err) && eval { $err->{code} }) {
+                    my $carried = $err->{code};
+                    $code = $carried if $carried =~ m/^\d+$/ && $carried >= 400 && $carried <= 599;
+                }
+                $self->error($reqstate, $code, "$err");
+                return;
+            }
+            if ($target) {
+                $self->local_http_proxy_request($reqstate, $method, $target);
+                return;
+            }
+        }
+
         if ($path =~ m/^\Q$base_uri\E/) {
             $self->handle_api2_request($reqstate, $auth, $method, $path);
             return;
-- 
2.55.0




  reply	other threads:[~2026-08-25 11:35 UTC|newest]

Thread overview: 11+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-08-25 11:34 [RFC pve-http-server/qemu-server/pve-manager/pve-{qemu-kyber,kyberproxy, kyber-web,qemu-rdp,rdpproxy,rdp-web} 00/13] add rdp && kyber consoles for qemu over D-Bus display Alexandre Derumier
2026-08-25 11:34 ` Alexandre Derumier [this message]
2026-08-25 11:34 ` [RFC qemu-server 02/13] add D-Bus display support Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 03/13] add kyber display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 04/13] add rdp display Alexandre Derumier
2026-08-25 11:34 ` [RFC qemu-server 05/13] add experimental kyber-gl display Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 06/13] ui: add kyber console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-manager 07/13] ui: add rdp console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-kyber-web 10/13] add pve-kyber-web: console's webassembly client Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-qemu-rdp 11/13] Add pve-qemu-rdp: an RDP server for the console Alexandre Derumier
2026-08-25 11:34 ` [RFC pve-rdpproxy 12/13] Add pve-rdpproxy Alexandre Derumier

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260825113442.947620-2-alexandre.derumier@groupe-cyllene.com \
    --to=alexandre.derumier@groupe-cyllene.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal