Merge: + Per-client settings

Close #727

* commit 'a83bc5eeeb4107f2157443b7b40636036fe2a7cc':
  * client: add source column
  * client: remove redundant table formatting for runtime clients table
  * client: show MAC address as default
  + client: add runtime clients table
  * client: add icons for table buttons
  * client: remove unused api method
  * client: confirm before deleting
  * client: remove table column min-width
  * client: fix no data text
  * client: fix sort helper
  + client: handle per-client settings
  - openapi.yaml: fix HTTP methods
  + openapi.yaml: add /clients handlers
  + dnsfilter: use callback function for applying per-client settings
  + dhcp: FindIPbyMAC()
  + dns: use per-client filtering settings
  + clients: config: save/restore clients info array
  + clients API
  + doc: clients
This commit is contained in:
Simon Zolin 2019-05-28 18:52:51 +03:00
commit c038e4cf14
37 changed files with 2031 additions and 116 deletions

View File

@ -12,6 +12,12 @@ Contents:
* Updating * Updating
* Get version command * Get version command
* Update command * Update command
* Device Names and Per-client Settings
* Per-client settings
* Get list of clients
* Add client
* Update client
* Delete client
* Enable DHCP server * Enable DHCP server
* "Check DHCP" command * "Check DHCP" command
* "Enable DHCP" command * "Enable DHCP" command
@ -420,3 +426,135 @@ Step 2.
If we would set a different IP address, we'd need to replace the IP address for the current network configuration. But currently this step isn't necessary. If we would set a different IP address, we'd need to replace the IP address for the current network configuration. But currently this step isn't necessary.
ip addr replace dev eth0 192.168.0.1/24 ip addr replace dev eth0 192.168.0.1/24
## Device Names and Per-client Settings
When a client requests information from DNS server, he's identified by IP address.
Administrator can set a name for a client with a known IP and also override global settings for this client. The name is used to improve readability of DNS logs: client's name is shown in UI next to its IP address. The names are loaded from 3 sources:
* automatically from "/etc/hosts" file. It's a list of `IP<->Name` entries which is loaded once on AGH startup from "/etc/hosts" file.
* automatically using rDNS. It's a list of `IP<->Name` entries which is added in runtime using rDNS mechanism when a client first makes a DNS request.
* manually configured via UI. It's a list of client's names and their settings which is loaded from configuration file and stored on disk.
### Per-client settings
UI provides means to manage the list of known clients (List/Add/Update/Delete) and their settings. These settings are stored in configuration file as an array of objects.
Notes:
* `name`, `ip` and `mac` values are unique.
* `ip` & `mac` values can't be set both at the same time.
* If `mac` is set and DHCP server is enabled, IP is taken from DHCP lease table.
* If `use_global_settings` is true, then DNS responses for this client are processed and filtered using global settings.
* If `use_global_settings` is false, then the client-specific settings are used to override (disable) global settings. For example, if global setting `parental_enabled` is true, then per-client setting `parental_enabled:false` can disable Parental Control for this specific client.
### Get list of clients
Request:
GET /control/clients
Response:
200 OK
{
clients: [
{
name: "client1"
ip: "..."
mac: "..."
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
}
]
auto_clients: [
{
name: "host"
ip: "..."
source: "etc/hosts" || "rDNS"
}
]
}
### Add client
Request:
POST /control/clients/add
{
name: "client1"
ip: "..."
mac: "..."
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
}
Response:
200 OK
Error response (Client already exists):
400
### Update client
Request:
POST /control/clients/update
{
name: "client1"
data: {
name: "client1"
ip: "..."
mac: "..."
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
}
}
Response:
200 OK
Error response (Client not found):
400
### Delete client
Request:
POST /control/clients/delete
{
name: "client1"
}
Response:
200 OK
Error response (Client not found):
400

View File

@ -19,6 +19,7 @@
"dhcp_config_saved": "Saved DHCP server config", "dhcp_config_saved": "Saved DHCP server config",
"form_error_required": "Required field", "form_error_required": "Required field",
"form_error_ip_format": "Invalid IPv4 format", "form_error_ip_format": "Invalid IPv4 format",
"form_error_mac_format": "Invalid MAC format",
"form_error_positive": "Must be greater than 0", "form_error_positive": "Must be greater than 0",
"dhcp_form_gateway_input": "Gateway IP", "dhcp_form_gateway_input": "Gateway IP",
"dhcp_form_subnet_input": "Subnet mask", "dhcp_form_subnet_input": "Subnet mask",
@ -105,6 +106,7 @@
"rules_count_table_header": "Rules count", "rules_count_table_header": "Rules count",
"last_time_updated_table_header": "Last time updated", "last_time_updated_table_header": "Last time updated",
"actions_table_header": "Actions", "actions_table_header": "Actions",
"edit_table_action": "Edit",
"delete_table_action": "Delete", "delete_table_action": "Delete",
"filters_and_hosts": "Filters and hosts blocklists", "filters_and_hosts": "Filters and hosts blocklists",
"filters_and_hosts_hint": "AdGuard Home understands basic adblock rules and hosts files syntax.", "filters_and_hosts_hint": "AdGuard Home understands basic adblock rules and hosts files syntax.",
@ -263,5 +265,30 @@
"dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.", "dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.",
"update_now": "Update now", "update_now": "Update now",
"update_failed": "Auto-update failed. Please <a href='https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update'>follow the steps<\/a> to update manually.", "update_failed": "Auto-update failed. Please <a href='https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update'>follow the steps<\/a> to update manually.",
"processing_update": "Please wait, AdGuard Home is being updated" "processing_update": "Please wait, AdGuard Home is being updated",
"clients_title": "Clients",
"clients_desc": "Configure devices connected to AdGuard Home",
"settings_global": "Global",
"settings_custom": "Custom",
"table_client": "Client",
"table_name": "Name",
"save_btn": "Save",
"client_add": "Add Client",
"client_new": "New Client",
"client_edit": "Edit Client",
"client_identifier": "Identifier",
"ip_address": "IP address",
"client_identifier_desc": "Clients can be identified by the IP address or MAC address. Please note, that using MAC as identifier is possible only if AdGuard Home is also a <0>DHCP server</0>",
"form_enter_ip": "Enter IP",
"form_enter_mac": "Enter MAC",
"form_client_name": "Enter client name",
"client_global_settings": "Use global settings",
"client_deleted": "Client \"{{key}}\" successfully deleted",
"client_added": "Client \"{{key}}\" successfully added",
"client_updated": "Client \"{{key}}\" successfully updated",
"table_statistics": "Requests count (last 24 hours)",
"clients_not_found": "No clients found",
"client_confirm_delete": "Are you sure you want to delete client \"{{key}}\"?",
"auto_clients_title": "Clients (runtime)",
"auto_clients_desc": "Data on the clients that use AdGuard Home, but not stored in the configuration"
} }

View File

@ -0,0 +1,84 @@
import { createAction } from 'redux-actions';
import { t } from 'i18next';
import Api from '../api/Api';
import { addErrorToast, addSuccessToast, getClients } from './index';
import { CLIENT_ID } from '../helpers/constants';
const apiClient = new Api();
export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL');
export const addClientRequest = createAction('ADD_CLIENT_REQUEST');
export const addClientFailure = createAction('ADD_CLIENT_FAILURE');
export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS');
export const addClient = config => async (dispatch) => {
dispatch(addClientRequest());
try {
let data;
if (config.identifier === CLIENT_ID.MAC) {
const { ip, identifier, ...values } = config;
data = { ...values };
} else {
const { mac, identifier, ...values } = config;
data = { ...values };
}
await apiClient.addClient(data);
dispatch(addClientSuccess());
dispatch(toggleClientModal());
dispatch(addSuccessToast(t('client_added', { key: config.name })));
dispatch(getClients());
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(addClientFailure());
}
};
export const deleteClientRequest = createAction('DELETE_CLIENT_REQUEST');
export const deleteClientFailure = createAction('DELETE_CLIENT_FAILURE');
export const deleteClientSuccess = createAction('DELETE_CLIENT_SUCCESS');
export const deleteClient = config => async (dispatch) => {
dispatch(deleteClientRequest());
try {
await apiClient.deleteClient(config);
dispatch(deleteClientSuccess());
dispatch(addSuccessToast(t('client_deleted', { key: config.name })));
dispatch(getClients());
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(deleteClientFailure());
}
};
export const updateClientRequest = createAction('UPDATE_CLIENT_REQUEST');
export const updateClientFailure = createAction('UPDATE_CLIENT_FAILURE');
export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS');
export const updateClient = (config, name) => async (dispatch) => {
dispatch(updateClientRequest());
try {
let data;
if (config.identifier === CLIENT_ID.MAC) {
const { ip, identifier, ...values } = config;
data = { name, data: { ...values } };
} else {
const { mac, identifier, ...values } = config;
data = { name, data: { ...values } };
}
await apiClient.updateClient(data);
dispatch(updateClientSuccess());
dispatch(toggleClientModal());
dispatch(addSuccessToast(t('client_updated', { key: name })));
dispatch(getClients());
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(updateClientFailure());
}
};

View File

