Pull request: 3419 client allowlist collision

Updates #3419.

Squashed commit of the following:

commit 370094c00d9c15b1336fbedb1e233bd4436c9898
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Fri Sep 10 17:31:16 2021 +0300

    added link to github issue

commit 407ba9b2db46b887a30ddb081bd37c56e56b0496
Merge: 426c8146 80548233
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Fri Sep 10 17:29:52 2021 +0300

    Merge branch 'master' into 3419-client-allowlist-collision

commit 426c8146cff5c112ebb25192af276c6601200528
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Fri Sep 10 16:28:11 2021 +0300

    fix en

commit d28c6022321828c6bdc55c3f9a4f655b26d146d2
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Fri Sep 10 15:49:12 2021 +0300

    added missing space

commit b374a09327968ca5343c1595d1ab8cf317c15ffe
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Fri Sep 10 15:43:55 2021 +0300

    fixes after review

commit 2be629d66e4703e2f5a85615bf1eaaa92e03c6fd
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Thu Sep 9 14:17:19 2021 +0300

    fixes

commit 5c2aa6201cc0ecf404d4057e354fbb0bdadcdd6d
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Wed Sep 8 15:04:30 2021 +0300

    return empty line to locale file

commit 3631c3772babbd595b1c3de4a7e91be6bac3e80f
Author: Dmitriy Seregin <d.seregin@adguard.com>
Date:   Wed Sep 8 13:57:51 2021 +0300

    all: fix collisions in access lists && expand block/unblock client
This commit is contained in:
Dmitry Seregin 2021-09-10 17:57:09 +03:00 committed by Ainar Garipov
parent 80548233ba
commit 8fdd789474
13 changed files with 238 additions and 65 deletions

View File

