Pull request #2323: AGDNS-2598-clients-search

Merge in DNS/adguard-home from AGDNS-2598-clients-search to master

Squashed commit of the following:

commit 9df3c19aca
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Dec 16 19:11:43 2024 +0300

    home: imp code

commit 7bf8f0a516
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Mon Dec 16 18:34:06 2024 +0300

    all: imp code

commit 2dd1c94123
Author: Stanislav Chzhen <s.chzhen@adguard.com>
Date:   Fri Dec 13 17:35:11 2024 +0300

    all: clients search
This commit is contained in:
Stanislav Chzhen 2024-12-17 15:08:31 +03:00
parent dedbadafc4
commit fe07786d2d
8 changed files with 281 additions and 26 deletions

View File

@ -18,6 +18,14 @@ See also the [v0.107.56 GitHub milestone][ms-v0.107.56].
NOTE: Add new changes BELOW THIS COMMENT. NOTE: Add new changes BELOW THIS COMMENT.
--> -->
### Added
- The new HTTP API `POST /clients/search` that finds clients by their IP addresses, CIDRs, MAC addresses, or ClientIDs. See `openapi/openapi.yaml` for the full description.
### Deprecated
- The `GET /clients/find` HTTP API is deprecated. Use the new `POST /clients/search` API.
<!-- <!--
NOTE: Add new changes ABOVE THIS COMMENT. NOTE: Add new changes ABOVE THIS COMMENT.
--> -->

View File

