Merge: + Clients: multiple IP, CIDR, MAC

Close #809

* commit 'f8202a74bd55b97cdd95c33d8f8ed97412ba43b1':
  + client: handle clients find
  + client: add multiple fields client form
  * minor
  * openapi: update 'Client' object; add /clients/find
  * config: upgrade scheme to v6
  * clients: multiple IP, CIDR, MAC addresses
This commit is contained in:
Simon Zolin 2019-11-29 17:05:00 +03:00
commit 127a68a39f
25 changed files with 1029 additions and 533 deletions

View File

@ -21,6 +21,7 @@ Contents:
* Add client * Add client
* Update client * Update client
* Delete client * Delete client
* API: Find clients by IP
* Enable DHCP server * Enable DHCP server
* "Show DHCP status" command * "Show DHCP status" command
* "Check DHCP" command * "Check DHCP" command
@ -618,8 +619,6 @@ Notes:
* `name`, `ip` and `mac` values are unique. * `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 `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 true, then DNS responses for this client are processed and filtered using global settings.
@ -643,8 +642,7 @@ Response:
clients: [ clients: [
{ {
name: "client1" name: "client1"
ip: "..." ids: ["...", ...] // IP, CIDR or MAC
mac: "..."
use_global_settings: true use_global_settings: true
filtering_enabled: false filtering_enabled: false
parental_enabled: false parental_enabled: false
@ -682,8 +680,7 @@ Request:
{ {
name: "client1" name: "client1"
ip: "..." ids: ["...", ...] // IP, CIDR or MAC
mac: "..."
use_global_settings: true use_global_settings: true
filtering_enabled: false filtering_enabled: false
parental_enabled: false parental_enabled: false
@ -712,8 +709,7 @@ Request:
name: "client1" name: "client1"
data: { data: {
name: "client1" name: "client1"
ip: "..." ids: ["...", ...] // IP, CIDR or MAC
mac: "..."
use_global_settings: true use_global_settings: true
filtering_enabled: false filtering_enabled: false
parental_enabled: false parental_enabled: false
@ -752,6 +748,41 @@ Error response (Client not found):
400 400
### API: Find clients by IP
This method returns the list of clients (manual and auto-clients) matching the IP list.
For auto-clients only `name`, `ids` and `whois_info` fields are set. Other fields are empty.
Request:
GET /control/clients/find?ip0=...&ip1=...&ip2=...
Response:
200 OK
[
{
"1.2.3.4": {
name: "client1"
ids: ["...", ...] // IP, CIDR or MAC
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
use_global_blocked_services: true
blocked_services: [ "name1", ... ]
whois_info: {
key: "value"
...
}
}
}
...
]
## DNS access settings ## DNS access settings
There are low-level settings that can block undesired DNS requests. "Blocking" means not responding to request. There are low-level settings that can block undesired DNS requests. "Blocking" means not responding to request.

View File

@ -299,9 +299,11 @@
"client_edit": "Edit Client", "client_edit": "Edit Client",
"client_identifier": "Identifier", "client_identifier": "Identifier",
"ip_address": "IP address", "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>", "client_identifier_desc": "Clients can be identified by the IP address, CIDR, 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_ip": "Enter IP",
"form_enter_mac": "Enter MAC", "form_enter_mac": "Enter MAC",
"form_enter_id": "Enter identifier",
"form_add_id": "Add identifier",
"form_client_name": "Enter client name", "form_client_name": "Enter client name",
"client_global_settings": "Use global settings", "client_global_settings": "Use global settings",
"client_deleted": "Client \"{{key}}\" successfully deleted", "client_deleted": "Client \"{{key}}\" successfully deleted",

View File

@ -2,7 +2,6 @@ import { createAction } from 'redux-actions';
import { t } from 'i18next'; import { t } from 'i18next';
import apiClient from '../api/Api'; import apiClient from '../api/Api';
import { addErrorToast, addSuccessToast, getClients } from './index'; import { addErrorToast, addSuccessToast, getClients } from './index';
import { CLIENT_ID } from '../helpers/constants';
export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL'); export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL');
@ -13,18 +12,7 @@ export const addClientSuccess = createAction('ADD_CLIENT_SUCCESS');
export const addClient = config => async (dispatch) => { export const addClient = config => async (dispatch) => {
dispatch(addClientRequest()); dispatch(addClientRequest());
try { try {
let data; await apiClient.addClient(config);
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(addClientSuccess());
dispatch(toggleClientModal()); dispatch(toggleClientModal());
dispatch(addSuccessToast(t('client_added', { key: config.name }))); dispatch(addSuccessToast(t('client_added', { key: config.name })));
@ -59,16 +47,7 @@ export const updateClientSuccess = createAction('UPDATE_CLIENT_SUCCESS');
export const updateClient = (config, name) => async (dispatch) => { export const updateClient = (config, name) => async (dispatch) => {
dispatch(updateClientRequest()); dispatch(updateClientRequest());
try { try {
let data; const data = { name, data: { ...config } };
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); await apiClient.updateClient(data);
dispatch(updateClientSuccess()); dispatch(updateClientSuccess());

View File

@ -2,7 +2,7 @@ import { createAction } from 'redux-actions';
import apiClient from '../api/Api'; import apiClient from '../api/Api';
import { addErrorToast, addSuccessToast } from './index'; import { addErrorToast, addSuccessToast } from './index';
import { normalizeLogs } from '../helpers/helpers'; import { normalizeLogs, getParamsForClientsSearch, addClientInfo } from '../helpers/helpers';
import { TABLE_DEFAULT_PAGE_SIZE } from '../helpers/constants'; import { TABLE_DEFAULT_PAGE_SIZE } from '../helpers/constants';
const getLogsWithParams = async (config) => { const getLogsWithParams = async (config) => {
@ -10,9 +10,12 @@ const getLogsWithParams = async (config) => {
const rawLogs = await apiClient.getQueryLog({ ...filter, older_than }); const rawLogs = await apiClient.getQueryLog({ ...filter, older_than });
const { data, oldest } = rawLogs; const { data, oldest } = rawLogs;
const logs = normalizeLogs(data); const logs = normalizeLogs(data);
const clientsParams = getParamsForClientsSearch(logs, 'client');
const clients = await apiClient.findClients(clientsParams);
const logsWithClientInfo = addClientInfo(logs, clients, 'client');
return { return {
logs, oldest, older_than, filter, ...values, logs: logsWithClientInfo, oldest, older_than, filter, ...values,
}; };
}; };

View File

@ -2,7 +2,7 @@ import { createAction } from 'redux-actions';
import apiClient from '../api/Api'; import apiClient from '../api/Api';
import { addErrorToast, addSuccessToast } from './index'; import { addErrorToast, addSuccessToast } from './index';
import { normalizeTopStats, secondsToMilliseconds } from '../helpers/helpers'; import { normalizeTopStats, secondsToMilliseconds, getParamsForClientsSearch, addClientInfo } from '../helpers/helpers';
export const getStatsConfigRequest = createAction('GET_STATS_CONFIG_REQUEST'); export const getStatsConfigRequest = createAction('GET_STATS_CONFIG_REQUEST');
export const getStatsConfigFailure = createAction('GET_STATS_CONFIG_FAILURE'); export const getStatsConfigFailure = createAction('GET_STATS_CONFIG_FAILURE');
@ -43,11 +43,15 @@ export const getStats = () => async (dispatch) => {
dispatch(getStatsRequest()); dispatch(getStatsRequest());
try { try {
const stats = await apiClient.getStats(); const stats = await apiClient.getStats();
const normalizedTopClients = normalizeTopStats(stats.top_clients);
const clientsParams = getParamsForClientsSearch(normalizedTopClients, 'name');
const clients = await apiClient.findClients(clientsParams);
const topClientsWithInfo = addClientInfo(normalizedTopClients, clients, 'name');
const normalizedStats = { const normalizedStats = {
...stats, ...stats,
top_blocked_domains: normalizeTopStats(stats.top_blocked_domains), top_blocked_domains: normalizeTopStats(stats.top_blocked_domains),
top_clients: normalizeTopStats(stats.top_clients), top_clients: topClientsWithInfo,
top_queried_domains: normalizeTopStats(stats.top_queried_domains), top_queried_domains: normalizeTopStats(stats.top_queried_domains),
avg_processing_time: secondsToMilliseconds(stats.avg_processing_time), avg_processing_time: secondsToMilliseconds(stats.avg_processing_time),
}; };

View File

@ -353,6 +353,7 @@ class Api {
// Per-client settings // Per-client settings
GET_CLIENTS = { path: 'clients', method: 'GET' }; GET_CLIENTS = { path: 'clients', method: 'GET' };
FIND_CLIENTS = { path: 'clients/find', method: 'GET' };
ADD_CLIENT = { path: 'clients/add', method: 'POST' }; ADD_CLIENT = { path: 'clients/add', method: 'POST' };
DELETE_CLIENT = { path: 'clients/delete', method: 'POST' }; DELETE_CLIENT = { path: 'clients/delete', method: 'POST' };
UPDATE_CLIENT = { path: 'clients/update', method: 'POST' }; UPDATE_CLIENT = { path: 'clients/update', method: 'POST' };
@ -389,6 +390,12 @@ class Api {
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
findClients(params) {
const { path, method } = this.FIND_CLIENTS;
const url = getPathWithQueryString(path, params);
return this.makeRequest(url, method);
}
// DNS access settings // DNS access settings
ACCESS_LIST = { path: 'access/list', method: 'GET' }; ACCESS_LIST = { path: 'access/list', method: 'GET' };
ACCESS_SET = { path: 'access/set', method: 'POST' }; ACCESS_SET = { path: 'access/set', method: 'POST' };

View File

@ -28,19 +28,17 @@ const countCell = dnsQueries =>
return <Cell value={value} percent={percent} color={percentColor} />; return <Cell value={value} percent={percent} color={percentColor} />;
}; };
const clientCell = (clients, autoClients, t) => const clientCell = t =>
function cell(row) { function cell(row) {
const { value } = row;
return ( return (
<div className="logs__row logs__row--overflow logs__row--column"> <div className="logs__row logs__row--overflow logs__row--column">
{formatClientCell(value, clients, autoClients, t)} {formatClientCell(row, t)}
</div> </div>
); );
}; };
const Clients = ({ const Clients = ({
t, refreshButton, topClients, subtitle, clients, autoClients, dnsQueries, t, refreshButton, topClients, subtitle, dnsQueries,
}) => ( }) => (
<Card <Card
title={t('top_clients')} title={t('top_clients')}
@ -49,9 +47,10 @@ const Clients = ({
refresh={refreshButton} refresh={refreshButton}
> >
<ReactTable <ReactTable
data={topClients.map(({ name: ip, count }) => ({ data={topClients.map(({ name: ip, count, info }) => ({
ip, ip,
count, count,
info,
}))} }))}
columns={[ columns={[
{ {
@ -59,7 +58,7 @@ const Clients = ({
accessor: 'ip', accessor: 'ip',
sortMethod: (a, b) => sortMethod: (a, b) =>
parseInt(a.replace(/\./g, ''), 10) - parseInt(b.replace(/\./g, ''), 10), parseInt(a.replace(/\./g, ''), 10) - parseInt(b.replace(/\./g, ''), 10),
Cell: clientCell(clients, autoClients, t), Cell: clientCell(t),
}, },
{ {
Header: <Trans>requests_count</Trans>, Header: <Trans>requests_count</Trans>,

View File

@ -20,7 +20,6 @@ class Dashboard extends Component {
getAllStats = () => { getAllStats = () => {
this.props.getStats(); this.props.getStats();
this.props.getStatsConfig(); this.props.getStatsConfig();
this.props.getClients();
}; };
getToggleFilteringButton = () => { getToggleFilteringButton = () => {
@ -44,7 +43,6 @@ class Dashboard extends Component {
const { dashboard, stats, t } = this.props; const { dashboard, stats, t } = this.props;
const dashboardProcessing = const dashboardProcessing =
dashboard.processing || dashboard.processing ||
dashboard.processingClients ||
stats.processingStats || stats.processingStats ||
stats.processingGetConfig; stats.processingGetConfig;

View File

@ -31,7 +31,6 @@ class Logs extends Component {
this.props.setLogsPage(TABLE_FIRST_PAGE); this.props.setLogsPage(TABLE_FIRST_PAGE);
this.getLogs(...INITIAL_REQUEST_DATA); this.getLogs(...INITIAL_REQUEST_DATA);
this.props.getFilteringStatus(); this.props.getFilteringStatus();
this.props.getClients();
this.props.getLogsConfig(); this.props.getLogsConfig();
} }
@ -191,9 +190,9 @@ class Logs extends Component {
); );
}; };
getClientCell = ({ original, value }) => { getClientCell = (row) => {
const { dashboard, t } = this.props; const { original } = row;
const { clients, autoClients } = dashboard; const { t } = this.props;
const { reason, domain } = original; const { reason, domain } = original;
const isFiltered = this.checkFiltered(reason); const isFiltered = this.checkFiltered(reason);
const isRewrite = this.checkRewrite(reason); const isRewrite = this.checkRewrite(reason);
@ -201,7 +200,7 @@ class Logs extends Component {
return ( return (
<Fragment> <Fragment>
<div className="logs__row logs__row--overflow logs__row--column"> <div className="logs__row logs__row--overflow logs__row--column">
{formatClientCell(value, clients, autoClients, t)} {formatClientCell(row, t)}
</div> </div>
{isRewrite ? ( {isRewrite ? (
<div className="logs__action"> <div className="logs__action">
@ -232,12 +231,11 @@ class Logs extends Component {
}; };
renderLogs() { renderLogs() {
const { queryLogs, dashboard, t } = this.props; const { queryLogs, t } = this.props;
const { processingClients } = dashboard;
const { const {
processingGetLogs, processingGetConfig, logs, pages, page, processingGetLogs, processingGetConfig, logs, pages, page,
} = queryLogs; } = queryLogs;
const isLoading = processingGetLogs || processingClients || processingGetConfig; const isLoading = processingGetLogs || processingGetConfig;
const columns = [ const columns = [
{ {

View File

@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { Trans, withNamespaces } from 'react-i18next'; import { Trans, withNamespaces } from 'react-i18next';
import ReactTable from 'react-table'; import ReactTable from 'react-table';
import { MODAL_TYPE, CLIENT_ID } from '../../../helpers/constants'; import { MODAL_TYPE } from '../../../helpers/constants';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import Modal from './Modal'; import Modal from './Modal';
import WrapCell from './WrapCell'; import WrapCell from './WrapCell';
@ -40,10 +40,7 @@ class ClientsTable extends Component {
const client = clients.find(item => name === item.name); const client = clients.find(item => name === item.name);
if (client) { if (client) {
const identifier = client.mac ? CLIENT_ID.MAC : CLIENT_ID.IP;
return { return {
identifier,
use_global_settings: true, use_global_settings: true,
use_global_blocked_services: true, use_global_blocked_services: true,
...client, ...client,
@ -51,7 +48,7 @@ class ClientsTable extends Component {
} }
return { return {
identifier: CLIENT_ID.IP, ids: [''],
use_global_settings: true, use_global_settings: true,
use_global_blocked_services: true, use_global_blocked_services: true,
}; };
@ -76,28 +73,22 @@ class ClientsTable extends Component {
columns = [ columns = [
{ {
Header: this.props.t('table_client'), Header: this.props.t('table_client'),
accessor: 'ip', accessor: 'ids',
minWidth: 150, minWidth: 150,
Cell: (row) => { Cell: (row) => {
if (row.original && row.original.mac) { const { value } = row;
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 ''; return (
<div className="logs__row logs__row--overflow">
<span className="logs__text">
{value.map(address => (
<div key={address} title={address}>
{address}
</div>
))}
</span>
</div>
);
}, },
}, },
{ {
@ -119,9 +110,7 @@ class ClientsTable extends Component {
return ( return (
<div className="logs__row logs__row--overflow"> <div className="logs__row logs__row--overflow">
<div className="logs__text" title={title}> <div className="logs__text">{title}</div>
{title}
</div>
</div> </div>
); );
}, },

View File

@ -1,14 +1,20 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Field, reduxForm, formValueSelector } from 'redux-form'; import { Field, FieldArray, reduxForm, formValueSelector } from 'redux-form';
import { Trans, withNamespaces } from 'react-i18next'; import { Trans, withNamespaces } from 'react-i18next';
import flow from 'lodash/flow'; import flow from 'lodash/flow';
import i18n from '../../../i18n';
import Tabs from '../../ui/Tabs'; import Tabs from '../../ui/Tabs';
import { toggleAllServices } from '../../../helpers/helpers'; import { toggleAllServices } from '../../../helpers/helpers';
import { renderField, renderRadioField, renderSelectField, renderServiceField, ip, mac, required } from '../../../helpers/form'; import {
import { CLIENT_ID, SERVICES } from '../../../helpers/constants'; renderField,
renderGroupField,
renderSelectField,
renderServiceField,
} from '../../../helpers/form';
import { SERVICES } from '../../../helpers/constants';
import './Service.css'; import './Service.css';
const settingsCheckboxes = [ const settingsCheckboxes = [
@ -34,6 +40,67 @@ const settingsCheckboxes = [
}, },
]; ];
const validate = (values) => {
const errors = {};
const { name, ids } = values;
if (!name || !name.length) {
errors.name = i18n.t('form_error_required');
}
if (ids && ids.length) {
const idArrayErrors = [];
ids.forEach((id, idx) => {
if (!id || !id.length) {
idArrayErrors[idx] = i18n.t('form_error_required');
}
});
if (idArrayErrors.length) {
errors.ids = idArrayErrors;
}
}
return errors;
};
const renderFields = (placeholder, buttonTitle) =>
function cell(row) {
const {
fields,
meta: { error },
} = row;
return (
<div className="form__group">
{fields.map((ip, index) => (
<div key={index} className="mb-1">
<Field
name={ip}
component={renderGroupField}
type="text"
className="form-control"
placeholder={placeholder}
isActionAvailable={index !== 0}
removeField={() => fields.remove(index)}
/>
</div>
))}
<button
type="button"
className="btn btn-link btn-block btn-sm"
onClick={() => fields.push()}
title={buttonTitle}
>
<svg className="icon icon--close">
<use xlinkHref="#plus" />
</svg>
</button>
{error && <div className="error">{error}</div>}
</div>
);
};
let Form = (props) => { let Form = (props) => {
const { const {
t, t,
@ -42,71 +109,19 @@ let Form = (props) => {
change, change,
pristine, pristine,
submitting, submitting,
clientIdentifier,
useGlobalSettings, useGlobalSettings,
useGlobalServices, useGlobalServices,
toggleClientModal, toggleClientModal,
processingAdding, processingAdding,
processingUpdating, processingUpdating,
invalid,
} = props; } = props;
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="modal-body"> <div className="modal-body">
<div className="form__group mb-0">
<div className="form__group"> <div className="form__group">
<div className="form__inline mb-2">
<strong className="mr-3">
<Trans>client_identifier</Trans>
</strong>
<div className="custom-controls-stacked">
<Field
name="identifier"
component={renderRadioField}
type="radio"
className="form-control mr-2"
value="ip"
placeholder={t('ip_address')}
/>
<Field
name="identifier"
component={renderRadioField}
type="radio"
className="form-control mr-2"
value="mac"
placeholder="MAC"
/>
</div>
</div>
<div className="row">
<div className="col col-sm-6">
{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={[ip, 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>
<div className="col col-sm-6">
<Field <Field
id="name" id="name"
name="name" name="name"
@ -114,11 +129,16 @@ let Form = (props) => {
type="text" type="text"
className="form-control" className="form-control"
placeholder={t('form_client_name')} placeholder={t('form_client_name')}
validate={[required]}
/> />
</div> </div>
<div className="form__group">
<div className="form__label">
<strong className="mr-3">
<Trans>client_identifier</Trans>
</strong>
</div> </div>
<div className="form__desc"> <div className="form__desc mt-0">
<Trans <Trans
components={[ components={[
<a href="#dhcp" key="0"> <a href="#dhcp" key="0">
@ -131,6 +151,14 @@ let Form = (props) => {
</div> </div>
</div> </div>
<div className="form__group">
<FieldArray
name="ids"
component={renderFields(t('form_enter_id'), t('form_add_id'))}
/>
</div>
</div>
<Tabs controlClass="form"> <Tabs controlClass="form">
<div label="settings" title={props.t('main_settings')}> <div label="settings" title={props.t('main_settings')}>
{settingsCheckboxes.map(setting => ( {settingsCheckboxes.map(setting => (
@ -140,7 +168,11 @@ let Form = (props) => {
type="checkbox" type="checkbox"
component={renderSelectField} component={renderSelectField}
placeholder={t(setting.placeholder)} placeholder={t(setting.placeholder)}
disabled={setting.name !== 'use_global_settings' ? useGlobalSettings : false} disabled={
setting.name !== 'use_global_settings'
? useGlobalSettings
: false
}
/> />
</div> </div>
))} ))}
@ -210,7 +242,13 @@ let Form = (props) => {
<button <button
type="submit" type="submit"
className="btn btn-success btn-standard" className="btn btn-success btn-standard"
disabled={submitting || pristine || processingAdding || processingUpdating} disabled={
submitting ||
invalid ||
pristine ||
processingAdding ||
processingUpdating
}
> >
<Trans>save_btn</Trans> <Trans>save_btn</Trans>
</button> </button>
@ -227,22 +265,20 @@ Form.propTypes = {
change: PropTypes.func.isRequired, change: PropTypes.func.isRequired,
submitting: PropTypes.bool.isRequired, submitting: PropTypes.bool.isRequired,
toggleClientModal: PropTypes.func.isRequired, toggleClientModal: PropTypes.func.isRequired,
clientIdentifier: PropTypes.string,
useGlobalSettings: PropTypes.bool, useGlobalSettings: PropTypes.bool,
useGlobalServices: PropTypes.bool, useGlobalServices: PropTypes.bool,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
processingAdding: PropTypes.bool.isRequired, processingAdding: PropTypes.bool.isRequired,
processingUpdating: PropTypes.bool.isRequired, processingUpdating: PropTypes.bool.isRequired,
invalid: PropTypes.bool.isRequired,
}; };
const selector = formValueSelector('clientForm'); const selector = formValueSelector('clientForm');
Form = connect((state) => { Form = connect((state) => {
const clientIdentifier = selector(state, 'identifier');
const useGlobalSettings = selector(state, 'use_global_settings'); const useGlobalSettings = selector(state, 'use_global_settings');
const useGlobalServices = selector(state, 'use_global_blocked_services'); const useGlobalServices = selector(state, 'use_global_blocked_services');
return { return {
clientIdentifier,
useGlobalSettings, useGlobalSettings,
useGlobalServices, useGlobalServices,
}; };
@ -253,5 +289,6 @@ export default flow([
reduxForm({ reduxForm({
form: 'clientForm', form: 'clientForm',
enableReinitialize: true, enableReinitialize: true,
validate,
}), }),
])(Form); ])(Form);

View File

@ -3,3 +3,8 @@
vertical-align: middle; vertical-align: middle;
height: 100%; height: 100%;
} }
.icon--close {
width: 24px;
height: 24px;
}

View File

@ -167,6 +167,14 @@ const Icons = () => (
<symbol id="location" viewBox="0 0 24 24" fill="currentColor" strokeLinecap="round" strokeLinejoin="round"> <symbol id="location" viewBox="0 0 24 24" fill="currentColor" strokeLinecap="round" strokeLinejoin="round">
<path d="M12,2C8.134,2,5,5.134,5,9c0,5,7,13,7,13s7-8,7-13C19,5.134,15.866,2,12,2z M12,11.5c-1.381,0-2.5-1.119-2.5-2.5 c0-1.381,1.119-2.5,2.5-2.5s2.5,1.119,2.5,2.5C14.5,10.381,13.381,11.5,12,11.5z"/> <path d="M12,2C8.134,2,5,5.134,5,9c0,5,7,13,7,13s7-8,7-13C19,5.134,15.866,2,12,2z M12,11.5c-1.381,0-2.5-1.119-2.5-2.5 c0-1.381,1.119-2.5,2.5-2.5s2.5,1.119,2.5,2.5C14.5,10.381,13.381,11.5,12,11.5z"/>
</symbol> </symbol>
<symbol id="cross" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</symbol>
<symbol id="plus" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line>
</symbol>
</svg> </svg>
); );

View File

@ -29,6 +29,50 @@ export const renderField = ({
</Fragment> </Fragment>
); );
export const renderGroupField = ({
input,
id,
className,
placeholder,
type,
disabled,
autoComplete,
isActionAvailable,
removeField,
meta: { touched, error },
}) => (
<Fragment>
<div className="input-group">
<input
{...input}
id={id}
placeholder={placeholder}
type={type}
className={className}
disabled={disabled}
autoComplete={autoComplete}
/>
{isActionAvailable &&
<span className="input-group-append">
<button
type="button"
className="btn btn-secondary btn-icon"
onClick={removeField}
>
<svg className="icon icon--close">
<use xlinkHref="#cross" />
</svg>
</button>
</span>
}
</div>
{!disabled &&
touched &&
(error && <span className="form__message form__message--error">{error}</span>)}
</Fragment>
);
export const renderRadioField = ({ export const renderRadioField = ({
input, placeholder, disabled, meta: { touched, error }, input, placeholder, disabled, meta: { touched, error },
}) => ( }) => (
@ -102,6 +146,7 @@ export const renderServiceField = ({
</Fragment> </Fragment>
); );
// Validation functions
export const required = (value) => { export const required = (value) => {
if (value || value === 0) { if (value || value === 0) {
return false; return false;

View File

@ -1,5 +1,5 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { getClientInfo, normalizeWhois } from './helpers'; import { normalizeWhois } from './helpers';
import { WHOIS_ICONS } from './constants'; import { WHOIS_ICONS } from './constants';
const getFormattedWhois = (whois, t) => { const getFormattedWhois = (whois, t) => {
@ -22,12 +22,14 @@ const getFormattedWhois = (whois, t) => {
); );
}; };
export const formatClientCell = (value, clients, autoClients, t) => { export const formatClientCell = (row, t) => {
const clientInfo = getClientInfo(clients, value) || getClientInfo(autoClients, value); const { value, original: { info } } = row;
const { name, whois } = clientInfo;
let whoisContainer = ''; let whoisContainer = '';
let nameContainer = value; let nameContainer = value;
if (info) {
const { name, whois } = info;
if (name) { if (name) {
nameContainer = ( nameContainer = (
<span className="logs__text logs__text--wrap" title={`${name} (${value})`}> <span className="logs__text logs__text--wrap" title={`${name} (${value})`}>
@ -43,6 +45,7 @@ export const formatClientCell = (value, clients, autoClients, t) => {
</div> </div>
); );
} }
}
return ( return (
<span className="logs__text"> <span className="logs__text">

View File

@ -8,6 +8,7 @@ import subDays from 'date-fns/sub_days';
import round from 'lodash/round'; import round from 'lodash/round';
import axios from 'axios'; import axios from 'axios';
import i18n from 'i18next'; import i18n from 'i18next';
import uniqBy from 'lodash/uniqBy';
import versionCompare from './versionCompare'; import versionCompare from './versionCompare';
import { import {
@ -92,6 +93,17 @@ export const normalizeTopStats = stats => (
})) }))
); );
export const addClientInfo = (data, clients, param) => (
data.map((row) => {
const clientIp = row[param];
const info = clients.find(item => item[clientIp]) || '';
return {
...row,
info: (info && info[clientIp]) || '',
};
})
);
export const normalizeFilteringStatus = (filteringStatus) => { export const normalizeFilteringStatus = (filteringStatus) => {
const { const {
enabled, filters, user_rules: userRules, interval, enabled, filters, user_rules: userRules, interval,
@ -248,6 +260,20 @@ export const redirectToCurrentProtocol = (values, httpPort = 80) => {
export const normalizeTextarea = text => text && text.replace(/[;, ]/g, '\n').split('\n').filter(n => n); export const normalizeTextarea = text => text && text.replace(/[;, ]/g, '\n').split('\n').filter(n => n);
export const getClientInfo = (clients, ip) => { export const getClientInfo = (clients, ip) => {
const client = clients
.find(item => item.ip_addrs && item.ip_addrs.find(clientIp => clientIp === ip));
if (!client) {
return '';
}
const { name, whois_info } = client;
const whois = Object.keys(whois_info).length > 0 ? whois_info : '';
return { name, whois };
};
export const getAutoClientInfo = (clients, ip) => {
const client = clients.find(item => ip === item.ip); const client = clients.find(item => ip === item.ip);
if (!client) { if (!client) {
@ -328,3 +354,13 @@ export const getPathWithQueryString = (path, params) => {
return `${path}?${searchParams.toString()}`; return `${path}?${searchParams.toString()}`;
}; };
export const getParamsForClientsSearch = (data, param) => {
const uniqueClients = uniqBy(data, param);
return uniqueClients
.reduce((acc, item, idx) => {
const key = `ip${idx}`;
acc[key] = item[param];
return acc;
}, {});
};

View File

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

View File

@ -1,11 +1,10 @@
package home package home
import ( import (
"encoding/json" "bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net" "net"
"net/http"
"os" "os"
"os/exec" "os/exec"
"runtime" "runtime"
@ -23,8 +22,7 @@ const (
// Client information // Client information
type Client struct { type Client struct {
IP string IDs []string
MAC string
Name string Name string
UseOwnSettings bool // false: use global settings UseOwnSettings bool // false: use global settings
FilteringEnabled bool FilteringEnabled bool
@ -37,22 +35,6 @@ type Client struct {
BlockedServices []string BlockedServices []string
} }
type clientJSON struct {
IP string `json:"ip"`
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"`
WhoisInfo map[string]interface{} `json:"whois_info"`
UseGlobalBlockedServices bool `json:"use_global_blocked_services"`
BlockedServices []string `json:"blocked_services"`
}
type clientSource uint type clientSource uint
// Client sources // Client sources
@ -74,24 +56,79 @@ type ClientHost struct {
type clientsContainer struct { type clientsContainer struct {
list map[string]*Client // name -> client list map[string]*Client // name -> client
ipIndex map[string]*Client // IP -> client idIndex map[string]*Client // IP -> client
ipHost map[string]*ClientHost // IP -> Hostname ipHost map[string]*ClientHost // IP -> Hostname
lock sync.Mutex lock sync.Mutex
} }
// Init initializes clients container // Init initializes clients container
// Note: this function must be called only once // Note: this function must be called only once
func (clients *clientsContainer) Init() { func (clients *clientsContainer) Init(objects []clientObject) {
if clients.list != nil { if clients.list != nil {
log.Fatal("clients.list != nil") log.Fatal("clients.list != nil")
} }
clients.list = make(map[string]*Client) clients.list = make(map[string]*Client)
clients.ipIndex = make(map[string]*Client) clients.idIndex = make(map[string]*Client)
clients.ipHost = make(map[string]*ClientHost) clients.ipHost = make(map[string]*ClientHost)
clients.addFromConfig(objects)
go clients.periodicUpdate() go clients.periodicUpdate()
} }
type clientObject struct {
Name string `yaml:"name"`
IDs []string `yaml:"ids"`
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"`
UseGlobalBlockedServices bool `yaml:"use_global_blocked_services"`
BlockedServices []string `yaml:"blocked_services"`
}
func (clients *clientsContainer) addFromConfig(objects []clientObject) {
for _, cy := range objects {
cli := Client{
Name: cy.Name,
IDs: cy.IDs,
UseOwnSettings: !cy.UseGlobalSettings,
FilteringEnabled: cy.FilteringEnabled,
ParentalEnabled: cy.ParentalEnabled,
SafeSearchEnabled: cy.SafeSearchEnabled,
SafeBrowsingEnabled: cy.SafeBrowsingEnabled,
UseOwnBlockedServices: !cy.UseGlobalBlockedServices,
BlockedServices: cy.BlockedServices,
}
_, err := clients.Add(cli)
if err != nil {
log.Tracef("clientAdd: %s", err)
}
}
}
// WriteDiskConfig - write configuration
func (clients *clientsContainer) WriteDiskConfig(objects *[]clientObject) {
clientsList := clients.GetList()
for _, cli := range clientsList {
cy := clientObject{
Name: cli.Name,
IDs: cli.IDs,
UseGlobalSettings: !cli.UseOwnSettings,
FilteringEnabled: cli.FilteringEnabled,
ParentalEnabled: cli.ParentalEnabled,
SafeSearchEnabled: cli.SafeSearchEnabled,
SafeBrowsingEnabled: cli.SafeBrowsingEnabled,
UseGlobalBlockedServices: !cli.UseOwnBlockedServices,
BlockedServices: cli.BlockedServices,
}
*objects = append(*objects, cy)
}
}
func (clients *clientsContainer) periodicUpdate() { func (clients *clientsContainer) periodicUpdate() {
for { for {
clients.addFromHostsFile() clients.addFromHostsFile()
@ -111,7 +148,7 @@ func (clients *clientsContainer) Exists(ip string, source clientSource) bool {
clients.lock.Lock() clients.lock.Lock()
defer clients.lock.Unlock() defer clients.lock.Unlock()
_, ok := clients.ipIndex[ip] _, ok := clients.idIndex[ip]
if ok { if ok {
return true return true
} }
@ -128,25 +165,42 @@ func (clients *clientsContainer) Exists(ip string, source clientSource) bool {
// Find searches for a client by IP // Find searches for a client by IP
func (clients *clientsContainer) Find(ip string) (Client, bool) { func (clients *clientsContainer) Find(ip string) (Client, bool) {
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
return Client{}, false
}
clients.lock.Lock() clients.lock.Lock()
defer clients.lock.Unlock() defer clients.lock.Unlock()
c, ok := clients.ipIndex[ip] c, ok := clients.idIndex[ip]
if ok { if ok {
return *c, true return *c, true
} }
for _, c = range clients.list { for _, c = range clients.list {
if len(c.MAC) != 0 { for _, id := range c.IDs {
mac, err := net.ParseMAC(c.MAC) _, ipnet, err := net.ParseCIDR(id)
if err != nil { if err != nil {
continue continue
} }
ipAddr := config.dhcpServer.FindIPbyMAC(mac) if ipnet.Contains(ipAddr) {
if ipAddr == nil { return *c, true
}
}
}
macFound := config.dhcpServer.FindMACbyIP(ipAddr)
if macFound == nil {
return Client{}, false
}
for _, c = range clients.list {
for _, id := range c.IDs {
hwAddr, err := net.ParseMAC(id)
if err != nil {
continue continue
} }
if ip == ipAddr.String() { if bytes.Equal(hwAddr, macFound) {
return *c, true return *c, true
} }
} }
@ -155,28 +209,51 @@ func (clients *clientsContainer) Find(ip string) (Client, bool) {
return Client{}, false return Client{}, false
} }
// FindAutoClient - search for an auto-client by IP
func (clients *clientsContainer) FindAutoClient(ip string) (ClientHost, bool) {
ipAddr := net.ParseIP(ip)
if ipAddr == nil {
return ClientHost{}, false
}
clients.lock.Lock()
defer clients.lock.Unlock()
ch, ok := clients.ipHost[ip]
if ok {
return *ch, true
}
return ClientHost{}, false
}
// Check if Client object's fields are correct // Check if Client object's fields are correct
func (c *Client) check() error { func (c *Client) check() error {
if len(c.Name) == 0 { if len(c.Name) == 0 {
return fmt.Errorf("Invalid Name") return fmt.Errorf("Invalid Name")
} }
if (len(c.IP) == 0 && len(c.MAC) == 0) || if len(c.IDs) == 0 {
(len(c.IP) != 0 && len(c.MAC) != 0) { return fmt.Errorf("ID required")
return fmt.Errorf("IP or MAC required")
} }
if len(c.IP) != 0 { for i, id := range c.IDs {
ip := net.ParseIP(c.IP) ip := net.ParseIP(id)
if ip == nil { if ip != nil {
return fmt.Errorf("Invalid IP") c.IDs[i] = ip.String() // normalize IP address
continue
} }
c.IP = ip.String()
} else { _, _, err := net.ParseCIDR(id)
_, err := net.ParseMAC(c.MAC) if err == nil {
if err != nil { continue
return fmt.Errorf("Invalid MAC: %s", err)
} }
_, err = net.ParseMAC(id)
if err == nil {
continue
}
return fmt.Errorf("Invalid ID: %s", id)
} }
return nil return nil
} }
@ -198,26 +275,34 @@ func (clients *clientsContainer) Add(c Client) (bool, error) {
return false, nil return false, nil
} }
// check IP index // check ID index
if len(c.IP) != 0 { for _, id := range c.IDs {
c2, ok := clients.ipIndex[c.IP] c2, ok := clients.idIndex[id]
if ok { if ok {
return false, fmt.Errorf("Another client uses the same IP address: %s", c2.Name) return false, fmt.Errorf("Another client uses the same ID (%s): %s", id, c2.Name)
} }
} }
ch, ok := clients.ipHost[c.IP] // remove auto-clients with the same IP address, keeping WHOIS info if possible
for _, id := range c.IDs {
ch, ok := clients.ipHost[id]
if ok { if ok {
if len(c.WhoisInfo) == 0 {
c.WhoisInfo = ch.WhoisInfo c.WhoisInfo = ch.WhoisInfo
delete(clients.ipHost, c.IP) }
delete(clients.ipHost, id)
}
} }
// update Name index
clients.list[c.Name] = &c clients.list[c.Name] = &c
if len(c.IP) != 0 {
clients.ipIndex[c.IP] = &c // update ID index
for _, id := range c.IDs {
clients.idIndex[id] = &c
} }
log.Tracef("'%s': '%s' | '%s' -> [%d]", c.Name, c.IP, c.MAC, len(clients.list)) log.Tracef("'%s': ID:%v [%d]", c.Name, c.IDs, len(clients.list))
return true, nil return true, nil
} }
@ -231,8 +316,26 @@ func (clients *clientsContainer) Del(name string) bool {
return false return false
} }
// update Name index
delete(clients.list, name) delete(clients.list, name)
delete(clients.ipIndex, c.IP)
// update ID index
for _, id := range c.IDs {
delete(clients.idIndex, id)
}
return true
}
// Return TRUE if arrays are equal
func arraysEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := 0; i != len(a); i++ {
if a[i] != b[i] {
return false
}
}
return true return true
} }
@ -260,27 +363,30 @@ func (clients *clientsContainer) Update(name string, c Client) error {
} }
// check IP index // check IP index
if old.IP != c.IP && len(c.IP) != 0 { if !arraysEqual(old.IDs, c.IDs) {
c2, ok := clients.ipIndex[c.IP] for _, id := range c.IDs {
if ok { c2, ok := clients.idIndex[id]
return fmt.Errorf("Another client uses the same IP address: %s", c2.Name) if ok && c2 != old {
return fmt.Errorf("Another client uses the same ID (%s): %s", id, c2.Name)
}
}
// update ID index
for _, id := range old.IDs {
delete(clients.idIndex, id)
}
for _, id := range c.IDs {
clients.idIndex[id] = old
} }
} }
// update Name index // update Name index
if old.Name != c.Name { if old.Name != c.Name {
delete(clients.list, old.Name) delete(clients.list, old.Name)
} clients.list[c.Name] = old
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
} }
*old = c
return nil return nil
} }
@ -289,7 +395,7 @@ func (clients *clientsContainer) SetWhoisInfo(ip string, info [][]string) {
clients.lock.Lock() clients.lock.Lock()
defer clients.lock.Unlock() defer clients.lock.Unlock()
c, ok := clients.ipIndex[ip] c, ok := clients.idIndex[ip]
if ok { if ok {
c.WhoisInfo = info c.WhoisInfo = info
log.Debug("Clients: set WHOIS info for client %s: %v", c.Name, c.WhoisInfo) log.Debug("Clients: set WHOIS info for client %s: %v", c.Name, c.WhoisInfo)
@ -319,7 +425,7 @@ func (clients *clientsContainer) AddHost(ip, host string, source clientSource) (
defer clients.lock.Unlock() defer clients.lock.Unlock()
// check index // check index
_, ok := clients.ipIndex[ip] _, ok := clients.idIndex[ip]
if ok { if ok {
return false, nil return false, nil
} }
@ -440,210 +546,3 @@ func (clients *clientsContainer) addFromDHCP() {
} }
log.Debug("Added %d client aliases from DHCP", n) log.Debug("Added %d client aliases from DHCP", n)
} }
type clientHostJSON struct {
IP string `json:"ip"`
Name string `json:"name"`
Source string `json:"source"`
WhoisInfo map[string]interface{} `json:"whois_info"`
}
type clientListJSON struct {
Clients []clientJSON `json:"clients"`
AutoClients []clientHostJSON `json:"auto_clients"`
}
// respond with information about configured clients
func handleGetClients(w http.ResponseWriter, r *http.Request) {
data := clientListJSON{}
config.clients.lock.Lock()
for _, c := range config.clients.list {
cj := clientJSON{
IP: c.IP,
MAC: c.MAC,
Name: c.Name,
UseGlobalSettings: !c.UseOwnSettings,
FilteringEnabled: c.FilteringEnabled,
ParentalEnabled: c.ParentalEnabled,
SafeSearchEnabled: c.SafeSearchEnabled,
SafeBrowsingEnabled: c.SafeBrowsingEnabled,
UseGlobalBlockedServices: !c.UseOwnBlockedServices,
BlockedServices: c.BlockedServices,
}
if len(c.MAC) != 0 {
hwAddr, _ := net.ParseMAC(c.MAC)
ipAddr := config.dhcpServer.FindIPbyMAC(hwAddr)
if ipAddr != nil {
cj.IP = ipAddr.String()
}
}
cj.WhoisInfo = make(map[string]interface{})
for _, wi := range c.WhoisInfo {
cj.WhoisInfo[wi[0]] = wi[1]
}
data.Clients = append(data.Clients, cj)
}
for ip, ch := range config.clients.ipHost {
cj := clientHostJSON{
IP: ip,
Name: ch.Host,
}
cj.Source = "etc/hosts"
switch ch.Source {
case ClientSourceDHCP:
cj.Source = "DHCP"
case ClientSourceRDNS:
cj.Source = "rDNS"
case ClientSourceARP:
cj.Source = "ARP"
case ClientSourceWHOIS:
cj.Source = "WHOIS"
}
cj.WhoisInfo = make(map[string]interface{})
for _, wi := range ch.WhoisInfo {
cj.WhoisInfo[wi[0]] = wi[1]
}
data.AutoClients = append(data.AutoClients, cj)
}
config.clients.lock.Unlock()
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w).Encode(data)
if e != nil {
httpError(w, http.StatusInternalServerError, "Failed to encode to json: %v", e)
return
}
}
// 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,
UseOwnBlockedServices: !cj.UseGlobalBlockedServices,
BlockedServices: cj.BlockedServices,
}
return &c, nil
}
// Add a new client
func handleAddClient(w http.ResponseWriter, r *http.Request) {
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 := config.clients.Add(*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) {
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 !config.clients.Del(cj.Name) {
httpError(w, http.StatusBadRequest, "Client not found")
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
type updateJSON struct {
Name string `json:"name"`
Data clientJSON `json:"data"`
}
// Update client's properties
func handleUpdateClient(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
httpError(w, http.StatusBadRequest, "failed to read request body: %s", err)
return
}
var dj updateJSON
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 = config.clients.Update(dj.Name, *c)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
// RegisterClientsHandlers registers HTTP handlers
func RegisterClientsHandlers() {
httpRegister(http.MethodGet, "/control/clients", handleGetClients)
httpRegister(http.MethodPost, "/control/clients/add", handleAddClient)
httpRegister(http.MethodPost, "/control/clients/delete", handleDelClient)
httpRegister(http.MethodPost, "/control/clients/update", handleUpdateClient)
}

286
home/clients_http.go Normal file
View File

@ -0,0 +1,286 @@
package home
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
type clientJSON struct {
IDs []string `json:"ids"`
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"`
WhoisInfo map[string]interface{} `json:"whois_info"`
UseGlobalBlockedServices bool `json:"use_global_blocked_services"`
BlockedServices []string `json:"blocked_services"`
}
type clientHostJSON struct {
IP string `json:"ip"`
Name string `json:"name"`
Source string `json:"source"`
WhoisInfo map[string]interface{} `json:"whois_info"`
}
type clientListJSON struct {
Clients []clientJSON `json:"clients"`
AutoClients []clientHostJSON `json:"auto_clients"`
}
// respond with information about configured clients
func handleGetClients(w http.ResponseWriter, r *http.Request) {
data := clientListJSON{}
config.clients.lock.Lock()
for _, c := range config.clients.list {
cj := clientToJSON(c)
data.Clients = append(data.Clients, cj)
}
for ip, ch := range config.clients.ipHost {
cj := clientHostJSON{
IP: ip,
Name: ch.Host,
}
cj.Source = "etc/hosts"
switch ch.Source {
case ClientSourceDHCP:
cj.Source = "DHCP"
case ClientSourceRDNS:
cj.Source = "rDNS"
case ClientSourceARP:
cj.Source = "ARP"
case ClientSourceWHOIS:
cj.Source = "WHOIS"
}
cj.WhoisInfo = make(map[string]interface{})
for _, wi := range ch.WhoisInfo {
cj.WhoisInfo[wi[0]] = wi[1]
}
data.AutoClients = append(data.AutoClients, cj)
}
config.clients.lock.Unlock()
w.Header().Set("Content-Type", "application/json")
e := json.NewEncoder(w).Encode(data)
if e != nil {
httpError(w, http.StatusInternalServerError, "Failed to encode to json: %v", e)
return
}
}
// Convert JSON object to Client object
func jsonToClient(cj clientJSON) (*Client, error) {
c := Client{
Name: cj.Name,
IDs: cj.IDs,
UseOwnSettings: !cj.UseGlobalSettings,
FilteringEnabled: cj.FilteringEnabled,
ParentalEnabled: cj.ParentalEnabled,
SafeSearchEnabled: cj.SafeSearchEnabled,
SafeBrowsingEnabled: cj.SafeBrowsingEnabled,
UseOwnBlockedServices: !cj.UseGlobalBlockedServices,
BlockedServices: cj.BlockedServices,
}
return &c, nil
}
// Convert Client object to JSON
func clientToJSON(c *Client) clientJSON {
cj := clientJSON{
Name: c.Name,
IDs: c.IDs,
UseGlobalSettings: !c.UseOwnSettings,
FilteringEnabled: c.FilteringEnabled,
ParentalEnabled: c.ParentalEnabled,
SafeSearchEnabled: c.SafeSearchEnabled,
SafeBrowsingEnabled: c.SafeBrowsingEnabled,
UseGlobalBlockedServices: !c.UseOwnBlockedServices,
BlockedServices: c.BlockedServices,
}
cj.WhoisInfo = make(map[string]interface{})
for _, wi := range c.WhoisInfo {
cj.WhoisInfo[wi[0]] = wi[1]
}
return cj
}
type clientHostJSONWithID struct {
IDs []string `json:"ids"`
Name string `json:"name"`
WhoisInfo map[string]interface{} `json:"whois_info"`
}
// Convert ClientHost object to JSON
func clientHostToJSON(ip string, ch ClientHost) clientHostJSONWithID {
cj := clientHostJSONWithID{
Name: ch.Host,
IDs: []string{ip},
}
cj.WhoisInfo = make(map[string]interface{})
for _, wi := range ch.WhoisInfo {
cj.WhoisInfo[wi[0]] = wi[1]
}
return cj
}
// Add a new client
func handleAddClient(w http.ResponseWriter, r *http.Request) {
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 := config.clients.Add(*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) {
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 !config.clients.Del(cj.Name) {
httpError(w, http.StatusBadRequest, "Client not found")
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
type updateJSON struct {
Name string `json:"name"`
Data clientJSON `json:"data"`
}
// Update client's properties
func handleUpdateClient(w http.ResponseWriter, r *http.Request) {
body, err := ioutil.ReadAll(r.Body)
if err != nil {
httpError(w, http.StatusBadRequest, "failed to read request body: %s", err)
return
}
var dj updateJSON
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 = config.clients.Update(dj.Name, *c)
if err != nil {
httpError(w, http.StatusBadRequest, "%s", err)
return
}
_ = writeAllConfigsAndReloadDNS()
returnOK(w)
}
// Get the list of clients by IP address list
func handleFindClient(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
data := []map[string]interface{}{}
for i := 0; ; i++ {
ip := q.Get(fmt.Sprintf("ip%d", i))
if len(ip) == 0 {
break
}
el := map[string]interface{}{}
c, ok := config.clients.Find(ip)
if !ok {
ch, ok := config.clients.FindAutoClient(ip)
if !ok {
continue // a client with this IP isn't found
}
cj := clientHostToJSON(ip, ch)
el[ip] = cj
} else {
cj := clientToJSON(&c)
el[ip] = cj
}
data = append(data, el)
}
js, err := json.Marshal(data)
if err != nil {
httpError(w, http.StatusInternalServerError, "json.Marshal: %s", err)
return
}
w.Header().Set("Content-Type", "application/json")
_, err = w.Write(js)
if err != nil {
httpError(w, http.StatusInternalServerError, "Couldn't write response: %s", err)
}
}
// RegisterClientsHandlers registers HTTP handlers
func RegisterClientsHandlers() {
httpRegister("GET", "/control/clients", handleGetClients)
httpRegister("POST", "/control/clients/add", handleAddClient)
httpRegister("POST", "/control/clients/delete", handleDelClient)
httpRegister("POST", "/control/clients/update", handleUpdateClient)
httpRegister("GET", "/control/clients/find", handleFindClient)
}

View File

@ -12,11 +12,11 @@ func TestClients(t *testing.T) {
var b bool var b bool
clients := clientsContainer{} clients := clientsContainer{}
clients.Init() clients.Init(nil)
// add // add
c = Client{ c = Client{
IP: "1.1.1.1", IDs: []string{"1.1.1.1", "aa:aa:aa:aa:aa:aa"},
Name: "client1", Name: "client1",
} }
b, e = clients.Add(c) b, e = clients.Add(c)
@ -26,7 +26,7 @@ func TestClients(t *testing.T) {
// add #2 // add #2
c = Client{ c = Client{
IP: "2.2.2.2", IDs: []string{"2.2.2.2"},
Name: "client2", Name: "client2",
} }
b, e = clients.Add(c) b, e = clients.Add(c)
@ -46,7 +46,7 @@ func TestClients(t *testing.T) {
// failed add - name in use // failed add - name in use
c = Client{ c = Client{
IP: "1.2.3.5", IDs: []string{"1.2.3.5"},
Name: "client1", Name: "client1",
} }
b, _ = clients.Add(c) b, _ = clients.Add(c)
@ -56,7 +56,7 @@ func TestClients(t *testing.T) {
// failed add - ip in use // failed add - ip in use
c = Client{ c = Client{
IP: "2.2.2.2", IDs: []string{"2.2.2.2"},
Name: "client3", Name: "client3",
} }
b, e = clients.Add(c) b, e = clients.Add(c)
@ -70,35 +70,45 @@ func TestClients(t *testing.T) {
assert.True(t, clients.Exists("2.2.2.2", ClientSourceHostsFile)) assert.True(t, clients.Exists("2.2.2.2", ClientSourceHostsFile))
// failed update - no such name // failed update - no such name
c.IP = "1.2.3.0" c.IDs = []string{"1.2.3.0"}
c.Name = "client3" c.Name = "client3"
if clients.Update("client3", c) == nil { if clients.Update("client3", c) == nil {
t.Fatalf("Update") t.Fatalf("Update")
} }
// failed update - name in use // failed update - name in use
c.IP = "1.2.3.0" c.IDs = []string{"1.2.3.0"}
c.Name = "client2" c.Name = "client2"
if clients.Update("client1", c) == nil { if clients.Update("client1", c) == nil {
t.Fatalf("Update - name in use") t.Fatalf("Update - name in use")
} }
// failed update - ip in use // failed update - ip in use
c.IP = "2.2.2.2" c.IDs = []string{"2.2.2.2"}
c.Name = "client1" c.Name = "client1"
if clients.Update("client1", c) == nil { if clients.Update("client1", c) == nil {
t.Fatalf("Update - ip in use") t.Fatalf("Update - ip in use")
} }
// update // update
c.IP = "1.1.1.2" c.IDs = []string{"1.1.1.2"}
c.Name = "client1" c.Name = "client1"
if clients.Update("client1", c) != nil { if clients.Update("client1", c) != nil {
t.Fatalf("Update") t.Fatalf("Update")
} }
// get after update // get after update
assert.True(t, !(clients.Exists("1.1.1.1", ClientSourceHostsFile) || !clients.Exists("1.1.1.2", ClientSourceHostsFile))) assert.True(t, !clients.Exists("1.1.1.1", ClientSourceHostsFile))
assert.True(t, clients.Exists("1.1.1.2", ClientSourceHostsFile))
// update - rename
c.IDs = []string{"1.1.1.2"}
c.Name = "client1-renamed"
c.UseOwnSettings = true
assert.True(t, clients.Update("client1", c) == nil)
c = Client{}
c, b = clients.Find("1.1.1.2")
assert.True(t, b && c.Name == "client1-renamed" && c.IDs[0] == "1.1.1.2" && c.UseOwnSettings)
// failed remove - no such name // failed remove - no such name
if clients.Del("client3") { if clients.Del("client3") {
@ -106,7 +116,7 @@ func TestClients(t *testing.T) {
} }
// remove // remove
assert.True(t, !(!clients.Del("client1") || clients.Exists("1.1.1.2", ClientSourceHostsFile))) assert.True(t, !(!clients.Del("client1-renamed") || clients.Exists("1.1.1.2", ClientSourceHostsFile)))
// add host client // add host client
b, e = clients.AddHost("1.1.1.1", "host", ClientSourceARP) b, e = clients.AddHost("1.1.1.1", "host", ClientSourceARP)
@ -139,7 +149,7 @@ func TestClients(t *testing.T) {
func TestClientsWhois(t *testing.T) { func TestClientsWhois(t *testing.T) {
var c Client var c Client
clients := clientsContainer{} clients := clientsContainer{}
clients.Init() clients.Init(nil)
whois := [][]string{{"orgname", "orgname-val"}, {"country", "country-val"}} whois := [][]string{{"orgname", "orgname-val"}, {"country", "country-val"}}
// set whois info on new client // set whois info on new client
@ -153,11 +163,11 @@ func TestClientsWhois(t *testing.T) {
// set whois info on existing client // set whois info on existing client
c = Client{ c = Client{
IP: "1.1.1.2", IDs: []string{"1.1.1.2"},
Name: "client1", Name: "client1",
} }
_, _ = clients.Add(c) _, _ = clients.Add(c)
clients.SetWhoisInfo("1.1.1.2", whois) clients.SetWhoisInfo("1.1.1.2", whois)
assert.True(t, clients.ipIndex["1.1.1.2"].WhoisInfo[0][1] == "orgname-val") assert.True(t, clients.idIndex["1.1.1.2"].WhoisInfo[0][1] == "orgname-val")
_ = clients.Del("client1") _ = clients.Del("client1")
} }

View File

@ -30,20 +30,6 @@ 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"`
UseGlobalBlockedServices bool `yaml:"use_global_blocked_services"`
BlockedServices []string `yaml:"blocked_services"`
}
type HTTPSServer struct { type HTTPSServer struct {
server *http.Server server *http.Server
cond *sync.Cond // reacts to config.TLS.Enabled, PortHTTPS, CertificateChain and PrivateKey cond *sync.Cond // reacts to config.TLS.Enabled, PortHTTPS, CertificateChain and PrivateKey
@ -285,27 +271,6 @@ func parseConfig() error {
config.DNS.FiltersUpdateIntervalHours = 24 config.DNS.FiltersUpdateIntervalHours = 24
} }
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,
UseOwnBlockedServices: !cy.UseGlobalBlockedServices,
BlockedServices: cy.BlockedServices,
}
_, err = config.clients.Add(cli)
if err != nil {
log.Tracef("clientAdd: %s", err)
}
}
config.Clients = nil
status := tlsConfigStatus{} status := tlsConfigStatus{}
if !tlsLoadConfig(&config.TLS, &status) { if !tlsLoadConfig(&config.TLS, &status) {
log.Error("%s", status.WarningValidation) log.Error("%s", status.WarningValidation)
@ -335,27 +300,7 @@ func (c *configuration) write() error {
c.Lock() c.Lock()
defer c.Unlock() defer c.Unlock()
clientsList := config.clients.GetList() config.clients.WriteDiskConfig(&config.Clients)
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,
UseGlobalBlockedServices: !cli.UseOwnBlockedServices,
BlockedServices: cli.BlockedServices,
}
config.Clients = append(config.Clients, cy)
}
if config.auth != nil { if config.auth != nil {
config.Users = config.auth.GetUsers() config.Users = config.auth.GetUsers()

View File

@ -98,7 +98,6 @@ func run(args options) {
}() }()
initConfig() initConfig()
config.clients.Init()
initServices() initServices()
if !config.firstRun { if !config.firstRun {
@ -119,6 +118,9 @@ func run(args options) {
} }
} }
config.clients.Init(config.Clients)
config.Clients = nil
if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") &&
config.RlimitNoFile != 0 { config.RlimitNoFile != 0 {
setRlimit(config.RlimitNoFile) setRlimit(config.RlimitNoFile)
@ -370,11 +372,13 @@ func cleanup() {
// Stop HTTP server, possibly waiting for all active connections to be closed // Stop HTTP server, possibly waiting for all active connections to be closed
func stopHTTPServer() { func stopHTTPServer() {
log.Info("Stopping HTTP server...")
config.httpsServer.shutdown = true config.httpsServer.shutdown = true
if config.httpsServer.server != nil { if config.httpsServer.server != nil {
config.httpsServer.server.Shutdown(context.TODO()) config.httpsServer.server.Shutdown(context.TODO())
} }
config.httpServer.Shutdown(context.TODO()) config.httpServer.Shutdown(context.TODO())
log.Info("Stopped HTTP server")
} }
// This function is called before application exits // This function is called before application exits

View File

@ -11,7 +11,7 @@ import (
yaml "gopkg.in/yaml.v2" yaml "gopkg.in/yaml.v2"
) )
const currentSchemaVersion = 5 // used for upgrading from old configs to new config const currentSchemaVersion = 6 // used for upgrading from old configs to new config
// Performs necessary upgrade operations if needed // Performs necessary upgrade operations if needed
func upgradeConfig() error { func upgradeConfig() error {
@ -82,6 +82,12 @@ func upgradeConfigSchema(oldVersion int, diskConfig *map[string]interface{}) err
if err != nil { if err != nil {
return err return err
} }
fallthrough
case 5:
err := upgradeSchema5to6(diskConfig)
if err != nil {
return err
}
default: default:
err := fmt.Errorf("configuration file contains unknown schema_version, abort") err := fmt.Errorf("configuration file contains unknown schema_version, abort")
log.Println(err) log.Println(err)
@ -268,3 +274,72 @@ func upgradeSchema4to5(diskConfig *map[string]interface{}) error {
(*diskConfig)["users"] = users (*diskConfig)["users"] = users
return nil return nil
} }
// clients:
// ...
// ip: 127.0.0.1
// mac: ...
//
// ->
//
// clients:
// ...
// ids:
// - 127.0.0.1
// - ...
func upgradeSchema5to6(diskConfig *map[string]interface{}) error {
log.Printf("%s(): called", _Func())
(*diskConfig)["schema_version"] = 6
clients, ok := (*diskConfig)["clients"]
if !ok {
return nil
}
switch arr := clients.(type) {
case []interface{}:
for i := range arr {
switch c := arr[i].(type) {
case map[interface{}]interface{}:
_ip, ok := c["ip"]
ids := []string{}
if ok {
ip, ok := _ip.(string)
if !ok {
log.Fatalf("client.ip is not a string: %v", _ip)
return nil
}
if len(ip) != 0 {
ids = append(ids, ip)
}
}
_mac, ok := c["mac"]
if ok {
mac, ok := _mac.(string)
if !ok {
log.Fatalf("client.mac is not a string: %v", _mac)
return nil
}
if len(mac) != 0 {
ids = append(ids, mac)
}
}
c["ids"] = ids
default:
continue
}
}
default:
return nil
}
return nil
}

View File

@ -1,6 +1,95 @@
# AdGuard Home API Change Log # AdGuard Home API Change Log
## v0.100: API changes
### API: Get list of clients: GET /control/clients
* "ip" and "mac" fields are removed
* "ids" and "ip_addrs" fields are added
Response:
200 OK
{
clients: [
{
name: "client1"
ids: ["...", ...] // IP or MAC
ip_addrs: ["...", ...] // all IP addresses (set by user and resolved by MAC)
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
use_global_blocked_services: true
blocked_services: [ "name1", ... ]
whois_info: {
key: "value"
...
}
}
]
auto_clients: [
{
name: "host"
ip: "..."
source: "etc/hosts" || "rDNS"
whois_info: {
key: "value"
...
}
}
]
}
### API: Add client: POST /control/clients/add
* "ip" and "mac" fields are removed
* "ids" field is added
Request:
POST /control/clients/add
{
name: "client1"
ids: ["...", ...] // IP or MAC
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
use_global_blocked_services: true
blocked_services: [ "name1", ... ]
}
### API: Update client: POST /control/clients/update
* "ip" and "mac" fields are removed
* "ids" field is added
Request:
POST /control/clients/update
{
name: "client1"
data: {
name: "client1"
ids: ["...", ...] // IP or MAC
use_global_settings: true
filtering_enabled: false
parental_enabled: false
safebrowsing_enabled: false
safesearch_enabled: false
use_global_blocked_services: true
blocked_services: [ "name1", ... ]
}
}
## v0.99.3: API changes ## v0.99.3: API changes
### API: Get query log: GET /control/querylog ### API: Get query log: GET /control/querylog

View File

@ -772,6 +772,22 @@ paths:
200: 200:
description: OK description: OK
/clients/find:
get:
tags:
- clients
operationId: clientsFind
summary: 'Get information about selected clients by their IP address'
parameters:
- name: ip0
in: query
type: string
responses:
200:
description: OK
schema:
$ref: "#/definitions/ClientsFindResponse"
/blocked_services/list: /blocked_services/list:
get: get:
@ -1589,15 +1605,14 @@ definitions:
type: "object" type: "object"
description: "Client information" description: "Client information"
properties: properties:
ip:
type: "string"
description: "IP address"
example: "127.0.0.1"
name: name:
type: "string" type: "string"
description: "Name" description: "Name"
example: "localhost" example: "localhost"
mac: ids:
type: "array"
description: "IP, CIDR or MAC address"
items:
type: "string" type: "string"
use_global_settings: use_global_settings:
type: "boolean" type: "boolean"
@ -1645,6 +1660,20 @@ definitions:
properties: properties:
name: name:
type: "string" type: "string"
ClientsFindResponse:
type: "array"
description: "Response to clients find operation"
items:
$ref: "#/definitions/ClientsFindEntry"
ClientsFindEntry:
type: "object"
properties:
"1.2.3.4":
items:
$ref: "#/definitions/Client"
Clients: Clients:
type: "object" type: "object"
properties: properties: