'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.name.match(/\.fullchain\.crt$/)) {
certs.push(f);
}
}
return certs;
}),
L.resolveDefault(fs.exec_direct('/usr/libexec/acmesh-dnsinfo.sh'), ''),
L.resolveDefault(fs.list('/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.') + '
' +
_('Validation via TLS ALPN') + ': ' + _('Validate via TLS port 443.') + '
' +
'' + _('See more') + ''
);
o.value('standalone', 'HTTP-01' + _('Standalone'));
o.value('webroot', 'HTTP-01' + _('Webroot Challenge Validation'));
o.value('dns', 'DNS-01 ' + _('DNS Challenge Validation'));
o.value('alpn', 'TLS-ALPN-01 ' + _('Validation via TLS ALPN'));
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.Value, 'listen_port', _('Listen port'),
_('Port where to listen for ACME challenge requests. The port will be temporarily open during validation.') + '
' +
_('It may be needed to change if your web server is behind reverse proxy and uses a different port.') + '
' +
_('Standalone') + ': ' + _('Default') + ' 80.' + '
' +
_('Webroot Challenge Validation') + ': ' + _('To temporary open port you can specify your web server port e.g. 80.') + '
' +
_('Validation via TLS ALPN') + ': ' + _('Default') + ' 443.'
);
o.optional = true;
o.placeholder = '80';
o.depends('validation_method', 'standalone');
o.depends('validation_method', 'webroot');
o.depends('validation_method', 'alpn');
o.modalonly = true;
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.depends('validation_method', 'dns');
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.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();
}
});
/**
* Is not an IP or a local domain without TLD
*/
function _isFqdn(domain) {
let i = domain.lastIndexOf('.');
if (i < 0) {
return false;
}
let tld = domain.substr(i + 1);
if (tld.length < 2) {
return false;
}
return /^[a-z0-9]+$/.test(tld);
}
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.replace(/\.fullchain\.crt$/, '');
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]);
}