From 665f254a55718a45296f85cf14306dc2bdb596c0 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Thu, 21 May 2026 18:50:53 +0200 Subject: [PATCH] feat(rest): BYOP + private-service endpoints in management REST client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the wrappers needed to drive the bring-your-own-proxy + private- service flows from external tooling (CI, IaC, dashboards built against the REST client). The existing Service / ProxyCluster types already carry Private / AccessGroups / direct_upstream via the openapi-generated shapes, so the new code is the API surface itself — not field-level plumbing. - ReverseProxyClustersAPI.Delete(clusterAddress): DELETE /api/reverse-proxies/clusters/{clusterAddress}. Removes every self-hosted (BYOP) proxy registration the calling account owns under that address. Shared clusters operated by NetBird are 404 here. - ReverseProxyTokensAPI (new file): List / Create / Delete against /api/reverse-proxies/proxy-tokens. Tokens are account-scoped and used by `netbird proxy` to register against management. Create returns api.ProxyTokenCreated, whose PlainToken is the one-shot secret; List and Delete operate on metadata only. Tests cover the success + error paths for each new operation and pin the cluster List response to round-trip the `private` capability flag. Existing reverse_proxy_services_test.go was failing to build on the parent commit because the regenerated api types switched Service.Auth to *ServiceAuthConfig and Service.Targets to *[]ServiceTarget; fixed the literals (`&api.ServiceAuthConfig{}`, `&[]api.ServiceTarget{...}`, `(*req.Targets)[0]`) so the integration tag builds again — necessary to even run the new tests. --- shared/management/client/rest/client.go | 5 + .../client/rest/reverse_proxy_clusters.go | 21 ++- .../rest/reverse_proxy_clusters_test.go | 90 ++++++++++++ .../rest/reverse_proxy_services_test.go | 25 ++-- .../client/rest/reverse_proxy_tokens.go | 72 ++++++++++ .../client/rest/reverse_proxy_tokens_test.go | 131 ++++++++++++++++++ 6 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 shared/management/client/rest/reverse_proxy_clusters_test.go create mode 100644 shared/management/client/rest/reverse_proxy_tokens.go create mode 100644 shared/management/client/rest/reverse_proxy_tokens_test.go diff --git a/shared/management/client/rest/client.go b/shared/management/client/rest/client.go index f0cb4d2d1..43312b9e6 100644 --- a/shared/management/client/rest/client.go +++ b/shared/management/client/rest/client.go @@ -143,6 +143,10 @@ type Client struct { // ReverseProxyDomains NetBird reverse proxy domains APIs ReverseProxyDomains *ReverseProxyDomainsAPI + + // ReverseProxyTokens account-scoped proxy access tokens used to register + // self-hosted (bring-your-own-proxy) `netbird proxy` instances. + ReverseProxyTokens *ReverseProxyTokensAPI } // New initialize new Client instance using PAT token @@ -204,6 +208,7 @@ func (c *Client) initialize() { c.ReverseProxyServices = &ReverseProxyServicesAPI{c} c.ReverseProxyClusters = &ReverseProxyClustersAPI{c} c.ReverseProxyDomains = &ReverseProxyDomainsAPI{c} + c.ReverseProxyTokens = &ReverseProxyTokensAPI{c} } // NewRequest creates and executes new management API request diff --git a/shared/management/client/rest/reverse_proxy_clusters.go b/shared/management/client/rest/reverse_proxy_clusters.go index b55cd35a3..249833b01 100644 --- a/shared/management/client/rest/reverse_proxy_clusters.go +++ b/shared/management/client/rest/reverse_proxy_clusters.go @@ -2,6 +2,7 @@ package rest import ( "context" + "net/url" "github.com/netbirdio/netbird/shared/management/http/api" ) @@ -11,7 +12,10 @@ type ReverseProxyClustersAPI struct { c *Client } -// List lists all available proxy clusters +// List lists all available proxy clusters. Each cluster is enriched with the +// capability flags reported by its connected proxies (supports_custom_ports, +// supports_crowdsec, private, etc.), so callers can render UX gates without +// a follow-up round-trip. func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster, error) { resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/clusters", nil, nil) if err != nil { @@ -23,3 +27,18 @@ func (a *ReverseProxyClustersAPI) List(ctx context.Context) ([]api.ProxyCluster, ret, err := parseResponse[[]api.ProxyCluster](resp) return ret, err } + +// Delete removes every self-hosted (BYOP) proxy registration for the given +// cluster address owned by the calling account. Shared clusters operated by +// NetBird cannot be deleted via this endpoint; the server returns 404 / 400 +// for cluster addresses the account does not own. +func (a *ReverseProxyClustersAPI) Delete(ctx context.Context, clusterAddress string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/clusters/"+url.PathEscape(clusterAddress), nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return nil +} diff --git a/shared/management/client/rest/reverse_proxy_clusters_test.go b/shared/management/client/rest/reverse_proxy_clusters_test.go new file mode 100644 index 000000000..2d9f6f7bb --- /dev/null +++ b/shared/management/client/rest/reverse_proxy_clusters_test.go @@ -0,0 +1,90 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +func boolPtr(b bool) *bool { return &b } + +var testCluster = api.ProxyCluster{ + Id: "cluster-1", + Address: "proxy.netbird.local", + Type: "shared", + Online: true, + ConnectedProxies: 2, + SupportsCustomPorts: boolPtr(true), + RequireSubdomain: boolPtr(false), + SupportsCrowdsec: boolPtr(false), + Private: boolPtr(true), +} + +func TestReverseProxyClusters_List_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method, "List must use GET") + retBytes, _ := json.Marshal([]api.ProxyCluster{testCluster}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyClusters.List(context.Background()) + require.NoError(t, err) + require.Len(t, ret, 1) + assert.Equal(t, testCluster.Id, ret[0].Id) + assert.Equal(t, testCluster.Address, ret[0].Address) + require.NotNil(t, ret[0].Private, "private capability must round-trip through the client") + assert.True(t, *ret[0].Private, "private capability must reflect the server value") + }) +} + +func TestReverseProxyClusters_List_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/clusters", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500}) + w.WriteHeader(500) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyClusters.List(context.Background()) + assert.Error(t, err) + assert.Empty(t, ret) + }) +} + +func TestReverseProxyClusters_Delete_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + // PathEscape on "proxy.netbird.local" leaves it intact; the route mux + // matches the unescaped form. Sanity-check both the method and that + // path-escaping doesn't double-encode the dotted address. + mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE") + w.WriteHeader(200) + }) + err := c.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local") + require.NoError(t, err) + }) +} + +func TestReverseProxyClusters_Delete_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/clusters/proxy.netbird.local", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + err := c.ReverseProxyClusters.Delete(context.Background(), "proxy.netbird.local") + assert.Error(t, err) + }) +} diff --git a/shared/management/client/rest/reverse_proxy_services_test.go b/shared/management/client/rest/reverse_proxy_services_test.go index 164563e97..1a93472db 100644 --- a/shared/management/client/rest/reverse_proxy_services_test.go +++ b/shared/management/client/rest/reverse_proxy_services_test.go @@ -116,8 +116,8 @@ func TestReverseProxyServices_Create_200(t *testing.T) { Name: "test-service", Domain: "test.example.com", Enabled: true, - Auth: api.ServiceAuthConfig{}, - Targets: []api.ServiceTarget{testServiceTarget}, + Auth: &api.ServiceAuthConfig{}, + Targets: &[]api.ServiceTarget{testServiceTarget}, }) require.NoError(t, err) assert.Equal(t, testService.Id, ret.Id) @@ -136,8 +136,8 @@ func TestReverseProxyServices_Create_Err(t *testing.T) { Name: "test-service", Domain: "test.example.com", Enabled: true, - Auth: api.ServiceAuthConfig{}, - Targets: []api.ServiceTarget{testServiceTarget}, + Auth: &api.ServiceAuthConfig{}, + Targets: &[]api.ServiceTarget{testServiceTarget}, }) assert.Error(t, err) assert.Equal(t, "No", err.Error()) @@ -154,8 +154,9 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) { var req api.ServiceRequest require.NoError(t, json.Unmarshal(reqBytes, &req)) - require.Len(t, req.Targets, 1) - target := req.Targets[0] + require.NotNil(t, req.Targets, "targets must be set on the request") + require.Len(t, *req.Targets, 1) + target := (*req.Targets)[0] require.NotNil(t, target.Options, "options should be present") opts := target.Options require.NotNil(t, opts.SkipTlsVerify, "skip_tls_verify should be present") @@ -177,8 +178,8 @@ func TestReverseProxyServices_Create_WithPerTargetOptions(t *testing.T) { Name: "test-service", Domain: "test.example.com", Enabled: true, - Auth: api.ServiceAuthConfig{}, - Targets: []api.ServiceTarget{ + Auth: &api.ServiceAuthConfig{}, + Targets: &[]api.ServiceTarget{ { TargetId: "peer-123", TargetType: "peer", @@ -216,8 +217,8 @@ func TestReverseProxyServices_Update_200(t *testing.T) { Name: "updated-service", Domain: "test.example.com", Enabled: true, - Auth: api.ServiceAuthConfig{}, - Targets: []api.ServiceTarget{testServiceTarget}, + Auth: &api.ServiceAuthConfig{}, + Targets: &[]api.ServiceTarget{testServiceTarget}, }) require.NoError(t, err) assert.Equal(t, testService.Id, ret.Id) @@ -236,8 +237,8 @@ func TestReverseProxyServices_Update_Err(t *testing.T) { Name: "updated-service", Domain: "test.example.com", Enabled: true, - Auth: api.ServiceAuthConfig{}, - Targets: []api.ServiceTarget{testServiceTarget}, + Auth: &api.ServiceAuthConfig{}, + Targets: &[]api.ServiceTarget{testServiceTarget}, }) assert.Error(t, err) assert.Equal(t, "No", err.Error()) diff --git a/shared/management/client/rest/reverse_proxy_tokens.go b/shared/management/client/rest/reverse_proxy_tokens.go new file mode 100644 index 000000000..de59f3176 --- /dev/null +++ b/shared/management/client/rest/reverse_proxy_tokens.go @@ -0,0 +1,72 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "net/url" + + "github.com/netbirdio/netbird/shared/management/http/api" +) + +// ReverseProxyTokensAPI exposes the account-scoped proxy access tokens that +// self-hosted (bring-your-own-proxy) deployments use to register a +// `netbird proxy` instance with management. Tokens are bound to the +// calling account; revoking a token disconnects every proxy that +// authenticated with it. +type ReverseProxyTokensAPI struct { + c *Client +} + +// List returns every proxy token the calling account has minted, including +// already-revoked entries. The plain token is never returned — only the +// metadata (id, name, created_at, last_used, revoked). +func (a *ReverseProxyTokensAPI) List(ctx context.Context) ([]api.ProxyToken, error) { + resp, err := a.c.NewRequest(ctx, "GET", "/api/reverse-proxies/proxy-tokens", nil, nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[[]api.ProxyToken](resp) + return ret, err +} + +// Create mints a fresh account-scoped proxy token. The returned +// ProxyTokenCreated.PlainToken is shown only once — callers must persist +// it immediately. Subsequent reads will only expose the token metadata, +// not the secret material. +func (a *ReverseProxyTokensAPI) Create(ctx context.Context, request api.ProxyTokenRequest) (*api.ProxyTokenCreated, error) { + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, err + } + resp, err := a.c.NewRequest(ctx, "POST", "/api/reverse-proxies/proxy-tokens", bytes.NewReader(requestBytes), nil) + if err != nil { + return nil, err + } + if resp.Body != nil { + defer resp.Body.Close() + } + ret, err := parseResponse[api.ProxyTokenCreated](resp) + if err != nil { + return nil, err + } + return &ret, nil +} + +// Delete revokes a previously-issued proxy token by ID. Revoked tokens +// remain in List output (with revoked=true) so operators can audit which +// credentials existed; the plain secret can no longer authenticate any +// new proxy registration. +func (a *ReverseProxyTokensAPI) Delete(ctx context.Context, tokenID string) error { + resp, err := a.c.NewRequest(ctx, "DELETE", "/api/reverse-proxies/proxy-tokens/"+url.PathEscape(tokenID), nil, nil) + if err != nil { + return err + } + if resp.Body != nil { + defer resp.Body.Close() + } + return nil +} diff --git a/shared/management/client/rest/reverse_proxy_tokens_test.go b/shared/management/client/rest/reverse_proxy_tokens_test.go new file mode 100644 index 000000000..a3f5e014f --- /dev/null +++ b/shared/management/client/rest/reverse_proxy_tokens_test.go @@ -0,0 +1,131 @@ +//go:build integration + +package rest_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/shared/management/client/rest" + "github.com/netbirdio/netbird/shared/management/http/api" + "github.com/netbirdio/netbird/shared/management/http/util" +) + +func intPtr(v int) *int { return &v } + +var testProxyToken = api.ProxyToken{ + Id: "tok-1", + Name: "ci-runner", + CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC), + Revoked: false, +} + +var testProxyTokenCreated = api.ProxyTokenCreated{ + Id: "tok-1", + Name: "ci-runner", + CreatedAt: time.Date(2026, 5, 21, 9, 0, 0, 0, time.UTC), + PlainToken: "nbproxy_abcdef0123456789", + Revoked: false, +} + +func TestReverseProxyTokens_List_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method, "List must use GET") + retBytes, _ := json.Marshal([]api.ProxyToken{testProxyToken}) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyTokens.List(context.Background()) + require.NoError(t, err) + require.Len(t, ret, 1) + assert.Equal(t, testProxyToken.Id, ret[0].Id) + assert.Equal(t, testProxyToken.Name, ret[0].Name) + }) +} + +func TestReverseProxyTokens_List_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "No", Code: 500}) + w.WriteHeader(500) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyTokens.List(context.Background()) + assert.Error(t, err) + assert.Empty(t, ret) + }) +} + +func TestReverseProxyTokens_Create_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method, "Create must use POST") + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + var req api.ProxyTokenRequest + require.NoError(t, json.Unmarshal(body, &req), "server must receive a valid ProxyTokenRequest body") + assert.Equal(t, "ci-runner", req.Name, "name must round-trip through the client") + require.NotNil(t, req.ExpiresIn, "expires_in must be sent when provided") + assert.Equal(t, 3600, *req.ExpiresIn, "expires_in value must round-trip unchanged") + + retBytes, _ := json.Marshal(testProxyTokenCreated) + _, err = w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{ + Name: "ci-runner", + ExpiresIn: intPtr(3600), + }) + require.NoError(t, err) + assert.Equal(t, testProxyTokenCreated.Id, ret.Id) + assert.Equal(t, testProxyTokenCreated.PlainToken, ret.PlainToken, + "PlainToken must be returned to the caller — it's the one-shot secret") + }) +} + +func TestReverseProxyTokens_Create_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Bad", Code: 400}) + w.WriteHeader(400) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + ret, err := c.ReverseProxyTokens.Create(context.Background(), api.ProxyTokenRequest{Name: ""}) + assert.Error(t, err) + assert.Nil(t, ret) + }) +} + +func TestReverseProxyTokens_Delete_200(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method, "Delete must use DELETE") + w.WriteHeader(200) + }) + err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1") + require.NoError(t, err) + }) +} + +func TestReverseProxyTokens_Delete_Err(t *testing.T) { + withMockClient(func(c *rest.Client, mux *http.ServeMux) { + mux.HandleFunc("/api/reverse-proxies/proxy-tokens/tok-1", func(w http.ResponseWriter, r *http.Request) { + retBytes, _ := json.Marshal(util.ErrorResponse{Message: "Not found", Code: 404}) + w.WriteHeader(404) + _, err := w.Write(retBytes) + require.NoError(t, err) + }) + err := c.ReverseProxyTokens.Delete(context.Background(), "tok-1") + assert.Error(t, err) + }) +}