Skip to content

Commit

Permalink
Summary:
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.

 Testing:

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.

The REST API was forced to return an error message, and the UI was verified as showing the RPC error message.

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 (unknown_all because this was done pre-commit).

Signed-off-by: Duncan Hill <openwrt-dev@cricalix.net>
  • Loading branch information
Duncan Hill committed Jun 26, 2022
1 parent fd72e1c commit a9ff41e
Show file tree
Hide file tree
Showing 9 changed files with 743 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 +lua-cjson +luasocket +lyaml
LUCI_PKGARCH:=all

PKG_LICENSE:=Apache-2.0

include ../../luci.mk

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

This LuCI app provides basic integration with the [AdGuard Home]() package for OpenWrt. Note that the AdGuard Home package requires interaction with the OpenWrt command line; this app does not remove any of that interaction.

## 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.

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

* lua-cjson, for doing JSON encoding/decoding
* luasocket, for talking to the AdGuard Home REST API
* lyaml, for parsing the AdGuard home YAML configuration
* adguardhome, as this app is useless without it
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'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.')
);
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,63 @@
'use strict';
'require dom';
'require fs';
'require poll';
'require view';

var css = ' \
#log_textarea { \
padding: 10px; \
text-align: left; \
} \
#log_textarea pre { \
padding: .5rem; \
word-break: break-all; \
margin: 0; \
} \
.description { \
background-color: #33ccff; \
}';

function pollLog(e) {
return Promise.all([
fs.exec_direct('/sbin/logread', ['-e', 'adguardhome']).then(function (res) {
return res.trim().split(/\n/).reverse().slice(0, 50).join('\n')
})
]).then(function (data) {
var t = E('pre', { 'wrap': 'pre' }, [
E('br'),
data[0] || _('No log data.')
]);
dom.content(e, t);
});
};

return view.extend({
render: function () {
var log_textarea = E('div', { 'id': 'log_textarea' },
E('img', {
'src': L.resource(['icons/loading.gif']),
'alt': _('Loading'),
'style': 'vertical-align:middle'
}, _('Collecting data...'))
);

poll.add(pollLog.bind(this, log_textarea));
return E([
E('style', [css]),
E('div', { 'class': 'cbi-map' }, [
E('h2', { 'name': 'content' }, '%s - %s'.format(_('AdGuard Home'), _('Syslog'))),
E('div', { 'class': 'cbi-section' }, [
log_textarea,
E('div', { 'style': 'text-align:right' },
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
)
])
])
]);
},

handleSave: null,
handleSaveApply: null,
handleReset: null
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
'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 upstream_dns = L.isObject(agh_config.dns.bootstrap_dns) ? agh_config.dns.bootstrap_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,
_('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 a9ff41e

Please sign in to comment.