]> git.proxmox.com Git - pmg-api.git/blob - src/PMG/NodeConfig.pm
RuleCache: reorganize to keep group structure
[pmg-api.git] / src / PMG / NodeConfig.pm
1 package PMG::NodeConfig;
2
3 use strict;
4 use warnings;
5
6 use Digest::SHA;
7
8 use PVE::INotify;
9 use PVE::JSONSchema qw(get_standard_option);
10 use PVE::Tools;
11
12 use PMG::API2::ACMEPlugin;
13 use PMG::CertHelpers;
14
15 # register up to 5 domain names per node for now
16 my $MAXDOMAINS = 5;
17
18 my $inotify_file_id = 'pmg-node-config.conf';
19 my $config_filename = '/etc/pmg/node.conf';
20 my $lockfile = "/var/lock/pmg-node-config.lck";
21
22 my $acme_domain_desc = {
23 domain => {
24 type => 'string',
25 format => 'pmg-acme-domain',
26 format_description => 'domain',
27 description => 'domain for this node\'s ACME certificate',
28 default_key => 1,
29 },
30 plugin => {
31 type => 'string',
32 format => 'pve-configid',
33 description => 'The ACME plugin ID',
34 format_description => 'name of the plugin configuration',
35 optional => 1,
36 default => 'standalone',
37 },
38 alias => {
39 type => 'string',
40 format => 'pmg-acme-alias',
41 format_description => 'domain',
42 description => 'Alias for the Domain to verify ACME Challenge over DNS',
43 optional => 1,
44 },
45 usage => {
46 type => 'string',
47 format => 'pmg-certificate-type-list',
48 format_description => 'usage list',
49 description => 'Whether this domain is used for the API, SMTP or both',
50 },
51 };
52
53 my $acmedesc = {
54 account => get_standard_option('pmg-acme-account-name'),
55 };
56
57 my $confdesc = {
58 acme => {
59 type => 'string',
60 description => 'Node specific ACME settings.',
61 format => $acmedesc,
62 optional => 1,
63 },
64 map {(
65 "acmedomain$_" => {
66 type => 'string',
67 description => 'ACME domain and validation plugin',
68 format => $acme_domain_desc,
69 optional => 1,
70 },
71 )} (0..$MAXDOMAINS),
72 };
73
74 sub acme_config_schema : prototype(;$) {
75 my ($overrides) = @_;
76
77 $overrides //= {};
78
79 return {
80 type => 'object',
81 additionalProperties => 0,
82 properties => {
83 %$confdesc,
84 %$overrides,
85 },
86 }
87 }
88
89 my $config_schema = acme_config_schema();
90
91 # Parse the config's acme property string if it exists.
92 #
93 # Returns nothing if the entry is not set.
94 sub parse_acme : prototype($) {
95 my ($cfg) = @_;
96 my $data = $cfg->{acme};
97 if (defined($data)) {
98 return PVE::JSONSchema::parse_property_string($acmedesc, $data);
99 }
100 return; # empty list otherwise
101 }
102
103 # Turn the acme object into a property string.
104 sub print_acme : prototype($) {
105 my ($acme) = @_;
106 return PVE::JSONSchema::print_property_string($acmedesc, $acme);
107 }
108
109 # Parse a domain entry from the config.
110 sub parse_domain : prototype($) {
111 my ($data) = @_;
112 return PVE::JSONSchema::parse_property_string($acme_domain_desc, $data);
113 }
114
115 # Turn a domain object into a property string.
116 sub print_domain : prototype($) {
117 my ($domain) = @_;
118 return PVE::JSONSchema::print_property_string($acme_domain_desc, $domain);
119 }
120
121 sub read_pmg_node_config {
122 my ($filename, $fh) = @_;
123 my $raw = defined($fh) ? do { local $/ = undef; <$fh> } : '';
124 my $digest = Digest::SHA::sha1_hex($raw);
125 my $conf = PVE::JSONSchema::parse_config($config_schema, $filename, $raw);
126 $conf->{digest} = $digest;
127 return $conf;
128 }
129
130 sub write_pmg_node_config {
131 my ($filename, $fh, $cfg) = @_;
132 my $raw = PVE::JSONSchema::dump_config($config_schema, $filename, $cfg);
133
134 # higher level ACME sanity checking
135 get_acme_conf($cfg);
136 PVE::Tools::safe_print($filename, $fh, $raw);
137 }
138
139 PVE::INotify::register_file($inotify_file_id, $config_filename,
140 \&read_pmg_node_config,
141 \&write_pmg_node_config,
142 undef,
143 always_call_parser => 1);
144
145 sub lock_config {
146 my ($code) = @_;
147 my $p = PVE::Tools::lock_file($lockfile, undef, $code);
148 die $@ if $@;
149 return $p;
150 }
151
152 sub load_config {
153 # auto-adds the standalone plugin if no config is there for backwards
154 # compatibility, so ALWAYS call the cfs registered parser
155 return PVE::INotify::read_file($inotify_file_id);
156 }
157
158 sub write_config {
159 my ($self) = @_;
160 return PVE::INotify::write_file($inotify_file_id, $self);
161 }
162
163 # we always convert domain values to lower case, since DNS entries are not case
164 # sensitive and ACME implementations might convert the ordered identifiers
165 # to lower case
166 # FIXME: Could also be shared between PVE and PMG
167 sub get_acme_conf {
168 my ($conf, $noerr) = @_;
169
170 $conf //= {};
171
172 my $res = {};
173 if (defined($conf->{acme})) {
174 $res = eval {
175 PVE::JSONSchema::parse_property_string($acmedesc, $conf->{acme})
176 };
177 if (my $err = $@) {
178 return undef if $noerr;
179 die $err;
180 }
181 my $standalone_domains = delete($res->{domains}) // '';
182 $res->{domains} = {};
183 for my $domain (split(";", $standalone_domains)) {
184 $domain = lc($domain);
185 die "duplicate domain '$domain' in ACME config properties\n"
186 if defined($res->{domains}->{$domain});
187
188 $res->{domains}->{$domain}->{plugin} = 'standalone';
189 $res->{domains}->{$domain}->{_configkey} = 'acme';
190 }
191 }
192
193 $res->{account} //= 'default';
194
195 for my $index (0..$MAXDOMAINS) {
196 my $domain_rec = $conf->{"acmedomain$index"};
197 next if !defined($domain_rec);
198
199 my $parsed = eval {
200 PVE::JSONSchema::parse_property_string($acme_domain_desc, $domain_rec)
201 };
202 if (my $err = $@) {
203 return undef if $noerr;
204 die $err;
205 }
206 my $domain = lc(delete $parsed->{domain});
207 if (my $exists = $res->{domains}->{$domain}) {
208 return undef if $noerr;
209 die "duplicate domain '$domain' in ACME config properties"
210 ." 'acmedomain$index' and '$exists->{_configkey}'\n";
211 }
212 $parsed->{plugin} //= 'standalone';
213
214 my $plugins = PMG::API2::ACMEPlugin::load_config();
215 my $plugin_id = $parsed->{plugin};
216 if ($plugin_id ne 'standalone') {
217 die "plugin '$plugin_id' for domain '$domain' not found!\n"
218 if !$plugins->{ids}->{$plugin_id};
219 }
220
221 # validation for wildcard domain names happens on the domain w/o
222 # wildcard - see https://tools.ietf.org/html/rfc8555#section-7.1.3
223 if ($domain =~ /^\*\.(.*)$/ ) {
224 $res->{validationtarget}->{$1} = $domain;
225 die "wildcard domain validation for '$domain' needs a dns-01 plugin.\n"
226 if $plugins->{ids}->{$plugin_id}->{type} ne 'dns';
227
228 }
229
230 $parsed->{_configkey} = "acmedomain$index";
231 $res->{domains}->{$domain} = $parsed;
232 }
233
234 return $res;
235 }
236
237 # Helper to filter the domains hash. Returns `undef` if the list is empty.
238 sub filter_domains_by_type : prototype($$) {
239 my ($domains, $type) = @_;
240
241 return undef if !$domains || !%$domains;
242
243 my $out = {};
244
245 foreach my $domain (keys %$domains) {
246 my $entry = $domains->{$domain};
247 if (grep { $_ eq $type } PVE::Tools::split_list($entry->{usage})) {
248 $out->{$domain} = $entry;
249 }
250 }
251
252 return undef if !%$out;
253 return $out;
254 }
255
256 1;