local lfs = require "lfs"; local pctl = require "prosody.util.prosodyctl"; local hi = require "prosody.util.human.io"; local configmanager = require "prosody.core.configmanager"; local openssl; local cert_commands = {}; -- If a file already exists, ask if the user wants to use it or replace it -- Backups the old file if replaced local function use_existing(filename) local attrs = lfs.attributes(filename); if attrs then if hi.show_yesno(filename .. " exists, do you want to replace it? [y/n]") then local backup = filename..".bkp~"..os.date("%FT%T", attrs.change); os.rename(filename, backup); pctl.show_message("%s backed up to %s", filename, backup); else -- Use the existing file return true; end end end local have_pposix, pposix = pcall(require, "prosody.util.pposix"); local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data; if have_pposix and pposix.getuid() == 0 then -- FIXME should be enough to check if this directory is writable local cert_dir = configmanager.get("*", "certificates") or "certs"; cert_basedir = configmanager.resolve_relative_path(prosody.paths.config, cert_dir); end function cert_commands.config(arg) if #arg >= 1 and arg[1] ~= "--help" then local conf_filename = cert_basedir .. "/" .. arg[1] .. ".cnf"; if use_existing(conf_filename) then return nil, conf_filename; end local distinguished_name; if arg[#arg]:find("^/") then distinguished_name = table.remove(arg); end local conf = openssl.config.new(); conf:from_prosody(prosody.hosts, configmanager, arg); if distinguished_name then local dn = {}; for k, v in distinguished_name:gmatch("/([^=/]+)=([^/]+)") do table.insert(dn, k); dn[k] = v; end conf.distinguished_name = dn; else pctl.show_message("Please provide details to include in the certificate config file."); pctl.show_message("Leave the field empty to use the default value or '.' to exclude the field.") for _, k in ipairs(openssl._DN_order) do local v = conf.distinguished_name[k]; if v then local nv = nil; if k == "commonName" then v = arg[1] elseif k == "emailAddress" then v = "xmpp@" .. arg[1]; elseif k == "countryName" then local tld = arg[1]:match"%.([a-z]+)$"; if tld and #tld == 2 and tld ~= "uk" then v = tld:upper(); end end nv = hi.show_prompt(("%s (%s):"):format(k, nv or v)); nv = (not nv or nv == "") and v or nv; if nv:find"[\192-\252][\128-\191]+" then conf.req.string_mask = "utf8only" end conf.distinguished_name[k] = nv ~= "." and nv or nil; end end end local conf_file, err = io.open(conf_filename, "w"); if not conf_file then pctl.show_warning("Could not open OpenSSL config file for writing"); pctl.show_warning("%s", err); os.exit(1); end conf_file:write(conf:serialize()); conf_file:close(); print(""); pctl.show_message("Config written to %s", conf_filename); return nil, conf_filename; else pctl.show_usage("cert config HOSTNAME [HOSTNAME+]", "Builds a certificate config file covering the supplied hostname(s)") end end function cert_commands.key(arg) if #arg >= 1 and arg[1] ~= "--help" then local key_filename = cert_basedir .. "/" .. arg[1] .. ".key"; if use_existing(key_filename) then return nil, key_filename; end os.remove(key_filename); -- This file, if it exists is unlikely to have write permissions local key_size = tonumber(arg[2] or hi.show_prompt("Choose key size (2048):") or 2048); local old_umask = pposix.umask("0377"); if openssl.genrsa{out=key_filename, key_size} then os.execute(("chmod 400 '%s'"):format(key_filename)); pctl.show_message("Key written to %s", key_filename); pposix.umask(old_umask); return nil, key_filename; end pctl.show_message("There was a problem, see OpenSSL output"); else pctl.show_usage("cert key HOSTNAME ", "Generates a RSA key named HOSTNAME.key\n " .."Prompts for a key size if none given") end end function cert_commands.request(arg) if #arg >= 1 and arg[1] ~= "--help" then local req_filename = cert_basedir .. "/" .. arg[1] .. ".req"; if use_existing(req_filename) then return nil, req_filename; end local _, key_filename = cert_commands.key({arg[1]}); local _, conf_filename = cert_commands.config(arg); if openssl.req{new=true, key=key_filename, utf8=true, sha256=true, config=conf_filename, out=req_filename} then pctl.show_message("Certificate request written to %s", req_filename); else pctl.show_message("There was a problem, see OpenSSL output"); end else pctl.show_usage("cert request HOSTNAME [HOSTNAME+]", "Generates a certificate request for the supplied hostname(s)") end end function cert_commands.generate(arg) if #arg >= 1 and arg[1] ~= "--help" then local cert_filename = cert_basedir .. "/" .. arg[1] .. ".crt"; if use_existing(cert_filename) then return nil, cert_filename; end local _, key_filename = cert_commands.key({arg[1]}); local _, conf_filename = cert_commands.config(arg); if key_filename and conf_filename and cert_filename and openssl.req{new=true, x509=true, nodes=true, key=key_filename, days=365, sha256=true, utf8=true, config=conf_filename, out=cert_filename} then pctl.show_message("Certificate written to %s", cert_filename); print(); else pctl.show_message("There was a problem, see OpenSSL output"); end else pctl.show_usage("cert generate HOSTNAME [HOSTNAME+]", "Generates a self-signed certificate for the current hostname(s)") end end local function sh_esc(s) return "'" .. s:gsub("'", "'\\''") .. "'"; end local function copy(from, to, umask, owner, group) local old_umask = umask and pposix.umask(umask); local attrs = lfs.attributes(to); if attrs then -- Move old file out of the way local backup = to..".bkp~"..os.date("%FT%T", attrs.change); os.rename(to, backup); end -- FIXME friendlier error handling, maybe move above backup back? local input = assert(io.open(from)); local output = assert(io.open(to, "w")); local data = input:read(2^11); while data and output:write(data) do data = input:read(2^11); end assert(input:close()); assert(output:close()); if not prosody.installed then -- FIXME this is possibly specific to GNU chown os.execute(("chown -c --reference=%s %s"):format(sh_esc(cert_basedir), sh_esc(to))); elseif owner and group then local ok = os.execute(("chown %s:%s %s"):format(sh_esc(owner), sh_esc(group), sh_esc(to))); assert(ok, "Failed to change ownership of "..to); end if old_umask then pposix.umask(old_umask); end return true; end function cert_commands.import(arg) local hostnames = {}; -- Move hostname arguments out of arg, the rest should be a list of paths while arg[1] and prosody.hosts[ arg[1] ] do table.insert(hostnames, table.remove(arg, 1)); end if hostnames[1] == nil then local domains = os.getenv"RENEWED_DOMAINS"; -- Set if invoked via certbot if domains then for host in domains:gmatch("%S+") do table.insert(hostnames, host); end else for host in pairs(prosody.hosts) do if host ~= "*" and configmanager.get(host, "enabled") ~= false then table.insert(hostnames, host); local http_host = configmanager.get(host, "http_host") or host; if http_host ~= host then table.insert(hostnames, http_host); end end end end end if not arg[1] or arg[1] == "--help" then -- Probably forgot the path pctl.show_usage("cert import [HOSTNAME+] /path/to/certs [/other/paths/]+", "Copies certificates to "..cert_basedir); return 1; end local owner, group; if pposix.getuid() == 0 then -- We need root to change ownership owner = configmanager.get("*", "prosody_user") or "prosody"; group = configmanager.get("*", "prosody_group") or owner; end local cm = require "prosody.core.certmanager"; local files_by_name = {} for _, dir in ipairs(arg) do cm.index_certs(dir, files_by_name); end local imported = {}; table.sort(hostnames, function (a, b) -- Try to find base domain name before sub-domains, then alphabetically, so -- that the order and choice of file name is deterministic. if #a == #b then return a < b; else return #a < #b; end end); for _, host in ipairs(hostnames) do local paths = cm.find_cert_in_index(files_by_name, host); if paths and imported[paths.certificate] then -- One certificate, many names! table.insert(imported, host); elseif paths then local c = copy(paths.certificate, cert_basedir .. "/" .. host .. ".crt", nil, owner, group); local k = copy(paths.key, cert_basedir .. "/" .. host .. ".key", "0377", owner, group); if c and k then table.insert(imported, host); imported[paths.certificate] = true; else if not c then pctl.show_warning("Could not copy certificate '%s'", paths.certificate); end if not k then pctl.show_warning("Could not copy key '%s'", paths.key); end end else -- TODO Say where we looked pctl.show_warning("No certificate for host %s found :(", host); end -- TODO Additional checks -- Certificate names matches the hostname -- Private key matches public key in certificate end if imported[1] then pctl.show_message("Imported certificate and key for hosts %s", table.concat(imported, ", ")); local ok, err = pctl.reload(); if not ok and err ~= "not-running" then pctl.show_message(pctl.error_messages[err]); end else pctl.show_warning("No certificates imported :("); return 1; end end local function cert(arg) if #arg >= 1 and arg[1] ~= "--help" then openssl = require "prosody.util.openssl"; lfs = require "lfs"; local cert_dir_attrs = lfs.attributes(cert_basedir); if not cert_dir_attrs then pctl.show_warning("The directory %s does not exist", cert_basedir); return 1; -- TODO Should we create it? end local uid = pposix.getuid(); if uid ~= 0 and uid ~= cert_dir_attrs.uid then pctl.show_warning("The directory %s is not owned by the current user, won't be able to write files to it", cert_basedir); return 1; elseif not cert_dir_attrs.permissions then -- COMPAT with LuaFilesystem < 1.6.2 (hey CentOS!) pctl.show_message("Unable to check permissions on %s (LuaFilesystem 1.6.2+ required)", cert_basedir); pctl.show_message("Please confirm that Prosody (and only Prosody) can write to this directory)"); elseif cert_dir_attrs.permissions:match("^%.w..%-..%-.$") then pctl.show_warning("The directory %s not only writable by its owner", cert_basedir); return 1; end local subcmd = table.remove(arg, 1); if type(cert_commands[subcmd]) == "function" then if subcmd ~= "import" then -- hostnames are optional for import if not arg[1] then pctl.show_message"You need to supply at least one hostname" arg = { "--help" }; end if arg[1] ~= "--help" and not prosody.hosts[arg[1]] then pctl.show_message(pctl.error_messages["no-such-host"]); return 1; end end return cert_commands[subcmd](arg); elseif subcmd == "check" then return require "prosody.util.prosodyctl.check".check({"certs"}); end end pctl.show_usage("cert config|request|generate|key|import", "Helpers for generating X.509 certificates and keys.") for _, cmd in pairs(cert_commands) do print() cmd{ "--help" } end end return { cert = cert; };