Skip to content

Commit

Permalink
luci-app-adguardhome: Add new app
Browse files Browse the repository at this point in the history
There is no integration into the web UI for AdGuard Home. This PR adds
* A Lua script for rpcd to interface with the AGH REST API
* ACL controls allowing JS code to call rpcd, uci, logread
* Menu entries to put AdGuard Home under Services
* Three UIs - status, logs, config

The Lua script supports three API calls:
* get_status - maps to /control/status in the AGH REST API
* get_statistics - maps to /control/stats in the AGH REST API
* get_config - converts /etc/adguardhome.yaml to JSON

Authentication details must be provided by the user, as the password in the YAML file is encrypted. This results in the AGH password being stored cleartext in /etc/config/adguardhome.

The Lua script will log if it encounters an error, in an effort to make it easier to debug. These logs are visible in the Logs UI of the application.

I could not find any unit testing for things like the UI or the Lua code, so the only testing done was by hand.

The credentials were removed from the config, and both the ubus call and the web UI render the failure to acquire the credentials.

Incorrect credentials were supplied, and the UI was verified as showing a RPC error message.

ACLs were checked by removing luci-compat.json (which wildcard allows saving/reading), and ensuring that the UI could read and save the config.

Package was built and tested with
```
 make package/luci-app-adguardhome/compile  -j20
 scp bin/packages/i386_pentium4/cricalixluci/luci-app-adguardhome_unknown_all.ipk root@192.168.0.1:
 ssh root@192.168.0.1
 opkg remove luci-app-adguardhome && opkg install luci-app-adguardhome_unknown_all.ipk && rm luci-app-adguardhome_unknown_all.ipk
```
and then browsing the UI to make sure the deployment was clean and behaved as expected.

Signed-off-by: Duncan Hill <openwrt-dev@cricalix.net>
  • Loading branch information
Duncan Hill committed Jun 27, 2022
1 parent fd72e1c commit ace79f8
Show file tree
Hide file tree
Showing 9 changed files with 760 additions and 0 deletions.
14 changes: 14 additions & 0 deletions applications/luci-app-adguardhome/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2022- Duncan Hill <openwrt-dev@cricalix.net>
# This is free software, licensed under the Apache License, Version 2.0

include $(TOPDIR)/rules.mk

LUCI_TITLE:=LuCI support for AdguardHome
LUCI_DEPENDS:=+adguardhome +luci-lib-jsonc +luasocket +lyaml
LUCI_PKGARCH:=all

PKG_LICENSE:=Apache-2.0

include ../../luci.mk

# call BuildPackage - OpenWrt buildroot signature
36 changes: 36 additions & 0 deletions applications/luci-app-adguardhome/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# LuCI App AdGuardHome

