public inbox for pve-devel@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 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