pve-manager/PVE/NodeConfig.pm
Thomas Lamprecht 75afd54a01 node config: verify abstract relations on write
for now mostly due to the "nice" property of the acmedomains which
do not use their property key as index but actually the doamain.

Without this one could set up duplicated domain entries just fine,
but once using them -> error.
This is not nice UX, so verify node config before writing an updated
one out, to catch those issues.

Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2020-05-03 14:10:17 +02:00

297 lines
6.8 KiB
Perl

package PVE::NodeConfig;
use strict;
use warnings;
use PVE::CertHelpers;
use PVE::JSONSchema qw(get_standard_option);
use PVE::Tools qw(file_get_contents file_set_contents lock_file);
use PVE::ACME;
# register up to 20 domain names
my $MAXDOMAINS = 20;
my $node_config_lock = '/var/lock/pvenode.lock';
PVE::JSONSchema::register_format('pve-acme-domain', sub {
my ($domain, $noerr) = @_;
my $label = qr/[a-z0-9][a-z0-9_-]*/i;
return $domain if $domain =~ /^$label(?:\.$label)+$/;
return undef if $noerr;
die "value does not look like a valid domain name";
});
sub config_file {
my ($node) = @_;
return "/etc/pve/nodes/${node}/config";
}
sub load_config {
my ($node) = @_;
my $filename = config_file($node);
my $raw = eval { PVE::Tools::file_get_contents($filename); };
return {} if !$raw;
return parse_node_config($raw);
}
sub write_config {
my ($node, $conf) = @_;
my $filename = config_file($node);
my $raw = write_node_config($conf);
PVE::Tools::file_set_contents($filename, $raw);
}
sub lock_config {
my ($node, $code, @param) = @_;
my $res = lock_file($node_config_lock, 10, $code, @param);
die $@ if $@;
return $res;
}
my $confdesc = {
description => {
type => 'string',
description => 'Node description/comment.',
optional => 1,
},
wakeonlan => {
type => 'string',
description => 'MAC address for wake on LAN',
format => 'mac-addr',
optional => 1,
},
'startall-onboot-delay' => {
description => 'Initial delay in seconds, before starting all the Virtual Guests with on-boot enabled.',
type => 'integer',
minimum => 0,
maximum => 300,
default => 0,
optional => 1,
},
};
my $acme_domain_desc = {
domain => {
type => 'string',
format => 'pve-acme-domain',
format_description => 'domain',
description => 'domain for this node\'s ACME certificate',
default_key => 1,
},
plugin => {
type => 'string',
format => 'pve-configid',
description => 'The ACME plugin ID',
format_description => 'name of the plugin configuration',
optional => 1,
default => 'standalone',
},
alias => {
type => 'string',
format => 'pve-acme-domain',
format_description => 'domain',
description => 'Alias for the Domain to verify ACME Challenge over DNS',
optional => 1,
},
};
my $acmedesc = {
account => get_standard_option('pve-acme-account-name'),
domains => {
type => 'string',
format => 'pve-acme-domain-list',
format_description => 'domain[;domain;...]',
description => 'List of domains for this node\'s ACME certificate',
optional => 1,
},
};
$confdesc->{acme} = {
type => 'string',
description => 'Node specific ACME settings.',
format => $acmedesc,
optional => 1,
};
for my $i (0..$MAXDOMAINS) {
$confdesc->{"acmedomain$i"} = {
type => 'string',
description => 'ACME domain and validation plugin',
format => $acme_domain_desc,
optional => 1,
};
};
sub check_type {
my ($key, $value) = @_;
die "unknown setting '$key'\n" if !$confdesc->{$key};
my $type = $confdesc->{$key}->{type};
if (!defined($value)) {
die "got undefined value\n";
}
if ($value =~ m/[\n\r]/) {
die "property contains a line feed\n";
}
if ($type eq 'boolean') {
return 1 if ($value eq '1') || ($value =~ m/^(on|yes|true)$/i);
return 0 if ($value eq '0') || ($value =~ m/^(off|no|false)$/i);
die "type check ('boolean') failed - got '$value'\n";
} elsif ($type eq 'integer') {
return int($1) if $value =~ m/^(\d+)$/;
die "type check ('integer') failed - got '$value'\n";
} elsif ($type eq 'number') {
return $value if $value =~ m/^(\d+)(\.\d+)?$/;
die "type check ('number') failed - got '$value'\n";
} elsif ($type eq 'string') {
if (my $fmt = $confdesc->{$key}->{format}) {
PVE::JSONSchema::check_format($fmt, $value);
return $value;
} elsif (my $pattern = $confdesc->{$key}->{pattern}) {
if ($value !~ m/^$pattern$/) {
die "value does not match the regex pattern\n";
}
}
return $value;
} else {
die "internal error"
}
}
sub parse_node_config {
my ($content) = @_;
return undef if !defined($content);
my $conf = {
digest => Digest::SHA::sha1_hex($content),
};
my $descr = '';
my @lines = split(/\n/, $content);
foreach my $line (@lines) {
if ($line =~ /^\#(.*)\s*$/ || $line =~ /^description:\s*(.*\S)\s*$/) {
$descr .= PVE::Tools::decode_text($1) . "\n";
next;
}
if ($line =~ /^([a-z][a-z-_]*\d*):\s*(\S.*)\s*$/) {
my $key = $1;
my $value = $2;
$value = eval { check_type($key, $value) };
die "cannot parse value of '$key' in node config: $@" if $@;
$conf->{$key} = $value;
} else {
warn "cannot parse line '$line' in node config\n";
}
}
$conf->{description} = $descr if $descr;
return $conf;
}
sub write_node_config {
my ($conf) = @_;
my $raw = '';
# add description as comment to top of file
my $descr = $conf->{description} || '';
foreach my $cl (split(/\n/, $descr)) {
$raw .= '#' . PVE::Tools::encode_text($cl) . "\n";
}
for my $key (sort keys %$conf) {
next if ($key eq 'description');
next if ($key eq 'digest');
my $value = $conf->{$key};
die "detected invalid newline inside property '$key'\n"
if $value =~ m/\n/;
$raw .= "$key: $value\n";
}
return $raw;
}
sub get_acme_conf {
my ($node_conf, $noerr) = @_;
$node_conf //= {};
my $res = {};
if (defined($node_conf->{acme})) {
$res = eval {
PVE::JSONSchema::parse_property_string($acmedesc, $node_conf->{acme})
};
if (my $err = $@) {
return undef if $noerr;
die $err;
}
my $standalone_domains = delete($res->{domains}) // '';
for my $domain (split(";", $standalone_domains)) {
$res->{domains}->{$domain}->{plugin} = 'standalone';
$res->{domains}->{$domain}->{_configkey} = 'acme';
}
}
$res->{account} //= 'default';
for my $index (0..$MAXDOMAINS) {
my $domain_rec = $node_conf->{"acmedomain$index"};
next if !defined($domain_rec);
my $parsed = eval {
PVE::JSONSchema::parse_property_string($acme_domain_desc, $domain_rec)
};
if (my $err = $@) {
return undef if $noerr;
die $err;
}
my $domain = delete $parsed->{domain};
if (my $exists = $res->{domains}->{$domain}) {
return undef if $noerr;
die "duplicate domain '$domain' in ACME config properties"
." 'acmedomain$index' and '$exists->{_configkey}'\n";
}
$parsed->{plugin} //= 'standalone';
$parsed->{_configkey} = "acmedomain$index";
$res->{domains}->{$domain} = $parsed;
}
return $res;
}
# expects that basic format verification was already done, this is more higher
# level verification
sub verify_conf {
my ($node_conf) = @_;
# verify ACME domain uniqueness
my $tmp = get_acme_conf($node_conf);
# TODO: what else?
return 1; # OK
}
sub get_nodeconfig_schema {
return $confdesc;
}
1;