]> git.proxmox.com Git - pve-cluster.git/blob - data/PVE/CLI/pvecm.pm
pvecm addnode: ensure ring address isn't already used by cluster
[pve-cluster.git] / data / PVE / CLI / pvecm.pm
1 package PVE::CLI::pvecm;
2
3 use strict;
4 use warnings;
5 use Getopt::Long;
6 use Socket;
7 use IO::File;
8 use Net::IP;
9 use File::Path;
10 use File::Basename;
11 use Data::Dumper; # fixme: remove
12 use PVE::Tools;
13 use PVE::Cluster;
14 use PVE::INotify;
15 use PVE::JSONSchema;
16 use PVE::CLIHandler;
17
18 use base qw(PVE::CLIHandler);
19
20 $ENV{HOME} = '/root'; # for ssh-copy-id
21
22 my $basedir = "/etc/pve";
23 my $clusterconf = "$basedir/corosync.conf";
24 my $libdir = "/var/lib/pve-cluster";
25 my $backupdir = "/var/lib/pve-cluster/backup";
26 my $dbfile = "$libdir/config.db";
27 my $authfile = "/etc/corosync/authkey";
28
29 sub backup_database {
30
31 print "backup old database\n";
32
33 mkdir $backupdir;
34
35 my $ctime = time();
36 my $cmd = [
37 ['echo', '.dump'],
38 ['sqlite3', $dbfile],
39 ['gzip', '-', \ ">${backupdir}/config-${ctime}.sql.gz"],
40 ];
41
42 PVE::Tools::run_command($cmd, 'errmsg' => "cannot backup old database\n");
43
44 # purge older backup
45 my $maxfiles = 10;
46
47 my @bklist = ();
48 foreach my $fn (<$backupdir/config-*.sql.gz>) {
49 if ($fn =~ m!/config-(\d+)\.sql.gz$!) {
50 push @bklist, [$fn, $1];
51 }
52 }
53
54 @bklist = sort { $b->[1] <=> $a->[1] } @bklist;
55
56 while (scalar (@bklist) >= $maxfiles) {
57 my $d = pop @bklist;
58 print "delete old backup '$d->[0]'\n";
59 unlink $d->[0];
60 }
61 }
62
63 __PACKAGE__->register_method ({
64 name => 'keygen',
65 path => 'keygen',
66 method => 'PUT',
67 description => "Generate new cryptographic key for corosync.",
68 parameters => {
69 additionalProperties => 0,
70 properties => {
71 filename => {
72 type => 'string',
73 description => "Output file name"
74 }
75 },
76 },
77 returns => { type => 'null' },
78
79 code => sub {
80 my ($param) = @_;
81
82 my $filename = $param->{filename};
83
84 # test EUID
85 $> == 0 || die "Error: Authorization key must be generated as root user.\n";
86 my $dirname = dirname($filename);
87 my $basename = basename($filename);
88
89 die "key file '$filename' already exists\n" if -e $filename;
90
91 File::Path::make_path($dirname) if $dirname;
92
93 my $cmd = ['corosync-keygen', '-l', '-k', $filename];
94 PVE::Tools::run_command($cmd);
95
96 return undef;
97 }});
98
99 __PACKAGE__->register_method ({
100 name => 'create',
101 path => 'create',
102 method => 'PUT',
103 description => "Generate new cluster configuration.",
104 parameters => {
105 additionalProperties => 0,
106 properties => {
107 clustername => {
108 description => "The name of the cluster.",
109 type => 'string', format => 'pve-node',
110 maxLength => 15,
111 },
112 nodeid => {
113 type => 'integer',
114 description => "Node id for this node.",
115 minimum => 1,
116 optional => 1,
117 },
118 votes => {
119 type => 'integer',
120 description => "Number of votes for this node.",
121 minimum => 1,
122 optional => 1,
123 },
124 bindnet0_addr => {
125 type => 'string', format => 'ip',
126 description => "This specifies the network address the corosync ring 0".
127 " executive should bind to and defaults to the local IP address of the node.",
128 optional => 1,
129 },
130 ring0_addr => {
131 type => 'string', format => 'address',
132 description => "Hostname (or IP) of the corosync ring0 address of this node.".
133 " Defaults to the hostname of the node.",
134 optional => 1,
135 },
136 bindnet1_addr => {
137 type => 'string', format => 'ip',
138 description => "This specifies the network address the corosync ring 1".
139 " executive should bind to and is optional.",
140 optional => 1,
141 },
142 ring1_addr => {
143 type => 'string', format => 'address',
144 description => "Hostname (or IP) of the corosync ring1 address, this".
145 " needs an valid bindnet1_addr.",
146 optional => 1,
147 },
148 },
149 },
150 returns => { type => 'null' },
151
152 code => sub {
153 my ($param) = @_;
154
155 -f $clusterconf && die "cluster config '$clusterconf' already exists\n";
156
157 PVE::Cluster::setup_sshd_config(1);
158 PVE::Cluster::setup_rootsshconfig();
159 PVE::Cluster::setup_ssh_keys();
160
161 -f $authfile || __PACKAGE__->keygen({filename => $authfile});
162
163 -f $authfile || die "no authentication key available\n";
164
165 my $clustername = $param->{clustername};
166
167 $param->{nodeid} = 1 if !$param->{nodeid};
168
169 $param->{votes} = 1 if !defined($param->{votes});
170
171 my $nodename = PVE::INotify::nodename();
172
173 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
174
175 $param->{bindnet0_addr} = $local_ip_address
176 if !defined($param->{bindnet0_addr});
177
178 $param->{ring0_addr} = $nodename if !defined($param->{ring0_addr});
179
180 die "Param bindnet1_addr and ring1_addr are dependend, use both or none!\n"
181 if (defined($param->{bindnet1_addr}) != defined($param->{ring1_addr}));
182
183 my $bind_is_ipv6 = Net::IP::ip_is_ipv6($param->{bindnet0_addr});
184
185 # use string as here-doc format distracts more
186 my $interfaces = "interface {\n ringnumber: 0\n" .
187 " bindnetaddr: $param->{bindnet0_addr}\n }";
188
189 my $ring_addresses = "ring0_addr: $param->{ring0_addr}" ;
190
191 # allow use of multiple rings (rrp) at cluster creation time
192 if ($param->{bindnet1_addr}) {
193 die "IPv6 and IPv4 cannot be mixed, use one or the other!\n"
194 if Net::IP::ip_is_ipv6($param->{bindnet1_addr}) != $bind_is_ipv6;
195
196 $interfaces .= "\n interface {\n ringnumber: 1\n" .
197 " bindnetaddr: $param->{bindnet1_addr}\n }\n";
198
199 $interfaces .= "rrp_mode: passive\n"; # only passive is stable and tested
200
201 $ring_addresses .= "\n ring1_addr: $param->{ring1_addr}";
202
203 } elsif($param->{rrp_mode} && $param->{rrp_mode} ne 'none') {
204
205 warn "rrp_mode '$param->{rrp_mode}' useless when using only one".
206 " ring, using 'none' instead";
207 # corosync defaults to none if only one interface is configured
208 $param->{rrp_mode} = undef;
209
210 }
211
212 # No, corosync cannot deduce this on its own
213 my $ipversion = $bind_is_ipv6 ? 'ipv6' : 'ipv4';
214
215 my $config = <<_EOD;
216 totem {
217 version: 2
218 secauth: on
219 cluster_name: $clustername
220 config_version: 1
221 ip_version: $ipversion
222 $interfaces
223 }
224
225 nodelist {
226 node {
227 $ring_addresses
228 name: $nodename
229 nodeid: $param->{nodeid}
230 quorum_votes: $param->{votes}
231 }
232 }
233
234 quorum {
235 provider: corosync_votequorum
236 }
237
238 logging {
239 to_syslog: yes
240 debug: off
241 }
242 _EOD
243 ;
244 PVE::Tools::file_set_contents($clusterconf, $config);
245
246 PVE::Cluster::ssh_merge_keys();
247
248 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address);
249
250 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address, 1);
251
252 PVE::Tools::run_command('systemctl restart pve-cluster'); # restart
253
254 PVE::Tools::run_command('systemctl restart corosync'); # restart
255
256 return undef;
257 }});
258
259 __PACKAGE__->register_method ({
260 name => 'addnode',
261 path => 'addnode',
262 method => 'PUT',
263 description => "Adds a node to the cluster configuration.",
264 parameters => {
265 additionalProperties => 0,
266 properties => {
267 node => PVE::JSONSchema::get_standard_option('pve-node'),
268 nodeid => {
269 type => 'integer',
270 description => "Node id for this node.",
271 minimum => 1,
272 optional => 1,
273 },
274 votes => {
275 type => 'integer',
276 description => "Number of votes for this node",
277 minimum => 0,
278 optional => 1,
279 },
280 force => {
281 type => 'boolean',
282 description => "Do not throw error if node already exists.",
283 optional => 1,
284 },
285 ring0_addr => {
286 type => 'string', format => 'address',
287 description => "Hostname (or IP) of the corosync ring0 address of this node.".
288 " Defaults to nodes hostname.",
289 optional => 1,
290 },
291 ring1_addr => {
292 type => 'string', format => 'address',
293 description => "Hostname (or IP) of the corosync ring1 address, this".
294 " needs an valid bindnet1_addr.",
295 optional => 1,
296 },
297 },
298 },
299 returns => { type => 'null' },
300
301 code => sub {
302 my ($param) = @_;
303
304 if (!$param->{force} && (-t STDIN || -t STDOUT)) {
305 die "error: `addnode` should not get called interactively!\nUse ".
306 "`pvecm add <cluster-node>` to add a node to a cluster!\n";
307 }
308
309 PVE::Cluster::check_cfs_quorum();
310
311 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
312
313 my $nodelist = PVE::Cluster::corosync_nodelist($conf);
314
315 my $totem_cfg = PVE::Cluster::corosync_totem_config($conf);
316
317 my $name = $param->{node};
318
319 # ensure we do not reuse an address, that can crash the whole cluster!
320 my $check_duplicate_addr = sub {
321 my $addr = shift;
322 return if !defined($addr);
323
324 while (my ($k, $v) = each %$nodelist) {
325 next if $k eq $name; # allows re-adding a node if force is set
326 if ($v->{ring0_addr} eq $addr || ($v->{ring1_addr} && $v->{ring1_addr} eq $addr)) {
327 die "corosync: address '$addr' already defined by node '$k'\n";
328 }
329 }
330 };
331
332 &$check_duplicate_addr($param->{ring0_addr});
333 &$check_duplicate_addr($param->{ring1_addr});
334
335 $param->{ring0_addr} = $name if !$param->{ring0_addr};
336
337 die " ring1_addr needs a configured ring 1 interface!\n"
338 if $param->{ring1_addr} && !defined($totem_cfg->{interface}->{1});
339
340 if (defined(my $res = $nodelist->{$name})) {
341 $param->{nodeid} = $res->{nodeid} if !$param->{nodeid};
342 $param->{votes} = $res->{quorum_votes} if !defined($param->{votes});
343
344 if ($res->{quorum_votes} == $param->{votes} &&
345 $res->{nodeid} == $param->{nodeid}) {
346 print "node $name already defined\n";
347 if ($param->{force}) {
348 exit (0);
349 } else {
350 exit (-1);
351 }
352 } else {
353 die "can't add existing node\n";
354 }
355 } elsif (!$param->{nodeid}) {
356 my $nodeid = 1;
357
358 while(1) {
359 my $found = 0;
360 foreach my $v (values %$nodelist) {
361 if ($v->{nodeid} eq $nodeid) {
362 $found = 1;
363 $nodeid++;
364 last;
365 }
366 }
367 last if !$found;
368 };
369
370 $param->{nodeid} = $nodeid;
371 }
372
373 $param->{votes} = 1 if !defined($param->{votes});
374
375 PVE::Cluster::gen_local_dirs($name);
376
377 eval { PVE::Cluster::ssh_merge_keys(); };
378 warn $@ if $@;
379
380 $nodelist->{$name} = {
381 ring0_addr => $param->{ring0_addr},
382 nodeid => $param->{nodeid},
383 name => $name,
384 };
385 $nodelist->{$name}->{ring1_addr} = $param->{ring1_addr} if $param->{ring1_addr};
386 $nodelist->{$name}->{quorum_votes} = $param->{votes} if $param->{votes};
387
388 PVE::Cluster::corosync_update_nodelist($conf, $nodelist);
389
390 exit (0);
391 }});
392
393
394 __PACKAGE__->register_method ({
395 name => 'delnode',
396 path => 'delnode',
397 method => 'PUT',
398 description => "Removes a node to the cluster configuration.",
399 parameters => {
400 additionalProperties => 0,
401 properties => {
402 node => {
403 type => 'string',
404 description => "Hostname or IP of the corosync ring0 address of this node.",
405 },
406 },
407 },
408 returns => { type => 'null' },
409
410 code => sub {
411 my ($param) = @_;
412
413 PVE::Cluster::check_cfs_quorum();
414
415 my $conf = PVE::Cluster::cfs_read_file("corosync.conf");
416
417 my $nodelist = PVE::Cluster::corosync_nodelist($conf);
418
419 my $node;
420 my $nodeid;
421
422 foreach my $tmp_node (keys %$nodelist) {
423 my $d = $nodelist->{$tmp_node};
424 my $ring0_addr = $d->{ring0_addr};
425 my $ring1_addr = $d->{ring1_addr};
426 if (($tmp_node eq $param->{node}) ||
427 (defined($ring0_addr) && ($ring0_addr eq $param->{node})) ||
428 (defined($ring1_addr) && ($ring1_addr eq $param->{node}))) {
429 $node = $tmp_node;
430 $nodeid = $d->{nodeid};
431 last;
432 }
433 }
434
435 die "Node/IP: $param->{node} is not a known host of the cluster.\n"
436 if !defined($node);
437
438 delete $nodelist->{$node};
439
440 PVE::Cluster::corosync_update_nodelist($conf, $nodelist);
441
442 PVE::Tools::run_command(['corosync-cfgtool','-k', $nodeid])
443 if defined($nodeid);
444
445 return undef;
446 }});
447
448 __PACKAGE__->register_method ({
449 name => 'add',
450 path => 'add',
451 method => 'PUT',
452 description => "Adds the current node to an existing cluster.",
453 parameters => {
454 additionalProperties => 0,
455 properties => {
456 hostname => {
457 type => 'string',
458 description => "Hostname (or IP) of an existing cluster member."
459 },
460 nodeid => {
461 type => 'integer',
462 description => "Node id for this node.",
463 minimum => 1,
464 optional => 1,
465 },
466 votes => {
467 type => 'integer',
468 description => "Number of votes for this node",
469 minimum => 0,
470 optional => 1,
471 },
472 force => {
473 type => 'boolean',
474 description => "Do not throw error if node already exists.",
475 optional => 1,
476 },
477 ring0_addr => {
478 type => 'string', format => 'address',
479 description => "Hostname (or IP) of the corosync ring0 address of this node.".
480 " Defaults to nodes hostname.",
481 optional => 1,
482 },
483 ring1_addr => {
484 type => 'string', format => 'address',
485 description => "Hostname (or IP) of the corosync ring1 address, this".
486 " needs an valid configured ring 1 interface in the cluster.",
487 optional => 1,
488 },
489 },
490 },
491 returns => { type => 'null' },
492
493 code => sub {
494 my ($param) = @_;
495
496 my $nodename = PVE::INotify::nodename();
497
498 PVE::Cluster::setup_sshd_config(1);
499 PVE::Cluster::setup_rootsshconfig();
500 PVE::Cluster::setup_ssh_keys();
501
502 my $host = $param->{hostname};
503
504 if (!$param->{force}) {
505
506 if (-f $authfile) {
507 die "authentication key already exists\n";
508 }
509
510 if (-f $clusterconf) {
511 die "cluster config '$clusterconf' already exists\n";
512 }
513
514 my $vmlist = PVE::Cluster::get_vmlist();
515 if ($vmlist && $vmlist->{ids} && scalar(keys %{$vmlist->{ids}})) {
516 die "this host already contains virtual machines - please remove them first\n";
517 }
518
519 if (system("corosync-quorumtool -l >/dev/null 2>&1") == 0) {
520 die "corosync is already running\n";
521 }
522 }
523
524 # make sure known_hosts is on local filesystem
525 PVE::Cluster::ssh_unmerge_known_hosts();
526
527 my $cmd = ['ssh-copy-id', '-i', '/root/.ssh/id_rsa', "root\@$host"];
528 PVE::Tools::run_command($cmd, 'outfunc' => sub {}, 'errfunc' => sub {},
529 'errmsg' => "unable to copy ssh ID");
530
531 $cmd = ['ssh', $host, '-o', 'BatchMode=yes',
532 'pvecm', 'addnode', $nodename, '--force', 1];
533
534 push @$cmd, '--nodeid', $param->{nodeid} if $param->{nodeid};
535
536 push @$cmd, '--votes', $param->{votes} if defined($param->{votes});
537
538 push @$cmd, '--ring0_addr', $param->{ring0_addr} if defined($param->{ring0_addr});
539
540 push @$cmd, '--ring1_addr', $param->{ring1_addr} if defined($param->{ring1_addr});
541
542 if (system (@$cmd) != 0) {
543 my $cmdtxt = join (' ', @$cmd);
544 die "unable to add node: command failed ($cmdtxt)\n";
545 }
546
547 my $tmpdir = "$libdir/.pvecm_add.tmp.$$";
548 mkdir $tmpdir;
549
550 eval {
551 print "copy corosync auth key\n";
552 $cmd = ['rsync', '--rsh=ssh -l root -o BatchMode=yes', '-lpgoq',
553 "[$host]:$authfile $clusterconf", $tmpdir];
554
555 system(@$cmd) == 0 || die "can't rsync data from host '$host'\n";
556
557 mkdir "/etc/corosync";
558 my $confbase = basename($clusterconf);
559
560 $cmd = "cp '$tmpdir/$confbase' '/etc/corosync/$confbase'";
561 system($cmd) == 0 || die "can't copy cluster configuration\n";
562
563 my $keybase = basename($authfile);
564 system ("cp '$tmpdir/$keybase' '$authfile'") == 0 ||
565 die "can't copy '$tmpdir/$keybase' to '$authfile'\n";
566
567 print "stopping pve-cluster service\n";
568
569 system("umount $basedir -f >/dev/null 2>&1");
570 system("systemctl stop pve-cluster") == 0 ||
571 die "can't stop pve-cluster service\n";
572
573 backup_database();
574
575 unlink $dbfile;
576
577 system("systemctl start pve-cluster") == 0 ||
578 die "starting pve-cluster failed\n";
579
580 system("systemctl start corosync");
581
582 # wait for quorum
583 my $printqmsg = 1;
584 while (!PVE::Cluster::check_cfs_quorum(1)) {
585 if ($printqmsg) {
586 print "waiting for quorum...";
587 STDOUT->flush();
588 $printqmsg = 0;
589 }
590 sleep(1);
591 }
592 print "OK\n" if !$printqmsg;
593
594 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
595
596 print "generating node certificates\n";
597 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address);
598
599 print "merge known_hosts file\n";
600 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address, 1);
601
602 print "restart services\n";
603 # restart pvedaemon (changed certs)
604 system("systemctl restart pvedaemon");
605 # restart pveproxy (changed certs)
606 system("systemctl restart pveproxy");
607
608 print "successfully added node '$nodename' to cluster.\n";
609 };
610 my $err = $@;
611
612 rmtree $tmpdir;
613
614 die $err if $err;
615
616 return undef;
617 }});
618
619 __PACKAGE__->register_method ({
620 name => 'status',
621 path => 'status',
622 method => 'GET',
623 description => "Displays the local view of the cluster status.",
624 parameters => {
625 additionalProperties => 0,
626 properties => {},
627 },
628 returns => { type => 'null' },
629
630 code => sub {
631 my ($param) = @_;
632
633 PVE::Cluster::check_corosync_conf_exists();
634
635 my $cmd = ['corosync-quorumtool', '-siH'];
636
637 exec (@$cmd);
638
639 exit (-1); # should not be reached
640 }});
641
642 __PACKAGE__->register_method ({
643 name => 'nodes',
644 path => 'nodes',
645 method => 'GET',
646 description => "Displays the local view of the cluster nodes.",
647 parameters => {
648 additionalProperties => 0,
649 properties => {},
650 },
651 returns => { type => 'null' },
652
653 code => sub {
654 my ($param) = @_;
655
656 PVE::Cluster::check_corosync_conf_exists();
657
658 my $cmd = ['corosync-quorumtool', '-l'];
659
660 exec (@$cmd);
661
662 exit (-1); # should not be reached
663 }});
664
665 __PACKAGE__->register_method ({
666 name => 'expected',
667 path => 'expected',
668 method => 'PUT',
669 description => "Tells corosync a new value of expected votes.",
670 parameters => {
671 additionalProperties => 0,
672 properties => {
673 expected => {
674 type => 'integer',
675 description => "Expected votes",
676 minimum => 1,
677 },
678 },
679 },
680 returns => { type => 'null' },
681
682 code => sub {
683 my ($param) = @_;
684
685 PVE::Cluster::check_corosync_conf_exists();
686
687 my $cmd = ['corosync-quorumtool', '-e', $param->{expected}];
688
689 exec (@$cmd);
690
691 exit (-1); # should not be reached
692
693 }});
694
695 __PACKAGE__->register_method ({
696 name => 'updatecerts',
697 path => 'updatecerts',
698 method => 'PUT',
699 description => "Update node certificates (and generate all needed files/directories).",
700 parameters => {
701 additionalProperties => 0,
702 properties => {
703 force => {
704 description => "Force generation of new SSL certifate.",
705 type => 'boolean',
706 optional => 1,
707 },
708 silent => {
709 description => "Ignore errors (i.e. when cluster has no quorum).",
710 type => 'boolean',
711 optional => 1,
712 },
713 },
714 },
715 returns => { type => 'null' },
716 code => sub {
717 my ($param) = @_;
718
719 PVE::Cluster::setup_sshd_config(0);
720 PVE::Cluster::setup_rootsshconfig();
721
722 PVE::Cluster::gen_pve_vzdump_symlink();
723
724 if (!PVE::Cluster::check_cfs_quorum(1)) {
725 return undef if $param->{silent};
726 die "no quorum - unable to update files\n";
727 }
728
729 PVE::Cluster::setup_ssh_keys();
730
731 my $nodename = PVE::INotify::nodename();
732
733 my $local_ip_address = PVE::Cluster::remote_node_ip($nodename);
734
735 PVE::Cluster::gen_pve_node_files($nodename, $local_ip_address, $param->{force});
736 PVE::Cluster::ssh_merge_keys();
737 PVE::Cluster::ssh_merge_known_hosts($nodename, $local_ip_address);
738 PVE::Cluster::gen_pve_vzdump_files();
739
740 return undef;
741 }});
742
743 __PACKAGE__->register_method ({
744 name => 'mtunnel',
745 path => 'mtunnel',
746 method => 'POST',
747 description => "Used by VM/CT migration - do not use manually.",
748 parameters => {
749 additionalProperties => 0,
750 properties => {
751 get_migration_ip => {
752 type => 'boolean',
753 default => 0,
754 description => 'return the migration IP, if configured',
755 optional => 1,
756 },
757 migration_network => {
758 type => 'string',
759 format => 'CIDR',
760 description => 'the migration network used to detect the local migration IP',
761 optional => 1,
762 },
763 },
764 },
765 returns => { type => 'null'},
766 code => sub {
767 my ($param) = @_;
768
769 if (!PVE::Cluster::check_cfs_quorum(1)) {
770 print "no quorum\n";
771 return undef;
772 }
773
774 if ($param->{get_migration_ip}) {
775 my $network = $param->{migration_network};
776 if (my $ip = PVE::Cluster::get_local_migration_ip($network)) {
777 print "ip: '$ip'\n";
778 } else {
779 print "no ip\n";
780 }
781 # do not keep tunnel open when asked for migration ip
782 return undef;
783 }
784
785 print "tunnel online\n";
786 *STDOUT->flush();
787
788 while (my $line = <>) {
789 chomp $line;
790 last if $line =~ m/^quit$/;
791 }
792
793 return undef;
794 }});
795
796
797 our $cmddef = {
798 keygen => [ __PACKAGE__, 'keygen', ['filename']],
799 create => [ __PACKAGE__, 'create', ['clustername']],
800 add => [ __PACKAGE__, 'add', ['hostname']],
801 addnode => [ __PACKAGE__, 'addnode', ['node']],
802 delnode => [ __PACKAGE__, 'delnode', ['node']],
803 status => [ __PACKAGE__, 'status' ],
804 nodes => [ __PACKAGE__, 'nodes' ],
805 expected => [ __PACKAGE__, 'expected', ['expected']],
806 updatecerts => [ __PACKAGE__, 'updatecerts', []],
807 mtunnel => [ __PACKAGE__, 'mtunnel', []],
808 };
809
810 1;