all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal