]> git.proxmox.com Git - pve-cluster.git/blobdiff - data/PVE/Cluster.pm
api/cluster: add join endpoint
[pve-cluster.git] / data / PVE / Cluster.pm
index 70ce250a0095dd2f40151d446e42c11fc80ff958..c515fa974cecb48509740be928c990f4712aaa51 100644 (file)
@@ -11,7 +11,7 @@ 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;
@@ -37,6 +37,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";
@@ -860,44 +868,45 @@ sub cfs_write_file {
 my $cfs_lock = sub {
     my ($lockid, $timeout, $code, @param) = @_;
 
+    my $prev_alarm = alarm(0); # suspend outer alarm early
+
     my $res;
+    my $got_lock = 0;
 
     # this timeout is for aquire the lock
     $timeout = 10 if !$timeout;
 
     my $filename = "$lockdir/$lockid";
 
-    my $msg = "can't aquire cfs lock '$lockid'";
-
     eval {
 
        mkdir $lockdir;
 
        if (! -d $lockdir) {
-           die "$msg: pve cluster filesystem not online.\n";
+           die "pve cluster filesystem not online.\n";
        }
 
-        local $SIG{ALRM} = sub { die "got lock request timeout\n"; };
+       my $timeout_err = sub { die "got lock request timeout\n"; };
+       local $SIG{ALRM} = $timeout_err;
 
-        alarm ($timeout);
+       while (1) {
+           alarm ($timeout);
+           $got_lock = mkdir($filename);
+           $timeout = alarm(0) - 1; # we'll sleep for 1s, see down below
 
-       if (!(mkdir $filename)) {
-           print STDERR "trying to aquire cfs lock '$lockid' ...";
-           while (1) {
-               if (!(mkdir $filename)) {
-                   (utime 0, 0, $filename); # cfs unlock request
-               } else {
-                   print STDERR " OK\n";
-                   last;
-               }
-               sleep(1);
-           }
+           last if $got_lock;
+
+           $timeout_err->() if $timeout <= 0;
+
+           print STDERR "trying to aquire cfs lock '$lockid' ...\n";
+           utime (0, 0, $filename); # cfs unlock request
+           sleep(1);
        }
 
        # fixed command timeout: cfs locks have a timeout of 120
        # using 60 gives us another 60 seconds to abort the task
-       alarm(60);
        local $SIG{ALRM} = sub { die "got lock timeout - aborting command\n"; };
+       alarm(60);
 
        cfs_update(); # make sure we read latest versions inside code()
 
@@ -908,19 +917,14 @@ my $cfs_lock = sub {
 
     my $err = $@;
 
-    alarm(0);
+    $err = "no quorum!\n" if !$got_lock && !check_cfs_quorum(1);
 
-    if ($err && ($err eq "got lock request timeout\n") &&
-       !check_cfs_quorum()){
-       $err = "$msg: no quorum!\n";
-    }
+    rmdir $filename if $got_lock; # if we held the lock always unlock again
 
-    if (!$err || $err !~ /^got lock timeout -/) {
-       rmdir $filename; # cfs unlock
-    }
+    alarm($prev_alarm);
 
     if ($err) {
-        $@ = $err;
+        $@ = "error with cfs lock '$lockid': $err";
         return undef;
     }
 
@@ -1119,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);
 
@@ -1132,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 {
@@ -1371,7 +1374,7 @@ 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).",
+           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), or an HTML5 based viewer (noVNC).",
            enum => ['applet', 'vv', 'html5'],
        },
        email_from => {
@@ -1403,6 +1406,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'),
     },
 };
 
@@ -1428,6 +1432,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;
 }
 
@@ -1440,6 +1449,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);
 }
 
@@ -1474,29 +1492,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;
        }
@@ -1519,6 +1517,39 @@ 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);
+    if (!$cert) {
+       Net::SSLeay::BIO_free($bio);
+       die "unable to read certificate from '$cert_path'\n";
+    }
+
+    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) = @_;
 
@@ -1526,11 +1557,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) {
@@ -1650,6 +1678,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
@@ -1663,4 +1692,189 @@ 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;
+    }
+}
+
+my $backup_cfs_database = sub {
+    my ($dbfile) = @_;
+
+    mkdir $dbbackupdir;
+
+    print "backup old database\n";
+    my $ctime = time();
+    my $cmd = [
+       ['echo', '.dump'],
+       ['sqlite3', $dbfile],
+       ['gzip', '-', \ ">${dbbackupdir}/config-${ctime}.sql.gz"],
+    ];
+
+    PVE::Tools::run_command($cmd, 'errmsg' => "cannot backup old database\n");
+
+    # purge older backup
+    my $maxfiles = 10;
+    my @bklist = ();
+    foreach my $fn (<$dbbackupdir/config-*.sql.gz>) {
+       if ($fn =~ m!/config-(\d+)\.sql.gz$!) {
+           push @bklist, [$fn, $1];
+       }
+    }
+
+    @bklist = sort { $b->[1] <=> $a->[1] } @bklist;
+    while (scalar (@bklist) >= $maxfiles) {
+       my $d = pop @bklist;
+       print "delete old backup '$d->[0]'\n";
+       unlink $d->[0];
+    }
+};
+
+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 $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 "Etablishing 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 if defined($ring0_addr);
+    $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;
+
+    my $local_ip_address = remote_node_ip($nodename);
+
+    print "generating node certificates\n";
+    gen_pve_node_files($nodename, $local_ip_address);
+
+    print "merge known_hosts file\n";
+    ssh_merge_known_hosts($nodename, $local_ip_address, 1);
+
+    print "node certificate changed, restart pveproxy and pvedaemon services\n";
+    run_command(['systemctl', 'reload-or-restart', 'pvedaemon', 'pveproxy']);
+
+    print "successfully added node '$nodename' to cluster.\n";
+}
+
+
 1;