]> git.proxmox.com Git - pmg-api.git/blame_incremental - PMG/Cluster.pm
PMG/Cluster.pm: improve sync_config_from_master
[pmg-api.git] / PMG / Cluster.pm
... / ...
CommitLineData
1package PMG::Cluster;
2
3use strict;
4use warnings;
5use Data::Dumper;
6use Socket;
7use File::Path;
8
9use PVE::SafeSyslog;
10use PVE::Tools;
11use PVE::INotify;
12
13use PMG::Config;
14use PMG::ClusterConfig;
15
16our $spooldir = "/var/spool/proxmox";
17
18sub create_needed_dirs {
19 my ($lcid, $cleanup) = @_;
20
21 # if requested, remove any stale date
22 rmtree("$spooldir/cluster", "$spooldir/virus", "$spooldir/spam") if $cleanup;
23
24 mkdir "$spooldir/spam";
25 mkdir "$spooldir/virus";
26
27 if ($lcid) {
28 mkpath "$spooldir/cluster/$lcid/virus";
29 mkpath "$spooldir/cluster/$lcid/spam";
30 }
31}
32
33sub remote_node_ip {
34 my ($nodename, $noerr) = @_;
35
36 my $cinfo = PMG::ClusterConfig->new();
37
38 foreach my $entry (values %{$cinfo->{ids}}) {
39 if ($entry->{name} eq $nodename) {
40 my $ip = $entry->{ip};
41 return $ip if !wantarray;
42 my $family = PVE::Tools::get_host_address_family($ip);
43 return ($ip, $family);
44 }
45 }
46
47 # fallback: try to get IP by other means
48 return PMG::Utils::lookup_node_ip($nodename, $noerr);
49}
50
51sub get_master_node {
52 my ($cinfo) = @_;
53
54 $cinfo = PMG::ClusterConfig->new() if !$cinfo;
55
56 return $cinfo->{master}->{name} if defined($cinfo->{master});
57
58 return 'localhost';
59}
60
61sub read_local_ssl_cert_fingerprint {
62 my $cert_path = "/etc/pmg/pmg-api.pem";
63
64 my $cert;
65 eval {
66 my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r');
67 $cert = Net::SSLeay::PEM_read_bio_X509($bio);
68 Net::SSLeay::BIO_free($bio);
69 };
70 if (my $err = $@) {
71 die "unable to read certificate '$cert_path' - $err\n";
72 }
73
74 if (!defined($cert)) {
75 die "unable to read certificate '$cert_path' - got empty value\n";
76 }
77
78 my $fp;
79 eval {
80 $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
81 };
82 if (my $err = $@) {
83 die "unable to get fingerprint for '$cert_path' - $err\n";
84 }
85
86 if (!defined($fp) || $fp eq '') {
87 die "unable to get fingerprint for '$cert_path' - got empty value\n";
88 }
89
90 return $fp;
91}
92
93my $hostrsapubkey_fn = '/etc/ssh/ssh_host_rsa_key.pub';
94my $rootrsakey_fn = '/root/.ssh/id_rsa';
95my $rootrsapubkey_fn = '/root/.ssh/id_rsa.pub';
96
97sub read_local_cluster_info {
98
99 my $res = {};
100
101 my $hostrsapubkey = PVE::Tools::file_read_firstline($hostrsapubkey_fn);
102 $hostrsapubkey =~ s/^.*ssh-rsa\s+//i;
103 $hostrsapubkey =~ s/\s+root\@\S+\s*$//i;
104
105 die "unable to parse ${hostrsapubkey_fn}\n"
106 if $hostrsapubkey !~ m/^[A-Za-z0-9\.\/\+]{200,}$/;
107
108 my $nodename = PVE::INotify::nodename();
109
110 $res->{name} = $nodename;
111
112 $res->{ip} = PMG::Utils::lookup_node_ip($nodename);
113
114 $res->{hostrsapubkey} = $hostrsapubkey;
115
116 if (! -f $rootrsapubkey_fn) {
117 unlink $rootrsakey_fn;
118 my $cmd = ['ssh-keygen', '-t', 'rsa', '-N', '', '-b', '2048',
119 '-f', $rootrsakey_fn];
120 PVE::Tools::run_command($cmd);
121 }
122
123 my $rootrsapubkey = PVE::Tools::file_read_firstline($rootrsapubkey_fn);
124 $rootrsapubkey =~ s/^.*ssh-rsa\s+//i;
125 $rootrsapubkey =~ s/\s+root\@\S+\s*$//i;
126
127 die "unable to parse ${rootrsapubkey_fn}\n"
128 if $rootrsapubkey !~ m/^[A-Za-z0-9\.\/\+]{200,}$/;
129
130 $res->{rootrsapubkey} = $rootrsapubkey;
131
132 $res->{fingerprint} = read_local_ssl_cert_fingerprint();
133
134 return $res;
135}
136
137# X509 Certificate cache helper
138
139my $cert_cache_nodes = {};
140my $cert_cache_timestamp = time();
141my $cert_cache_fingerprints = {};
142
143sub update_cert_cache {
144
145 $cert_cache_timestamp = time();
146
147 $cert_cache_fingerprints = {};
148 $cert_cache_nodes = {};
149
150 my $cinfo = PMG::ClusterConfig->new();
151
152 foreach my $entry (values %{$cinfo->{ids}}) {
153 my $node = $entry->{name};
154 my $fp = $entry->{fingerprint};
155 if ($node && $fp) {
156 $cert_cache_fingerprints->{$fp} = 1;
157 $cert_cache_nodes->{$node} = $fp;
158 }
159 }
160}
161
162# load and cache cert fingerprint once
163sub initialize_cert_cache {
164 my ($node) = @_;
165
166 update_cert_cache()
167 if defined($node) && !defined($cert_cache_nodes->{$node});
168}
169
170sub check_cert_fingerprint {
171 my ($cert) = @_;
172
173 # clear cache every 30 minutes at least
174 update_cert_cache() if time() - $cert_cache_timestamp >= 60*30;
175
176 # get fingerprint of server certificate
177 my $fp;
178 eval {
179 $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
180 };
181 return 0 if $@ || !defined($fp) || $fp eq ''; # error
182
183 my $check = sub {
184 for my $expected (keys %$cert_cache_fingerprints) {
185 return 1 if $fp eq $expected;
186 }
187 return 0;
188 };
189
190 return 1 if $check->();
191
192 # clear cache and retry at most once every minute
193 if (time() - $cert_cache_timestamp >= 60) {
194 syslog ('info', "Could not verify remote node certificate '$fp' with list of pinned certificates, refreshing cache");
195 update_cert_cache();
196 return $check->();
197 }
198
199 return 0;
200}
201
202my $sshglobalknownhosts = "/etc/ssh/ssh_known_hosts2";
203my $rootsshauthkeys = "/root/.ssh/authorized_keys";
204my $ssh_rsa_id = "/root/.ssh/id_rsa.pub";
205
206sub update_ssh_keys {
207 my ($cinfo) = @_;
208
209 my $data = '';
210 foreach my $node (values %{$cinfo->{ids}}) {
211 $data .= "$node->{ip} ssh-rsa $node->{hostrsapubkey}\n";
212 $data .= "$node->{name} ssh-rsa $node->{hostrsapubkey}\n";
213 }
214
215 PVE::Tools::file_set_contents($sshglobalknownhosts, $data);
216
217 $data = '';
218
219 # always add ourself
220 if (-f $ssh_rsa_id) {
221 my $pub = PVE::Tools::file_get_contents($ssh_rsa_id);
222 chomp($pub);
223 $data .= "$pub\n";
224 }
225
226 foreach my $node (values %{$cinfo->{ids}}) {
227 $data .= "ssh-rsa $node->{rootrsapubkey} root\@$node->{name}\n";
228 }
229
230 if (-f $rootsshauthkeys) {
231 my $mykey = PVE::Tools::file_get_contents($rootsshauthkeys, 128*1024);
232 chomp($mykey);
233 $data .= "$mykey\n";
234 }
235
236 my $newdata = "";
237 my $vhash = {};
238 my @lines = split(/\n/, $data);
239 foreach my $line (@lines) {
240 if ($line !~ /^#/ && $line =~ m/(^|\s)ssh-(rsa|dsa)\s+(\S+)\s+\S+$/) {
241 next if $vhash->{$3}++;
242 }
243 $newdata .= "$line\n";
244 }
245
246 PVE::Tools::file_set_contents($rootsshauthkeys, $newdata, 0600);
247}
248
249my $cfgdir = '/etc/pmg';
250my $syncdir = "$cfgdir/master";
251
252my $cond_commit_synced_file = sub {
253 my ($filename, $dstfn) = @_;
254
255 $dstfn = "$cfgdir/$filename" if !defined($dstfn);
256 my $srcfn = "$syncdir/$filename";
257
258 if (! -f $srcfn) {
259 unlink $dstfn;
260 return;
261 }
262
263 my $new = PVE::Tools::file_get_contents($srcfn, 1024*1024);
264
265 if (-f $dstfn) {
266 my $old = PVE::Tools::file_get_contents($dstfn, 1024*1024);
267 return 0 if $new eq $old;
268 }
269
270 rename($srcfn, $dstfn) ||
271 die "cond_rename_file '$filename' failed - $!\n";
272
273 print STDERR "updated $dstfn\n";
274
275 return 1;
276};
277
278sub sync_config_from_master {
279 my ($cinfo, $master_ip, $noreload) = @_;
280
281 my $local_ip = $cinfo->{local}->{ip};
282 my $local_name = $cinfo->{local}->{name};
283
284 return if $local_ip eq $master_ip;
285
286 mkdir $syncdir;
287 File::Path::remove_tree($syncdir, {keep_root => 1});
288
289 my $sa_conf_dir = "/etc/mail/spamassassin";
290 my $sa_custom_cf = "custom.cf";
291
292 my $cmd = ['rsync', '--rsh=ssh -l root -o BatchMode=yes', '-lpgoq',
293 "${master_ip}:$cfgdir/* ${sa_conf_dir}/${sa_custom_cf}",
294 "$syncdir/",
295 '--exclude', '*~',
296 '--exclude', '*.db',
297 '--exclude', 'pmg-api.pem',
298 '--exclude', 'pmg-tls.pem',
299 ];
300
301 my $errmsg = "syncing master configuration from '${master_ip}' failed";
302 PVE::Tools::run_command($cmd, errmsg => $errmsg);
303
304 # verify that the remote host is cluster master
305 open (my $fh, '<', "$syncdir/cluster.conf") ||
306 die "unable to open synced cluster.conf - $!\n";
307 my $newcinfo = PMG::ClusterConfig::read_cluster_conf('cluster.conf', $fh);
308
309 if (!$newcinfo->{master} || ($newcinfo->{master}->{ip} ne $master_ip)) {
310 die "host '$master_ip' is not cluster master\n";
311 }
312
313 my $role = $newcinfo->{'local'}->{type} // '-';
314 die "local node '$newcinfo->{local}->{name}' not part of cluster\n"
315 if $role eq '-';
316
317 die "local node '$newcinfo->{local}->{name}' is new cluster master\n"
318 if $role eq 'master';
319
320
321 $cond_commit_synced_file->('cluster.conf');
322 $cinfo = $newcinfo;
323
324 my $files = [
325 'pmg-authkey.key',
326 'pmg-authkey.pub',
327 'pmg-csrf.key',
328 'ldap.conf',
329 'user.conf',
330 ];
331
332 foreach my $filename (@$files) {
333 $cond_commit_synced_file->($filename);
334 }
335
336 my $force_restart = {};
337
338 if ($cond_commit_synced_file->($sa_custom_cf, "${sa_conf_dir}/${sa_custom_cf}")) {
339 $force_restart->{spam} = 1;
340 }
341
342 $cond_commit_synced_file->('pmg.conf');
343
344 my $cfg = PMG::Config->new();
345
346 $cfg->rewrite_config(1, $force_restart);
347}
348
3491;