@ -4,7 +4,7 @@ import { t } from 'i18next';
import { showLoading, hideLoading } from 'react-redux-loading-bar'; import { showLoading, hideLoading } from 'react-redux-loading-bar';
import axios from 'axios'; import axios from 'axios';
import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea } from '../helpers/helpers'; import { normalizeHistory, normalizeFilteringStatus, normalizeLogs, normalizeTextarea, sortClients } from '../helpers/helpers';
import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants'; import { SETTINGS_NAMES, CHECK_TIMEOUT } from '../helpers/constants';
import Api from '../api/Api'; import Api from '../api/Api';
@ -213,14 +213,41 @@ export const getClientsSuccess = createAction('GET_CLIENTS_SUCCESS');
export const getClients = () => async (dispatch) => { export const getClients = () => async (dispatch) => {
dispatch(getClientsRequest()); dispatch(getClientsRequest());
try { try {
const clients = await apiClient.getGlobalClients(); const data = await apiClient.getClients();
dispatch(getClientsSuccess(clients)); const sortedClients = data.clients && sortClients(data.clients);
const sortedAutoClients = data.auto_clients && sortClients(data.auto_clients);
dispatch(getClientsSuccess({
clients: sortedClients || [],
autoClients: sortedAutoClients || [],
}));
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(getClientsFailure()); dispatch(getClientsFailure());
} }
}; };
export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST');
export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE');
export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS');
export const getTopStats = () => async (dispatch, getState) => {
dispatch(getTopStatsRequest());
const timer = setInterval(async () => {
const state = getState();
if (state.dashboard.isCoreRunning) {
clearInterval(timer);
try {
const stats = await apiClient.getGlobalStatsTop();
dispatch(getTopStatsSuccess(stats));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(getTopStatsFailure(error));
}
}
}, 100);
};
export const dnsStatusRequest = createAction('DNS_STATUS_REQUEST'); export const dnsStatusRequest = createAction('DNS_STATUS_REQUEST');
export const dnsStatusFailure = createAction('DNS_STATUS_FAILURE'); export const dnsStatusFailure = createAction('DNS_STATUS_FAILURE');
export const dnsStatusSuccess = createAction('DNS_STATUS_SUCCESS'); export const dnsStatusSuccess = createAction('DNS_STATUS_SUCCESS');
@ -232,6 +259,7 @@ export const getDnsStatus = () => async (dispatch) => {
dispatch(dnsStatusSuccess(dnsStatus)); dispatch(dnsStatusSuccess(dnsStatus));
dispatch(getVersion()); dispatch(getVersion());
dispatch(getClients()); dispatch(getClients());
dispatch(getTopStats());
} catch (error) { } catch (error) {
dispatch(addErrorToast({ error })); dispatch(addErrorToast({ error }));
dispatch(initSettingsFailure()); dispatch(initSettingsFailure());
@ -289,27 +317,6 @@ export const getStats = () => async (dispatch) => {
} }
}; };
export const getTopStatsRequest = createAction('GET_TOP_STATS_REQUEST');
export const getTopStatsFailure = createAction('GET_TOP_STATS_FAILURE');
export const getTopStatsSuccess = createAction('GET_TOP_STATS_SUCCESS');
export const getTopStats = () => async (dispatch, getState) => {
dispatch(getTopStatsRequest());
const timer = setInterval(async () => {
const state = getState();
if (state.dashboard.isCoreRunning) {
clearInterval(timer);
try {
const stats = await apiClient.getGlobalStatsTop();
dispatch(getTopStatsSuccess(stats));
} catch (error) {
dispatch(addErrorToast({ error }));
dispatch(getTopStatsFailure(error));
}
}
}, 100);
};
export const getLogsRequest = createAction('GET_LOGS_REQUEST'); export const getLogsRequest = createAction('GET_LOGS_REQUEST');
export const getLogsFailure = createAction('GET_LOGS_FAILURE'); export const getLogsFailure = createAction('GET_LOGS_FAILURE');
export const getLogsSuccess = createAction('GET_LOGS_SUCCESS'); export const getLogsSuccess = createAction('GET_LOGS_SUCCESS');

View File

