]> git.proxmox.com Git - pve-cluster.git/blobdiff - data/PVE/Cluster.pm
refactor run_silent_cmd
[pve-cluster.git] / data / PVE / Cluster.pm
index 0a9dfa36b643c99181072ee88c28cfc3860e8a8b..f4dd1a3a1877254d015f9513cf4c1c3d372d5977 100644 (file)
@@ -11,12 +11,13 @@ use MIME::Base64;
 use Digest::SHA;
 use Digest::HMAC_SHA1;
 use Net::SSLeay;
-use PVE::Tools;
+use PVE::Tools qw(run_command);
 use PVE::INotify;
 use PVE::IPCC;
 use PVE::SafeSyslog;
 use PVE::JSONSchema;
 use PVE::Network;
+use PVE::Cluster::IPCConst;
 use JSON;
 use RRDs;
 use Encode;
@@ -37,6 +38,14 @@ my $basedir = "/etc/pve";
 my $authdir = "$basedir/priv";
 my $lockdir = "/etc/pve/priv/lock";
 
+# cfs and corosync files
+my $dbfile = "/var/lib/pve-cluster/config.db";
+my $dbbackupdir = "/var/lib/pve-cluster/backup";
+my $localclusterdir = "/etc/corosync";
+my $localclusterconf = "$localclusterdir/corosync.conf";
+my $authfile = "$localclusterdir/authkey";
+my $clusterconf = "$basedir/corosync.conf";
+
 my $authprivkeyfn = "$authdir/authkey.key";
 my $authpubkeyfn = "$basedir/authkey.pub";
 my $pveca_key_fn = "$authdir/pve-root-ca.key";
