]> git.proxmox.com Git - pmg-api.git/blame - PMG/Cluster.pm
PMG/Cluster.pm: implement sync_ruledb_from_master()
[pmg-api.git] / PMG / Cluster.pm
CommitLineData
0854fb22
DM
1package PMG::Cluster;
2
3use strict;
4use warnings;
45e68618 5use Data::Dumper;
0854fb22 6use Socket;
cfdf6608 7use File::Path;
45e68618 8
a7c7cad7 9use PVE::SafeSyslog;
0854fb22
DM
10use PVE::Tools;
11use PVE::INotify;
12
1fb2ab76 13use PMG::Utils;
a7c7cad7 14use PMG::Config;
9f67f5b3 15use PMG::ClusterConfig;
db303db4
DM
16use PMG::RuleDB;
17use PMG::RuleCache;
18use PVE::APIClient::LWP;
0854fb22 19
cfdf6608
DM
20our $spooldir = "/var/spool/proxmox";
21
22sub create_needed_dirs {
23 my ($lcid, $cleanup) = @_;
24
25 # if requested, remove any stale date
12720220 26 File::Path::remove_tree("$spooldir/cluster", "$spooldir/virus", "$spooldir/spam") if $cleanup;
d8782874
DM
27
28 mkdir "$spooldir/spam";
29 mkdir "$spooldir/virus";
cfdf6608
DM
30
31 if ($lcid) {
32 mkpath "$spooldir/cluster/$lcid/virus";
33 mkpath "$spooldir/cluster/$lcid/spam";
34 }
35}
36
8737f93a
DM
37sub remote_node_ip {
38 my ($nodename, $noerr) = @_;
39
d8782874 40 my $cinfo = PMG::ClusterConfig->new();
8737f93a 41
45e68618 42 foreach my $entry (values %{$cinfo->{ids}}) {
8737f93a
DM
43 if ($entry->{name} eq $nodename) {
44 my $ip = $entry->{ip};
45 return $ip if !wantarray;
46 my $family = PVE::Tools::get_host_address_family($ip);
47 return ($ip, $family);
48 }
49 }
50
51 # fallback: try to get IP by other means
9f67f5b3 52 return PMG::Utils::lookup_node_ip($nodename, $noerr);
8737f93a
DM
53}
54
d2e43f9e
DM
55sub get_master_node {
56 my ($cinfo) = @_;
57
d8782874 58 $cinfo = PMG::ClusterConfig->new() if !$cinfo;
d2e43f9e
DM
59
60 return $cinfo->{master}->{name} if defined($cinfo->{master});
61
62 return 'localhost';
63}
64
cba17aeb
DM
65sub read_local_ssl_cert_fingerprint {
66 my $cert_path = "/etc/pmg/pmg-api.pem";
0854fb22 67
cba17aeb
DM
68 my $cert;
69 eval {
70 my $bio = Net::SSLeay::BIO_new_file($cert_path, 'r');
71 $cert = Net::SSLeay::PEM_read_bio_X509($bio);
72 Net::SSLeay::BIO_free($bio);
73 };
74 if (my $err = $@) {
75 die "unable to read certificate '$cert_path' - $err\n";
76 }
0854fb22 77
cba17aeb
DM
78 if (!defined($cert)) {
79 die "unable to read certificate '$cert_path' - got empty value\n";
80 }
81
82 my $fp;
83 eval {
84 $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
85 };
86 if (my $err = $@) {
87 die "unable to get fingerprint for '$cert_path' - $err\n";
88 }
0854fb22 89
cba17aeb
DM
90 if (!defined($fp) || $fp eq '') {
91 die "unable to get fingerprint for '$cert_path' - got empty value\n";
92 }
0854fb22 93
cba17aeb
DM
94 return $fp;
95}
0854fb22 96
cba17aeb
DM
97my $hostrsapubkey_fn = '/etc/ssh/ssh_host_rsa_key.pub';
98my $rootrsakey_fn = '/root/.ssh/id_rsa';
99my $rootrsapubkey_fn = '/root/.ssh/id_rsa.pub';
0854fb22 100
cba17aeb
DM
101sub read_local_cluster_info {
102
103 my $res = {};
104
105 my $hostrsapubkey = PVE::Tools::file_read_firstline($hostrsapubkey_fn);
106 $hostrsapubkey =~ s/^.*ssh-rsa\s+//i;
107 $hostrsapubkey =~ s/\s+root\@\S+\s*$//i;
108
109 die "unable to parse ${hostrsapubkey_fn}\n"
110 if $hostrsapubkey !~ m/^[A-Za-z0-9\.\/\+]{200,}$/;
0854fb22
DM
111
112 my $nodename = PVE::INotify::nodename();
113
cba17aeb 114 $res->{name} = $nodename;
0854fb22 115
cba17aeb 116 $res->{ip} = PMG::Utils::lookup_node_ip($nodename);
0854fb22 117
cba17aeb 118 $res->{hostrsapubkey} = $hostrsapubkey;
0854fb22 119
cba17aeb
DM
120 if (! -f $rootrsapubkey_fn) {
121 unlink $rootrsakey_fn;
122 my $cmd = ['ssh-keygen', '-t', 'rsa', '-N', '', '-b', '2048',
123 '-f', $rootrsakey_fn];
1fb2ab76 124 PMG::Utils::run_silent_cmd($cmd);
cba17aeb
DM
125 }
126
127 my $rootrsapubkey = PVE::Tools::file_read_firstline($rootrsapubkey_fn);
128 $rootrsapubkey =~ s/^.*ssh-rsa\s+//i;
129 $rootrsapubkey =~ s/\s+root\@\S+\s*$//i;
130
131 die "unable to parse ${rootrsapubkey_fn}\n"
132 if $rootrsapubkey !~ m/^[A-Za-z0-9\.\/\+]{200,}$/;
133
134 $res->{rootrsapubkey} = $rootrsapubkey;
135
136 $res->{fingerprint} = read_local_ssl_cert_fingerprint();
137
138 return $res;
139}
140
141# X509 Certificate cache helper
142
143my $cert_cache_nodes = {};
144my $cert_cache_timestamp = time();
145my $cert_cache_fingerprints = {};
0854fb22 146
cba17aeb 147sub update_cert_cache {
0854fb22 148
cba17aeb
DM
149 $cert_cache_timestamp = time();
150
151 $cert_cache_fingerprints = {};
152 $cert_cache_nodes = {};
153
d8782874 154 my $cinfo = PMG::ClusterConfig->new();
cba17aeb
DM
155
156 foreach my $entry (values %{$cinfo->{ids}}) {
157 my $node = $entry->{name};
158 my $fp = $entry->{fingerprint};
159 if ($node && $fp) {
160 $cert_cache_fingerprints->{$fp} = 1;
161 $cert_cache_nodes->{$node} = $fp;
0854fb22
DM
162 }
163 }
164}
165
166# load and cache cert fingerprint once
167sub initialize_cert_cache {
168 my ($node) = @_;
169
cba17aeb 170 update_cert_cache()
0854fb22
DM
171 if defined($node) && !defined($cert_cache_nodes->{$node});
172}
173
174sub check_cert_fingerprint {
175 my ($cert) = @_;
176
177 # clear cache every 30 minutes at least
cba17aeb 178 update_cert_cache() if time() - $cert_cache_timestamp >= 60*30;
0854fb22
DM
179
180 # get fingerprint of server certificate
181 my $fp;
182 eval {
183 $fp = Net::SSLeay::X509_get_fingerprint($cert, 'sha256');
184 };
185 return 0 if $@ || !defined($fp) || $fp eq ''; # error
186
187 my $check = sub {
188 for my $expected (keys %$cert_cache_fingerprints) {
189 return 1 if $fp eq $expected;
190 }
191 return 0;
192 };
193
cba17aeb 194 return 1 if $check->();
0854fb22
DM
195
196 # clear cache and retry at most once every minute
197 if (time() - $cert_cache_timestamp >= 60) {
198 syslog ('info', "Could not verify remote node certificate '$fp' with list of pinned certificates, refreshing cache");
199 update_cert_cache();
cba17aeb 200 return $check->();
0854fb22
DM
201 }
202
203 return 0;
204}
205
58072364
DM
206my $sshglobalknownhosts = "/etc/ssh/ssh_known_hosts2";
207my $rootsshauthkeys = "/root/.ssh/authorized_keys";
208my $ssh_rsa_id = "/root/.ssh/id_rsa.pub";
209
210sub update_ssh_keys {
211 my ($cinfo) = @_;
212
213 my $data = '';
214 foreach my $node (values %{$cinfo->{ids}}) {
215 $data .= "$node->{ip} ssh-rsa $node->{hostrsapubkey}\n";
216 $data .= "$node->{name} ssh-rsa $node->{hostrsapubkey}\n";
217 }
218
219 PVE::Tools::file_set_contents($sshglobalknownhosts, $data);
220
221 $data = '';
222
223 # always add ourself
224 if (-f $ssh_rsa_id) {
225 my $pub = PVE::Tools::file_get_contents($ssh_rsa_id);
226 chomp($pub);
227 $data .= "$pub\n";
228 }
229
230 foreach my $node (values %{$cinfo->{ids}}) {
231 $data .= "ssh-rsa $node->{rootrsapubkey} root\@$node->{name}\n";
232 }
233
234 if (-f $rootsshauthkeys) {
a7c7cad7
DM
235 my $mykey = PVE::Tools::file_get_contents($rootsshauthkeys, 128*1024);
236 chomp($mykey);
237 $data .= "$mykey\n";
58072364
DM
238 }
239
240 my $newdata = "";
241 my $vhash = {};
242 my @lines = split(/\n/, $data);
243 foreach my $line (@lines) {
244 if ($line !~ /^#/ && $line =~ m/(^|\s)ssh-(rsa|dsa)\s+(\S+)\s+\S+$/) {
245 next if $vhash->{$3}++;
246 }
247 $newdata .= "$line\n";
248 }
249
250 PVE::Tools::file_set_contents($rootsshauthkeys, $newdata, 0600);
251}
252
a7c7cad7
DM
253my $cfgdir = '/etc/pmg';
254my $syncdir = "$cfgdir/master";
255
256my $cond_commit_synced_file = sub {
257 my ($filename, $dstfn) = @_;
258
259 $dstfn = "$cfgdir/$filename" if !defined($dstfn);
260 my $srcfn = "$syncdir/$filename";
261
262 if (! -f $srcfn) {
263 unlink $dstfn;
264 return;
265 }
266
267 my $new = PVE::Tools::file_get_contents($srcfn, 1024*1024);
268
269 if (-f $dstfn) {
270 my $old = PVE::Tools::file_get_contents($dstfn, 1024*1024);
271 return 0 if $new eq $old;
272 }
273
274 rename($srcfn, $dstfn) ||
275 die "cond_rename_file '$filename' failed - $!\n";
276
277 print STDERR "updated $dstfn\n";
278
279 return 1;
280};
281
58072364 282sub sync_config_from_master {
8871a5f0 283 my ($cinfo, $master_name, $master_ip, $noreload) = @_;
58072364
DM
284
285 my $local_ip = $cinfo->{local}->{ip};
286 my $local_name = $cinfo->{local}->{name};
287
52072c81
DM
288 if ($local_ip eq $master_ip) {
289 print STDERR "local node is master - nothing to do\n";
290 return;
291 }
58072364 292
58072364 293 mkdir $syncdir;
a7c7cad7 294 File::Path::remove_tree($syncdir, {keep_root => 1});
58072364 295
a7c7cad7
DM
296 my $sa_conf_dir = "/etc/mail/spamassassin";
297 my $sa_custom_cf = "custom.cf";
58072364 298
8871a5f0
DM
299 my $ssh_cmd = '--rsh=ssh -l root -o BatchMode=yes';
300 $ssh_cmd .= " -o HostKeyAlias=${master_name}" if $master_name;
301
db303db4 302 my $cmd = ['rsync', "--rsh=ssh -l root -o BatchMode=yes -o HostKeyAlias=${master_name}", '-lpgoq',
a7c7cad7 303 "${master_ip}:$cfgdir/* ${sa_conf_dir}/${sa_custom_cf}",
58072364 304 "$syncdir/",
a7c7cad7
DM
305 '--exclude', '*~',
306 '--exclude', '*.db',
307 '--exclude', 'pmg-api.pem',
308 '--exclude', 'pmg-tls.pem',
309 ];
58072364
DM
310
311 my $errmsg = "syncing master configuration from '${master_ip}' failed";
312 PVE::Tools::run_command($cmd, errmsg => $errmsg);
a7c7cad7
DM
313
314 # verify that the remote host is cluster master
315 open (my $fh, '<', "$syncdir/cluster.conf") ||
316 die "unable to open synced cluster.conf - $!\n";
317 my $newcinfo = PMG::ClusterConfig::read_cluster_conf('cluster.conf', $fh);
318
319 if (!$newcinfo->{master} || ($newcinfo->{master}->{ip} ne $master_ip)) {
320 die "host '$master_ip' is not cluster master\n";
321 }
322
323 my $role = $newcinfo->{'local'}->{type} // '-';
324 die "local node '$newcinfo->{local}->{name}' not part of cluster\n"
325 if $role eq '-';
326
327 die "local node '$newcinfo->{local}->{name}' is new cluster master\n"
328 if $role eq 'master';
329
330
331 $cond_commit_synced_file->('cluster.conf');
332 $cinfo = $newcinfo;
333
334 my $files = [
335 'pmg-authkey.key',
336 'pmg-authkey.pub',
337 'pmg-csrf.key',
338 'ldap.conf',
339 'user.conf',
340 ];
341
342 foreach my $filename (@$files) {
343 $cond_commit_synced_file->($filename);
344 }
345
346 my $force_restart = {};
347
348 if ($cond_commit_synced_file->($sa_custom_cf, "${sa_conf_dir}/${sa_custom_cf}")) {
349 $force_restart->{spam} = 1;
350 }
351
352 $cond_commit_synced_file->('pmg.conf');
353
354 my $cfg = PMG::Config->new();
355
356 $cfg->rewrite_config(1, $force_restart);
58072364
DM
357}
358
db303db4
DM
359sub sync_ruledb_from_master {
360 my ($ldb, $rdb, $ni, $ticket) = @_;
361
362 my $ruledb = PMG::RuleDB->new($ldb);
363 my $rulecache = PMG::RuleCache->new($ruledb);
364
365 my $conn = PVE::APIClient::LWP->new(
366 ticket => $ticket,
367 cookie_name => 'PMGAuthCookie',
368 host => $ni->{ip},
369 cached_fingerprints => {
370 $ni->{fingerprint} => 1,
371 });
372
373 my $digest = $conn->get("/config/ruledb/digest", {});
374
375 return if $digest eq $rulecache->{digest}; # no changes
376
377 syslog('info', "detected rule database changes - starting sync from '$ni->{ip}'");
378
379 eval {
380 $ldb->begin_work;
381
382 $ldb->do("DELETE FROM Rule");
383 $ldb->do("DELETE FROM RuleGroup");
384 $ldb->do("DELETE FROM ObjectGroup");
385 $ldb->do("DELETE FROM Object");
386 $ldb->do("DELETE FROM Attribut");
387
388 eval {
389 $rdb->begin_work;
390
391 # read a consistent snapshot
392 $rdb->do("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
393
394 PMG::DBTools::copy_table($ldb, $rdb, "Rule");
395 PMG::DBTools::copy_table($ldb, $rdb, "RuleGroup");
396 PMG::DBTools::copy_table($ldb, $rdb, "ObjectGroup");
397 PMG::DBTools::copy_table($ldb, $rdb, "Object", 'value');
398 PMG::DBTools::copy_table($ldb, $rdb, "Attribut", 'value');
399 };
400
401 $rdb->rollback; # end transaction
402
403 die $@ if $@;
404
405 # update sequences
406
407 $ldb->do("SELECT setval('rule_id_seq', max(id)+1) FROM Rule");
408 $ldb->do("SELECT setval('object_id_seq', max(id)+1) FROM Object");
409 $ldb->do("SELECT setval('objectgroup_id_seq', max(id)+1) FROM ObjectGroup");
410
411 $ldb->commit;
412 };
413 if (my $err = $@) {
414 $ldb->rollback;
415 die $err;
416 }
417
418 syslog('info', "finished rule database sync from host '$ni->{ip}'");
419}
420
0854fb22 4211;