diff --git a/AGHTechDoc.md b/AGHTechDoc.md index dafb44c0..93ae634c 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -21,6 +21,7 @@ Contents: * Add client * Update client * Delete client + * API: Find clients by IP * Enable DHCP server * "Show DHCP status" command * "Check DHCP" command @@ -618,8 +619,6 @@ 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. @@ -643,8 +642,7 @@ Response: clients: [ { name: "client1" - ip: "..." - mac: "..." + ids: ["...", ...] // IP, CIDR or MAC use_global_settings: true filtering_enabled: false parental_enabled: false @@ -682,8 +680,7 @@ Request: { name: "client1" - ip: "..." - mac: "..." + ids: ["...", ...] // IP, CIDR or MAC use_global_settings: true filtering_enabled: false parental_enabled: false @@ -712,8 +709,7 @@ Request: name: "client1" data: { name: "client1" - ip: "..." - mac: "..." + ids: ["...", ...] // IP, CIDR or MAC use_global_settings: true filtering_enabled: false parental_enabled: false @@ -752,6 +748,41 @@ Error response (Client not found): 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 There are low-level settings that can block undesired DNS requests. "Blocking" means not responding to request. diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 764d1450..9ff11424 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -299,9 +299,11 @@ "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", + "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", "form_enter_ip": "Enter IP", "form_enter_mac": "Enter MAC", + "form_enter_id": "Enter identifier", + "form_add_id": "Add identifier", "form_client_name": "Enter client name", "client_global_settings": "Use global settings", "client_deleted": "Client \"{{key}}\" successfully deleted", diff --git a/client/src/actions/clients.js b/client/src/actions/clients.js index 3974a38c..b6fcf011 100644 --- a/client/src/actions/clients.js +++ b/client/src/actions/clients.js @@ -2,7 +2,6 @@ import { createAction } from 'redux-actions'; import { t } from 'i18next'; import apiClient from '../api/Api'; import { addErrorToast, addSuccessToast, getClients } from './index'; -import { CLIENT_ID } from '../helpers/constants'; export const toggleClientModal = createAction('TOGGLE_CLIENT_MODAL'); @@ -13,18 +12,7 @@ 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); + await apiClient.addClient(config); dispatch(addClientSuccess()); dispatch(toggleClientModal()); 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) => { 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 } }; - } + const data = { name, data: { ...config } }; await apiClient.updateClient(data); dispatch(updateClientSuccess()); diff --git a/client/src/actions/queryLogs.js b/client/src/actions/queryLogs.js index 35b4f7af..155f0a76 100644 --- a/client/src/actions/queryLogs.js +++ b/client/src/actions/queryLogs.js @@ -2,7 +2,7 @@ import { createAction } from 'redux-actions'; import apiClient from '../api/Api'; 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'; const getLogsWithParams = async (config) => { @@ -10,9 +10,12 @@ const getLogsWithParams = async (config) => { const rawLogs = await apiClient.getQueryLog({ ...filter, older_than }); const { data, oldest } = rawLogs; const logs = normalizeLogs(data); + const clientsParams = getParamsForClientsSearch(logs, 'client'); + const clients = await apiClient.findClients(clientsParams); + const logsWithClientInfo = addClientInfo(logs, clients, 'client'); return { - logs, oldest, older_than, filter, ...values, + logs: logsWithClientInfo, oldest, older_than, filter, ...values, }; }; diff --git a/client/src/actions/stats.js b/client/src/actions/stats.js index d8ab5bf5..25897aab 100644 --- a/client/src/actions/stats.js +++ b/client/src/actions/stats.js @@ -2,7 +2,7 @@ import { createAction } from 'redux-actions'; import apiClient from '../api/Api'; 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 getStatsConfigFailure = createAction('GET_STATS_CONFIG_FAILURE'); @@ -43,11 +43,15 @@ export const getStats = () => async (dispatch) => { dispatch(getStatsRequest()); try { 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 = { ...stats, 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), avg_processing_time: secondsToMilliseconds(stats.avg_processing_time), }; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 470577a8..72d6d527 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -353,6 +353,7 @@ class Api { // Per-client settings GET_CLIENTS = { path: 'clients', method: 'GET' }; + FIND_CLIENTS = { path: 'clients/find', method: 'GET' }; ADD_CLIENT = { path: 'clients/add', method: 'POST' }; DELETE_CLIENT = { path: 'clients/delete', method: 'POST' }; UPDATE_CLIENT = { path: 'clients/update', method: 'POST' }; @@ -389,6 +390,12 @@ class Api { 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 ACCESS_LIST = { path: 'access/list', method: 'GET' }; ACCESS_SET = { path: 'access/set', method: 'POST' }; diff --git a/client/src/components/Dashboard/Clients.js b/client/src/components/Dashboard/Clients.js index ace7ed21..e83addcb 100644 --- a/client/src/components/Dashboard/Clients.js +++ b/client/src/components/Dashboard/Clients.js @@ -28,19 +28,17 @@ const countCell = dnsQueries => return ; }; -const clientCell = (clients, autoClients, t) => +const clientCell = t => function cell(row) { - const { value } = row; - return (
- {formatClientCell(value, clients, autoClients, t)} + {formatClientCell(row, t)}
); }; const Clients = ({ - t, refreshButton, topClients, subtitle, clients, autoClients, dnsQueries, + t, refreshButton, topClients, subtitle, dnsQueries, }) => ( ({ + data={topClients.map(({ name: ip, count, info }) => ({ ip, count, + info, }))} columns={[ { @@ -59,7 +58,7 @@ const Clients = ({ accessor: 'ip', sortMethod: (a, b) => parseInt(a.replace(/\./g, ''), 10) - parseInt(b.replace(/\./g, ''), 10), - Cell: clientCell(clients, autoClients, t), + Cell: clientCell(t), }, { Header: requests_count, diff --git a/client/src/components/Dashboard/index.js b/client/src/components/Dashboard/index.js index 1960a793..b4889db6 100644 --- a/client/src/components/Dashboard/index.js +++ b/client/src/components/Dashboard/index.js @@ -20,7 +20,6 @@ class Dashboard extends Component { getAllStats = () => { this.props.getStats(); this.props.getStatsConfig(); - this.props.getClients(); }; getToggleFilteringButton = () => { @@ -44,7 +43,6 @@ class Dashboard extends Component { const { dashboard, stats, t } = this.props; const dashboardProcessing = dashboard.processing || - dashboard.processingClients || stats.processingStats || stats.processingGetConfig; diff --git a/client/src/components/Logs/index.js b/client/src/components/Logs/index.js index 68b9cc61..3b52f4e4 100644 --- a/client/src/components/Logs/index.js +++ b/client/src/components/Logs/index.js @@ -31,7 +31,6 @@ class Logs extends Component { this.props.setLogsPage(TABLE_FIRST_PAGE); this.getLogs(...INITIAL_REQUEST_DATA); this.props.getFilteringStatus(); - this.props.getClients(); this.props.getLogsConfig(); } @@ -191,9 +190,9 @@ class Logs extends Component { ); }; - getClientCell = ({ original, value }) => { - const { dashboard, t } = this.props; - const { clients, autoClients } = dashboard; + getClientCell = (row) => { + const { original } = row; + const { t } = this.props; const { reason, domain } = original; const isFiltered = this.checkFiltered(reason); const isRewrite = this.checkRewrite(reason); @@ -201,7 +200,7 @@ class Logs extends Component { return (
- {formatClientCell(value, clients, autoClients, t)} + {formatClientCell(row, t)}
{isRewrite ? (
@@ -232,12 +231,11 @@ class Logs extends Component { }; renderLogs() { - const { queryLogs, dashboard, t } = this.props; - const { processingClients } = dashboard; + const { queryLogs, t } = this.props; const { processingGetLogs, processingGetConfig, logs, pages, page, } = queryLogs; - const isLoading = processingGetLogs || processingClients || processingGetConfig; + const isLoading = processingGetLogs || processingGetConfig; const columns = [ { diff --git a/client/src/components/Settings/Clients/ClientsTable.js b/client/src/components/Settings/Clients/ClientsTable.js index de1c166f..50bb48ab 100644 --- a/client/src/components/Settings/Clients/ClientsTable.js +++ b/client/src/components/Settings/Clients/ClientsTable.js @@ -3,7 +3,7 @@ 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 { MODAL_TYPE } from '../../../helpers/constants'; import Card from '../../ui/Card'; import Modal from './Modal'; import WrapCell from './WrapCell'; @@ -40,10 +40,7 @@ class ClientsTable extends Component { 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, use_global_blocked_services: true, ...client, @@ -51,7 +48,7 @@ class ClientsTable extends Component { } return { - identifier: CLIENT_ID.IP, + ids: [''], use_global_settings: true, use_global_blocked_services: true, }; @@ -76,28 +73,22 @@ class ClientsTable extends Component { columns = [ { Header: this.props.t('table_client'), - accessor: 'ip', + accessor: 'ids', minWidth: 150, Cell: (row) => { - if (row.original && row.original.mac) { - return ( -
- - {row.original.mac} (MAC) - -
- ); - } else if (row.value) { - return ( -
- - {row.value} (IP) - -
- ); - } + const { value } = row; - return ''; + return ( +
+ + {value.map(address => ( +
+ {address} +
+ ))} +
+
+ ); }, }, { @@ -119,9 +110,7 @@ class ClientsTable extends Component { return (
-
- {title} -
+
{title}
); }, diff --git a/client/src/components/Settings/Clients/Form.js b/client/src/components/Settings/Clients/Form.js index 2bf6679f..6e3ceced 100644 --- a/client/src/components/Settings/Clients/Form.js +++ b/client/src/components/Settings/Clients/Form.js @@ -1,14 +1,20 @@ import React from 'react'; import { connect } from 'react-redux'; 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 flow from 'lodash/flow'; +import i18n from '../../../i18n'; import Tabs from '../../ui/Tabs'; import { toggleAllServices } from '../../../helpers/helpers'; -import { renderField, renderRadioField, renderSelectField, renderServiceField, ip, mac, required } from '../../../helpers/form'; -import { CLIENT_ID, SERVICES } from '../../../helpers/constants'; +import { + renderField, + renderGroupField, + renderSelectField, + renderServiceField, +} from '../../../helpers/form'; +import { SERVICES } from '../../../helpers/constants'; import './Service.css'; 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 ( +
+ {fields.map((ip, index) => ( +
+ fields.remove(index)} + /> +
+ ))} + + {error &&
{error}
} +
+ ); + }; + let Form = (props) => { const { t, @@ -42,92 +109,53 @@ let Form = (props) => { change, pristine, submitting, - clientIdentifier, useGlobalSettings, useGlobalServices, toggleClientModal, processingAdding, processingUpdating, + invalid, } = props; return (
-
-
- - client_identifier - -
- - +
+
+ +
+ +
+
+ + client_identifier + +
+
+ + link + , + ]} + > + client_identifier_desc +
-
-
- {clientIdentifier === CLIENT_ID.IP && ( -
- -
- )} - {clientIdentifier === CLIENT_ID.MAC && ( -
- -
- )} -
-
- -
-
-
- - link - , - ]} - > - client_identifier_desc - + +
+
@@ -140,7 +168,11 @@ let Form = (props) => { type="checkbox" component={renderSelectField} placeholder={t(setting.placeholder)} - disabled={setting.name !== 'use_global_settings' ? useGlobalSettings : false} + disabled={ + setting.name !== 'use_global_settings' + ? useGlobalSettings + : false + } />
))} @@ -210,7 +242,13 @@ let Form = (props) => { @@ -227,22 +265,20 @@ Form.propTypes = { change: PropTypes.func.isRequired, submitting: PropTypes.bool.isRequired, toggleClientModal: PropTypes.func.isRequired, - clientIdentifier: PropTypes.string, useGlobalSettings: PropTypes.bool, useGlobalServices: PropTypes.bool, t: PropTypes.func.isRequired, processingAdding: PropTypes.bool.isRequired, processingUpdating: PropTypes.bool.isRequired, + invalid: PropTypes.bool.isRequired, }; const selector = formValueSelector('clientForm'); Form = connect((state) => { - const clientIdentifier = selector(state, 'identifier'); const useGlobalSettings = selector(state, 'use_global_settings'); const useGlobalServices = selector(state, 'use_global_blocked_services'); return { - clientIdentifier, useGlobalSettings, useGlobalServices, }; @@ -253,5 +289,6 @@ export default flow([ reduxForm({ form: 'clientForm', enableReinitialize: true, + validate, }), ])(Form); diff --git a/client/src/components/ui/Icons.css b/client/src/components/ui/Icons.css index 17d608fd..da2c5f4e 100644 --- a/client/src/components/ui/Icons.css +++ b/client/src/components/ui/Icons.css @@ -3,3 +3,8 @@ vertical-align: middle; height: 100%; } + +.icon--close { + width: 24px; + height: 24px; +} diff --git a/client/src/components/ui/Icons.js b/client/src/components/ui/Icons.js index 100e74de..24d05e65 100644 --- a/client/src/components/ui/Icons.js +++ b/client/src/components/ui/Icons.js @@ -167,6 +167,14 @@ const Icons = () => ( + + + + + + + + ); diff --git a/client/src/helpers/form.js b/client/src/helpers/form.js index fc0286a1..55e0b0a0 100644 --- a/client/src/helpers/form.js +++ b/client/src/helpers/form.js @@ -29,6 +29,50 @@ export const renderField = ({ ); +export const renderGroupField = ({ + input, + id, + className, + placeholder, + type, + disabled, + autoComplete, + isActionAvailable, + removeField, + meta: { touched, error }, +}) => ( + +
+ + {isActionAvailable && + + + + } +
+ + {!disabled && + touched && + (error && {error})} +
+); + export const renderRadioField = ({ input, placeholder, disabled, meta: { touched, error }, }) => ( @@ -102,6 +146,7 @@ export const renderServiceField = ({ ); +// Validation functions export const required = (value) => { if (value || value === 0) { return false; diff --git a/client/src/helpers/formatClientCell.js b/client/src/helpers/formatClientCell.js index 931210a7..c5626061 100644 --- a/client/src/helpers/formatClientCell.js +++ b/client/src/helpers/formatClientCell.js @@ -1,5 +1,5 @@ import React, { Fragment } from 'react'; -import { getClientInfo, normalizeWhois } from './helpers'; +import { normalizeWhois } from './helpers'; import { WHOIS_ICONS } from './constants'; const getFormattedWhois = (whois, t) => { @@ -22,26 +22,29 @@ const getFormattedWhois = (whois, t) => { ); }; -export const formatClientCell = (value, clients, autoClients, t) => { - const clientInfo = getClientInfo(clients, value) || getClientInfo(autoClients, value); - const { name, whois } = clientInfo; +export const formatClientCell = (row, t) => { + const { value, original: { info } } = row; let whoisContainer = ''; let nameContainer = value; - if (name) { - nameContainer = ( - - {name} ({value}) - - ); - } + if (info) { + const { name, whois } = info; - if (whois) { - whoisContainer = ( -
- {getFormattedWhois(whois, t)} -
- ); + if (name) { + nameContainer = ( + + {name} ({value}) + + ); + } + + if (whois) { + whoisContainer = ( +
+ {getFormattedWhois(whois, t)} +
+ ); + } } return ( diff --git a/client/src/helpers/helpers.js b/client/src/helpers/helpers.js index ced2e9ad..089f0604 100644 --- a/client/src/helpers/helpers.js +++ b/client/src/helpers/helpers.js @@ -8,6 +8,7 @@ import subDays from 'date-fns/sub_days'; import round from 'lodash/round'; import axios from 'axios'; import i18n from 'i18next'; +import uniqBy from 'lodash/uniqBy'; import versionCompare from './versionCompare'; 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) => { const { 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 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); if (!client) { @@ -328,3 +354,13 @@ export const getPathWithQueryString = (path, params) => { 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; + }, {}); +}; diff --git a/dhcpd/dhcpd.go b/dhcpd/dhcpd.go index 3163c753..1231063f 100644 --- a/dhcpd/dhcpd.go +++ b/dhcpd/dhcpd.go @@ -684,6 +684,21 @@ func (s *Server) FindIPbyMAC(mac net.HardwareAddr) net.IP { 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 func (s *Server) reset() { s.leasesLock.Lock() diff --git a/home/clients.go b/home/clients.go index eacdadb9..2b92c4d2 100644 --- a/home/clients.go +++ b/home/clients.go @@ -1,11 +1,10 @@ package home import ( - "encoding/json" + "bytes" "fmt" "io/ioutil" "net" - "net/http" "os" "os/exec" "runtime" @@ -23,8 +22,7 @@ const ( // Client information type Client struct { - IP string - MAC string + IDs []string Name string UseOwnSettings bool // false: use global settings FilteringEnabled bool @@ -37,22 +35,6 @@ type Client struct { 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 // Client sources @@ -74,24 +56,79 @@ type ClientHost struct { type clientsContainer struct { list map[string]*Client // name -> client - ipIndex map[string]*Client // IP -> client + idIndex map[string]*Client // IP -> client ipHost map[string]*ClientHost // IP -> Hostname lock sync.Mutex } // Init initializes clients container // Note: this function must be called only once -func (clients *clientsContainer) Init() { +func (clients *clientsContainer) Init(objects []clientObject) { if clients.list != nil { log.Fatal("clients.list != nil") } 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.addFromConfig(objects) 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() { for { clients.addFromHostsFile() @@ -111,7 +148,7 @@ func (clients *clientsContainer) Exists(ip string, source clientSource) bool { clients.lock.Lock() defer clients.lock.Unlock() - _, ok := clients.ipIndex[ip] + _, ok := clients.idIndex[ip] if ok { return true } @@ -128,25 +165,42 @@ func (clients *clientsContainer) Exists(ip string, source clientSource) bool { // Find searches for a client by IP func (clients *clientsContainer) Find(ip string) (Client, bool) { + ipAddr := net.ParseIP(ip) + if ipAddr == nil { + return Client{}, false + } + clients.lock.Lock() defer clients.lock.Unlock() - c, ok := clients.ipIndex[ip] + c, ok := clients.idIndex[ip] if ok { return *c, true } for _, c = range clients.list { - if len(c.MAC) != 0 { - mac, err := net.ParseMAC(c.MAC) + for _, id := range c.IDs { + _, ipnet, err := net.ParseCIDR(id) if err != nil { continue } - ipAddr := config.dhcpServer.FindIPbyMAC(mac) - if ipAddr == nil { + if ipnet.Contains(ipAddr) { + 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 } - if ip == ipAddr.String() { + if bytes.Equal(hwAddr, macFound) { return *c, true } } @@ -155,28 +209,51 @@ func (clients *clientsContainer) Find(ip string) (Client, bool) { 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 func (c *Client) check() 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.IDs) == 0 { + return fmt.Errorf("ID required") } - if len(c.IP) != 0 { - ip := net.ParseIP(c.IP) - if ip == nil { - return fmt.Errorf("Invalid IP") + for i, id := range c.IDs { + ip := net.ParseIP(id) + if ip != nil { + c.IDs[i] = ip.String() // normalize IP address + continue } - c.IP = ip.String() - } else { - _, err := net.ParseMAC(c.MAC) - if err != nil { - return fmt.Errorf("Invalid MAC: %s", err) + + _, _, err := net.ParseCIDR(id) + if err == nil { + continue } + + _, err = net.ParseMAC(id) + if err == nil { + continue + } + + return fmt.Errorf("Invalid ID: %s", id) } return nil } @@ -198,26 +275,34 @@ func (clients *clientsContainer) Add(c Client) (bool, error) { return false, nil } - // check IP index - if len(c.IP) != 0 { - c2, ok := clients.ipIndex[c.IP] + // check ID index + for _, id := range c.IDs { + c2, ok := clients.idIndex[id] 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] - if ok { - c.WhoisInfo = ch.WhoisInfo - delete(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 len(c.WhoisInfo) == 0 { + c.WhoisInfo = ch.WhoisInfo + } + delete(clients.ipHost, id) + } } + // update Name index 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 } @@ -231,8 +316,26 @@ func (clients *clientsContainer) Del(name string) bool { return false } + // update Name index 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 } @@ -260,27 +363,30 @@ func (clients *clientsContainer) Update(name string, c Client) error { } // 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) + if !arraysEqual(old.IDs, c.IDs) { + for _, id := range c.IDs { + c2, ok := clients.idIndex[id] + 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 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 + clients.list[c.Name] = old } + *old = c return nil } @@ -289,7 +395,7 @@ func (clients *clientsContainer) SetWhoisInfo(ip string, info [][]string) { clients.lock.Lock() defer clients.lock.Unlock() - c, ok := clients.ipIndex[ip] + c, ok := clients.idIndex[ip] if ok { c.WhoisInfo = info 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() // check index - _, ok := clients.ipIndex[ip] + _, ok := clients.idIndex[ip] if ok { return false, nil } @@ -440,210 +546,3 @@ func (clients *clientsContainer) addFromDHCP() { } 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) -} diff --git a/home/clients_http.go b/home/clients_http.go new file mode 100644 index 00000000..dbfbf873 --- /dev/null +++ b/home/clients_http.go @@ -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) +} diff --git a/home/clients_test.go b/home/clients_test.go index f535d69f..70493a19 100644 --- a/home/clients_test.go +++ b/home/clients_test.go @@ -12,11 +12,11 @@ func TestClients(t *testing.T) { var b bool clients := clientsContainer{} - clients.Init() + clients.Init(nil) // add c = Client{ - IP: "1.1.1.1", + IDs: []string{"1.1.1.1", "aa:aa:aa:aa:aa:aa"}, Name: "client1", } b, e = clients.Add(c) @@ -26,7 +26,7 @@ func TestClients(t *testing.T) { // add #2 c = Client{ - IP: "2.2.2.2", + IDs: []string{"2.2.2.2"}, Name: "client2", } b, e = clients.Add(c) @@ -46,7 +46,7 @@ func TestClients(t *testing.T) { // failed add - name in use c = Client{ - IP: "1.2.3.5", + IDs: []string{"1.2.3.5"}, Name: "client1", } b, _ = clients.Add(c) @@ -56,7 +56,7 @@ func TestClients(t *testing.T) { // failed add - ip in use c = Client{ - IP: "2.2.2.2", + IDs: []string{"2.2.2.2"}, Name: "client3", } b, e = clients.Add(c) @@ -70,35 +70,45 @@ func TestClients(t *testing.T) { assert.True(t, clients.Exists("2.2.2.2", ClientSourceHostsFile)) // failed update - no such name - c.IP = "1.2.3.0" + c.IDs = []string{"1.2.3.0"} c.Name = "client3" if clients.Update("client3", c) == nil { t.Fatalf("Update") } // failed update - name in use - c.IP = "1.2.3.0" + c.IDs = []string{"1.2.3.0"} c.Name = "client2" if clients.Update("client1", c) == nil { t.Fatalf("Update - name in use") } // failed update - ip in use - c.IP = "2.2.2.2" + c.IDs = []string{"2.2.2.2"} c.Name = "client1" if clients.Update("client1", c) == nil { t.Fatalf("Update - ip in use") } // update - c.IP = "1.1.1.2" + c.IDs = []string{"1.1.1.2"} c.Name = "client1" if clients.Update("client1", c) != nil { t.Fatalf("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 if clients.Del("client3") { @@ -106,7 +116,7 @@ func TestClients(t *testing.T) { } // 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 b, e = clients.AddHost("1.1.1.1", "host", ClientSourceARP) @@ -139,7 +149,7 @@ func TestClients(t *testing.T) { func TestClientsWhois(t *testing.T) { var c Client clients := clientsContainer{} - clients.Init() + clients.Init(nil) whois := [][]string{{"orgname", "orgname-val"}, {"country", "country-val"}} // set whois info on new client @@ -153,11 +163,11 @@ func TestClientsWhois(t *testing.T) { // set whois info on existing client c = Client{ - IP: "1.1.1.2", + IDs: []string{"1.1.1.2"}, Name: "client1", } _, _ = clients.Add(c) 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") } diff --git a/home/config.go b/home/config.go index 8c53574a..c7bbb269 100644 --- a/home/config.go +++ b/home/config.go @@ -30,20 +30,6 @@ type logSettings struct { 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 { server *http.Server cond *sync.Cond // reacts to config.TLS.Enabled, PortHTTPS, CertificateChain and PrivateKey @@ -285,27 +271,6 @@ func parseConfig() error { 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{} if !tlsLoadConfig(&config.TLS, &status) { log.Error("%s", status.WarningValidation) @@ -335,27 +300,7 @@ func (c *configuration) write() error { c.Lock() defer c.Unlock() - clientsList := config.clients.GetList() - 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) - } + config.clients.WriteDiskConfig(&config.Clients) if config.auth != nil { config.Users = config.auth.GetUsers() diff --git a/home/home.go b/home/home.go index 1569e796..cf407a00 100644 --- a/home/home.go +++ b/home/home.go @@ -98,7 +98,6 @@ func run(args options) { }() initConfig() - config.clients.Init() initServices() 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") && config.RlimitNoFile != 0 { setRlimit(config.RlimitNoFile) @@ -370,11 +372,13 @@ func cleanup() { // Stop HTTP server, possibly waiting for all active connections to be closed func stopHTTPServer() { + log.Info("Stopping HTTP server...") config.httpsServer.shutdown = true if config.httpsServer.server != nil { config.httpsServer.server.Shutdown(context.TODO()) } config.httpServer.Shutdown(context.TODO()) + log.Info("Stopped HTTP server") } // This function is called before application exits diff --git a/home/upgrade.go b/home/upgrade.go index 9445e8b1..3a703ebc 100644 --- a/home/upgrade.go +++ b/home/upgrade.go @@ -11,7 +11,7 @@ import ( 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 func upgradeConfig() error { @@ -82,6 +82,12 @@ func upgradeConfigSchema(oldVersion int, diskConfig *map[string]interface{}) err if err != nil { return err } + fallthrough + case 5: + err := upgradeSchema5to6(diskConfig) + if err != nil { + return err + } default: err := fmt.Errorf("configuration file contains unknown schema_version, abort") log.Println(err) @@ -268,3 +274,72 @@ func upgradeSchema4to5(diskConfig *map[string]interface{}) error { (*diskConfig)["users"] = users 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 +} diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md index 3172be07..c6e79b8a 100644 --- a/openapi/CHANGELOG.md +++ b/openapi/CHANGELOG.md @@ -1,6 +1,95 @@ # 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 ### API: Get query log: GET /control/querylog diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 406194b2..6514922c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -772,6 +772,22 @@ paths: 200: 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: get: @@ -1589,16 +1605,15 @@ definitions: type: "object" description: "Client information" properties: - ip: - type: "string" - description: "IP address" - example: "127.0.0.1" name: type: "string" description: "Name" example: "localhost" - mac: - type: "string" + ids: + type: "array" + description: "IP, CIDR or MAC address" + items: + type: "string" use_global_settings: type: "boolean" filtering_enabled: @@ -1645,6 +1660,20 @@ definitions: properties: name: 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: type: "object" properties: