public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH pve-manager 2/2] pvesh: add 'record' subcommand to trace user's API requests
Date: Wed, 17 Jun 2026 18:47:49 +0200	[thread overview]
Message-ID: <20260617164749.574759-3-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260617164749.574759-1-h.laimer@proxmox.com>

Given a user, `pvesh record <user>` records the api requests made by
that user and translates them into their corresponding `pvesh` command.
By default only writes (POST/PUT/DELETE) are recorded, with '--all' also
reads (GET). Requests are recorded and printed as long as the command
runs.

Makes it easy to repeat things done through the webUI via a script, for
example.

Can only be run as root, like the rest of pvesh.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 PVE/CLI/pvesh.pm        | 140 ++++++++++++++++++++++++++++++++++++++++
 PVE/Service/pveproxy.pm |   1 +
 2 files changed, 141 insertions(+)

diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm
index acd9a605..fa01b01c 100755
--- a/PVE/CLI/pvesh.pm
+++ b/PVE/CLI/pvesh.pm
@@ -12,6 +12,7 @@ use PVE::RPCEnvironment;
 use PVE::RESTHandler;
 use PVE::CLIFormatter;
 use PVE::CLIHandler;
+use PVE::APIServer::RequestRecorder;
 use PVE::API2Tools;
 use PVE::API2;
 use JSON;
@@ -82,6 +83,37 @@ my $method_map = {
     delete => 'DELETE',
 };
 
+# reverse of $method_map: HTTP method -> pvesh command
+my $command_map = { reverse %$method_map };
+
+# turn a recorded request event into the equivalent, ready-to-run 'pvesh' command
+my $event_to_pvesh = sub {
+    my ($event) = @_;
+
+    my $method = $event->{method} // return undef;
+    my $cmd = $command_map->{$method} // return undef;
+    my $path = $event->{path} // return undef;
+
+    my $args = ['pvesh', $cmd, $path];
+
+    my $params = $event->{params};
+    if (ref($params) eq 'HASH') {
+        foreach my $key (sort keys %$params) {
+            my $val = $params->{$key};
+            if (ref($val) eq 'ARRAY') {
+                push @$args, "--$key", $_ for @$val;
+            } elsif (ref($val)) {
+                # nested structure from a JSON request body - keep it as JSON
+                push @$args, "--$key", encode_json($val);
+            } else {
+                push @$args, "--$key", $val;
+            }
+        }
+    }
+
+    return String::ShellQuote::shell_quote(@$args);
+};
+
 sub check_proxyto {
     my ($info, $uri_param, $params) = @_;
 
@@ -604,6 +636,113 @@ __PACKAGE__->register_method({
     },
 });
 
+__PACKAGE__->register_method({
+    name => 'record',
+    path => 'record',
+    method => 'GET',
+    description => "Watch the API requests a user makes (for example via the web UI) and "
+        . "print them as the equivalent 'pvesh' commands. Stop with CTRL+C.",
+    protected => 1,
+    parameters => {
+        additionalProperties => 0,
+        properties => {
+            user => get_standard_option(
+                'userid',
+                {
+                    description => "Only record requests made by this user "
+                        . "(also matches the user's API tokens).",
+                },
+            ),
+            all => {
+                description => "Also record read-only (GET) requests. By default only "
+                    . "writing requests (POST/PUT/DELETE) are recorded.",
+                type => 'boolean',
+                optional => 1,
+                default => 0,
+            },
+        },
+    },
+    returns => { type => 'null' },
+    code => sub {
+        my ($param) = @_;
+
+        die "only root can record API requests\n" if $> != 0;
+
+        my $user = $param->{user};
+        my $filter = { userid => $user, all => $param->{all} ? 1 : 0 };
+
+        my $id = PVE::APIServer::RequestRecorder::start_recording($filter);
+
+        my $cleaned = 0;
+        my $cleanup = sub {
+            return if $cleaned;
+            $cleaned = 1;
+            PVE::APIServer::RequestRecorder::stop_recording($id);
+        };
+
+        local $SIG{INT} = local $SIG{TERM} = sub {
+            $cleanup->();
+            print STDERR "\nrecording stopped\n";
+            exit(0);
+        };
+
+        local $| = 1; # show recorded commands as they come in
+
+        my $events_path = PVE::APIServer::RequestRecorder::events_path($id);
+
+        eval {
+            my $fh;
+            my $pos = 0;
+            my $buf = '';
+            my $ino = 0;
+
+            while (1) {
+                # (re)open the spool if it appeared, got rotated to a new inode,
+                # or was truncated/recreated underneath us
+                if (my @st = stat($events_path)) {
+                    if (!$fh || $st[1] != $ino || $st[7] < $pos) {
+                        open($fh, '<', $events_path)
+                            or die "unable to open event spool '$events_path' - $!\n";
+                        $ino = $st[1];
+                        $pos = 0;
+                        $buf = '';
+                    }
+                }
+
+                if ($fh) {
+                    seek($fh, $pos, 0); # also clears EOF so we pick up appended data
+                    my $chunk = do { local $/ = undef; <$fh> };
+                    if (defined($chunk) && length($chunk)) {
+                        $pos += length($chunk);
+                        $buf .= $chunk;
+                        while ($buf =~ s/^([^\n]*)\n//) {
+                            my $line = $1;
+                            next if $line eq '';
+                            my $event = eval { decode_json($line) };
+                            if (!$event) {
+                                next;
+                            }
+                            my $cmd = $event_to_pvesh->($event);
+                            if (!defined($cmd)) {
+                                next;
+                            }
+                            print "$cmd\n";
+                        }
+                    }
+                }
+
+                select(undef, undef, undef, 0.2);
+            }
+        };
+        my $err = $@;
+
+        $cleanup->();
+        die $err if $err;
+
+        return undef;
+    },
+});
+
 our $cmddef = {
     usage => [__PACKAGE__, 'usage', ['api_path']],
     get => [__PACKAGE__, 'get', ['api_path']],
@@ -611,6 +750,7 @@ our $cmddef = {
     set => [__PACKAGE__, 'set', ['api_path']],
     create => [__PACKAGE__, 'create', ['api_path']],
     delete => [__PACKAGE__, 'delete', ['api_path']],
+    record => [__PACKAGE__, 'record', ['user']],
 };
 
 1;
diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm
index c6011a00..1dc6fa0f 100755
--- a/PVE/Service/pveproxy.pm
+++ b/PVE/Service/pveproxy.pm
@@ -106,6 +106,7 @@ sub init {
         debug => $self->{debug},
         trusted_env => 0, # not trusted, anyone can connect
         logfile => '/var/log/pveproxy/access.log',
+        request_recording => 1, # enable 'pvesh record' (front-end sees full request bodies)
         allow_from => $proxyconf->{ALLOW_FROM},
         deny_from => $proxyconf->{DENY_FROM},
         policy => $proxyconf->{POLICY},
-- 
2.47.3





      parent reply	other threads:[~2026-06-17 16:48 UTC|newest]

Thread overview: 3+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-06-17 16:47 [RFC http-server/manager 0/2] add pvesh record subcommand Hannes Laimer
2026-06-17 16:47 ` [PATCH pve-http-server 1/2] apiserver: add opt-in recording of api requests Hannes Laimer
2026-06-17 16:47 ` Hannes Laimer [this message]

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=20260617164749.574759-3-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.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