@@ -84,20 +93,11 @@ sub run_silent_cmd {
     my ($cmd) = @_;
 
     my $outbuf = '';
+    my $record = sub { $outbuf .= shift . "\n"; };
 
-    my $record_output = sub {
-       $outbuf .= shift;
-       $outbuf .= "\n";
-    };
-
-    eval {
-       PVE::Tools::run_command($cmd, outfunc => $record_output,
-                               errfunc => $record_output);
-    };
-
-    my $err = $@;
+    eval { run_command($cmd, outfunc => $record, errfunc => $record) };
 
-    if ($err) {
+    if (my $err = $@) {
        print STDERR $outbuf;
        die $err;
     }
@@ -400,7 +400,7 @@ my $ipcc_get_config = sub {
     my ($path) = @_;
 
     my $bindata = pack "Z*", $path;
-    my $res = PVE::IPCC::ipcc_send_rec(6, $bindata);
+    my $res = PVE::IPCC::ipcc_send_rec(CFS_IPC_GET_CONFIG, $bindata);
     if (!defined($res)) {
        if ($! != 0) {
            return undef if $! == ENOENT;
@@ -416,7 +416,7 @@ my $ipcc_get_status = sub {
     my ($name, $nodename) = @_;
 
     my $bindata = pack "Z[256]Z[256]", $name, ($nodename || "");
-    return PVE::IPCC::ipcc_send_rec(5, $bindata);
+    return PVE::IPCC::ipcc_send_rec(CFS_IPC_GET_STATUS, $bindata);
 };
 
 my $ipcc_update_status = sub {
@@ -426,7 +426,7 @@ my $ipcc_update_status = sub {
     # update status
     my $bindata = pack "Z[256]Z*", $name, $raw;
 
-    return &$ipcc_send_rec(4, $bindata);
+    return &$ipcc_send_rec(CFS_IPC_SET_STATUS, $bindata);
 };
 
 my $ipcc_log = sub {
@@ -435,7 +435,7 @@ my $ipcc_log = sub {
     my $bindata = pack "CCCZ*Z*Z*", $priority, bytes::length($ident) + 1,
     bytes::length($tag) + 1, $ident, $tag, $msg;
 
-    return &$ipcc_send_rec(7, $bindata);
+    return &$ipcc_send_rec(CFS_IPC_LOG_CLUSTER_MSG, $bindata);
 };
 
 my $ipcc_get_cluster_log = sub {
@@ -444,7 +444,7 @@ my $ipcc_get_cluster_log = sub {
     $max = 0 if !defined($max);
 
     my $bindata = pack "VVVVZ*", $max, 0, 0, 0, ($user || "");
-    return &$ipcc_send_rec(8, $bindata);
+    return &$ipcc_send_rec(CFS_IPC_GET_CLUSTER_LOG, $bindata);
 };
 
 my $ccache = {};
@@ -452,7 +452,7 @@ my $ccache = {};
 sub cfs_update {
     my ($fail) = @_;
     eval {
-       my $res = &$ipcc_send_rec_json(1);
+       my $res = &$ipcc_send_rec_json(CFS_IPC_GET_FS_VERSION);
        #warn "GOT1: " . Dumper($res);
        die "no starttime\n" if !$res->{starttime};
 
@@ -479,7 +479,7 @@ sub cfs_update {
     eval {
        if (!$clinfo->{version} || $clinfo->{version} != $versions->{clinfo}) {
            #warn "detected new clinfo\n";
-           $clinfo = &$ipcc_send_rec_json(2);
+           $clinfo = &$ipcc_send_rec_json(CFS_IPC_GET_CLUSTER_INFO);
        }
     };
     $err = $@;
@@ -492,7 +492,7 @@ sub cfs_update {
     eval {
        if (!$vmlist->{version} || $vmlist->{version} != $versions->{vmlist}) {
            #warn "detected new vmlist1\n";
-           $vmlist = &$ipcc_send_rec_json(3);
+           $vmlist = &$ipcc_send_rec_json(CFS_IPC_GET_GUEST_LIST);
        }
     };
     $err = $@;
@@ -609,7 +609,7 @@ sub rrd_dump {
 
     my $raw;
     eval {
-       $raw = &$ipcc_send_rec(10);
+       $raw = &$ipcc_send_rec(CFS_IPC_GET_RRD_DUMP);
     };
     my $err = $@;
 
@@ -865,7 +865,7 @@ my $cfs_lock = sub {
     my $res;
     my $got_lock = 0;
 
-    # this timeout is for aquire the lock
+    # this timeout is for acquire the lock
     $timeout = 10 if !$timeout;
 
     my $filename = "$lockdir/$lockid";
@@ -884,13 +884,13 @@ my $cfs_lock = sub {
        while (1) {
            alarm ($timeout);
            $got_lock = mkdir($filename);
-           $timeout = alarm(0);
+           $timeout = alarm(0) - 1; # we'll sleep for 1s, see down below
 
            last if $got_lock;
 
-           $timeout_err->() if $timeout == 0;
+           $timeout_err->() if $timeout <= 0;
 
-           print STDERR "trying to aquire cfs lock '$lockid' ...";
+           print STDERR "trying to acquire cfs lock '$lockid' ...\n";
            utime (0, 0, $filename); # cfs unlock request
            sleep(1);
        }
@@ -952,6 +952,14 @@ sub cfs_lock_domain {
     &$cfs_lock($lockid, $timeout, $code, @param);
 }
 
+sub cfs_lock_acme {
+    my ($account, $timeout, $code, @param) = @_;
+
+    my $lockid = "acme-$account";
+
+    &$cfs_lock($lockid, $timeout, $code, @param);
+}
+
 my $log_levels = {
     "emerg" => 0,
     "alert" => 1,
@@ -1115,7 +1123,7 @@ sub ssh_merge_keys {
 }
 
 sub setup_sshd_config {
-    my ($start_sshd) = @_;
+    my () = @_;
 
     my $conf = PVE::Tools::file_get_contents($sshd_config_fn);
 
@@ -1128,8 +1136,7 @@ sub setup_sshd_config {
 
     PVE::Tools::file_set_contents($sshd_config_fn, $conf);
 
-    my $cmd = $start_sshd ? 'reload-or-restart' : 'reload-or-try-restart';
-    PVE::Tools::run_command(['systemctl', $cmd, 'sshd']);
+    PVE::Tools::run_command(['systemctl', 'reload-or-restart', 'sshd']);
 }
 
 sub setup_rootsshconfig {
@@ -1344,7 +1351,27 @@ my $datacenter_schema = {
            optional => 1,
            type => 'string',
            description => "Default GUI language.",
-           enum => [ 'en', 'de' ],
+           enum => [
+               'zh_CN',
+               'zh_TW',
+               'ca',
+               'en',
+               'eu',
+               'fr',
+               'de',
+               'it',
+               'es',
+               'ja',
+               'nb',
+               'nn',
+               'fa',
+               'pl',
+               'pt_BR',
+               'ru',
+               'sl',
+               'sv',
+               'tr',
+           ],
        },
        http_proxy => {
            optional => 1,
@@ -1367,8 +1394,8 @@ my $datacenter_schema = {
        console => {
            optional => 1,
            type => 'string',
-           description => "Select the default Console viewer. You can either use the builtin java applet (VNC), an external virt-viewer comtatible application (SPICE), or an HTML5 based viewer (noVNC).",
-           enum => ['applet', 'vv', 'html5'],
+           description => "Select the default Console viewer. You can either use the builtin java applet (VNC; deprecated and maps to html5), an external virt-viewer comtatible application (SPICE), an HTML5 based vnc viewer (noVNC), or an HTML5 based console client (xtermjs). If the selected viewer is not available (e.g. SPICE not activated for the VM), the fallback is noVNC.",
+           enum => ['applet', 'vv', 'html5', 'xtermjs'],
        },
        email_from => {
            optional => 1,
@@ -1399,6 +1426,7 @@ my $datacenter_schema = {
            pattern => qr/[a-f0-9]{2}(?::[a-f0-9]{2}){0,2}:?/i,
            description => 'Prefix for autogenerated MAC addresses.',
        },
+       bwlimit => PVE::JSONSchema::get_standard_option('bwlimit'),
     },
 };
 
@@ -1424,6 +1452,11 @@ sub parse_datacenter_config {
        }
     }
 
+    # for backwards compatibility only, applet maps to html5
+    if (defined($res->{console}) && $res->{console} eq 'applet') {
+       $res->{console} = 'html5';
+    }
+
     return $res;
 }
 
@@ -1436,6 +1469,15 @@ sub write_datacenter_config {
        $cfg->{migration}->{type} = ($migration_unsecure) ? 'insecure' : 'secure';
     }
 
+    # map deprecated applet setting to html5
+    if (defined($cfg->{console}) && $cfg->{console} eq 'applet') {
+       $cfg->{console} = 'html5';
+    }
+
+    if (my $migration = $cfg->{migration}) {
+       $cfg->{migration} = PVE::JSONSchema::print_property_string($migration, $migration_format);
+    }
+
     return PVE::JSONSchema::dump_config($datacenter_schema, $filename, $cfg);
 }
 
@@ -1470,29 +1512,9 @@ sub update_cert_cache {
            }
        };
 
-       my $cert_path = "/etc/pve/nodes/$node/pve-ssl.pem";
-       my $custom_cert_path = "/etc/pve/nodes/$node/pveproxy-ssl.pem";
-
-       $cert_path = $custom_cert_path if -f $custom_cert_path;
-
-       my $cert;
-       eval {
-           my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r');
-           $cert = Net::SSLeay::PEM_read_bio_X509($bio);
-           Net::SSLeay::BIO_free($bio);
-       };
-       my $err = $@;
-       if ($err || !defined($cert)) {
-           &$clear_old() if $clear;
-           next;
-       }
-
-       my $fp;
-       eval {
-           $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
-       };
-       $err = $@;
-       if ($err || !defined($fp) || $fp eq '') {
+       my $fp = eval { get_node_fingerprint($node) };
+       if (my $err = $@) {
+           warn "$err\n";
            &$clear_old() if $clear;
            next;
        }
@@ -1515,6 +1537,38 @@ sub initialize_cert_cache {
        if defined($node) && !defined($cert_cache_nodes->{$node});
 }
 
+sub read_ssl_cert_fingerprint {
+    my ($cert_path) = @_;
+
+    my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r')
+       or die "unable to read '$cert_path' - $!\n";
+
+    my $cert = Net::SSLeay::PEM_read_bio_X509($bio);
+    Net::SSLeay::BIO_free($bio);
+
+    die "unable to read certificate from '$cert_path'\n" if !$cert;
+
+    my $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
+    Net::SSLeay::X509_free($cert);
+
+    die "unable to get fingerprint for '$cert_path' - got empty value\n"
+       if !defined($fp) || $fp eq '';
+
+    return $fp;
+}
+
+sub get_node_fingerprint {
+    my ($node) = @_;
+
+    my $cert_path = "/etc/pve/nodes/$node/pve-ssl.pem";
+    my $custom_cert_path = "/etc/pve/nodes/$node/pveproxy-ssl.pem";
+
+    $cert_path = $custom_cert_path if -f $custom_cert_path;
+
+    return read_ssl_cert_fingerprint($cert_path);
+}
+
+
 sub check_cert_fingerprint {
     my ($cert) = @_;
 
@@ -1522,11 +1576,8 @@ sub check_cert_fingerprint {
     update_cert_cache(undef, 1) if time() - $cert_cache_timestamp >= 60*30;
 
     # get fingerprint of server certificate
-    my $fp;
-    eval {
-       $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
-    };
-    return 0 if $@ || !defined($fp) || $fp eq ''; # error
+    my $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
+    return 0 if !defined($fp) || $fp eq ''; # error
 
     my $check = sub {
        for my $expected (keys %$cert_cache_fingerprints) {
@@ -1646,6 +1697,7 @@ sub ssh_info_to_command_base {
     my ($info, @extra_options) = @_;
     return [
        '/usr/bin/ssh',
+       '-e', 'none',
        '-o', 'BatchMode=yes',
        '-o', 'HostKeyAlias='.$info->{name},
        @extra_options
@@ -1659,4 +1711,206 @@ sub ssh_info_to_command {
     return $cmd;
 }
 
+sub assert_joinable {
+    my ($ring0_addr, $ring1_addr, $force) = @_;
+
+    my $errors = '';
+    my $error = sub { $errors .= "* $_[0]\n"; };
+
+    if (-f $authfile) {
+       $error->("authentication key '$authfile' already exists");
+    }
+
+    if (-f $clusterconf)  {
+       $error->("cluster config '$clusterconf' already exists");
+    }
+
+    my $vmlist = get_vmlist();
+    if ($vmlist && $vmlist->{ids} && scalar(keys %{$vmlist->{ids}})) {
+       $error->("this host already contains virtual guests");
+    }
+
+    if (run_command(['corosync-quorumtool', '-l'], noerr => 1, quiet => 1) == 0) {
+       $error->("corosync is already running, is this node already in a cluster?!");
+    }
+
+    # check if corosync ring IPs are configured on the current nodes interfaces
+    my $check_ip = sub {
+       my $ip = shift // return;
+       if (!PVE::JSONSchema::pve_verify_ip($ip, 1)) {
+           my $host = $ip;
+           eval { $ip = PVE::Network::get_ip_from_hostname($host); };
+           if ($@) {
+               $error->("cannot use '$host': $@\n") ;
+               return;
+           }
+       }
+
+       my $cidr = (Net::IP::ip_is_ipv6($ip)) ? "$ip/128" : "$ip/32";
+       my $configured_ips = PVE::Network::get_local_ip_from_cidr($cidr);
+
+       $error->("cannot use IP '$ip', it must be configured exactly once on local node!\n")
+           if (scalar(@$configured_ips) != 1);
+    };
+
+    $check_ip->($ring0_addr);
+    $check_ip->($ring1_addr);
+
+    if ($errors) {
+       warn "detected the following error(s):\n$errors";
+       die "Check if node may join a cluster failed!\n" if !$force;
+    }
+}
+
+# NOTE: filesystem must be offline here, no DB changes allowed
+my $backup_cfs_database = sub {
+    my ($dbfile) = @_;
+
+    mkdir $dbbackupdir;
+
+    my $ctime = time();
+    my $backup_fn = "$dbbackupdir/config-$ctime.sql.gz";
+
+    print "backup old database to '$backup_fn'\n";
+
+    my $cmd = [ ['sqlite3', $dbfile, '.dump'], ['gzip', '-', \ ">${backup_fn}"] ];
+    run_command($cmd, 'errmsg' => "cannot backup old database\n");
+
+    my $maxfiles = 10; # purge older backup
+    my $backups = [ sort { $b cmp $a } <$dbbackupdir/config-*.sql.gz> ];
+
+    if ((my $count = scalar(@$backups)) > $maxfiles) {
+       foreach my $f (@$backups[$maxfiles..$count-1]) {
+           next if $f !~ m/^(\S+)$/; # untaint
+           print "delete old backup '$1'\n";
+           unlink $1;
+       }
+    }
+};
+
+sub join {
+    my ($param) = @_;
+
+    my $nodename = PVE::INotify::nodename();
+
+    setup_sshd_config();
+    setup_rootsshconfig();
+    setup_ssh_keys();
+
+    # check if we can join with the given parameters and current node state
+    my ($ring0_addr, $ring1_addr) = $param->@{'ring0_addr', 'ring1_addr'};
+    assert_joinable($ring0_addr, $ring1_addr, $param->{force});
+
+    # make sure known_hosts is on local filesystem
+    ssh_unmerge_known_hosts();
+
+    my $host = $param->{hostname};
+    my $local_ip_address = remote_node_ip($nodename);
+
+    my $conn_args = {
+       username => 'root@pam',
+       password => $param->{password},
+       cookie_name => 'PVEAuthCookie',
+       protocol => 'https',
+       host => $host,
+       port => 8006,
+    };
+
+    if (my $fp = $param->{fingerprint}) {
+       $conn_args->{cached_fingerprints} = { uc($fp) => 1 };
+    } else {
+       # API schema ensures that we can only get here from CLI handler
+       $conn_args->{manual_verification} = 1;
+    }
+
+    print "Establishing API connection with host '$host'\n";
+
+    my $conn = PVE::APIClient::LWP->new(%$conn_args);
+    $conn->login();
+
+    # login raises an exception on failure, so if we get here we're good
+    print "Login succeeded.\n";
+
+    my $args = {};
+    $args->{force} = $param->{force} if defined($param->{force});
+    $args->{nodeid} = $param->{nodeid} if $param->{nodeid};
+    $args->{votes} = $param->{votes} if defined($param->{votes});
+    $args->{ring0_addr} = $ring0_addr // $local_ip_address;
+    $args->{ring1_addr} = $ring1_addr if defined($ring1_addr);
+
+    print "Request addition of this node\n";
+    my $res = $conn->post("/cluster/config/nodes/$nodename", $args);
+
+    print "Join request OK, finishing setup locally\n";
+
+    # added successfuly - now prepare local node
+    finish_join($nodename, $res->{corosync_conf}, $res->{corosync_authkey});
+}
+
+sub finish_join {
+    my ($nodename, $corosync_conf, $corosync_authkey) = @_;
+
+    mkdir "$localclusterdir";
+    PVE::Tools::file_set_contents($authfile, $corosync_authkey);
+    PVE::Tools::file_set_contents($localclusterconf, $corosync_conf);
+
+    print "stopping pve-cluster service\n";
+    my $cmd = ['systemctl', 'stop', 'pve-cluster'];
+    run_command($cmd, errmsg => "can't stop pve-cluster service");
+
+    $backup_cfs_database->($dbfile);
+    unlink $dbfile;
+
+    $cmd = ['systemctl', 'start', 'corosync', 'pve-cluster'];
+    run_command($cmd, errmsg => "starting pve-cluster failed");
+
+    # wait for quorum
+    my $printqmsg = 1;
+    while (!check_cfs_quorum(1)) {
+       if ($printqmsg) {
+           print "waiting for quorum...";
+           STDOUT->flush();
+           $printqmsg = 0;
+       }
+       sleep(1);
+    }
+    print "OK\n" if !$printqmsg;
+
+    updatecerts_and_ssh(1);
+
+    print "generated new node certificate, restart pveproxy and pvedaemon services\n";
+    run_command(['systemctl', 'reload-or-restart', 'pvedaemon', 'pveproxy']);
+
+    print "successfully added node '$nodename' to cluster.\n";
+}
+
+sub updatecerts_and_ssh {
+    my ($force_new_cert, $silent) = @_;
+
+    my $p = sub { print "$_[0]\n" if !$silent };
+
+    setup_rootsshconfig();
+
+    gen_pve_vzdump_symlink();
+
+    if (!check_cfs_quorum(1)) {
+       return undef if $silent;
+       die "no quorum - unable to update files\n";
+    }
+
+    setup_ssh_keys();
+
+    my $nodename = PVE::INotify::nodename();
+    my $local_ip_address = remote_node_ip($nodename);
+
+    $p->("(re)generate node files");
+    $p->("generate new node certificate") if $force_new_cert;
+    gen_pve_node_files($nodename, $local_ip_address, $force_new_cert);
+
+    $p->("merge authorized SSH keys and known hosts");
+    ssh_merge_keys();
+    ssh_merge_known_hosts($nodename, $local_ip_address, 1);
+    gen_pve_vzdump_files();
+}
+
 1;