]>
Commit | Line | Data |
---|---|---|
504802b9 WB |
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', | |
24630098 | 48 | format_description => 'usage list', |
504802b9 WB |
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 | local $/ = undef; # slurp mode | |
124 | my $raw = defined($fh) ? <$fh> : ''; | |
125 | my $digest = Digest::SHA::sha1_hex($raw); | |
126 | my $conf = PVE::JSONSchema::parse_config($config_schema, $filename, $raw); | |
127 | $conf->{digest} = $digest; | |
128 | return $conf; | |
129 | } | |
130 | ||
131 | sub write_pmg_node_config { | |
132 | my ($filename, $fh, $cfg) = @_; | |
133 | my $raw = PVE::JSONSchema::dump_config($config_schema, $filename, $cfg); | |
134 | PVE::Tools::safe_print($filename, $fh, $raw); | |
135 | } | |
136 | ||
137 | PVE::INotify::register_file($inotify_file_id, $config_filename, | |
138 | \&read_pmg_node_config, | |
139 | \&write_pmg_node_config, | |
140 | undef, | |
141 | always_call_parser => 1); | |
142 | ||
143 | sub lock_config { | |
144 | my ($code) = @_; | |
145 | my $p = PVE::Tools::lock_file($lockfile, undef, $code); | |
146 | die $@ if $@; | |
147 | return $p; | |
148 | } | |
149 | ||
150 | sub load_config { | |
151 | # auto-adds the standalone plugin if no config is there for backwards | |
152 | # compatibility, so ALWAYS call the cfs registered parser | |
153 | return PVE::INotify::read_file($inotify_file_id); | |
154 | } | |
155 | ||
156 | sub write_config { | |
157 | my ($self) = @_; | |
158 | return PVE::INotify::write_file($inotify_file_id, $self); | |
159 | } | |
160 | ||
161 | # we always convert domain values to lower case, since DNS entries are not case | |
162 | # sensitive and ACME implementations might convert the ordered identifiers | |
163 | # to lower case | |
164 | # FIXME: Could also be shared between PVE and PMG | |
165 | sub get_acme_conf { | |
166 | my ($conf, $noerr) = @_; | |
167 | ||
168 | $conf //= {}; | |
169 | ||
170 | my $res = {}; | |
171 | if (defined($conf->{acme})) { | |
172 | $res = eval { | |
173 | PVE::JSONSchema::parse_property_string($acmedesc, $conf->{acme}) | |
174 | }; | |
175 | if (my $err = $@) { | |
176 | return undef if $noerr; | |
177 | die $err; | |
178 | } | |
179 | my $standalone_domains = delete($res->{domains}) // ''; | |
180 | $res->{domains} = {}; | |
181 | for my $domain (split(";", $standalone_domains)) { | |
182 | $domain = lc($domain); | |
183 | die "duplicate domain '$domain' in ACME config properties\n" | |
184 | if defined($res->{domains}->{$domain}); | |
185 | ||
186 | $res->{domains}->{$domain}->{plugin} = 'standalone'; | |
187 | $res->{domains}->{$domain}->{_configkey} = 'acme'; | |
188 | } | |
189 | } | |
190 | ||
191 | $res->{account} //= 'default'; | |
192 | ||
193 | for my $index (0..$MAXDOMAINS) { | |
194 | my $domain_rec = $conf->{"acmedomain$index"}; | |
195 | next if !defined($domain_rec); | |
196 | ||
197 | my $parsed = eval { | |
198 | PVE::JSONSchema::parse_property_string($acme_domain_desc, $domain_rec) | |
199 | }; | |
200 | if (my $err = $@) { | |
201 | return undef if $noerr; | |
202 | die $err; | |
203 | } | |
204 | my $domain = lc(delete $parsed->{domain}); | |
205 | if (my $exists = $res->{domains}->{$domain}) { | |
206 | return undef if $noerr; | |
207 | die "duplicate domain '$domain' in ACME config properties" | |
208 | ." 'acmedomain$index' and '$exists->{_configkey}'\n"; | |
209 | } | |
210 | $parsed->{plugin} //= 'standalone'; | |
211 | ||
a90140cc | 212 | my $plugins = PMG::API2::ACMEPlugin::load_config(); |
504802b9 WB |
213 | my $plugin_id = $parsed->{plugin}; |
214 | if ($plugin_id ne 'standalone') { | |
504802b9 WB |
215 | die "plugin '$plugin_id' for domain '$domain' not found!\n" |
216 | if !$plugins->{ids}->{$plugin_id}; | |
217 | } | |
218 | ||
fe0886a9 SI |
219 | # validation for wildcard domain names happens on the domain w/o |
220 | # wildcard - see https://tools.ietf.org/html/rfc8555#section-7.1.3 | |
221 | if ($domain =~ /^\*\.(.*)$/ ) { | |
222 | $res->{validationtarget}->{$1} = $domain; | |
a90140cc SI |
223 | die "wildcard domain validation for '$domain' needs a dns-01 plugin.\n" |
224 | if $plugins->{ids}->{$plugin_id}->{type} ne 'dns'; | |
225 | ||
fe0886a9 SI |
226 | } |
227 | ||
504802b9 WB |
228 | $parsed->{_configkey} = "acmedomain$index"; |
229 | $res->{domains}->{$domain} = $parsed; | |
230 | } | |
231 | ||
232 | return $res; | |
233 | } | |
234 | ||
4b01992f WB |
235 | # Helper to filter the domains hash. Returns `undef` if the list is empty. |
236 | sub filter_domains_by_type : prototype($$) { | |
237 | my ($domains, $type) = @_; | |
238 | ||
239 | return undef if !$domains || !%$domains; | |
240 | ||
241 | my $out = {}; | |
242 | ||
243 | foreach my $domain (keys %$domains) { | |
244 | my $entry = $domains->{$domain}; | |
245 | if (grep { $_ eq $type } PVE::Tools::split_list($entry->{usage})) { | |
246 | $out->{$domain} = $entry; | |
247 | } | |
248 | } | |
249 | ||
250 | return undef if !%$out; | |
251 | return $out; | |
252 | } | |
253 | ||
504802b9 | 254 | 1; |