@ -46,7 +46,7 @@ export const getStats = () => async (dispatch: any) => {
const normalizedTopClients = normalizeTopStats(stats.top_clients); const normalizedTopClients = normalizeTopStats(stats.top_clients);
const clientsParams = getParamsForClientsSearch(normalizedTopClients, 'name'); const clientsParams = getParamsForClientsSearch(normalizedTopClients, 'name');
const clients = await apiClient.findClients(clientsParams); const clients = await apiClient.searchClients(clientsParams);
const topClientsWithInfo = addClientInfo(normalizedTopClients, clients, 'name'); const topClientsWithInfo = addClientInfo(normalizedTopClients, clients, 'name');
const normalizedStats = { const normalizedStats = {

View File

@ -415,7 +415,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' }; SEARCH_CLIENTS = { path: 'clients/search', method: 'POST' };
ADD_CLIENT = { path: 'clients/add', method: 'POST' }; ADD_CLIENT = { path: 'clients/add', method: 'POST' };
@ -453,11 +453,12 @@ class Api {
return this.makeRequest(path, method, parameters); return this.makeRequest(path, method, parameters);
} }
findClients(params: any) { searchClients(config: any) {
const { path, method } = this.FIND_CLIENTS; const { path, method } = this.SEARCH_CLIENTS;
const url = getPathWithQueryString(path, params); const parameters = {
data: config,
return this.makeRequest(url, method); };
return this.makeRequest(path, method, parameters);
} }
// DNS access settings // DNS access settings

View File

@ -451,13 +451,10 @@ export const getParamsForClientsSearch = (data: any, param: any, additionalParam
clients.add(e[additionalParam]); clients.add(e[additionalParam]);
} }
}); });
const params = {};
const ids = Array.from(clients.values());
ids.forEach((id, i) => {
params[`ip${i}`] = id;
});
return params; return {
clients: Array.from(clients).map(id => ({ id })),
};
}; };
/** /**

View File

@ -424,6 +424,8 @@ func (clients *clientsContainer) handleUpdateClient(w http.ResponseWriter, r *ht
} }
// handleFindClient is the handler for GET /control/clients/find HTTP API. // handleFindClient is the handler for GET /control/clients/find HTTP API.
//
// Deprecated: Remove it when migration to the new API is over.
func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http.Request) { func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query() q := r.URL.Query()
data := []map[string]*clientJSON{} data := []map[string]*clientJSON{}
@ -433,19 +435,58 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http
break break
} }
data = append(data, map[string]*clientJSON{
idStr: clients.findClient(idStr),
})
}
aghhttp.WriteJSONResponseOK(w, r, data)
}
// findClient returns available information about a client by idStr from the
// client's storage or access settings. cj is guaranteed to be non-nil.
func (clients *clientsContainer) findClient(idStr string) (cj *clientJSON) {
ip, _ := netip.ParseAddr(idStr) ip, _ := netip.ParseAddr(idStr)
c, ok := clients.storage.Find(idStr) c, ok := clients.storage.Find(idStr)
var cj *clientJSON
if !ok { if !ok {
cj = clients.findRuntime(ip, idStr) return clients.findRuntime(ip, idStr)
} else { }
cj = clientToJSON(c) cj = clientToJSON(c)
disallowed, rule := clients.clientChecker.IsBlockedClient(ip, idStr) disallowed, rule := clients.clientChecker.IsBlockedClient(ip, idStr)
cj.Disallowed, cj.DisallowedRule = &disallowed, &rule cj.Disallowed, cj.DisallowedRule = &disallowed, &rule
return cj
} }
// searchQueryJSON is a request to the POST /control/clients/search HTTP API.
//
// TODO(s.chzhen): Add UIDs.
type searchQueryJSON struct {
Clients []searchClientJSON `json:"clients"`
}
// searchClientJSON is a part of [searchQueryJSON] that contains a string
// representation of the client's IP address, CIDR, MAC address, or ClientID.
type searchClientJSON struct {
ID string `json:"id"`
}
// handleSearchClient is the handler for the POST /control/clients/search HTTP API.
func (clients *clientsContainer) handleSearchClient(w http.ResponseWriter, r *http.Request) {
q := searchQueryJSON{}
err := json.NewDecoder(r.Body).Decode(&q)
if err != nil {
aghhttp.Error(r, w, http.StatusBadRequest, "failed to process request body: %s", err)
return
}
data := []map[string]*clientJSON{}
for _, c := range q.Clients {
idStr := c.ID
data = append(data, map[string]*clientJSON{ data = append(data, map[string]*clientJSON{
idStr: cj, idStr: clients.findClient(idStr),
}) })
} }
@ -493,5 +534,8 @@ func (clients *clientsContainer) registerWebHandlers() {
httpRegister(http.MethodPost, "/control/clients/add", clients.handleAddClient) httpRegister(http.MethodPost, "/control/clients/add", clients.handleAddClient)
httpRegister(http.MethodPost, "/control/clients/delete", clients.handleDelClient) httpRegister(http.MethodPost, "/control/clients/delete", clients.handleDelClient)
httpRegister(http.MethodPost, "/control/clients/update", clients.handleUpdateClient) httpRegister(http.MethodPost, "/control/clients/update", clients.handleUpdateClient)
httpRegister(http.MethodPost, "/control/clients/search", clients.handleSearchClient)
// Deprecated handler.
httpRegister(http.MethodGet, "/control/clients/find", clients.handleFindClient) httpRegister(http.MethodGet, "/control/clients/find", clients.handleFindClient)
} }

View File

@ -16,6 +16,7 @@ import (
"github.com/AdguardTeam/AdGuardHome/internal/client" "github.com/AdguardTeam/AdGuardHome/internal/client"
"github.com/AdguardTeam/AdGuardHome/internal/filtering" "github.com/AdguardTeam/AdGuardHome/internal/filtering"
"github.com/AdguardTeam/AdGuardHome/internal/schedule" "github.com/AdguardTeam/AdGuardHome/internal/schedule"
"github.com/AdguardTeam/AdGuardHome/internal/whois"
"github.com/AdguardTeam/golibs/testutil" "github.com/AdguardTeam/golibs/testutil"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -408,3 +409,145 @@ func TestClientsContainer_HandleFindClient(t *testing.T) {
}) })
} }
} }
func TestClientsContainer_HandleSearchClient(t *testing.T) {
var (
runtimeCli = "runtime_client1"
runtimeCliIP = "3.3.3.3"
blockedCliIP = "4.4.4.4"
nonExistentCliIP = "5.5.5.5"
allowed = false
dissallowed = true
emptyRule = ""
disallowedRule = "disallowed_rule"
)
clients := newClientsContainer(t)
clients.clientChecker = &testBlockedClientChecker{
onIsBlockedClient: func(ip netip.Addr, _ string) (ok bool, rule string) {
if ip == netip.MustParseAddr(blockedCliIP) {
return true, disallowedRule
}
return false, emptyRule
},
}
ctx := testutil.ContextWithTimeout(t, testTimeout)
clientOne := newPersistentClientWithIDs(t, "client1", []string{testClientIP1})
err := clients.storage.Add(ctx, clientOne)
require.NoError(t, err)
clientTwo := newPersistentClientWithIDs(t, "client2", []string{testClientIP2})
err = clients.storage.Add(ctx, clientTwo)
require.NoError(t, err)
assertPersistentClients(t, clients, []*client.Persistent{clientOne, clientTwo})
clients.UpdateAddress(ctx, netip.MustParseAddr(runtimeCliIP), runtimeCli, nil)
testCases := []struct {
name string
query *searchQueryJSON
wantPersistent []*client.Persistent
wantRuntime *clientJSON
}{{
name: "single",
query: &searchQueryJSON{
Clients: []searchClientJSON{{
ID: testClientIP1,
}},
},
wantPersistent: []*client.Persistent{clientOne},
}, {
name: "multiple",
query: &searchQueryJSON{
Clients: []searchClientJSON{{
ID: testClientIP1,
}, {
ID: testClientIP2,
}},
},
wantPersistent: []*client.Persistent{clientOne, clientTwo},
}, {
name: "runtime",
query: &searchQueryJSON{
Clients: []searchClientJSON{{
ID: runtimeCliIP,
}},
},
wantRuntime: &clientJSON{
Name: runtimeCli,
IDs: []string{runtimeCliIP},
Disallowed: &allowed,
DisallowedRule: &emptyRule,
WHOIS: &whois.Info{},
},
}, {
name: "blocked_access",
query: &searchQueryJSON{
Clients: []searchClientJSON{{
ID: blockedCliIP,
}},
},
wantRuntime: &clientJSON{
IDs: []string{blockedCliIP},
Disallowed: &dissallowed,
DisallowedRule: &disallowedRule,
WHOIS: &whois.Info{},
},
}, {
name: "non_existing_client",
query: &searchQueryJSON{
Clients: []searchClientJSON{{
ID: nonExistentCliIP,
}},
},
wantRuntime: &clientJSON{
IDs: []string{nonExistentCliIP},
Disallowed: &allowed,
DisallowedRule: &emptyRule,
WHOIS: &whois.Info{},
},
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var body []byte
body, err = json.Marshal(tc.query)
require.NoError(t, err)
var r *http.Request
r, err = http.NewRequest(http.MethodPost, "", bytes.NewReader(body))
require.NoError(t, err)
rw := httptest.NewRecorder()
clients.handleSearchClient(rw, r)
require.NoError(t, err)
require.Equal(t, http.StatusOK, rw.Code)
body, err = io.ReadAll(rw.Body)
require.NoError(t, err)
clientData := []map[string]*clientJSON{}
err = json.Unmarshal(body, &clientData)
require.NoError(t, err)
if tc.wantPersistent != nil {
assertPersistentClientsData(t, clients, clientData, tc.wantPersistent)
return
}
require.Len(t, clientData, 1)
require.Len(t, clientData[0], 1)
rc := clientData[0][tc.wantRuntime.IDs[0]]
assert.Equal(t, tc.wantRuntime, rc)
})
}
}

View File

@ -4,7 +4,32 @@
## v0.108.0: API changes ## v0.108.0: API changes
## v0.107.55: API changes ## v0.107.56: API changes
### Deprecated client APIs
* The `GET /control/clients/find` HTTP API; use the new `POST
/control/clients/search` API instead.
### New client APIs
* The new `POST /control/clients/search` HTTP API allows config updates. It
accepts a JSON object with the following format:
```json
{
"clients": [
{
"id": "192.0.2.1"
},
{
"id": "test"
}
]
}
```
## v0.107.53: API changes
### The new field `"ecosia"` in `SafeSearchConfig` ### The new field `"ecosia"` in `SafeSearchConfig`

View File

@ -934,6 +934,9 @@
'description': 'OK.' 'description': 'OK.'
'/clients/find': '/clients/find':
'get': 'get':
'deprecated': true
'description': >
Deprecated: Use `POST /clients/search` instead.
'tags': 'tags':
- 'clients' - 'clients'
'operationId': 'clientsFind' 'operationId': 'clientsFind'
@ -957,6 +960,26 @@
'application/json': 'application/json':
'schema': 'schema':
'$ref': '#/components/schemas/ClientsFindResponse' '$ref': '#/components/schemas/ClientsFindResponse'
'/clients/search':
'post':
'tags':
- 'clients'
'operationId': 'clientsSearch'
'summary': >
Get information about clients by their IP addresses, CIDRs, MAC addresses, or ClientIDs.
'requestBody':
'content':
'application/json':
'schema':
'$ref': '#/components/schemas/ClientsSearchRequest'
'required': true
'responses':
'200':
'description': 'OK.'
'content':
'application/json':
'schema':
'$ref': '#/components/schemas/ClientsFindResponse'
'/access/list': '/access/list':
'get': 'get':
'operationId': 'accessList' 'operationId': 'accessList'
@ -2749,6 +2772,20 @@
'properties': 'properties':
'name': 'name':
'type': 'string' 'type': 'string'
'ClientsSearchRequest':
'type': 'object'
'description': 'Client search request'
'properties':
'clients':
'type': 'array'
'items':
'$ref': '#/components/schemas/ClientsSearchRequestItem'
'ClientsSearchRequestItem':
'type': 'object'
'properties':
'id':
'type': 'string'
'description': 'Client IP address, CIDR, MAC address, or ClientID'
'ClientsFindResponse': 'ClientsFindResponse':
'type': 'array' 'type': 'array'
'description': 'Client search results.' 'description': 'Client search results.'