]> git.proxmox.com Git - pve-manager.git/blob - PVE/NodeConfig.pm
fix #2784: always compare ACME domains in lower case
[pve-manager.git] / PVE / NodeConfig.pm
1 package PVE::NodeConfig;
2
3 use strict;
4 use warnings;
5
6 use PVE::CertHelpers;
7 use PVE::JSONSchema qw(get_standard_option);
8 use PVE::Tools qw(file_get_contents file_set_contents lock_file);
9 use PVE::ACME;
10
11 use PVE::API2::ACMEPlugin;
12
13 # register up to 5 domain names per node for now
14 my $MAXDOMAINS = 5;
15
16 my $node_config_lock = '/var/lock/pvenode.lock';
17
18 PVE::JSONSchema::register_format('pve-acme-domain', sub {
19 my ($domain, $noerr) = @_;
20
21 my $label = qr/[a-z0-9][a-z0-9_-]*/i;
22
23 return $domain if $domain =~ /^$label(?:\.$label)+$/;
24 return undef if $noerr;
25 die "value '$domain' does not look like a valid domain name!\n";
26 });
27
28 sub config_file {
29 my ($node) = @_;
30
31 return "/etc/pve/nodes/${node}/config";
32 }
33
34 sub load_config {
35 my ($node) = @_;
36
37 my $filename = config_file($node);
38 my $raw = eval { PVE::Tools::file_get_contents($filename); };
39 return {} if !$raw;
40
41 return parse_node_config($raw);
42 }
43
44 sub write_config {
45 my ($node, $conf) = @_;
46
47 my $filename = config_file($node);
48
49 my $raw = write_node_config($conf);
50
51 PVE::Tools::file_set_contents($filename, $raw);
52 }
53
54 sub lock_config {
55 my ($node, $realcode, @param) = @_;
56
57 # make sure configuration file is up-to-date
58 my $code = sub {
59 PVE::Cluster::cfs_update();
60 $realcode->(@_);
61 };
62
63 my $res = lock_file($node_config_lock, 10, $code, @param);
64
65 die $@ if $@;
66
67 return $res;
68 }
69
70 my $confdesc = {
71 description => {
72 type => 'string',
73 description => 'Node description/comment.',
74 optional => 1,
75 },
76 wakeonlan => {
77 type => 'string',
78 description => 'MAC address for wake on LAN',
79 format => 'mac-addr',
80 optional => 1,
81 },
82 'startall-onboot-delay' => {
83 description => 'Initial delay in seconds, before starting all the Virtual Guests with on-boot enabled.',
84 type => 'integer',
85 minimum => 0,
86 maximum => 300,
87 default => 0,
88 optional => 1,
89 },
90 };
91
92 my $acme_domain_desc = {
93 domain => {
94 type => 'string',
95 format => 'pve-acme-domain',
96 format_description => 'domain',
97 description => 'domain for this node\'s ACME certificate',
98 default_key => 1,
99 },
100 plugin => {
101 type => 'string',
102 format => 'pve-configid',
103 description => 'The ACME plugin ID',
104 format_description => 'name of the plugin configuration',
105 optional => 1,
106 default => 'standalone',
107 },
108 alias => {
109 type => 'string',
110 format => 'pve-acme-domain',
111 format_description => 'domain',
112 description => 'Alias for the Domain to verify ACME Challenge over DNS',
113 optional => 1,
114 },
115 };
116
117 my $acmedesc = {
118 account => get_standard_option('pve-acme-account-name'),
119 domains => {
120 type => 'string',
121 format => 'pve-acme-domain-list',
122 format_description => 'domain[;domain;...]',
123 description => 'List of domains for this node\'s ACME certificate',
124 optional => 1,
125 },
126 };
127
128 $confdesc->{acme} = {
129 type => 'string',
130 description => 'Node specific ACME settings.',
131 format => $acmedesc,
132 optional => 1,
133 };
134
135 for my $i (0..$MAXDOMAINS) {
136 $confdesc->{"acmedomain$i"} = {
137 type => 'string',
138 description => 'ACME domain and validation plugin',
139 format => $acme_domain_desc,
140 optional => 1,
141 };
142 };
143
144 sub check_type {
145 my ($key, $value) = @_;
146
147 die "unknown setting '$key'\n" if !$confdesc->{$key};
148
149 my $type = $confdesc->{$key}->{type};
150
151 if (!defined($value)) {
152 die "got undefined value\n";
153 }
154
155 if ($value =~ m/[\n\r]/) {
156 die "property contains a line feed\n";
157 }
158
159 if ($type eq 'boolean') {
160 return 1 if ($value eq '1') || ($value =~ m/^(on|yes|true)$/i);
161 return 0 if ($value eq '0') || ($value =~ m/^(off|no|false)$/i);
162 die "type check ('boolean') failed - got '$value'\n";
163 } elsif ($type eq 'integer') {
164 return int($1) if $value =~ m/^(\d+)$/;
165 die "type check ('integer') failed - got '$value'\n";
166 } elsif ($type eq 'number') {
167 return $value if $value =~ m/^(\d+)(\.\d+)?$/;
168 die "type check ('number') failed - got '$value'\n";
169 } elsif ($type eq 'string') {
170 if (my $fmt = $confdesc->{$key}->{format}) {
171 PVE::JSONSchema::check_format($fmt, $value);
172 return $value;
173 } elsif (my $pattern = $confdesc->{$key}->{pattern}) {
174 if ($value !~ m/^$pattern$/) {
175 die "value does not match the regex pattern\n";
176 }
177 }
178 return $value;
179 } else {
180 die "internal error"
181 }
182 }
183
184 sub parse_node_config {
185 my ($content) = @_;
186
187 return undef if !defined($content);
188
189 my $conf = {
190 digest => Digest::SHA::sha1_hex($content),
191 };
192 my $descr = '';
193
194 my @lines = split(/\n/, $content);
195 foreach my $line (@lines) {
196 if ($line =~ /^\#(.*)\s*$/ || $line =~ /^description:\s*(.*\S)\s*$/) {
197 $descr .= PVE::Tools::decode_text($1) . "\n";
198 next;
199 }
200 if ($line =~ /^([a-z][a-z-_]*\d*):\s*(\S.*)\s*$/) {
201 my $key = $1;
202 my $value = $2;
203 $value = eval { check_type($key, $value) };
204 die "cannot parse value of '$key' in node config: $@" if $@;
205 $conf->{$key} = $value;
206 } else {
207 warn "cannot parse line '$line' in node config\n";
208 }
209 }
210
211 $conf->{description} = $descr if $descr;
212
213 return $conf;
214 }
215
216 sub write_node_config {
217 my ($conf) = @_;
218
219 my $raw = '';
220 # add description as comment to top of file
221 my $descr = $conf->{description} || '';
222 foreach my $cl (split(/\n/, $descr)) {
223 $raw .= '#' . PVE::Tools::encode_text($cl) . "\n";
224 }
225
226 for my $key (sort keys %$conf) {
227 next if ($key eq 'description');
228 next if ($key eq 'digest');
229
230 my $value = $conf->{$key};
231 die "detected invalid newline inside property '$key'\n"
232 if $value =~ m/\n/;
233 $raw .= "$key: $value\n";
234 }
235
236 return $raw;
237 }
238
239 # we always convert domain values to lower case, since DNS entries are not case
240 # sensitive and ACME implementations might convert the ordered identifiers
241 # to lower case
242 sub get_acme_conf {
243 my ($node_conf, $noerr) = @_;
244
245 $node_conf //= {};
246
247 my $res = {};
248 if (defined($node_conf->{acme})) {
249 $res = eval {
250 PVE::JSONSchema::parse_property_string($acmedesc, $node_conf->{acme})
251 };
252 if (my $err = $@) {
253 return undef if $noerr;
254 die $err;
255 }
256 my $standalone_domains = delete($res->{domains}) // '';
257 $res->{domains} = {};
258 for my $domain (split(";", $standalone_domains)) {
259 $domain = lc($domain);
260 die "duplicate domain '$domain' in ACME config properties\n"
261 if defined($res->{domains}->{$domain});
262
263 $res->{domains}->{$domain}->{plugin} = 'standalone';
264 $res->{domains}->{$domain}->{_configkey} = 'acme';
265 }
266 }
267
268 $res->{account} //= 'default';
269
270 for my $index (0..$MAXDOMAINS) {
271 my $domain_rec = $node_conf->{"acmedomain$index"};
272 next if !defined($domain_rec);
273
274 my $parsed = eval {
275 PVE::JSONSchema::parse_property_string($acme_domain_desc, $domain_rec)
276 };
277 if (my $err = $@) {
278 return undef if $noerr;
279 die $err;
280 }
281 my $domain = lc(delete $parsed->{domain});
282 if (my $exists = $res->{domains}->{$domain}) {
283 return undef if $noerr;
284 die "duplicate domain '$domain' in ACME config properties"
285 ." 'acmedomain$index' and '$exists->{_configkey}'\n";
286 }
287 $parsed->{plugin} //= 'standalone';
288
289 my $plugin_id = $parsed->{plugin};
290 if ($plugin_id ne 'standalone') {
291 my $plugins = PVE::API2::ACMEPlugin::load_config();
292 die "plugin '$plugin_id' for domain '$domain' not found!\n"
293 if !$plugins->{ids}->{$plugin_id};
294 }
295
296 $parsed->{_configkey} = "acmedomain$index";
297 $res->{domains}->{$domain} = $parsed;
298 }
299
300 return $res;
301 }
302
303 # expects that basic format verification was already done, this is more higher
304 # level verification
305 sub verify_conf {
306 my ($node_conf) = @_;
307
308 # verify ACME domain uniqueness
309 my $tmp = get_acme_conf($node_conf);
310
311 # TODO: what else?
312
313 return 1; # OK
314 }
315
316 sub get_nodeconfig_schema {
317 return $confdesc;
318 }
319
320 1;