]>
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 | ||
a7c7cad7 | 13 | use PMG::Config; |
9f67f5b3 | 14 | use PMG::ClusterConfig; |
0854fb22 | 15 | |
cfdf6608 DM |
16 | our $spooldir = "/var/spool/proxmox"; |
17 | ||
18 | sub create_needed_dirs { | |
19 | my ($lcid, $cleanup) = @_; | |
20 | ||
21 | # if requested, remove any stale date | |
58072364 | 22 | rmtree("$spooldir/cluster", "$spooldir/virus", "$spooldir/spam") if $cleanup; |
d8782874 DM |
23 | |
24 | mkdir "$spooldir/spam"; | |
25 | mkdir "$spooldir/virus"; | |
cfdf6608 DM |
26 | |
27 | if ($lcid) { | |
28 | mkpath "$spooldir/cluster/$lcid/virus"; | |
29 | mkpath "$spooldir/cluster/$lcid/spam"; | |
30 | } | |
31 | } | |
32 | ||
8737f93a DM |
33 | sub remote_node_ip { |
34 | my ($nodename, $noerr) = @_; | |
35 | ||
d8782874 | 36 | my $cinfo = PMG::ClusterConfig->new(); |
8737f93a | 37 | |
45e68618 | 38 | foreach my $entry (values %{$cinfo->{ids}}) { |
8737f93a DM |
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 | |
9f67f5b3 | 48 | return PMG::Utils::lookup_node_ip($nodename, $noerr); |
8737f93a DM |
49 | } |
50 | ||
d2e43f9e DM |
51 | sub get_master_node { |
52 | my ($cinfo) = @_; | |
53 | ||
d8782874 | 54 | $cinfo = PMG::ClusterConfig->new() if !$cinfo; |
d2e43f9e DM |
55 | |
56 | return $cinfo->{master}->{name} if defined($cinfo->{master}); | |
57 | ||
58 | return 'localhost'; | |
59 | } | |
60 | ||
cba17aeb DM |
61 | sub read_local_ssl_cert_fingerprint { |
62 | my $cert_path = "/etc/pmg/pmg-api.pem"; | |
0854fb22 | 63 | |
cba17aeb DM |
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 | } | |
0854fb22 | 73 | |
cba17aeb DM |
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 | } | |
0854fb22 | 85 | |
cba17aeb DM |
86 | if (!defined($fp) || $fp eq '') { |
87 | die "unable to get fingerprint for '$cert_path' - got empty value\n"; | |
88 | } | |
0854fb22 | 89 | |
cba17aeb DM |
90 | return $fp; |
91 | } | |
0854fb22 | 92 | |
cba17aeb DM |
93 | my $hostrsapubkey_fn = '/etc/ssh/ssh_host_rsa_key.pub'; |
94 | my $rootrsakey_fn = '/root/.ssh/id_rsa'; | |
95 | my $rootrsapubkey_fn = '/root/.ssh/id_rsa.pub'; | |
0854fb22 | 96 | |
cba17aeb DM |
97 | sub 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,}$/; | |
0854fb22 DM |
107 | |
108 | my $nodename = PVE::INotify::nodename(); | |
109 | ||
cba17aeb | 110 | $res->{name} = $nodename; |
0854fb22 | 111 | |
cba17aeb | 112 | $res->{ip} = PMG::Utils::lookup_node_ip($nodename); |
0854fb22 | 113 | |
cba17aeb | 114 | $res->{hostrsapubkey} = $hostrsapubkey; |
0854fb22 | 115 | |
cba17aeb DM |
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 | ||
139 | my $cert_cache_nodes = {}; | |
140 | my $cert_cache_timestamp = time(); | |
141 | my $cert_cache_fingerprints = {}; | |
0854fb22 | 142 | |
cba17aeb | 143 | sub update_cert_cache { |
0854fb22 | 144 | |
cba17aeb DM |
145 | $cert_cache_timestamp = time(); |
146 | ||
147 | $cert_cache_fingerprints = {}; | |
148 | $cert_cache_nodes = {}; | |
149 | ||
d8782874 | 150 | my $cinfo = PMG::ClusterConfig->new(); |
cba17aeb DM |
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; | |
0854fb22 DM |
158 | } |
159 | } | |
160 | } | |
161 | ||
162 | # load and cache cert fingerprint once | |
163 | sub initialize_cert_cache { | |
164 | my ($node) = @_; | |
165 | ||
cba17aeb | 166 | update_cert_cache() |
0854fb22 DM |
167 | if defined($node) && !defined($cert_cache_nodes->{$node}); |
168 | } | |
169 | ||
170 | sub check_cert_fingerprint { | |
171 | my ($cert) = @_; | |
172 | ||
173 | # clear cache every 30 minutes at least | |
cba17aeb | 174 | update_cert_cache() if time() - $cert_cache_timestamp >= 60*30; |
0854fb22 DM |
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 | ||
cba17aeb | 190 | return 1 if $check->(); |
0854fb22 DM |
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(); | |
cba17aeb | 196 | return $check->(); |
0854fb22 DM |
197 | } |
198 | ||
199 | return 0; | |
200 | } | |
201 | ||
58072364 DM |
202 | my $sshglobalknownhosts = "/etc/ssh/ssh_known_hosts2"; |
203 | my $rootsshauthkeys = "/root/.ssh/authorized_keys"; | |
204 | my $ssh_rsa_id = "/root/.ssh/id_rsa.pub"; | |
205 | ||
206 | sub 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) { | |
a7c7cad7 DM |
231 | my $mykey = PVE::Tools::file_get_contents($rootsshauthkeys, 128*1024); |
232 | chomp($mykey); | |
233 | $data .= "$mykey\n"; | |
58072364 DM |
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 | ||
a7c7cad7 DM |
249 | my $cfgdir = '/etc/pmg'; |
250 | my $syncdir = "$cfgdir/master"; | |
251 | ||
252 | my $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 | ||
58072364 DM |
278 | sub 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 | ||
58072364 | 286 | mkdir $syncdir; |
a7c7cad7 | 287 | File::Path::remove_tree($syncdir, {keep_root => 1}); |
58072364 | 288 | |
a7c7cad7 DM |
289 | my $sa_conf_dir = "/etc/mail/spamassassin"; |
290 | my $sa_custom_cf = "custom.cf"; | |
58072364 DM |
291 | |
292 | my $cmd = ['rsync', '--rsh=ssh -l root -o BatchMode=yes', '-lpgoq', | |
a7c7cad7 | 293 | "${master_ip}:$cfgdir/* ${sa_conf_dir}/${sa_custom_cf}", |
58072364 | 294 | "$syncdir/", |
a7c7cad7 DM |
295 | '--exclude', '*~', |
296 | '--exclude', '*.db', | |
297 | '--exclude', 'pmg-api.pem', | |
298 | '--exclude', 'pmg-tls.pem', | |
299 | ]; | |
58072364 DM |
300 | |
301 | my $errmsg = "syncing master configuration from '${master_ip}' failed"; | |
302 | PVE::Tools::run_command($cmd, errmsg => $errmsg); | |
a7c7cad7 DM |
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); | |
58072364 DM |
347 | } |
348 | ||
0854fb22 | 349 | 1; |