diff --git a/CHANGELOG.md b/CHANGELOG.md index fbffff1f..c4b9f685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,14 @@ See also the [v0.107.56 GitHub milestone][ms-v0.107.56]. 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. + diff --git a/client/src/actions/stats.ts b/client/src/actions/stats.ts index d0dcc852..05b189ed 100644 --- a/client/src/actions/stats.ts +++ b/client/src/actions/stats.ts @@ -46,7 +46,7 @@ export const getStats = () => async (dispatch: any) => { const normalizedTopClients = normalizeTopStats(stats.top_clients); const clientsParams = getParamsForClientsSearch(normalizedTopClients, 'name'); - const clients = await apiClient.findClients(clientsParams); + const clients = await apiClient.searchClients(clientsParams); const topClientsWithInfo = addClientInfo(normalizedTopClients, clients, 'name'); const normalizedStats = { diff --git a/client/src/api/Api.ts b/client/src/api/Api.ts index 40423ab0..c77264dd 100644 --- a/client/src/api/Api.ts +++ b/client/src/api/Api.ts @@ -415,7 +415,7 @@ class Api { // Per-client settings 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' }; @@ -453,11 +453,12 @@ class Api { return this.makeRequest(path, method, parameters); } - findClients(params: any) { - const { path, method } = this.FIND_CLIENTS; - const url = getPathWithQueryString(path, params); - - return this.makeRequest(url, method); + searchClients(config: any) { + const { path, method } = this.SEARCH_CLIENTS; + const parameters = { + data: config, + }; + return this.makeRequest(path, method, parameters); } // DNS access settings diff --git a/client/src/helpers/helpers.tsx b/client/src/helpers/helpers.tsx index 1a0f368b..0ad9e9e2 100644 --- a/client/src/helpers/helpers.tsx +++ b/client/src/helpers/helpers.tsx @@ -451,13 +451,10 @@ export const getParamsForClientsSearch = (data: any, param: any, 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 })), + }; }; /** diff --git a/internal/home/clientshttp.go b/internal/home/clientshttp.go index 8cea8650..2971dfea 100644 --- a/internal/home/clientshttp.go +++ b/internal/home/clientshttp.go @@ -424,6 +424,8 @@ func (clients *clientsContainer) handleUpdateClient(w http.ResponseWriter, r *ht } // 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) { q := r.URL.Query() data := []map[string]*clientJSON{} @@ -433,19 +435,58 @@ func (clients *clientsContainer) handleFindClient(w http.ResponseWriter, r *http break } - ip, _ := netip.ParseAddr(idStr) - c, ok := clients.storage.Find(idStr) - var cj *clientJSON - if !ok { - cj = clients.findRuntime(ip, idStr) - } else { - cj = clientToJSON(c) - disallowed, rule := clients.clientChecker.IsBlockedClient(ip, idStr) - cj.Disallowed, cj.DisallowedRule = &disallowed, &rule - } - data = append(data, map[string]*clientJSON{ - idStr: cj, + 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) + c, ok := clients.storage.Find(idStr) + if !ok { + return clients.findRuntime(ip, idStr) + } + + cj = clientToJSON(c) + disallowed, rule := clients.clientChecker.IsBlockedClient(ip, idStr) + 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{ + 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/delete", clients.handleDelClient) 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) } diff --git a/internal/home/clientshttp_internal_test.go b/internal/home/clientshttp_internal_test.go index a10ca8d1..c1c495f2 100644 --- a/internal/home/clientshttp_internal_test.go +++ b/internal/home/clientshttp_internal_test.go @@ -16,6 +16,7 @@ import ( "github.com/AdguardTeam/AdGuardHome/internal/client" "github.com/AdguardTeam/AdGuardHome/internal/filtering" "github.com/AdguardTeam/AdGuardHome/internal/schedule" + "github.com/AdguardTeam/AdGuardHome/internal/whois" "github.com/AdguardTeam/golibs/testutil" "github.com/stretchr/testify/assert" "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) + }) + } +} diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md index e9894c37..c7aab4f6 100644 --- a/openapi/CHANGELOG.md +++ b/openapi/CHANGELOG.md @@ -4,7 +4,32 @@ ## 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` diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 32c691d1..f0a8ea6c 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -934,6 +934,9 @@ 'description': 'OK.' '/clients/find': 'get': + 'deprecated': true + 'description': > + Deprecated: Use `POST /clients/search` instead. 'tags': - 'clients' 'operationId': 'clientsFind' @@ -957,6 +960,26 @@ 'application/json': 'schema': '$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': 'get': 'operationId': 'accessList' @@ -2749,6 +2772,20 @@ 'properties': 'name': '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': 'type': 'array' 'description': 'Client search results.'