all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Kefu Chai <k.chai@proxmox.com>
To: pve-devel@lists.proxmox.com
Cc: Thomas Lamprecht <t.lamprecht@proxmox.com>
Subject: [PATCH manager 1/4] migrations: cephx: ask a monitor about itself through one route
Date: Sun, 27 Sep 2026 08:59:03 +0800	[thread overview]
Message-ID: <20260927005906.4184138-2-k.chai@proxmox.com> (raw)
In-Reply-To: <20260927005906.4184138-1-k.chai@proxmox.com>

The helper asks a monitor three things about itself: which auth methods
it requires, whether it can hold two valid client keys, and which
clients have a session with it. All three go through the admin socket,
which needs no cephx and works even while auth is broken, but does need
a shell on the monitor's host.

A daemon can run on a host that is not a node of this Proxmox VE
cluster, such as the tiebreaker monitor of a stretch cluster. Nothing of
ours reaches such a host: no SSH trust, no pmxcfs, no pvestatd. The
probes fail, so the helper treats the monitor as unreachable, refuses to
stage a client key, and reports an incomplete session view.

Record whether a daemon's node is one we manage, and route the three
probes through that. 'ceph tell' reaches the same admin socket over the
network, but needs cephx and a quorum, so it is the fallback, not the
default. It runs as 'mon.' so it keeps working during a 'client.admin'
rotation, and carries a connect timeout so a monitor across a WAN link
cannot stall a run.

Signed-off-by: Kefu Chai <k.chai@proxmox.com>
---
 PVE/Ceph/KeyMigration.pm            | 19 +++----
 bin/pve-cephx-rotate-service-keys   | 78 ++++++++++++++++-------------
 test/CephKeyMigrationScript_test.pl | 62 +++++++++++++++++++++--
 3 files changed, 110 insertions(+), 49 deletions(-)

diff --git a/PVE/Ceph/KeyMigration.pm b/PVE/Ceph/KeyMigration.pm
index d0613e8b..1ad73826 100644
--- a/PVE/Ceph/KeyMigration.pm
+++ b/PVE/Ceph/KeyMigration.pm
@@ -1101,15 +1101,16 @@ sub merge_configured_daemons($daemons, $type, $configured, $existing = undef) {
             push @$ghosts, { type => $type, id => "$id", node => $configured->{$id} };
             next;
         }
-        push @$daemons,
-            {
-                type => $type,
-                id => "$id",
-                entity => $type eq 'mon' ? 'mon.' : "$type.$id",
-                node => $configured->{$id},
-                down => 1,
-                $type eq 'osd' && $existing ? ('osd-uuid' => $existing->{$id}) : (),
-            };
+        push @$daemons, {
+            type => $type,
+            id => "$id",
+            entity => $type eq 'mon' ? 'mon.' : "$type.$id",
+            node => $configured->{$id},
+            # pvestatd publishes this inventory, so the node is one of this cluster's own
+            managed => 1,
+            down => 1,
+            $type eq 'osd' && $existing ? ('osd-uuid' => $existing->{$id}) : (),
+        };
     }
 
     return wantarray ? ($daemons, $ghosts) : $daemons;
diff --git a/bin/pve-cephx-rotate-service-keys b/bin/pve-cephx-rotate-service-keys
index b60f3619..95d10266 100755
--- a/bin/pve-cephx-rotate-service-keys
+++ b/bin/pve-cephx-rotate-service-keys
@@ -175,11 +175,20 @@ my sub cluster_nodes() {
     return { map { $_ => 1 } PVE::Cluster::get_nodelist()->@* };
 }
 
