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
prev 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