@ -39,8 +39,6 @@ export default class Api {
GLOBAL_VERSION = { path: 'version.json', method: 'GET' }; GLOBAL_VERSION = { path: 'version.json', method: 'GET' };
GLOBAL_ENABLE_PROTECTION = { path: 'enable_protection', method: 'POST' }; GLOBAL_ENABLE_PROTECTION = { path: 'enable_protection', method: 'POST' };
GLOBAL_DISABLE_PROTECTION = { path: 'disable_protection', method: 'POST' }; GLOBAL_DISABLE_PROTECTION = { path: 'disable_protection', method: 'POST' };
GLOBAL_CLIENTS = { path: 'clients', method: 'GET' }
GLOBAL_CLIENTS = { path: 'clients', method: 'GET' };
GLOBAL_UPDATE = { path: 'update', method: 'POST' }; GLOBAL_UPDATE = { path: 'update', method: 'POST' };
restartGlobalFiltering() { restartGlobalFiltering() {
@ -142,11 +140,6 @@ export default class Api {
return this.makeRequest(path, method); return this.makeRequest(path, method);
} }
getGlobalClients() {
const { path, method } = this.GLOBAL_CLIENTS;
return this.makeRequest(path, method);
}
getUpdate() { getUpdate() {
const { path, method } = this.GLOBAL_UPDATE; const { path, method } = this.GLOBAL_UPDATE;
return this.makeRequest(path, method); return this.makeRequest(path, method);
@ -409,4 +402,42 @@ export default class Api {
}; };
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
// Per-client settings
GET_CLIENTS = { path: 'clients', method: 'GET' }
ADD_CLIENT = { path: 'clients/add', method: 'POST' }
DELETE_CLIENT = { path: 'clients/delete', method: 'POST' }
UPDATE_CLIENT = { path: 'clients/update', method: 'POST' }
getClients() {
const { path, method } = this.GET_CLIENTS;
return this.makeRequest(path, method);
}
addClient(config) {
const { path, method } = this.ADD_CLIENT;
const parameters = {
data: config,
headers: { 'Content-Type': 'application/json' },
};
return this.makeRequest(path, method, parameters);
}
deleteClient(config) {
const { path, method } = this.DELETE_CLIENT;
const parameters = {
data: config,
headers: { 'Content-Type': 'application/json' },
};
return this.makeRequest(path, method, parameters);
}
updateClient(config) {
const { path, method } = this.UPDATE_CLIENT;
const parameters = {
data: config,
headers: { 'Content-Type': 'application/json' },
};
return this.makeRequest(path, method, parameters);
}
} }

View File

@ -21,6 +21,7 @@ import Status from '../ui/Status';
import UpdateTopline from '../ui/UpdateTopline'; import UpdateTopline from '../ui/UpdateTopline';
import UpdateOverlay from '../ui/UpdateOverlay'; import UpdateOverlay from '../ui/UpdateOverlay';
import EncryptionTopline from '../ui/EncryptionTopline'; import EncryptionTopline from '../ui/EncryptionTopline';
import Icons from '../ui/Icons';
import i18n from '../../i18n'; import i18n from '../../i18n';
class App extends Component { class App extends Component {
@ -103,6 +104,7 @@ class App extends Component {
</div> </div>
<Footer /> <Footer />
<Toasts /> <Toasts />
<Icons />
</Fragment> </Fragment>
</HashRouter> </HashRouter>
); );

View File

@ -24,7 +24,8 @@ class Clients extends Component {
Header: 'IP', Header: 'IP',
accessor: 'ip', accessor: 'ip',
Cell: ({ value }) => { Cell: ({ value }) => {
const clientName = getClientName(this.props.clients, value); const clientName = getClientName(this.props.clients, value)
|| getClientName(this.props.autoClients, value);
let client; let client;
if (clientName) { if (clientName) {
@ -79,6 +80,7 @@ Clients.propTypes = {
dnsQueries: PropTypes.number.isRequired, dnsQueries: PropTypes.number.isRequired,
refreshButton: PropTypes.node.isRequired, refreshButton: PropTypes.node.isRequired,
clients: PropTypes.array.isRequired, clients: PropTypes.array.isRequired,
autoClients: PropTypes.array.isRequired,
t: PropTypes.func, t: PropTypes.func,
}; };

View File

@ -96,6 +96,7 @@ class Dashboard extends Component {
refreshButton={refreshButton} refreshButton={refreshButton}
topClients={dashboard.topStats.top_clients} topClients={dashboard.topStats.top_clients}
clients={dashboard.clients} clients={dashboard.clients}
autoClients={dashboard.autoClients}
/> />
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">

View File

@ -1,13 +0,0 @@
.remove-icon {
position: relative;
top: 2px;
display: inline-block;
width: 20px;
height: 18px;
opacity: 0.6;
}
.remove-icon:hover {
cursor: pointer;
opacity: 1;
}

View File

@ -6,7 +6,6 @@ import Modal from '../ui/Modal';
import PageTitle from '../ui/PageTitle'; import PageTitle from '../ui/PageTitle';
import Card from '../ui/Card'; import Card from '../ui/Card';
import UserRules from './UserRules'; import UserRules from './UserRules';
import './Filters.css';
class Filters extends Component { class Filters extends Component {
componentDidMount() { componentDidMount() {
@ -59,7 +58,18 @@ class Filters extends Component {
}, { }, {
Header: <Trans>actions_table_header</Trans>, Header: <Trans>actions_table_header</Trans>,
accessor: 'url', accessor: 'url',
Cell: ({ value }) => (<span title={ this.props.t('delete_table_action') } className='remove-icon fe fe-trash-2' onClick={() => this.props.removeFilter(value)}/>), Cell: ({ value }) => (
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
onClick={() => this.props.removeFilter(value)}
title={this.props.t('delete_table_action')}
>
<svg className="icons">
<use xlinkHref="#delete" />
</svg>
</button>
),
className: 'text-center', className: 'text-center',
width: 80, width: 80,
sortable: false, sortable: false,

View File

@ -5,6 +5,10 @@
min-height: 26px; min-height: 26px;
} }
.logs__row--center {
justify-content: center;
}
.logs__row--overflow { .logs__row--overflow {
overflow: hidden; overflow: hidden;
} }

View File

@ -196,7 +196,8 @@ class Logs extends Component {
Cell: (row) => { Cell: (row) => {
const { reason } = row.original; const { reason } = row.original;
const isFiltered = row ? reason.indexOf('Filtered') === 0 : false; const isFiltered = row ? reason.indexOf('Filtered') === 0 : false;
const clientName = getClientName(dashboard.clients, row.value); const clientName = getClientName(dashboard.clients, row.value)
|| getClientName(dashboard.autoClients, row.value);
let client; let client;
if (clientName) { if (clientName) {

View File

@ -0,0 +1,118 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { withNamespaces } from 'react-i18next';
import ReactTable from 'react-table';
import { CLIENT_ID } from '../../../helpers/constants';
import Card from '../../ui/Card';
class AutoClients extends Component {
getClient = (name, clients) => {
const client = clients.find(item => name === item.name);
if (client) {
const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP;
return {
identifier,
use_global_settings: true,
...client,
};
}
return {
identifier: 'ip',
use_global_settings: true,
};
};
getStats = (ip, stats) => {
if (stats && stats.top_clients) {
return stats.top_clients[ip];
}
return '';
};
cellWrap = ({ value }) => (
<div className="logs__row logs__row--overflow">
<span className="logs__text" title={value}>
{value}
</span>
</div>
);
columns = [
{
Header: this.props.t('table_client'),
accessor: 'ip',
Cell: this.cellWrap,
},
{
Header: this.props.t('table_name'),
accessor: 'name',
Cell: this.cellWrap,
},
{
Header: this.props.t('source_label'),
accessor: 'source',
Cell: this.cellWrap,
},
{
Header: this.props.t('table_statistics'),
accessor: 'statistics',
Cell: (row) => {
const clientIP = row.original.ip;
const clientStats = clientIP && this.getStats(clientIP, this.props.topStats);
if (clientStats) {
return (
<div className="logs__row">
<div className="logs__text" title={clientStats}>
{clientStats}
</div>
</div>
);
}
return '';
},
},
];
render() {
const { t, autoClients } = this.props;
return (
<Card
title={t('auto_clients_title')}
subtitle={t('auto_clients_desc')}
bodyType="card-body box-body--settings"
>
<ReactTable
data={autoClients || []}
columns={this.columns}
className="-striped -highlight card-table-overflow"
showPagination={true}
defaultPageSize={10}
minRows={5}
previousText={t('previous_btn')}
nextText={t('next_btn')}
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText={t('of_table_footer_text')}
rowsText={t('rows_table_footer_text')}
noDataText={t('clients_not_found')}
/>
</Card>
);
}
}
AutoClients.propTypes = {
t: PropTypes.func.isRequired,
autoClients: PropTypes.array.isRequired,
topStats: PropTypes.object.isRequired,
};
export default withNamespaces()(AutoClients);

View File

@ -0,0 +1,217 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { Field, reduxForm, formValueSelector } from 'redux-form';
import { Trans, withNamespaces } from 'react-i18next';
import flow from 'lodash/flow';
import { renderField, renderSelectField, ipv4, mac, required } from '../../../helpers/form';
import { CLIENT_ID } from '../../../helpers/constants';
let Form = (props) => {
const {
t,
handleSubmit,
reset,
pristine,
submitting,
clientIdentifier,
useGlobalSettings,
toggleClientModal,
processingAdding,
processingUpdating,
} = props;
return (
<form onSubmit={handleSubmit}>
<div className="modal-body">
<div className="form__group">
<div className="form-inline mb-3">
<strong className="mr-3">
<Trans>client_identifier</Trans>
</strong>
<label className="mr-3">
<Field
name="identifier"
component={renderField}
type="radio"
className="form-control mr-2"
value="ip"
/>{' '}
<Trans>ip_address</Trans>
</label>
<label>
<Field
name="identifier"
component={renderField}
type="radio"
className="form-control mr-2"
value="mac"
/>{' '}
MAC
</label>
</div>
{clientIdentifier === CLIENT_ID.IP && (
<div className="form__group">
<Field
id="ip"
name="ip"
component={renderField}
type="text"
className="form-control"
placeholder={t('form_enter_ip')}
validate={[ipv4, required]}
/>
</div>
)}
{clientIdentifier === CLIENT_ID.MAC && (
<div className="form__group">
<Field
id="mac"
name="mac"
component={renderField}
type="text"
className="form-control"
placeholder={t('form_enter_mac')}
validate={[mac, required]}
/>
</div>
)}
<div className="form__desc">
<Trans
components={[
<a href="#settings_dhcp" key="0">
link
</a>,
]}
>
client_identifier_desc
</Trans>
</div>
</div>
<div className="form__group">
<Field
id="name"
name="name"
component={renderField}
type="text"
className="form-control"
placeholder={t('form_client_name')}
validate={[required]}
/>
</div>
<div className="mb-4">
<strong>
<Trans>settings</Trans>
</strong>
</div>
<div className="form__group">
<Field
name="use_global_settings"
type="checkbox"
component={renderSelectField}
placeholder={t('client_global_settings')}
/>
</div>
<div className="form__group">
<Field
name="filtering_enabled"
type="checkbox"
component={renderSelectField}
placeholder={t('block_domain_use_filters_and_hosts')}
disabled={useGlobalSettings}
/>
</div>
<div className="form__group">
<Field
name="safebrowsing_enabled"
type="checkbox"
component={renderSelectField}
placeholder={t('use_adguard_browsing_sec')}
disabled={useGlobalSettings}
/>
</div>
<div className="form__group">
<Field
name="parental_enabled"
type="checkbox"
component={renderSelectField}
placeholder={t('use_adguard_parental')}
disabled={useGlobalSettings}
/>
</div>
<div className="form__group">
<Field
name="safesearch_enabled"
type="checkbox"
component={renderSelectField}
placeholder={t('enforce_safe_search')}
disabled={useGlobalSettings}
/>
</div>
</div>
<div className="modal-footer">
<div className="btn-list">
<button
type="button"
className="btn btn-secondary btn-standard"
disabled={submitting}
onClick={() => {
reset();
toggleClientModal();
}}
>
<Trans>cancel_btn</Trans>
</button>
<button
type="submit"
className="btn btn-success btn-standard"
disabled={submitting || pristine || processingAdding || processingUpdating}
>
<Trans>save_btn</Trans>
</button>
</div>
</div>
</form>
);
};
Form.propTypes = {
pristine: PropTypes.bool.isRequired,
handleSubmit: PropTypes.func.isRequired,
reset: PropTypes.func.isRequired,
submitting: PropTypes.bool.isRequired,
toggleClientModal: PropTypes.func.isRequired,
clientIdentifier: PropTypes.string,
useGlobalSettings: PropTypes.bool,
t: PropTypes.func.isRequired,
processingAdding: PropTypes.bool.isRequired,
processingUpdating: PropTypes.bool.isRequired,
};
const selector = formValueSelector('clientForm');
Form = connect((state) => {
const clientIdentifier = selector(state, 'identifier');
const useGlobalSettings = selector(state, 'use_global_settings');
return {
clientIdentifier,
useGlobalSettings,
};
})(Form);
export default flow([
withNamespaces(),
reduxForm({
form: 'clientForm',
enableReinitialize: true,
}),
])(Form);

View File

@ -0,0 +1,64 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Trans, withNamespaces } from 'react-i18next';
import ReactModal from 'react-modal';
import { MODAL_TYPE } from '../../../helpers/constants';
import Form from './Form';
const Modal = (props) => {
const {
isModalOpen,
modalType,
currentClientData,
handleSubmit,
toggleClientModal,
processingAdding,
processingUpdating,
} = props;
return (
<ReactModal
className="Modal__Bootstrap modal-dialog modal-dialog-centered modal-dialog--clients"
closeTimeoutMS={0}
isOpen={isModalOpen}
onRequestClose={() => toggleClientModal()}
>
<div className="modal-content">
<div className="modal-header">
<h4 className="modal-title">
{modalType === MODAL_TYPE.EDIT ? (
<Trans>client_edit</Trans>
) : (
<Trans>client_new</Trans>
)}
</h4>
<button type="button" className="close" onClick={() => toggleClientModal()}>
<span className="sr-only">Close</span>
</button>
</div>
<Form
initialValues={{
...currentClientData,
}}
onSubmit={handleSubmit}
toggleClientModal={toggleClientModal}
processingAdding={processingAdding}
processingUpdating={processingUpdating}
/>
</div>
</ReactModal>
);
};
Modal.propTypes = {
isModalOpen: PropTypes.bool.isRequired,
modalType: PropTypes.string.isRequired,
currentClientData: PropTypes.object.isRequired,
handleSubmit: PropTypes.func.isRequired,
toggleClientModal: PropTypes.func.isRequired,
processingAdding: PropTypes.bool.isRequired,
processingUpdating: PropTypes.bool.isRequired,
};
export default withNamespaces()(Modal);

View File

@ -0,0 +1,263 @@
import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types';
import { Trans, withNamespaces } from 'react-i18next';
import ReactTable from 'react-table';
import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants';
import Card from '../../ui/Card';
import Modal from './Modal';
class Clients extends Component {
handleFormAdd = (values) => {
this.props.addClient(values);
};
handleFormUpdate = (values, name) => {
this.props.updateClient(values, name);
};
handleSubmit = (values) => {
if (this.props.modalType === MODAL_TYPE.EDIT) {
this.handleFormUpdate(values, this.props.modalClientName);
} else {
this.handleFormAdd(values);
}
};
cellWrap = ({ value }) => (
<div className="logs__row logs__row--overflow">
<span className="logs__text" title={value}>
{value}
</span>
</div>
);
getClient = (name, clients) => {
const client = clients.find(item => name === item.name);
if (client) {
const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP;
return {
identifier,
use_global_settings: true,
...client,
};
}
return {
identifier: CLIENT_ID.IP,
use_global_settings: true,
};
};
getStats = (ip, stats) => {
if (stats && stats.top_clients) {
return stats.top_clients[ip];
}
return '';
};
handleDelete = (data) => {
// eslint-disable-next-line no-alert
if (window.confirm(this.props.t('client_confirm_delete', { key: data.name }))) {
this.props.deleteClient(data);
}
}
columns = [
{
Header: this.props.t('table_client'),
accessor: 'ip',
Cell: (row) => {
if (row.original && row.original.mac) {
return (
<div className="logs__row logs__row--overflow">
<span className="logs__text" title={row.original.mac}>
{row.original.mac} <em>(MAC)</em>
</span>
</div>
);
} else if (row.value) {
return (
<div className="logs__row logs__row--overflow">
<span className="logs__text" title={row.value}>
{row.value} <em>(IP)</em>
</span>
</div>
);
}
return '';
},
},
{
Header: this.props.t('table_name'),
accessor: 'name',
Cell: this.cellWrap,
},
{
Header: this.props.t('settings'),
accessor: 'use_global_settings',
Cell: ({ value }) => {
const title = value ? (
<Trans>settings_global</Trans>
) : (
<Trans>settings_custom</Trans>
);
return (
<div className="logs__row logs__row--overflow">
<div className="logs__text" title={title}>
{title}
</div>
</div>
);
},
},
{
Header: this.props.t('table_statistics'),
accessor: 'statistics',
Cell: (row) => {
const clientIP = row.original.ip;
const clientStats = clientIP && this.getStats(clientIP, this.props.topStats);
if (clientStats) {
return (
<div className="logs__row">
<div className="logs__text" title={clientStats}>
{clientStats}
</div>
</div>
);
}
return '';
},
},
{
Header: this.props.t('actions_table_header'),
accessor: 'actions',
maxWidth: 150,
Cell: (row) => {
const clientName = row.original.name;
const {
toggleClientModal,
processingDeleting,
processingUpdating,
t,
} = this.props;
return (
<div className="logs__row logs__row--center">
<button
type="button"
className="btn btn-icon btn-outline-primary btn-sm mr-2"
onClick={() =>
toggleClientModal({
type: MODAL_TYPE.EDIT,
name: clientName,
})
}
disabled={processingUpdating}
title={t('edit_table_action')}
>
<svg className="icons">
<use xlinkHref="#edit" />
</svg>
</button>
<button
type="button"
className="btn btn-icon btn-outline-secondary btn-sm"
onClick={() => this.handleDelete({ name: clientName })}
disabled={processingDeleting}
title={t('delete_table_action')}
>
<svg className="icons">
<use xlinkHref="#delete" />
</svg>
</button>
</div>
);
},
},
];
render() {
const {
t,
clients,
isModalOpen,
modalType,
modalClientName,
toggleClientModal,
processingAdding,
processingUpdating,
} = this.props;
const currentClientData = this.getClient(modalClientName, clients);
return (
<Card
title={t('clients_title')}
subtitle={t('clients_desc')}
bodyType="card-body box-body--settings"
>
<Fragment>
<ReactTable
data={clients || []}
columns={this.columns}
className="-striped -highlight card-table-overflow"
showPagination={true}
defaultPageSize={10}
minRows={5}
previousText={t('previous_btn')}
nextText={t('next_btn')}
loadingText={t('loading_table_status')}
pageText={t('page_table_footer_text')}
ofText={t('of_table_footer_text')}
rowsText={t('rows_table_footer_text')}
noDataText={t('clients_not_found')}
/>
<button
type="button"
className="btn btn-success btn-standard mt-3"
onClick={() => toggleClientModal(MODAL_TYPE.ADD)}
disabled={processingAdding}
>
<Trans>client_add</Trans>
</button>
<Modal
isModalOpen={isModalOpen}
modalType={modalType}
toggleClientModal={toggleClientModal}
currentClientData={currentClientData}
handleSubmit={this.handleSubmit}
processingAdding={processingAdding}
processingUpdating={processingUpdating}
/>
</Fragment>
</Card>
);
}
}
Clients.propTypes = {
t: PropTypes.func.isRequired,
clients: PropTypes.array.isRequired,
topStats: PropTypes.object.isRequired,
toggleClientModal: PropTypes.func.isRequired,
deleteClient: PropTypes.func.isRequired,
addClient: PropTypes.func.isRequired,
updateClient: PropTypes.func.isRequired,
isModalOpen: PropTypes.bool.isRequired,
modalType: PropTypes.string.isRequired,
modalClientName: PropTypes.string.isRequired,
processingAdding: PropTypes.bool.isRequired,
processingDeleting: PropTypes.bool.isRequired,
processingUpdating: PropTypes.bool.isRequired,
};
export default withNamespaces()(Clients);

View File

@ -76,3 +76,11 @@
.encryption__list li { .encryption__list li {
list-style: inside; list-style: inside;
} }
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
}

View File

@ -1,13 +1,17 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { withNamespaces, Trans } from 'react-i18next'; import { withNamespaces, Trans } from 'react-i18next';
import Upstream from './Upstream'; import Upstream from './Upstream';
import Dhcp from './Dhcp'; import Dhcp from './Dhcp';
import Encryption from './Encryption'; import Encryption from './Encryption';
import Clients from './Clients';
import AutoClients from './Clients/AutoClients';
import Checkbox from '../ui/Checkbox'; import Checkbox from '../ui/Checkbox';
import Loading from '../ui/Loading'; import Loading from '../ui/Loading';
import PageTitle from '../ui/PageTitle'; import PageTitle from '../ui/PageTitle';
import Card from '../ui/Card'; import Card from '../ui/Card';
import './Settings.css'; import './Settings.css';
class Settings extends Component { class Settings extends Component {
@ -46,29 +50,38 @@ class Settings extends Component {
return Object.keys(settings).map((key) => { return Object.keys(settings).map((key) => {
const setting = settings[key]; const setting = settings[key];
const { enabled } = setting; const { enabled } = setting;
return (<Checkbox return (
key={key} <Checkbox
{...settings[key]} key={key}
handleChange={() => this.props.toggleSetting(key, enabled)} {...settings[key]}
/>); handleChange={() => this.props.toggleSetting(key, enabled)}
/>
);
}); });
} }
return ( return (
<div><Trans>no_settings</Trans></div> <div>
<Trans>no_settings</Trans>
</div>
); );
} };
render() { render() {
const { settings, dashboard, t } = this.props; const {
settings, dashboard, clients, t,
} = this.props;
return ( return (
<Fragment> <Fragment>
<PageTitle title={ t('settings') } /> <PageTitle title={t('settings')} />
{settings.processing && <Loading />} {settings.processing && <Loading />}
{!settings.processing && {!settings.processing && (
<div className="content"> <div className="content">
<div className="row"> <div className="row">
<div className="col-md-12"> <div className="col-md-12">
<Card title={ t('general_settings') } bodyType="card-body box-body--settings"> <Card
title={t('general_settings')}
bodyType="card-body box-body--settings"
>
<div className="form"> <div className="form">
{this.renderSettings(settings.settingsList)} {this.renderSettings(settings.settingsList)}
</div> </div>
@ -82,6 +95,28 @@ class Settings extends Component {
processingTestUpstream={settings.processingTestUpstream} processingTestUpstream={settings.processingTestUpstream}
processingSetUpstream={settings.processingSetUpstream} processingSetUpstream={settings.processingSetUpstream}
/> />
{!dashboard.processingTopStats && !dashboard.processingClients && (
<Fragment>
<Clients
clients={dashboard.clients}
topStats={dashboard.topStats}
isModalOpen={clients.isModalOpen}
modalClientName={clients.modalClientName}
modalType={clients.modalType}
addClient={this.props.addClient}
updateClient={this.props.updateClient}
deleteClient={this.props.deleteClient}
toggleClientModal={this.props.toggleClientModal}
processingAdding={clients.processingAdding}
processingDeleting={clients.processingDeleting}
processingUpdating={clients.processingUpdating}
/>
<AutoClients
autoClients={dashboard.autoClients}
topStats={dashboard.topStats}
/>
</Fragment>
)}
<Encryption <Encryption
encryption={this.props.encryption} encryption={this.props.encryption}
setTlsConfig={this.props.setTlsConfig} setTlsConfig={this.props.setTlsConfig}
@ -97,7 +132,7 @@ class Settings extends Component {
</div> </div>
</div> </div>
</div> </div>
} )}
</Fragment> </Fragment>
); );
} }

View File

@ -2,7 +2,7 @@
position: fixed; position: fixed;
right: 24px; right: 24px;
bottom: 24px; bottom: 24px;
z-index: 103; z-index: 105;
width: 345px; width: 345px;
} }

View File

@ -0,0 +1,5 @@
.icons {
display: inline-block;
vertical-align: middle;
height: 100%;
}

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import './Icons.css';
const Icons = () => ( const Icons = () => (
<svg xmlns="http://www.w3.org/2000/svg" className="hidden"> <svg xmlns="http://www.w3.org/2000/svg" className="hidden">
<symbol id="android" viewBox="0 0 14 16" fill="currentColor"> <symbol id="android" viewBox="0 0 14 16" fill="currentColor">
@ -21,6 +23,14 @@ const Icons = () => (
<symbol id="router" viewBox="0 0 30 30" fill="currentColor"> <symbol id="router" viewBox="0 0 30 30" fill="currentColor">
<path d="M17.646 2.332a1 1 0 0 0-.697 1.719 6.984 6.984 0 0 1 0 9.898 1 1 0 1 0 1.414 1.414c3.507-3.506 3.507-9.22 0-12.726a1 1 0 0 0-.717-.305zm-12.662.654A1 1 0 0 0 4 4v14a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h22a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H12V9a1 1 0 0 0-1.016-1.014A1 1 0 0 0 10 9v9H6V4a1 1 0 0 0-1.016-1.014zm9.834 2.176a1 1 0 0 0-.697 1.717 2.985 2.985 0 0 1 0 4.242 1 1 0 1 0 1.414 1.414 5.014 5.014 0 0 0 0-7.07 1 1 0 0 0-.717-.303zM5 21a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" /> <path d="M17.646 2.332a1 1 0 0 0-.697 1.719 6.984 6.984 0 0 1 0 9.898 1 1 0 1 0 1.414 1.414c3.507-3.506 3.507-9.22 0-12.726a1 1 0 0 0-.717-.305zm-12.662.654A1 1 0 0 0 4 4v14a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h22a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H12V9a1 1 0 0 0-1.016-1.014A1 1 0 0 0 10 9v9H6V4a1 1 0 0 0-1.016-1.014zm9.834 2.176a1 1 0 0 0-.697 1.717 2.985 2.985 0 0 1 0 4.242 1 1 0 1 0 1.414 1.414 5.014 5.014 0 0 0 0-7.07 1 1 0 0 0-.717-.303zM5 21a1 1 0 1 1 0 2 1 1 0 0 1 0-2zm4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2z" />
</symbol> </symbol>
<symbol id="edit" viewBox="0 0 24 24" stroke="currentColor" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</symbol>
<symbol id="delete" viewBox="0 0 24 24" stroke="currentColor" fill="none" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<path d="m3 6h2 16"/><path d="m19 6v14a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2-2v-14m3 0v-2a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="m10 11v6"/><path d="m14 11v6"/>
</symbol>
</svg> </svg>
); );

View File

@ -5,7 +5,7 @@
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
z-index: 1; z-index: 104;
} }
.ReactModal__Overlay--after-open { .ReactModal__Overlay--after-open {
@ -38,3 +38,9 @@
border: none; border: none;
background-color: transparent; background-color: transparent;
} }
@media (min-width: 576px) {
.modal-dialog--clients {
max-width: 650px;
}
}

View File

@ -17,6 +17,12 @@ import {
setTlsConfig, setTlsConfig,
validateTlsConfig, validateTlsConfig,
} from '../actions/encryption'; } from '../actions/encryption';
import {
addClient,
updateClient,
deleteClient,
toggleClientModal,
} from '../actions/clients';
import Settings from '../components/Settings'; import Settings from '../components/Settings';
const mapStateToProps = (state) => { const mapStateToProps = (state) => {
@ -25,12 +31,14 @@ const mapStateToProps = (state) => {
dashboard, dashboard,
dhcp, dhcp,
encryption, encryption,
clients,
} = state; } = state;
const props = { const props = {
settings, settings,
dashboard, dashboard,
dhcp, dhcp,
encryption, encryption,
clients,
}; };
return props; return props;
}; };
@ -50,6 +58,10 @@ const mapDispatchToProps = {
getTlsStatus, getTlsStatus,
setTlsConfig, setTlsConfig,
validateTlsConfig, validateTlsConfig,
addClient,
updateClient,
deleteClient,
toggleClientModal,
}; };
export default connect( export default connect(

View File

@ -1,5 +1,6 @@
export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/\w[\w_\-.]*\.[a-z]{2,8}[^\s]*$/; export const R_URL_REQUIRES_PROTOCOL = /^https?:\/\/\w[\w_\-.]*\.[a-z]{2,8}[^\s]*$/;
export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g; export const R_IPV4 = /^(?:(?:^|\.)(?:2(?:5[0-5]|[0-4]\d)|1?\d?\d)){4}$/g;
export const R_MAC = /^((([a-fA-F0-9][a-fA-F0-9]+[-]){5}|([a-fA-F0-9][a-fA-F0-9]+[:]){5})([a-fA-F0-9][a-fA-F0-9])$)|(^([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]+[.]){2}([a-fA-F0-9][a-fA-F0-9][a-fA-F0-9][a-fA-F0-9]))$/g;
export const STATS_NAMES = { export const STATS_NAMES = {
avg_processing_time: 'average_processing_time', avg_processing_time: 'average_processing_time',
@ -19,7 +20,8 @@ export const STATUS_COLORS = {
export const REPOSITORY = { export const REPOSITORY = {
URL: 'https://github.com/AdguardTeam/AdGuardHome', URL: 'https://github.com/AdguardTeam/AdGuardHome',
TRACKERS_DB: 'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json', TRACKERS_DB:
'https://github.com/AdguardTeam/AdGuardHome/tree/master/client/src/helpers/trackers/adguard.json',
}; };
export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html'; export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html';
@ -165,3 +167,13 @@ export const DHCP_STATUS_RESPONSE = {
NO: 'no', NO: 'no',
ERROR: 'error', ERROR: 'error',
}; };
export const MODAL_TYPE = {
ADD: 'add',
EDIT: 'edit',
};
export const CLIENT_ID = {
MAC: 'mac',
IP: 'ip',
};

View File

@ -1,7 +1,7 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Trans } from 'react-i18next'; import { Trans } from 'react-i18next';
import { R_IPV4, UNSAFE_PORTS } from '../helpers/constants'; import { R_IPV4, R_MAC, UNSAFE_PORTS } from '../helpers/constants';
export const renderField = ({ export const renderField = ({
input, id, className, placeholder, type, disabled, meta: { touched, error }, input, id, className, placeholder, type, disabled, meta: { touched, error },
@ -55,6 +55,13 @@ export const ipv4 = (value) => {
return false; return false;
}; };
export const mac = (value) => {
if (value && !new RegExp(R_MAC).test(value)) {
return <Trans>form_error_mac_format</Trans>;
}
return false;
};
export const isPositive = (value) => { export const isPositive = (value) => {
if ((value || value === 0) && (value <= 0)) { if ((value || value === 0) && (value <= 0)) {
return <Trans>form_error_positive</Trans>; return <Trans>form_error_positive</Trans>;

View File

@ -208,3 +208,20 @@ export const getClientName = (clients, ip) => {
const client = clients.find(item => ip === item.ip); const client = clients.find(item => ip === item.ip);
return (client && client.name) || ''; return (client && client.name) || '';
}; };
export const sortClients = (clients) => {
const compare = (a, b) => {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
if (nameA > nameB) {
return 1;
} else if (nameA < nameB) {
return -1;
}
return 0;
};
return clients.sort(compare);
};

View File

@ -0,0 +1,63 @@
import { handleActions } from 'redux-actions';
import * as actions from '../actions/clients';
const clients = handleActions({
[actions.addClientRequest]: state => ({ ...state, processingAdding: true }),
[actions.addClientFailure]: state => ({ ...state, processingAdding: false }),
[actions.addClientSuccess]: (state) => {
const newState = {
...state,
processingAdding: false,
};
return newState;
},
[actions.deleteClientRequest]: state => ({ ...state, processingDeleting: true }),
[actions.deleteClientFailure]: state => ({ ...state, processingDeleting: false }),
[actions.deleteClientSuccess]: (state) => {
const newState = {
...state,
processingDeleting: false,
};
return newState;
},
[actions.updateClientRequest]: state => ({ ...state, processingUpdating: true }),
[actions.updateClientFailure]: state => ({ ...state, processingUpdating: false }),
[actions.updateClientSuccess]: (state) => {
const newState = {
...state,
processingUpdating: false,
};
return newState;
},
[actions.toggleClientModal]: (state, { payload }) => {
if (payload) {
const newState = {
...state,
modalType: payload.type || '',
modalClientName: payload.name || '',
isModalOpen: !state.isModalOpen,
};
return newState;
}
const newState = {
...state,
isModalOpen: !state.isModalOpen,
};
return newState;
},
}, {
processing: true,
processingAdding: false,
processingDeleting: false,
processingUpdating: false,
isModalOpen: false,
modalClientName: '',
modalType: '',
});
export default clients;

View File

@ -7,6 +7,7 @@ import versionCompare from '../helpers/versionCompare';
import * as actions from '../actions'; import * as actions from '../actions';
import toasts from './toasts'; import toasts from './toasts';
import encryption from './encryption'; import encryption from './encryption';
import clients from './clients';
const settings = handleActions({ const settings = handleActions({
[actions.initSettingsRequest]: state => ({ ...state, processing: true }), [actions.initSettingsRequest]: state => ({ ...state, processing: true }),
@ -184,7 +185,8 @@ const dashboard = handleActions({
[actions.getClientsSuccess]: (state, { payload }) => { [actions.getClientsSuccess]: (state, { payload }) => {
const newState = { const newState = {
...state, ...state,
clients: payload, clients: payload.clients,
autoClients: payload.autoClients,
processingClients: false, processingClients: false,
}; };
return newState; return newState;
@ -209,6 +211,8 @@ const dashboard = handleActions({
dnsAddresses: [], dnsAddresses: [],
dnsVersion: '', dnsVersion: '',
clients: [], clients: [],
autoClients: [],
topStats: [],
}); });
const queryLogs = handleActions({ const queryLogs = handleActions({
@ -361,6 +365,7 @@ export default combineReducers({
toasts, toasts,
dhcp, dhcp,
encryption, encryption,
clients,
loadingBar: loadingBarReducer, loadingBar: loadingBarReducer,
form: formReducer, form: formReducer,
}); });

View File

@ -2,32 +2,264 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io/ioutil" "io/ioutil"
"net"
"net/http" "net/http"
"os" "os"
"runtime" "runtime"
"strings" "strings"
"sync"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
) )
// Client information // Client information
type Client struct { type Client struct {
IP string IP string
Name string MAC string
//Source source // Hosts file / User settings / DHCP Name string
UseOwnSettings bool // false: use global settings
FilteringEnabled bool
SafeSearchEnabled bool
SafeBrowsingEnabled bool
ParentalEnabled bool
} }
type clientJSON struct { type clientJSON struct {
IP string `json:"ip"` IP string `json:"ip"`
Name string `json:"name"` MAC string `json:"mac"`
Name string `json:"name"`
UseGlobalSettings bool `json:"use_global_settings"`
FilteringEnabled bool `json:"filtering_enabled"`
ParentalEnabled bool `json:"parental_enabled"`
SafeSearchEnabled bool `json:"safebrowsing_enabled"`
SafeBrowsingEnabled bool `json:"safesearch_enabled"`
} }
var clients []Client type clientSource uint
var clientsFilled bool
const (
ClientSourceHostsFile clientSource = 0 // from /etc/hosts
ClientSourceRDNS clientSource = 1 // from rDNS
)
// ClientHost information
type ClientHost struct {
Host string
Source clientSource
}
type clientsContainer struct {
list map[string]*Client
ipIndex map[string]*Client
ipHost map[string]ClientHost // IP -> Hostname
lock sync.Mutex
}
var clients clientsContainer
// Initialize clients container
func clientsInit() {
if clients.list != nil {
log.Fatal("clients.list != nil")
}
clients.list = make(map[string]*Client)
clients.ipIndex = make(map[string]*Client)
clients.ipHost = make(map[string]ClientHost)
clientsAddFromHostsFile()
}
func clientsGetList() map[string]*Client {
return clients.list
}
func clientExists(ip string) bool {
clients.lock.Lock()
defer clients.lock.Unlock()
_, ok := clients.ipIndex[ip]
if ok {
return true
}
_, ok = clients.ipHost[ip]
return ok
}
// Search for a client by IP
func clientFind(ip string) (Client, bool) {
clients.lock.Lock()
defer clients.lock.Unlock()
c, ok := clients.ipIndex[ip]
if ok {
return *c, true
}
for _, c = range clients.list {
if len(c.MAC) != 0 {
mac, err := net.ParseMAC(c.MAC)
if err != nil {
continue
}
ipAddr := dhcpServer.FindIPbyMAC(mac)
if ipAddr == nil {
continue
}
if ip == ipAddr.String() {
return *c, true
}
}
}
return Client{}, false
}
// Check if Client object's fields are correct
func clientCheck(c *Client) error {
if len(c.Name) == 0 {
return fmt.Errorf("Invalid Name")
}
if (len(c.IP) == 0 && len(c.MAC) == 0) ||
(len(c.IP) != 0 && len(c.MAC) != 0) {
return fmt.Errorf("IP or MAC required")
}
if len(c.IP) != 0 {
ip := net.ParseIP(c.IP)
if ip == nil {
return fmt.Errorf("Invalid IP")
}
c.IP = ip.String()
} else {
_, err := net.ParseMAC(c.MAC)
if err != nil {
return fmt.Errorf("Invalid MAC: %s", err)
}
}
return nil
}
// Add a new client object
// Return true: success; false: client exists.
func clientAdd(c Client) (bool, error) {
e := clientCheck(&c)
if e != nil {
return false, e
}
clients.lock.Lock()
defer clients.lock.Unlock()
// check Name index
_, ok := clients.list[c.Name]
if ok {
return false, nil
}
// check IP index
if len(c.IP) != 0 {
c2, ok := clients.ipIndex[c.IP]
if ok {
return false, fmt.Errorf("Another client uses the same IP address: %s", c2.Name)
}
}
clients.list[c.Name] = &c
if len(c.IP) != 0 {
clients.ipIndex[c.IP] = &c
}
log.Tracef("'%s': '%s' | '%s' -> [%d]", c.Name, c.IP, c.MAC, len(clients.list))
return true, nil
}
// Remove a client
func clientDel(name string) bool {
clients.lock.Lock()
defer clients.lock.Unlock()
c, ok := clients.list[name]
if !ok {
return false
}
delete(clients.list, name)
delete(clients.ipIndex, c.IP)
return true
}
// Update a client
func clientUpdate(name string, c Client) error {
err := clientCheck(&c)
if err != nil {
return err
}
clients.lock.Lock()
defer clients.lock.Unlock()
old, ok := clients.list[name]
if !ok {
return fmt.Errorf("Client not found")
}
// check Name index
if old.Name != c.Name {
_, ok = clients.list[c.Name]
if ok {
return fmt.Errorf("Client already exists")
}
}
// check IP index
if old.IP != c.IP && len(c.IP) != 0 {
c2, ok := clients.ipIndex[c.IP]
if ok {
return fmt.Errorf("Another client uses the same IP address: %s", c2.Name)
}
}
// update Name index
if old.Name != c.Name {
delete(clients.list, old.Name)
}
clients.list[c.Name] = &c
// update IP index
if old.IP != c.IP {
delete(clients.ipIndex, old.IP)
}
if len(c.IP) != 0 {
clients.ipIndex[c.IP] = &c
}
return nil
}
func clientAddHost(ip, host string, source clientSource) (bool, error) {
clients.lock.Lock()
defer clients.lock.Unlock()
// check index
_, ok := clients.ipHost[ip]
if ok {
return false, nil
}
clients.ipHost[ip] = ClientHost{
Host: host,
Source: source,
}
log.Tracef("'%s': '%s' -> [%d]", host, ip, len(clients.ipHost))
return true, nil
}
// Parse system 'hosts' file and fill clients array // Parse system 'hosts' file and fill clients array
func fillClientInfo() { func clientsAddFromHostsFile() {
hostsFn := "/etc/hosts" hostsFn := "/etc/hosts"
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
hostsFn = os.ExpandEnv("$SystemRoot\\system32\\drivers\\etc\\hosts") hostsFn = os.ExpandEnv("$SystemRoot\\system32\\drivers\\etc\\hosts")
@ -40,6 +272,7 @@ func fillClientInfo() {
} }
lines := strings.Split(string(d), "\n") lines := strings.Split(string(d), "\n")
n := 0
for _, ln := range lines { for _, ln := range lines {
ln = strings.TrimSpace(ln) ln = strings.TrimSpace(ln)
if len(ln) == 0 || ln[0] == '#' { if len(ln) == 0 || ln[0] == '#' {
@ -51,33 +284,71 @@ func fillClientInfo() {
continue continue
} }
var c Client ok, e := clientAddHost(fields[0], fields[1], ClientSourceHostsFile)
c.IP = fields[0] if e != nil {
c.Name = fields[1] log.Tracef("%s", e)
clients = append(clients, c) }
log.Tracef("%s -> %s", c.IP, c.Name) if ok {
n++
}
} }
log.Info("Added %d client aliases from %s", len(clients), hostsFn) log.Info("Added %d client aliases from %s", n, hostsFn)
clientsFilled = true }
type clientHostJSON struct {
IP string `json:"ip"`
Name string `json:"name"`
Source string `json:"source"`
}
type clientListJSON struct {
Clients []clientJSON `json:"clients"`
AutoClients []clientHostJSON `json:"auto_clients"`
} }
// respond with information about configured clients // respond with information about configured clients
func handleGetClients(w http.ResponseWriter, r *http.Request) { func handleGetClients(w http.ResponseWriter, r *http.Request) {
log.Tracef("%s %v", r.Method, r.URL) log.Tracef("%s %v", r.Method, r.URL)
if !clientsFilled { data := clientListJSON{}
fillClientInfo()
}
data := []clientJSON{} clients.lock.Lock()
for _, c := range clients { for _, c := range clients.list {
cj := clientJSON{ cj := clientJSON{
IP: c.IP, IP: c.IP,
Name: c.Name, MAC: c.MAC,
Name: c.Name,
UseGlobalSettings: !c.UseOwnSettings,
FilteringEnabled: c.FilteringEnabled,
ParentalEnabled: c.ParentalEnabled,
SafeSearchEnabled: c.SafeSearchEnabled,
SafeBrowsingEnabled: c.SafeBrowsingEnabled,
} }
data = append(data, cj)
if len(c.MAC) != 0 {
hwAddr, _ := net.ParseMAC(c.MAC)
ipAddr := dhcpServer.FindIPbyMAC(hwAddr)
if ipAddr != nil {
cj.IP = ipAddr.String()
}
}
data.Clients = append(data.Clients, cj)
} }
for ip, ch := range clients.ipHost {
cj := clientHostJSON{
IP: ip,
Name: ch.Host,
}
cj.Source = "etc/hosts"
if ch.Source == ClientSourceRDNS {
cj.Source = "rDNS"
}
data.AutoClients = append(data.AutoClients, cj)
}
clients.lock.Unlock()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w).Encode(data) e := json.NewEncoder(w).Encode(data)
if e != nil { if e != nil {
@ -86,7 +357,126 @@ func handleGetClients(w http.ResponseWriter, r *http.Request) {
} }
} }
// Convert JSON object to Client object
func jsonToClient(cj clientJSON) (*Client, error) {
c := Client{
IP: cj.IP,
MAC: cj.MAC,
Name: cj.Name,
UseOwnSettings: !cj.UseGlobalSettings,
FilteringEnabled: cj.FilteringEnabled,
ParentalEnabled: cj.ParentalEnabled,
SafeSearchEnabled: cj.SafeSearchEnabled,
SafeBrowsingEnabled: cj.SafeBrowsingEnabled,
}
return &c, nil
}
// Add a new client
func handleAddClient(w http.ResponseWriter, r *http.Request) {
log.Tracef("%s %v", r.Method, r.URL)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
httpError(w, http.StatusBadRequest, "failed to read request body: %s", err)
return
}
cj := clientJSON{}
err = json.Unmarshal(body, &cj)
if err != nil {
httpError(w, http.StatusBadRequest, "JSON parse: %s", err)
return
}
c, err := jsonToClient(cj)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
ok, err := clientAdd(*c)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
if !ok {
httpError(w, http.StatusBadRequest, "Client already exists")
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
// Remove client
func handleDelClient(w http.ResponseWriter, r *http.Request) {
log.Tracef("%s %v", r.Method, r.URL)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
httpError(w, http.StatusBadRequest, "failed to read request body: %s", err)
return
}
cj := clientJSON{}
err = json.Unmarshal(body, &cj)
if err != nil || len(cj.Name) == 0 {
httpError(w, http.StatusBadRequest, "JSON parse: %s", err)
return
}
if !clientDel(cj.Name) {
httpError(w, http.StatusBadRequest, "Client not found")
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
type clientUpdateJSON struct {
Name string `json:"name"`
Data clientJSON `json:"data"`
}
// Update client's properties
func handleUpdateClient(w http.ResponseWriter, r *http.Request) {
log.Tracef("%s %v", r.Method, r.URL)
body, err := ioutil.ReadAll(r.Body)
if err != nil {
httpError(w, http.StatusBadRequest, "failed to read request body: %s", err)
return
}
var dj clientUpdateJSON
err = json.Unmarshal(body, &dj)
if err != nil {
httpError(w, http.StatusBadRequest, "JSON parse: %s", err)
return
}
if len(dj.Name) == 0 {
httpError(w, http.StatusBadRequest, "Invalid request")
return
}
c, err := jsonToClient(dj.Data)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
err = clientUpdate(dj.Name, *c)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
// RegisterClientsHandlers registers HTTP handlers // RegisterClientsHandlers registers HTTP handlers
func RegisterClientsHandlers() { func RegisterClientsHandlers() {
http.HandleFunc("/control/clients", postInstall(optionalAuth(ensureGET(handleGetClients)))) http.HandleFunc("/control/clients", postInstall(optionalAuth(ensureGET(handleGetClients))))
http.HandleFunc("/control/clients/add", postInstall(optionalAuth(ensurePOST(handleAddClient))))
http.HandleFunc("/control/clients/delete", postInstall(optionalAuth(ensurePOST(handleDelClient))))
http.HandleFunc("/control/clients/update", postInstall(optionalAuth(ensurePOST(handleUpdateClient))))
} }

122
clients_test.go Normal file
View File

@ -0,0 +1,122 @@
package main
import "testing"
func TestClients(t *testing.T) {
var c Client
var e error
var b bool
clientsInit()
// add
c = Client{
IP: "1.1.1.1",
Name: "client1",
}
b, e = clientAdd(c)
if !b || e != nil {
t.Fatalf("clientAdd #1")
}
// add #2
c = Client{
IP: "2.2.2.2",
Name: "client2",
}
b, e = clientAdd(c)
if !b || e != nil {
t.Fatalf("clientAdd #2")
}
// failed add - name in use
c = Client{
IP: "1.2.3.5",
Name: "client1",
}
b, e = clientAdd(c)
if b {
t.Fatalf("clientAdd - name in use")
}
// failed add - ip in use
c = Client{
IP: "2.2.2.2",
Name: "client3",
}
b, e = clientAdd(c)
if b || e == nil {
t.Fatalf("clientAdd - ip in use")
}
// get
if clientExists("1.2.3.4") {
t.Fatalf("clientExists")
}
if !clientExists("1.1.1.1") {
t.Fatalf("clientExists #1")
}
if !clientExists("2.2.2.2") {
t.Fatalf("clientExists #2")
}
// failed update - no such name
c.IP = "1.2.3.0"
c.Name = "client3"
if clientUpdate("client3", c) == nil {
t.Fatalf("clientUpdate")
}
// failed update - name in use
c.IP = "1.2.3.0"
c.Name = "client2"
if clientUpdate("client1", c) == nil {
t.Fatalf("clientUpdate - name in use")
}
// failed update - ip in use
c.IP = "2.2.2.2"
c.Name = "client1"
if clientUpdate("client1", c) == nil {
t.Fatalf("clientUpdate - ip in use")
}
// update
c.IP = "1.1.1.2"
c.Name = "client1"
if clientUpdate("client1", c) != nil {
t.Fatalf("clientUpdate")
}
// get after update
if clientExists("1.1.1.1") || !clientExists("1.1.1.2") {
t.Fatalf("clientExists - get after update")
}
// failed remove - no such name
if clientDel("client3") {
t.Fatalf("clientDel - no such name")
}
// remove
if !clientDel("client1") || clientExists("1.1.1.2") {
t.Fatalf("clientDel")
}
// add host client
b, e = clientAddHost("1.1.1.1", "host", ClientSourceHostsFile)
if !b || e != nil {
t.Fatalf("clientAddHost")
}
// failed add - ip exists
b, e = clientAddHost("1.1.1.1", "host", ClientSourceHostsFile)
if b {
t.Fatalf("clientAddHost - ip exists")
}
// get
if !clientExists("1.1.1.1") {
t.Fatalf("clientAddHost")
}
}

View File

@ -27,6 +27,17 @@ type logSettings struct {
Verbose bool `yaml:"verbose"` // If true, verbose logging is enabled Verbose bool `yaml:"verbose"` // If true, verbose logging is enabled
} }
type clientObject struct {
Name string `yaml:"name"`
IP string `yaml:"ip"`
MAC string `yaml:"mac"`
UseGlobalSettings bool `yaml:"use_global_settings"`
FilteringEnabled bool `yaml:"filtering_enabled"`
ParentalEnabled bool `yaml:"parental_enabled"`
SafeSearchEnabled bool `yaml:"safebrowsing_enabled"`
SafeBrowsingEnabled bool `yaml:"safesearch_enabled"`
}
// configuration is loaded from YAML // configuration is loaded from YAML
// field ordering is important -- yaml fields will mirror ordering from here // field ordering is important -- yaml fields will mirror ordering from here
type configuration struct { type configuration struct {
@ -54,6 +65,9 @@ type configuration struct {
UserRules []string `yaml:"user_rules"` UserRules []string `yaml:"user_rules"`
DHCP dhcpd.ServerConfig `yaml:"dhcp"` DHCP dhcpd.ServerConfig `yaml:"dhcp"`
// Note: this array is filled only before file read/write and then it's cleared
Clients []clientObject `yaml:"clients"`
logSettings `yaml:",inline"` logSettings `yaml:",inline"`
sync.RWMutex `yaml:"-"` sync.RWMutex `yaml:"-"`
@ -206,6 +220,25 @@ func parseConfig() error {
return err return err
} }
clientsInit()
for _, cy := range config.Clients {
cli := Client{
Name: cy.Name,
IP: cy.IP,
MAC: cy.MAC,
UseOwnSettings: !cy.UseGlobalSettings,
FilteringEnabled: cy.FilteringEnabled,
ParentalEnabled: cy.ParentalEnabled,
SafeSearchEnabled: cy.SafeSearchEnabled,
SafeBrowsingEnabled: cy.SafeBrowsingEnabled,
}
_, err = clientAdd(cli)
if err != nil {
log.Tracef("clientAdd: %s", err)
}
}
config.Clients = nil
// Deduplicate filters // Deduplicate filters
deduplicateFilters() deduplicateFilters()
@ -233,9 +266,30 @@ func readConfigFile() ([]byte, error) {
func (c *configuration) write() error { func (c *configuration) write() error {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
clientsList := clientsGetList()
for _, cli := range clientsList {
ip := cli.IP
if len(cli.MAC) != 0 {
ip = ""
}
cy := clientObject{
Name: cli.Name,
IP: ip,
MAC: cli.MAC,
UseGlobalSettings: !cli.UseOwnSettings,
FilteringEnabled: cli.FilteringEnabled,
ParentalEnabled: cli.ParentalEnabled,
SafeSearchEnabled: cli.SafeSearchEnabled,
SafeBrowsingEnabled: cli.SafeBrowsingEnabled,
}
config.Clients = append(config.Clients, cy)
}
configFile := config.getConfigFilename() configFile := config.getConfigFilename()
log.Debug("Writing YAML file: %s", configFile) log.Debug("Writing YAML file: %s", configFile)
yamlText, err := yaml.Marshal(&config) yamlText, err := yaml.Marshal(&config)
config.Clients = nil
if err != nil { if err != nil {
log.Error("Couldn't generate YAML file: %s", err) log.Error("Couldn't generate YAML file: %s", err)
return err return err

View File

@ -540,6 +540,19 @@ func (s *Server) printLeases() {
} }
} }
// FindIPbyMAC finds an IP address by MAC address in the currently active DHCP leases
func (s *Server) FindIPbyMAC(mac net.HardwareAddr) net.IP {
now := time.Now().Unix()
s.RLock()
defer s.RUnlock()
for _, l := range s.leases {
if l.Expiry.Unix() > now && bytes.Equal(mac, l.HWAddr) {
return l.IP
}
}
return nil
}
// Reset internal state // Reset internal state
func (s *Server) reset() { func (s *Server) reset() {
s.Lock() s.Lock()

23
dns.go
View File

@ -70,9 +70,32 @@ func generateServerConfig() dnsforward.ServerConfig {
newconfig.Upstreams = upstreamConfig.Upstreams newconfig.Upstreams = upstreamConfig.Upstreams
newconfig.DomainsReservedUpstreams = upstreamConfig.DomainReservedUpstreams newconfig.DomainsReservedUpstreams = upstreamConfig.DomainReservedUpstreams
newconfig.AllServers = config.DNS.AllServers newconfig.AllServers = config.DNS.AllServers
newconfig.FilterHandler = applyClientSettings
return newconfig return newconfig
} }
// If a client has his own settings, apply them
func applyClientSettings(clientAddr string, setts *dnsfilter.RequestFilteringSettings) {
c, ok := clientFind(clientAddr)
if !ok || !c.UseOwnSettings {
return
}
log.Debug("Using settings for client with IP %s", clientAddr)
if !c.FilteringEnabled {
setts.FilteringEnabled = false
}
if !c.SafeSearchEnabled {
setts.SafeSearchEnabled = false
}
if !c.SafeBrowsingEnabled {
setts.SafeBrowsingEnabled = false
}
if !c.ParentalEnabled {
setts.ParentalEnabled = false
}
}
func startDNSServer() error { func startDNSServer() error {
if isRunning() { if isRunning() {
return fmt.Errorf("unable to start forwarding DNS server: Already running") return fmt.Errorf("unable to start forwarding DNS server: Already running")

View File

@ -46,6 +46,14 @@ const shortcutLength = 6 // used for rule search optimization, 6 hits the sweet
const enableFastLookup = true // flag for debugging, must be true in production for faster performance const enableFastLookup = true // flag for debugging, must be true in production for faster performance
const enableDelayedCompilation = true // flag for debugging, must be true in production for faster performance const enableDelayedCompilation = true // flag for debugging, must be true in production for faster performance
// Custom filtering settings
type RequestFilteringSettings struct {
FilteringEnabled bool
SafeSearchEnabled bool
SafeBrowsingEnabled bool
ParentalEnabled bool
}
// Config allows you to configure DNS filtering with New() or just change variables directly. // Config allows you to configure DNS filtering with New() or just change variables directly.
type Config struct { type Config struct {
FilteringTempFilename string `yaml:"filtering_temp_filename"` // temporary file for storing unused filtering rules FilteringTempFilename string `yaml:"filtering_temp_filename"` // temporary file for storing unused filtering rules
@ -55,6 +63,9 @@ type Config struct {
SafeSearchEnabled bool `yaml:"safesearch_enabled"` SafeSearchEnabled bool `yaml:"safesearch_enabled"`
SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"` SafeBrowsingEnabled bool `yaml:"safebrowsing_enabled"`
ResolverAddress string // DNS server address ResolverAddress string // DNS server address
// Filtering callback function
FilterHandler func(clientAddr string, settings *RequestFilteringSettings) `yaml:"-"`
} }
type privateConfig struct { type privateConfig struct {
@ -149,7 +160,7 @@ func (r Reason) Matched() bool {
} }
// CheckHost tries to match host against rules, then safebrowsing and parental if they are enabled // CheckHost tries to match host against rules, then safebrowsing and parental if they are enabled
func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) { func (d *Dnsfilter) CheckHost(host string, qtype uint16, clientAddr string) (Result, error) {
// sometimes DNS clients will try to resolve ".", which is a request to get root servers // sometimes DNS clients will try to resolve ".", which is a request to get root servers
if host == "" { if host == "" {
return Result{Reason: NotFilteredNotFound}, nil return Result{Reason: NotFilteredNotFound}, nil
@ -160,17 +171,30 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) {
return Result{}, nil return Result{}, nil
} }
// try filter lists first var setts RequestFilteringSettings
result, err := d.matchHost(host, qtype) setts.FilteringEnabled = true
if err != nil { setts.SafeSearchEnabled = d.SafeSearchEnabled
return result, err setts.SafeBrowsingEnabled = d.SafeBrowsingEnabled
setts.ParentalEnabled = d.ParentalEnabled
if len(clientAddr) != 0 && d.FilterHandler != nil {
d.FilterHandler(clientAddr, &setts)
} }
if result.Reason.Matched() {
return result, nil var result Result
var err error
// try filter lists first
if setts.FilteringEnabled {
result, err = d.matchHost(host, qtype)
if err != nil {
return result, err
}
if result.Reason.Matched() {
return result, nil
}
} }
// check safeSearch if no match // check safeSearch if no match
if d.SafeSearchEnabled { if setts.SafeSearchEnabled {
result, err = d.checkSafeSearch(host) result, err = d.checkSafeSearch(host)
if err != nil { if err != nil {
log.Printf("Failed to safesearch HTTP lookup, ignoring check: %v", err) log.Printf("Failed to safesearch HTTP lookup, ignoring check: %v", err)
@ -183,7 +207,7 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) {
} }
// check safebrowsing if no match // check safebrowsing if no match
if d.SafeBrowsingEnabled { if setts.SafeBrowsingEnabled {
result, err = d.checkSafeBrowsing(host) result, err = d.checkSafeBrowsing(host)
if err != nil { if err != nil {
// failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache // failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache
@ -196,7 +220,7 @@ func (d *Dnsfilter) CheckHost(host string, qtype uint16) (Result, error) {
} }
// check parental if no match // check parental if no match
if d.ParentalEnabled { if setts.ParentalEnabled {
result, err = d.checkParental(host) result, err = d.checkParental(host)
if err != nil { if err != nil {
// failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache // failed to do HTTP lookup -- treat it as if we got empty response, but don't save cache

View File

@ -18,6 +18,7 @@ import (
// SAFE SEARCH // SAFE SEARCH
// PARENTAL // PARENTAL
// FILTERING // FILTERING
// CLIENTS SETTINGS
// BENCHMARKS // BENCHMARKS
// HELPERS // HELPERS
@ -52,7 +53,7 @@ func NewForTestFilters(filters map[int]string) *Dnsfilter {
func (d *Dnsfilter) checkMatch(t *testing.T, hostname string) { func (d *Dnsfilter) checkMatch(t *testing.T, hostname string) {
t.Helper() t.Helper()
ret, err := d.CheckHost(hostname, dns.TypeA) ret, err := d.CheckHost(hostname, dns.TypeA, "")
if err != nil { if err != nil {
t.Errorf("Error while matching host %s: %s", hostname, err) t.Errorf("Error while matching host %s: %s", hostname, err)
} }
@ -63,7 +64,7 @@ func (d *Dnsfilter) checkMatch(t *testing.T, hostname string) {
func (d *Dnsfilter) checkMatchIP(t *testing.T, hostname string, ip string, qtype uint16) { func (d *Dnsfilter) checkMatchIP(t *testing.T, hostname string, ip string, qtype uint16) {
t.Helper() t.Helper()
ret, err := d.CheckHost(hostname, qtype) ret, err := d.CheckHost(hostname, qtype, "")
if err != nil { if err != nil {
t.Errorf("Error while matching host %s: %s", hostname, err) t.Errorf("Error while matching host %s: %s", hostname, err)
} }
@ -77,7 +78,7 @@ func (d *Dnsfilter) checkMatchIP(t *testing.T, hostname string, ip string, qtype
func (d *Dnsfilter) checkMatchEmpty(t *testing.T, hostname string) { func (d *Dnsfilter) checkMatchEmpty(t *testing.T, hostname string) {
t.Helper() t.Helper()
ret, err := d.CheckHost(hostname, dns.TypeA) ret, err := d.CheckHost(hostname, dns.TypeA, "")
if err != nil { if err != nil {
t.Errorf("Error while matching host %s: %s", hostname, err) t.Errorf("Error while matching host %s: %s", hostname, err)
} }
@ -212,7 +213,7 @@ func TestCheckHostSafeSearchYandex(t *testing.T) {
// Check host for each domain // Check host for each domain
for _, host := range yandex { for _, host := range yandex {
result, err := d.CheckHost(host, dns.TypeA) result, err := d.CheckHost(host, dns.TypeA, "")
if err != nil { if err != nil {
t.Errorf("SafeSearch doesn't work for yandex domain `%s` cause %s", host, err) t.Errorf("SafeSearch doesn't work for yandex domain `%s` cause %s", host, err)
} }
@ -235,7 +236,7 @@ func TestCheckHostSafeSearchGoogle(t *testing.T) {
// Check host for each domain // Check host for each domain
for _, host := range googleDomains { for _, host := range googleDomains {
result, err := d.CheckHost(host, dns.TypeA) result, err := d.CheckHost(host, dns.TypeA, "")
if err != nil { if err != nil {
t.Errorf("SafeSearch doesn't work for %s cause %s", host, err) t.Errorf("SafeSearch doesn't work for %s cause %s", host, err)
} }
@ -255,7 +256,7 @@ func TestSafeSearchCacheYandex(t *testing.T) {
var err error var err error
// Check host with disabled safesearch // Check host with disabled safesearch
result, err = d.CheckHost(domain, dns.TypeA) result, err = d.CheckHost(domain, dns.TypeA, "")
if err != nil { if err != nil {
t.Fatalf("Cannot check host due to %s", err) t.Fatalf("Cannot check host due to %s", err)
} }
@ -265,7 +266,7 @@ func TestSafeSearchCacheYandex(t *testing.T) {
// Enable safesearch // Enable safesearch
d.SafeSearchEnabled = true d.SafeSearchEnabled = true
result, err = d.CheckHost(domain, dns.TypeA) result, err = d.CheckHost(domain, dns.TypeA, "")
if err != nil { if err != nil {
t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err) t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err)
} }
@ -295,7 +296,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
d := NewForTest() d := NewForTest()
defer d.Destroy() defer d.Destroy()
domain := "www.google.ru" domain := "www.google.ru"
result, err := d.CheckHost(domain, dns.TypeA) result, err := d.CheckHost(domain, dns.TypeA, "")
if err != nil { if err != nil {
t.Fatalf("Cannot check host due to %s", err) t.Fatalf("Cannot check host due to %s", err)
} }
@ -324,7 +325,7 @@ func TestSafeSearchCacheGoogle(t *testing.T) {
} }
} }
result, err = d.CheckHost(domain, dns.TypeA) result, err = d.CheckHost(domain, dns.TypeA, "")
if err != nil { if err != nil {
t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err) t.Fatalf("CheckHost for safesearh domain %s failed cause %s", domain, err)
} }
@ -441,7 +442,7 @@ func TestMatching(t *testing.T) {
d := NewForTestFilters(filters) d := NewForTestFilters(filters)
defer d.Destroy() defer d.Destroy()
ret, err := d.CheckHost(test.hostname, dns.TypeA) ret, err := d.CheckHost(test.hostname, dns.TypeA, "")
if err != nil { if err != nil {
t.Errorf("Error while matching host %s: %s", test.hostname, err) t.Errorf("Error while matching host %s: %s", test.hostname, err)
} }
@ -455,6 +456,52 @@ func TestMatching(t *testing.T) {
} }
} }
// CLIENT SETTINGS
func applyClientSettings(clientAddr string, setts *RequestFilteringSettings) {
setts.FilteringEnabled = false
setts.ParentalEnabled = false
}
func TestClientSettings(t *testing.T) {
var r Result
filters := make(map[int]string)
filters[0] = "||example.org^\n"
d := NewForTestFilters(filters)
defer d.Destroy()
d.ParentalEnabled = true
d.ParentalSensitivity = 3
// no client settings:
// blocked by filters
r, _ = d.CheckHost("example.org", dns.TypeA, "1.1.1.1")
if !r.IsFiltered || r.Reason != FilteredBlackList {
t.Fatalf("CheckHost FilteredBlackList")
}
// blocked by parental
r, _ = d.CheckHost("pornhub.com", dns.TypeA, "1.1.1.1")
if !r.IsFiltered || r.Reason != FilteredParental {
t.Fatalf("CheckHost FilteredParental")
}
// override client settings:
d.FilterHandler = applyClientSettings
// override filtering settings
r, _ = d.CheckHost("example.org", dns.TypeA, "1.1.1.1")
if r.IsFiltered {
t.Fatalf("CheckHost")
}
// override parental settings
r, _ = d.CheckHost("pornhub.com", dns.TypeA, "1.1.1.1")
if r.IsFiltered {
t.Fatalf("CheckHost")
}
}
// BENCHMARKS // BENCHMARKS
func BenchmarkSafeBrowsing(b *testing.B) { func BenchmarkSafeBrowsing(b *testing.B) {
@ -463,7 +510,7 @@ func BenchmarkSafeBrowsing(b *testing.B) {
d.SafeBrowsingEnabled = true d.SafeBrowsingEnabled = true
for n := 0; n < b.N; n++ { for n := 0; n < b.N; n++ {
hostname := "wmconvirus.narod.ru" hostname := "wmconvirus.narod.ru"
ret, err := d.CheckHost(hostname, dns.TypeA) ret, err := d.CheckHost(hostname, dns.TypeA, "")
if err != nil { if err != nil {
b.Errorf("Error while matching host %s: %s", hostname, err) b.Errorf("Error while matching host %s: %s", hostname, err)
} }
@ -480,7 +527,7 @@ func BenchmarkSafeBrowsingParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) { b.RunParallel(func(pb *testing.PB) {
for pb.Next() { for pb.Next() {
hostname := "wmconvirus.narod.ru" hostname := "wmconvirus.narod.ru"
ret, err := d.CheckHost(hostname, dns.TypeA) ret, err := d.CheckHost(hostname, dns.TypeA, "")
if err != nil { if err != nil {
b.Errorf("Error while matching host %s: %s", hostname, err) b.Errorf("Error while matching host %s: %s", hostname, err)
} }

View File

@ -378,7 +378,11 @@ func (s *Server) filterDNSRequest(d *proxy.DNSContext) (*dnsfilter.Result, error
var res dnsfilter.Result var res dnsfilter.Result
var err error var err error
res, err = dnsFilter.CheckHost(host, d.Req.Question[0].Qtype) clientAddr := ""
if d.Addr != nil {
clientAddr, _, _ = net.SplitHostPort(d.Addr.String())
}
res, err = dnsFilter.CheckHost(host, d.Req.Question[0].Qtype, clientAddr)
if err != nil { if err != nil {
// Return immediately if there's an error // Return immediately if there's an error
return nil, errorx.Decorate(err, "dnsfilter failed to check host '%s'", host) return nil, errorx.Decorate(err, "dnsfilter failed to check host '%s'", host)

View File

@ -2,7 +2,7 @@ swagger: '2.0'
info: info:
title: 'AdGuard Home' title: 'AdGuard Home'
description: 'AdGuard Home REST API. Admin web interface is built on top of this REST API.' description: 'AdGuard Home REST API. Admin web interface is built on top of this REST API.'
version: 0.95.0 version: 0.96.0
schemes: schemes:
- http - http
basePath: /control basePath: /control
@ -424,7 +424,7 @@ paths:
description: OK description: OK
/filtering/add_url: /filtering/add_url:
put: post:
tags: tags:
- filtering - filtering
operationId: filteringAddURL operationId: filteringAddURL
@ -444,7 +444,7 @@ paths:
description: OK description: OK
/filtering/remove_url: /filtering/remove_url:
delete: post:
tags: tags:
- filtering - filtering
operationId: filteringRemoveURL operationId: filteringRemoveURL
@ -530,7 +530,7 @@ paths:
description: OK with how many filters were actually updated description: OK with how many filters were actually updated
/filtering/set_rules: /filtering/set_rules:
put: post:
tags: tags:
- filtering - filtering
operationId: filteringSetRules operationId: filteringSetRules
@ -698,6 +698,54 @@ paths:
schema: schema:
$ref: "#/definitions/Clients" $ref: "#/definitions/Clients"
/clients/add:
post:
tags:
- clients
operationId: clientsAdd
summary: 'Add a new client'
parameters:
- in: "body"
name: "body"
required: true
schema:
$ref: "#/definitions/Client"
responses:
200:
description: OK
/clients/delete:
post:
tags:
- clients
operationId: clientsDelete
summary: 'Remove a client'
parameters:
- in: "body"
name: "body"
required: true
schema:
$ref: "#/definitions/ClientDelete"
responses:
200:
description: OK
/clients/update:
post:
tags:
- clients
operationId: clientsUpdate
summary: 'Update client information'
parameters:
- in: "body"
name: "body"
required: true
schema:
$ref: "#/definitions/ClientUpdate"
responses:
200:
description: OK
# -------------------------------------------------- # --------------------------------------------------
# I18N methods # I18N methods
# -------------------------------------------------- # --------------------------------------------------
@ -1377,11 +1425,65 @@ definitions:
type: "string" type: "string"
description: "Name" description: "Name"
example: "localhost" example: "localhost"
mac:
type: "string"
use_global_settings:
type: "boolean"
filtering_enabled:
type: "boolean"
parental_enabled:
type: "boolean"
safebrowsing_enabled:
type: "boolean"
safesearch_enabled:
type: "boolean"
ClientAuto:
type: "object"
description: "Auto-Client information"
properties:
ip:
type: "string"
description: "IP address"
example: "127.0.0.1"
name:
type: "string"
description: "Name"
example: "localhost"
source:
type: "string"
description: "The source of this information"
example: "etc/hosts"
ClientUpdate:
type: "object"
description: "Client update request"
properties:
name:
type: "string"
data:
$ref: "#/definitions/Client"
ClientDelete:
type: "object"
description: "Client delete request"
properties:
name:
type: "string"
Clients: Clients:
type: "object"
properties:
clients:
$ref: "#/definitions/ClientsArray"
auto_clients:
$ref: "#/definitions/ClientsAutoArray"
ClientsArray:
type: "array" type: "array"
items: items:
$ref: "#/definitions/Client" $ref: "#/definitions/Client"
description: "Clients array" description: "Clients array"
ClientsAutoArray:
type: "array"
items:
$ref: "#/definitions/ClientAuto"
description: "Auto-Clients array"
CheckConfigRequest: CheckConfigRequest:
type: "object" type: "object"
description: "Configuration to be checked" description: "Configuration to be checked"