Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

luci-app-acme: improve UI for inexperienced users #7147

Draft
wants to merge 25 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4007313
luci-app-acme: dns_wait option
stokito Jun 1, 2024
28ec2b4
luci-app-acme: Move link out of translations
stokito Jun 1, 2024
96495b6
luci-app-acme: introduction: remove LetsEncrypt mention
stokito Jun 1, 2024
b0d2426
luci-app-acme: introduction: clarify that ZeroSSL is by default
stokito Jun 1, 2024
abc07d5
luci-app-acme: acme_server: remove Let's Encrypt from title
stokito Jun 1, 2024
6249c07
luci-app-acme: acme_server: add "See more" link
stokito Jun 1, 2024
7706c6d
luci-app-acme: introduction: add link to OpenWrt Wiki
stokito Jun 1, 2024
76ca13f
luci-app-acme: put validation_method above domains
stokito Jun 1, 2024
99aea87
luci-app-acme: Validate domains
stokito Jun 1, 2024
30f8b1b
luci-app-acme: show button "Install package acme-acmesh-dnsapi" if DN…
stokito Jun 1, 2024
1e8a228
luci-app-acme: Guess the system domain and pre-fill it to domains.
stokito Jun 1, 2024
8e86389
luci-app-acme: Import domains from DDNS
stokito Jun 1, 2024
8ed4768
luci-app-acme: Add Log reader
stokito Jun 1, 2024
30e8b84
luci-app-acme: Set default validation_method to standalone
stokito Jun 2, 2024
86458bb
luci-app-acme: LetsEncrypt is default
stokito Jun 3, 2024
94f551e
luci-app-acme: code style: Use <br />
stokito Jun 3, 2024
dc3c47d
luci-app-acme: ACL for /proc/sys/kernel/hostname
stokito Jun 3, 2024
ef59e3c
luci-app-acme: domains validate
stokito Jun 3, 2024
35f4d1e
luci-app-acme: staging: show the flag only for letsencrypt
stokito Jun 3, 2024
c11d803
luci-app-acme: fix typo
stokito Jun 4, 2024
3ff1564
luci-app-acme: fix _isFqdn() to not allow raw IPv4
stokito Jun 4, 2024
6b4530d
luci-app-acme: remove the ZeroSSL mention
stokito Jun 4, 2024
c5a7289
luci-app-acme: DDNS import: check for duplicates
stokito Jun 4, 2024
faefaf1
luci-app-acme: DDNS import: also create a wildcard domain
stokito Jun 4, 2024
7b3206d
luci-app-acme: wildcards * require Validation method: DNS
stokito Jun 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,50 @@
'require view';

