Comparison

util/prosodyctl/cert.lua @ 10871:e5dee71d0ebb

prosodyctl+util.prosodyctl.*: Start breaking up the ever-growing prosodyctl
author Matthew Wild <mwild1@gmail.com>
date Tue, 02 Jun 2020 08:01:21 +0100
child 11203:d10f59ac7f74
comparison
equal deleted inserted replaced
10870:3f1889608f3e 10871:e5dee71d0ebb
1 local lfs = require "lfs";
2
3 local pctl = require "util.prosodyctl";
4 local configmanager = require "core.configmanager";
5
6 local openssl;
7
8 local cert_commands = {};
9
10 -- If a file already exists, ask if the user wants to use it or replace it
11 -- Backups the old file if replaced
12 local function use_existing(filename)
13 local attrs = lfs.attributes(filename);
14 if attrs then
15 if pctl.show_yesno(filename .. " exists, do you want to replace it? [y/n]") then
16 local backup = filename..".bkp~"..os.date("%FT%T", attrs.change);
17 os.rename(filename, backup);
18 pctl.show_message("%s backed up to %s", filename, backup);
19 else
20 -- Use the existing file
21 return true;
22 end
23 end
24 end
25
26 local have_pposix, pposix = pcall(require, "util.pposix");
27 local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data;
28 if have_pposix and pposix.getuid() == 0 then
29 -- FIXME should be enough to check if this directory is writable
30 local cert_dir = configmanager.get("*", "certificates") or "certs";
31 cert_basedir = configmanager.resolve_relative_path(prosody.paths.config, cert_dir);
32 end
33
34 function cert_commands.config(arg)
35 if #arg >= 1 and arg[1] ~= "--help" then
36 local conf_filename = cert_basedir .. "/" .. arg[1] .. ".cnf";
37 if use_existing(conf_filename) then
38 return nil, conf_filename;
39 end
40 local distinguished_name;
41 if arg[#arg]:find("^/") then
42 distinguished_name = table.remove(arg);
43 end
44 local conf = openssl.config.new();
45 conf:from_prosody(prosody.hosts, configmanager, arg);
46 if distinguished_name then
47 local dn = {};
48 for k, v in distinguished_name:gmatch("/([^=/]+)=([^/]+)") do
49 table.insert(dn, k);
50 dn[k] = v;
51 end
52 conf.distinguished_name = dn;
53 else
54 pctl.show_message("Please provide details to include in the certificate config file.");
55 pctl.show_message("Leave the field empty to use the default value or '.' to exclude the field.")
56 for _, k in ipairs(openssl._DN_order) do
57 local v = conf.distinguished_name[k];
58 if v then
59 local nv = nil;
60 if k == "commonName" then
61 v = arg[1]
62 elseif k == "emailAddress" then
63 v = "xmpp@" .. arg[1];
64 elseif k == "countryName" then
65 local tld = arg[1]:match"%.([a-z]+)$";
66 if tld and #tld == 2 and tld ~= "uk" then
67 v = tld:upper();
68 end
69 end
70 nv = pctl.show_prompt(("%s (%s):"):format(k, nv or v));
71 nv = (not nv or nv == "") and v or nv;
72 if nv:find"[\192-\252][\128-\191]+" then
73 conf.req.string_mask = "utf8only"
74 end
75 conf.distinguished_name[k] = nv ~= "." and nv or nil;
76 end
77 end
78 end
79 local conf_file, err = io.open(conf_filename, "w");
80 if not conf_file then
81 pctl.show_warning("Could not open OpenSSL config file for writing");
82 pctl.show_warning(err);
83 os.exit(1);
84 end
85 conf_file:write(conf:serialize());
86 conf_file:close();
87 print("");
88 pctl.show_message("Config written to %s", conf_filename);
89 return nil, conf_filename;
90 else
91 pctl.show_usage("cert config HOSTNAME [HOSTNAME+]", "Builds a certificate config file covering the supplied hostname(s)")
92 end
93 end
94
95 function cert_commands.key(arg)
96 if #arg >= 1 and arg[1] ~= "--help" then
97 local key_filename = cert_basedir .. "/" .. arg[1] .. ".key";
98 if use_existing(key_filename) then
99 return nil, key_filename;
100 end
101 os.remove(key_filename); -- This file, if it exists is unlikely to have write permissions
102 local key_size = tonumber(arg[2] or pctl.show_prompt("Choose key size (2048):") or 2048);
103 local old_umask = pposix.umask("0377");
104 if openssl.genrsa{out=key_filename, key_size} then
105 os.execute(("chmod 400 '%s'"):format(key_filename));
106 pctl.show_message("Key written to %s", key_filename);
107 pposix.umask(old_umask);
108 return nil, key_filename;
109 end
110 pctl.show_message("There was a problem, see OpenSSL output");
111 else
112 pctl.show_usage("cert key HOSTNAME <bits>", "Generates a RSA key named HOSTNAME.key\n "
113 .."Prompts for a key size if none given")
114 end
115 end
116
117 function cert_commands.request(arg)
118 if #arg >= 1 and arg[1] ~= "--help" then
119 local req_filename = cert_basedir .. "/" .. arg[1] .. ".req";
120 if use_existing(req_filename) then
121 return nil, req_filename;
122 end
123 local _, key_filename = cert_commands.key({arg[1]});
124 local _, conf_filename = cert_commands.config(arg);
125 if openssl.req{new=true, key=key_filename, utf8=true, sha256=true, config=conf_filename, out=req_filename} then
126 pctl.show_message("Certificate request written to %s", req_filename);
127 else
128 pctl.show_message("There was a problem, see OpenSSL output");
129 end
130 else
131 pctl.show_usage("cert request HOSTNAME [HOSTNAME+]", "Generates a certificate request for the supplied hostname(s)")
132 end
133 end
134
135 function cert_commands.generate(arg)
136 if #arg >= 1 and arg[1] ~= "--help" then
137 local cert_filename = cert_basedir .. "/" .. arg[1] .. ".crt";
138 if use_existing(cert_filename) then
139 return nil, cert_filename;
140 end
141 local _, key_filename = cert_commands.key({arg[1]});
142 local _, conf_filename = cert_commands.config(arg);
143 if key_filename and conf_filename and cert_filename
144 and openssl.req{new=true, x509=true, nodes=true, key=key_filename,
145 days=365, sha256=true, utf8=true, config=conf_filename, out=cert_filename} then
146 pctl.show_message("Certificate written to %s", cert_filename);
147 print();
148 else
149 pctl.show_message("There was a problem, see OpenSSL output");
150 end
151 else
152 pctl.show_usage("cert generate HOSTNAME [HOSTNAME+]", "Generates a self-signed certificate for the current hostname(s)")
153 end
154 end
155
156 local function sh_esc(s)
157 return "'" .. s:gsub("'", "'\\''") .. "'";
158 end
159
160 local function copy(from, to, umask, owner, group)
161 local old_umask = umask and pposix.umask(umask);
162 local attrs = lfs.attributes(to);
163 if attrs then -- Move old file out of the way
164 local backup = to..".bkp~"..os.date("%FT%T", attrs.change);
165 os.rename(to, backup);
166 end
167 -- FIXME friendlier error handling, maybe move above backup back?
168 local input = assert(io.open(from));
169 local output = assert(io.open(to, "w"));
170 local data = input:read(2^11);
171 while data and output:write(data) do
172 data = input:read(2^11);
173 end
174 assert(input:close());
175 assert(output:close());
176 if not prosody.installed then
177 -- FIXME this is possibly specific to GNU chown
178 os.execute(("chown -c --reference=%s %s"):format(sh_esc(cert_basedir), sh_esc(to)));
179 elseif owner and group then
180 local ok = os.execute(("chown %s:%s %s"):format(sh_esc(owner), sh_esc(group), sh_esc(to)));
181 assert(ok == true or ok == 0, "Failed to change ownership of "..to);
182 end
183 if old_umask then pposix.umask(old_umask); end
184 return true;
185 end
186
187 function cert_commands.import(arg)
188 local hostnames = {};
189 -- Move hostname arguments out of arg, the rest should be a list of paths
190 while arg[1] and prosody.hosts[ arg[1] ] do
191 table.insert(hostnames, table.remove(arg, 1));
192 end
193 if hostnames[1] == nil then
194 local domains = os.getenv"RENEWED_DOMAINS"; -- Set if invoked via certbot
195 if domains then
196 for host in domains:gmatch("%S+") do
197 table.insert(hostnames, host);
198 end
199 else
200 for host in pairs(prosody.hosts) do
201 if host ~= "*" and configmanager.get(host, "enabled") ~= false then
202 table.insert(hostnames, host);
203 end
204 end
205 end
206 end
207 if not arg[1] or arg[1] == "--help" then -- Probably forgot the path
208 pctl.show_usage("cert import [HOSTNAME+] /path/to/certs [/other/paths/]+",
209 "Copies certificates to "..cert_basedir);
210 return 1;
211 end
212 local owner, group;
213 if pposix.getuid() == 0 then -- We need root to change ownership
214 owner = configmanager.get("*", "prosody_user") or "prosody";
215 group = configmanager.get("*", "prosody_group") or owner;
216 end
217 local cm = require "core.certmanager";
218 local imported = {};
219 for _, host in ipairs(hostnames) do
220 for _, dir in ipairs(arg) do
221 local paths = cm.find_cert(dir, host);
222 if paths then
223 copy(paths.certificate, cert_basedir .. "/" .. host .. ".crt", nil, owner, group);
224 copy(paths.key, cert_basedir .. "/" .. host .. ".key", "0377", owner, group);
225 table.insert(imported, host);
226 else
227 -- TODO Say where we looked
228 pctl.show_warning("No certificate for host "..host.." found :(");
229 end
230 -- TODO Additional checks
231 -- Certificate names matches the hostname
232 -- Private key matches public key in certificate
233 end
234 end
235 if imported[1] then
236 pctl.show_message("Imported certificate and key for hosts %s", table.concat(imported, ", "));
237 local ok, err = pctl.reload();
238 if not ok and err ~= "not-running" then
239 pctl.show_message(pctl.error_messages[err]);
240 end
241 else
242 pctl.show_warning("No certificates imported :(");
243 return 1;
244 end
245 end
246
247 local function cert(arg)
248 if #arg >= 1 and arg[1] ~= "--help" then
249 openssl = require "util.openssl";
250 lfs = require "lfs";
251 local cert_dir_attrs = lfs.attributes(cert_basedir);
252 if not cert_dir_attrs then
253 pctl.show_warning("The directory "..cert_basedir.." does not exist");
254 return 1; -- TODO Should we create it?
255 end
256 local uid = pposix.getuid();
257 if uid ~= 0 and uid ~= cert_dir_attrs.uid then
258 pctl.show_warning("The directory "..cert_basedir.." is not owned by the current user, won't be able to write files to it");
259 return 1;
260 elseif not cert_dir_attrs.permissions then -- COMPAT with LuaFilesystem < 1.6.2 (hey CentOS!)
261 pctl.show_message("Unable to check permissions on %s (LuaFilesystem 1.6.2+ required)", cert_basedir);
262 pctl.show_message("Please confirm that Prosody (and only Prosody) can write to this directory)");
263 elseif cert_dir_attrs.permissions:match("^%.w..%-..%-.$") then
264 pctl.show_warning("The directory "..cert_basedir.." not only writable by its owner");
265 return 1;
266 end
267 local subcmd = table.remove(arg, 1);
268 if type(cert_commands[subcmd]) == "function" then
269 if subcmd ~= "import" then -- hostnames are optional for import
270 if not arg[1] then
271 pctl.show_message"You need to supply at least one hostname"
272 arg = { "--help" };
273 end
274 if arg[1] ~= "--help" and not prosody.hosts[arg[1]] then
275 pctl.show_message(pctl.error_messages["no-such-host"]);
276 return 1;
277 end
278 end
279 return cert_commands[subcmd](arg);
280 elseif subcmd == "check" then
281 return require "util.prosodyctl.check".check({"certs"});
282 end
283 end
284 pctl.show_usage("cert config|request|generate|key|import", "Helpers for generating X.509 certificates and keys.")
285 for _, cmd in pairs(cert_commands) do
286 print()
287 cmd{ "--help" }
288 end
289 end
290
291 return {
292 cert = cert;
293 };