+# Where a daemon runs decides what this cluster can do with it. 'managed' means its host is a node
+# of this Proxmox VE cluster, so the cluster's SSH trust, pmxcfs and pvestatd cover it: its files,
+# its unit and its installed packages. A daemon without it is still part of the Ceph cluster and
+# still answers over Ceph, the tiebreaker monitor of a stretch cluster for example.
 my sub daemon_location($type, $id, $host) {
-    return { node => undef } if !defined($host) || (!ref($host) && !length($host));
+    return { node => undef, managed => 0 } if !defined($host) || (!ref($host) && !length($host));
     die "Cannot locate '$type.$id': no valid host name was reported\n" if ref($host);
-    my $node = PVE::Ceph::Services::metadata_host_node($host, cluster_nodes());
-    return { node => $node, $node ne $host ? ('metadata-host' => $host) : () };
+    my $nodes = cluster_nodes();
+    my $node = PVE::Ceph::Services::metadata_host_node($host, $nodes);
+    return {
+        node => $node,
+        managed => $nodes->{$node} ? 1 : 0,
+        $node ne $host ? ('metadata-host' => $host) : (),
+    };
 }
 
 my sub assert_member($node, $entity) {
@@ -559,13 +568,14 @@ my sub auth_entry($rados, $entity) {
 }
 
 # the monitor identity keeps working while a client.admin rotation invalidates the default keyring
-my sub monitor_command($args) {
+my sub monitor_command($args, $timeout = undef) {
     return node_run(
         $nodename,
         [
             'ceph', '--cluster', $ccname, '--name', 'mon.', '--keyring', $pve_mon_keyring,
             @$args,
         ],
+        defined($timeout) ? (timeout => $timeout) : (),
     );
 }
 
@@ -579,19 +589,31 @@ my sub monitor_auth_entry($entity) {
     return $res->[0];
 }
 
+# Everything a monitor is asked about itself goes through its own admin socket, which needs no
+# cephx and answers while the authentication layer is in trouble. A monitor on a host outside this
+# cluster has no socket we can reach, and 'ceph tell' hands the command to that same socket over
+# the network, so the answer is identical. That route needs cephx and a quorum, which is why it is
+# the fallback and not the rule. It runs as 'mon.', which keeps answering while a 'client.admin'
+# rotation is in flight.
+my sub mon_admin_run($node, $id, $words, $run_node = undef) {
+    if (defined($node) && cluster_nodes()->{$node}) {
+        my $cmd = ['ceph', 'daemon', "mon.$id", @$words];
+        return $run_node ? $run_node->($node, $cmd) : node_run($node, $cmd, entity => "mon.$id");
+    }
+
+    # a monitor across a WAN link answers in well under this, while one that cannot be reached at
+    # all must not hold the run the way the 300 second default would
+    return monitor_command(['--connect-timeout', '10', 'tell', "mon.$id", @$words], 20);
+}
+
 my sub poll_monitor_authentication($monitors, $nodes, $run_node = undef) {
     $run_node //= sub($node, $command) { node_run($node, $command, entity => $command->[2]) };
     my ($reports, $errors) = ({}, {});
     for my $mon (@$monitors) {
         my $option = 'auth_service_required';
         my $reply = eval {
-            assert_member($nodes->{$mon}, "mon.$mon");
-            decode_json(
-                $run_node->(
-                    $nodes->{$mon},
-                    ['ceph', 'daemon', "mon.$mon", 'config', 'get', $option],
-                ),
-            );
+            decode_json(mon_admin_run(
+                $nodes->{$mon}, $mon, ['config', 'get', $option], $run_node));
         };
         my $error = $@;
         $reports->{$mon}->{$option} = ref($reply) eq 'HASH' ? $reply->{$option} : undef;
@@ -789,20 +811,12 @@ for my $fsid (@fsids) {
 }
 PERL
 
-# Every monitor is asked over its admin socket, which needs no cephx and answers even while the
-# authentication layer itself is in trouble. One that does not answer marks the result incomplete,
-# so the checks built on it stay honest.
+# A monitor that does not answer marks the result incomplete, so the checks built on it stay honest.
 my sub poll_client_sessions($info) {
     my $per_mon = [];
     for my $mon ($info->{daemons}->{mon}->@*) {
         next if $mon->{down};
-        my $sessions = eval {
-            decode_json(node_run(
-                $mon->{node},
-                ['ceph', 'daemon', "mon.$mon->{id}", 'sessions'],
-                entity => "mon.$mon->{id}",
-            ));
-        };
+        my $sessions = eval { decode_json(mon_admin_run($mon->{node}, $mon->{id}, ['sessions'])) };
         push @$per_mon, { mon => $mon->{id}, sessions => $sessions };
     }
     return summarize_sessions($per_mon, $info->{monmap_mons});
@@ -811,15 +825,14 @@ my sub poll_client_sessions($info) {
 # Whether a monitor can keep two client keys valid is told by its admin socket, which needs no
 # cephx. One that answers but does not report the option is old; one that does not answer at all
 # cannot be told apart from a broken connection, and rules staging out just the same.
-my sub probe_manual_promotion($node, $id, $run_node) {
-    my $out =
-        eval { $run_node->($node, ['ceph', 'daemon', "mon.$id", 'config', 'get', $GRACE_OPTION]) };
+my sub probe_manual_promotion($node, $id, $run_node = undef) {
+    my $out = eval { mon_admin_run($node, $id, ['config', 'get', $GRACE_OPTION], $run_node) };
     if (!$@) {
         my $res = eval { decode_json($out) };
         my $value = ref($res) eq 'HASH' ? $res->{$GRACE_OPTION} : undef;
         return { reached => 1, value => defined($value) && !ref($value) ? "$value" : undef };
     }
-    my $reached = eval { $run_node->($node, ['ceph', 'daemon', "mon.$id", 'version']); 1 } ? 1 : 0;
+    my $reached = eval { mon_admin_run($node, $id, ['version'], $run_node); 1 } ? 1 : 0;
     return { reached => $reached, value => undef };
 }
 
@@ -827,11 +840,7 @@ my sub poll_manual_promotion($info) {
     my $reports = {};
     for my $mon ($info->{daemons}->{mon}->@*) {
         next if $mon->{down};
-        $reports->{ $mon->{id} } = probe_manual_promotion(
-            $mon->{node},
-            $mon->{id},
-            sub($node, $cmd) { node_run($node, $cmd, entity => "mon.$mon->{id}") },
-        );
+        $reports->{ $mon->{id} } = probe_manual_promotion($mon->{node}, $mon->{id});
     }
     return manual_promotion_support($reports, $info->{monmap_mons});
 }
@@ -936,13 +945,9 @@ my sub collect_current_monitor_state($rados, $run_node = undef) {
             $errors->{$id} = 'not in quorum';
         } elsif (!$nodes->{$id}) {
             $errors->{$id} = 'no hostname in monitor metadata';
-        } elsif (!eval { assert_member($nodes->{$id}, "mon.$id"); 1 }) {
-            $errors->{$id} = $@;
         } else {
-            $sessions = eval {
-                decode_json($run_node->(
-                    $nodes->{$id}, ['ceph', 'daemon', "mon.$id", 'sessions']));
-            };
+            $sessions =
+                eval { decode_json(mon_admin_run($nodes->{$id}, $id, ['sessions'], $run_node)) };
             my $error = $@;
             if ($error) {
                 $error =~ s/\s+/ /g;
@@ -6258,6 +6263,7 @@ sub key_migration_test_hooks {
         client_key_files => \&client_key_files,
         check_client_kernels => \&check_client_kernels,
         manual_promotion_with_retries => \&manual_promotion_with_retries,
+        mon_admin_run => \&mon_admin_run,
         describe_live => \&describe_live,
     };
 }
diff --git a/test/CephKeyMigrationScript_test.pl b/test/CephKeyMigrationScript_test.pl
index a53b1430..fa1cee4e 100755
--- a/test/CephKeyMigrationScript_test.pl
+++ b/test/CephKeyMigrationScript_test.pl
@@ -6595,7 +6595,16 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     $rados->{host} = 'foreign.invalid';
     @commands = ();
     $info = $HOOKS->{collect_cluster_info}->($rados, { 'rotate-lockbox-keys' => 1 }, {});
-    is_deeply(\@commands, [], 'non-member gets no commands during pre-preflight collection');
+    is(
+        scalar(grep { /foreign\.invalid/ } map { join(' ', @$_) } @commands),
+        0,
+        'pre-preflight collection sends nothing to the non-member host',
+    );
+    is(
+        scalar(grep { /tell mon\.a config get/ } map { join(' ', @$_) } @commands),
+        1,
+        'the monitor outside the cluster is asked with tell instead of its socket',
+    );
     like(
         $info->{lockbox}->{'client.osd-lockbox.osd-uuid'}->{missing},
         qr/osd\.7.*foreign\.invalid.*not a node/,
@@ -6604,10 +6613,14 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     @targets = ();
     $monitor = $HOOKS->{collect_monitor_state}->($rados, sub { push @targets, $_[0]; return '[]' });
     is_deeply(\@targets, [], 'fresh monitor collection never queries a non-member');
-    like(
+    is(
         $monitor->{sessions}->{errors}->{a},
-        qr/mon\.a.*not a node/,
-        'monitor diagnostic names membership',
+        undef,
+        'and its sessions come over the network, so nothing is left unanswered',
+    );
+    ok(
+        $monitor->{manual_promotion}->{supported},
+        'so a client key can still be staged with a monitor outside the cluster',
     );
 
     for my $version (1, 2) {
@@ -7837,6 +7850,47 @@ for my $case ([0, 0], [1, 0], [0, 1], [1, 1]) {
     );
 }
 
+# Whatever a monitor is asked about itself, the route depends on whether this cluster has a shell
+# on its host. 'ceph tell' reaches the same admin socket over the network for one that it does not.
+{
+    no warnings qw(once redefine);
+    local *PVE::SSHInfo::get_ssh_info = sub { return { node => $_[0] } };
+    local *PVE::SSHInfo::ssh_info_to_command = sub { return ['ssh', $_[0]->{node}, '--'] };
+
+    my @commands;
+    local *main::run_command = sub {
+        my ($cmd, %args) = @_;
+        push @commands, join(' ', @$cmd);
+        $args{outfunc}->('answer');
+    };
+
+    my $run = sub {
+        @commands = ();
+        return $HOOKS->{mon_admin_run}->(@_);
+    };
+
+    is($run->('node-a', 'a', ['sessions']), "answer\n", 'a member answers over its admin socket');
+    is($commands[0], 'ssh node-a -- ceph daemon mon.a sessions', 'which is asked through ssh');
+
+    is($run->('witness.invalid', 'tiebreaker', ['sessions']), "answer\n", 'so does an outsider');
+    like(
+        $commands[0],
+        qr/^ceph --cluster \S+ --name mon\. --keyring \S+ --connect-timeout 10 tell mon\.tiebreaker sessions$/,
+        'asked locally with tell, as mon., and with a connect timeout instead of a long default',
+    );
+
+    $run->(undef, 'nameless', ['version']);
+    like($commands[0], qr/tell mon\.nameless version/, 'a monitor without a known host too');
+
+    my @routed;
+    $run->('node-a', 'a', ['config', 'get', 'opt'], sub { push @routed, $_[1]; return '{}' });
+    is_deeply(
+        \@routed,
+        [['ceph', 'daemon', 'mon.a', 'config', 'get', 'opt']],
+        'a caller-supplied runner keeps the member route, which the tests rely on',
+    );
+}
+
 {
     my $out = '';
     {
-- 
2.47.3





  reply	other threads:[~2026-09-27  0:59 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-27  0:59 [PATCH manager 0/4] cephx: migrate keys with a monitor this cluster does not manage Kefu Chai
2026-09-27  0:59 ` Kefu Chai [this message]
2026-09-27  0:59 ` [PATCH manager 2/4] migrations: cephx: judge cipher support by the version a daemon runs Kefu Chai
2026-09-27  0:59 ` [PATCH manager 3/4] migrations: cephx: rotate the monitor key with monitors we do not manage Kefu Chai
2026-09-27  0:59 ` [PATCH manager 4/4] migrations: cephx: name the hosts keeping key copies we cannot write Kefu Chai

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=20260927005906.4184138-2-k.chai@proxmox.com \
    --to=k.chai@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.lamprecht@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