mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-10 00:41:29 +02:00
Merge branch 'main' into embedded-vnc
This commit is contained in:
@@ -289,36 +289,64 @@ func getPublicKey(token *jwt.Token, jwks *Jwks) (interface{}, error) {
|
||||
return nil, errKeyNotFound
|
||||
}
|
||||
|
||||
func getPublicKeyFromECDSA(jwk JSONWebKey) (publicKey *ecdsa.PublicKey, err error) {
|
||||
func curveFromName(crv string) (elliptic.Curve, error) {
|
||||
switch crv {
|
||||
case p256:
|
||||
return elliptic.P256(), nil
|
||||
case p384:
|
||||
return elliptic.P384(), nil
|
||||
case p521:
|
||||
return elliptic.P521(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported elliptic curve %q", crv)
|
||||
}
|
||||
}
|
||||
|
||||
func getPublicKeyFromECDSA(jwk JSONWebKey) (*ecdsa.PublicKey, error) {
|
||||
if jwk.X == "" || jwk.Y == "" || jwk.Crv == "" {
|
||||
return nil, fmt.Errorf("ecdsa key incomplete")
|
||||
}
|
||||
|
||||
var xCoordinate []byte
|
||||
if xCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.X); err != nil {
|
||||
curve, err := curveFromName(jwk.Crv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var yCoordinate []byte
|
||||
if yCoordinate, err = base64.RawURLEncoding.DecodeString(jwk.Y); err != nil {
|
||||
return nil, err
|
||||
xCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ecdsa x coordinate: %w", err)
|
||||
}
|
||||
|
||||
publicKey = &ecdsa.PublicKey{}
|
||||
|
||||
var curve elliptic.Curve
|
||||
switch jwk.Crv {
|
||||
case p256:
|
||||
curve = elliptic.P256()
|
||||
case p384:
|
||||
curve = elliptic.P384()
|
||||
case p521:
|
||||
curve = elliptic.P521()
|
||||
yCoordinate, err := base64.RawURLEncoding.DecodeString(jwk.Y)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode ecdsa y coordinate: %w", err)
|
||||
}
|
||||
|
||||
publicKey.Curve = curve
|
||||
publicKey.X = big.NewInt(0).SetBytes(xCoordinate)
|
||||
publicKey.Y = big.NewInt(0).SetBytes(yCoordinate)
|
||||
var x, y big.Int
|
||||
x.SetBytes(xCoordinate)
|
||||
y.SetBytes(yCoordinate)
|
||||
|
||||
bits := curve.Params().BitSize
|
||||
if x.BitLen() > bits {
|
||||
return nil, fmt.Errorf("ecdsa x coordinate is %d bits, exceeds curve %s field size of %d bits", x.BitLen(), jwk.Crv, bits)
|
||||
}
|
||||
if y.BitLen() > bits {
|
||||
return nil, fmt.Errorf("ecdsa y coordinate is %d bits, exceeds curve %s field size of %d bits", y.BitLen(), jwk.Crv, bits)
|
||||
}
|
||||
|
||||
// Round up: P-521's field is 521 bits, so a coordinate needs 66 bytes, not 65.
|
||||
size := (bits + 7) / 8
|
||||
|
||||
// Assemble the SEC 1 uncompressed point (0x04 || X || Y)
|
||||
point := make([]byte, 1+2*size)
|
||||
point[0] = 4
|
||||
x.FillBytes(point[1 : 1+size])
|
||||
y.FillBytes(point[1+size:])
|
||||
|
||||
publicKey, err := ecdsa.ParseUncompressedPublicKey(curve, point)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ecdsa public key: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
214
shared/auth/jwt/validator_test.go
Normal file
214
shared/auth/jwt/validator_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ecdsaJWK builds a JWK for pub using uncompressed-point encoding
|
||||
func ecdsaJWK(t *testing.T, kid string, pub *ecdsa.PublicKey, crv string, size int) JSONWebKey {
|
||||
t.Helper()
|
||||
|
||||
point, err := pub.Bytes()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, point, 1+2*size)
|
||||
require.Equal(t, byte(4), point[0], "expected uncompressed point")
|
||||
|
||||
return JSONWebKey{
|
||||
Kty: "EC",
|
||||
Kid: kid,
|
||||
Use: "sig",
|
||||
Crv: crv,
|
||||
X: base64.RawURLEncoding.EncodeToString(point[1 : 1+size]),
|
||||
Y: base64.RawURLEncoding.EncodeToString(point[1+size:]),
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPublicKeyFromECDSA_RoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
crv string
|
||||
curve elliptic.Curve
|
||||
size int
|
||||
}{
|
||||
{p256, elliptic.P256(), 32},
|
||||
{p384, elliptic.P384(), 48},
|
||||
{p521, elliptic.P521(), 66},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.crv, func(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := getPublicKeyFromECDSA(ecdsaJWK(t, "kid", &priv.PublicKey, tc.crv, tc.size))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, priv.PublicKey.Equal(got), "parsed key differs from the original")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPublicKeyFromECDSA_ShortCoordinate covers IdPs that strip leading zero
|
||||
// bytes from a coordinate instead of padding to the curve's field size.
|
||||
func TestGetPublicKeyFromECDSA_ShortCoordinate(t *testing.T) {
|
||||
var (
|
||||
priv *ecdsa.PrivateKey
|
||||
point []byte
|
||||
)
|
||||
for i := 0; i < 10000; i++ {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
p, err := key.PublicKey.Bytes()
|
||||
require.NoError(t, err)
|
||||
|
||||
if p[1] == 0 || p[33] == 0 {
|
||||
priv, point = key, p
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, priv, "no key with a leading zero coordinate byte was generated")
|
||||
|
||||
jwk := JSONWebKey{
|
||||
Kty: "EC",
|
||||
Kid: "kid",
|
||||
Crv: p256,
|
||||
X: base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[1:33], "\x00")),
|
||||
Y: base64.RawURLEncoding.EncodeToString(bytes.TrimLeft(point[33:], "\x00")),
|
||||
}
|
||||
|
||||
got, err := getPublicKeyFromECDSA(jwk)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, priv.PublicKey.Equal(got))
|
||||
}
|
||||
|
||||
func TestGetPublicKeyFromECDSA_Invalid(t *testing.T) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
valid := ecdsaJWK(t, "kid", &priv.PublicKey, p256, 32)
|
||||
|
||||
offCurve := valid
|
||||
x, err := base64.RawURLEncoding.DecodeString(valid.X)
|
||||
require.NoError(t, err)
|
||||
x[31] ^= 0xff
|
||||
offCurve.X = base64.RawURLEncoding.EncodeToString(x)
|
||||
|
||||
// 33 non-zero bytes is 264 bits, past P-256's 256-bit field.
|
||||
oversized := valid
|
||||
oversized.X = base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 33))
|
||||
|
||||
// P-521 coordinates occupy 66 bytes but only 521 bits, so a full 66-byte
|
||||
// 0xff value (528 bits) is over the field size without being over the byte
|
||||
// length. Only a bit-length bound catches this.
|
||||
overP521 := JSONWebKey{
|
||||
Kty: "EC",
|
||||
Crv: p521,
|
||||
X: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
|
||||
Y: base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{0xff}, 66)),
|
||||
}
|
||||
|
||||
zeroPoint := valid
|
||||
zeroPoint.X = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
zeroPoint.Y = base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
jwk JSONWebKey
|
||||
errContains string
|
||||
}{
|
||||
{name: "missing crv", jwk: JSONWebKey{Kty: "EC", X: valid.X, Y: valid.Y}},
|
||||
{name: "missing x", jwk: JSONWebKey{Kty: "EC", Crv: p256, Y: valid.Y}},
|
||||
{name: "unsupported curve", jwk: JSONWebKey{Kty: "EC", Crv: "P-224", X: valid.X, Y: valid.Y}, errContains: "unsupported elliptic curve"},
|
||||
{name: "undecodable x", jwk: JSONWebKey{Kty: "EC", Crv: p256, X: "!!not base64!!!", Y: valid.Y}, errContains: "decode ecdsa x coordinate"},
|
||||
{name: "coordinate over field size", jwk: oversized, errContains: "exceeds curve P-256 field size of 256 bits"},
|
||||
{name: "p521 coordinate over field size", jwk: overP521, errContains: "exceeds curve P-521 field size of 521 bits"},
|
||||
{name: "off-curve point", jwk: offCurve, errContains: "parse ecdsa public key"},
|
||||
{name: "point at infinity", jwk: zeroPoint, errContains: "parse ecdsa public key"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key, err := getPublicKeyFromECDSA(tc.jwk)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
if tc.errContains != "" {
|
||||
assert.ErrorContains(t, err, tc.errContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAndParse_ECDSA verifies an ES256-signed token end to end, proving
|
||||
// the parsed key actually validates signatures.
|
||||
func TestValidateAndParse_ECDSA(t *testing.T) {
|
||||
const (
|
||||
kid = "es256-kid"
|
||||
issuer = "https://issuer.example.com/"
|
||||
audience = "netbird"
|
||||
)
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
jwks, err := json.Marshal(Jwks{Keys: []JSONWebKey{ecdsaJWK(t, kid, &priv.PublicKey, p256, 32)}})
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwks)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"sub": "user-1",
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
token.Header["kid"] = kid
|
||||
|
||||
signed, err := token.SignedString(priv)
|
||||
require.NoError(t, err)
|
||||
|
||||
v := NewValidator(issuer, []string{audience}, srv.URL, false)
|
||||
|
||||
parsed, err := v.ValidateAndParse(context.Background(), signed)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed.Valid)
|
||||
|
||||
claims, ok := parsed.Claims.(jwt.MapClaims)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "user-1", claims["sub"])
|
||||
|
||||
// A token signed by a different key of the same curve must be rejected.
|
||||
other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
forged := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
|
||||
"iss": issuer,
|
||||
"aud": audience,
|
||||
"sub": "user-1",
|
||||
"iat": time.Now().Add(-time.Minute).Unix(),
|
||||
"exp": time.Now().Add(time.Hour).Unix(),
|
||||
})
|
||||
forged.Header["kid"] = kid
|
||||
|
||||
forgedSigned, err := forged.SignedString(other)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = v.ValidateAndParse(context.Background(), forgedSigned)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1039,6 +1039,7 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
|
||||
RosenpassEnabled: info.RosenpassEnabled,
|
||||
RosenpassPermissive: info.RosenpassPermissive,
|
||||
ServerSSHAllowed: info.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: info.RemoteJobsAllowed,
|
||||
ServerVNCAllowed: info.ServerVNCAllowed,
|
||||
|
||||
DisableClientRoutes: info.DisableClientRoutes,
|
||||
|
||||
@@ -154,6 +154,14 @@ components:
|
||||
type: boolean
|
||||
description: Whether sensitive data should be anonymized in the bundle.
|
||||
example: false
|
||||
anonymize_level:
|
||||
type: string
|
||||
description: How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
example: strict
|
||||
upload_url:
|
||||
type: string
|
||||
description: Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
|
||||
example: https://upload.debug.netbird.io
|
||||
required:
|
||||
- bundle_for
|
||||
- bundle_for_time
|
||||
@@ -976,6 +984,10 @@ components:
|
||||
description: Indicates whether SSH access this peer is allowed or not
|
||||
type: boolean
|
||||
example: true
|
||||
remote_jobs_allowed:
|
||||
description: Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles)
|
||||
type: boolean
|
||||
example: true
|
||||
server_vnc_allowed:
|
||||
description: Indicates whether the embedded VNC server is enabled on this peer
|
||||
type: boolean
|
||||
@@ -5820,6 +5832,57 @@ components:
|
||||
required:
|
||||
- name
|
||||
- checks
|
||||
AgentNetworkAgentConfig:
|
||||
type: object
|
||||
description: The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only.
|
||||
properties:
|
||||
configured:
|
||||
type: boolean
|
||||
description: False only when the account has no Agent Network set up. A caller that no policy covers yet still reads as configured, with an empty providers list.
|
||||
endpoint:
|
||||
type: string
|
||||
description: The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false.
|
||||
example: https://calm-otter.proxy.example.com
|
||||
providers:
|
||||
type: array
|
||||
description: The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller.
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentNetworkAgentConfigProvider'
|
||||
required:
|
||||
- configured
|
||||
- endpoint
|
||||
- providers
|
||||
AgentNetworkAgentConfigProvider:
|
||||
type: object
|
||||
description: One provider the caller may use, reduced to what a local tool needs for configuration.
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Operator-assigned provider label.
|
||||
example: Bedrock prod
|
||||
catalog_id:
|
||||
type: string
|
||||
description: Catalog entry id naming the provider type.
|
||||
example: bedrock_api
|
||||
api_flavor:
|
||||
type: string
|
||||
description: Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
|
||||
example: anthropic
|
||||
all_models_allowed:
|
||||
type: boolean
|
||||
description: True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
|
||||
models:
|
||||
type: array
|
||||
description: The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
|
||||
items:
|
||||
type: string
|
||||
example: [ "anthropic.claude-sonnet-4-5" ]
|
||||
required:
|
||||
- name
|
||||
- catalog_id
|
||||
- api_flavor
|
||||
- all_models_allowed
|
||||
- models
|
||||
AgentNetworkConsumption:
|
||||
type: object
|
||||
description: One per-(dimension, window) consumption counter row. The proxy ticks one row per dimension on every served LLM request; the dashboard reads this listing to surface live counter growth.
|
||||
@@ -13484,7 +13547,7 @@ paths:
|
||||
/api/agent-network/access-logs:
|
||||
get:
|
||||
summary: List Agent Network access logs
|
||||
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained.
|
||||
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access log entries. Available only when the account has log collection enabled; otherwise entries are not retained. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13599,7 +13662,7 @@ paths:
|
||||
/api/agent-network/access-log-sessions:
|
||||
get:
|
||||
summary: List Agent Network access logs grouped by session
|
||||
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled.
|
||||
description: Returns a paginated, server-side-filtered list of agent-network (LLM) access logs grouped by session. The page unit is a session (total_records counts sessions); each session carries an aggregate summary and its ordered entries. Requests the client sent no session id for each form their own singleton group. Accepts the same filters as the flat access-logs endpoint. Available only when the account has log collection enabled. Callers without the account-wide grant are not denied - the response is scoped to their own requests (any user_id or group_id filter is overridden).
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13714,7 +13777,7 @@ paths:
|
||||
/api/agent-network/usage/overview:
|
||||
get:
|
||||
summary: Agent Network usage overview
|
||||
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection).
|
||||
description: Returns agent-network token and cost usage aggregated into time buckets, server-side filtered. Usage is always collected (independent of log collection). Callers without the account-wide grant are not denied - the response is scoped to their own usage (any user_id or group_id filter is overridden).
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
@@ -13814,6 +13877,25 @@ paths:
|
||||
"$ref": "#/components/responses/forbidden"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/agent-config:
|
||||
get:
|
||||
summary: Retrieve the caller's Agent Network agent config
|
||||
description: Returns everything the caller needs to configure a local AI tool and nothing more - the account's Agent Network endpoint plus the providers and models the caller's own policies allow. Available to every authenticated user regardless of role; the response never contains provider credentials, policy or guardrail configuration, or providers the caller cannot reach.
|
||||
tags: [ Agent Network ]
|
||||
security:
|
||||
- BearerAuth: [ ]
|
||||
- TokenAuth: [ ]
|
||||
responses:
|
||||
'200':
|
||||
description: The caller-scoped Agent Network agent config
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentNetworkAgentConfig'
|
||||
'401':
|
||||
"$ref": "#/components/responses/requires_authentication"
|
||||
'500':
|
||||
"$ref": "#/components/responses/internal_error"
|
||||
/api/agent-network/settings:
|
||||
get:
|
||||
summary: Retrieve Agent Network settings
|
||||
|
||||
@@ -1931,6 +1931,36 @@ type AgentNetworkAccessLogsResponse struct {
|
||||
TotalRecords int `json:"total_records"`
|
||||
}
|
||||
|
||||
// AgentNetworkAgentConfig The caller-scoped Agent Network connection config backing the self-service "Connect your agent" view. Available to every authenticated user; the providers are computed from the caller's own groups and the answer carries display metadata only.
|
||||
type AgentNetworkAgentConfig struct {
|
||||
// Configured False only when the account has no Agent Network set up. A caller that no policy covers yet still reads as configured, with an empty providers list.
|
||||
Configured bool `json:"configured"`
|
||||
|
||||
// Endpoint The account's Agent Network base URL, reachable over the NetBird tunnel only. Returned to every member of a configured account - it authorizes nothing on its own, since the gateway still refuses every request no policy permits. Empty when configured is false.
|
||||
Endpoint string `json:"endpoint"`
|
||||
|
||||
// Providers The providers at least one of the caller's policies authorizes, in creation order. Empty when no policy covers the caller.
|
||||
Providers []AgentNetworkAgentConfigProvider `json:"providers"`
|
||||
}
|
||||
|
||||
// AgentNetworkAgentConfigProvider One provider the caller may use, reduced to what a local tool needs for configuration.
|
||||
type AgentNetworkAgentConfigProvider struct {
|
||||
// AllModelsAllowed True when no model allowlist restricts this provider for the caller; models then lists the declared or catalog models as a courtesy.
|
||||
AllModelsAllowed bool `json:"all_models_allowed"`
|
||||
|
||||
// ApiFlavor Request-body shape the provider speaks ("anthropic", "openai"). Empty when the gateway dispatches it by URL path instead.
|
||||
ApiFlavor string `json:"api_flavor"`
|
||||
|
||||
// CatalogId Catalog entry id naming the provider type.
|
||||
CatalogId string `json:"catalog_id"`
|
||||
|
||||
// Models The effective model allowlist for the caller (or the declared/catalog models when all_models_allowed is true).
|
||||
Models []string `json:"models"`
|
||||
|
||||
// Name Operator-assigned provider label.
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// AgentNetworkBudgetRule Account-level budget rule. A limit-only rule bound to groups and/or users that applies across all policies as a min-wins ceiling. Empty targets means it applies to every caller.
|
||||
type AgentNetworkBudgetRule struct {
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
@@ -2575,6 +2605,9 @@ type BundleParameters struct {
|
||||
// Anonymize Whether sensitive data should be anonymized in the bundle.
|
||||
Anonymize bool `json:"anonymize"`
|
||||
|
||||
// AnonymizeLevel How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
AnonymizeLevel *string `json:"anonymize_level,omitempty"`
|
||||
|
||||
// BundleFor Whether to generate a bundle for the given timeframe.
|
||||
BundleFor bool `json:"bundle_for"`
|
||||
|
||||
@@ -2583,6 +2616,9 @@ type BundleParameters struct {
|
||||
|
||||
// LogFileCount Maximum number of log files to include in the bundle.
|
||||
LogFileCount int `json:"log_file_count"`
|
||||
|
||||
// UploadUrl Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
|
||||
UploadUrl *string `json:"upload_url,omitempty"`
|
||||
}
|
||||
|
||||
// BundleResult defines model for BundleResult.
|
||||
@@ -4327,6 +4363,9 @@ type PeerLocalFlags struct {
|
||||
// LazyConnectionEnabled Indicates whether lazy connection is enabled on this peer
|
||||
LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"`
|
||||
|
||||
// RemoteJobsAllowed Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles)
|
||||
RemoteJobsAllowed *bool `json:"remote_jobs_allowed,omitempty"`
|
||||
|
||||
// RosenpassEnabled Indicates whether Rosenpass is enabled on this peer
|
||||
RosenpassEnabled *bool `json:"rosenpass_enabled,omitempty"`
|
||||
|
||||
|
||||
@@ -54,6 +54,10 @@ type NetworkMapData struct { //nolint:revive // established name across the code
|
||||
// builder can load because they are never written to the database.
|
||||
Services []*nmdata.Service
|
||||
|
||||
// Domains are the account's registered reverse-proxy domains, used to
|
||||
// resolve the zone apex a private service's records hang under.
|
||||
Domains []nmdata.ProxyDomain
|
||||
|
||||
peerGroupsOnce sync.Once
|
||||
peerGroupsIdx map[string]map[string]struct{}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ type Peer struct {
|
||||
IP netip.Addr
|
||||
IPv6 netip.Addr
|
||||
RequiresApproval bool
|
||||
Connected bool
|
||||
ExtraDNSLabels []string
|
||||
Meta PeerSystemMeta
|
||||
ProxyMeta ProxyMeta
|
||||
@@ -39,6 +40,14 @@ type ProxyMeta struct {
|
||||
Cluster string
|
||||
}
|
||||
|
||||
// ProxyDomain is the slim twin of a registered reverse-proxy domain, carrying
|
||||
// what private-service zone resolution needs: the apex a service domain can sit
|
||||
// under, and the cluster it is registered against.
|
||||
type ProxyDomain struct {
|
||||
Domain string
|
||||
TargetCluster string
|
||||
}
|
||||
|
||||
// PeerSystemMeta is the slim twin of peer.PeerSystemMeta.
|
||||
type PeerSystemMeta struct {
|
||||
WtVersion string
|
||||
|
||||
@@ -9,6 +9,7 @@ type Service struct {
|
||||
Enabled bool
|
||||
Private bool
|
||||
Mode string
|
||||
Domain string
|
||||
ProxyCluster string
|
||||
AccessGroups []string
|
||||
Targets []*ServiceTarget
|
||||
|
||||
124
shared/management/networkmap/privatezones.go
Normal file
124
shared/management/networkmap/privatezones.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package networkmap
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
// privateServiceDNSRecordTTL is short so proxy-peer changes propagate quickly.
|
||||
const privateServiceDNSRecordTTL = 5
|
||||
|
||||
// BuildPrivateServiceCandidates derives the per-service DNS records a private
|
||||
// service publishes, from the twin's own services. It is the counterpart of
|
||||
// InjectProxyPolicies: that one synthesises the ACL half of a private service,
|
||||
// this one the DNS half, and both read nmd.Services so a service added to the
|
||||
// twin after it was loaded — an agent-network service is synthesised in memory
|
||||
// and never persisted — reaches the peer with both halves rather than one.
|
||||
//
|
||||
// The per-peer access-group gate and the merge by apex stay in the components
|
||||
// calculation; this only precomputes what is account-wide.
|
||||
func (nmd *NetworkMapData) BuildPrivateServiceCandidates() {
|
||||
if len(nmd.Services) == 0 {
|
||||
nmd.PrivateServiceCandidates = nil
|
||||
return
|
||||
}
|
||||
|
||||
proxyPeersByCluster := nmd.connectedProxyPeersByCluster()
|
||||
if len(proxyPeersByCluster) == 0 {
|
||||
nmd.PrivateServiceCandidates = nil
|
||||
return
|
||||
}
|
||||
|
||||
var out []PrivateServiceCandidate
|
||||
for _, svc := range nmd.Services {
|
||||
if svc == nil || !svc.Enabled || !svc.Private || len(svc.AccessGroups) == 0 || svc.Domain == "" {
|
||||
continue
|
||||
}
|
||||
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
|
||||
if len(proxyPeers) == 0 {
|
||||
continue
|
||||
}
|
||||
apex := nmd.privateServiceApex(svc)
|
||||
if apex == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
records := make([]nmdata.SimpleRecord, 0, len(proxyPeers))
|
||||
for _, p := range proxyPeers {
|
||||
records = append(records, nmdata.SimpleRecord{
|
||||
Name: dns.Fqdn(svc.Domain),
|
||||
Type: int(dns.TypeA),
|
||||
Class: "IN",
|
||||
TTL: privateServiceDNSRecordTTL,
|
||||
RData: p.IP.String(),
|
||||
})
|
||||
}
|
||||
|
||||
out = append(out, PrivateServiceCandidate{
|
||||
AccessGroups: svc.AccessGroups,
|
||||
Zone: nmdata.CustomZone{
|
||||
// NonAuthoritative keeps the zone match-only, so names without
|
||||
// an explicit record fall through to the upstream resolver
|
||||
// instead of returning NXDOMAIN for the whole apex.
|
||||
Domain: dns.Fqdn(apex),
|
||||
Records: records,
|
||||
NonAuthoritative: true,
|
||||
SearchDomainDisabled: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
nmd.PrivateServiceCandidates = out
|
||||
}
|
||||
|
||||
// privateServiceApex resolves the zone a service's record hangs under: the
|
||||
// cluster when the service sits directly beneath it, otherwise the longest
|
||||
// registered custom domain pointing at that same cluster. A service whose
|
||||
// domain matches no registered apex publishes nothing, since a zone the client
|
||||
// never intercepts cannot answer the query.
|
||||
func (nmd *NetworkMapData) privateServiceApex(svc *nmdata.Service) string {
|
||||
if domainUnderSuffix(svc.Domain, svc.ProxyCluster) {
|
||||
return svc.ProxyCluster
|
||||
}
|
||||
|
||||
apex := ""
|
||||
for _, d := range nmd.Domains {
|
||||
if d.TargetCluster != svc.ProxyCluster {
|
||||
continue
|
||||
}
|
||||
if domainUnderSuffix(svc.Domain, d.Domain) && len(d.Domain) > len(apex) {
|
||||
apex = d.Domain
|
||||
}
|
||||
}
|
||||
return apex
|
||||
}
|
||||
|
||||
func domainUnderSuffix(domain, suffix string) bool {
|
||||
if suffix == "" {
|
||||
return false
|
||||
}
|
||||
return domain == suffix || strings.HasSuffix(domain, "."+suffix)
|
||||
}
|
||||
|
||||
// connectedProxyPeersByCluster groups the account's embedded proxy peers by the
|
||||
// cluster they serve, keeping only connected ones.
|
||||
func (nmd *NetworkMapData) connectedProxyPeersByCluster() map[string][]*nmdata.Peer {
|
||||
var out map[string][]*nmdata.Peer
|
||||
for _, peer := range nmd.Peers {
|
||||
if peer == nil || !peer.ProxyMeta.Embedded || !peer.Connected || !peer.IP.IsValid() {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = make(map[string][]*nmdata.Peer)
|
||||
}
|
||||
out[peer.ProxyMeta.Cluster] = append(out[peer.ProxyMeta.Cluster], peer)
|
||||
}
|
||||
for _, peers := range out {
|
||||
slices.SortFunc(peers, func(a, b *nmdata.Peer) int { return strings.Compare(a.ID, b.ID) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
148
shared/management/networkmap/privatezones_test.go
Normal file
148
shared/management/networkmap/privatezones_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package networkmap
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
|
||||
)
|
||||
|
||||
func proxyPeer(id, ip, cluster string, connected bool) *nmdata.Peer {
|
||||
return &nmdata.Peer{
|
||||
ID: id, Key: id + "-key", IP: netip.MustParseAddr(ip), Connected: connected,
|
||||
ProxyMeta: nmdata.ProxyMeta{Embedded: true, Cluster: cluster},
|
||||
}
|
||||
}
|
||||
|
||||
func privateService(id, domain, cluster string, groups ...string) *nmdata.Service {
|
||||
return &nmdata.Service{
|
||||
ID: id, Enabled: true, Private: true, Mode: "http",
|
||||
Domain: domain, ProxyCluster: cluster, AccessGroups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
func twinWithProxy(services ...*nmdata.Service) *NetworkMapData {
|
||||
return &NetworkMapData{
|
||||
Peers: map[string]*nmdata.Peer{
|
||||
"proxy-1": proxyPeer("proxy-1", "100.64.0.99", "eu.proxy.netbird.io", true),
|
||||
},
|
||||
Services: services,
|
||||
}
|
||||
}
|
||||
|
||||
// An agent-network service is synthesised in memory and never persisted, so it
|
||||
// only ever reaches the twin through nmd.Services. Deriving the zone from that
|
||||
// same field is what stops it from arriving with an ACL and no name.
|
||||
func TestBuildPrivateServiceCandidates_SynthesisedServiceGetsAZone(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService(
|
||||
"agent-network-acct-1", "acct-1.agent.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 0,
|
||||
"a service whose domain sits under no registered apex publishes nothing")
|
||||
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "agent.netbird.io", TargetCluster: "eu.proxy.netbird.io"},
|
||||
}
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
got := nmd.PrivateServiceCandidates[0]
|
||||
assert.Equal(t, []string{"grp-admins"}, got.AccessGroups)
|
||||
assert.Equal(t, "agent.netbird.io.", got.Zone.Domain, "apex is the registered domain, not the service FQDN")
|
||||
assert.True(t, got.Zone.NonAuthoritative, "zone stays match-only")
|
||||
assert.True(t, got.Zone.SearchDomainDisabled)
|
||||
require.Len(t, got.Zone.Records, 1)
|
||||
assert.Equal(t, nmdata.SimpleRecord{
|
||||
Name: "acct-1.agent.netbird.io.", Type: 1, Class: "IN", TTL: 5, RData: "100.64.0.99",
|
||||
}, got.Zone.Records[0])
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_ClusterApexNeedsNoRegisteredDomain(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
assert.Equal(t, "eu.proxy.netbird.io.", nmd.PrivateServiceCandidates[0].Zone.Domain)
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_LongestRegisteredApexWins(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "app.sub.example.com", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "example.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
{Domain: "sub.example.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
{Domain: "other.com", TargetCluster: "eu.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
assert.Equal(t, "sub.example.com.", nmd.PrivateServiceCandidates[0].Zone.Domain)
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_RegisteredApexOfAnotherClusterIsIgnored(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "app.example.com", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Domains = []nmdata.ProxyDomain{
|
||||
{Domain: "example.com", TargetCluster: "us.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates)
|
||||
}
|
||||
|
||||
// A disconnected proxy peer's tunnel IP does not answer, so publishing it
|
||||
// black-holes the name for as long as a client caches the record.
|
||||
func TestBuildPrivateServiceCandidates_OnlyConnectedProxyPeersSurface(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", false)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
require.Len(t, nmd.PrivateServiceCandidates[0].Zone.Records, 1)
|
||||
assert.Equal(t, "100.64.0.99", nmd.PrivateServiceCandidates[0].Zone.Records[0].RData)
|
||||
|
||||
nmd.Peers["proxy-1"].Connected = false
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates, "no connected proxy peer means no zone at all")
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_SkipsServicesThatGrantNothing(t *testing.T) {
|
||||
cases := map[string]func(*nmdata.Service){
|
||||
"disabled": func(s *nmdata.Service) { s.Enabled = false },
|
||||
"not private": func(s *nmdata.Service) { s.Private = false },
|
||||
"no access groups": func(s *nmdata.Service) { s.AccessGroups = nil },
|
||||
"no domain": func(s *nmdata.Service) { s.Domain = "" },
|
||||
"other cluster": func(s *nmdata.Service) { s.ProxyCluster = "us.proxy.netbird.io" },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins")
|
||||
mutate(svc)
|
||||
nmd := twinWithProxy(svc)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
assert.Empty(t, nmd.PrivateServiceCandidates)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrivateServiceCandidates_MultipleConnectedProxyPeersEachGetARecord(t *testing.T) {
|
||||
nmd := twinWithProxy(privateService("svc-1", "myapp.eu.proxy.netbird.io", "eu.proxy.netbird.io", "grp-admins"))
|
||||
nmd.Peers["proxy-2"] = proxyPeer("proxy-2", "100.64.0.100", "eu.proxy.netbird.io", true)
|
||||
|
||||
nmd.BuildPrivateServiceCandidates()
|
||||
|
||||
require.Len(t, nmd.PrivateServiceCandidates, 1)
|
||||
records := nmd.PrivateServiceCandidates[0].Zone.Records
|
||||
require.Len(t, records, 2)
|
||||
assert.Equal(t, "100.64.0.99", records[0].RData, "records are ordered by proxy peer id")
|
||||
assert.Equal(t, "100.64.0.100", records[1].RData)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -114,6 +114,9 @@ message BundleParameters {
|
||||
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
|
||||
// Unknown values are treated as "strict".
|
||||
string anonymize_level = 5;
|
||||
// upload_url is the service URL the client requests an upload URL from
|
||||
// before uploading the bundle. Empty selects the default upload server.
|
||||
string upload_url = 6;
|
||||
}
|
||||
|
||||
message BundleResult {
|
||||
@@ -232,6 +235,11 @@ message Flags {
|
||||
|
||||
bool disableIPv6 = 16;
|
||||
|
||||
// remoteJobsAllowed mirrors the peer's local opt-in for management-requested
|
||||
// remote jobs (e.g. debug bundles). Reported so the dashboard can surface
|
||||
// peers that have opted out.
|
||||
bool remoteJobsAllowed = 17;
|
||||
|
||||
bool serverVNCAllowed = 18;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user