1 package PVE
::Cluster
::Setup
;
14 use PVE
::APIClient
::LWP
;
23 # Only relevant for pre-join checks, after join happened versions can differ
24 use constant JOIN_API_VERSION
=> 1;
25 # (APIVER-this) is oldest version a new node in addnode can have to be accepted
26 use constant JOIN_API_AGE_AS_CLUSTER
=> 1;
27 # (APIVER-this) is oldest version a cluster node can have to still try joining
28 use constant JOIN_API_AGE_AS_JOINEE
=> 1;
30 my $pmxcfs_base_dir = PVE
::Cluster
::base_dir
();
31 my $pmxcfs_auth_dir = PVE
::Cluster
::auth_dir
();
33 # only write output if something fails
38 my $record = sub { $outbuf .= shift . "\n"; };
40 eval { PVE
::Tools
::run_command
($cmd, outfunc
=> $record, errfunc
=> $record) };
48 # Corosync related files
49 my $localclusterdir = "/etc/corosync";
50 my $localclusterconf = "$localclusterdir/corosync.conf";
51 my $authfile = "$localclusterdir/authkey";
52 my $clusterconf = "$pmxcfs_base_dir/corosync.conf";
54 # CA/certificate related files
55 my $pveca_key_fn = "$pmxcfs_auth_dir/pve-root-ca.key";
56 my $pveca_srl_fn = "$pmxcfs_auth_dir/pve-root-ca.srl";
57 my $pveca_cert_fn = "$pmxcfs_base_dir/pve-root-ca.pem";
58 # this is just a secret accessable by the web browser
59 # and is used for CSRF prevention
60 my $pvewww_key_fn = "$pmxcfs_base_dir/pve-www.key";
63 my $ssh_rsa_id_priv = "/root/.ssh/id_rsa";
64 my $ssh_rsa_id = "/root/.ssh/id_rsa.pub";
65 my $ssh_host_rsa_id = "/etc/ssh/ssh_host_rsa_key.pub";
66 my $sshglobalknownhosts = "/etc/ssh/ssh_known_hosts";
67 my $sshknownhosts = "$pmxcfs_auth_dir/known_hosts";
68 my $sshauthkeys = "$pmxcfs_auth_dir/authorized_keys";
69 my $sshd_config_fn = "/etc/ssh/sshd_config";
70 my $rootsshauthkeys = "/root/.ssh/authorized_keys";
71 my $rootsshauthkeysbackup = "${rootsshauthkeys}.org";
72 my $rootsshconfig = "/root/.ssh/config";
74 # ssh related utility functions
77 # remove duplicate keys in $sshauthkeys
78 # ssh-copy-id simply add keys, so the file can grow to large
81 if (-f
$sshauthkeys) {
82 $data = PVE
::Tools
::file_get_contents
($sshauthkeys, 128*1024);
87 if (-f
$rootsshauthkeysbackup) {
89 $data .= PVE
::Tools
::file_get_contents
($rootsshauthkeysbackup, 128*1024);
96 my $pub = PVE
::Tools
::file_get_contents
($ssh_rsa_id);
103 my @lines = split(/\n/, $data);
104 foreach my $line (@lines) {
105 if ($line !~ /^#/ && $line =~ m/(^|\s)ssh-(rsa|dsa)\s+(\S+)\s+\S+$/) {
106 next if $vhash->{$3}++;
108 $newdata .= "$line\n";
111 PVE
::Tools
::file_set_contents
($sshauthkeys, $newdata, 0600);
113 if ($found_backup && -l
$rootsshauthkeys) {
114 # everything went well, so we can remove the backup
115 unlink $rootsshauthkeysbackup;
119 sub setup_sshd_config
{
122 my $conf = PVE
::Tools
::file_get_contents
($sshd_config_fn);
124 return if $conf =~ m/^PermitRootLogin\s+yes\s*$/m;
126 if ($conf !~ s/^#?PermitRootLogin.*$/PermitRootLogin yes/m) {
128 $conf .= "\nPermitRootLogin yes\n";
131 PVE
::Tools
::file_set_contents
($sshd_config_fn, $conf);
133 PVE
::Tools
::run_command
(['systemctl', 'reload-or-restart', 'sshd']);
136 sub setup_rootsshconfig
{
138 # create ssh key if it does not exist
139 if (! -f
$ssh_rsa_id) {
141 system ("echo|ssh-keygen -t rsa -N '' -b 2048 -f ${ssh_rsa_id_priv}");
144 # create ssh config if it does not exist
145 if (! -f
$rootsshconfig) {
147 if (my $fh = IO
::File-
>new($rootsshconfig, O_CREAT
|O_WRONLY
|O_EXCL
, 0640)) {
148 # this is the default ciphers list from Debian's OpenSSH package (OpenSSH_7.4p1 Debian-10, OpenSSL 1.0.2k 26 Jan 2017)
149 # changed order to put AES before Chacha20 (most hardware has AESNI)
150 print $fh "Ciphers aes128-ctr,aes192-ctr,aes256-ctr,aes128-gcm\@openssh.com,aes256-gcm\@openssh.com,chacha20-poly1305\@openssh.com\n";
158 mkdir $pmxcfs_auth_dir;
162 if (! -f
$sshauthkeys) {
164 if (-f
$rootsshauthkeys) {
165 $old = PVE
::Tools
::file_get_contents
($rootsshauthkeys, 128*1024);
167 if (my $fh = IO
::File-
>new ($sshauthkeys, O_CREAT
|O_WRONLY
|O_EXCL
, 0400)) {
168 PVE
::Tools
::safe_print
($sshauthkeys, $fh, $old) if $old;
174 warn "can't create shared ssh key database '$sshauthkeys'\n"
175 if ! -f
$sshauthkeys;
177 if (-f
$rootsshauthkeys && ! -l
$rootsshauthkeys) {
178 if (!rename($rootsshauthkeys , $rootsshauthkeysbackup)) {
179 warn "rename $rootsshauthkeys failed - $!\n";
183 if (! -l
$rootsshauthkeys) {
184 symlink $sshauthkeys, $rootsshauthkeys;
187 if (! -l
$rootsshauthkeys) {
188 warn "can't create symlink for ssh keys '$rootsshauthkeys' -> '$sshauthkeys'\n";
190 unlink $rootsshauthkeysbackup if $import_ok;
194 sub ssh_unmerge_known_hosts
{
195 return if ! -l
$sshglobalknownhosts;
198 $old = PVE
::Tools
::file_get_contents
($sshknownhosts, 128*1024)
199 if -f
$sshknownhosts;
201 PVE
::Tools
::file_set_contents
($sshglobalknownhosts, $old);
204 sub ssh_merge_known_hosts
{
205 my ($nodename, $ip_address, $createLink) = @_;
207 die "no node name specified" if !$nodename;
208 die "no ip address specified" if !$ip_address;
210 # ssh lowercases hostnames (aliases) before comparision, so we need too
211 $nodename = lc($nodename);
212 $ip_address = lc($ip_address);
214 mkdir $pmxcfs_auth_dir;
216 if (! -f
$sshknownhosts) {
217 if (my $fh = IO
::File-
>new($sshknownhosts, O_CREAT
|O_WRONLY
|O_EXCL
, 0600)) {
222 my $old = PVE
::Tools
::file_get_contents
($sshknownhosts, 128*1024);
226 if ((! -l
$sshglobalknownhosts) && (-f
$sshglobalknownhosts)) {
227 $new = PVE
::Tools
::file_get_contents
($sshglobalknownhosts, 128*1024);
230 my $hostkey = PVE
::Tools
::file_get_contents
($ssh_host_rsa_id);
231 # Note: file sometimes containe emty lines at start, so we use multiline match
232 die "can't parse $ssh_host_rsa_id" if $hostkey !~ m/^(ssh-rsa\s\S+)(\s.*)?$/m;
241 my $merge_line = sub {
242 my ($line, $all) = @_;
244 return if $line =~ m/^\s*$/; # skip empty lines
245 return if $line =~ m/^#/; # skip comments
247 if ($line =~ m/^(\S+)\s(ssh-rsa\s\S+)(\s.*)?$/) {
250 if (!$vhash->{$key}) {
252 if ($key =~ m/\|1\|([^\|\s]+)\|([^\|\s]+)$/) {
253 my $salt = decode_base64
($1);
255 my $hmac = Digest
::HMAC_SHA1-
>new($salt);
256 $hmac->add($nodename);
257 my $hd = $hmac->b64digest . '=';
258 if ($digest eq $hd) {
259 if ($rsakey eq $hostkey) {
265 $hmac = Digest
::HMAC_SHA1-
>new($salt);
266 $hmac->add($ip_address);
267 $hd = $hmac->b64digest . '=';
268 if ($digest eq $hd) {
269 if ($rsakey eq $hostkey) {
276 $key = lc($key); # avoid duplicate entries, ssh compares lowercased
277 if ($key eq $ip_address) {
278 $found_local_ip = 1 if $rsakey eq $hostkey;
279 } elsif ($key eq $nodename) {
280 $found_nodename = 1 if $rsakey eq $hostkey;
290 while ($old && $old =~ s/^((.*?)(\n|$))//) {
292 &$merge_line($line, 1);
295 while ($new && $new =~ s/^((.*?)(\n|$))//) {
300 # add our own key if not already there
301 $data .= "$nodename $hostkey\n" if !$found_nodename;
302 $data .= "$ip_address $hostkey\n" if !$found_local_ip;
304 PVE
::Tools
::file_set_contents
($sshknownhosts, $data);
306 return if !$createLink;
308 unlink $sshglobalknownhosts;
309 symlink $sshknownhosts, $sshglobalknownhosts;
311 warn "can't create symlink for ssh known hosts '$sshglobalknownhosts' -> '$sshknownhosts'\n"
312 if ! -l
$sshglobalknownhosts;
316 # directory and file creation
321 PVE
::Cluster
::check_cfs_is_mounted
();
323 my @required_dirs = (
324 "$pmxcfs_base_dir/priv",
325 "$pmxcfs_base_dir/nodes",
326 "$pmxcfs_base_dir/nodes/$nodename",
327 "$pmxcfs_base_dir/nodes/$nodename/lxc",
328 "$pmxcfs_base_dir/nodes/$nodename/qemu-server",
329 "$pmxcfs_base_dir/nodes/$nodename/openvz",
330 "$pmxcfs_base_dir/nodes/$nodename/priv");
332 foreach my $dir (@required_dirs) {
334 mkdir($dir) || $! == EEXIST
|| die "unable to create directory '$dir' - $!\n";
340 my $authprivkeyfn = "$pmxcfs_auth_dir/authkey.key";
341 my $authpubkeyfn = "$pmxcfs_base_dir/authkey.pub";
343 return if -f
"$authprivkeyfn";
345 PVE
::Cluster
::check_cfs_is_mounted
();
347 PVE
::Cluster
::cfs_lock_authkey
(undef, sub {
348 mkdir $pmxcfs_auth_dir || $! == EEXIST
|| die "unable to create dir '$pmxcfs_auth_dir' - $!\n";
350 run_silent_cmd
(['openssl', 'genrsa', '-out', $authprivkeyfn, '2048']);
352 run_silent_cmd
(['openssl', 'rsa', '-in', $authprivkeyfn, '-pubout', '-out', $authpubkeyfn]);
360 return if -f
$pveca_key_fn;
363 run_silent_cmd
(['openssl', 'genrsa', '-out', $pveca_key_fn, '4096']);
366 die "unable to generate pve ca key:\n$@" if $@;
371 if (-f
$pveca_key_fn && -f
$pveca_cert_fn) {
377 # we try to generate an unique 'subject' to avoid browser problems
378 # (reused serial numbers, ..)
380 UUID
::generate
($uuid);
382 UUID
::unparse
($uuid, $uuid_str);
385 # wrap openssl with faketime to prevent bug #904
386 run_silent_cmd
(['faketime', 'yesterday', 'openssl', 'req', '-batch',
387 '-days', '3650', '-new', '-x509', '-nodes', '-key',
388 $pveca_key_fn, '-out', $pveca_cert_fn, '-subj',
389 "/CN=Proxmox Virtual Environment/OU=$uuid_str/O=PVE Cluster Manager CA/"]);
392 die "generating pve root certificate failed:\n$@" if $@;
397 sub gen_pve_ssl_key
{
400 die "no node name specified" if !$nodename;
402 my $pvessl_key_fn = "$pmxcfs_base_dir/nodes/$nodename/pve-ssl.key";
404 return if -f
$pvessl_key_fn;
407 run_silent_cmd
(['openssl', 'genrsa', '-out', $pvessl_key_fn, '2048']);
410 die "unable to generate pve ssl key for node '$nodename':\n$@" if $@;
413 sub gen_pve_www_key
{
415 return if -f
$pvewww_key_fn;
418 run_silent_cmd
(['openssl', 'genrsa', '-out', $pvewww_key_fn, '2048']);
421 die "unable to generate pve www key:\n$@" if $@;
427 PVE
::Tools
::file_set_contents
($pveca_srl_fn, $serial);
430 sub gen_pve_ssl_cert
{
431 my ($force, $nodename, $ip) = @_;
433 die "no node name specified" if !$nodename;
434 die "no IP specified" if !$ip;
436 my $pvessl_cert_fn = "$pmxcfs_base_dir/nodes/$nodename/pve-ssl.pem";
438 return if !$force && -f
$pvessl_cert_fn;
440 my $names = "IP:127.0.0.1,IP:::1,DNS:localhost";
442 my $rc = PVE
::INotify
::read_file
('resolvconf');
446 my $fqdn = $nodename;
448 $names .= ",DNS:$nodename";
450 if ($rc && $rc->{search
}) {
451 $fqdn = $nodename . "." . $rc->{search
};
452 $names .= ",DNS:$fqdn";
455 my $sslconf = <<__EOD;
456 RANDFILE = /root/.rnd
461 distinguished_name = req_distinguished_name
462 req_extensions = v3_req
464 string_mask = nombstr
466 [ req_distinguished_name ]
467 organizationalUnitName = PVE Cluster Node
468 organizationName = Proxmox Virtual Environment
472 basicConstraints = CA:FALSE
473 extendedKeyUsage = serverAuth
474 subjectAltName = $names
477 my $cfgfn = "/tmp/pvesslconf-$$.tmp";
478 my $fh = IO
::File-
>new ($cfgfn, "w");
482 my $reqfn = "/tmp/pvecertreq-$$.tmp";
485 my $pvessl_key_fn = "$pmxcfs_base_dir/nodes/$nodename/pve-ssl.key";
487 run_silent_cmd
(['openssl', 'req', '-batch', '-new', '-config', $cfgfn,
488 '-key', $pvessl_key_fn, '-out', $reqfn]);
494 die "unable to generate pve certificate request:\n$err";
497 update_serial
("0000000000000000") if ! -f
$pveca_srl_fn;
500 my $cainfo = PVE
::Certificate
::get_certificate_info
($pveca_cert_fn);
501 my $daysleft = int(($cainfo->{notafter
} - time())/(24*60*60));
503 if ($daysleft < 14) {
504 die "CA expires in less than 2 weeks, unable to generate certificate.\n";
507 # let the certificate expire a little sooner that the ca, so subtract 2 days
510 # we want the certificates to only last 2 years, since some browsers
511 # do not accept certificates with very long expiry time
512 if ($daysleft >= 2*365) {
517 # wrap openssl with faketime to prevent bug #904
518 run_silent_cmd
(['faketime', 'yesterday', 'openssl', 'x509', '-req',
519 '-in', $reqfn, '-days', $daysleft, '-out', $pvessl_cert_fn,
520 '-CAkey', $pveca_key_fn, '-CA', $pveca_cert_fn,
521 '-CAserial', $pveca_srl_fn, '-extfile', $cfgfn]);
527 die "unable to generate pve ssl certificate:\n$err";
534 sub gen_pve_node_files
{
535 my ($nodename, $ip, $opt_force) = @_;
537 gen_local_dirs
($nodename);
541 # make sure we have a (cluster wide) secret
542 # for CSRFR prevention
545 # make sure we have a (per node) private key
546 gen_pve_ssl_key
($nodename);
548 # make sure we have a CA
549 my $force = gen_pveca_cert
();
551 $force = 1 if $opt_force;
553 gen_pve_ssl_cert
($force, $nodename, $ip);
556 my $vzdump_cron_dummy = <<__EOD;
557 # cluster wide vzdump cron schedule
558 # Atomatically generated file - do not edit
560 PATH="/usr/sbin:/usr/bin:/sbin:/bin"
564 sub gen_pve_vzdump_symlink
{
566 my $filename = "/etc/pve/vzdump.cron";
568 my $link_fn = "/etc/cron.d/vzdump";
570 if ((-f
$filename) && (! -l
$link_fn)) {
571 rename($link_fn, "/root/etc_cron_vzdump.org"); # make backup if file exists
572 symlink($filename, $link_fn);
576 sub gen_pve_vzdump_files
{
578 my $filename = "/etc/pve/vzdump.cron";
580 PVE
::Tools
::file_set_contents
($filename, $vzdump_cron_dummy)
583 gen_pve_vzdump_symlink
();
588 sub assert_joinable
{
589 my ($local_addr, $links, $force) = @_;
592 my $error = sub { $errors .= "* $_[0]\n"; };
595 $error->("authentication key '$authfile' already exists");
598 if (-f
$clusterconf) {
599 $error->("cluster config '$clusterconf' already exists");
602 my $vmlist = PVE
::Cluster
::get_vmlist
();
603 if ($vmlist && $vmlist->{ids
} && scalar(keys %{$vmlist->{ids
}})) {
604 $error->("this host already contains virtual guests");
607 if (PVE
::Tools
::run_command
(['corosync-quorumtool', '-l'], noerr
=> 1, quiet
=> 1) == 0) {
608 $error->("corosync is already running, is this node already in a cluster?!");
611 # check if corosync ring IPs are configured on the current nodes interfaces
613 my $ip = shift // return;
615 if (!PVE
::JSONSchema
::pve_verify_ip
($ip, 1)) {
617 eval { $ip = PVE
::Network
::get_ip_from_hostname
($host); };
619 $error->("$logid: cannot use '$host': $@\n") ;
624 my $cidr = (Net
::IP
::ip_is_ipv6
($ip)) ?
"$ip/128" : "$ip/32";
625 my $configured_ips = PVE
::Network
::get_local_ip_from_cidr
($cidr);
627 $error->("$logid: cannot use IP '$ip', not found on local node!\n")
628 if scalar(@$configured_ips) < 1;
631 $check_ip->($local_addr, 'local node address');
633 foreach my $link (keys %$links) {
634 $check_ip->($links->{$link}->{address
}, "link$link");
638 warn "detected the following error(s):\n$errors";
639 die "Check if node may join a cluster failed!\n" if !$force;
640 warn "\nWARNING : detected error but forced to continue!\n\n";
647 my $nodename = PVE
::INotify
::nodename
();
648 my $local_ip_address = PVE
::Cluster
::remote_node_ip
($nodename);
650 my $links = PVE
::Corosync
::extract_corosync_link_args
($param);
652 # check if we can join with the given parameters and current node state
653 assert_joinable
($local_ip_address, $links, $param->{force
});
656 setup_rootsshconfig
();
659 # make sure known_hosts is on local filesystem
660 ssh_unmerge_known_hosts
();
662 my $host = $param->{hostname
};
664 username
=> 'root@pam',
665 password
=> $param->{password
},
666 cookie_name
=> 'PVEAuthCookie',
672 if (my $fp = $param->{fingerprint
}) {
673 $conn_args->{cached_fingerprints
} = { uc($fp) => 1 };
675 # API schema ensures that we can only get here from CLI handler
676 $conn_args->{manual_verification
} = 1;
679 print "Establishing API connection with host '$host'\n";
681 my $conn = PVE
::APIClient
::LWP-
>new(%$conn_args);
684 # login raises an exception on failure, so if we get here we're good
685 print "Login succeeded.\n";
687 # check cluster join API version
688 my $apiver = eval { $conn->get("/cluster/config/apiversion") } // 0;
690 if ($apiver < (JOIN_API_VERSION
- JOIN_API_AGE_AS_JOINEE
)) {
691 die "error: incompatible join API version on cluster ($apiver, local has "
692 . JOIN_API_VERSION
. "). Make sure all nodes are up-to-date.\n";
696 $args->{force
} = $param->{force
} if defined($param->{force
});
697 $args->{nodeid
} = $param->{nodeid
} if $param->{nodeid
};
698 $args->{votes
} = $param->{votes
} if defined($param->{votes
});
699 foreach my $link (keys %$links) {
700 $args->{"link$link"} = PVE
::Corosync
::print_corosync_link
($links->{$link});
703 # this will be used as fallback if no links are specified
705 $args->{link0
} = $local_ip_address if $apiver == 0;
706 $args->{new_node_ip
} = $local_ip_address if $apiver >= 1;
708 print "No cluster network links passed explicitly, fallback to local node"
709 . " IP '$local_ip_address'\n";
713 $args->{apiversion
} = JOIN_API_VERSION
;
716 print "Request addition of this node\n";
717 my $res = eval { $conn->post("/cluster/config/nodes/$nodename", $args); };
719 if (ref($err) && $err->isa('PVE::APIClient::Exception')) {
720 # we received additional info about the error, show the user
722 warn "An error occured on the cluster node: $err->{msg}\n";
723 foreach my $key (sort keys %{$err->{errors
}}) {
724 my $symbol = ($key =~ m/^warning/) ?
'*' : '!';
725 warn "$symbol $err->{errors}->{$key}\n";
728 die "Cluster join aborted!\n";
734 if (defined($res->{warnings
})) {
735 foreach my $warn (@{$res->{warnings
}}) {
736 warn "cluster: $warn\n";
740 print "Join request OK, finishing setup locally\n";
742 # added successfuly - now prepare local node
743 finish_join
($nodename, $res->{corosync_conf
}, $res->{corosync_authkey
});
747 my ($nodename, $corosync_conf, $corosync_authkey) = @_;
749 mkdir "$localclusterdir";
750 PVE
::Tools
::file_set_contents
($authfile, $corosync_authkey);
751 PVE
::Tools
::file_set_contents
($localclusterconf, $corosync_conf);
753 print "stopping pve-cluster service\n";
754 my $cmd = ['systemctl', 'stop', 'pve-cluster'];
755 PVE
::Tools
::run_command
($cmd, errmsg
=> "can't stop pve-cluster service");
757 my $dbfile = PVE
::Cluster
::cfs_backup_database
();
760 $cmd = ['systemctl', 'start', 'corosync', 'pve-cluster'];
761 PVE
::Tools
::run_command
($cmd, errmsg
=> "starting pve-cluster failed");
765 while (!PVE
::Cluster
::check_cfs_quorum
(1)) {
767 print "waiting for quorum...";
773 print "OK\n" if !$printqmsg;
775 updatecerts_and_ssh
(1);
777 print "generated new node certificate, restart pveproxy and pvedaemon services\n";
778 PVE
::Tools
::run_command
(['systemctl', 'reload-or-restart', 'pvedaemon', 'pveproxy']);
780 print "successfully added node '$nodename' to cluster.\n";
783 sub updatecerts_and_ssh
{
784 my ($force_new_cert, $silent) = @_;
786 my $p = sub { print "$_[0]\n" if !$silent };
788 setup_rootsshconfig
();
790 gen_pve_vzdump_symlink
();
792 if (!PVE
::Cluster
::check_cfs_quorum
(1)) {
793 return undef if $silent;
794 die "no quorum - unable to update files\n";
799 my $nodename = PVE
::INotify
::nodename
();
800 my $local_ip_address = PVE
::Cluster
::remote_node_ip
($nodename);
802 $p->("(re)generate node files");
803 $p->("generate new node certificate") if $force_new_cert;
804 gen_pve_node_files
($nodename, $local_ip_address, $force_new_cert);
806 $p->("merge authorized SSH keys and known hosts");
808 ssh_merge_known_hosts
($nodename, $local_ip_address, 1);
809 gen_pve_vzdump_files
();