]>
Commit | Line | Data |
---|---|---|
0854fb22 DM |
1 | package PMG::Cluster; |
2 | ||
3 | use strict; | |
4 | use warnings; | |
45e68618 | 5 | use Data::Dumper; |
0854fb22 | 6 | use Socket; |
cfdf6608 | 7 | use File::Path; |
45e68618 | 8 | |
a7c7cad7 | 9 | use PVE::SafeSyslog; |
0854fb22 DM |
10 | use PVE::Tools; |
11 | use PVE::INotify; | |
12 | ||
1fb2ab76 | 13 | use PMG::Utils; |
a7c7cad7 | 14 | use PMG::Config; |
9f67f5b3 | 15 | use PMG::ClusterConfig; |
db303db4 DM |
16 | use PMG::RuleDB; |
17 | use PMG::RuleCache; | |
18 | use PVE::APIClient::LWP; | |
0854fb22 | 19 | |
cfdf6608 DM |
20 | our $spooldir = "/var/spool/proxmox"; |
21 | ||
22 | sub 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 |
37 | sub 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 |
55 | sub 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 |
65 | sub 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 |
97 | my $hostrsapubkey_fn = '/etc/ssh/ssh_host_rsa_key.pub'; |
98 | my $rootrsakey_fn = '/root/.ssh/id_rsa'; | |
99 | my $rootrsapubkey_fn = '/root/.ssh/id_rsa.pub'; | |
0854fb22 | 100 | |
cba17aeb DM |
101 | sub 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 | ||
143 | my $cert_cache_nodes = {}; | |
144 | my $cert_cache_timestamp = time(); | |
145 | my $cert_cache_fingerprints = {}; | |
0854fb22 | 146 | |
cba17aeb | 147 | sub 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 | |
167 | sub 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 | ||
174 | sub 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 |
206 | my $sshglobalknownhosts = "/etc/ssh/ssh_known_hosts2"; |
207 | my $rootsshauthkeys = "/root/.ssh/authorized_keys"; | |
208 | my $ssh_rsa_id = "/root/.ssh/id_rsa.pub"; | |
209 | ||
210 | sub 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 |
253 | my $cfgdir = '/etc/pmg'; |
254 | my $syncdir = "$cfgdir/master"; | |
255 | ||
256 | my $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 | 282 | sub 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 |
359 | sub 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 | 421 | 1; |