From: Sergey Ponomarev Date: Sat, 1 Jun 2024 19:30:37 +0000 (+0300) Subject: luci-app-acme: Add Log reader X-Git-Url: http://git.openwrt.org/?a=commitdiff_plain;h=65d4b4871099fa97da6d7f877b6819e60971af4e;p=project%2Fluci.git luci-app-acme: Add Log reader Signed-off-by: Sergey Ponomarev --- diff --git a/applications/luci-app-acme/htdocs/luci-static/resources/view/acme.js b/applications/luci-app-acme/htdocs/luci-static/resources/view/acme.js deleted file mode 100644 index 6e6fd5f7f1..0000000000 --- a/applications/luci-app-acme/htdocs/luci-static/resources/view/acme.js +++ /dev/null @@ -1,511 +0,0 @@ -'use strict'; -'require form'; -'require fs'; -'require uci'; -'require ui'; -'require view'; -"require view.dnsapi as dnsapi"; - -return view.extend({ - load() { - return Promise.all([ - L.resolveDefault(fs.list('/etc/ssl/acme/'), []).then(files => { - let certs = []; - for (let f of files) { - if (f.type == 'file' && f.name.match(/\.key$/)) { - certs.push(f); - } - } - return certs; - }), - L.resolveDefault(fs.exec_direct('/usr/libexec/acmesh-dnsinfo.sh'), ''), - L.resolveDefault(fs.stat('/usr/lib/acme/client/dnsapi'), null), - L.resolveDefault(fs.lines('/proc/sys/kernel/hostname'), ''), - L.resolveDefault(uci.load('ddns')), - ]); - }, - - render(data) { - let certs = data[0]; - let dnsApiInfoText = data[1]; - let apiInfos = dnsapi.parseFile(dnsApiInfoText); - let hasDnsApi = data[2] != null; - let hostname = data[3]; - let systemDomain = _guessDomain(hostname); - let ddnsDomains = _collectDdnsDomains(); - let wikiUrl = 'https://github.com/acmesh-official/acme.sh/wiki/'; - let wikiInstructionUrl = wikiUrl + 'dnsapi'; - let m, s, o; - - m = new form.Map("acme", _("ACME certificates"), - _("This configures ACME (Letsencrypt) automatic certificate installation. " + - "Simply fill out this to have the router configured with Letsencrypt-issued " + - "certificates for the web interface. " + - "Note that the domain names in the certificate must already be configured to " + - "point at the router's public IP address. " + - "Once configured, issuing certificates can take a while. " + - "Check the logs for progress and any errors.") + '
' + - _("Cert files are stored in") + ' /etc/ssl/acme'+ '
' + - '' + _('See more') + '' - ); - - s = m.section(form.TypedSection, "acme", _("ACME global config")); - s.anonymous = true; - - o = s.option(form.Value, "account_email", _("Account email"), - _('Email address to associate with account key.') + '
' + - _('If a certificate wasn\'t renewed in time then you\'ll receive a notice at 20 days before expiry.') - ); - o.rmempty = false; - o.datatype = "minlength(1)"; - - o = s.option(form.Flag, "debug", _("Enable debug logging")); - o.rmempty = false; - - if (ddnsDomains && ddnsDomains.length > 0) { - let ddnsDomainsList = ddnsDomains.map(d => d.domains[0]); - o = s.option(form.Button, '_import_ddns'); - o.title = _('Found DDNS domains'); - o.inputtitle = _('Import') + ': ' + ddnsDomainsList.join(); - o.inputstyle = 'apply'; - o.onclick = function () { - _importDdns(ddnsDomains); - }; - } - - s = m.section(form.GridSection, "cert", _("Certificate config")); - s.anonymous = false; - s.addremove = true; - s.nodescriptions = true; - - o = s.tab("general", _("General Settings")); - o = s.tab('challenge_webroot', _('Webroot Challenge Validation')); - o = s.tab('challenge_dns', _('DNS Challenge Validation')); - o = s.tab("advanced", _('Advanced Settings')); - - o = s.taboption('general', form.Flag, "enabled", _("Enabled")); - o.rmempty = false; - - o = s.taboption('general', form.ListValue, 'validation_method', _('Validation method'), - _('Standalone mode will use the built-in webserver of acme.sh to issue a certificate. ' + - 'Webroot mode will use an existing webserver to issue a certificate. ' + - 'DNS mode will allow you to use the DNS API of your DNS provider to issue a certificate.') - ); - o.value('standalone', _('Standalone')); - o.value('webroot', _('Webroot')); - o.value('dns', _('DNS')); - o.default = 'standalone'; - - if (!hasDnsApi) { - let dnsApiPkg = 'acme-acmesh-dnsapi'; - o = s.taboption('general', form.Button, '_install'); - o.depends('validation_method', 'dns'); - o.title = _('Package is not installed'); - o.inputtitle = _('Install package %s').format(dnsApiPkg); - o.inputstyle = 'apply'; - o.onclick = function () { - let link = L.url('admin/system/package-manager') + '?query=' + dnsApiPkg; - window.open(link, '_blank', 'noopener'); - }; - } - - o = s.taboption('general', form.DynamicList, "domains", _("Domain names"), - _("Domain names to include in the certificate. " + - "The first name will be the subject name, subsequent names will be alt names. " + - "Note that all domain names must point at the router in the global DNS.")); - o.datatype = "list(string)"; - if (systemDomain) { - o.default = [systemDomain]; - } - o.validate = function (section_id, value) { - if (!value) { - return true; - } - if (!/^[*a-z0-9][a-z0-9.-]*$/.test(value)) { - return _('Invalid domain. Allowed lowercase a-z, numbers and hyphen -'); - } - if (value.startsWith('*')) { - let method = this.section.children.filter(function (o) { return o.option == 'validation_method'; })[0].formvalue(section_id); - if (method && method !== 'dns') { - return _('wildcards * require Validation method: DNS'); - } - } - return true; - }; - - o = s.taboption('challenge_webroot', form.Value, 'webroot', _('Webroot directory'), - _("Webserver root directory. Set this to the webserver " + - "document root to run Acme in webroot mode. The web " + - "server must be accessible from the internet on port 80.") + '
' + - _("Default") + " /var/run/acme/challenge/" - ); - o.optional = true; - o.depends("validation_method", "webroot"); - o.modalonly = true; - - o = s.taboption('challenge_dns', form.ListValue, 'dns', _('DNS API'), - _("To use DNS mode to issue certificates, set this to the name of a DNS API supported by acme.sh. " + - "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi for the list of available APIs. " + - "In DNS mode, the domain name does not have to resolve to the router IP. " + - "DNS mode is also the only mode that supports wildcard certificates. " + - "Using this mode requires the acme-dnsapi package to be installed.")); - o.depends("validation_method", "dns"); - // List of supported DNS API. Names are same as file names in acme.sh for easier search. - // May be outdated but not changed too often. - o.value('', ''); - for (let info of apiInfos) { - let title = info.Name; - if (info.Domains) { - title += ' (' + info.Domains + ')'; - } - o.value(info.Id, title); - } - o.modalonly = true; - o.onchange = _handleCheckService; - - o = s.taboption('challenge_dns', form.DummyValue, '_wiki_url', _('See instructions'), ''); - o.rawhtml = true; - o.default = 'Acme Wiki DNS API' - .format(wikiInstructionUrl); - o.depends('validation_method', 'dns'); - o.modalonly = true; - - o = s.taboption('challenge_dns', form.Flag, '_dns_options_alt', _('Alternative DNS API options'), ''); - o.modalonly = true; - - for (let info of apiInfos) { - if (info.OptsTitle) { - o = s.taboption('challenge_dns', form.DummyValue, '_dns_OptsTitle_' + info.Id, ' ', ''); - o.default = info.OptsTitle; - o.depends({'dns': info.Id, '_dns_options_alt': '0'}); - o.modalonly = true; - } - for (let opt of info.Opts) { - _addDnsProviderField(s, info.Id, opt, false); - } - if (info.OptsAltTitle) { - o = s.taboption('challenge_dns', form.DummyValue, '_dns_OptsAltTitle_' + info.Id, ' ', ''); - o.default = info.OptsAltTitle; - o.depends({'dns': info.Id, '_dns_options_alt': '1'}); - o.modalonly = true; - } - for (let opt of info.OptsAlt) { - _addDnsProviderField(s, info.Id, opt, true); - } - } - - o = s.taboption('challenge_dns', form.DynamicList, 'credentials', _('DNS API credentials'), - _("The credentials for the DNS API mode selected above. " + - "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi for the format of credentials required by each API. " + - "Add multiple entries here in KEY=VAL shell variable format to supply multiple credential variables.")); - o.datatype = "list(string)"; - o.depends("validation_method", "dns"); - o.modalonly = true; - - o = s.taboption('challenge_dns', form.Value, 'calias', _('Challenge Alias'), - _("The challenge alias to use for ALL domains. " + - "See https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode for the details of this process. " + - "LUCI only supports one challenge alias per certificate.")); - o.depends("validation_method", "dns"); - o.modalonly = true; - - o = s.taboption('challenge_dns', form.Value, 'dalias', _('Domain Alias'), - _("The domain alias to use for ALL domains. " + - "See https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode for the details of this process. " + - "LUCI only supports one challenge domain per certificate.")); - o.depends("validation_method", "dns"); - o.modalonly = true; - - o = s.taboption('challenge_dns', form.Value, 'dns_wait', _('Wait for DNS update'), - _('Seconds to wait for a DNS record to be updated before continue.') + '
' + - '' + _('See more') + '' - ); - o.depends('validation_method', 'dns'); - o.modalonly = true; - - - o = s.taboption('advanced', form.ListValue, 'key_type', _('Key type'), - _('Key size (and type) for the generated certificate.') - ); - o.value('rsa2048', _('RSA 2048 bits')); - o.value('rsa3072', _('RSA 3072 bits')); - o.value('rsa4096', _('RSA 4096 bits')); - o.value('ec256', _('ECC 256 bits')); - o.value('ec384', _('ECC 384 bits')); - o.rmempty = false; - o.optional = true; - o.modalonly = true; - o.cfgvalue = function(section_id) { - let keylength = uci.get('acme', section_id, 'keylength'); - if (keylength) { - // migrate the old keylength to a new keytype - switch (keylength) { - case '2048': return 'rsa2048'; - case '3072': return 'rsa3072'; - case '4096': return 'rsa4096'; - case 'ec-256': return 'ec256'; - case 'ec-384': return 'ec384'; - default: return ''; // bad value - } - } - return this.super('cfgvalue', arguments); - }; - o.write = function(section_id, value) { - // remove old keylength - uci.unset('acme', section_id, 'keylength'); - uci.set('acme', section_id, 'key_type', value); - }; - - o = s.taboption('advanced', form.Value, "acme_server", _("ACME server URL"), - _('Use a custom CA instead of Let\'s Encrypt.') + ' ' + _('Custom ACME server directory URL.') + '
' + - '' + _('See more') + '' + '
' - + _('Default') + ' letsencrypt' - ); - o.placeholder = "https://api.buypass.com/acme/directory"; - o.optional = true; - o.modalonly = true; - - o = s.taboption('advanced', form.Flag, 'staging', _('Use staging server'), - _( - 'Get certificate from the Letsencrypt staging server ' + - '(use for testing; the certificate won\'t be valid).' - ) - ); - o.depends('acme_server', ''); - o.depends('acme_server', 'letsencrypt'); - o.optional = true; - o.modalonly = true; - - o = s.taboption('advanced', form.Value, 'days', _('Days until renewal')); - o.optional = true; - o.placeholder = 'acme.sh default (60 days)'; - o.datatype = 'uinteger'; - o.modalonly = true; - - - s = m.section(form.GridSection, '_certificates'); - - s.render = L.bind(_renderCerts, this, certs); - - return m.render(); - } -}); - -function _isFqdn(domain) { - // Is not an IP i.e. starts from alphanumeric and has least one dot - return /[a-z0-9-]\..*$/.test(domain) && !/[0-9-]\..*$/.test(domain); -} - -function _guessDomain(hostname) { - return _isFqdn(hostname) ? hostname : (_isFqdn(window.location.hostname) ? window.location.hostname : ''); -} - -function _collectDdnsDomains() { - let ddnsDomains = []; - let ddnsServices = uci.sections('ddns', 'service'); - for (let ddnsService of ddnsServices) { - let dnsApi = ''; - let credentials = []; - switch (ddnsService.service_name) { - case 'duckdns.org': - dnsApi = 'dns_duckdns'; - credentials = [ - 'DuckDNS_Token=' + ddnsService['password'], - ]; - break; - case 'dynv6.com': - dnsApi = 'dns_dynv6'; - credentials = [ - 'DYNV6_TOKEN=' + ddnsService['password'], - ]; - break; - case 'afraid.org-v2-basic': - dnsApi = 'dns_freedns'; - credentials = [ - 'FREEDNS_User=' + ddnsService['username'], - 'FREEDNS_Password=' + ddnsService['password'], - ]; - break; - case 'cloudflare.com-v4': - dnsApi = 'dns_cf'; - credentials = [ - 'CF_Token=' + ddnsService['password'], - ]; - break; - } - if (credentials.length > 0) { - ddnsDomains.push({ - sectionId: ddnsService['.name'], - domains: [ddnsService['domain'], '*.' + ddnsService['domain']], - dnsApi: dnsApi, - credentials: credentials, - }); - } - } - return ddnsDomains; -} - -function _importDdns(ddnsDomains) { - let certSections = uci.sections('acme', 'cert'); - let certSectionNames = new Map(); - let certSectionDomains = new Map(); - for (let s of certSections) { - certSectionNames.set(s['.name'], null); - if (s.domains) { - for (let d of s.domains) { - certSectionDomains.set(d, s['.name']); - } - } - } - let importedDomains = {}; - let importedErrors = []; - for (let ddnsDomain of ddnsDomains) { - let sectionId = ddnsDomain.sectionId; - // ensure unique sectionId - if (certSectionNames.has(sectionId)) { - sectionId += '_' + new Date().getTime(); - } - if (ddnsDomain.domains) { - for (let d of ddnsDomain.domains) { - let dupDomainSection = certSectionDomains.get(d); - if (dupDomainSection) { - let errorText = _('The domain %s in DDNS %s is already configured in %s. Please check it after the importing.') - .format(d, sectionId, dupDomainSection); - importedErrors.push(errorText); - } - } - } - importedDomains[sectionId] = { - 'domains': ddnsDomain.domains, - 'validation_method': 'dns', - 'dns': ddnsDomain.dnsApi, - 'credentials': ddnsDomain.credentials, - }; - } - ui.showModal(_('Check the configurations of the added domain certificates'), [ - E('p', JSON.stringify(importedDomains, null, 2)), - E('p', importedErrors.join('
')), - E('div', { 'class': 'right' }, [ - E('button', { - 'class': 'btn cbi-button', - 'click': ui.hideModal - }, _('Cancel')), - ' ', - E('button', { - 'class': 'btn cbi-button-action', - 'click': ui.createHandlerFn(this, function (ev) { - for (let [sectionId, opts] of Object.entries(importedDomains)) { - uci.add('acme', 'cert', sectionId); - for (let [key, val] of Object.entries(opts)) { - uci.set('acme', sectionId, key, val); - } - } - uci.save().then(() => window.location.reload()); - }) - }, _('Save')) - ]) - ]); -} - -function _addDnsProviderField(s, apiId, opt, isOptsAlt) { - let desc = '' + opt.Name + ' ' + opt.Description; - if (opt.Default) { - desc += '
' + _('Default') + ' ' + opt.Default + ''; - } - let optionName = '_credentials_' + opt.Name; - if (isOptsAlt) { - optionName += '_OptsAlt' - } - let o = s.taboption('challenge_dns', form.Value, optionName, opt.Title, desc); - o.depends({'dns': apiId, '_dns_options_alt': isOptsAlt ? '1' : '0'}); - o.modalonly = true; - o.placeholder = opt.Default; - o.cfgvalue = function (section_id) { - let creds = this.map.data.get(this.map.config, section_id, 'credentials'); - return _extractParamValue(creds, opt.Name); - }; - o.write = function (section_id, value) { }; - o.onchange = _handleEditChange; - return o; -} - -function _handleEditChange(event, section_id, newVal) { - // Add the provider field value directly to the credentials DynList - let credentialsDynList = this.map.lookupOption('credentials', section_id)[0].getUIElement(section_id); - let creds = credentialsDynList.getValue(); - let credsMap = _parseKeyValueListToMap(creds); - let optName = this.option.substring('_credentials_'.length); - optName = optName.replace(/_OptsAlt$/, ''); - if (newVal) { - credsMap.set(optName, newVal); - } else { - credsMap.delete(optName); - } - let newCreds = []; - for (let [key, val] of credsMap) { - newCreds.push(key + '="' + val + '"'); - } - credentialsDynList.setValue(newCreds); -} - -/** - * @param {string[]} paramsKeyVals - * @param {string} paramName - * @returns {string} - */ -function _extractParamValue(paramsKeyVals, paramName) { - let map = _parseKeyValueListToMap(paramsKeyVals) - return map.get(paramName) || ''; -} - -/** - * @param {string[]} paramsKeyVals - * @returns {Map} - */ -function _parseKeyValueListToMap(paramsKeyVals) { - let map = new Map(); - if (!paramsKeyVals) { - return map; - } - for (let paramKeyVal of paramsKeyVals) { - let pos = paramKeyVal.indexOf("="); - if (pos < 0) { - continue; - } - let name = paramKeyVal.slice(0, pos); - let unquotedVal = paramKeyVal.slice(pos + 2, paramKeyVal.length - 1); - map.set(name, unquotedVal); - } - return map; -} - -function _handleCheckService(event, section_id, newVal) { - document.getElementById('wikiInstructionUrl').href = 'https://github.com/acmesh-official/acme.sh/wiki/dnsapi#' + newVal; -} - -function _renderCerts(certs) { - let table = E('table', {'class': 'table cbi-section-table', 'id': 'certificates_table'}, [ - E('tr', {'class': 'tr table-titles'}, [ - E('th', {'class': 'th'}, _('Main Domain')), - E('th', {'class': 'th'}, _('Private Key')), - E('th', {'class': 'th'}, _('Public Certificate')), - E('th', {'class': 'th'}, _('Issued on')), - ]) - ]); - - let rows = certs.map(function (cert) { - let domain = cert.name.substring(0, cert.name.length - 4); - let issueDate = new Date(cert.mtime * 1000).toLocaleDateString(); - return [ - domain, - '/etc/ssl/acme/' + domain + '.key', - '/etc/ssl/acme/' + domain + '.fullchain.crt', - issueDate, - ]; - }); - - cbi_update_table(table, rows); - - return E('div', {'class': 'cbi-section cbi-tblsection'}, [ - E('h3', _('Certificates')), table]); -} diff --git a/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/acme.js b/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/acme.js new file mode 100644 index 0000000000..6e6fd5f7f1 --- /dev/null +++ b/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/acme.js @@ -0,0 +1,511 @@ +'use strict'; +'require form'; +'require fs'; +'require uci'; +'require ui'; +'require view'; +"require view.dnsapi as dnsapi"; + +return view.extend({ + load() { + return Promise.all([ + L.resolveDefault(fs.list('/etc/ssl/acme/'), []).then(files => { + let certs = []; + for (let f of files) { + if (f.type == 'file' && f.name.match(/\.key$/)) { + certs.push(f); + } + } + return certs; + }), + L.resolveDefault(fs.exec_direct('/usr/libexec/acmesh-dnsinfo.sh'), ''), + L.resolveDefault(fs.stat('/usr/lib/acme/client/dnsapi'), null), + L.resolveDefault(fs.lines('/proc/sys/kernel/hostname'), ''), + L.resolveDefault(uci.load('ddns')), + ]); + }, + + render(data) { + let certs = data[0]; + let dnsApiInfoText = data[1]; + let apiInfos = dnsapi.parseFile(dnsApiInfoText); + let hasDnsApi = data[2] != null; + let hostname = data[3]; + let systemDomain = _guessDomain(hostname); + let ddnsDomains = _collectDdnsDomains(); + let wikiUrl = 'https://github.com/acmesh-official/acme.sh/wiki/'; + let wikiInstructionUrl = wikiUrl + 'dnsapi'; + let m, s, o; + + m = new form.Map("acme", _("ACME certificates"), + _("This configures ACME (Letsencrypt) automatic certificate installation. " + + "Simply fill out this to have the router configured with Letsencrypt-issued " + + "certificates for the web interface. " + + "Note that the domain names in the certificate must already be configured to " + + "point at the router's public IP address. " + + "Once configured, issuing certificates can take a while. " + + "Check the logs for progress and any errors.") + '
' + + _("Cert files are stored in") + ' /etc/ssl/acme'+ '
' + + '' + _('See more') + '' + ); + + s = m.section(form.TypedSection, "acme", _("ACME global config")); + s.anonymous = true; + + o = s.option(form.Value, "account_email", _("Account email"), + _('Email address to associate with account key.') + '
' + + _('If a certificate wasn\'t renewed in time then you\'ll receive a notice at 20 days before expiry.') + ); + o.rmempty = false; + o.datatype = "minlength(1)"; + + o = s.option(form.Flag, "debug", _("Enable debug logging")); + o.rmempty = false; + + if (ddnsDomains && ddnsDomains.length > 0) { + let ddnsDomainsList = ddnsDomains.map(d => d.domains[0]); + o = s.option(form.Button, '_import_ddns'); + o.title = _('Found DDNS domains'); + o.inputtitle = _('Import') + ': ' + ddnsDomainsList.join(); + o.inputstyle = 'apply'; + o.onclick = function () { + _importDdns(ddnsDomains); + }; + } + + s = m.section(form.GridSection, "cert", _("Certificate config")); + s.anonymous = false; + s.addremove = true; + s.nodescriptions = true; + + o = s.tab("general", _("General Settings")); + o = s.tab('challenge_webroot', _('Webroot Challenge Validation')); + o = s.tab('challenge_dns', _('DNS Challenge Validation')); + o = s.tab("advanced", _('Advanced Settings')); + + o = s.taboption('general', form.Flag, "enabled", _("Enabled")); + o.rmempty = false; + + o = s.taboption('general', form.ListValue, 'validation_method', _('Validation method'), + _('Standalone mode will use the built-in webserver of acme.sh to issue a certificate. ' + + 'Webroot mode will use an existing webserver to issue a certificate. ' + + 'DNS mode will allow you to use the DNS API of your DNS provider to issue a certificate.') + ); + o.value('standalone', _('Standalone')); + o.value('webroot', _('Webroot')); + o.value('dns', _('DNS')); + o.default = 'standalone'; + + if (!hasDnsApi) { + let dnsApiPkg = 'acme-acmesh-dnsapi'; + o = s.taboption('general', form.Button, '_install'); + o.depends('validation_method', 'dns'); + o.title = _('Package is not installed'); + o.inputtitle = _('Install package %s').format(dnsApiPkg); + o.inputstyle = 'apply'; + o.onclick = function () { + let link = L.url('admin/system/package-manager') + '?query=' + dnsApiPkg; + window.open(link, '_blank', 'noopener'); + }; + } + + o = s.taboption('general', form.DynamicList, "domains", _("Domain names"), + _("Domain names to include in the certificate. " + + "The first name will be the subject name, subsequent names will be alt names. " + + "Note that all domain names must point at the router in the global DNS.")); + o.datatype = "list(string)"; + if (systemDomain) { + o.default = [systemDomain]; + } + o.validate = function (section_id, value) { + if (!value) { + return true; + } + if (!/^[*a-z0-9][a-z0-9.-]*$/.test(value)) { + return _('Invalid domain. Allowed lowercase a-z, numbers and hyphen -'); + } + if (value.startsWith('*')) { + let method = this.section.children.filter(function (o) { return o.option == 'validation_method'; })[0].formvalue(section_id); + if (method && method !== 'dns') { + return _('wildcards * require Validation method: DNS'); + } + } + return true; + }; + + o = s.taboption('challenge_webroot', form.Value, 'webroot', _('Webroot directory'), + _("Webserver root directory. Set this to the webserver " + + "document root to run Acme in webroot mode. The web " + + "server must be accessible from the internet on port 80.") + '
' + + _("Default") + " /var/run/acme/challenge/" + ); + o.optional = true; + o.depends("validation_method", "webroot"); + o.modalonly = true; + + o = s.taboption('challenge_dns', form.ListValue, 'dns', _('DNS API'), + _("To use DNS mode to issue certificates, set this to the name of a DNS API supported by acme.sh. " + + "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi for the list of available APIs. " + + "In DNS mode, the domain name does not have to resolve to the router IP. " + + "DNS mode is also the only mode that supports wildcard certificates. " + + "Using this mode requires the acme-dnsapi package to be installed.")); + o.depends("validation_method", "dns"); + // List of supported DNS API. Names are same as file names in acme.sh for easier search. + // May be outdated but not changed too often. + o.value('', ''); + for (let info of apiInfos) { + let title = info.Name; + if (info.Domains) { + title += ' (' + info.Domains + ')'; + } + o.value(info.Id, title); + } + o.modalonly = true; + o.onchange = _handleCheckService; + + o = s.taboption('challenge_dns', form.DummyValue, '_wiki_url', _('See instructions'), ''); + o.rawhtml = true; + o.default = 'Acme Wiki DNS API' + .format(wikiInstructionUrl); + o.depends('validation_method', 'dns'); + o.modalonly = true; + + o = s.taboption('challenge_dns', form.Flag, '_dns_options_alt', _('Alternative DNS API options'), ''); + o.modalonly = true; + + for (let info of apiInfos) { + if (info.OptsTitle) { + o = s.taboption('challenge_dns', form.DummyValue, '_dns_OptsTitle_' + info.Id, ' ', ''); + o.default = info.OptsTitle; + o.depends({'dns': info.Id, '_dns_options_alt': '0'}); + o.modalonly = true; + } + for (let opt of info.Opts) { + _addDnsProviderField(s, info.Id, opt, false); + } + if (info.OptsAltTitle) { + o = s.taboption('challenge_dns', form.DummyValue, '_dns_OptsAltTitle_' + info.Id, ' ', ''); + o.default = info.OptsAltTitle; + o.depends({'dns': info.Id, '_dns_options_alt': '1'}); + o.modalonly = true; + } + for (let opt of info.OptsAlt) { + _addDnsProviderField(s, info.Id, opt, true); + } + } + + o = s.taboption('challenge_dns', form.DynamicList, 'credentials', _('DNS API credentials'), + _("The credentials for the DNS API mode selected above. " + + "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi for the format of credentials required by each API. " + + "Add multiple entries here in KEY=VAL shell variable format to supply multiple credential variables.")); + o.datatype = "list(string)"; + o.depends("validation_method", "dns"); + o.modalonly = true; + + o = s.taboption('challenge_dns', form.Value, 'calias', _('Challenge Alias'), + _("The challenge alias to use for ALL domains. " + + "See https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode for the details of this process. " + + "LUCI only supports one challenge alias per certificate.")); + o.depends("validation_method", "dns"); + o.modalonly = true; + + o = s.taboption('challenge_dns', form.Value, 'dalias', _('Domain Alias'), + _("The domain alias to use for ALL domains. " + + "See https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode for the details of this process. " + + "LUCI only supports one challenge domain per certificate.")); + o.depends("validation_method", "dns"); + o.modalonly = true; + + o = s.taboption('challenge_dns', form.Value, 'dns_wait', _('Wait for DNS update'), + _('Seconds to wait for a DNS record to be updated before continue.') + '
' + + '' + _('See more') + '' + ); + o.depends('validation_method', 'dns'); + o.modalonly = true; + + + o = s.taboption('advanced', form.ListValue, 'key_type', _('Key type'), + _('Key size (and type) for the generated certificate.') + ); + o.value('rsa2048', _('RSA 2048 bits')); + o.value('rsa3072', _('RSA 3072 bits')); + o.value('rsa4096', _('RSA 4096 bits')); + o.value('ec256', _('ECC 256 bits')); + o.value('ec384', _('ECC 384 bits')); + o.rmempty = false; + o.optional = true; + o.modalonly = true; + o.cfgvalue = function(section_id) { + let keylength = uci.get('acme', section_id, 'keylength'); + if (keylength) { + // migrate the old keylength to a new keytype + switch (keylength) { + case '2048': return 'rsa2048'; + case '3072': return 'rsa3072'; + case '4096': return 'rsa4096'; + case 'ec-256': return 'ec256'; + case 'ec-384': return 'ec384'; + default: return ''; // bad value + } + } + return this.super('cfgvalue', arguments); + }; + o.write = function(section_id, value) { + // remove old keylength + uci.unset('acme', section_id, 'keylength'); + uci.set('acme', section_id, 'key_type', value); + }; + + o = s.taboption('advanced', form.Value, "acme_server", _("ACME server URL"), + _('Use a custom CA instead of Let\'s Encrypt.') + ' ' + _('Custom ACME server directory URL.') + '
' + + '' + _('See more') + '' + '
' + + _('Default') + ' letsencrypt' + ); + o.placeholder = "https://api.buypass.com/acme/directory"; + o.optional = true; + o.modalonly = true; + + o = s.taboption('advanced', form.Flag, 'staging', _('Use staging server'), + _( + 'Get certificate from the Letsencrypt staging server ' + + '(use for testing; the certificate won\'t be valid).' + ) + ); + o.depends('acme_server', ''); + o.depends('acme_server', 'letsencrypt'); + o.optional = true; + o.modalonly = true; + + o = s.taboption('advanced', form.Value, 'days', _('Days until renewal')); + o.optional = true; + o.placeholder = 'acme.sh default (60 days)'; + o.datatype = 'uinteger'; + o.modalonly = true; + + + s = m.section(form.GridSection, '_certificates'); + + s.render = L.bind(_renderCerts, this, certs); + + return m.render(); + } +}); + +function _isFqdn(domain) { + // Is not an IP i.e. starts from alphanumeric and has least one dot + return /[a-z0-9-]\..*$/.test(domain) && !/[0-9-]\..*$/.test(domain); +} + +function _guessDomain(hostname) { + return _isFqdn(hostname) ? hostname : (_isFqdn(window.location.hostname) ? window.location.hostname : ''); +} + +function _collectDdnsDomains() { + let ddnsDomains = []; + let ddnsServices = uci.sections('ddns', 'service'); + for (let ddnsService of ddnsServices) { + let dnsApi = ''; + let credentials = []; + switch (ddnsService.service_name) { + case 'duckdns.org': + dnsApi = 'dns_duckdns'; + credentials = [ + 'DuckDNS_Token=' + ddnsService['password'], + ]; + break; + case 'dynv6.com': + dnsApi = 'dns_dynv6'; + credentials = [ + 'DYNV6_TOKEN=' + ddnsService['password'], + ]; + break; + case 'afraid.org-v2-basic': + dnsApi = 'dns_freedns'; + credentials = [ + 'FREEDNS_User=' + ddnsService['username'], + 'FREEDNS_Password=' + ddnsService['password'], + ]; + break; + case 'cloudflare.com-v4': + dnsApi = 'dns_cf'; + credentials = [ + 'CF_Token=' + ddnsService['password'], + ]; + break; + } + if (credentials.length > 0) { + ddnsDomains.push({ + sectionId: ddnsService['.name'], + domains: [ddnsService['domain'], '*.' + ddnsService['domain']], + dnsApi: dnsApi, + credentials: credentials, + }); + } + } + return ddnsDomains; +} + +function _importDdns(ddnsDomains) { + let certSections = uci.sections('acme', 'cert'); + let certSectionNames = new Map(); + let certSectionDomains = new Map(); + for (let s of certSections) { + certSectionNames.set(s['.name'], null); + if (s.domains) { + for (let d of s.domains) { + certSectionDomains.set(d, s['.name']); + } + } + } + let importedDomains = {}; + let importedErrors = []; + for (let ddnsDomain of ddnsDomains) { + let sectionId = ddnsDomain.sectionId; + // ensure unique sectionId + if (certSectionNames.has(sectionId)) { + sectionId += '_' + new Date().getTime(); + } + if (ddnsDomain.domains) { + for (let d of ddnsDomain.domains) { + let dupDomainSection = certSectionDomains.get(d); + if (dupDomainSection) { + let errorText = _('The domain %s in DDNS %s is already configured in %s. Please check it after the importing.') + .format(d, sectionId, dupDomainSection); + importedErrors.push(errorText); + } + } + } + importedDomains[sectionId] = { + 'domains': ddnsDomain.domains, + 'validation_method': 'dns', + 'dns': ddnsDomain.dnsApi, + 'credentials': ddnsDomain.credentials, + }; + } + ui.showModal(_('Check the configurations of the added domain certificates'), [ + E('p', JSON.stringify(importedDomains, null, 2)), + E('p', importedErrors.join('
')), + E('div', { 'class': 'right' }, [ + E('button', { + 'class': 'btn cbi-button', + 'click': ui.hideModal + }, _('Cancel')), + ' ', + E('button', { + 'class': 'btn cbi-button-action', + 'click': ui.createHandlerFn(this, function (ev) { + for (let [sectionId, opts] of Object.entries(importedDomains)) { + uci.add('acme', 'cert', sectionId); + for (let [key, val] of Object.entries(opts)) { + uci.set('acme', sectionId, key, val); + } + } + uci.save().then(() => window.location.reload()); + }) + }, _('Save')) + ]) + ]); +} + +function _addDnsProviderField(s, apiId, opt, isOptsAlt) { + let desc = '' + opt.Name + ' ' + opt.Description; + if (opt.Default) { + desc += '
' + _('Default') + ' ' + opt.Default + ''; + } + let optionName = '_credentials_' + opt.Name; + if (isOptsAlt) { + optionName += '_OptsAlt' + } + let o = s.taboption('challenge_dns', form.Value, optionName, opt.Title, desc); + o.depends({'dns': apiId, '_dns_options_alt': isOptsAlt ? '1' : '0'}); + o.modalonly = true; + o.placeholder = opt.Default; + o.cfgvalue = function (section_id) { + let creds = this.map.data.get(this.map.config, section_id, 'credentials'); + return _extractParamValue(creds, opt.Name); + }; + o.write = function (section_id, value) { }; + o.onchange = _handleEditChange; + return o; +} + +function _handleEditChange(event, section_id, newVal) { + // Add the provider field value directly to the credentials DynList + let credentialsDynList = this.map.lookupOption('credentials', section_id)[0].getUIElement(section_id); + let creds = credentialsDynList.getValue(); + let credsMap = _parseKeyValueListToMap(creds); + let optName = this.option.substring('_credentials_'.length); + optName = optName.replace(/_OptsAlt$/, ''); + if (newVal) { + credsMap.set(optName, newVal); + } else { + credsMap.delete(optName); + } + let newCreds = []; + for (let [key, val] of credsMap) { + newCreds.push(key + '="' + val + '"'); + } + credentialsDynList.setValue(newCreds); +} + +/** + * @param {string[]} paramsKeyVals + * @param {string} paramName + * @returns {string} + */ +function _extractParamValue(paramsKeyVals, paramName) { + let map = _parseKeyValueListToMap(paramsKeyVals) + return map.get(paramName) || ''; +} + +/** + * @param {string[]} paramsKeyVals + * @returns {Map} + */ +function _parseKeyValueListToMap(paramsKeyVals) { + let map = new Map(); + if (!paramsKeyVals) { + return map; + } + for (let paramKeyVal of paramsKeyVals) { + let pos = paramKeyVal.indexOf("="); + if (pos < 0) { + continue; + } + let name = paramKeyVal.slice(0, pos); + let unquotedVal = paramKeyVal.slice(pos + 2, paramKeyVal.length - 1); + map.set(name, unquotedVal); + } + return map; +} + +function _handleCheckService(event, section_id, newVal) { + document.getElementById('wikiInstructionUrl').href = 'https://github.com/acmesh-official/acme.sh/wiki/dnsapi#' + newVal; +} + +function _renderCerts(certs) { + let table = E('table', {'class': 'table cbi-section-table', 'id': 'certificates_table'}, [ + E('tr', {'class': 'tr table-titles'}, [ + E('th', {'class': 'th'}, _('Main Domain')), + E('th', {'class': 'th'}, _('Private Key')), + E('th', {'class': 'th'}, _('Public Certificate')), + E('th', {'class': 'th'}, _('Issued on')), + ]) + ]); + + let rows = certs.map(function (cert) { + let domain = cert.name.substring(0, cert.name.length - 4); + let issueDate = new Date(cert.mtime * 1000).toLocaleDateString(); + return [ + domain, + '/etc/ssl/acme/' + domain + '.key', + '/etc/ssl/acme/' + domain + '.fullchain.crt', + issueDate, + ]; + }); + + cbi_update_table(table, rows); + + return E('div', {'class': 'cbi-section cbi-tblsection'}, [ + E('h3', _('Certificates')), table]); +} diff --git a/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/logread.js b/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/logread.js new file mode 100644 index 0000000000..ff2294ab53 --- /dev/null +++ b/applications/luci-app-acme/htdocs/luci-static/resources/view/acme/logread.js @@ -0,0 +1,4 @@ +'use strict'; +'require tools.views as views'; + +return views.LogreadBox("acme", "acme"); \ No newline at end of file diff --git a/applications/luci-app-acme/root/usr/share/luci/menu.d/luci-app-acme.json b/applications/luci-app-acme/root/usr/share/luci/menu.d/luci-app-acme.json index aa855905ae..335be3c2f5 100644 --- a/applications/luci-app-acme/root/usr/share/luci/menu.d/luci-app-acme.json +++ b/applications/luci-app-acme/root/usr/share/luci/menu.d/luci-app-acme.json @@ -3,11 +3,27 @@ "title": "ACME certificates", "order": 50, "action": { - "type": "view", - "path": "acme" + "type": "alias", + "path": "admin/services/acme/configure" }, "depends": { "acl": [ "luci-app-acme" ] } + }, + "admin/services/acme/configure": { + "title": "Configure", + "order": 10, + "action": { + "type": "view", + "path": "acme/acme" + } + }, + "admin/services/acme/logread": { + "title": "Log View", + "order": 20, + "action": { + "type": "view", + "path": "acme/logread" + } } } diff --git a/applications/luci-app-acme/root/usr/share/rpcd/acl.d/luci-app-acme.json b/applications/luci-app-acme/root/usr/share/rpcd/acl.d/luci-app-acme.json index a8bd9f9fb3..5abeab0632 100644 --- a/applications/luci-app-acme/root/usr/share/rpcd/acl.d/luci-app-acme.json +++ b/applications/luci-app-acme/root/usr/share/rpcd/acl.d/luci-app-acme.json @@ -6,7 +6,9 @@ "file": { "/proc/sys/kernel/hostname": [ "read" ], "/etc/ssl/acme": [ "list" ], - "/usr/libexec/acmesh-dnsinfo.sh": [ "exec" ] + "/usr/libexec/acmesh-dnsinfo.sh": [ "exec" ], + "/sbin/logread -e acme": [ "exec" ], + "/usr/sbin/logread -e acme": [ "exec" ] }, "uci": [ "acme" ] },