@ -46,6 +46,8 @@ and this project adheres to
### Changed ### Changed
- Items in allowed clients, disallowed clients, and blocked hosts lists must
be unique ([#3419]).
- The TLS private key previously saved as a string isn't shown in API responses - The TLS private key previously saved as a string isn't shown in API responses
any more ([#1898]). any more ([#1898]).
- Better OpenWrt detection ([#3435]). - Better OpenWrt detection ([#3435]).
@ -178,6 +180,7 @@ In this release, the schema version has changed from 10 to 12.
[#3351]: https://github.com/AdguardTeam/AdGuardHome/issues/3351 [#3351]: https://github.com/AdguardTeam/AdGuardHome/issues/3351
[#3372]: https://github.com/AdguardTeam/AdGuardHome/issues/3372 [#3372]: https://github.com/AdguardTeam/AdGuardHome/issues/3372
[#3417]: https://github.com/AdguardTeam/AdGuardHome/issues/3417 [#3417]: https://github.com/AdguardTeam/AdGuardHome/issues/3417
[#3419]: https://github.com/AdguardTeam/AdGuardHome/issues/3419
[#3435]: https://github.com/AdguardTeam/AdGuardHome/issues/3435 [#3435]: https://github.com/AdguardTeam/AdGuardHome/issues/3435
[#3437]: https://github.com/AdguardTeam/AdGuardHome/issues/3437 [#3437]: https://github.com/AdguardTeam/AdGuardHome/issues/3437
[#3443]: https://github.com/AdguardTeam/AdGuardHome/issues/3443 [#3443]: https://github.com/AdguardTeam/AdGuardHome/issues/3443

View File

@ -613,7 +613,8 @@
"click_to_view_queries": "Click to view queries", "click_to_view_queries": "Click to view queries",
"port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction</0> on how to resolve this.", "port_53_faq_link": "Port 53 is often occupied by \"DNSStubListener\" or \"systemd-resolved\" services. Please read <0>this instruction</0> on how to resolve this.",
"adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client.", "adg_will_drop_dns_queries": "AdGuard Home will be dropping all DNS queries from this client.",
"client_not_in_allowed_clients": "The client is not allowed because it is not in the \"Allowed clients\" list.", "filter_allowlist": "WARNING: This action also will exclude the rule \"{{disallowed_rule}}\" from the list of allowed clients.",
"last_rule_in_allowlist": "Cannot disallow this client because excluding the rule \"{{disallowed_rule}}\" will DISABLE \"Allowed clients\" list.",
"experimental": "Experimental", "experimental": "Experimental",
"use_saved_key": "Use the previously saved key" "use_saved_key": "Use the previously saved key"
} }

View File

@ -52,25 +52,34 @@ export const toggleClientBlock = (ip, disallowed, disallowed_rule) => async (dis
dispatch(toggleClientBlockRequest()); dispatch(toggleClientBlockRequest());
try { try {
const accessList = await apiClient.getAccessList(); const accessList = await apiClient.getAccessList();
const allowed_clients = accessList.allowed_clients ?? [];
const blocked_hosts = accessList.blocked_hosts ?? []; const blocked_hosts = accessList.blocked_hosts ?? [];
const disallowed_clients = accessList.disallowed_clients ?? []; let allowed_clients = accessList.allowed_clients ?? [];
let disallowed_clients = accessList.disallowed_clients ?? [];
const updatedDisallowedClients = disallowed
? disallowed_clients.filter((client) => client !== disallowed_rule)
: disallowed_clients.concat(ip);
if (disallowed) {
if (!disallowed_rule) {
allowed_clients = allowed_clients.concat(ip);
} else {
disallowed_clients = disallowed_clients
.filter((client) => client !== disallowed_rule);
}
} else if (allowed_clients.length > 1) {
allowed_clients = allowed_clients
.filter((client) => client !== disallowed_rule);
} else {
disallowed_clients = disallowed_clients.concat(ip);
}
const values = { const values = {
allowed_clients, allowed_clients,
blocked_hosts, blocked_hosts,
disallowed_clients: updatedDisallowedClients, disallowed_clients,
}; };
await apiClient.setAccessList(values); await apiClient.setAccessList(values);
dispatch(toggleClientBlockSuccess(values)); dispatch(toggleClientBlockSuccess(values));
if (disallowed) { if (disallowed) {
dispatch(addSuccessToast(i18next.t('client_unblocked', { ip: disallowed_rule }))); dispatch(addSuccessToast(i18next.t('client_unblocked', { ip: disallowed_rule || ip })));
} else { } else {
dispatch(addSuccessToast(i18next.t('client_blocked', { ip }))); dispatch(addSuccessToast(i18next.t('client_blocked', { ip })));
} }

View File

@ -38,15 +38,23 @@ const renderBlockingButton = (ip, disallowed, disallowed_rule) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const processingSet = useSelector((state) => state.access.processingSet); const processingSet = useSelector((state) => state.access.processingSet);
const allowedСlients = useSelector((state) => state.access.allowed_clients, shallowEqual);
const buttonClass = classNames('button-action button-action--main', { const buttonClass = classNames('button-action button-action--main', {
'button-action--unblock': disallowed, 'button-action--unblock': disallowed,
}); });
const toggleClientStatus = async (ip, disallowed, disallowed_rule) => { const toggleClientStatus = async (ip, disallowed, disallowed_rule) => {
const confirmMessage = disallowed let confirmMessage;
? t('client_confirm_unblock', { ip: disallowed_rule })
: `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}`; if (disallowed) {
confirmMessage = t('client_confirm_unblock', { ip: disallowed_rule || ip });
} else {
confirmMessage = `${t('adg_will_drop_dns_queries')} ${t('client_confirm_block', { ip })}`;
if (allowedСlients.length > 0) {
confirmMessage = confirmMessage.concat(`\n\n${t('filter_allowlist', { disallowed_rule })}`);
}
}
if (window.confirm(confirmMessage)) { if (window.confirm(confirmMessage)) {
await dispatch(toggleClientBlock(ip, disallowed, disallowed_rule)); await dispatch(toggleClientBlock(ip, disallowed, disallowed_rule));
@ -58,15 +66,16 @@ const renderBlockingButton = (ip, disallowed, disallowed_rule) => {
const text = disallowed ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK; const text = disallowed ? BLOCK_ACTIONS.UNBLOCK : BLOCK_ACTIONS.BLOCK;
const isNotInAllowedList = disallowed && disallowed_rule === ''; const lastRuleInAllowlist = !disallowed && allowedСlients === disallowed_rule;
const disabled = processingSet || lastRuleInAllowlist;
return ( return (
<div className="table__action pl-4"> <div className="table__action pl-4">
<button <button
type="button" type="button"
className={buttonClass} className={buttonClass}
onClick={isNotInAllowedList ? undefined : onClick} onClick={onClick}
disabled={isNotInAllowedList || processingSet} disabled={disabled}
title={t(isNotInAllowedList ? 'client_not_in_allowed_clients' : text)} title={lastRuleInAllowlist ? t('last_rule_in_allowlist', { disallowed_rule }) : ''}
> >
<Trans>{text}</Trans> <Trans>{text}</Trans>
</button> </button>

View File

@ -28,6 +28,8 @@ const ClientCell = ({
const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual); const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
const processingRules = useSelector((state) => state.filtering.processingRules); const processingRules = useSelector((state) => state.filtering.processingRules);
const isDetailed = useSelector((state) => state.queryLogs.isDetailed); const isDetailed = useSelector((state) => state.queryLogs.isDetailed);
const processingSet = useSelector((state) => state.access.processingSet);
const allowedСlients = useSelector((state) => state.access.allowed_clients, shallowEqual);
const [isOptionsOpened, setOptionsOpened] = useState(false); const [isOptionsOpened, setOptionsOpened] = useState(false);
const autoClient = autoClients.find((autoClient) => autoClient.name === client); const autoClient = autoClients.find((autoClient) => autoClient.name === client);
@ -71,11 +73,12 @@ const ClientCell = ({
const { const {
confirmMessage, confirmMessage,
buttonKey: blockingClientKey, buttonKey: blockingClientKey,
isNotInAllowedList, lastRuleInAllowlist,
} = getBlockClientInfo( } = getBlockClientInfo(
client, client,
client_info?.disallowed || false, client_info?.disallowed || false,
client_info?.disallowed_rule || '', client_info?.disallowed_rule || '',
allowedСlients,
); );
const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only'; const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
@ -100,7 +103,7 @@ const ClientCell = ({
await dispatch(updateLogs()); await dispatch(updateLogs());
} }
}, },
disabled: isNotInAllowedList, disabled: processingSet || lastRuleInAllowlist,
}, },
]; ];

View File

@ -2,17 +2,24 @@ import i18next from 'i18next';
export const BUTTON_PREFIX = 'btn_'; export const BUTTON_PREFIX = 'btn_';
export const getBlockClientInfo = (ip, disallowed, disallowed_rule) => { export const getBlockClientInfo = (ip, disallowed, disallowed_rule, allowedСlients) => {
const confirmMessage = disallowed let confirmMessage;
? i18next.t('client_confirm_unblock', { ip: disallowed_rule })
: `${i18next.t('adg_will_drop_dns_queries')} ${i18next.t('client_confirm_block', { ip })}`; if (disallowed) {
confirmMessage = i18next.t('client_confirm_unblock', { ip: disallowed_rule || ip });
} else {
confirmMessage = `${i18next.t('adg_will_drop_dns_queries')} ${i18next.t('client_confirm_block', { ip })}`;
if (allowedСlients.length > 0) {
confirmMessage = confirmMessage.concat(`\n\n${i18next.t('filter_allowlist', { disallowed_rule })}`);
}
}
const buttonKey = i18next.t(disallowed ? 'allow_this_client' : 'disallow_this_client'); const buttonKey = i18next.t(disallowed ? 'allow_this_client' : 'disallow_this_client');
const isNotInAllowedList = disallowed && disallowed_rule === ''; const lastRuleInAllowlist = !disallowed && allowedСlients === disallowed_rule;
return { return {
confirmMessage, confirmMessage,
buttonKey, buttonKey,
isNotInAllowedList, lastRuleInAllowlist,
}; };
}; };

View File

@ -50,6 +50,8 @@ const Row = memo(({
const filters = useSelector((state) => state.filtering.filters, shallowEqual); const filters = useSelector((state) => state.filtering.filters, shallowEqual);
const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual); const whitelistFilters = useSelector((state) => state.filtering.whitelistFilters, shallowEqual);
const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual); const autoClients = useSelector((state) => state.dashboard.autoClients, shallowEqual);
const processingSet = useSelector((state) => state.access.processingSet);
const allowedСlients = useSelector((state) => state.access.allowed_clients, shallowEqual);
const clients = useSelector((state) => state.dashboard.clients); const clients = useSelector((state) => state.dashboard.clients);
@ -104,11 +106,12 @@ const Row = memo(({
const { const {
confirmMessage, confirmMessage,
buttonKey: blockingClientKey, buttonKey: blockingClientKey,
isNotInAllowedList, lastRuleInAllowlist,
} = getBlockClientInfo( } = getBlockClientInfo(
client, client,
client_info?.disallowed || false, client_info?.disallowed || false,
client_info?.disallowed_rule || '', client_info?.disallowed_rule || '',
allowedСlients,
); );
const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only'; const blockingForClientKey = isFiltered ? 'unblock_for_this_client_only' : 'block_for_this_client_only';
@ -147,7 +150,7 @@ const Row = memo(({
const blockClientButton = <button const blockClientButton = <button
className='text-center font-weight-bold py-2 button-action--arrow-option' className='text-center font-weight-bold py-2 button-action--arrow-option'
onClick={onBlockingClientClick} onClick={onBlockingClientClick}
disabled={isNotInAllowedList}> disabled={processingSet || lastRuleInAllowlist}>
{t(blockingClientKey)} {t(blockingClientKey)}
</button>; </button>;

View File

@ -15,6 +15,7 @@ import Disabled from './Disabled';
import { getFilteringStatus } from '../../actions/filtering'; import { getFilteringStatus } from '../../actions/filtering';
import { getClients } from '../../actions'; import { getClients } from '../../actions';
import { getDnsConfig } from '../../actions/dnsConfig'; import { getDnsConfig } from '../../actions/dnsConfig';
import { getAccessList } from '../../actions/access';
import { import {
getLogsConfig, getLogsConfig,
resetFilteredLogs, resetFilteredLogs,
@ -126,6 +127,7 @@ const Logs = () => {
await Promise.all([ await Promise.all([
dispatch(getLogsConfig()), dispatch(getLogsConfig()),
dispatch(getDnsConfig()), dispatch(getDnsConfig()),
dispatch(getAccessList()),
]); ]);
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Field, reduxForm } from 'redux-form'; import { connect } from 'react-redux';
import { Field, reduxForm, formValueSelector } from 'redux-form';
import { Trans, withTranslation } from 'react-i18next'; import { Trans, withTranslation } from 'react-i18next';
import flow from 'lodash/flow'; import flow from 'lodash/flow';
import { renderTextareaField } from '../../../../helpers/form'; import { renderTextareaField } from '../../../../helpers/form';
@ -31,16 +32,20 @@ const fields = [
}, },
]; ];
const Form = (props) => { let Form = (props) => {
const { const {
handleSubmit, submitting, invalid, processingSet, allowedClients, handleSubmit, submitting, invalid, processingSet,
} = props; } = props;
const renderField = ({ const renderField = ({
id, title, subtitle, disabled = processingSet, normalizeOnBlur, id, title, subtitle, disabled = false, processingSet, normalizeOnBlur,
}) => <div key={id} className="form__group mb-5"> }) => <div key={id} className="form__group mb-5">
<label className="form__label form__label--with-desc" htmlFor={id}> <label className="form__label form__label--with-desc" htmlFor={id}>
<Trans>{title}</Trans> <Trans>{title}</Trans>
{disabled && <>
<span> </span>
(<Trans>disabled</Trans>)
</>}
</label> </label>
<div className="form__desc form__desc--top"> <div className="form__desc form__desc--top">
<Trans>{subtitle}</Trans> <Trans>{subtitle}</Trans>
@ -51,7 +56,7 @@ const Form = (props) => {
component={renderTextareaField} component={renderTextareaField}
type="text" type="text"
className="form-control form-control--textarea font-monospace" className="form-control form-control--textarea font-monospace"
disabled={disabled} disabled={disabled || processingSet}
normalizeOnBlur={normalizeOnBlur} normalizeOnBlur={normalizeOnBlur}
/> />
</div>; </div>;
@ -66,7 +71,15 @@ const Form = (props) => {
return ( return (
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
{fields.map(renderField)} {
fields.map((f) => {
const props = { ...f };
if (allowedClients && f.id === 'disallowed_clients') {
props.disabled = true;
}
return renderField(props);
})
}
<div className="card-actions"> <div className="card-actions">
<div className="btn-list"> <div className="btn-list">
<button <button
@ -90,6 +103,21 @@ Form.propTypes = {
processingSet: PropTypes.bool.isRequired, processingSet: PropTypes.bool.isRequired,
t: PropTypes.func.isRequired, t: PropTypes.func.isRequired,
textarea: PropTypes.bool, textarea: PropTypes.bool,
allowedClients: PropTypes.string,
}; };
export default flow([withTranslation(), reduxForm({ form: FORM_NAME.ACCESS })])(Form); const selector = formValueSelector(FORM_NAME.ACCESS);
Form = connect((state) => {
const allowedClients = selector(state, 'allowed_clients');
return {
allowedClients,
};
})(Form);
export default flow([
withTranslation(),
reduxForm({
form: FORM_NAME.ACCESS,
}),
])(Form);

View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/AdguardTeam/golibs/errors"
"github.com/AdguardTeam/golibs/log" "github.com/AdguardTeam/golibs/log"
"github.com/AdguardTeam/golibs/netutil" "github.com/AdguardTeam/golibs/netutil"
"github.com/AdguardTeam/golibs/stringutil" "github.com/AdguardTeam/golibs/stringutil"
@ -192,6 +193,60 @@ func (s *Server) handleAccessList(w http.ResponseWriter, r *http.Request) {
} }
} }
func isUniq(slice []string) (ok bool, uniqueMap map[string]unit) {
exists := make(map[string]unit)
for _, key := range slice {
if _, has := exists[key]; has {
return false, nil
}
exists[key] = unit{}
}
return true, exists
}
func intersect(mapA, mapB map[string]unit) bool {
for key := range mapA {
if _, has := mapB[key]; has {
return true
}
}
return false
}
// validateAccessSet checks the internal accessListJSON lists. To search for
// duplicates, we cannot compare the new stringutil.Set and []string, because
// creating a set for a large array can be an unnecessary algorithmic complexity
func validateAccessSet(list accessListJSON) (err error) {
const (
errAllowedDup errors.Error = "duplicates in allowed clients"
errDisallowedDup errors.Error = "duplicates in disallowed clients"
errBlockedDup errors.Error = "duplicates in blocked hosts"
errIntersect errors.Error = "some items in allowed and " +
"disallowed lists at the same time"
)
ok, allowedClients := isUniq(list.AllowedClients)
if !ok {
return errAllowedDup
}
ok, disallowedClients := isUniq(list.DisallowedClients)
if !ok {
return errDisallowedDup
}
ok, _ = isUniq(list.BlockedHosts)
if !ok {
return errBlockedDup
}
if intersect(allowedClients, disallowedClients) {
return errIntersect
}
return nil
}
func (s *Server) handleAccessSet(w http.ResponseWriter, r *http.Request) { func (s *Server) handleAccessSet(w http.ResponseWriter, r *http.Request) {
list := accessListJSON{} list := accessListJSON{}
err := json.NewDecoder(r.Body).Decode(&list) err := json.NewDecoder(r.Body).Decode(&list)
@ -201,6 +256,13 @@ func (s *Server) handleAccessSet(w http.ResponseWriter, r *http.Request) {
return return
} }
err = validateAccessSet(list)
if err != nil {
httpError(r, w, http.StatusBadRequest, err.Error())
return
}
var a *accessCtx var a *accessCtx
a, err = newAccessCtx(list.AllowedClients, list.DisallowedClients, list.BlockedHosts) a, err = newAccessCtx(list.AllowedClients, list.DisallowedClients, list.BlockedHosts)
if err != nil { if err != nil {

View File

@ -285,39 +285,70 @@ func toQueryLogWHOIS(wi *RuntimeClientWHOISInfo) (cw *querylog.ClientWHOIS) {
} }
} }
// findMultiple is a wrapper around Find to make it a valid client finder for // findMultiple returns info about client. If no information about the client
// the query log. err is always nil. // is found, it sends the client by default only with the "Disallowed" field
// filled in. err is always nil.
func (clients *clientsContainer) findMultiple(ids []string) (c *querylog.Client, err error) { func (clients *clientsContainer) findMultiple(ids []string) (c *querylog.Client, err error) {
for _, id := range ids { var emptyClient *querylog.Client
var name string
whois := &querylog.ClientWHOIS{} for _, id := range ids {
ip := net.ParseIP(id) ip := net.ParseIP(id)
disallowed, disallowedRule := clients.dnsServer.IsBlockedClient(ip, id)
client := clients.clientInfo(ip, id, disallowed, disallowedRule)
if client.Name == "" && client.DisallowedRule == "" {
emptyClient = client
c, ok := clients.Find(id)
if ok {
name = c.Name
} else if ip != nil {
var rc *RuntimeClient
rc, ok = clients.FindRuntimeClient(ip)
if !ok {
continue continue
} }
name = rc.Host return client, nil
whois = toQueryLogWHOIS(rc.WHOISInfo)
} }
disallowed, disallowedRule := clients.dnsServer.IsBlockedClient(ip, id) return emptyClient, nil
}
// clientInfo is a wrapper around Find to make it a valid client finder for
// the query log.
func (clients *clientsContainer) clientInfo(
ip net.IP,
id string,
disallowed bool,
rule string,
) (c *querylog.Client) {
whois := &querylog.ClientWHOIS{}
client, ok := clients.Find(id)
if ok {
return &querylog.Client{ return &querylog.Client{
Name: name, Name: client.Name,
DisallowedRule: disallowedRule, DisallowedRule: rule,
WHOIS: whois, WHOIS: whois,
Disallowed: disallowed, Disallowed: disallowed,
}, nil }
} }
return nil, nil if ip == nil {
return nil
}
var rc *RuntimeClient
rc, ok = clients.FindRuntimeClient(ip)
if ok {
return &querylog.Client{
Name: rc.Host,
DisallowedRule: rule,
WHOIS: toQueryLogWHOIS(rc.WHOISInfo),
Disallowed: disallowed,
}
}
return &querylog.Client{
Name: "",
DisallowedRule: rule,
WHOIS: &querylog.ClientWHOIS{},
Disallowed: disallowed,
}
} }
func (clients *clientsContainer) Find(id string) (c *Client, ok bool) { func (clients *clientsContainer) Find(id string) (c *Client, ok bool) {

View File

@ -4,6 +4,19 @@
## v0.107: API changes ## v0.107: API changes
### New possible value of `"name"` field in `QueryLogItemClient`
* The value of `"name"` field in `GET /control/querylog` method is never empty:
either persistent client's name or runtime client's hostname.
### Lists in `AccessList`
* Fields `"allowed_clients"`, `"disallowed_clients"` and `"blocked_hosts"` in
`POST /access/set` now should contain only unique elements.
* Fields `"allowed_clients"` and `"disallowed_clients"` cannot contain the same
elements.
### The new field `"private_key_saved"` in `TlsConfig` ### The new field `"private_key_saved"` in `TlsConfig`
* The new field `"private_key_saved"` in `POST /control/tls/configure`, * The new field `"private_key_saved"` in `POST /control/tls/configure`,

View File

@ -1962,8 +1962,7 @@
The rule due to which the client is allowed or blocked. The rule due to which the client is allowed or blocked.
'name': 'name':
'description': > 'description': >
Persistent client's name or an empty string if this is a runtime Persistent client's name or runtime client's hostname.
client.
'type': 'string' 'type': 'string'
'whois': 'whois':
'$ref': '#/components/schemas/QueryLogItemClientWhois' '$ref': '#/components/schemas/QueryLogItemClientWhois'
@ -2363,7 +2362,10 @@
'AccessSetRequest': 'AccessSetRequest':
'$ref': '#/components/schemas/AccessList' '$ref': '#/components/schemas/AccessList'
'AccessList': 'AccessList':
'description': 'Client and host access list' 'description': >
Client and host access list. Each of the lists should contain only
unique elements. In addition, allowed and disallowed lists cannot
contain the same elements.
'properties': 'properties':
'allowed_clients': 'allowed_clients':
'description': > 'description': >