]> git.proxmox.com Git - pve-cluster.git/blobdiff - data/PVE/Cluster.pm
ssh_merge_known_hosts: also add entry if current sshkey does not match
[pve-cluster.git] / data / PVE / Cluster.pm
index 3d7f6a36003365c4e25632ba8c8e7491d285ee45..8990656ce055390a9fab08b04edfd491cd64f134 100644 (file)
@@ -8,9 +8,9 @@ use Socket;
 use Storable qw(dclone);
 use IO::File;
 use MIME::Base64;
-use XML::Parser;
 use Digest::SHA;
 use Digest::HMAC_SHA1;
+use Net::SSLeay;
 use PVE::Tools;
 use PVE::INotify;
 use PVE::IPCC;
@@ -62,6 +62,7 @@ my $observed = {
     'vzdump.cron' => 1,
     'storage.cfg' => 1,
     'datacenter.cfg' => 1,
+    'replication.cfg' => 1,
     'corosync.conf' => 1,
     'corosync.conf.new' => 1,
     'user.cfg' => 1,
@@ -90,7 +91,7 @@ sub run_silent_cmd {
     };
 
     eval {
-       PVE::Tools::run_command($cmd, outfunc => $record_output, 
+       PVE::Tools::run_command($cmd, outfunc => $record_output,
                                errfunc => $record_output);
     };
 
@@ -133,13 +134,13 @@ sub gen_local_dirs {
 
     my @required_dirs = (
        "$basedir/priv",
-       "$basedir/nodes", 
+       "$basedir/nodes",
        "$basedir/nodes/$nodename",
        "$basedir/nodes/$nodename/lxc",
        "$basedir/nodes/$nodename/qemu-server",
        "$basedir/nodes/$nodename/openvz",
        "$basedir/nodes/$nodename/priv");
-              
+
     foreach my $dir (@required_dirs) {
        if (! -d $dir) {
            mkdir($dir) || $! == EEXIST || die "unable to create directory '$dir' - $!\n";
@@ -247,7 +248,7 @@ sub gen_pve_ssl_cert {
     my $rc = PVE::INotify::read_file('resolvconf');
 
     $names .= ",IP:$ip";
-  
+
     my $fqdn = $nodename;
 
     $names .= ",DNS:$nodename";
@@ -547,7 +548,7 @@ sub get_tasklist {
        eval {
            my $ver = $kvstore->{$node}->{tasklist} if $kvstore->{$node};
            my $cd = $tasklistcache->{$node};
-           if (!$cd || !$ver || !$cd->{version} || 
+           if (!$cd || !$ver || !$cd->{version} ||
                ($cd->{version} != $ver)) {
                my $raw = &$ipcc_get_status("tasklist", $node) || '[]';
                my $data = decode_json($raw);
@@ -608,7 +609,7 @@ sub rrd_dump {
            my ($key, @ela) = split(/:/, $1);
            next if !$key;
            next if !(scalar(@ela) > 1);
-           $res->{$key} = \@ela;
+           $res->{$key} = [ map { $_ eq 'U' ? undef : $_ } @ela ];
        }
     }
 
@@ -652,8 +653,8 @@ sub create_rrd_data {
 
     my $err = RRDs::error;
     die "RRD error: $err\n" if $err;
-    
-    die "got wrong time resolution ($step != $reso)\n" 
+
+    die "got wrong time resolution ($step != $reso)\n"
        if $step != $reso;
 
     my $res = [];
@@ -682,7 +683,7 @@ sub create_rrd_graph {
     # Using RRD graph is clumsy - maybe it
     # is better to simply fetch the data, and do all display
     # related things with javascript (new extjs html5 graph library).
-       
+
     my $rrddir = "/var/lib/rrdcached/db";
 
     my $rrd = "$rrddir/$rrdname";
@@ -816,7 +817,7 @@ sub cfs_file_version {
 sub cfs_read_file {
     my ($filename) = @_;
 
-    my ($version, $info) = cfs_file_version($filename); 
+    my ($version, $info) = cfs_file_version($filename);
     my $parser = $info->{parser};
 
     return &$ccache_read($filename, $parser, $version);
@@ -825,7 +826,7 @@ sub cfs_read_file {
 sub cfs_write_file {
     my ($filename, $data) = @_;
 
-    my ($version, $info) = cfs_file_version($filename); 
+    my ($version, $info) = cfs_file_version($filename);
 
     my $writer = $info->{writer} || die "no writer defined";
 
@@ -896,7 +897,7 @@ my $cfs_lock = sub {
     if ($err && ($err eq "got lock request timeout\n") &&
        !check_cfs_quorum()){
        $err = "$msg: no quorum!\n";
-    }  
+    }
 
     if (!$err || $err !~ /^got lock timeout -/) {
        rmdir $filename; # cfs unlock
@@ -985,12 +986,12 @@ sub log_msg {
 
 sub check_vmid_unused {
     my ($vmid, $noerr) = @_;
-    
+
     my $vmlist = get_vmlist();
 
     my $d = $vmlist->{ids}->{$vmid};
     return 1 if !defined($d);
-    
+
     return undef if $noerr;
 
     my $vmtypestr =  $d->{type} eq 'qemu' ? 'VM' : 'CT';
@@ -1022,31 +1023,12 @@ sub remote_node_ip {
                    $family =
                    PVE::Tools::get_host_address_family($ip);
            }
-           return ($ip, $family);
+           return wantarray ? ($ip, $family) : $ip;
        }
     }
 
     # fallback: try to get IP by other means
-    my ($family, $packed_ip);
-
-    eval {
-       my @res = PVE::Tools::getaddrinfo_all($nodename);
-       $family = $res[0]->{family};
-       $packed_ip = (PVE::Tools::unpack_sockaddr_in46($res[0]->{addr}))[2];
-    };
-
-    if ($@) {
-       die "hostname lookup failed:\n$@" if !$noerr;
-       return undef;
-    }
-
-    my $ip = Socket::inet_ntop($family, $packed_ip);
-    if ($ip =~ m/^127\.|^::1$/) {
-       die "hostname lookup failed - got local IP address ($nodename = $ip)\n" if !$noerr;
-       return undef;
-    }
-
-    return wantarray ? ($ip, $family) : $ip;
+    return PVE::Network::get_ip_from_hostname($nodename, $noerr);
 }
 
 sub get_local_migration_ip {
@@ -1124,13 +1106,13 @@ sub setup_sshd_config {
     my ($start_sshd) = @_;
 
     my $conf = PVE::Tools::file_get_contents($sshd_config_fn);
-    
+
     return if $conf =~ m/^PermitRootLogin\s+yes\s*$/m;
 
     if ($conf !~ s/^#?PermitRootLogin.*$/PermitRootLogin yes/m) {
        chomp $conf;
        $conf .= "\nPermitRootLogin yes\n";
-    } 
+    }
 
     PVE::Tools::file_set_contents($sshd_config_fn, $conf);
 
@@ -1175,7 +1157,7 @@ sub setup_ssh_keys {
        }
     }
 
-    warn "can't create shared ssh key database '$sshauthkeys'\n" 
+    warn "can't create shared ssh key database '$sshauthkeys'\n"
        if ! -f $sshauthkeys;
 
     if (-f $rootsshauthkeys && ! -l $rootsshauthkeys) {
@@ -1210,7 +1192,11 @@ sub ssh_merge_known_hosts {
 
     die "no node name specified" if !$nodename;
     die "no ip address specified" if !$ip_address;
-   
+
+    # ssh lowercases hostnames (aliases) before comparision, so we need too
+    $nodename = lc($nodename);
+    $ip_address = lc($ip_address);
+
     mkdir $authdir;
 
     if (! -f $sshknownhosts) {
@@ -1219,10 +1205,10 @@ sub ssh_merge_known_hosts {
        }
     }
 
-    my $old = PVE::Tools::file_get_contents($sshknownhosts, 128*1024); 
-    
+    my $old = PVE::Tools::file_get_contents($sshknownhosts, 128*1024);
+
     my $new = '';
-    
+
     if ((! -l $sshglobalknownhosts) && (-f $sshglobalknownhosts)) {
        $new = PVE::Tools::file_get_contents($sshglobalknownhosts, 128*1024);
     }
@@ -1241,6 +1227,9 @@ sub ssh_merge_known_hosts {
     my $merge_line = sub {
        my ($line, $all) = @_;
 
+       return if $line =~ m/^\s*$/; # skip empty lines
+       return if $line =~ m/^#/; # skip comments
+
        if ($line =~ m/^(\S+)\s(ssh-rsa\s\S+)(\s.*)?$/) {
            my $key = $1;
            my $rsakey = $2;
@@ -1269,6 +1258,13 @@ sub ssh_merge_known_hosts {
                        }
                        return;
                    }
+               } else {
+                   $key = lc($key); # avoid duplicate entries, ssh compares lowercased
+                   if ($key eq $ip_address) {
+                       $found_local_ip = 1 if $rsakey eq $hostkey;
+                   } elsif ($key eq $nodename) {
+                       $found_nodename = 1 if $rsakey eq $hostkey;
+                   }
                }
                $data .= $line;
            }
@@ -1279,34 +1275,17 @@ sub ssh_merge_known_hosts {
 
     while ($old && $old =~ s/^((.*?)(\n|$))//) {
        my $line = "$2\n";
-       next if $line =~ m/^\s*$/; # skip empty lines
-       next if $line =~ m/^#/; # skip comments
        &$merge_line($line, 1);
     }
 
     while ($new && $new =~ s/^((.*?)(\n|$))//) {
        my $line = "$2\n";
-       next if $line =~ m/^\s*$/; # skip empty lines
-       next if $line =~ m/^#/; # skip comments
        &$merge_line($line);
     }
 
-    my $addIndex = $$;
-    my $add_known_hosts_entry  = sub {
-       my ($name, $hostkey) = @_;
-       $addIndex++;
-       my $hmac = Digest::HMAC_SHA1->new("$addIndex" . time());
-       my $b64salt = $hmac->b64digest . '=';
-       $hmac = Digest::HMAC_SHA1->new(decode_base64($b64salt));
-       $hmac->add($name);
-       my $digest = $hmac->b64digest . '=';
-       $data .= "|1|$b64salt|$digest $hostkey\n";
-    };
-
-    if (!$found_nodename || !$found_local_ip) {
-       &$add_known_hosts_entry($nodename, $hostkey) if !$found_nodename;
-       &$add_known_hosts_entry($ip_address, $hostkey) if !$found_local_ip;
-    }
+    # add our own key if not already there
+    $data .= "$nodename $hostkey\n" if !$found_nodename;
+    $data .= "$ip_address $hostkey\n" if !$found_local_ip;
 
     PVE::Tools::file_set_contents($sshknownhosts, $data);
 
@@ -1314,8 +1293,8 @@ sub ssh_merge_known_hosts {
 
     unlink $sshglobalknownhosts;
     symlink $sshknownhosts, $sshglobalknownhosts;
-    warn "can't create symlink for ssh known hosts '$sshglobalknownhosts' -> '$sshknownhosts'\n" 
+
+    warn "can't create symlink for ssh known hosts '$sshglobalknownhosts' -> '$sshknownhosts'\n"
        if ! -l $sshglobalknownhosts;
 
 }
@@ -1447,153 +1426,112 @@ sub write_datacenter_config {
     return PVE::JSONSchema::dump_config($datacenter_schema, $filename, $cfg);
 }
 
-cfs_register_file('datacenter.cfg', 
-                 \&parse_datacenter_config,  
+cfs_register_file('datacenter.cfg',
+                 \&parse_datacenter_config,
                  \&write_datacenter_config);
 
-# a very simply parser ...
-sub parse_corosync_conf {
-    my ($filename, $raw) = @_;
-
-    return {} if !$raw;
-
-    my $digest = Digest::SHA::sha1_hex(defined($raw) ? $raw : '');
-
-    $raw =~ s/#.*$//mg;
-    $raw =~ s/\r?\n/ /g;
-    $raw =~ s/\s+/ /g;
-    $raw =~ s/^\s+//;
-    $raw =~ s/\s*$//;
-  
-    my @tokens = split(/\s/, $raw);
-    
-    my $conf = { section => 'main', children => [] };
-
-    my $stack = [];
-    my $section = $conf;
-    
-    while (defined(my $token = shift @tokens)) {
-       my $nexttok = $tokens[0];
-
-       if ($nexttok && ($nexttok eq '{')) {
-           shift @tokens; # skip '{'
-           my $new_section = {
-               section => $token,
-               children => [],
-           };
-           push @{$section->{children}}, $new_section;
-           push @$stack, $section;
-           $section = $new_section;
-           next;
-       }
-
-       if ($token eq '}') {
-           $section = pop @$stack;
-           die "parse error - uncexpected '}'\n" if !$section;
-           next;
-       }
+# X509 Certificate cache helper
 
-       my $key = $token;
-       die "missing ':' after key '$key'\n" if ! ($key =~ s/:$//);
-       
-       die "parse error - no value for '$key'\n" if !defined($nexttok);
-       my $value = shift @tokens;
+my $cert_cache_nodes = {};
+my $cert_cache_timestamp = time();
+my $cert_cache_fingerprints = {};
 
-       push @{$section->{children}}, { key => $key, value => $value };
-    }
+sub update_cert_cache {
+    my ($update_node, $clear) = @_;
 
-    $conf->{digest} = $digest;
+    syslog('info', "Clearing outdated entries from certificate cache")
+       if $clear;
 
-    return $conf;
-}
+    $cert_cache_timestamp = time() if !defined($update_node);
 
-my $dump_corosync_section;
-$dump_corosync_section = sub {
-    my ($section, $prefix) = @_;
+    my $node_list = defined($update_node) ?
+       [ $update_node ] : [ keys %$cert_cache_nodes ];
 
-    my $raw = $prefix . $section->{section} . " {\n";
-    
-    my @list = grep { defined($_->{key}) } @{$section->{children}};
-    foreach my $child (sort {$a->{key} cmp $b->{key}} @list) {
-       $raw .= $prefix . "  $child->{key}: $child->{value}\n";
-    }
-    
-    @list = grep { defined($_->{section}) } @{$section->{children}};
-    foreach my $child (sort {$a->{section} cmp $b->{section}} @list) {
-       $raw .= &$dump_corosync_section($child, "$prefix  ");
-    }
+    foreach my $node (@$node_list) {
+       my $clear_old = sub {
+           if (my $old_fp = $cert_cache_nodes->{$node}) {
+               # distrust old fingerprint
+               delete $cert_cache_fingerprints->{$old_fp};
+               # ensure reload on next proxied request
+               delete $cert_cache_nodes->{$node};
+           }
+       };
 
-    $raw .= $prefix . "}\n\n";
-    
-    return $raw;
-    
-};
+       my $cert_path = "/etc/pve/nodes/$node/pve-ssl.pem";
+       my $custom_cert_path = "/etc/pve/nodes/$node/pveproxy-ssl.pem";
 
-sub write_corosync_conf {
-    my ($filename, $conf) = @_;
+       $cert_path = $custom_cert_path if -f $custom_cert_path;
 
-    my $raw = '';
+       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 $prefix = '';
-    
-    die "no main section" if $conf->{section} ne 'main';
+       my $fp;
+       eval {
+           $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
+       };
+       $err = $@;
+       if ($err || !defined($fp) || $fp eq '') {
+           &$clear_old() if $clear;
+           next;
+       }
 
-    my @list = grep { defined($_->{key}) } @{$conf->{children}};
-    foreach my $child (sort {$a->{key} cmp $b->{key}} @list) {
-       $raw .= "$child->{key}: $child->{value}\n";
-    }
+       my $old_fp = $cert_cache_nodes->{$node};
+       $cert_cache_fingerprints->{$fp} = 1;
+       $cert_cache_nodes->{$node} = $fp;
 
-    @list = grep { defined($_->{section}) } @{$conf->{children}};
-    foreach my $child (sort {$a->{section} cmp $b->{section}} @list) {
-       $raw .= &$dump_corosync_section($child, $prefix);
+       if (defined($old_fp) && $fp ne $old_fp) {
+           delete $cert_cache_fingerprints->{$old_fp};
+       }
     }
-
-    return $raw;
 }
 
-sub corosync_conf_version {
-    my ($conf, $noerr, $new_value) = @_;
-
-    foreach my $child (@{$conf->{children}}) {
-       next if !defined($child->{section});
-       if ($child->{section} eq 'totem') {
-           foreach my $e (@{$child->{children}}) {
-               next if !defined($e->{key});
-               if ($e->{key} eq 'config_version') {
-                   if ($new_value) {
-                       $e->{value} = $new_value;
-                       return $new_value;
-                   } elsif (my $version = int($e->{value})) {
-                       return $version;
-                   }
-                   last;
-               }
-           }
-       }
-    }
-    
-    return undef if $noerr;
+# load and cache cert fingerprint once
+sub initialize_cert_cache {
+    my ($node) = @_;
 
-    die "invalid corosync config - unable to read version\n";
+    update_cert_cache($node)
+       if defined($node) && !defined($cert_cache_nodes->{$node});
 }
 
-# read only - use "rename corosync.conf.new corosync.conf" to write
-PVE::Cluster::cfs_register_file('corosync.conf', \&parse_corosync_conf);
-# this is read/write
-PVE::Cluster::cfs_register_file('corosync.conf.new', \&parse_corosync_conf, 
-                               \&write_corosync_conf);
+sub check_cert_fingerprint {
+    my ($cert) = @_;
 
-sub check_corosync_conf_exists {
-    my ($silent) = @_;
+    # clear cache every 30 minutes at least
+    update_cert_cache(undef, 1) if time() - $cert_cache_timestamp >= 60*30;
 
-    $silent = $silent // 0;
+    # 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 $check = sub {
+       for my $expected (keys %$cert_cache_fingerprints) {
+           return 1 if $fp eq $expected;
+       }
+       return 0;
+    };
 
-    my $exists = -f "$basedir/corosync.conf";
+    return 1 if &$check();
 
-    warn "Corosync config '$basedir/corosync.conf' does not exist - is this node part of a cluster?\n"
-       if !$silent && !$exists;
+    # clear cache and retry at most once every minute
+    if (time() - $cert_cache_timestamp >= 60) {
+       syslog ('info', "Could not verify remote node certificate '$fp' with list of pinned certificates, refreshing cache");
+       update_cert_cache();
+       return &$check();
+    }
 
-    return $exists;
+    return 0;
 }
 
 # bash completion helpers
@@ -1650,4 +1588,62 @@ sub complete_migration_target {
     return $res;
 }
 
+sub get_ssh_info {
+    my ($node, $network_cidr) = @_;
+
+    my $ip;
+    if (defined($network_cidr)) {
+       # Use mtunnel via to get the remote node's ip inside $network_cidr.
+       # This goes over the regular network (iow. uses get_ssh_info() with
+       # $network_cidr undefined.
+       # FIXME: Use the REST API client for this after creating an API entry
+       # for get_migration_ip.
+       my $default_remote = get_ssh_info($node, undef);
+       my $default_ssh = ssh_info_to_command($default_remote);
+       my $cmd =[@$default_ssh, 'pvecm', 'mtunnel',
+           '-migration_network', $network_cidr,
+           '-get_migration_ip'
+       ];
+       PVE::Tools::run_command($cmd, outfunc => sub {
+           my ($line) = @_;
+           chomp $line;
+           die "internal error: unexpected output from mtunnel\n"
+               if defined($ip);
+           if ($line =~ /^ip: '(.*)'$/) {
+               $ip = $1;
+           } else {
+               die "internal error: bad output from mtunnel\n"
+                   if defined($ip);
+           }
+       });
+       die "failed to get ip for node '$node' in network '$network_cidr'\n"
+           if !defined($ip);
+    } else {
+       $ip = remote_node_ip($node);
+    }
+    return {
+       ip => $ip,
+       name => $node,
+       network => $network_cidr,
+    };
+}
+
+sub ssh_info_to_command_base {
+    my ($info, @extra_options) = @_;
+    return [
+       '/usr/bin/ssh',
+       '-o', 'BatchMode=yes',
+       '-o', 'HostKeyAlias='.$info->{name},
+       @extra_options
+    ];
+}
+
+sub ssh_info_to_command {
+    my ($info, @extra_options) = @_;
+    my $cmd = ssh_info_to_command_base($info, @extra_options);
+    push @$cmd, "root\@$info->{ip}";
+    return $cmd;
+}
+
 1;