return view.extend({
load: function() {
return L.resolveDefault(fs.list('/etc/ssl/acme/'), []).then(function(entries) {
var certs = [];
for (var i = 0; i < entries.length; i++) {
if (entries[i].type == 'file' && entries[i].name.match(/\.key$/)) {
certs.push(entries[i]);
load: function () {
return Promise.all([
L.resolveDefault(fs.list('/etc/ssl/acme/'), []).then(function (entries) {
var certs = [];
for (var i = 0; i < entries.length; i++) {
if (entries[i].type == 'file' && entries[i].name.match(/\.key$/)) {
certs.push(entries[i]);
}
}
}
return certs;
});
return certs;
}),
L.resolveDefault(fs.stat('/usr/lib/acme/client/dnsapi'), null),
L.resolveDefault(fs.lines('/proc/sys/kernel/hostname'), ''),
L.resolveDefault(uci.load('ddns')),
]);
},

render: function (certs) {
render: function (data) {
let certs = data[0];
let hasDnsApi = data[1] != null;
let hostname = data[2];
let systemDomain = _guessDomain(hostname);
let ddnsDomains = _collectDdnsDomains();
let wikiUrl = 'https://github.com/acmesh-official/acme.sh/wiki/';
var wikiInstructionUrl = wikiUrl + 'dnsapi';
var 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 " +
_("This configures ACME automatic certificate installation. " +
"Simply fill out this to have the router configured with 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>'
"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/>' +
_('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;
Expand All @@ -46,6 +57,20 @@ return view.extend({
o = s.option(form.Flag, "debug", _("Enable debug logging"));
o.rmempty = false;

if (ddnsDomains) {
let ddnsDomainsList = [];
for (let ddnsDomain of ddnsDomains) {
ddnsDomainsList.push(ddnsDomain.domains[0]);
}
Comment on lines +61 to +64
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to clean it up, I'm fine with either tho.

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;
Expand All @@ -59,25 +84,57 @@ return view.extend({
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 opkgPackage = '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(opkgPackage);
o.inputstyle = 'apply';
o.onclick = function () {
window.open(L.url('admin/system/opkg') +
'?query=' + opkgPackage, '_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)";

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 = 'webroot';
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 _('A domain name with wildcard * available only when the Validation Method: DNS');
stokito marked this conversation as resolved.
Show resolved Hide resolved
}
}
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/>' +
"server must be accessible from the internet on port 80.") + '<br />' +
_("Default") + " <em>/var/run/acme/challenge/</em>"
);
o.optional = true;
Expand All @@ -86,10 +143,12 @@ return view.extend({

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. " +
'See %s 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."));
"Using this mode requires the acme-dnsapi package to be installed.")
.format('<a href="https://github.com/acmesh-official/acme.sh/wiki/dnsapi" target="_blank">DNS API</a>')
);
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.
Expand Down Expand Up @@ -443,36 +502,40 @@ return view.extend({

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."))
'See %s for the format of credentials required by each API. ' +
'Add multiple entries here in KEY=VAL shell variable format to supply multiple credential variables.')
.format('<a href="https://github.com/acmesh-official/acme.sh/wiki/dnsapi" target="_blank">DNS API</a>')
)
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."));
'See %s for the details of this process. ' +
'LUCI only supports one challenge alias per certificate.')
.format('<a href="https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode" target="_blank">DNS Alias Mode</a>')
);
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."));
'See %s for the details of this process. ' +
'LUCI only supports one challenge domain per certificate.')
.format('<a href="https://github.com/acmesh-official/acme.sh/wiki/DNS-alias-mode" target="_blank">DNS Alias Mode</a>')
);
o.depends("validation_method", "dns");
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 = 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.rmempty = false;
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.')
);
Expand Down Expand Up @@ -506,15 +569,28 @@ return view.extend({
};

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.'));
o.depends("staging", "0");
_('Use a custom CA.') + ' ' + _('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 = 90;
o.placeholder = 60;
o.datatype = 'uinteger';
o.modalonly = true;

Expand All @@ -527,6 +603,98 @@ return view.extend({
}
})

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);
stokito marked this conversation as resolved.
Show resolved Hide resolved
}

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']],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why? I don't think we should be encouraging people to randomly issue wildcard certs...

dnsApi: dnsApi,
credentials: credentials,
});
}
}
return ddnsDomains;
}

function _importDdns(ddnsDomains) {
alert(_('After import check the added domain certificate configurations.'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alert() is not a good way to send messages to the uses. Pretty sure luci has some way of adding messages, please use that instead.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this alert()

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']);
}
}
}
console.log(certSections);
console.log(certSectionDomains);
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) {
alert(_('The domain %s in DDNS %s already was configured in %s. Please check it after the import.').format(d, sectionId, dupDomainSection));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's already configured we should just skip it instead of bugging the user...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this alert()

}
}
}
uci.add('acme', 'cert', sectionId);
uci.set('acme', sectionId, 'domains', ddnsDomain.domains);
uci.set('acme', sectionId, 'validation_method', 'dns');
uci.set('acme', sectionId, 'dns', ddnsDomain.dnsApi);
uci.set('acme', sectionId, 'credentials', ddnsDomain.credentials);
}
uci.save();
window.location.reload();
}

function _addDnsProviderField(s, provider, env, title, desc) {
let o = s.taboption('challenge_dns', form.Value, '_' + env, _(title),
Expand Down Expand Up @@ -555,7 +723,7 @@ function _extractParamValue(paramsKeyVals, paramName) {
for (let i = 0; i < paramsKeyVals.length; i++) {
var paramKeyVal = paramsKeyVals[i];
var parts = paramKeyVal.split('=');
if (parts.lenght < 2) {
if (parts.length < 2) {
continue;
}
var name = parts[0];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
'use strict';
'require tools.views as views';

return views.LogreadBox("acme", "acme");
Loading