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