mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-21 16:01:28 +02:00
[management, client, proxy] add expose NetBird-only services over tunnel peers (#6226)
Adds a new "private" service mode for the reverse proxy: services reachable exclusively over the embedded WireGuard tunnel, gated by per-peer group membership instead of operator auth schemes. Wire contract - ProxyMapping.private (field 13): the proxy MUST call ValidateTunnelPeer and fail closed; operator schemes are bypassed. - ProxyCapabilities.private (4) + supports_private_service (5): capability gate. Management never streams private mappings to proxies that don't claim the capability; the broadcast path applies the same filter via filterMappingsForProxy. - ValidateTunnelPeer RPC: resolves an inbound tunnel IP to a peer, checks the peer's groups against service.AccessGroups, and mints a session JWT on success. checkPeerGroupAccess fails closed when a private service has empty AccessGroups. - ValidateSession/ValidateTunnelPeer responses now carry peer_group_ids + peer_group_names so the proxy can authorise policy-aware middlewares without an extra management round-trip. - ProxyInboundListener + SendStatusUpdate.inbound_listener: per-account inbound listener state surfaced to dashboards. - PathTargetOptions.direct_upstream (11): bypass the embedded NetBird client and dial the target via the proxy host's network stack for upstreams reachable without WireGuard. Data model - Service.Private (bool) + Service.AccessGroups ([]string, JSON- serialised). Validate() rejects bearer auth on private services. Copy() deep-copies AccessGroups. pgx getServices loads the columns. - DomainConfig.Private threaded into the proxy auth middleware. Request handler routes private services through forwardWithTunnelPeer and returns 403 on validation failure. - Account-level SynthesizePrivateServiceZones (synthetic DNS) and injectPrivateServicePolicies (synthetic ACL) gate on len(svc.AccessGroups) > 0. Proxy - /netbird proxy --private (embedded mode) flag; Config.Private in proxy/lifecycle.go. - Per-account inbound listener (proxy/inbound.go) binding HTTP/HTTPS on the embedded NetBird client's WireGuard tunnel netstack. - proxy/internal/auth/tunnel_cache: ValidateTunnelPeer response cache with single-flight de-duplication and per-account eviction. - Local peerstore short-circuit: when the inbound IP isn't in the account roster, deny fast without an RPC. - proxy/server.go reports SupportsPrivateService=true and redacts the full ProxyMapping JSON from info logs (auth_token + header-auth hashed values now only at debug level). Identity forwarding - ValidateSessionJWT returns user_id, email, method, groups, group_names. sessionkey.Claims carries Email + Groups + GroupNames so the proxy can stamp identity onto upstream requests without an extra management round-trip on every cookie-bearing request. - CapturedData carries userEmail / userGroups / userGroupNames; the proxy stamps X-NetBird-User and X-NetBird-Groups on r.Out from the authenticated identity (strips client-supplied values first to prevent spoofing). - AccessLog.UserGroups: access-log enrichment captures the user's group memberships at write time so the dashboard can render group context without reverse-resolving stale memberships. OpenAPI/dashboard surface - ReverseProxyService gains private + access_groups; ReverseProxyCluster gains private + supports_private. ReverseProxyTarget target_type enum gains "cluster". ServiceTargetOptions gains direct_upstream. ProxyAccessLog gains user_groups.
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)
|
||||
})
|
||||
}
|
||||
@@ -3067,6 +3067,17 @@ components:
|
||||
$ref: '#/components/schemas/AccessRestrictions'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ServiceMeta'
|
||||
private:
|
||||
type: boolean
|
||||
description: When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
|
||||
default: false
|
||||
example: false
|
||||
access_groups:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
|
||||
example: ["group-engineering"]
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
@@ -3147,6 +3158,17 @@ components:
|
||||
$ref: '#/components/schemas/ServiceAuthConfig'
|
||||
access_restrictions:
|
||||
$ref: '#/components/schemas/AccessRestrictions'
|
||||
private:
|
||||
type: boolean
|
||||
description: When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
|
||||
default: false
|
||||
example: false
|
||||
access_groups:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
|
||||
example: ["group-engineering"]
|
||||
required:
|
||||
- name
|
||||
- domain
|
||||
@@ -3185,6 +3207,15 @@ components:
|
||||
type: string
|
||||
description: Idle timeout before a UDP session is reaped, as a Go duration string (e.g. "30s", "2m").
|
||||
example: "2m"
|
||||
direct_upstream:
|
||||
type: boolean
|
||||
description: |
|
||||
When true, the proxy dials this target via the host's network stack
|
||||
instead of through its embedded NetBird client. Use for upstreams
|
||||
reachable without WireGuard (public APIs, LAN services, localhost
|
||||
sidecars).
|
||||
default: false
|
||||
example: false
|
||||
ServiceTarget:
|
||||
type: object
|
||||
properties:
|
||||
@@ -3195,7 +3226,7 @@ components:
|
||||
target_type:
|
||||
type: string
|
||||
description: Target type
|
||||
enum: [peer, host, domain, subnet]
|
||||
enum: [peer, host, domain, subnet, cluster]
|
||||
example: "subnet"
|
||||
path:
|
||||
type: string
|
||||
@@ -3439,6 +3470,10 @@ components:
|
||||
type: boolean
|
||||
description: Whether all active proxies in the cluster have CrowdSec configured
|
||||
example: false
|
||||
private:
|
||||
type: boolean
|
||||
description: True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
|
||||
example: false
|
||||
required:
|
||||
- id
|
||||
- address
|
||||
@@ -3494,6 +3529,10 @@ components:
|
||||
type: boolean
|
||||
description: Whether the proxy cluster has CrowdSec configured
|
||||
example: false
|
||||
supports_private:
|
||||
type: boolean
|
||||
description: Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
|
||||
example: false
|
||||
required:
|
||||
- id
|
||||
- domain
|
||||
|
||||
@@ -1063,15 +1063,18 @@ func (e ServiceTargetProtocol) Valid() bool {
|
||||
|
||||
// Defines values for ServiceTargetTargetType.
|
||||
const (
|
||||
ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
|
||||
ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
|
||||
ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
|
||||
ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
|
||||
ServiceTargetTargetTypeCluster ServiceTargetTargetType = "cluster"
|
||||
ServiceTargetTargetTypeDomain ServiceTargetTargetType = "domain"
|
||||
ServiceTargetTargetTypeHost ServiceTargetTargetType = "host"
|
||||
ServiceTargetTargetTypePeer ServiceTargetTargetType = "peer"
|
||||
ServiceTargetTargetTypeSubnet ServiceTargetTargetType = "subnet"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ServiceTargetTargetType enum.
|
||||
func (e ServiceTargetTargetType) Valid() bool {
|
||||
switch e {
|
||||
case ServiceTargetTargetTypeCluster:
|
||||
return true
|
||||
case ServiceTargetTargetTypeDomain:
|
||||
return true
|
||||
case ServiceTargetTargetTypeHost:
|
||||
@@ -3819,6 +3822,9 @@ type ProxyCluster struct {
|
||||
// Online Whether at least one proxy in the cluster has heartbeated within the active window
|
||||
Online bool `json:"online"`
|
||||
|
||||
// Private True when at least one connected proxy in this cluster is running embedded in a netbird client (`netbird proxy`) and serving over a WireGuard tunnel. Lets the dashboard distinguish per-peer / private clusters from centralised ones.
|
||||
Private *bool `json:"private,omitempty"`
|
||||
|
||||
// RequireSubdomain Whether services on this cluster must include a subdomain label
|
||||
RequireSubdomain *bool `json:"require_subdomain,omitempty"`
|
||||
|
||||
@@ -3896,6 +3902,9 @@ type ReverseProxyDomain struct {
|
||||
// SupportsCustomPorts Whether the cluster supports binding arbitrary TCP/UDP ports
|
||||
SupportsCustomPorts *bool `json:"supports_custom_ports,omitempty"`
|
||||
|
||||
// SupportsPrivate Whether the proxy cluster supports private (NetBird-only) services. True when at least one connected proxy in the cluster runs embedded in a netbird client.
|
||||
SupportsPrivate *bool `json:"supports_private,omitempty"`
|
||||
|
||||
// TargetCluster The proxy cluster this domain is validated against (only for custom domains)
|
||||
TargetCluster *string `json:"target_cluster,omitempty"`
|
||||
|
||||
@@ -4085,6 +4094,9 @@ type SentinelOneMatchAttributesNetworkStatus string
|
||||
|
||||
// Service defines model for Service.
|
||||
type Service struct {
|
||||
// AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
|
||||
AccessGroups *[]string `json:"access_groups,omitempty"`
|
||||
|
||||
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
|
||||
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
|
||||
Auth ServiceAuthConfig `json:"auth"`
|
||||
@@ -4114,6 +4126,9 @@ type Service struct {
|
||||
// PortAutoAssigned Whether the listen port was auto-assigned
|
||||
PortAutoAssigned *bool `json:"port_auto_assigned,omitempty"`
|
||||
|
||||
// Private When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
|
||||
Private *bool `json:"private,omitempty"`
|
||||
|
||||
// ProxyCluster The proxy cluster handling this service (derived from domain)
|
||||
ProxyCluster *string `json:"proxy_cluster,omitempty"`
|
||||
|
||||
@@ -4156,6 +4171,9 @@ type ServiceMetaStatus string
|
||||
|
||||
// ServiceRequest defines model for ServiceRequest.
|
||||
type ServiceRequest struct {
|
||||
// AccessGroups NetBird group IDs whose peers may reach this private service over the tunnel. Required when private=true; ignored otherwise. Mutually exclusive with bearer auth (SSO).
|
||||
AccessGroups *[]string `json:"access_groups,omitempty"`
|
||||
|
||||
// AccessRestrictions Connection-level access restrictions based on IP address or geography. Applies to both HTTP and L4 services.
|
||||
AccessRestrictions *AccessRestrictions `json:"access_restrictions,omitempty"`
|
||||
Auth *ServiceAuthConfig `json:"auth,omitempty"`
|
||||
@@ -4178,6 +4196,9 @@ type ServiceRequest struct {
|
||||
// PassHostHeader When true, the original client Host header is passed through to the backend instead of being rewritten to the backend's address
|
||||
PassHostHeader *bool `json:"pass_host_header,omitempty"`
|
||||
|
||||
// Private When true, the service is NetBird-only — its target points at a proxy cluster, inbound peers authenticate via their WireGuard tunnel identity (no OIDC), and an ACL policy is auto-generated from access_groups to the cluster's proxy-peer group. Requires mode=http.
|
||||
Private *bool `json:"private,omitempty"`
|
||||
|
||||
// RewriteRedirects When true, Location headers in backend responses are rewritten to replace the backend address with the public-facing domain
|
||||
RewriteRedirects *bool `json:"rewrite_redirects,omitempty"`
|
||||
|
||||
@@ -4224,6 +4245,12 @@ type ServiceTargetOptions struct {
|
||||
// CustomHeaders Extra headers sent to the backend. Hop-by-hop and proxy-managed headers (Host, Connection, Transfer-Encoding, etc.) are rejected.
|
||||
CustomHeaders *map[string]string `json:"custom_headers,omitempty"`
|
||||
|
||||
// DirectUpstream When true, the proxy dials this target via the host's network stack
|
||||
// instead of through its embedded NetBird client. Use for upstreams
|
||||
// reachable without WireGuard (public APIs, LAN services, localhost
|
||||
// sidecars).
|
||||
DirectUpstream *bool `json:"direct_upstream,omitempty"`
|
||||
|
||||
// PathRewrite Controls how the request path is rewritten before forwarding to the backend. Default strips the matched prefix. "preserve" keeps the full original request path.
|
||||
PathRewrite *ServiceTargetOptionsPathRewrite `json:"path_rewrite,omitempty"`
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,15 @@ service ProxyService {
|
||||
// ValidateSession validates a session token and checks user access permissions.
|
||||
// Called by the proxy after receiving a session token from OIDC callback.
|
||||
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
|
||||
|
||||
// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
|
||||
// checks the resolved user's access against the service's access_groups.
|
||||
// Acts as a fast-path equivalent of OIDC for requests originating on the
|
||||
// netbird mesh: when the source IP maps to a known peer in the calling
|
||||
// account and that peer is in the service's access_groups, the proxy can
|
||||
// issue a session cookie without redirecting through the OIDC flow.
|
||||
// Mirrors ValidateSession's response shape.
|
||||
rpc ValidateTunnelPeer(ValidateTunnelPeerRequest) returns (ValidateTunnelPeerResponse);
|
||||
}
|
||||
|
||||
// ProxyCapabilities describes what a proxy can handle.
|
||||
@@ -45,6 +54,13 @@ message ProxyCapabilities {
|
||||
optional bool require_subdomain = 2;
|
||||
// Whether the proxy has CrowdSec configured and can enforce IP reputation checks.
|
||||
optional bool supports_crowdsec = 3;
|
||||
// Whether the proxy is running embedded in the netbird client and serving
|
||||
// exclusively over the WireGuard tunnel (i.e. `netbird proxy` rather than
|
||||
// the standalone netbird-proxy binary). Surfaces upstream so dashboards can
|
||||
// distinguish per-peer / private clusters from centralised ones.
|
||||
optional bool private = 4;
|
||||
// Whether the proxy enforces ProxyMapping.private (fails closed on ValidateTunnelPeer failure). Management MUST NOT stream private mappings to proxies that don't claim this.
|
||||
optional bool supports_private_service = 5;
|
||||
}
|
||||
|
||||
// GetMappingUpdateRequest is sent to initialise a mapping stream.
|
||||
@@ -86,6 +102,11 @@ message PathTargetOptions {
|
||||
bool proxy_protocol = 5;
|
||||
// Idle timeout before a UDP session is reaped.
|
||||
google.protobuf.Duration session_idle_timeout = 6;
|
||||
// When true, the proxy dials this target via the host's network stack
|
||||
// instead of through the embedded NetBird client. Useful for upstreams
|
||||
// reachable without WireGuard (public APIs, LAN services, localhost
|
||||
// sidecars). Defaults to false — embedded client is the standard path.
|
||||
bool direct_upstream = 7;
|
||||
}
|
||||
|
||||
message PathMapping {
|
||||
@@ -138,6 +159,8 @@ message ProxyMapping {
|
||||
// For L4/TLS: the port the proxy listens on.
|
||||
int32 listen_port = 11;
|
||||
AccessRestrictions access_restrictions = 12;
|
||||
// NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
|
||||
bool private = 13;
|
||||
}
|
||||
|
||||
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
|
||||
@@ -213,6 +236,25 @@ message SendStatusUpdateRequest {
|
||||
ProxyStatus status = 3;
|
||||
bool certificate_issued = 4;
|
||||
optional string error_message = 5;
|
||||
// Per-account inbound listener state for the account that owns
|
||||
// service_id. Populated only when --private-inbound is enabled and the
|
||||
// embedded client for the account is up. Field numbers >=50 reserved
|
||||
// for observability extensions.
|
||||
optional ProxyInboundListener inbound_listener = 50;
|
||||
}
|
||||
|
||||
// ProxyInboundListener describes a per-account inbound listener that the
|
||||
// proxy has bound on the embedded netstack of the account's WireGuard
|
||||
// client. Surfaced so dashboards can render "this account is reachable
|
||||
// at <tunnel_ip>:<https_port> on this proxy".
|
||||
message ProxyInboundListener {
|
||||
// Tunnel IP the embedded netstack listens on. Same address other peers
|
||||
// in the account see for the proxy peer.
|
||||
string tunnel_ip = 1;
|
||||
// TLS port served on tunnel_ip (auto-detected, default 443).
|
||||
uint32 https_port = 2;
|
||||
// Plain-HTTP port served on tunnel_ip (auto-detected, default 80).
|
||||
uint32 http_port = 3;
|
||||
}
|
||||
|
||||
// SendStatusUpdateResponse is intentionally empty to allow for future expansion
|
||||
@@ -254,6 +296,52 @@ message ValidateSessionResponse {
|
||||
string user_id = 2;
|
||||
string user_email = 3;
|
||||
string denied_reason = 4;
|
||||
// peer_group_ids carries the calling user's group memberships so the
|
||||
// proxy can authorise policy-aware middlewares without an additional
|
||||
// management round-trip.
|
||||
repeated string peer_group_ids = 5;
|
||||
// peer_group_names carries the human-readable display names for the
|
||||
// ids in peer_group_ids, ordered identically (positional pairing).
|
||||
// Stamped onto upstream requests as X-NetBird-Groups so downstream
|
||||
// services can read names rather than opaque ids.
|
||||
repeated string peer_group_names = 6;
|
||||
}
|
||||
|
||||
// ValidateTunnelPeerRequest carries the inbound peer's tunnel IP and the
|
||||
// service domain whose group requirements should gate access. The calling
|
||||
// account is inferred from the proxy's gRPC metadata (ProxyToken).
|
||||
message ValidateTunnelPeerRequest {
|
||||
string tunnel_ip = 1;
|
||||
string domain = 2;
|
||||
}
|
||||
|
||||
// ValidateTunnelPeerResponse mirrors ValidateSessionResponse plus a freshly
|
||||
// minted session_token: when valid is true, the proxy installs the token as
|
||||
// a session cookie so subsequent requests skip the management round-trip,
|
||||
// matching the OIDC flow's UX. denied_reason values:
|
||||
// "peer_not_found" — no peer with that tunnel IP in the calling account
|
||||
// "no_user" — peer exists but is not bound to a user
|
||||
// "service_not_found"
|
||||
// "account_mismatch"
|
||||
// "not_in_group" — peer resolved but not in service.access_groups
|
||||
message ValidateTunnelPeerResponse {
|
||||
bool valid = 1;
|
||||
string user_id = 2;
|
||||
string user_email = 3;
|
||||
string denied_reason = 4;
|
||||
// session_token is set only when valid is true. Same shape as the JWT
|
||||
// the OIDC flow produces — proxy installs it via setSessionCookie so the
|
||||
// tunnel fast-path is indistinguishable from OIDC for subsequent requests.
|
||||
string session_token = 5;
|
||||
// peer_group_ids carries the resolved peer's user group memberships so
|
||||
// the proxy can authorise policy-aware middlewares without an additional
|
||||
// management round-trip.
|
||||
repeated string peer_group_ids = 6;
|
||||
// peer_group_names carries the human-readable display names for the
|
||||
// ids in peer_group_ids, ordered identically (positional pairing).
|
||||
// Stamped onto upstream requests as X-NetBird-Groups so downstream
|
||||
// services can read names rather than opaque ids.
|
||||
repeated string peer_group_names = 7;
|
||||
}
|
||||
|
||||
// SyncMappingsRequest is sent by the proxy on the bidirectional SyncMappings
|
||||
@@ -287,3 +375,4 @@ message SyncMappingsResponse {
|
||||
// initial_sync_complete is set on the last message of the initial snapshot.
|
||||
bool initial_sync_complete = 2;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,14 @@ type ProxyServiceClient interface {
|
||||
// ValidateSession validates a session token and checks user access permissions.
|
||||
// Called by the proxy after receiving a session token from OIDC callback.
|
||||
ValidateSession(ctx context.Context, in *ValidateSessionRequest, opts ...grpc.CallOption) (*ValidateSessionResponse, error)
|
||||
// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
|
||||
// checks the resolved user's access against the service's access_groups.
|
||||
// Acts as a fast-path equivalent of OIDC for requests originating on the
|
||||
// netbird mesh: when the source IP maps to a known peer in the calling
|
||||
// account and that peer is in the service's access_groups, the proxy can
|
||||
// issue a session cookie without redirecting through the OIDC flow.
|
||||
// Mirrors ValidateSession's response shape.
|
||||
ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error)
|
||||
}
|
||||
|
||||
type proxyServiceClient struct {
|
||||
@@ -162,6 +170,15 @@ func (c *proxyServiceClient) ValidateSession(ctx context.Context, in *ValidateSe
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *proxyServiceClient) ValidateTunnelPeer(ctx context.Context, in *ValidateTunnelPeerRequest, opts ...grpc.CallOption) (*ValidateTunnelPeerResponse, error) {
|
||||
out := new(ValidateTunnelPeerResponse)
|
||||
err := c.cc.Invoke(ctx, "/management.ProxyService/ValidateTunnelPeer", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProxyServiceServer is the server API for ProxyService service.
|
||||
// All implementations must embed UnimplementedProxyServiceServer
|
||||
// for forward compatibility
|
||||
@@ -183,6 +200,14 @@ type ProxyServiceServer interface {
|
||||
// ValidateSession validates a session token and checks user access permissions.
|
||||
// Called by the proxy after receiving a session token from OIDC callback.
|
||||
ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error)
|
||||
// ValidateTunnelPeer resolves an inbound peer by its WireGuard tunnel IP and
|
||||
// checks the resolved user's access against the service's access_groups.
|
||||
// Acts as a fast-path equivalent of OIDC for requests originating on the
|
||||
// netbird mesh: when the source IP maps to a known peer in the calling
|
||||
// account and that peer is in the service's access_groups, the proxy can
|
||||
// issue a session cookie without redirecting through the OIDC flow.
|
||||
// Mirrors ValidateSession's response shape.
|
||||
ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error)
|
||||
mustEmbedUnimplementedProxyServiceServer()
|
||||
}
|
||||
|
||||
@@ -214,6 +239,9 @@ func (UnimplementedProxyServiceServer) GetOIDCURL(context.Context, *GetOIDCURLRe
|
||||
func (UnimplementedProxyServiceServer) ValidateSession(context.Context, *ValidateSessionRequest) (*ValidateSessionResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ValidateSession not implemented")
|
||||
}
|
||||
func (UnimplementedProxyServiceServer) ValidateTunnelPeer(context.Context, *ValidateTunnelPeerRequest) (*ValidateTunnelPeerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ValidateTunnelPeer not implemented")
|
||||
}
|
||||
func (UnimplementedProxyServiceServer) mustEmbedUnimplementedProxyServiceServer() {}
|
||||
|
||||
// UnsafeProxyServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
@@ -382,6 +410,24 @@ func _ProxyService_ValidateSession_Handler(srv interface{}, ctx context.Context,
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _ProxyService_ValidateTunnelPeer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ValidateTunnelPeerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/management.ProxyService/ValidateTunnelPeer",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(ProxyServiceServer).ValidateTunnelPeer(ctx, req.(*ValidateTunnelPeerRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// ProxyService_ServiceDesc is the grpc.ServiceDesc for ProxyService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -413,6 +459,10 @@ var ProxyService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ValidateSession",
|
||||
Handler: _ProxyService_ValidateSession_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ValidateTunnelPeer",
|
||||
Handler: _ProxyService_ValidateTunnelPeer_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user