mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 20:19:55 +00:00
feat(rest): BYOP + private-service endpoints in management REST client
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
90
shared/management/client/rest/reverse_proxy_clusters_test.go
Normal file
90
shared/management/client/rest/reverse_proxy_clusters_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
72
shared/management/client/rest/reverse_proxy_tokens.go
Normal file
72
shared/management/client/rest/reverse_proxy_tokens.go
Normal file
@@ -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
|
||||
}
|
||||
131
shared/management/client/rest/reverse_proxy_tokens_test.go
Normal file
131
shared/management/client/rest/reverse_proxy_tokens_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user