luci-app-acme: Add Log reader
authorSergey Ponomarev <[email protected]>
Sat, 1 Jun 2024 19:30:37 +0000 (22:30 +0300)
committerToke Høiland-Jørgensen <[email protected]>
Tue, 7 Oct 2025 12:01:06 +0000 (14:01 +0200)
Signed-off-by: Sergey Ponomarev <[email protected]>
applications/luci-app-acme/htdocs/luci-static/resources/view/acme.js [deleted file]
applications/luci-app-acme/htdocs/luci-static/resources/view/acme/acme.js [new file with mode: 0644]
applications/luci-app-acme/htdocs/luci-static/resources/view/acme/logread.js [new file with mode: 0644]
applications/luci-app-acme/root/usr/share/luci/menu.d/luci-app-acme.json
applications/luci-app-acme/root/usr/share/rpcd/acl.d/luci-app-acme.json

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 (file)
index 6e6fd5f..0000000
+++ /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.") + '<br/>' +
-                               _("Cert files are stored in") + ' <em>/etc/ssl/acme</em>'+ '<br />' +
-                               '<a href="https://openwrt.org/docs/guide-user/services/tls/acmesh" target="_blank">' + _('See more') + '</a>'
-               );
-
-               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.') + '<br/>' +
-                       _('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.") + '<br/>' +
-                       _("Default") + " <em>/var/run/acme/challenge/</em>"
-               );
-               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 = '<a id="wikiInstructionUrl" href="%s" target="_blank" rel="noreferrer">Acme Wiki DNS API</a>'
-                       .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.') + '<br />' +
-                       '<a href="https://github.com/acmesh-official/acme.sh/wiki/dnssleep" target="_blank">' + _('See more') + '</a>'
-               );
-               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.') + '<br />' +
-                       '<a href="https://github.com/acmesh-official/acme.sh/wiki/Server" target="_blank">' + _('See more') + '</a>' + '<br />'
-                       + _('Default') + ' <code>letsencrypt</code>'
-               );
-               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('<br />')),
-               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 = '<code>' + opt.Name + '</code> ' + opt.Description;
-       if (opt.Default) {
-               desc += '<br />' + _('Default')  + ' <code>' + opt.Default + '</code>';
-       }
-       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 (file)
index 0000000..6e6fd5f
--- /dev/null
@@ -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.") + '<br/>' +
+                               _("Cert files are stored in") + ' <em>/etc/ssl/acme</em>'+ '<br />' +
+                               '<a href="https://openwrt.org/docs/guide-user/services/tls/acmesh" target="_blank">' + _('See more') + '</a>'
+               );
+
+               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.') + '<br/>' +
+                       _('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.") + '<br/>' +
+                       _("Default") + " <em>/var/run/acme/challenge/</em>"
+               );
+               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 = '<a id="wikiInstructionUrl" href="%s" target="_blank" rel="noreferrer">Acme Wiki DNS API</a>'
+                       .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.') + '<br />' +
+                       '<a href="https://github.com/acmesh-official/acme.sh/wiki/dnssleep" target="_blank">' + _('See more') + '</a>'
+               );
+               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.') + '<br />' +
+                       '<a href="https://github.com/acmesh-official/acme.sh/wiki/Server" target="_blank">' + _('See more') + '</a>' + '<br />'
+                       + _('Default') + ' <code>letsencrypt</code>'
+               );
+               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('<br />')),
+               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 = '<code>' + opt.Name + '</code> ' + opt.Description;
+       if (opt.Default) {
+               desc += '<br />' + _('Default')  + ' <code>' + opt.Default + '</code>';
+       }
+       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 (file)
index 0000000..ff2294a
--- /dev/null
@@ -0,0 +1,4 @@
+'use strict';
+'require tools.views as views';
+
+return views.LogreadBox("acme", "acme");
\ No newline at end of file
index aa855905ae7807995288e25c48b73bdd0ca4e3f3..335be3c2f5c11cf8fa22a6583544ac1c78d903d4 100644 (file)
@@ -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"
+               }
        }
 }
index a8bd9f9fb30490e5c4431c9655706e8f1965cc11..5abeab06329e919a602924a4d5efc6be4c9dbc63 100644 (file)
@@ -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" ]
                 },