This LuCI app provides basic integration with the [AdGuard Home](https://github.com/AdguardTeam/AdGuardHome) [package](https://openwrt.org/packages/pkgdata/adguardhome) for OpenWrt. Note that the AdGuard Home package installation and configuration requires interaction with the OpenWrt command line; this app does not remove any of that interaction.

See also: [AdGuard Home @ AdGuard](https://adguard.com/en/adguard-home/overview.html)

## Using/installing this app

First, install the AdGuard Home package - either via the web UI for software package management, or
```
opkg install adguardhome
```

Follow the [installation instructions](https://openwrt.org/docs/guide-user/services/dns/adguard-home) for AdGuard Home, and make a note of the username and password for authenticating to the web UI.

Next, install this package - either via the web UI for software package management, or
```
opkg install luci-app-adguardhome
```

This package is unable to automatically determine the username and password (the password is encrypted in AdGuard Home's configuration file), so you'll need to go to `Services > AdGuard Home > Configuration` and provide these credentials. The credentials will be stored **unencrypted** in `/etc/config/adguardhome`.

With the credentials saved, the `Services > AdGuard Home > Status` page should now work, and show you the general status of AdGuard Home.

If you go to `Services > AdGuard Home > Logs`, you can see the last 50 log lines from both the supporting script used by this package, and the AdGuard Home software.

This app provides a link to the AdGuard Home web UI, making it easy to see more detailed statistics, and the query log.

## Dependencies

Dependencies are declared in the Makefile, but are

* adguardhome, as this app is useless without it
* luasocket, for talking to the AdGuard Home REST API
* luci-lib-jsonc, for doing JSON encoding
* lyaml, for parsing the AdGuard home YAML configuration
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use strict';
'require form';
'require view';

return view.extend({
render: function () {
// A basic configuration form; the luci.adguardhome script that
// powers the other UI pages needs a username and password to
// communicate with the AdguardHome REST API.
var s, o;
var m = new form.Map(
'adguardhome',
_('AdGuard Home Configuration'),
_('This application requires the username and password that were configured when you set up AdGuard Home, ' +
'as the REST API for AdGuard Home is password protected. The password cannot be read from the YAML ' +
'configuration file for AdGuard Home, as it is encrypted in that store. The credentials supplied here will ' +
'be stored unencrypted in /etc/config/adguardhome on your device.')
);
s = m.section(
form.TypedSection,
'adguardhome',
_('General settings'),
);
s.anonymous = true;
o = s.option(
form.Value,
'web_username',
_('Username for AdGuard Home'),
_('The username you configured when you set up AdGuard Home')
);
o.placeholder = 'adguard';
o = s.option(
form.Value,
'web_password',
_('Password for AdGuard Home'),
_('The password you configured when you set up AdGuard Home')
);
o.password = true;
return m.render();
}
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use strict';
'require dom';
'require fs';
'require poll';
'require ui';
'require view';

return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(fs.stat('/sbin/logread'), null),
L.resolveDefault(fs.stat('/usr/sbin/logread'), null)
]).then(function(stat) {
var logger = stat[0] ? stat[0].path : stat[1] ? stat[1].path : null;

return fs.exec_direct(logger, [ '-e', 'adguardhome' ]).catch(function(err) {
ui.addNotification(null, E('p', {}, _('Unable to load log data: ' + err.message)));
return '';
});
});
},

render: function(logdata) {
var loglines = logdata.trim().split(/\n/).reverse().slice(0, 50);

return E([], [
E('h2', {}, [_('System Log (AdGuard Home)')]),
E('div', {}, [_('Showing last 50 lines')]),
E('div', { 'id': 'content_syslog' }, [
E('textarea', {
'id': 'syslog',
'style': 'font-size:12px',
'readonly': 'readonly',
'wrap': 'off',
'rows': loglines.length + 1
}, [ loglines.join('\n') ])
])
]);
},

handleSave: null,
handleSaveApply: null,
handleReset: null
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
'use strict';
'require rpc';
'require view';


return view.extend({
load_adguardhome_config: rpc.declare({
object: 'luci.adguardhome',
method: 'get_config'
}),
load_adguardhome_status: rpc.declare({
object: 'luci.adguardhome',
method: 'get_status'
}),
load_adguardhome_statistics: rpc.declare({
object: 'luci.adguardhome',
method: 'get_statistics'
}),

generic_failure: function(message) {
return E('div', {'class': 'error'}, [_('RPC call failure: '), message])
},
urlmaker: function (host, port, tls_flag) {
var proto = tls_flag ? 'https://' : 'http://';
return proto + host + ':' + port + '/';
},
render_status_table: function (status, agh_config) {
if (status.error) {
return this.generic_failure(status.error)
}
// Take a hint from the base LuCI module for the Overview page,
// declare the fields and use a loop to build the tabular status view.
// Written out as key/value pairs, but it's just an iterable of elements.
const weburl = this.urlmaker(agh_config.bind_host, status.http_port, agh_config.tls.enabled);
const listen_addresses = L.isObject(status.dns_addresses) ? status.dns_addresses.join(', ') : _('Not found');
const bootstrap_dns = L.isObject(agh_config.dns.bootstrap_dns) ? agh_config.dns.bootstrap_dns.join(', ') : _('Not found');
const upstream_dns = L.isObject(agh_config.dns.upstream_dns) ? agh_config.dns.upstream_dns.join(', ') : _('Not found');
const fields = [
_('Running'), status.running ? _('Yes') : _('No'),
_('Protection enabled'), status.protection_enabled ? _('Yes') : _('No'),
_('Statistics period (days)'), agh_config.dns.statistics_interval,
_('Web interface'), E('a', { 'href': weburl, 'target': '_blank' }, status.http_port),
_('DNS listen port'), status.dns_port,
_('DNS listen addresses'), listen_addresses,
_('Bootstrap DNS addresses'), bootstrap_dns,
_('Upstream DNS addresses'), upstream_dns,
_('Version'), status.version,
];

var table = E('table', { 'class': 'table', 'id': 'status' });
for (var i = 0; i < fields.length; i += 2) {
table.appendChild(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [fields[i]]),
E('td', { 'class': 'td left' }, [(fields[i + 1] != null) ? fields[i + 1] : _('Not found')])
]));
}
return table;
},
render_statistics_table: function (statistics) {
// High level statistics
if (statistics.error) {
return this.generic_failure(statistics.error)
}
const fields = [
_('DNS queries'), statistics.num_dns_queries,
_('DNS blocks'), statistics.num_blocked_filtering,
_('DNS replacements (safesearch)'), statistics.num_replaced_safesearch,
_('DNS replacements (malware/phishing)'), statistics.num_replaced_safebrowsing,
_('Average processing time (seconds)'), statistics.avg_processing_time,
];

var table = E('table', { 'class': 'table', 'id': 'statistics' });
for (var i = 0; i < fields.length; i += 2) {
table.appendChild(
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, [fields[i]]),
E('td', { 'class': 'td left' }, [(fields[i + 1] != null) ? fields[i + 1] : _('Not found')])
]));
}
return table;
},
render_top_table: function(table_id, objects) {
var table = E('table', { 'class': 'table', 'id': table_id });
for (i = 0; i < objects.length; i++) {
table.appendChild(
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '33%' }, Object.keys(objects[i])[0]),
E('td', { 'class': 'td left' }, Object.values(objects[i])[0])
])
);
}
return table;
},
render_top_queries_table: function (statistics) {
// Top 5 queried domains table view
if (statistics.error) {
return this.generic_failure(statistics.error)
}
const top_queries = statistics.top_queried_domains.slice(0, 5);
return this.render_top_table('top_queries', top_queries)
},
render_top_blocked_table: function (statistics) {
// Top 5 blocked domains table view
if (statistics.error) {
return this.generic_failure(statistics.error)
}
const top_blocked = statistics.top_blocked_domains.slice(0, 5);
return this.render_top_table('top_blocked', top_blocked)
},
// Core LuCI functions from here on.
load: function () {
return Promise.all([
this.load_adguardhome_status(),
this.load_adguardhome_statistics(),
this.load_adguardhome_config()
]);
},
render: function (data) {
// data[0] should be load_adguardhome_status() result
var status = data[0] || {};
// data[1] should be load_adguardhome_statistics() result
var statistics = data[1] || {};
// data[2] should be load_adguardhome_config() result
var agh_config = data[2] || {};

// status.auth_error is only filled in when the config fetch failed
// to get the credentials. That stops all activity, since the user must
// first configure the username and password. Don't even bother trying
// to make REST API calls without credentials.
if (status.auth_error) {
return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'left' }, [
E('h3', _('AdGuard Home Status - Error')),
E('div', { 'class': 'error' }, status.auth_error),
E('div', { 'class': 'info' }, _('Please open the Configuration section, and provide the credentials.'))
])
]),
]);
}

// Render all the status tables as one big block.
return E('div', { 'class': 'cbi-map', 'id': 'map' }, [
E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'left' }, [
E('h3', _('AdGuard Home Status')),
this.render_status_table(status, agh_config)
]),
E('div', { 'class': 'left' }, [
E('h3', _('AdGuard Home Statistics')),
this.render_statistics_table(statistics)
]),
E('div', { 'class': 'left' }, [
E('h3', _('Top Queried Domains')),
this.render_top_queries_table(statistics)
]),
E('div', { 'class': 'left' }, [
E('h3', _('Top Blocked Domains')),
this.render_top_blocked_table(statistics)
])
]),
]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null
})
Loading

0 comments on commit ace79f8

Please sign in to comment.