Compare commits

..

5 Commits

Author SHA1 Message Date
Dmitri Dolguikh
25e882004f added retrieval of policies
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 18:06:28 +02:00
Dmitri Dolguikh
15003258d2 networkmap read-only interface for pgsql
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-28 16:46:45 +02:00
Dmitri Dolguikh
2af3a5fba5 do not send resource policies map over the wire
Signed-off-by: Dmitri Dolguikh <dmitri.external@netbird.io>
2026-07-27 19:00:30 +02:00
pascal
a6603a2e0a have explicit network map data type and do calculation from there 2026-07-27 15:17:30 +02:00
pascal
7ed3737cda revert component types 2026-07-24 15:16:55 +02:00
142 changed files with 4929 additions and 7895 deletions

View File

@@ -5,13 +5,6 @@ on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
bedrock_model:
description: >-
Bedrock inference-profile id to drive the matrix with, exactly as
AWS issues it. Leave empty for the Sonnet 4.6 default.
required: false
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -69,8 +62,6 @@ jobs:
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
# to "global", model to a pinned claude snapshot.
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}

View File

@@ -273,8 +273,8 @@ dockers_v2:
- netbirdio/netbird
- ghcr.io/netbirdio/netbird
tags:
- "{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
- "v{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh

View File

@@ -464,8 +464,6 @@ func Test_RemovePeer(t *testing.T) {
}
func Test_ConnectPeers(t *testing.T) {
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
peer1Key, _ := wgtypes.GeneratePrivateKey()
@@ -507,8 +505,12 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
localIP1 := "127.0.0.1"
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
localIP, err := getLocalIP()
if err != nil {
t.Fatal(err)
}
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
if err != nil {
t.Fatal(err)
}
@@ -544,8 +546,7 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
localIP2 := "127.0.0.1"
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
if err != nil {
t.Fatal(err)
}
@@ -620,3 +621,28 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
}
return wgtypes.Peer{}, fmt.Errorf("peer not found")
}
func getLocalIP() (string, error) {
// Get all interfaces
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.IsLoopback() {
continue
}
if ipNet.IP.To4() == nil {
continue
}
return ipNet.IP.String(), nil
}
return "", fmt.Errorf("no local IP found")
}

View File

@@ -9,220 +9,12 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/netbirdio/netbird/e2e/harness"
"github.com/netbirdio/netbird/shared/management/http/api"
)
// per1k is a model's published USD rates per 1k tokens. read is the prompt-cache read rate
// (OpenAI: the cached-input discount rate); write is the cache-creation rate where one exists.
type per1k struct{ in, out, read, write float64 }
// publishedPer1k hardcodes the vendors' PUBLISHED rates for the models the live matrix can drive,
// keyed by the normalized model id the proxy stamps. Deliberately independent of the proxy's
// pricing table so a wrong embedded rate or a broken normalization fails the run.
var publishedPer1k = map[string]per1k{
"gpt-4o-mini": {0.00015, 0.0006, 0.000075, 0},
"gpt-4o": {0.0025, 0.01, 0.00125, 0},
"claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
"claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
"claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
"kimi-k3": {0.003, 0.015, 0.0003, 0.003}, // no published write rate: bills at the input rate
"anthropic.claude-haiku-4-5": {0.001, 0.005, 0.0001, 0.00125},
"anthropic.claude-sonnet-4-5": {0.003, 0.015, 0.0003, 0.00375},
"anthropic.claude-sonnet-4-6": {0.003, 0.015, 0.0003, 0.00375},
}
// rawCostVerificationSQL is the operator-facing double-check, run straight against the management
// sqlite store: recompute each usage row's expected total and cache cost from its own persisted
// token buckets and hardcoded published rates. OpenAI counts cached tokens as a subset of input;
// Anthropic-shape providers count cache buckets additively.
const rawCostVerificationSQL = `
WITH rates(model, in_rate, out_rate, read_rate, write_rate) AS (
VALUES
('gpt-4o-mini', 0.00015, 0.0006, 0.000075, 0.0),
('gpt-4o', 0.0025, 0.01, 0.00125, 0.0),
('claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
('claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
('claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375),
('kimi-k3', 0.003, 0.015, 0.0003, 0.003),
('anthropic.claude-haiku-4-5', 0.001, 0.005, 0.0001, 0.00125),
('anthropic.claude-sonnet-4-5', 0.003, 0.015, 0.0003, 0.00375),
('anthropic.claude-sonnet-4-6', 0.003, 0.015, 0.0003, 0.00375)
)
SELECT
u.provider,
u.model,
u.input_tokens,
u.output_tokens,
u.cached_input_tokens,
u.cache_creation_tokens,
u.input_cost_usd,
u.cached_input_cost_usd,
u.cache_creation_cost_usd,
u.output_cost_usd,
-- No cost_usd / cache_cost_usd columns are stored: both are derived from the
-- four per-bucket columns above, exactly as the API renders them.
(u.input_cost_usd + u.cached_input_cost_usd + u.cache_creation_cost_usd + u.output_cost_usd) AS cost_usd,
(u.cached_input_cost_usd + u.cache_creation_cost_usd) AS cache_cost_usd,
CASE WHEN u.provider = 'openai' THEN
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
ELSE
u.input_tokens*r.in_rate/1000.0
END AS expected_input,
CASE WHEN u.provider = 'openai' THEN
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
ELSE
u.cached_input_tokens*r.read_rate/1000.0
END AS expected_cached_input,
CASE WHEN u.provider = 'openai' THEN
0.0
ELSE
u.cache_creation_tokens*r.write_rate/1000.0
END AS expected_cache_creation,
u.output_tokens*r.out_rate/1000.0 AS expected_output,
CASE WHEN u.provider = 'openai' THEN
(u.input_tokens - MIN(u.cached_input_tokens, u.input_tokens))*r.in_rate/1000.0
+ MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
+ u.output_tokens*r.out_rate/1000.0
ELSE
u.input_tokens*r.in_rate/1000.0 + u.cached_input_tokens*r.read_rate/1000.0
+ u.cache_creation_tokens*r.write_rate/1000.0 + u.output_tokens*r.out_rate/1000.0
END AS expected_total,
CASE WHEN u.provider = 'openai' THEN
MIN(u.cached_input_tokens, u.input_tokens)*r.read_rate/1000.0
ELSE
u.cached_input_tokens*r.read_rate/1000.0 + u.cache_creation_tokens*r.write_rate/1000.0
END AS expected_cache
FROM agent_network_request_usage u
JOIN rates r ON r.model = u.model
ORDER BY u.timestamp`
// verifyUsageRowsSQL re-checks every persisted usage row directly in the management sqlite store,
// bypassing the API path — the same audit an operator can run on a production store.db.
func verifyUsageRowsSQL(t *testing.T, srv *harness.Combined) {
t.Helper()
dbPath, err := srv.SnapshotStoreDB(t.TempDir())
require.NoError(t, err, "snapshot management sqlite store")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
require.NoError(t, err, "open store snapshot")
sqlDB, err := db.DB()
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
rows, err := db.Raw(rawCostVerificationSQL).Rows()
require.NoError(t, err, "run raw cost verification query")
defer func() { _ = rows.Close() }()
verified := 0
for rows.Next() {
var provider, model string
var inTok, outTok, readTok, writeTok int64
var inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost float64
var wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache float64
require.NoError(t, rows.Scan(&provider, &model, &inTok, &outTok, &readTok, &writeTok,
&inCost, &cachedInCost, &cacheCreateCost, &outCost, &cost, &cacheCost,
&wantInput, &wantCachedInput, &wantCacheCreation, &wantOutput, &wantTotal, &wantCache), "scan usage row")
t.Logf("[sql] %s/%s: in=%d out=%d cache_read=%d cache_write=%d stored in/cached/create/out=$%.6f/$%.6f/$%.6f/$%.6f total=$%.6f cache=$%.6f expected total=$%.6f cache=$%.6f",
provider, model, inTok, outTok, readTok, writeTok,
inCost, cachedInCost, cacheCreateCost, outCost, cost, cacheCost, wantTotal, wantCache)
assert.InDeltaf(t, wantInput, inCost, 1e-6, "stored input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCachedInput, cachedInCost, 1e-6, "stored cached_input_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCacheCreation, cacheCreateCost, 1e-6, "stored cache_creation_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantOutput, outCost, 1e-6, "stored output_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantTotal, cost, 1e-6, "derived cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, wantCache, cacheCost, 1e-6, "derived cache_cost_usd for %s/%s must match the published-rate recompute", provider, model)
assert.InDeltaf(t, inCost+cachedInCost+cacheCreateCost+outCost, cost, 1e-9,
"stored buckets must sum to the derived cost_usd for %s/%s", provider, model)
verified++
}
require.NoError(t, rows.Err(), "iterate usage rows")
require.Positive(t, verified, "raw SQL check must cover at least one usage row")
t.Logf("[sql] verified %d usage rows in store.db against published rates", verified)
gwRows, err := db.Raw(`SELECT model,
(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd) AS cost_usd
FROM agent_network_request_usage WHERE model LIKE '%/%'`).Rows()
require.NoError(t, err, "query gateway-prefixed usage rows")
defer func() { _ = gwRows.Close() }()
for gwRows.Next() {
var model string
var cost float64
require.NoError(t, gwRows.Scan(&model, &cost), "scan gateway usage row")
t.Logf("[sql] gateway %s: stored=$%.6f (must be 0 — deliberately unpriced)", model, cost)
assert.Zerof(t, cost, "gateway-prefixed model %q must store cost 0, never a guessed rate", model)
}
require.NoError(t, gwRows.Err(), "iterate gateway usage rows")
}
// validateAccessLogCost recomputes a live access-log row's expected total and cache cost from the
// published per-1k rates and the row's persisted token buckets, and asserts both stored values.
// Gateway-prefixed model ids the proxy deliberately does not price must store cost 0.
func validateAccessLogCost(t *testing.T, pc providerCase, row api.AgentNetworkAccessLog) {
t.Helper()
model := catalogModel(pc)
provider := ""
if row.Provider != nil {
provider = *row.Provider
}
t.Logf("[cost] %s: provider=%s model=%s in=%d out=%d total=%d cache_read=%d cache_write=%d cost=$%.6f cache_cost=$%.6f",
pc.name, provider, model, row.InputTokens, row.OutputTokens, row.TotalTokens,
row.CachedInputTokens, row.CacheCreationTokens, row.CostUsd, row.CacheCostUsd)
rates, known := publishedPer1k[model]
if !known {
if strings.Contains(model, "/") {
assert.Zerof(t, row.CostUsd, "gateway-prefixed model %q is not priced so the cost meter must skip (cost 0)", model)
return
}
t.Logf("[cost] %s: no published rate on file for model %q (env-overridden?); skipping cost validation", pc.name, model)
return
}
// input_tokens may legitimately be 0: Moonshot/Kimi reports fully cached prompts under the cache
// buckets only. Output and total must always be present on a priced row.
require.Positive(t, row.OutputTokens, "priced row must carry output tokens")
require.Positive(t, row.TotalTokens, "priced row must carry total tokens")
var wantInput, wantCachedInput, wantCacheCreation float64
if provider == "openai" {
cached := min(row.CachedInputTokens, row.InputTokens) // cached is a subset of input
wantInput = float64(row.InputTokens-cached) / 1000 * rates.in
wantCachedInput = float64(cached) / 1000 * rates.read
// OpenAI has no cache-write bucket; wantCacheCreation stays 0.
} else {
// Anthropic / Bedrock shape: cache buckets are additive to input_tokens.
wantInput = float64(row.InputTokens) / 1000 * rates.in
wantCachedInput = float64(row.CachedInputTokens) / 1000 * rates.read
wantCacheCreation = float64(row.CacheCreationTokens) / 1000 * rates.write
}
wantOutput := float64(row.OutputTokens) / 1000 * rates.out
wantCache := wantCachedInput + wantCacheCreation
wantTotal := wantInput + wantCache + wantOutput
t.Logf("[cost] %s: expecting input=$%.6f cached_input=$%.6f cache_creation=$%.6f output=$%.6f total=$%.6f cache=$%.6f from published rates",
pc.name, wantInput, wantCachedInput, wantCacheCreation, wantOutput, wantTotal, wantCache)
assert.InDeltaf(t, wantInput, row.InputCostUsd, 1e-6, "stored input_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCachedInput, row.CachedInputCostUsd, 1e-6, "stored cached_input_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCacheCreation, row.CacheCreationCostUsd, 1e-6, "stored cache_creation_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantOutput, row.OutputCostUsd, 1e-6, "stored output_cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantTotal, row.CostUsd, 1e-6, "derived cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, wantCache, row.CacheCostUsd, 1e-6, "derived cache_cost_usd for %s (%s)", pc.name, model)
// The aggregates must be exactly the sum of the stored components, not an
// independently-computed figure that could drift from the breakdown.
assert.InDeltaf(t, row.InputCostUsd+row.CachedInputCostUsd+row.CacheCreationCostUsd+row.OutputCostUsd,
row.CostUsd, 1e-9, "stored buckets must sum to the derived cost_usd for %s (%s)", pc.name, model)
assert.InDeltaf(t, row.CachedInputCostUsd+row.CacheCreationCostUsd,
row.CacheCostUsd, 1e-9, "stored cache buckets must sum to the derived cache_cost_usd for %s (%s)", pc.name, model)
}
// providerCase is one entry in the live provider matrix. The same scenario runs
// for every available provider; availability is keyed off env vars so the suite
// covers whatever credentials are present (source ~/.llm-keys locally / set the
@@ -324,12 +116,12 @@ func availableProviders() []providerCase {
if region == "" {
region = "eu-central-1"
}
// A valid Bedrock inference-profile id, overridable per account (AWS_BEDROCK_MODEL, also the
// workflow's bedrock_model dispatch input). `global.` profiles work from any region. Defaults to
// Sonnet 4.6, whose id convention dropped the -YYYYMMDD-v1:0 suffix that Haiku 4.5 still carries.
// A valid Bedrock inference-profile id (region prefix + date + version),
// overridable per account. `global.` profiles can be invoked from any
// region; set AWS_BEDROCK_MODEL to match the enabled profile for the token.
model := os.Getenv("AWS_BEDROCK_MODEL")
if model == "" {
model = "global.anthropic.claude-sonnet-4-6"
model = "global.anthropic.claude-haiku-4-5-20251001-v1:0"
}
ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock})
}
@@ -465,10 +257,6 @@ func TestProvidersMatrix(t *testing.T) {
// session id and confirm the marker propagated end-to-end.
sessionID := "e2e-session-" + pc.name
// A long-form prompt so completions carry realistic token counts for cost validation;
// max_tokens in the harness bodies (2048) lets the full answer through.
const matrixPrompt = "explain GitHub workflow in 1000 words"
// Retry briefly to absorb tunnel/DNS jitter on the first call.
var code int
var body string
@@ -479,11 +267,11 @@ func TestProvidersMatrix(t *testing.T) {
var cerr error
switch pc.kind {
case harness.WireVertex:
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, matrixPrompt, sessionID)
c, b, cerr = cl.Vertex(ctx, settings.Endpoint, proxyIP, pc.project, pc.region, pc.model, "Reply with exactly: pong", sessionID)
case harness.WireBedrock:
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, matrixPrompt, sessionID)
c, b, cerr = cl.Bedrock(ctx, settings.Endpoint, proxyIP, pc.model, "Reply with exactly: pong", sessionID)
default:
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, matrixPrompt, sessionID)
c, b, cerr = cl.ChatPrefixed(ctx, settings.Endpoint, proxyIP, pc.pathPrefix, pc.kind, pc.model, "Reply with exactly: pong", sessionID)
}
if cerr == nil {
code, body = c, b
@@ -502,7 +290,6 @@ func TestProvidersMatrix(t *testing.T) {
// The session id sent as x-session-id must round-trip into the
// access-log row for this provider.
var row api.AgentNetworkAccessLog
require.Eventually(t, func() bool {
logs, lerr := srv.ListAccessLogs(ctx)
if lerr != nil {
@@ -510,15 +297,11 @@ func TestProvidersMatrix(t *testing.T) {
}
for _, r := range logs.Data {
if r.SessionId != nil && *r.SessionId == sessionID {
row = r
return true
}
}
return false
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row for %s", sessionID, pc.name)
// Stored total and cache cost must match the published rates applied to the row's buckets.
validateAccessLogCost(t, pc, row)
})
}
@@ -539,7 +322,4 @@ func TestProvidersMatrix(t *testing.T) {
}
return false
}, 60*time.Second, 3*time.Second, "consumption must be recorded with positive token counts after live traffic")
// Final raw-SQL audit: bypass the API and re-verify every persisted usage row in the store.
verifyUsageRowsSQL(t, srv)
}

View File

@@ -256,7 +256,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
case WireMessages:
path = "/v1/messages"
headers = []string{"anthropic-version: 2023-06-01"}
body = fmt.Sprintf(`{"model":%q,"max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, model, prompt)
body = fmt.Sprintf(`{"model":%q,"max_tokens":64,"messages":[{"role":"user","content":%q}]}`, model, prompt)
default:
path = "/v1/chat/completions"
body = fmt.Sprintf(`{"model":%q,"messages":[{"role":"user","content":%q}]}`, model, prompt)
@@ -271,7 +271,7 @@ func (cl *Client) ChatPrefixed(ctx context.Context, endpoint, proxyIP, pathPrefi
// is sent as the universal x-session-id header the proxy records.
func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region, model, prompt, sessionID string) (int, string, error) {
path := fmt.Sprintf("/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:rawPredict", project, region, model)
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
body := fmt.Sprintf(`{"anthropic_version":"vertex-2023-10-16","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
}
@@ -282,7 +282,7 @@ func (cl *Client) Vertex(ctx context.Context, endpoint, proxyIP, project, region
// header the proxy records.
func (cl *Client) Bedrock(ctx context.Context, endpoint, proxyIP, model, prompt, sessionID string) (int, string, error) {
path := "/model/" + model + "/invoke"
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":2048,"messages":[{"role":"user","content":%q}]}`, prompt)
body := fmt.Sprintf(`{"anthropic_version":"bedrock-2023-05-31","max_tokens":64,"messages":[{"role":"user","content":%q}]}`, prompt)
return cl.post(ctx, endpoint, proxyIP, path, body, withSessionID(nil, sessionID))
}
@@ -341,7 +341,7 @@ func (cl *Client) Terminate(ctx context.Context) error {
return cl.container.Terminate(ctx)
}
// containerLogs reads up to 4 MiB of a container's logs for diagnostics — enough for a whole provider-matrix run.
// containerLogs reads up to 256 KiB of a container's logs for diagnostics.
func containerLogs(ctx context.Context, c testcontainers.Container) string {
if c == nil {
return ""
@@ -351,6 +351,6 @@ func containerLogs(ctx context.Context, c testcontainers.Container) string {
return fmt.Sprintf("<logs error: %v>", err)
}
defer r.Close()
b, _ := io.ReadAll(io.LimitReader(r, 4<<20))
b, _ := io.ReadAll(io.LimitReader(r, 256<<10))
return string(b)
}

View File

@@ -221,29 +221,6 @@ func (c *Combined) CreateProxyTokenCLI(ctx context.Context, name string) (string
return "", fmt.Errorf("token not found in CLI output: %s", string(out))
}
// SnapshotStoreDB copies the management sqlite store (with WAL/SHM sidecars) out of the bind-mounted
// data dir into dstDir and returns the copy's path; reading a copy avoids locking against live writes.
func (c *Combined) SnapshotStoreDB(dstDir string) (string, error) {
src := filepath.Join(c.workDir, "data", "store.db")
if _, err := os.Stat(src); err != nil {
return "", fmt.Errorf("management store not found at %s: %w", src, err)
}
dst := filepath.Join(dstDir, "store.db")
for _, suffix := range []string{"", "-wal", "-shm"} {
data, err := os.ReadFile(src + suffix)
if err != nil {
if os.IsNotExist(err) && suffix != "" {
continue // sidecar only exists in WAL mode
}
return "", fmt.Errorf("read %s: %w", src+suffix, err)
}
if err := os.WriteFile(dst+suffix, data, 0o600); err != nil {
return "", fmt.Errorf("write %s: %w", dst+suffix, err)
}
}
return dst, nil
}
// Logs returns the combined server container logs, for diagnostics.
func (c *Combined) Logs(ctx context.Context) string {
return containerLogs(ctx, c.container)

View File

@@ -228,8 +228,6 @@ read_enable_crowdsec() {
echo "CrowdSec checks client IPs against a community threat intelligence database" > /dev/stderr
echo "and blocks known malicious sources before they reach your services." > /dev/stderr
echo "A local CrowdSec LAPI container will be added to your deployment." > /dev/stderr
echo "It also enables the AppSec (WAF) endpoint, so services can inspect HTTP" > /dev/stderr
echo "requests for exploits. Both stay off per service until you enable them." > /dev/stderr
echo -n "Enable CrowdSec? [y/N]: " > /dev/stderr
read -r CHOICE < /dev/tty
@@ -499,8 +497,7 @@ generate_configuration_files() {
# TCP ServersTransport for PROXY protocol v2 to the proxy backend
render_traefik_dynamic > traefik-dynamic.yaml
if [[ "$ENABLE_CROWDSEC" == "true" ]]; then
mkdir -p crowdsec/acquis.d
render_crowdsec_appsec_acquis > crowdsec/acquis.d/appsec.yaml
mkdir -p crowdsec
fi
fi
;;
@@ -534,23 +531,6 @@ generate_configuration_files() {
return 0
}
# The AppSec (WAF) listener only exists if an appsec acquisition datasource is
# configured. One datasource is one listener carrying one merged rule set: the
# protocol has no rule-set selector, so per-service rule variation would need
# either a second datasource on another port or pre_eval hooks filtering on
# req.Host.
render_crowdsec_appsec_acquis() {
cat <<EOF
source: appsec
listen_addr: 0.0.0.0:7422
appsec_configs:
- crowdsecurity/appsec-default
labels:
type: appsec
EOF
return 0
}
start_services_and_show_instructions() {
# For built-in Traefik, start containers immediately
# For NPM, start containers first (NPM needs services running to create proxy)
@@ -762,11 +742,7 @@ render_docker_compose_traefik_builtin() {
restart: unless-stopped
networks: [netbird]
environment:
# appsec-generic-rules is required alongside appsec-virtual-patching:
# the appsec-default config references crowdsecurity/generic-* and
# crowdsecurity/experimental-*, which only that collection provides, and
# the engine exits at startup if they are missing.
COLLECTIONS: crowdsecurity/linux crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules
COLLECTIONS: crowdsecurity/linux
volumes:
- ./crowdsec:/etc/crowdsec
- crowdsec_db:/var/lib/crowdsec/data
@@ -1031,11 +1007,6 @@ EOF
cat <<EOF
NB_PROXY_CROWDSEC_API_URL=http://crowdsec:8080
NB_PROXY_CROWDSEC_API_KEY=$CROWDSEC_BOUNCER_KEY
# AppSec (WAF) request inspection. Separate endpoint from the LAPI above and
# validated with the same bouncer key. Setting it makes the proxy advertise the
# AppSec capability, which is what lets a service select appsec_mode; nothing is
# inspected until a service opts in.
NB_PROXY_CROWDSEC_APPSEC_URL=http://crowdsec:7422/
EOF
fi

View File

@@ -641,7 +641,7 @@ func (c *Controller) GetValidatedPeerWithComponents(ctx context.Context, isRequi
if err != nil {
return nil, nil, nil, nil, 0, err
}
return peer, &types.NetworkMapComponents{Network: network.Copy()}, nil, nil, 0, nil
return peer, &types.NetworkMapComponents{Network: types.TwinNetwork(network)}, nil, nil, 0, nil
}
account, err := c.requestBuffer.GetAccountWithBackpressure(ctx, accountID)
@@ -794,7 +794,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
}
emptyMap := &types.NetworkMap{
Network: network.Copy(),
Network: types.TwinNetwork(network),
}
return emptyMap, nil, 0, nil
}

View File

@@ -18,26 +18,21 @@ import (
// contract between the proxy and management; management flattens them into
// queryable columns. Keep in sync with the proxy side.
const (
metaKeyProvider = "llm.provider"
metaKeyModel = "llm.model"
metaKeyResolvedProviderID = "llm.resolved_provider_id"
metaKeySelectedPolicyID = "llm.selected_policy_id"
metaKeyPolicyDecision = "llm_policy.decision"
metaKeyPolicyReason = "llm_policy.reason"
metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyCachedInputTokens = "llm.cached_input_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyCacheCreationTokens = "llm.cache_creation_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyCostUSDInput = "cost.usd_input"
metaKeyCostUSDCachedInput = "cost.usd_cached_input"
metaKeyCostUSDCacheCreate = "cost.usd_cache_creation"
metaKeyCostUSDOutput = "cost.usd_output"
metaKeyStream = "llm.stream"
metaKeySessionID = "llm.session_id"
metaKeyAuthorisingGroups = "llm.authorising_groups"
metaKeyRequestPrompt = "llm.request_prompt"
metaKeyResponseCompletion = "llm.response_completion"
metaKeyProvider = "llm.provider"
metaKeyModel = "llm.model"
metaKeyResolvedProviderID = "llm.resolved_provider_id"
metaKeySelectedPolicyID = "llm.selected_policy_id"
metaKeyPolicyDecision = "llm_policy.decision"
metaKeyPolicyReason = "llm_policy.reason"
metaKeyInputTokens = "llm.input_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyOutputTokens = "llm.output_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyTotalTokens = "llm.total_tokens" //nolint:gosec // metadata key name, not a credential
metaKeyCostUSDTotal = "cost.usd_total"
metaKeyStream = "llm.stream"
metaKeySessionID = "llm.session_id"
metaKeyAuthorisingGroups = "llm.authorising_groups"
metaKeyRequestPrompt = "llm.request_prompt"
metaKeyResponseCompletion = "llm.response_completion"
)
// IngestAccessLog flattens the metadata-bearing reverse-proxy access-log entry
@@ -113,25 +108,20 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo
BytesUpload: e.BytesUpload,
BytesDownload: e.BytesDownload,
Provider: meta[metaKeyProvider],
Model: meta[metaKeyModel],
SessionID: meta[metaKeySessionID],
ResolvedProviderID: meta[metaKeyResolvedProviderID],
SelectedPolicyID: meta[metaKeySelectedPolicyID],
Decision: meta[metaKeyPolicyDecision],
DenyReason: meta[metaKeyPolicyReason],
InputTokens: parseMetaInt(meta, metaKeyInputTokens),
OutputTokens: parseMetaInt(meta, metaKeyOutputTokens),
TotalTokens: parseMetaInt(meta, metaKeyTotalTokens),
CachedInputTokens: parseMetaInt(meta, metaKeyCachedInputTokens),
CacheCreationTokens: parseMetaInt(meta, metaKeyCacheCreationTokens),
InputCostUSD: parseMetaFloat(meta, metaKeyCostUSDInput),
CachedInputCostUSD: parseMetaFloat(meta, metaKeyCostUSDCachedInput),
CacheCreationCostUSD: parseMetaFloat(meta, metaKeyCostUSDCacheCreate),
OutputCostUSD: parseMetaFloat(meta, metaKeyCostUSDOutput),
Stream: parseMetaBool(meta, metaKeyStream),
RequestPrompt: meta[metaKeyRequestPrompt],
ResponseCompletion: meta[metaKeyResponseCompletion],
Provider: meta[metaKeyProvider],
Model: meta[metaKeyModel],
SessionID: meta[metaKeySessionID],
ResolvedProviderID: meta[metaKeyResolvedProviderID],
SelectedPolicyID: meta[metaKeySelectedPolicyID],
Decision: meta[metaKeyPolicyDecision],
DenyReason: meta[metaKeyPolicyReason],
InputTokens: parseMetaInt(meta, metaKeyInputTokens),
OutputTokens: parseMetaInt(meta, metaKeyOutputTokens),
TotalTokens: parseMetaInt(meta, metaKeyTotalTokens),
CostUSD: parseMetaFloat(meta, metaKeyCostUSDTotal),
Stream: parseMetaBool(meta, metaKeyStream),
RequestPrompt: meta[metaKeyRequestPrompt],
ResponseCompletion: meta[metaKeyResponseCompletion],
}
var groups []types.AgentNetworkAccessLogGroup
@@ -150,23 +140,18 @@ func flattenAccessLog(e *accesslogs.AccessLogEntry) (*types.AgentNetworkAccessLo
// log's ID so the two correlate.
func usageFromFlattenedLog(e *types.AgentNetworkAccessLog, groups []types.AgentNetworkAccessLogGroup) (*types.AgentNetworkUsage, []types.AgentNetworkUsageGroup) {
usage := &types.AgentNetworkUsage{
ID: e.ID,
AccountID: e.AccountID,
Timestamp: e.Timestamp,
UserID: e.UserID,
ResolvedProviderID: e.ResolvedProviderID,
Provider: e.Provider,
Model: e.Model,
SessionID: e.SessionID,
InputTokens: e.InputTokens,
OutputTokens: e.OutputTokens,
TotalTokens: e.TotalTokens,
CachedInputTokens: e.CachedInputTokens,
CacheCreationTokens: e.CacheCreationTokens,
InputCostUSD: e.InputCostUSD,
CachedInputCostUSD: e.CachedInputCostUSD,
CacheCreationCostUSD: e.CacheCreationCostUSD,
OutputCostUSD: e.OutputCostUSD,
ID: e.ID,
AccountID: e.AccountID,
Timestamp: e.Timestamp,
UserID: e.UserID,
ResolvedProviderID: e.ResolvedProviderID,
Provider: e.Provider,
Model: e.Model,
SessionID: e.SessionID,
InputTokens: e.InputTokens,
OutputTokens: e.OutputTokens,
TotalTokens: e.TotalTokens,
CostUSD: e.CostUSD,
}
usageGroups := make([]types.AgentNetworkUsageGroup, 0, len(groups))

View File

@@ -28,22 +28,17 @@ func newIngestTestEntry() *accesslogs.AccessLogEntry {
UserId: "user-1",
AgentNetwork: true,
Metadata: map[string]string{
metaKeyProvider: "openai",
metaKeyModel: "gpt-5.4",
metaKeyResolvedProviderID: "prov-1",
metaKeySessionID: "sess-1",
metaKeyInputTokens: "100",
metaKeyOutputTokens: "50",
metaKeyTotalTokens: "1174",
metaKeyCachedInputTokens: "256",
metaKeyCacheCreationTokens: "768",
metaKeyCostUSDInput: "0.0071",
metaKeyCostUSDCachedInput: "0.0009",
metaKeyCostUSDCacheCreate: "0.0020",
metaKeyCostUSDOutput: "0.0023",
metaKeyStream: "true",
metaKeyRequestPrompt: "hello",
metaKeyResponseCompletion: "world",
metaKeyProvider: "openai",
metaKeyModel: "gpt-5.4",
metaKeyResolvedProviderID: "prov-1",
metaKeySessionID: "sess-1",
metaKeyInputTokens: "100",
metaKeyOutputTokens: "50",
metaKeyTotalTokens: "150",
metaKeyCostUSDTotal: "0.0123",
metaKeyStream: "true",
metaKeyRequestPrompt: "hello",
metaKeyResponseCompletion: "world",
// repeated id must be de-duplicated before the group rows insert.
metaKeyAuthorisingGroups: "grp-eng,grp-eng,grp-ops",
},
@@ -70,19 +65,7 @@ func TestIngestAccessLog_RealStore_LogCollectionOff(t *testing.T) {
require.Len(t, usage, 1, "usage row must be written even with log collection off")
assert.Equal(t, int64(100), usage[0].InputTokens, "input tokens must round-trip from metadata")
assert.Equal(t, int64(50), usage[0].OutputTokens, "output tokens must round-trip from metadata")
assert.Equal(t, int64(256), usage[0].CachedInputTokens, "cache-read tokens must round-trip from metadata")
assert.Equal(t, int64(768), usage[0].CacheCreationTokens, "cache-write tokens must round-trip from metadata")
// The per-bucket breakdown is the only cost state stored, and must survive
// the write/read cycle as real columns — usage rows are the only cost
// record for accounts with log collection off, so a dropped column here
// loses the split permanently.
assert.InDelta(t, 0.0071, usage[0].InputCostUSD, 1e-9, "input cost must round-trip from metadata")
assert.InDelta(t, 0.0009, usage[0].CachedInputCostUSD, 1e-9, "cache-read cost must round-trip from metadata")
assert.InDelta(t, 0.0020, usage[0].CacheCreationCostUSD, 1e-9, "cache-write cost must round-trip from metadata")
assert.InDelta(t, 0.0023, usage[0].OutputCostUSD, 1e-9, "output cost must round-trip from metadata")
// Aggregates are derived from the stored columns, never stored themselves.
assert.InDelta(t, 0.0123, usage[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
assert.InDelta(t, 0.0029, usage[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
assert.InDelta(t, 0.0123, usage[0].CostUSD, 1e-9, "cost must round-trip from metadata")
logs, _, err := s.GetAgentNetworkAccessLogs(ctx, store.LockingStrengthNone, testAccountID, types.AgentNetworkAccessLogFilter{})
require.NoError(t, err)
@@ -113,14 +96,6 @@ func TestIngestAccessLog_RealStore_LogCollectionOn(t *testing.T) {
require.Equal(t, int64(1), total, "exactly one access-log row expected")
require.Len(t, logs, 1, "full access-log row must be written when log collection is on")
assert.Equal(t, "gpt-5.4", logs[0].Model, "model must flatten from metadata")
assert.Equal(t, int64(256), logs[0].CachedInputTokens, "cache-read tokens must flatten from metadata")
assert.Equal(t, int64(768), logs[0].CacheCreationTokens, "cache-write tokens must flatten from metadata")
assert.InDelta(t, 0.0029, logs[0].CacheCostUSD(), 1e-9, "cache cost is derived from the two cache buckets")
assert.InDelta(t, 0.0123, logs[0].TotalCostUSD(), 1e-9, "total is derived from the stored buckets")
assert.InDelta(t, 0.0071, logs[0].InputCostUSD, 1e-9, "input cost must flatten from metadata")
assert.InDelta(t, 0.0009, logs[0].CachedInputCostUSD, 1e-9, "cache-read cost must flatten from metadata")
assert.InDelta(t, 0.0020, logs[0].CacheCreationCostUSD, 1e-9, "cache-write cost must flatten from metadata")
assert.InDelta(t, 0.0023, logs[0].OutputCostUSD, 1e-9, "output cost must flatten from metadata")
assert.Equal(t, "hello", logs[0].RequestPrompt, "prompt must be retained when log collection is on")
assert.Equal(t, "world", logs[0].ResponseCompletion, "completion must be retained when log collection is on")
assert.True(t, logs[0].Stream, "stream flag must flatten from metadata")

View File

@@ -38,7 +38,7 @@ func accessLogRow(id, sessionID string, ts time.Time, opts ...func(*types.AgentN
InputTokens: 100,
OutputTokens: 50,
TotalTokens: 150,
InputCostUSD: 0.01,
CostUSD: 0.01,
}
for _, o := range opts {
o(e)
@@ -74,7 +74,7 @@ func withTokens(in, out, total int64, cost float64) func(*types.AgentNetworkAcce
e.InputTokens = in
e.OutputTokens = out
e.TotalTokens = total
e.InputCostUSD = cost
e.CostUSD = cost
}
}
@@ -155,7 +155,7 @@ func TestAccessLogSessions_FoldAndAggregate(t *testing.T) {
assert.Equal(t, int64(310), a.InputTokens, "input tokens summed")
assert.Equal(t, int64(135), a.OutputTokens, "output tokens summed")
assert.Equal(t, int64(445), a.TotalTokens, "total tokens summed")
assert.InDelta(t, 0.031, a.TotalCostUSD(), 1e-9, "cost summed")
assert.InDelta(t, 0.031, a.CostUSD, 1e-9, "cost summed")
assert.Equal(t, "deny", a.Decision, "any deny makes the session a deny")
assert.ElementsMatch(t, []string{"openai", "anthropic"}, a.Providers, "distinct providers")
assert.ElementsMatch(t, []string{"gpt-5.4", "claude-haiku-4-5"}, a.Models, "distinct models")

View File

@@ -41,24 +41,8 @@ type AgentNetworkAccessLog struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
// Prompt-cache buckets: read + write token counts.
CachedInputTokens int64
CacheCreationTokens int64
// Per-bucket cost breakdown — one column per token bucket the provider
// bills separately. These four are the only cost state stored: the total
// and the cache portion are derived on read (TotalCostUSD / CacheCostUSD)
// rather than stored alongside, so a stored aggregate can never drift out
// of step with the components it summarises.
//
// default:0 matters on upgrade: these columns are ALTER TABLE ADD COLUMN
// on an existing table, and without it every historical row holds NULL —
// which a raw SUM()/scan into float64 can't read. The default backfills
// them as 0, so pre-upgrade rows report an unknown split, not an error.
InputCostUSD float64 `gorm:"not null;default:0"`
CachedInputCostUSD float64 `gorm:"not null;default:0"`
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
OutputCostUSD float64 `gorm:"not null;default:0"`
Stream bool
CostUSD float64
Stream bool
// Prompt capture. Only populated when prompt collection is enabled
// (account master switch AND policy guardrail). Heavy free text.
@@ -76,44 +60,19 @@ type AgentNetworkAccessLog struct {
// the reverse-proxy AccessLogEntry table.
func (AgentNetworkAccessLog) TableName() string { return "agent_network_access_log" }
// CostUSDSQLExpr is the SQL sum of the per-bucket cost columns — the total cost
// of a row. Used wherever a query has to sort or aggregate on total cost now
// that no cost_usd column is stored. Plain arithmetic over NOT NULL columns, so
// it stays portable across SQLite and Postgres.
const CostUSDSQLExpr = "(input_cost_usd + cached_input_cost_usd + cache_creation_cost_usd + output_cost_usd)"
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
// costs. Derived rather than stored so it cannot disagree with the breakdown.
func (a *AgentNetworkAccessLog) TotalCostUSD() float64 {
return a.InputCostUSD + a.CachedInputCostUSD + a.CacheCreationCostUSD + a.OutputCostUSD
}
// CacheCostUSD is the portion of the total billed for prompt-cache buckets:
// cache reads plus cache writes.
func (a *AgentNetworkAccessLog) CacheCostUSD() float64 {
return a.CachedInputCostUSD + a.CacheCreationCostUSD
}
// ToAPIResponse renders the flattened entry as the API representation.
func (a *AgentNetworkAccessLog) ToAPIResponse() api.AgentNetworkAccessLog {
out := api.AgentNetworkAccessLog{
Id: a.ID,
ServiceId: a.ServiceID,
Timestamp: a.Timestamp,
StatusCode: a.StatusCode,
DurationMs: int(a.Duration.Milliseconds()),
InputTokens: a.InputTokens,
OutputTokens: a.OutputTokens,
TotalTokens: a.TotalTokens,
CachedInputTokens: a.CachedInputTokens,
CacheCreationTokens: a.CacheCreationTokens,
InputCostUsd: a.InputCostUSD,
CachedInputCostUsd: a.CachedInputCostUSD,
CacheCreationCostUsd: a.CacheCreationCostUSD,
OutputCostUsd: a.OutputCostUSD,
CostUsd: a.TotalCostUSD(),
CacheCostUsd: a.CacheCostUSD(),
Stream: &a.Stream,
Id: a.ID,
ServiceId: a.ServiceID,
Timestamp: a.Timestamp,
StatusCode: a.StatusCode,
DurationMs: int(a.Duration.Milliseconds()),
InputTokens: a.InputTokens,
OutputTokens: a.OutputTokens,
TotalTokens: a.TotalTokens,
CostUsd: a.CostUSD,
Stream: &a.Stream,
}
out.UserId = strPtr(a.UserID)
@@ -153,36 +112,20 @@ func strPtr(s string) *string {
// summary plus its ordered entries. Assembled in Go from a page of entries — it
// is not a stored table.
type AgentNetworkAccessLogSession struct {
SessionID string // empty for a session-less (singleton) request
UserID string
GroupIDs []string // union of the entries' authorising groups
StartedAt time.Time
EndedAt time.Time
RequestCount int
InputTokens int64
OutputTokens int64
TotalTokens int64
CachedInputTokens int64
CacheCreationTokens int64
InputCostUSD float64
CachedInputCostUSD float64
CacheCreationCostUSD float64
OutputCostUSD float64
Providers []string // distinct vendors seen in the session
Models []string // distinct models seen in the session
Decision string // "deny" if any entry was denied, else "allow"
Entries []*AgentNetworkAccessLog
}
// TotalCostUSD is the session's total cost: the sum of the four per-bucket
// costs accumulated across its entries.
func (sess *AgentNetworkAccessLogSession) TotalCostUSD() float64 {
return sess.InputCostUSD + sess.CachedInputCostUSD + sess.CacheCreationCostUSD + sess.OutputCostUSD
}
// CacheCostUSD is the session's prompt-cache spend: cache reads plus writes.
func (sess *AgentNetworkAccessLogSession) CacheCostUSD() float64 {
return sess.CachedInputCostUSD + sess.CacheCreationCostUSD
SessionID string // empty for a session-less (singleton) request
UserID string
GroupIDs []string // union of the entries' authorising groups
StartedAt time.Time
EndedAt time.Time
RequestCount int
InputTokens int64
OutputTokens int64
TotalTokens int64
CostUSD float64
Providers []string // distinct vendors seen in the session
Models []string // distinct models seen in the session
Decision string // "deny" if any entry was denied, else "allow"
Entries []*AgentNetworkAccessLog
}
// sessionKey is the grouping key for an entry: its session id, or — when the
@@ -262,12 +205,7 @@ func (sess *AgentNetworkAccessLogSession) foldEntry(sk *sessionSeen, e *AgentNet
sess.InputTokens += e.InputTokens
sess.OutputTokens += e.OutputTokens
sess.TotalTokens += e.TotalTokens
sess.CachedInputTokens += e.CachedInputTokens
sess.CacheCreationTokens += e.CacheCreationTokens
sess.InputCostUSD += e.InputCostUSD
sess.CachedInputCostUSD += e.CachedInputCostUSD
sess.CacheCreationCostUSD += e.CacheCreationCostUSD
sess.OutputCostUSD += e.OutputCostUSD
sess.CostUSD += e.CostUSD
if e.Timestamp.Before(sess.StartedAt) {
sess.StartedAt = e.Timestamp
}
@@ -310,22 +248,15 @@ func (sess *AgentNetworkAccessLogSession) ToAPIResponse() api.AgentNetworkAccess
}
out := api.AgentNetworkAccessLogSession{
StartedAt: sess.StartedAt,
EndedAt: sess.EndedAt,
RequestCount: sess.RequestCount,
InputTokens: sess.InputTokens,
OutputTokens: sess.OutputTokens,
TotalTokens: sess.TotalTokens,
CachedInputTokens: sess.CachedInputTokens,
CacheCreationTokens: sess.CacheCreationTokens,
InputCostUsd: sess.InputCostUSD,
CachedInputCostUsd: sess.CachedInputCostUSD,
CacheCreationCostUsd: sess.CacheCreationCostUSD,
OutputCostUsd: sess.OutputCostUSD,
CostUsd: sess.TotalCostUSD(),
CacheCostUsd: sess.CacheCostUSD(),
Decision: sess.Decision,
Entries: entries,
StartedAt: sess.StartedAt,
EndedAt: sess.EndedAt,
RequestCount: sess.RequestCount,
InputTokens: sess.InputTokens,
OutputTokens: sess.OutputTokens,
TotalTokens: sess.TotalTokens,
CostUsd: sess.CostUSD,
Decision: sess.Decision,
Entries: entries,
}
out.SessionId = strPtr(sess.SessionID)
out.UserId = strPtr(sess.UserID)

View File

@@ -54,7 +54,7 @@ var accessLogSortFields = map[string]string{
"provider": "provider",
"status_code": "status_code",
"duration": "duration",
"cost_usd": CostUSDSQLExpr,
"cost_usd": "cost_usd",
"total_tokens": "total_tokens",
"user_id": "user_id",
"decision": "decision",
@@ -70,7 +70,7 @@ var accessLogSortFields = map[string]string{
var sessionSortExprs = map[string]string{ //nolint:gosec // G101 false positive: "total_tokens" sort key, not a credential
"timestamp": "MAX(timestamp)",
"started_at": "MIN(timestamp)",
"cost_usd": "SUM" + CostUSDSQLExpr,
"cost_usd": "SUM(cost_usd)",
"total_tokens": "SUM(total_tokens)",
"duration": "SUM(duration)",
"request_count": "COUNT(*)",

View File

@@ -1,124 +0,0 @@
package types
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// costRow builds an access-log entry carrying only a cost breakdown — the rest
// of the row is irrelevant to the summation identities under test.
func costRow(id, session string, ts time.Time, in, cachedIn, cacheCreate, out float64) *AgentNetworkAccessLog {
return &AgentNetworkAccessLog{
ID: id,
SessionID: session,
Timestamp: ts,
InputCostUSD: in,
CachedInputCostUSD: cachedIn,
CacheCreationCostUSD: cacheCreate,
OutputCostUSD: out,
}
}
// TestAPIResponse_CostComponentsSumToAggregates is the contract a client adding
// up an API response depends on: within a single rendered object, the four
// per-bucket fields sum to cost_usd, and the two cache fields sum to
// cache_cost_usd. Uses rates that are not exactly representable in binary
// floating point, so the identity is checked against real arithmetic rather
// than round numbers.
func TestAPIResponse_CostComponentsSumToAggregates(t *testing.T) {
row := costRow("r1", "s1", time.Now(), 0.000768, 0.0002304, 0.00192, 0.003)
api := row.ToAPIResponse()
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
api.CostUsd, 1e-12, "rendered buckets must sum to the rendered cost_usd")
assert.InDelta(t, api.CachedInputCostUsd+api.CacheCreationCostUsd, api.CacheCostUsd, 1e-12,
"rendered cache buckets must sum to the rendered cache_cost_usd")
assert.InDelta(t, 0.0059184, api.CostUsd, 1e-12, "total is the exact sum, not a separately rounded figure")
assert.InDelta(t, 0.0021504, api.CacheCostUsd, 1e-12, "cache cost is the exact sum of the two cache buckets")
}
// TestSessionSummary_SumsMatchSummedEntries proves a session summary equals the
// sum of the entries it renders: a client that adds up the entries itself must
// land on the same number the summary reports, per bucket and in total.
func TestSessionSummary_SumsMatchSummedEntries(t *testing.T) {
base := time.Date(2026, 5, 5, 10, 0, 0, 0, time.UTC)
entries := []*AgentNetworkAccessLog{
costRow("r1", "s1", base, 0.000768, 0.0002304, 0.00192, 0.003),
costRow("r2", "s1", base.Add(time.Minute), 0.000625, 0.0009375, 0, 0.005),
costRow("r3", "s1", base.Add(2*time.Minute), 0.0000016, 0, 0, 0.0000032),
}
sessions := FoldAccessLogSessions([]string{"s1"}, entries)
require.Len(t, sessions, 1)
sess := sessions[0].ToAPIResponse()
var wantInput, wantCachedInput, wantCacheCreation, wantOutput float64
for _, e := range entries {
wantInput += e.InputCostUSD
wantCachedInput += e.CachedInputCostUSD
wantCacheCreation += e.CacheCreationCostUSD
wantOutput += e.OutputCostUSD
}
assert.InDelta(t, wantInput, sess.InputCostUsd, 1e-12, "session input cost is the sum of its entries")
assert.InDelta(t, wantCachedInput, sess.CachedInputCostUsd, 1e-12, "session cache-read cost is the sum of its entries")
assert.InDelta(t, wantCacheCreation, sess.CacheCreationCostUsd, 1e-12, "session cache-write cost is the sum of its entries")
assert.InDelta(t, wantOutput, sess.OutputCostUsd, 1e-12, "session output cost is the sum of its entries")
assert.InDelta(t, wantInput+wantCachedInput+wantCacheCreation+wantOutput, sess.CostUsd, 1e-12,
"session total equals the summed entry buckets")
// Summing the rendered entries must give the same answer as reading the
// summary — the property a UI relies on when it totals a table itself.
var fromEntries float64
for _, e := range sess.Entries {
fromEntries += e.CostUsd
}
assert.InDelta(t, sess.CostUsd, fromEntries, 1e-12, "summary total must match the summed rendered entries")
// The sub-microdollar row must still contribute; it would vanish under
// 6-decimal quantisation.
assert.Greater(t, sess.InputCostUsd, 0.001393, "small-cost rows must not be quantised away")
}
// TestUsageBuckets_SumsMatchSummedRows proves the same identity one level up:
// a usage bucket equals the sum of the ledger rows folded into it, and the
// buckets together equal the whole range.
func TestUsageBuckets_SumsMatchSummedRows(t *testing.T) {
day1 := time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC)
day2 := time.Date(2026, 5, 6, 9, 0, 0, 0, time.UTC)
rows := []*AgentNetworkUsage{
{ID: "u1", Timestamp: day1, InputCostUSD: 0.000768, CachedInputCostUSD: 0.0002304, CacheCreationCostUSD: 0.00192, OutputCostUSD: 0.003},
{ID: "u2", Timestamp: day1.Add(time.Hour), InputCostUSD: 0.000625, CachedInputCostUSD: 0.0009375, OutputCostUSD: 0.005},
{ID: "u3", Timestamp: day2, InputCostUSD: 0.0000016, OutputCostUSD: 0.0000032},
}
buckets := AggregateUsageByGranularity(rows, UsageGranularityDay)
require.Len(t, buckets, 2, "two distinct days expected")
var total, cache float64
for _, b := range buckets {
api := b.ToAPIResponse()
assert.InDelta(t, api.InputCostUsd+api.CachedInputCostUsd+api.CacheCreationCostUsd+api.OutputCostUsd,
api.CostUsd, 1e-12, "each bucket's components must sum to its cost_usd")
total += api.CostUsd
cache += api.CacheCostUsd
}
var wantTotal, wantCache float64
for _, r := range rows {
wantTotal += r.TotalCostUSD()
wantCache += r.CacheCostUSD()
}
assert.InDelta(t, wantTotal, total, 1e-12, "buckets must sum to the total across all ledger rows")
assert.InDelta(t, wantCache, cache, 1e-12, "buckets must sum to the cache spend across all ledger rows")
// A month bucket over the same rows must total identically — regrouping
// changes the partition, never the sum.
monthly := AggregateUsageByGranularity(rows, UsageGranularityMonth)
require.Len(t, monthly, 1)
assert.InDelta(t, wantTotal, monthly[0].ToAPIResponse().CostUsd, 1e-12,
"re-bucketing at a different granularity must preserve the total")
}

View File

@@ -25,19 +25,8 @@ type AgentNetworkUsage struct {
InputTokens int64
OutputTokens int64
TotalTokens int64
// Prompt-cache buckets: read + write token counts.
CachedInputTokens int64
CacheCreationTokens int64
// Per-bucket cost breakdown, mirroring AgentNetworkAccessLog — the only
// cost state stored; total and cache portion are derived on read. Kept on
// the usage ledger too so spend can be attributed per bucket even for
// accounts with log collection turned off. See AgentNetworkAccessLog for
// why the columns carry a zero default.
InputCostUSD float64 `gorm:"not null;default:0"`
CachedInputCostUSD float64 `gorm:"not null;default:0"`
CacheCreationCostUSD float64 `gorm:"not null;default:0"`
OutputCostUSD float64 `gorm:"not null;default:0"`
CreatedAt time.Time
CostUSD float64
CreatedAt time.Time
}
// TableName keeps usage records in their own stripped table. Named
@@ -45,17 +34,6 @@ type AgentNetworkUsage struct {
// agent_network_usage table in a shared database.
func (AgentNetworkUsage) TableName() string { return "agent_network_request_usage" }
// TotalCostUSD is the request's total cost: the sum of the four per-bucket
// costs. Derived rather than stored so it cannot disagree with the breakdown.
func (u *AgentNetworkUsage) TotalCostUSD() float64 {
return u.InputCostUSD + u.CachedInputCostUSD + u.CacheCreationCostUSD + u.OutputCostUSD
}
// CacheCostUSD is the portion of the total billed for prompt-cache buckets.
func (u *AgentNetworkUsage) CacheCostUSD() float64 {
return u.CachedInputCostUSD + u.CacheCreationCostUSD
}
// AgentNetworkUsageGroup is the normalised many-to-many row linking a usage
// record to one authorising group, mirroring AgentNetworkAccessLogGroup so the
// usage overview can filter by group with a `group_id IN (...)` join.

View File

@@ -33,45 +33,21 @@ func ParseUsageGranularity(s string) UsageGranularity {
// AgentNetworkUsageBucket is one aggregated usage time bucket. PeriodStart is
// the UTC start of the bucket as YYYY-MM-DD.
type AgentNetworkUsageBucket struct {
PeriodStart string
InputTokens int64
OutputTokens int64
TotalTokens int64
CachedInputTokens int64
CacheCreationTokens int64
InputCostUSD float64
CachedInputCostUSD float64
CacheCreationCostUSD float64
OutputCostUSD float64
}
// TotalCostUSD is the bucket's total spend: the sum of the four per-bucket
// costs. Derived rather than accumulated separately so it cannot disagree with
// the components.
func (b *AgentNetworkUsageBucket) TotalCostUSD() float64 {
return b.InputCostUSD + b.CachedInputCostUSD + b.CacheCreationCostUSD + b.OutputCostUSD
}
// CacheCostUSD is the bucket's prompt-cache spend: cache reads plus writes.
func (b *AgentNetworkUsageBucket) CacheCostUSD() float64 {
return b.CachedInputCostUSD + b.CacheCreationCostUSD
PeriodStart string
InputTokens int64
OutputTokens int64
TotalTokens int64
CostUSD float64
}
// ToAPIResponse renders the bucket as the API representation.
func (b *AgentNetworkUsageBucket) ToAPIResponse() api.AgentNetworkUsageBucket {
return api.AgentNetworkUsageBucket{
PeriodStart: b.PeriodStart,
InputTokens: b.InputTokens,
OutputTokens: b.OutputTokens,
TotalTokens: b.TotalTokens,
CachedInputTokens: b.CachedInputTokens,
CacheCreationTokens: b.CacheCreationTokens,
InputCostUsd: b.InputCostUSD,
CachedInputCostUsd: b.CachedInputCostUSD,
CacheCreationCostUsd: b.CacheCreationCostUSD,
OutputCostUsd: b.OutputCostUSD,
CostUsd: b.TotalCostUSD(),
CacheCostUsd: b.CacheCostUSD(),
PeriodStart: b.PeriodStart,
InputTokens: b.InputTokens,
OutputTokens: b.OutputTokens,
TotalTokens: b.TotalTokens,
CostUsd: b.CostUSD,
}
}
@@ -108,12 +84,7 @@ func AggregateUsageByGranularity(rows []*AgentNetworkUsage, g UsageGranularity)
b.InputTokens += r.InputTokens
b.OutputTokens += r.OutputTokens
b.TotalTokens += r.TotalTokens
b.CachedInputTokens += r.CachedInputTokens
b.CacheCreationTokens += r.CacheCreationTokens
b.InputCostUSD += r.InputCostUSD
b.CachedInputCostUSD += r.CachedInputCostUSD
b.CacheCreationCostUSD += r.CacheCreationCostUSD
b.OutputCostUSD += r.OutputCostUSD
b.CostUSD += r.CostUSD
}
out := make([]*AgentNetworkUsageBucket, 0, len(byPeriod))

View File

@@ -23,9 +23,6 @@ type Domain struct {
// SupportsCrowdSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsCrowdSec *bool `gorm:"-"`
// SupportsAppSec is populated at query time from proxy cluster capabilities.
// Not persisted.
SupportsAppSec *bool `gorm:"-"`
// SupportsPrivate is populated at query time from proxy cluster capabilities. Not persisted.
SupportsPrivate *bool `gorm:"-"`
}

View File

@@ -49,7 +49,6 @@ func domainToApi(d *domain.Domain) api.ReverseProxyDomain {
SupportsCustomPorts: d.SupportsCustomPorts,
RequireSubdomain: d.RequireSubdomain,
SupportsCrowdsec: d.SupportsCrowdSec,
SupportsAppsec: d.SupportsAppSec,
SupportsPrivate: d.SupportsPrivate,
}
if d.TargetCluster != "" {

View File

@@ -35,7 +35,6 @@ type proxyManager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -95,7 +94,6 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
d.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, cluster)
d.RequireSubdomain = m.proxyManager.ClusterRequireSubdomain(ctx, cluster)
d.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, cluster)
d.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, cluster)
d.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, cluster)
ret = append(ret, d)
}
@@ -113,7 +111,6 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
if d.TargetCluster != "" {
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
cd.SupportsCrowdSec = m.proxyManager.ClusterSupportsCrowdSec(ctx, d.TargetCluster)
cd.SupportsAppSec = m.proxyManager.ClusterSupportsAppSec(ctx, d.TargetCluster)
cd.SupportsPrivate = m.proxyManager.ClusterSupportsPrivate(ctx, d.TargetCluster)
}
// Custom domains never require a subdomain by default since

View File

@@ -40,10 +40,6 @@ func (m *mockProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *mockProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -19,7 +19,6 @@ type Manager interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStale(ctx context.Context, inactivityDuration time.Duration) error
GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error)

View File

@@ -21,7 +21,6 @@ type store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetProxyByAccountID(ctx context.Context, accountID string) (*proxy.Proxy, error)
@@ -139,13 +138,6 @@ func (m Manager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string
return m.store.GetClusterSupportsCrowdSec(ctx, clusterAddr)
}
// ClusterSupportsAppSec returns whether all active proxies in the cluster have
// a CrowdSec AppSec endpoint configured (unanimous). Returns nil when no proxy
// has reported capabilities.
func (m Manager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsAppSec(ctx, clusterAddr)
}
// ClusterSupportsPrivate reports whether any active proxy claims the private capability (nil = unreported).
func (m Manager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
return m.store.GetClusterSupportsPrivate(ctx, clusterAddr)

View File

@@ -99,9 +99,6 @@ func (m *mockStore) GetClusterRequireSubdomain(_ context.Context, _ string) *boo
func (m *mockStore) GetClusterSupportsCrowdSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *mockStore) GetClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}

View File

@@ -50,6 +50,20 @@ func (mr *MockManagerMockRecorder) CleanupStale(ctx, inactivityDuration interfac
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CleanupStale", reflect.TypeOf((*MockManager)(nil).CleanupStale), ctx, inactivityDuration)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterRequireSubdomain mocks base method.
func (m *MockManager) ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -64,20 +78,6 @@ func (mr *MockManagerMockRecorder) ClusterRequireSubdomain(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterRequireSubdomain", reflect.TypeOf((*MockManager)(nil).ClusterRequireSubdomain), ctx, clusterAddr)
}
// ClusterSupportsAppSec mocks base method.
func (m *MockManager) ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsAppSec indicates an expected call of ClusterSupportsAppSec.
func (mr *MockManagerMockRecorder) ClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsAppSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsAppSec), ctx, clusterAddr)
}
// ClusterSupportsCrowdSec mocks base method.
func (m *MockManager) ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -92,20 +92,6 @@ func (mr *MockManagerMockRecorder) ClusterSupportsCrowdSec(ctx, clusterAddr inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCrowdSec", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCrowdSec), ctx, clusterAddr)
}
// ClusterSupportsCustomPorts mocks base method.
func (m *MockManager) ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ClusterSupportsCustomPorts", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// ClusterSupportsCustomPorts indicates an expected call of ClusterSupportsCustomPorts.
func (mr *MockManagerMockRecorder) ClusterSupportsCustomPorts(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClusterSupportsCustomPorts", reflect.TypeOf((*MockManager)(nil).ClusterSupportsCustomPorts), ctx, clusterAddr)
}
// ClusterSupportsPrivate mocks base method.
func (m *MockManager) ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
@@ -135,35 +121,6 @@ func (mr *MockManagerMockRecorder) Connect(ctx, proxyID, sessionID, clusterAddre
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Connect", reflect.TypeOf((*MockManager)(nil).Connect), ctx, proxyID, sessionID, clusterAddress, ipAddress, accountID, capabilities)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// Disconnect mocks base method.
func (m *MockManager) Disconnect(ctx context.Context, proxyID, sessionID string) error {
m.ctrl.T.Helper()
@@ -178,21 +135,6 @@ func (mr *MockManagerMockRecorder) Disconnect(ctx, proxyID, sessionID interface{
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Disconnect", reflect.TypeOf((*MockManager)(nil).Disconnect), ctx, proxyID, sessionID)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// GetActiveClusterAddresses mocks base method.
func (m *MockManager) GetActiveClusterAddresses(ctx context.Context) ([]string, error) {
m.ctrl.T.Helper()
@@ -208,7 +150,6 @@ func (mr *MockManagerMockRecorder) GetActiveClusterAddresses(ctx interface{}) *g
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddresses", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddresses), ctx)
}
// GetActiveClusterAddressesForAccount mocks base method.
func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, accountID string) ([]string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetActiveClusterAddressesForAccount", ctx, accountID)
@@ -217,7 +158,6 @@ func (m *MockManager) GetActiveClusterAddressesForAccount(ctx context.Context, a
return ret0, ret1
}
// GetActiveClusterAddressesForAccount indicates an expected call of GetActiveClusterAddressesForAccount.
func (mr *MockManagerMockRecorder) GetActiveClusterAddressesForAccount(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveClusterAddressesForAccount", reflect.TypeOf((*MockManager)(nil).GetActiveClusterAddressesForAccount), ctx, accountID)
@@ -237,6 +177,36 @@ func (mr *MockManagerMockRecorder) Heartbeat(ctx, p interface{}) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Heartbeat", reflect.TypeOf((*MockManager)(nil).Heartbeat), ctx, p)
}
// GetAccountProxy mocks base method.
func (m *MockManager) GetAccountProxy(ctx context.Context, accountID string) (*Proxy, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetAccountProxy", ctx, accountID)
ret0, _ := ret[0].(*Proxy)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAccountProxy indicates an expected call of GetAccountProxy.
func (mr *MockManagerMockRecorder) GetAccountProxy(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAccountProxy", reflect.TypeOf((*MockManager)(nil).GetAccountProxy), ctx, accountID)
}
// CountAccountProxies mocks base method.
func (m *MockManager) CountAccountProxies(ctx context.Context, accountID string) (int64, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountAccountProxies", ctx, accountID)
ret0, _ := ret[0].(int64)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountAccountProxies indicates an expected call of CountAccountProxies.
func (mr *MockManagerMockRecorder) CountAccountProxies(ctx, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAccountProxies", reflect.TypeOf((*MockManager)(nil).CountAccountProxies), ctx, accountID)
}
// IsClusterAddressAvailable mocks base method.
func (m *MockManager) IsClusterAddressAvailable(ctx context.Context, clusterAddress, accountID string) (bool, error) {
m.ctrl.T.Helper()
@@ -252,6 +222,20 @@ func (mr *MockManagerMockRecorder) IsClusterAddressAvailable(ctx, clusterAddress
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsClusterAddressAvailable", reflect.TypeOf((*MockManager)(nil).IsClusterAddressAvailable), ctx, clusterAddress, accountID)
}
// DeleteAccountCluster mocks base method.
func (m *MockManager) DeleteAccountCluster(ctx context.Context, clusterAddress, accountID string) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteAccountCluster", ctx, clusterAddress, accountID)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteAccountCluster indicates an expected call of DeleteAccountCluster.
func (mr *MockManagerMockRecorder) DeleteAccountCluster(ctx, clusterAddress, accountID interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteAccountCluster", reflect.TypeOf((*MockManager)(nil).DeleteAccountCluster), ctx, clusterAddress, accountID)
}
// MockController is a mock of Controller interface.
type MockController struct {
ctrl *gomock.Controller

View File

@@ -20,9 +20,6 @@ type Capabilities struct {
RequireSubdomain *bool
// SupportsCrowdsec indicates whether this proxy has CrowdSec configured.
SupportsCrowdsec *bool
// SupportsAppsec indicates whether this proxy has a CrowdSec AppSec (WAF)
// endpoint configured.
SupportsAppsec *bool
// Private indicates whether this proxy supports inbound access via Wireguard
// tunnel and netbird-only authentication policies
Private *bool
@@ -77,6 +74,5 @@ type Cluster struct {
SupportsCustomPorts *bool
RequireSubdomain *bool
SupportsCrowdSec *bool
SupportsAppSec *bool
Private *bool
}

View File

@@ -204,7 +204,6 @@ func (h *handler) getClusters(w http.ResponseWriter, r *http.Request) {
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdSec,
SupportsAppsec: c.SupportsAppSec,
Private: c.Private,
})
}

View File

@@ -82,7 +82,6 @@ type CapabilityProvider interface {
ClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
ClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
ClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
}
@@ -138,7 +137,6 @@ func (m *Manager) GetClusters(ctx context.Context, accountID, userID string) ([]
clusters[i].SupportsCustomPorts = m.capabilities.ClusterSupportsCustomPorts(ctx, clusters[i].Address)
clusters[i].RequireSubdomain = m.capabilities.ClusterRequireSubdomain(ctx, clusters[i].Address)
clusters[i].SupportsCrowdSec = m.capabilities.ClusterSupportsCrowdSec(ctx, clusters[i].Address)
clusters[i].SupportsAppSec = m.capabilities.ClusterSupportsAppSec(ctx, clusters[i].Address)
clusters[i].Private = m.capabilities.ClusterSupportsPrivate(ctx, clusters[i].Address)
}

View File

@@ -165,18 +165,6 @@ type AccessRestrictions struct {
AllowedCountries []string `json:"allowed_countries,omitempty" gorm:"serializer:json"`
BlockedCountries []string `json:"blocked_countries,omitempty" gorm:"serializer:json"`
CrowdSecMode string `json:"crowdsec_mode,omitempty" gorm:"serializer:json"`
// AppSecMode is the CrowdSec AppSec (WAF) request inspection mode: "",
// "off", "enforce", or "observe". HTTP services only.
AppSecMode string `json:"appsec_mode,omitempty" gorm:"serializer:json"`
}
// isEmpty reports whether no restriction is configured. Both conversions drop
// the object entirely in that case, so a field missing from this check is
// silently discarded on the way to the API and the proxy.
func (r AccessRestrictions) isEmpty() bool {
return len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" && r.AppSecMode == ""
}
// Copy returns a deep copy of the AccessRestrictions.
@@ -187,7 +175,6 @@ func (r AccessRestrictions) Copy() AccessRestrictions {
AllowedCountries: slices.Clone(r.AllowedCountries),
BlockedCountries: slices.Clone(r.BlockedCountries),
CrowdSecMode: r.CrowdSecMode,
AppSecMode: r.AppSecMode,
}
}
@@ -821,17 +808,13 @@ func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error)
}
res.CrowdSecMode = string(*r.CrowdsecMode)
}
if r.AppsecMode != nil {
if !r.AppsecMode.Valid() {
return AccessRestrictions{}, fmt.Errorf("invalid appsec_mode %q", *r.AppsecMode)
}
res.AppSecMode = string(*r.AppsecMode)
}
return res, nil
}
func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
if r.isEmpty() {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
return nil
}
res := &api.AccessRestrictions{}
@@ -851,15 +834,13 @@ func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode)
res.CrowdsecMode = &mode
}
if r.AppSecMode != "" {
mode := api.AccessRestrictionsAppsecMode(r.AppSecMode)
res.AppsecMode = &mode
}
return res
}
func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
if r.isEmpty() {
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
r.CrowdSecMode == "" {
return nil
}
return &proto.AccessRestrictions{
@@ -868,7 +849,6 @@ func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
AllowedCountries: r.AllowedCountries,
BlockedCountries: r.BlockedCountries,
CrowdsecMode: r.CrowdSecMode,
AppsecMode: r.AppSecMode,
}
}
@@ -894,11 +874,6 @@ func (s *Service) Validate() error {
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
return err
}
// AppSec inspects HTTP requests, so it cannot apply to the L4 modes, which
// forward opaque byte streams.
if appSecEnabled(s.Restrictions.AppSecMode) && s.Mode != ModeHTTP {
return fmt.Errorf("appsec_mode is only supported for HTTP services, got mode %q", s.Mode)
}
if err := s.validatePrivateRequirements(); err != nil {
return err
}
@@ -1267,27 +1242,10 @@ func validateCrowdSecMode(mode string) error {
}
}
func validateAppSecMode(mode string) error {
switch mode {
case "", "off", "enforce", "observe":
return nil
default:
return fmt.Errorf("appsec_mode %q is invalid", mode)
}
}
// appSecEnabled reports whether the mode asks for request inspection.
func appSecEnabled(mode string) bool {
return mode == "enforce" || mode == "observe"
}
func validateAccessRestrictions(r *AccessRestrictions) error {
if err := validateCrowdSecMode(r.CrowdSecMode); err != nil {
return err
}
if err := validateAppSecMode(r.AppSecMode); err != nil {
return err
}
if len(r.AllowedCIDRs) > maxCIDREntries {
return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries)

View File

@@ -26,17 +26,6 @@ func validProxy() *Service {
}
}
// validL4Proxy returns a service that passes validation in one of the L4 modes.
func validL4Proxy(mode string) *Service {
rp := validProxy()
rp.Mode = mode
rp.ListenPort = 9000
rp.Targets = []*Target{
{TargetId: "peer-1", TargetType: TargetTypePeer, Host: "10.0.0.1", Port: 5432, Protocol: mode, Enabled: true},
}
return rp
}
func TestValidate_Valid(t *testing.T) {
require.NoError(t, validProxy().Validate())
}
@@ -1326,68 +1315,3 @@ func TestValidate_Private_RejectsNonHTTPMode(t *testing.T) {
}}
assert.ErrorContains(t, rp.Validate(), "HTTP")
}
func TestRestrictions_AppSecMode_RoundTrip(t *testing.T) {
mode := api.AccessRestrictionsAppsecModeEnforce
apiIn := &api.AccessRestrictions{AppsecMode: &mode}
model, err := restrictionsFromAPI(apiIn)
require.NoError(t, err)
assert.Equal(t, "enforce", model.AppSecMode)
// appsec_mode alone must keep the restrictions object alive on both the API
// and proto legs: it is meaningful without any CIDR or country entry.
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut, "appsec_mode alone must not collapse the restrictions to nil")
require.NotNil(t, apiOut.AppsecMode)
assert.Equal(t, api.AccessRestrictionsAppsecModeEnforce, *apiOut.AppsecMode)
protoOut := restrictionsToProto(model)
require.NotNil(t, protoOut, "appsec_mode alone must reach the proxy")
assert.Equal(t, "enforce", protoOut.AppsecMode)
}
func TestRestrictions_AppSecMode_EmptyIsOmitted(t *testing.T) {
model, err := restrictionsFromAPI(&api.AccessRestrictions{
AllowedCidrs: &[]string{"203.0.113.0/24"},
})
require.NoError(t, err)
assert.Empty(t, model.AppSecMode)
apiOut := restrictionsToAPI(model)
require.NotNil(t, apiOut)
assert.Nil(t, apiOut.AppsecMode, "empty appsec_mode is omitted from the API response")
}
func TestRestrictions_AppSecMode_CopyIsDeep(t *testing.T) {
original := AccessRestrictions{AppSecMode: "observe", CrowdSecMode: "enforce"}
assert.Equal(t, original, original.Copy(), "Copy must carry every mode field")
}
func TestValidate_RejectsInvalidAppSecMode(t *testing.T) {
rp := validProxy()
rp.Restrictions = AccessRestrictions{AppSecMode: "sometimes"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode")
}
func TestValidate_RejectsAppSecOnL4Modes(t *testing.T) {
// AppSec inspects HTTP requests, so the L4 modes cannot honor it. Accepting
// the field there would report protection that never runs.
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "enforce"}
assert.ErrorContains(t, rp.Validate(), "appsec_mode is only supported for HTTP services")
})
}
}
func TestValidate_AllowsAppSecOffOnL4Modes(t *testing.T) {
for _, mode := range []string{ModeTCP, ModeUDP, ModeTLS} {
t.Run(mode, func(t *testing.T) {
rp := validL4Proxy(mode)
rp.Restrictions = AccessRestrictions{AppSecMode: "off"}
require.NoError(t, rp.Validate(), "an explicit off must not be rejected on L4 services")
})
}
}

View File

@@ -0,0 +1,17 @@
package networkmapdb
import (
"context"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
type NetworkMapDBStore interface {
GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, error) // TODO: join/populate peers
GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, error)
GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, error)
}
type NetworkMapDBStoreImpl struct {
store NetworkMapDBStore
}

View File

@@ -0,0 +1,11 @@
package networkmapdb
import (
"context"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func (db *NetworkMapDBStoreImpl) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, error) {
return db.store.GetGroups(ctx, accountId)
}

View File

@@ -0,0 +1,53 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"github.com/jackc/pgx/v5"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetGroupsQuery = `
select name, public_id, resources from groups where account_id=$1
`
)
func (pg *PgStore) GetGroups(ctx context.Context, accountId string) ([]nmdata.Group, error) {
rows, err := pg.pool.Query(ctx, GetGroupsQuery, accountId)
if err != nil {
return nil, err
}
var (
group nmdata.Group
name, publicId, resources sql.NullString
)
groups, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nmdata.Group, error) {
err := row.Scan(&name, &publicId, &resources)
if err != nil {
return group, err
}
if name.Valid {
group.Name = name.String
}
if publicId.Valid {
group.PublicID = publicId.String
}
if resources.Valid {
groupResources := make([]nmdata.Resource, 0)
err := json.Unmarshal([]byte(resources.String), &groupResources)
if err != nil {
return group, err
}
group.Resources = groupResources
}
return group, nil
})
return groups, err
}

View File

@@ -0,0 +1,55 @@
package networkmap_pgsql
import (
"context"
"strings"
"testing"
_ "embed"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/stretchr/testify/assert"
)
//go:embed test_db.sql
var initDb string
func TestXxx(t *testing.T) {
ctx := context.TODO()
s, err := NewPostgresqlStore(ctx, "postgresql://root:netbird@localhost:5432/netbird")
assert.NoError(t, err)
// err = loadSQL(ctx, s.pool, initDb)
//assert.NoError(t, err)
_, err = s.pool.Query(ctx, "insert into groups (id, account_id, name, resources, public_id) VALUES('test-group-id-1','ck7bnf2t2r9s739pkug0','test-group-1', '[{\"ID\":\"cui7q2jl0ubs73d8qpi0\",\"Type\":\"host\"}]','public-id-1')")
assert.NoError(t, err)
groups, err := s.GetGroups(ctx, "ck7bnf2t2r9s739pkug0")
assert.NoError(t, err)
assert.Contains(t,
groups,
nmdata.Group{Name: "test-group-1", PublicID: "public-id-1", Resources: []nmdata.Resource{{ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
)
assert.Contains(t,
groups,
nmdata.Group{Name: "All", PublicID: "d9aejspvcsu517nkh4a0", Resources: []nmdata.Resource{{ID: "cui7olrl0ubs73d8qpe0", Type: "subnet"}, {ID: "cui7q2jl0ubs73d8qpi0", Type: "host"}}},
)
}
func loadSQL(ctx context.Context, pool *pgxpool.Pool, initdb string) error {
queries := strings.Split(string(initdb), ";")
for _, query := range queries {
query = strings.TrimSpace(query)
if query != "" {
_, err := pool.Query(ctx, query)
if err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,132 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"github.com/jackc/pgx/v5"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPeersQuery = `
select id, key, ssh_key, dns_label, user_id, ssh_enabled, login_expiration_enabled, last_login, ip, ipv6,
meta_wt_version, meta_go_os, meta_os_version, meta_kernel_version, meta_network_addresses, meta_files, meta_capabilities, meta_flags,
location_country_code, location_city_name, location_connection_ip
from peers
where account_id = $1
`
)
func (pg *PgStore) GetPeers(ctx context.Context, accountId string) ([]nmdata.Peer, error) {
rows, err := pg.pool.Query(ctx, GetPeersQuery, accountId)
if err != nil {
return nil, err
}
var (
key, sshKey, dnsLabel, userId sql.NullString
lastLogin sql.NullTime
sshEnabled, loginExpirationEnabled sql.NullBool
ip, ipv6, locationConnectionIp []byte
metaFiles, metaCapabilities, metaFlags, metaNetworkAddresses []byte
metaWtVersion, metaGoOS, metaOSVersion, metaKernelVersion sql.NullString
locationCountryCode, locationCityName sql.NullString
)
peers, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nmdata.Peer, error) {
var peer nmdata.Peer
err := row.Scan(&peer.ID, &key, &sshKey, &dnsLabel, &userId, &sshEnabled, &loginExpirationEnabled, &lastLogin, &ip, &ipv6,
&metaWtVersion, &metaGoOS, &metaOSVersion, &metaKernelVersion, &metaNetworkAddresses, &metaFiles, &metaCapabilities, &metaFlags,
&locationCountryCode, &locationCityName, &locationConnectionIp)
if err != nil {
return peer, err
}
if key.Valid {
peer.Key = key.String
}
if sshKey.Valid {
peer.SSHKey = sshKey.String
}
if dnsLabel.Valid {
peer.DNSLabel = dnsLabel.String
}
if userId.Valid {
peer.UserID = userId.String
}
if lastLogin.Valid {
peer.LastLogin = &lastLogin.Time
}
if sshEnabled.Valid {
peer.SSHEnabled = sshEnabled.Bool
}
if loginExpirationEnabled.Valid {
peer.LoginExpirationEnabled = loginExpirationEnabled.Bool
}
if metaWtVersion.Valid {
peer.Meta.WtVersion = metaWtVersion.String
}
if metaGoOS.Valid {
peer.Meta.GoOS = metaGoOS.String
}
if metaOSVersion.Valid {
peer.Meta.OSVersion = metaOSVersion.String
}
if metaKernelVersion.Valid {
peer.Meta.KernelVersion = metaKernelVersion.String
}
if locationCountryCode.Valid {
peer.Location.CountryCode = locationCountryCode.String
}
if locationCityName.Valid {
peer.Location.CityName = locationCityName.String
}
if ip != nil {
err := json.Unmarshal(ip, &peer.IP)
if err != nil {
return peer, err
}
}
if ipv6 != nil {
err := json.Unmarshal(ipv6, &peer.IPv6)
if err != nil {
return peer, err
}
}
if locationConnectionIp != nil {
err := json.Unmarshal(locationConnectionIp, &peer.Location.ConnectionIP)
if err != nil {
return peer, err
}
}
if metaFiles != nil {
err := json.Unmarshal(metaFiles, &peer.Meta.Files)
if err != nil {
return peer, err
}
}
if metaCapabilities != nil {
err := json.Unmarshal(metaCapabilities, &peer.Meta.Capabilities)
if err != nil {
return peer, err
}
}
if metaFlags != nil {
err := json.Unmarshal(metaFlags, &peer.Meta.Flags)
if err != nil {
return peer, err
}
}
if metaNetworkAddresses != nil {
err := json.Unmarshal(metaNetworkAddresses, &peer.Meta.NetworkAddresses)
if err != nil {
return peer, err
}
}
return peer, nil
})
return peers, err
}

View File

@@ -0,0 +1,56 @@
package networkmap_pgsql
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
)
const (
pgMaxConnections = 30
pgMinConnections = 1
pgMaxConnLifetime = 60 * time.Minute
pgHealthCheckPeriod = 1 * time.Minute
)
var _ networkmapdb.NetworkMapDBStore = &PgStore{}
type PgStore struct {
pool *pgxpool.Pool
}
func NewPostgresqlStore(ctx context.Context, dsn string) (*PgStore, error) {
pool, err := connectToPgDb(context.Background(), dsn)
if err != nil {
return nil, err
}
return &PgStore{pool: pool}, nil
}
func connectToPgDb(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
config, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("unable to parse database config: %w", err)
}
config.MaxConns = pgMaxConnections
config.MinConns = pgMinConnections
config.MaxConnLifetime = pgMaxConnLifetime
config.HealthCheckPeriod = pgHealthCheckPeriod
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("unable to create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("unable to ping database: %w", err)
}
return pool, nil
}

View File

@@ -0,0 +1,137 @@
package networkmap_pgsql
import (
"context"
"database/sql"
"encoding/json"
"github.com/jackc/pgx/v5"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
const (
GetPoliciesQuery = `
select p.id, p.public_id, p.enabled, p.source_posture_checks, pr.enabled, pr.action, pr.protocol, pr.bidirectional,
pr.sources, pr.destinations, pr.source_resource, pr.destination_resource, pr.ports, pr.port_ranges,
pr.authorized_groups, pr.authorized_user
from policies as p
left join policy_rules as pr on p.id = pr.policy_id
where account_id=$1
`
)
func (pg *PgStore) GetPolicies(ctx context.Context, accountId string) ([]nmdata.Policy, error) {
rows, err := pg.pool.Query(ctx, GetPoliciesQuery, accountId)
if err != nil {
return nil, err
}
var (
publicId, sourcePostureChecks sql.NullString
enabled, ruleEnabled, bidirectional sql.NullBool
action, protocol, sources, destinations sql.NullString
sourceResource, destinationResource, ports, portRanges sql.NullString
authorizedGroups, authorizedUser sql.NullString
)
policies, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (nmdata.Policy, error) {
var policy nmdata.Policy
var policyRule *nmdata.PolicyRule
pr := func() *nmdata.PolicyRule {
if policyRule != nil {
return policyRule
}
policyRule = &nmdata.PolicyRule{}
return policyRule
}
err := row.Scan(&policy.ID, &publicId, &enabled, &sourcePostureChecks, &ruleEnabled, &action, &protocol, &bidirectional,
&sources, &destinations, &sourceResource, &destinationResource, &ports, &portRanges,
&authorizedGroups, &authorizedUser)
if err != nil {
return policy, err
}
if publicId.Valid {
policy.PublicID = publicId.String
}
if sourcePostureChecks.Valid {
err := json.Unmarshal([]byte(sourcePostureChecks.String), &policy.SourcePostureChecks)
if err != nil {
return policy, err
}
}
if enabled.Valid {
policy.Enabled = enabled.Bool
}
if ruleEnabled.Valid {
pr().Enabled = ruleEnabled.Bool
}
if action.Valid {
pr().Action = action.String
}
if protocol.Valid {
pr().Protocol = protocol.String
}
if bidirectional.Valid {
pr().Bidirectional = bidirectional.Bool
}
if sources.Valid {
err := json.Unmarshal([]byte(sources.String), &pr().Sources)
if err != nil {
return policy, err
}
}
if destinations.Valid {
err := json.Unmarshal([]byte(destinations.String), &pr().Destinations)
if err != nil {
return policy, err
}
}
if sourceResource.Valid {
err := json.Unmarshal([]byte(sourceResource.String), &pr().SourceResource)
if err != nil {
return policy, err
}
}
if destinationResource.Valid {
err := json.Unmarshal([]byte(destinationResource.String), &pr().DestinationResource)
if err != nil {
return policy, err
}
}
if ports.Valid {
err := json.Unmarshal([]byte(ports.String), &pr().Ports)
if err != nil {
return policy, err
}
}
if portRanges.Valid {
err := json.Unmarshal([]byte(portRanges.String), &pr().PortRanges)
if err != nil {
return policy, err
}
}
if authorizedGroups.Valid {
err := json.Unmarshal([]byte(authorizedGroups.String), &pr().AuthorizedGroups)
if err != nil {
return policy, err
}
}
if authorizedUser.Valid {
pr().AuthorizedUser = authorizedUser.String
}
if policyRule != nil {
policyRule.ID = policy.ID
policyRule.PolicyID = policy.ID
policy.Rules = []*nmdata.PolicyRule{policyRule}
}
return policy, nil
})
return policies, err
}

View File

@@ -0,0 +1,8 @@
CREATE TABLE `accounts` (`id` text,`created_by` text,`created_at` datetime,`domain` text,`domain_category` text,`is_domain_primary_account` numeric,`network_identifier` text,`network_net` text,`network_dns` text,`network_serial` integer,`dns_settings_disabled_management_groups` text,`settings_peer_login_expiration_enabled` numeric,`settings_peer_login_expiration` integer,`settings_regular_users_view_blocked` numeric,`settings_groups_propagation_enabled` numeric,`settings_jwt_groups_enabled` numeric,`settings_jwt_groups_claim_name` text,`settings_jwt_allow_groups` text,`settings_extra_peer_approval_enabled` numeric,`settings_extra_integrated_validator_groups` text,PRIMARY KEY (`id`));
CREATE TABLE `peers` (`id` text,`account_id` text,`key` text,`setup_key` text,`ip` text,`meta_hostname` text,`meta_go_os` text,`meta_kernel` text,`meta_core` text,`meta_platform` text,`meta_os` text,`meta_os_version` text,`meta_wt_version` text,`meta_ui_version` text,`meta_kernel_version` text,`meta_network_addresses` text,`meta_system_serial_number` text,`meta_system_product_name` text,`meta_system_manufacturer` text,`meta_environment` text,`meta_files` text,`name` text,`dns_label` text,`peer_status_last_seen` datetime,`peer_status_connected` numeric,`peer_status_login_expired` numeric,`peer_status_requires_approval` numeric,`user_id` text,`ssh_key` text,`ssh_enabled` numeric,`login_expiration_enabled` numeric,`last_login` datetime,`created_at` datetime,`ephemeral` numeric,`location_connection_ip` text,`location_country_code` text,`location_city_name` text,`location_geo_name_id` integer,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_peers_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
CREATE TABLE `groups` (`id` text,`account_id` text,`name` text,`issued` text,`peers` text,`integration_ref_id` integer,`integration_ref_integration_type` text,PRIMARY KEY (`id`),CONSTRAINT `fk_accounts_groups_g` FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`));
INSERT INTO accounts VALUES('bf1c8084-ba50-4ce7-9439-34653001fc3b','edafee4e-63fb-11ec-90d6-0242ac120003','2024-10-02 16:01:38.210000+02:00','test.com','private',1,'af1c8024-ha40-4ce2-9418-34653101fc3c','{"IP":"100.64.0.0","Mask":"//8AAA=="}','',0,'[]',0,86400000000000,0,0,0,'',NULL,NULL,NULL);
INSERT INTO "groups" VALUES('cfefqs706sqkneg59g4g','bf1c8084-ba50-4ce7-9439-34653001fc3b','All','api','[]',0,'');
INSERT INTO "groups" VALUES('cfefqs706sqkneg59g3g','bf1c8084-ba50-4ce7-9439-34653001fc3b','AwesomeGroup1','api','[]',0,'');
INSERT INTO "groups" VALUES('cfefqs706sqkneg59g2g','bf1c8084-ba50-4ce7-9439-34653001fc3b','AwesomeGroup2','api','[]',0,'');

View File

@@ -4,10 +4,9 @@ import (
"encoding/base64"
"strconv"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/types"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/networkmap"
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -82,6 +81,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
enc := newComponentEncoder(c)
enc.indexAllPeers()
routerIdxs := enc.indexRouterPeers(c.RouterPeers)
enc.indexAllNetworkResources()
// Phase 2: gather every policy that any consumer references (peer-pair
// policies + resource-only policies) so encodeResourcePoliciesMap can
@@ -103,7 +103,6 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
DnsSettings: enc.encodeDNSSettings(c.DNSSettings),
DnsDomain: in.DNSDomain,
CustomZoneDomain: c.CustomZoneDomain,
AgentVersions: enc.agentVersions,
Peers: enc.peers,
RouterPeerIndexes: routerIdxs,
Policies: policies,
@@ -128,7 +127,7 @@ func EncodeNetworkMapEnvelope(in ComponentsEnvelopeInput) *proto.NetworkMapEnvel
// networkSerial returns c.Network.CurrentSerial() with a nil guard. The
// production path always populates c.Network, but the encoder is exported
// and a hand-built components struct may omit it.
func networkSerial(n *types.Network) uint64 {
func networkSerial(n *nmdata.Network) uint64 {
if n == nil {
return 0
}
@@ -141,16 +140,15 @@ type componentEncoder struct {
peerOrder map[string]uint32
peers []*proto.PeerCompact
agentVersionOrder map[string]uint32
agentVersions []string
networkIdToPublicId map[string]string
}
func newComponentEncoder(c *types.NetworkMapComponents) *componentEncoder {
return &componentEncoder{
components: c,
peerOrder: make(map[string]uint32, len(c.Peers)),
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
agentVersionOrder: make(map[string]uint32),
components: c,
peerOrder: make(map[string]uint32, len(c.Peers)),
peers: make([]*proto.PeerCompact, 0, len(c.Peers)),
networkIdToPublicId: make(map[string]string),
}
}
@@ -163,7 +161,7 @@ func (e *componentEncoder) indexAllPeers() {
}
}
func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
func (e *componentEncoder) appendPeer(p *nmdata.Peer) uint32 {
if idx, ok := e.peerOrder[p.ID]; ok {
return idx
}
@@ -177,7 +175,7 @@ func (e *componentEncoder) appendPeer(p *types.ComponentPeer) uint32 {
// (c.RouterPeers may contain peers not in c.Peers when validation rules drop
// them) and returns their wire indexes for the RouterPeerIndexes field. Must
// run before any encoder that resolves peer ids via e.peerOrder.
func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentPeer) []uint32 {
func (e *componentEncoder) indexRouterPeers(routers map[string]*nmdata.Peer) []uint32 {
if len(routers) == 0 {
return nil
}
@@ -191,6 +189,15 @@ func (e *componentEncoder) indexRouterPeers(routers map[string]*types.ComponentP
return out
}
func (e *componentEncoder) indexAllNetworkResources() {
for _, r := range e.components.NetworkResources {
if !r.Enabled {
continue
}
e.networkIdToPublicId[r.ID] = r.PublicID
}
}
func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
if len(e.components.Groups) == 0 {
return nil
@@ -204,10 +211,20 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
peerIdxs = append(peerIdxs, idx)
}
}
groupCompactResources := func() []*proto.ResourceCompact {
var toret []*proto.ResourceCompact
for _, r := range g.Resources {
toret = append(toret, e.resourceToProto(r))
}
return toret
}
out = append(out, &proto.GroupCompact{
Id: g.PublicID,
PeerIndexes: peerIdxs,
IsAll: g.IsGroupAll(),
Resources: groupCompactResources(),
})
}
return out
@@ -217,7 +234,7 @@ func (e *componentEncoder) encodeGroups() []*proto.GroupCompact {
// list and a map from policy pointer to the indexes of its emitted rules in
// that list — used by encodeResourcePoliciesMap to translate
// ResourcePoliciesMap[resourceID][]*Policy into wire-side indexes.
func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.PolicyCompact {
func (e *componentEncoder) encodePolicies(policies []*nmdata.Policy) []*proto.PolicyCompact {
if len(policies) == 0 {
return nil
}
@@ -239,7 +256,7 @@ func (e *componentEncoder) encodePolicies(policies []*types.Policy) []*proto.Pol
}
// encodePolicyRule maps a single PolicyRule under pol to a PolicyCompact entry.
func (e *componentEncoder) encodePolicyRule(pol *types.Policy, r *types.PolicyRule) *proto.PolicyCompact {
func (e *componentEncoder) encodePolicyRule(pol *nmdata.Policy, r *nmdata.PolicyRule) *proto.PolicyCompact {
return &proto.PolicyCompact{
Id: pol.PublicID,
Action: networkmap.GetProtoAction(string(r.Action)),
@@ -278,14 +295,14 @@ func (e *componentEncoder) groupPublicXids(src []string) []string {
// only live in ResourcePoliciesMap; without this union step they'd be lost
// from the wire and the client's resource-policy lookup would come back
// empty.
func unionPolicies(policies []*types.Policy, resourcePolicies map[string][]*types.Policy) []*types.Policy {
func unionPolicies(policies []*nmdata.Policy, resourcePolicies map[string][]*nmdata.Policy) []*nmdata.Policy {
// Fast path: non-router peers have no resource-only policies, so the
// "union" is identical to `policies`. Skip the dedup map allocation.
if len(resourcePolicies) == 0 {
return policies
}
seen := make(map[string]struct{}, len(policies))
out := make([]*types.Policy, 0, len(policies))
out := make([]*nmdata.Policy, 0, len(policies))
for _, p := range policies {
if p == nil {
continue
@@ -343,18 +360,31 @@ func (e *componentEncoder) groupPublicXid(groupID string) (string, bool) {
// peers array. For other resource types only the type string is shipped
// today (Calculate's resource-typed rule path consults SourceResource only
// for "peer" — other types fall through to group-based lookup).
func (e *componentEncoder) resourceToProto(r types.Resource) *proto.ResourceCompact {
if r.ID == "" && r.Type == "" {
func (e *componentEncoder) resourceToProto(r nmdata.Resource) *proto.ResourceCompact {
t, ok := proto.ResourceCompactType_value[string(r.Type)]
if !ok || t == 0 || r.ID == "" {
return nil
}
out := &proto.ResourceCompact{Type: string(r.Type)}
if r.Type == types.ResourceTypePeer && r.ID != "" {
if idx, ok := e.peerOrder[r.ID]; ok {
out.PeerIndexSet = true
out.PeerIndex = idx
if t == int32(proto.ResourceCompactType_peer) {
idx, ok := e.peerOrder[r.ID]
if !ok {
return nil
}
return &proto.ResourceCompact{
Type: proto.ResourceCompactType_peer,
ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: idx},
}
}
return out
publicID, ok := e.networkIdToPublicId[r.ID]
if !ok {
return nil
}
return &proto.ResourceCompact{
Type: proto.ResourceCompactType(t),
ResourceId: &proto.ResourceCompact_Id{Id: publicID},
}
}
// postureCheckSeqs translates a slice of posture-check xids to their
@@ -387,7 +417,7 @@ func (e *componentEncoder) networkPublicId(xid string) (string, bool) {
return id, true
}
func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSettingsCompact {
func (e *componentEncoder) encodeDNSSettings(s *nmdata.DNSSettings) *proto.DNSSettingsCompact {
if s == nil || len(s.DisabledManagementGroups) == 0 {
return nil
}
@@ -402,7 +432,7 @@ func (e *componentEncoder) encodeDNSSettings(s *types.DNSSettings) *proto.DNSSet
return out
}
func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteRaw {
func (e *componentEncoder) encodeRoutes(routes []*nmdata.Route) []*proto.RouteRaw {
if len(routes) == 0 {
return nil
}
@@ -440,7 +470,7 @@ func (e *componentEncoder) encodeRoutes(routes []*nbroute.Route) []*proto.RouteR
return out
}
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup) []*proto.NameServerGroupRaw {
func (e *componentEncoder) encodeNameServerGroups(nsgs []*nmdata.NameServerGroup) []*proto.NameServerGroupRaw {
if len(nsgs) == 0 {
return nil
}
@@ -463,7 +493,7 @@ func (e *componentEncoder) encodeNameServerGroups(nsgs []*nbdns.NameServerGroup)
return out
}
func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
func encodeNameServers(servers []nmdata.NameServer) []*proto.NameServer {
if len(servers) == 0 {
return nil
}
@@ -478,7 +508,7 @@ func encodeNameServers(servers []nbdns.NameServer) []*proto.NameServer {
return out
}
func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
func encodeSimpleRecords(records []nmdata.SimpleRecord) []*proto.SimpleRecord {
if len(records) == 0 {
return nil
}
@@ -495,7 +525,7 @@ func encodeSimpleRecords(records []nbdns.SimpleRecord) []*proto.SimpleRecord {
return out
}
func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
func encodeCustomZones(zones []nmdata.CustomZone) []*proto.CustomZone {
if len(zones) == 0 {
return nil
}
@@ -511,7 +541,7 @@ func encodeCustomZones(zones []nbdns.CustomZone) []*proto.CustomZone {
return out
}
func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentResource) []*proto.NetworkResourceRaw {
func (e *componentEncoder) encodeNetworkResources(resources []*nmdata.NetworkResource) []*proto.NetworkResourceRaw {
if len(resources) == 0 {
return nil
}
@@ -540,7 +570,7 @@ func (e *componentEncoder) encodeNetworkResources(resources []*types.ComponentRe
return out
}
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*types.ComponentRouter) map[string]*proto.NetworkRouterList {
func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*nmdata.NetworkRouter) map[string]*proto.NetworkRouterList {
if len(routersMap) == 0 {
return nil
}
@@ -576,7 +606,7 @@ func (e *componentEncoder) encodeRoutersMap(routersMap map[string]map[string]*ty
return out
}
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*types.Policy) map[string]*proto.PolicyIds {
func (e *componentEncoder) encodeResourcePoliciesMap(rpm map[string][]*nmdata.Policy) map[string]*proto.PolicyIds {
if len(rpm) == 0 {
return nil
}
@@ -663,7 +693,7 @@ func (e *componentEncoder) encodePostureFailedPeers(m map[string]map[string]stru
// (which shouldn't happen in production but the encoder is exported)
// degrades to login_expiration_enabled = false, which makes
// LoginExpired() return false for every peer.
func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettingsCompact {
func toAccountSettingsCompact(s *nmdata.AccountSettingsInfo) *proto.AccountSettingsCompact {
if s == nil {
return &proto.AccountSettingsCompact{}
}
@@ -673,7 +703,7 @@ func toAccountSettingsCompact(s *types.AccountSettingsInfo) *proto.AccountSettin
}
}
func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
func toAccountNetwork(n *nmdata.Network) *proto.AccountNetwork {
if n == nil {
return nil
}
@@ -689,20 +719,20 @@ func toAccountNetwork(n *types.Network) *proto.AccountNetwork {
return out
}
func toPeerCompact(p *types.ComponentPeer) *proto.PeerCompact {
func toPeerCompact(p *nmdata.Peer) *proto.PeerCompact {
pc := &proto.PeerCompact{
WgPubKey: decodeWgKey(p.Key),
SshPubKey: []byte(p.SSHKey),
DnsLabel: p.DNSLabel,
AgentVersion: p.AgentVersion,
AddedWithSsoLogin: p.AddedWithSSOLogin,
AgentVersion: p.Meta.WtVersion,
AddedWithSsoLogin: p.UserID != "",
LoginExpirationEnabled: p.LoginExpirationEnabled,
SshEnabled: p.SSHEnabled,
SupportsIpv6: p.SupportsIPv6,
SupportsSourcePrefixes: p.SupportsSourcePrefixes,
ServerSshAllowed: p.ServerSSHAllowed,
SupportsIpv6: p.SupportsIPv6(),
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
ServerSshAllowed: p.Meta.Flags.ServerSSHAllowed,
}
if !p.LastLogin.IsZero() {
if p.LastLogin != nil {
pc.LastLoginUnixNano = p.LastLogin.UnixNano()
}
switch {
@@ -751,7 +781,7 @@ func portsToUint32(ports []string) []uint32 {
return out
}
func portRangesToProto(ranges []types.RulePortRange) []*proto.PortInfo_Range {
func portRangesToProto(ranges []nmdata.RulePortRange) []*proto.PortInfo_Range {
if len(ranges) == 0 {
return nil
}

View File

@@ -16,7 +16,7 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/server/types"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -152,66 +152,66 @@ func envelopesEquivalent(a, b *proto.NetworkMapEnvelope) bool {
}
func newTestComponents() *types.NetworkMapComponents {
peerA := &types.ComponentPeer{
ID: "peer-a",
Key: testWgKeyA,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
DNSLabel: "peera",
SSHKey: "ssh-a",
AgentVersion: "0.40.0",
peerA := &nmdata.Peer{
ID: "peer-a",
Key: testWgKeyA,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 1}),
DNSLabel: "peera",
SSHKey: "ssh-a",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
peerB := &types.ComponentPeer{
ID: "peer-b",
Key: testWgKeyB,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
DNSLabel: "peerb",
AgentVersion: "0.25.0",
peerB := &nmdata.Peer{
ID: "peer-b",
Key: testWgKeyB,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 2}),
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}),
DNSLabel: "peerb",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.25.0"},
}
peerC := &types.ComponentPeer{
ID: "peer-c",
Key: testWgKeyC,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc",
AgentVersion: "0.40.0",
peerC := &nmdata.Peer{
ID: "peer-c",
Key: testWgKeyC,
IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
return &types.NetworkMapComponents{
PeerID: "peer-a",
Network: &types.Network{
Network: &nmdata.Network{
Identifier: "net-test",
Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)},
Serial: 7,
},
AccountSettings: &types.AccountSettingsInfo{
AccountSettings: &nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: true,
PeerLoginExpiration: 2 * time.Hour,
},
Peers: map[string]*types.ComponentPeer{
Peers: map[string]*nmdata.Peer{
"peer-a": peerA,
"peer-b": peerB,
"peer-c": peerC,
},
Groups: map[string]*types.ComponentGroup{
"group-src": {ID: "group-src", PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
"group-dst": {ID: "group-dst", PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
Groups: map[string]*nmdata.Group{
"group-src": {PublicID: "1", Name: "Src", Peers: []string{"peer-a"}},
"group-dst": {PublicID: "2", Name: "Dst", Peers: []string{"peer-b", "peer-c"}},
},
Policies: []*types.Policy{
Policies: []*nmdata.Policy{
{
ID: "pol-1",
PublicID: "10",
Enabled: true,
Rules: []*types.PolicyRule{{
ID: "rule-1", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP, Bidirectional: true,
Rules: []*nmdata.PolicyRule{{
ID: "rule-1", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP), Bidirectional: true,
Ports: []string{"22", "80"},
PortRanges: []types.RulePortRange{{Start: 8000, End: 8100}},
PortRanges: []nmdata.RulePortRange{{Start: 8000, End: 8100}},
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
},
},
RouterPeers: map[string]*types.ComponentPeer{"peer-c": peerC},
RouterPeers: map[string]*nmdata.Peer{"peer-c": peerC},
}
}
@@ -304,6 +304,31 @@ func TestEncodeNetworkMapEnvelope_GroupsByAccountPublicId(t *testing.T) {
assert.Len(t, groupByID["2"].PeerIndexes, 2)
}
func TestEncodePolicy(t *testing.T) {
encoder := componentEncoder{peerOrder: map[string]uint32{"peerId": uint32(1234)}, networkIdToPublicId: map[string]string{"domain": "publicDomain", "host": "publicHost", "subnet": "publicSubnet"}}
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "peerId"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_peer, ResourceId: &proto.ResourceCompact_PeerIndex{PeerIndex: uint32(1234)}})
// verify invalid peer id results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "peer", ID: "boom"}))
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "domain", ID: "domain"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_domain, ResourceId: &proto.ResourceCompact_Id{Id: "publicDomain"}})
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "host"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_host, ResourceId: &proto.ResourceCompact_Id{Id: "publicHost"}})
assert.Equal(t,
encoder.resourceToProto(nmdata.Resource{Type: "subnet", ID: "subnet"}),
&proto.ResourceCompact{Type: proto.ResourceCompactType_subnet, ResourceId: &proto.ResourceCompact_Id{Id: "publicSubnet"}})
// verify invalid resource type results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "boom", ID: "boom"}))
// verify invalid networkresource id results in nil
assert.Nil(t,
encoder.resourceToProto(nmdata.Resource{Type: "host", ID: "boom"}))
}
func TestEncodeNetworkMapEnvelope_PolicyExpansion(t *testing.T) {
c := newTestComponents()
@@ -377,12 +402,12 @@ func TestEncodeNetworkMapEnvelope_MalformedWgKey(t *testing.T) {
func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
c := newTestComponents()
v6Only := &types.ComponentPeer{
ID: "peer-v6",
Key: testWgKeyA,
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
DNSLabel: "peerv6",
AgentVersion: "0.40.0",
v6Only := &nmdata.Peer{
ID: "peer-v6",
Key: testWgKeyA,
IPv6: netip.AddrFrom16([16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 9}),
DNSLabel: "peerv6",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
c.Peers["peer-v6"] = v6Only
@@ -401,11 +426,11 @@ func TestEncodeNetworkMapEnvelope_IPv6OnlyPeer(t *testing.T) {
func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
c := newTestComponents()
c.Peers["peer-noip"] = &types.ComponentPeer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
AgentVersion: "0.40.0",
c.Peers["peer-noip"] = &nmdata.Peer{
ID: "peer-noip",
Key: testWgKeyA,
DNSLabel: "peernoip",
Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -423,7 +448,7 @@ func TestEncodeNetworkMapEnvelope_PeerWithoutIP(t *testing.T) {
func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
}
env := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c})
@@ -440,9 +465,9 @@ func TestEncodeNetworkMapEnvelope_EmptyInput(t *testing.T) {
func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
c := newTestComponents()
now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
c.Peers["peer-a"].AddedWithSSOLogin = true
c.Peers["peer-a"].UserID = "user-1"
c.Peers["peer-a"].LoginExpirationEnabled = true
c.Peers["peer-a"].LastLogin = now
c.Peers["peer-a"].LastLogin = &now
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -472,7 +497,7 @@ func TestEncodeNetworkMapEnvelope_PeerLoginExpirationFields(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
c := newTestComponents()
c.Routes = []*nbroute.Route{
c.Routes = []*nmdata.Route{
{
ID: "route-peer",
PublicID: "100",
@@ -519,7 +544,7 @@ func TestEncodeNetworkMapEnvelope_RoutesRoundTrip(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RouteWithMissingPeerLeavesIndexUnset(t *testing.T) {
c := newTestComponents()
c.Routes = []*nbroute.Route{{
c.Routes = []*nmdata.Route{{
ID: "route-x",
PublicID: "100",
Peer: "peer-not-in-components",
@@ -539,21 +564,21 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
// Policy that exists ONLY in ResourcePoliciesMap, not in c.Policies. This
// is the I1 case — without unionPolicies the encoder would silently
// drop it from the wire.
resourceOnlyPolicy := &types.Policy{
resourceOnlyPolicy := &nmdata.Policy{
ID: "pol-resource", PublicID: "99", Enabled: true,
Rules: []*types.PolicyRule{{
ID: "rule-r", Enabled: true, Action: types.PolicyTrafficActionAccept,
Protocol: types.PolicyRuleProtocolTCP,
Rules: []*nmdata.PolicyRule{{
ID: "rule-r", Enabled: true, Action: string(types.PolicyTrafficActionAccept),
Protocol: string(types.PolicyRuleProtocolTCP),
Sources: []string{"group-src"},
Destinations: []string{"group-dst"},
}},
}
c.ResourcePoliciesMap = map[string][]*types.Policy{
c.ResourcePoliciesMap = map[string][]*nmdata.Policy{
"resource-x": {c.Policies[0], resourceOnlyPolicy}, // shared + resource-only
}
// Resource must appear in components.NetworkResources with a seq id —
// encoder uses that to translate the xid map key to uint32.
c.NetworkResources = []*types.ComponentResource{
c.NetworkResources = []*nmdata.NetworkResource{
{ID: "resource-x", PublicID: "77", Name: "res-x", Enabled: true},
}
@@ -562,27 +587,16 @@ func TestEncodeNetworkMapEnvelope_ResourceOnlyPolicyShippedAndIndexed(t *testing
require.Len(t, full.Policies, 2, "encoded policies must include both peer-traffic and resource-only")
policyByID := map[string]*proto.PolicyCompact{}
policyIds := make([]string, 0)
for _, p := range full.Policies {
policyByID[p.Id] = p
policyIds = append(policyIds, p.Id)
}
require.Contains(t, policyByID, "10", "original peer-traffic policy id 10")
require.Contains(t, policyByID, "99", "resource-only policy id 99")
require.Contains(t, full.ResourcePoliciesMap, "77")
ids := full.ResourcePoliciesMap["77"].Ids
require.Len(t, ids, 2)
assert.ElementsMatch(t, policyIds, ids,
"resource policies map must reference both wire policy indexes")
}
func TestEncodeNetworkMapEnvelope_NameServerGroups(t *testing.T) {
c := newTestComponents()
c.NameServerGroups = []*nbdns.NameServerGroup{{
c.NameServerGroups = []*nmdata.NameServerGroup{{
ID: "nsg-1", PublicID: "50", Name: "Main", Description: "primary",
NameServers: []nbdns.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: nbdns.UDPNameServerType, Port: 53,
NameServers: []nmdata.NameServer{{
IP: netip.MustParseAddr("8.8.8.8"), NSType: int(nbdns.UDPNameServerType), Port: 53,
}},
Groups: []string{"group-src", "group-not-persisted"},
Primary: true, Enabled: true,
@@ -621,11 +635,11 @@ func TestEncodeNetworkMapEnvelope_PostureFailedPeers(t *testing.T) {
func TestEncodeNetworkMapEnvelope_RoutersMap(t *testing.T) {
c := newTestComponents()
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
"net-1": {
"peer-c": {
PublicID: "200",
Peer: "peer-c", Masquerade: true, Metric: 10, Enabled: true,
PublicID: "200",
Masquerade: true, Metric: 10, Enabled: true,
},
},
}
@@ -651,14 +665,14 @@ func TestEncodeNetworkMapEnvelope_RouterPeerNotInComponentsPeers(t *testing.T) {
// peer_index reference must still resolve.
c := newTestComponents()
delete(c.Peers, "peer-c")
routerPeer := &types.ComponentPeer{
routerPeer := &nmdata.Peer{
ID: "peer-c", Key: testWgKeyC, IP: netip.AddrFrom4([4]byte{100, 64, 0, 3}),
DNSLabel: "peerc", AgentVersion: "0.40.0",
DNSLabel: "peerc", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}
c.RouterPeers = map[string]*types.ComponentPeer{"peer-c": routerPeer}
c.RouterPeers = map[string]*nmdata.Peer{"peer-c": routerPeer}
c.NetworkXIDToPublicID = map[string]string{"net-1": "5"}
c.RoutersMap = map[string]map[string]*types.ComponentRouter{
"net-1": {"peer-c": {PublicID: "1", Peer: "peer-c", Enabled: true}},
c.RoutersMap = map[string]map[string]*nmdata.NetworkRouter{
"net-1": {"peer-c": {PublicID: "1", Enabled: true}},
}
full := EncodeNetworkMapEnvelope(ComponentsEnvelopeInput{Components: c}).GetFull()
@@ -691,9 +705,9 @@ func TestToProxyPatch_EmptyInputReturnsNil(t *testing.T) {
func TestToProxyPatch_PopulatesAllFields(t *testing.T) {
nm := &types.NetworkMap{
Peers: []*types.ComponentPeer{{
Peers: []*nmdata.Peer{{
ID: "ext-peer", Key: testWgKeyA, IP: netip.AddrFrom4([4]byte{100, 64, 0, 9}),
DNSLabel: "extpeer", AgentVersion: "0.40.0",
DNSLabel: "extpeer", Meta: nmdata.PeerSystemMeta{WtVersion: "0.40.0"},
}},
FirewallRules: []*types.FirewallRule{{
PeerIP: "100.64.0.9", Action: "accept", Direction: 0, Protocol: "tcp",
@@ -762,7 +776,7 @@ func TestEncodeNetworkMapEnvelope_NilComponentsGracefulDegrade(t *testing.T) {
func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
c := &types.NetworkMapComponents{
Network: &types.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
Network: &nmdata.Network{Identifier: "x", Net: net.IPNet{IP: net.IP{100, 64, 0, 0}, Mask: net.CIDRMask(10, 32)}},
// AccountSettings deliberately nil
}
@@ -776,6 +790,6 @@ func TestEncodeNetworkMapEnvelope_AccountSettingsAlwaysEmitted(t *testing.T) {
func emptyNetworkMapComponents() *types.NetworkMapComponents {
return types.EmptyNetworkMapComponents(
&types.NetworkMapComponents{
PeerID: "peer-id", Peers: map[string]*types.ComponentPeer{"peer-id": {}}},
PeerID: "peer-id", Peers: map[string]*nmdata.Peer{"peer-id": {}}},
)
}

View File

@@ -12,6 +12,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
"github.com/netbirdio/netbird/shared/management/networkmap"
nmdata "github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -170,25 +171,25 @@ func computeSSHEnabledForPeer(c *types.NetworkMapComponents, peer *nbpeer.Peer)
// ruleEnablesSSHForPeer returns true when rule is active, targets peer, and
// either explicitly authorises SSH or covers the legacy TCP/22 path while the
// peer itself has SSH enabled locally.
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *types.PolicyRule, peer *nbpeer.Peer) bool {
func ruleEnablesSSHForPeer(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peer *nbpeer.Peer) bool {
if rule == nil || !rule.Enabled {
return false
}
if !peerInDestinations(c, rule, peer.ID) {
return false
}
if rule.Protocol == types.PolicyRuleProtocolNetbirdSSH {
if rule.Protocol == string(types.PolicyRuleProtocolNetbirdSSH) {
return true
}
return peer.SSHEnabled && types.PolicyRuleImpliesLegacySSH(rule)
return peer.SSHEnabled && nmdata.PolicyRuleImpliesLegacySSH(rule)
}
// peerInDestinations reports whether peerID is in any of rule.Destinations'
// groups (or matches DestinationResource if it's a peer-typed resource —
// for non-peer types Calculate falls through to group lookup, so we mirror
// that exactly to avoid silent divergence).
func peerInDestinations(c *types.NetworkMapComponents, rule *types.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == types.ResourceTypePeer && rule.DestinationResource.ID != "" {
func peerInDestinations(c *types.NetworkMapComponents, rule *nmdata.PolicyRule, peerID string) bool {
if rule.DestinationResource.Type == string(types.ResourceTypePeer) && rule.DestinationResource.ID != "" {
return rule.DestinationResource.ID == peerID
}
for _, groupID := range rule.Destinations {

View File

@@ -7,6 +7,7 @@ import (
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// TestComputeSSHEnabledForPeer covers both Calculate-mirroring branches:
@@ -17,16 +18,15 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
const targetPeerID = "target"
const targetGroupID = "g_dst"
mkComponents := func(rule *types.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
mkComponents := func(rule *nmdata.PolicyRule, sshEnabled bool) (*types.NetworkMapComponents, *nbpeer.Peer) {
peer := &nbpeer.Peer{ID: targetPeerID, SSHEnabled: sshEnabled}
group := &types.ComponentGroup{ID: targetGroupID, Name: "dst", Peers: []string{targetPeerID}}
return &types.NetworkMapComponents{
Peers: map[string]*types.ComponentPeer{targetPeerID: peer.ToComponent()},
Groups: map[string]*types.ComponentGroup{targetGroupID: group},
Policies: []*types.Policy{{
Peers: map[string]*nmdata.Peer{targetPeerID: types.TwinPeer(peer)},
Groups: map[string]*nmdata.Group{targetGroupID: {Name: "dst", Peers: []string{targetPeerID}}},
Policies: []*nmdata.Policy{{
ID: "p",
Enabled: true,
Rules: []*types.PolicyRule{rule},
Rules: []*nmdata.PolicyRule{rule},
}},
}, peer
}
@@ -34,14 +34,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
cases := []struct {
name string
peerSSH bool
rule types.PolicyRule
rule nmdata.PolicyRule
wantEnabled bool
}{
{
name: "explicit-netbird-ssh-activates-regardless-of-peer-ssh",
peerSSH: false,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -49,8 +49,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -58,8 +58,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22-without-peer-ssh-disabled",
peerSSH: false,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -67,8 +67,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-tcp-22022-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"22022"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"22022"},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -76,8 +76,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-all-protocol-with-peer-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolALL,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolALL),
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -85,10 +85,10 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "implicit-port-range-covers-22",
peerSSH: true,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolTCP,
PortRanges: []types.RulePortRange{{Start: 20, End: 30}},
Protocol: string(types.PolicyRuleProtocolTCP),
PortRanges: []nmdata.RulePortRange{{Start: 20, End: 30}},
Destinations: []string{targetGroupID},
},
wantEnabled: true,
@@ -96,8 +96,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "tcp-80-no-ssh",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolTCP, Ports: []string{"80"},
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolTCP), Ports: []string{"80"},
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -105,8 +105,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "disabled-rule-skipped",
peerSSH: true,
rule: types.PolicyRule{
Enabled: false, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: false, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{targetGroupID},
},
wantEnabled: false,
@@ -114,8 +114,8 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-not-in-destinations",
peerSSH: true,
rule: types.PolicyRule{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
rule: nmdata.PolicyRule{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{"g_other"}, // target not in this group
},
wantEnabled: false,
@@ -123,21 +123,21 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
{
name: "peer-typed-destination-resource-matches",
peerSSH: false,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: types.ResourceTypePeer},
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: string(types.ResourceTypePeer)},
},
wantEnabled: true,
},
{
name: "non-peer-destination-resource-falls-through-to-groups",
peerSSH: false,
rule: types.PolicyRule{
rule: nmdata.PolicyRule{
Enabled: true,
Protocol: types.PolicyRuleProtocolNetbirdSSH,
DestinationResource: types.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
DestinationResource: nmdata.Resource{ID: targetPeerID, Type: "host"}, // wrong type
Destinations: []string{targetGroupID}, // saved by group fallback
},
wantEnabled: true,
},
@@ -158,14 +158,14 @@ func TestComputeSSHEnabledForPeer(t *testing.T) {
func TestComputeSSHEnabledForPeer_TargetMissingFromComponents(t *testing.T) {
peer := &nbpeer.Peer{ID: "missing", SSHEnabled: true}
c := &types.NetworkMapComponents{
Peers: map[string]*types.ComponentPeer{}, // target peer NOT present
Groups: map[string]*types.ComponentGroup{
"g": {ID: "g", Peers: []string{"missing"}},
Peers: map[string]*nmdata.Peer{}, // target peer NOT present
Groups: map[string]*nmdata.Group{
"g": {Peers: []string{"missing"}},
},
Policies: []*types.Policy{{
Policies: []*nmdata.Policy{{
ID: "p", Enabled: true,
Rules: []*types.PolicyRule{{
Enabled: true, Protocol: types.PolicyRuleProtocolNetbirdSSH,
Rules: []*nmdata.PolicyRule{{
Enabled: true, Protocol: string(types.PolicyRuleProtocolNetbirdSSH),
Destinations: []string{"g"},
}},
}},

View File

@@ -22,6 +22,7 @@ import (
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/netiputil"
)
@@ -119,7 +120,7 @@ func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken
return nbConfig
}
func toPeerConfig(peer *nbpeer.Peer, network *types.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig {
func toPeerConfig(peer *nbpeer.Peer, network *nmdata.Network, dnsName string, settings *types.Settings, httpConfig *nbconfig.HttpServerConfig, deviceFlowConfig *nbconfig.DeviceAuthorizationFlow, enableSSH bool) *proto.PeerConfig {
netmask, _ := network.Net.Mask.Size()
fqdn := peer.FQDN(dnsName)

View File

@@ -29,7 +29,6 @@ import (
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
@@ -37,6 +36,7 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
"github.com/netbirdio/netbird/management/server/idp"
"github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/management/server/users"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
@@ -505,7 +505,6 @@ func (s *ProxyServiceServer) registerProxyConnection(ctx context.Context, params
SupportsCustomPorts: c.SupportsCustomPorts,
RequireSubdomain: c.RequireSubdomain,
SupportsCrowdsec: c.SupportsCrowdsec,
SupportsAppsec: c.SupportsAppsec,
Private: c.Private,
}
}

View File

@@ -921,7 +921,7 @@ func (s *Server) prepareLoginResponse(ctx context.Context, peer *nbpeer.Peer, ne
// if peer has reached this point then it has logged in
loginResp := &proto.LoginResponse{
NetbirdConfig: toNetbirdConfig(s.config, nil, relayToken, nil, settings),
PeerConfig: toPeerConfig(peer, network, s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH),
PeerConfig: toPeerConfig(peer, types.TwinNetwork(network), s.networkMapController.GetDNSDomain(settings), settings, s.config.HttpConfig, s.config.DeviceAuthorizationFlow, enableSSH),
Checks: toProtocolChecks(ctx, postureChecks),
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/netbirdio/netbird/management/server/account"
"github.com/netbirdio/netbird/management/server/activity"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
"github.com/netbirdio/netbird/management/server/permissions"
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
@@ -31,10 +30,6 @@ type managerImpl struct {
accountManager account.Manager
}
func eventMetaResource(group *types.Group, resource *resourceTypes.NetworkResource) map[string]any {
return map[string]any{"name": group.Name, "id": group.ID, "resource_name": resource.Name, "resource_id": resource.ID, "resource_type": resource.Type}
}
type mockManager struct {
}
@@ -114,7 +109,7 @@ func (m *managerImpl) AddResourceToGroupInTransaction(ctx context.Context, trans
}
event := func() {
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, eventMetaResource(group, networkResource))
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceAddedToGroup, group.EventMetaResource(networkResource))
}
return event, nil
@@ -138,7 +133,7 @@ func (m *managerImpl) RemoveResourceFromGroupInTransaction(ctx context.Context,
}
event := func() {
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, eventMetaResource(group, networkResource))
m.accountManager.StoreEvent(ctx, userID, groupID, accountID, activity.ResourceRemovedFromGroup, group.EventMetaResource(networkResource))
}
return event, nil

View File

@@ -446,7 +446,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
netMap := account.GetPeerNetworkMapFromComponents(ctx, peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
util.WriteJSONObject(ctx, w, toAccessiblePeers(netMap, account.Peers, dnsDomain))
util.WriteJSONObject(ctx, w, toAccessiblePeers(account.Peers, netMap, dnsDomain))
}
func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request) {
@@ -534,20 +534,22 @@ func (h *Handler) CreateTemporaryAccess(w http.ResponseWriter, r *http.Request)
util.WriteJSONObject(r.Context(), w, resp)
}
// toAccessiblePeers rehydrates the calculated map's component peers into the
// account's full peer objects, which carry the location/status/meta fields
// the API response needs.
func toAccessiblePeers(netMap *types.NetworkMap, accountPeers map[string]*nbpeer.Peer, dnsDomain string) []api.AccessiblePeer {
// toAccessiblePeers resolves the twin peers in netMap back to the full account
// peers (by ID) so the API response keeps Status/Name/OS/GeoNameID, which the
// slim netmap twins intentionally don't carry.
func toAccessiblePeers(accountPeers map[string]*nbpeer.Peer, netMap *types.NetworkMap, dnsDomain string) []api.AccessiblePeer {
accessiblePeers := make([]api.AccessiblePeer, 0, len(netMap.Peers)+len(netMap.OfflinePeers))
add := func(peers []*types.ComponentPeer) {
for _, p := range peers {
if peer := accountPeers[p.ID]; peer != nil {
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(peer, dnsDomain))
}
appendByID := func(id string) {
if p, ok := accountPeers[id]; ok && p != nil {
accessiblePeers = append(accessiblePeers, peerToAccessiblePeer(p, dnsDomain))
}
}
add(netMap.Peers)
add(netMap.OfflinePeers)
for _, p := range netMap.Peers {
appendByID(p.ID)
}
for _, p := range netMap.OfflinePeers {
appendByID(p.ID)
}
return accessiblePeers
}

View File

@@ -683,81 +683,3 @@ func BackfillPublicIDs[T any](ctx context.Context, db *gorm.DB) error {
log.WithContext(ctx).Infof("Backfill of empty public_id in table %s completed", tableName)
return nil
}
// FoldCostAggregatesIntoBuckets migrates a per-request cost table from the old
// "stored aggregate" shape (cost_usd + cache_cost_usd columns) to the per-bucket
// breakdown, where the total and cache portion are derived on read instead.
//
// The fold preserves both aggregates exactly for historical rows: the cache
// total moves into cached_input_cost_usd and the remainder into
// input_cost_usd, so a row's derived total and cache cost still match what it
// reported before the upgrade. The finer split is genuinely unknown for those
// rows — the old schema never recorded a read/write or input/output division —
// so it is lumped rather than guessed; only rows written after the upgrade
// carry a true four-way split.
//
// Dropping the columns before folding would zero every historical row's cost,
// so the update runs first and the drop only happens once it succeeds. A table
// with no cost_usd column has already been migrated (or was created fresh) and
// is skipped.
func FoldCostAggregatesIntoBuckets[T any](ctx context.Context, db *gorm.DB) error {
var model T
if !db.Migrator().HasTable(&model) {
log.WithContext(ctx).Debugf("table for %T does not exist, no cost-bucket migration needed", model)
return nil
}
if !db.Migrator().HasColumn(&model, "cost_usd") {
log.WithContext(ctx).Debugf("table for %T has no cost_usd column, cost buckets already migrated", model)
return nil
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(&model); err != nil {
return fmt.Errorf("parse model schema: %w", err)
}
tableName := stmt.Schema.Table
// COALESCE guards rows whose new columns were added as NULL by an earlier
// AutoMigrate run that predates the NOT NULL default.
hasCacheColumn := db.Migrator().HasColumn(&model, "cache_cost_usd")
cacheExpr := "0"
if hasCacheColumn {
cacheExpr = "COALESCE(cache_cost_usd, 0)"
}
if err := db.Transaction(func(tx *gorm.DB) error {
// Only touch rows that carry a legacy total and no breakdown yet, so
// the migration is idempotent and never overwrites a true split.
update := fmt.Sprintf(`UPDATE %s
SET input_cost_usd = COALESCE(cost_usd, 0) - %s,
cached_input_cost_usd = %s,
cache_creation_cost_usd = 0,
output_cost_usd = 0
WHERE COALESCE(cost_usd, 0) <> 0
AND COALESCE(input_cost_usd, 0) = 0
AND COALESCE(cached_input_cost_usd, 0) = 0
AND COALESCE(cache_creation_cost_usd, 0) = 0
AND COALESCE(output_cost_usd, 0) = 0`, tableName, cacheExpr, cacheExpr)
res := tx.Exec(update)
if res.Error != nil {
return fmt.Errorf("fold legacy cost aggregates in %s: %w", tableName, res.Error)
}
log.WithContext(ctx).Infof("folded legacy cost aggregates into per-bucket columns for %d rows in table %s", res.RowsAffected, tableName)
if err := tx.Migrator().DropColumn(&model, "cost_usd"); err != nil {
return fmt.Errorf("drop cost_usd from %s: %w", tableName, err)
}
if hasCacheColumn {
if err := tx.Migrator().DropColumn(&model, "cache_cost_usd"); err != nil {
return fmt.Errorf("drop cache_cost_usd from %s: %w", tableName, err)
}
}
return nil
}); err != nil {
return err
}
log.WithContext(ctx).Infof("migration of stored cost aggregates to per-bucket columns in table %s completed", tableName)
return nil
}

View File

@@ -16,7 +16,6 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/migration"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/testutil"
@@ -640,99 +639,3 @@ func TestCleanupOrphanedResources_SkipsWhenForeignKeyExists(t *testing.T) {
db.Model(&testChildWithFK{}).Count(&count)
assert.Equal(t, int64(2), count, "Both rows should survive — migration must skip when FK constraint exists")
}
// legacyCostRow is the pre-breakdown shape of the usage table: cost was stored
// as a total plus a cache portion, with no per-bucket columns. Used to build a
// realistic pre-upgrade table for the fold migration to run against.
type legacyCostRow struct {
ID string `gorm:"primaryKey"`
AccountID string
Model string
CostUSD float64
CacheCostUSD float64
}
func (legacyCostRow) TableName() string { return "agent_network_request_usage" }
// TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost covers the upgrade
// path: a table written under the old schema must come out with its per-row
// total and cache cost unchanged, because dropping cost_usd without folding it
// forward would silently zero every historical row's spend.
func TestFoldCostAggregatesIntoBuckets_PreservesHistoricalCost(t *testing.T) {
ctx := context.Background()
db := setupDatabase(t)
// setupDatabase hands back a process-shared database, so start from a clean
// table rather than inheriting rows from another test.
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
require.NoError(t, db.AutoMigrate(&legacyCostRow{}), "legacy table must be created")
require.NoError(t, db.Create(&legacyCostRow{
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6", CostUSD: 0.0123, CacheCostUSD: 0.0029,
}).Error)
require.NoError(t, db.Create(&legacyCostRow{
ID: "u2", AccountID: "acct-1", Model: "gpt-4o", CostUSD: 0.5, CacheCostUSD: 0,
}).Error)
// A zero-cost row (denied / unpriced request) must stay zero, not be touched.
require.NoError(t, db.Create(&legacyCostRow{ID: "u3", AccountID: "acct-1", Model: "gw/unpriced"}).Error)
// AutoMigrate adds the per-bucket columns alongside the legacy ones, exactly
// as a real upgrade does before the post-auto migrations run.
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}), "new columns must be added")
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db))
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cost_usd"),
"legacy cost_usd column must be dropped once folded")
assert.False(t, db.Migrator().HasColumn(&agentNetworkTypes.AgentNetworkUsage{}, "cache_cost_usd"),
"legacy cache_cost_usd column must be dropped once folded")
var rows []*agentNetworkTypes.AgentNetworkUsage
require.NoError(t, db.Order("id").Find(&rows).Error)
require.Len(t, rows, 3)
// u1: total and cache portion both preserved; the read/write and
// input/output splits are unknowable for a legacy row, so the cache total
// lands on cached_input and the remainder on input.
assert.InDelta(t, 0.0123, rows[0].TotalCostUSD(), 1e-9, "historical total must survive the fold")
assert.InDelta(t, 0.0029, rows[0].CacheCostUSD(), 1e-9, "historical cache cost must survive the fold")
assert.InDelta(t, 0.0094, rows[0].InputCostUSD, 1e-9, "non-cache remainder lands on input")
assert.InDelta(t, 0.0029, rows[0].CachedInputCostUSD, 1e-9, "legacy cache total lands on cached input")
assert.Zero(t, rows[0].CacheCreationCostUSD, "legacy rows carry no read/write split to recover")
assert.Zero(t, rows[0].OutputCostUSD, "legacy rows carry no input/output split to recover")
// u2: no cache spend — the whole total is the non-cache remainder.
assert.InDelta(t, 0.5, rows[1].TotalCostUSD(), 1e-9, "cache-free historical total must survive")
assert.Zero(t, rows[1].CacheCostUSD(), "a cache-free row must stay cache-free")
// u3: zero stays zero rather than being rewritten.
assert.Zero(t, rows[2].TotalCostUSD(), "an unpriced row must remain unpriced")
}
// TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated proves the migration is
// safe to re-run: with no legacy column present it is a no-op that leaves a
// true four-way split untouched.
func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
ctx := context.Background()
db := setupDatabase(t)
require.NoError(t, db.Migrator().DropTable(&agentNetworkTypes.AgentNetworkUsage{}))
require.NoError(t, db.AutoMigrate(&agentNetworkTypes.AgentNetworkUsage{}))
// Timestamp must be set explicitly: a zero time.Time serialises as
// '0000-00-00 00:00:00', which MySQL rejects under strict mode.
require.NoError(t, db.Create(&agentNetworkTypes.AgentNetworkUsage{
ID: "u1", AccountID: "acct-1", Model: "claude-sonnet-4-6",
Timestamp: time.Date(2026, 5, 5, 9, 0, 0, 0, time.UTC),
InputCostUSD: 0.001, CachedInputCostUSD: 0.002, CacheCreationCostUSD: 0.003, OutputCostUSD: 0.004,
}).Error)
require.NoError(t, migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db),
"running against an already-migrated table must be a no-op, not an error")
var row agentNetworkTypes.AgentNetworkUsage
require.NoError(t, db.First(&row, "id = ?", "u1").Error)
assert.InDelta(t, 0.001, row.InputCostUSD, 1e-9, "a true split must not be rewritten")
assert.InDelta(t, 0.002, row.CachedInputCostUSD, 1e-9)
assert.InDelta(t, 0.003, row.CacheCreationCostUSD, 1e-9)
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
}

View File

@@ -14,7 +14,6 @@ import (
nbDomain "github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/http/api"
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
)
type NetworkResourceType string
@@ -65,27 +64,6 @@ func NewNetworkResource(accountID, networkID, name, description, address string,
}, nil
}
// ToComponent converts the resource to its self-contained components
// representation. Returns nil for a nil resource.
func (n *NetworkResource) ToComponent() *sharedTypes.ComponentResource {
if n == nil {
return nil
}
return &sharedTypes.ComponentResource{
ID: n.ID,
PublicID: n.PublicID,
NetworkID: n.NetworkID,
AccountID: n.AccountID,
Name: n.Name,
Description: n.Description,
Type: sharedTypes.ComponentResourceType(n.Type),
Address: n.Address,
Domain: n.Domain,
Prefix: n.Prefix,
Enabled: n.Enabled,
}
}
func (n *NetworkResource) ToAPIResponse(groups []api.GroupMinimum) *api.NetworkResource {
addr := n.Prefix.String()
if n.Type == Domain {

View File

@@ -7,7 +7,6 @@ import (
"github.com/netbirdio/netbird/management/server/networks/types"
"github.com/netbirdio/netbird/shared/management/http/api"
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
)
type NetworkRouter struct {
@@ -22,36 +21,6 @@ type NetworkRouter struct {
Enabled bool
}
// ToComponent converts the router to its self-contained components
// representation. Returns nil for a nil router.
func (n *NetworkRouter) ToComponent() *sharedTypes.ComponentRouter {
if n == nil {
return nil
}
return &sharedTypes.ComponentRouter{
NetworkID: n.NetworkID,
PublicID: n.PublicID,
Peer: n.Peer,
PeerGroups: n.PeerGroups,
Masquerade: n.Masquerade,
Metric: n.Metric,
Enabled: n.Enabled,
}
}
// ToComponentMap converts a peer-keyed router map to its components
// representation.
func ToComponentMap(routers map[string]*NetworkRouter) map[string]*sharedTypes.ComponentRouter {
if routers == nil {
return nil
}
out := make(map[string]*sharedTypes.ComponentRouter, len(routers))
for id, r := range routers {
out[id] = r.ToComponent()
}
return out
}
func NewNetworkRouter(accountID string, networkID string, peer string, peerGroups []string, masquerade bool, metric int, enabled bool) (*NetworkRouter, error) {
r := &NetworkRouter{
ID: xid.New().String(),

View File

@@ -21,6 +21,7 @@ import (
"github.com/netbirdio/netbird/management/server/permissions/modules"
"github.com/netbirdio/netbird/management/server/permissions/operations"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/store"
@@ -405,7 +406,7 @@ func (am *DefaultAccountManager) CreatePeerJob(ctx context.Context, accountID, p
return status.NewPeerNotPartOfAccountError()
}
meetMinVer, err := version.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
meetMinVer, err := posture.MeetsMinVersion(remoteJobsMinVer, p.Meta.WtVersion)
if !version.IsDevelopmentVersion(p.Meta.WtVersion) && (!meetMinVer || err != nil) {
return status.Errorf(status.PreconditionFailed, "peer version %s does not meet the minimum required version %s for remote jobs", p.Meta.WtVersion, remoteJobsMinVer)
}
@@ -1588,7 +1589,7 @@ func affectedPeerIDsFromNetworkMap(nmap *types.NetworkMap, selfPeerID string) []
}
seen := make(map[string]struct{}, len(nmap.Peers)+len(nmap.OfflinePeers))
ids := make([]string, 0, len(nmap.Peers)+len(nmap.OfflinePeers))
add := func(peers []*types.ComponentPeer) {
add := func(peers []*nmdata.Peer) {
for _, p := range peers {
if p == nil || p.ID == "" || p.ID == selfPeerID {
continue

View File

@@ -13,7 +13,6 @@ import (
"github.com/netbirdio/netbird/management/server/util"
"github.com/netbirdio/netbird/shared/management/http/api"
sharedTypes "github.com/netbirdio/netbird/shared/management/types"
)
// Peer capability constants mirror the proto enum values.
@@ -206,35 +205,6 @@ func (p *Peer) AddedWithSSOLogin() bool {
return p.UserID != ""
}
// ToComponent converts the peer to its self-contained components
// representation, carrying exactly the subset of peer data that crosses the
// components wire format. Returns nil for a nil peer so callers can convert
// possibly-missing peers without guarding.
func (p *Peer) ToComponent() *sharedTypes.ComponentPeer {
if p == nil {
return nil
}
cp := &sharedTypes.ComponentPeer{
ID: p.ID,
Key: p.Key,
IP: p.IP,
IPv6: p.IPv6,
DNSLabel: p.DNSLabel,
SSHKey: p.SSHKey,
SSHEnabled: p.SSHEnabled,
ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
AgentVersion: p.Meta.WtVersion,
SupportsSourcePrefixes: p.SupportsSourcePrefixes(),
SupportsIPv6: p.SupportsIPv6(),
LoginExpirationEnabled: p.LoginExpirationEnabled,
AddedWithSSOLogin: p.AddedWithSSOLogin(),
}
if p.LastLogin != nil {
cp.LastLogin = *p.LastLogin
}
return cp
}
// HasCapability reports whether the peer has the given capability.
func (p *Peer) HasCapability(capability int32) bool {
return slices.Contains(p.Meta.Capabilities, capability)

View File

@@ -57,6 +57,7 @@ import (
"github.com/netbirdio/netbird/management/server/types"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
"github.com/netbirdio/netbird/shared/management/proto"
)
@@ -1091,22 +1092,22 @@ func TestToSyncResponse(t *testing.T) {
Signature: "turn-pass",
}
networkMap := &types.NetworkMap{
Network: &types.Network{Net: *ipnet, Serial: 1000},
Peers: []*types.ComponentPeer{{
Network: &nmdata.Network{Net: *ipnet, Serial: 1000},
Peers: []*nmdata.Peer{{
IP: netip.MustParseAddr("192.168.1.2"),
IPv6: netip.MustParseAddr("fd00::2"),
Key: "peer2-key",
DNSLabel: "peer2",
SSHEnabled: true,
SSHKey: "peer2-ssh-key"}},
OfflinePeers: []*types.ComponentPeer{{
OfflinePeers: []*nmdata.Peer{{
IP: netip.MustParseAddr("192.168.1.3"),
IPv6: netip.MustParseAddr("fd00::3"),
Key: "peer3-key",
DNSLabel: "peer3",
SSHEnabled: true,
SSHKey: "peer3-ssh-key"}},
Routes: []*nbroute.Route{
Routes: []*nmdata.Route{
{
ID: "route1",
Network: netip.MustParsePrefix("10.0.0.0/24"),

View File

@@ -3,9 +3,11 @@ package posture
import (
"context"
"fmt"
"strings"
"github.com/hashicorp/go-version"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
nbversion "github.com/netbirdio/netbird/version"
)
type NBVersionCheck struct {
@@ -14,8 +16,14 @@ type NBVersionCheck struct {
var _ Check = (*NBVersionCheck)(nil)
// sanitizeVersion removes anything after the pre-release tag (e.g., "-dev", "-alpha", etc.)
func sanitizeVersion(version string) string {
parts := strings.Split(version, "-")
return parts[0]
}
func (n *NBVersionCheck) Check(ctx context.Context, peer nbpeer.Peer) (bool, error) {
meetsMin, err := nbversion.MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
meetsMin, err := MeetsMinVersion(n.MinVersion, peer.Meta.WtVersion)
if err != nil {
return false, err
}
@@ -40,3 +48,21 @@ func (n *NBVersionCheck) Validate() error {
}
return nil
}
// MeetsMinVersion checks if the peer's version meets or exceeds the minimum required version
func MeetsMinVersion(minVer, peerVer string) (bool, error) {
peerVer = sanitizeVersion(peerVer)
minVer = sanitizeVersion(minVer)
peerNBVer, err := version.NewVersion(peerVer)
if err != nil {
return false, err
}
constraints, err := version.NewConstraint(">= " + minVer)
if err != nil {
return false, err
}
return constraints.Check(peerNBVer), nil
}

View File

@@ -139,3 +139,68 @@ func TestNBVersionCheck_Validate(t *testing.T) {
})
}
}
func TestMeetsMinVersion(t *testing.T) {
tests := []struct {
name string
minVer string
peerVer string
want bool
wantErr bool
}{
{
name: "Peer version greater than min version",
minVer: "0.26.0",
peerVer: "0.60.1",
want: true,
wantErr: false,
},
{
name: "Peer version equals min version",
minVer: "1.0.0",
peerVer: "1.0.0",
want: true,
wantErr: false,
},
{
name: "Peer version less than min version",
minVer: "1.0.0",
peerVer: "0.9.9",
want: false,
wantErr: false,
},
{
name: "Peer version with pre-release tag greater than min version",
minVer: "1.0.0",
peerVer: "1.0.1-alpha",
want: true,
wantErr: false,
},
{
name: "Invalid peer version format",
minVer: "1.0.0",
peerVer: "dev",
want: false,
wantErr: true,
},
{
name: "Invalid min version format",
minVer: "invalid.version",
peerVer: "1.0.0",
want: false,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MeetsMinVersion(tt.minVer, tt.peerVer)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -1201,7 +1201,7 @@ func TestGetNetworkMap_RouteSync(t *testing.T) {
peer1Routes, err := am.GetNetworkMap(context.Background(), peer1ID)
require.NoError(t, err)
require.Len(t, peer1Routes.Routes, 1, "we should receive one route for peer1")
require.True(t, expectedRoute.Equal(peer1Routes.Routes[0]), "received route should be equal")
require.True(t, types.TwinRoute(expectedRoute).Equal(peer1Routes.Routes[0]), "received route should be equal")
peer2Routes, err := am.GetNetworkMap(context.Background(), peer2ID)
require.NoError(t, err)

View File

@@ -2259,7 +2259,7 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p
}
func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpservice.Service, error) {
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth, restrictions,
const serviceQuery = `SELECT id, account_id, name, domain, enabled, auth,
meta_created_at, meta_certificate_issued_at, meta_status, proxy_cluster,
pass_host_header, rewrite_redirects, session_private_key, session_public_key,
mode, listen_port, port_auto_assigned, source, source_peer, terminated,
@@ -2278,7 +2278,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
services, err := pgx.CollectRows(serviceRows, func(row pgx.CollectableRow) (*rpservice.Service, error) {
var s rpservice.Service
var auth []byte
var restrictions []byte
var accessGroups []byte
var createdAt, certIssuedAt sql.NullTime
var status, proxyCluster, sessionPrivateKey, sessionPublicKey sql.NullString
@@ -2292,7 +2291,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
&s.Domain,
&s.Enabled,
&auth,
&restrictions,
&createdAt,
&certIssuedAt,
&status,
@@ -2320,12 +2318,6 @@ func (s *SqlStore) getServices(ctx context.Context, accountID string) ([]*rpserv
}
}
if len(restrictions) > 0 {
if err := json.Unmarshal(restrictions, &s.Restrictions); err != nil {
return nil, fmt.Errorf("unmarshal restrictions: %w", err)
}
}
if len(accessGroups) > 0 {
if err := json.Unmarshal(accessGroups, &s.AccessGroups); err != nil {
return nil, fmt.Errorf("unmarshal access_groups: %w", err)
@@ -6365,7 +6357,6 @@ var validCapabilityColumns = map[string]struct{}{
"supports_custom_ports": {},
"require_subdomain": {},
"supports_crowdsec": {},
"supports_appsec": {},
"private": {},
}
@@ -6396,14 +6387,6 @@ func (s *SqlStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr s
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_crowdsec")
}
// GetClusterSupportsAppSec returns whether all active proxies in the cluster
// have a CrowdSec AppSec endpoint configured. Returns nil when no proxy
// reported the capability. Unanimous for the same reason as CrowdSec: a single
// proxy without AppSec would let requests through uninspected.
func (s *SqlStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
return s.getClusterUnanimousCapability(ctx, clusterAddr, "supports_appsec")
}
// getClusterUnanimousCapability returns an aggregated boolean capability
// requiring all active proxies in the cluster to report true.
func (s *SqlStore) getClusterUnanimousCapability(ctx context.Context, clusterAddr, column string) *bool {

View File

@@ -71,7 +71,7 @@ func (s *SqlStore) GetAgentNetworkMetrics(ctx context.Context) (AgentNetworkMetr
usageRow := db.Model(&agentNetworkTypes.AgentNetworkUsage{}).
Select("COALESCE(SUM(input_tokens), 0) AS input_tokens, " +
"COALESCE(SUM(output_tokens), 0) AS output_tokens, " +
"COALESCE(SUM" + agentNetworkTypes.CostUSDSQLExpr + ", 0) AS cost_usd").Row()
"COALESCE(SUM(cost_usd), 0) AS cost_usd").Row()
if err := usageRow.Scan(&m.InputTokens, &m.OutputTokens, &m.CostUSD); err != nil {
return AgentNetworkMetrics{}, fmt.Errorf("scan agent network usage metrics: %w", err)
}

View File

@@ -37,7 +37,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) {
InputTokens: 1200,
OutputTokens: 640,
TotalTokens: 1840,
InputCostUSD: 0.0231,
CostUSD: 0.0231,
}
usageGroups := []agentNetworkTypes.AgentNetworkUsageGroup{
{UsageID: usage.ID, GroupID: "grp-eng", AccountID: accountID},
@@ -71,7 +71,7 @@ func TestAgentNetworkUsage_RealStore_RoundTrip(t *testing.T) {
InputTokens: 1200,
OutputTokens: 640,
TotalTokens: 1840,
InputCostUSD: 0.0231,
CostUSD: 0.0231,
}
entryGroups := []agentNetworkTypes.AgentNetworkAccessLogGroup{
{LogID: entry.ID, GroupID: "grp-eng", AccountID: accountID},
@@ -127,7 +127,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) {
mk := func(id string, ts time.Time, model string, in, out int64, cost float64) *agentNetworkTypes.AgentNetworkUsage {
return &agentNetworkTypes.AgentNetworkUsage{
ID: id, AccountID: accountID, Timestamp: ts, Model: model,
InputTokens: in, OutputTokens: out, TotalTokens: in + out, InputCostUSD: cost,
InputTokens: in, OutputTokens: out, TotalTokens: in + out, CostUSD: cost,
}
}
require.NoError(t, s.CreateAgentNetworkUsage(ctx, mk("u1", day1, "gpt-4o", 100, 50, 0.10), nil))
@@ -143,7 +143,7 @@ func TestAgentNetworkUsageOverview_DailyAggregation(t *testing.T) {
assert.Equal(t, "2026-05-05", buckets[0].PeriodStart, "oldest-first ordering")
assert.Equal(t, int64(300), buckets[0].InputTokens, "same-day input tokens summed")
assert.Equal(t, int64(130), buckets[0].OutputTokens)
assert.InDelta(t, 0.30, buckets[0].TotalCostUSD(), 1e-9, "same-day cost summed")
assert.InDelta(t, 0.30, buckets[0].CostUSD, 1e-9, "same-day cost summed")
assert.Equal(t, "2026-05-06", buckets[1].PeriodStart)
assert.Equal(t, int64(15), buckets[1].TotalTokens)
@@ -174,7 +174,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) {
ID: id, AccountID: accountID, ServiceID: "svc", Timestamp: ts,
UserID: user, StatusCode: 200, Provider: provider, Model: model,
SessionID: session, Decision: decision,
InputTokens: 100, OutputTokens: 50, TotalTokens: 150, InputCostUSD: cost,
InputTokens: 100, OutputTokens: 50, TotalTokens: 150, CostUSD: cost,
}
}
@@ -207,7 +207,7 @@ func TestAgentNetworkAccessLogSessions_RealStore(t *testing.T) {
s1 := sessions[2]
assert.Equal(t, 2, s1.RequestCount, "s1 has two requests")
assert.Equal(t, int64(300), s1.TotalTokens, "tokens summed across the session")
assert.InDelta(t, 0.30, s1.TotalCostUSD(), 1e-9, "cost summed across the session")
assert.InDelta(t, 0.30, s1.CostUSD, 1e-9, "cost summed across the session")
assert.Equal(t, "alice", s1.UserID)
assert.Equal(t, "allow", s1.Decision)
// SQLite hands times back in time.Local; normalise to UTC so the instant is

View File

@@ -1,119 +0,0 @@
package store
import (
"context"
"fmt"
"os"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
)
// Capabilities travel proxy → gRPC → embedded gorm columns → aggregation → API.
// A field dropped at any of those hops reads as "capability absent", which is
// indistinguishable from a proxy that never reported it: the dashboard simply
// hides the feature and nothing fails. These assertions cover the persistence
// and aggregation hops.
func TestSqlStore_ClusterCapabilityAggregation(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
yes, no := true, false
tests := []struct {
name string
reported []*bool // one entry per connected proxy in the cluster
wantAppSec *bool
wantAssertion string
}{
{
name: "unreported stays unknown",
reported: []*bool{nil},
wantAppSec: nil,
wantAssertion: "an unreported capability must not read as false",
},
{
name: "single proxy reporting true",
reported: []*bool{&yes},
wantAppSec: &yes,
wantAssertion: "a reported capability must survive persistence",
},
{
name: "one proxy without it disables the cluster",
reported: []*bool{&yes, &no},
wantAppSec: &no,
wantAssertion: "capability must be unanimous, so a rolling upgrade cannot leave traffic uninspected",
},
{
name: "one proxy yet to report disables the cluster",
reported: []*bool{&yes, nil},
wantAppSec: &no,
wantAssertion: "a proxy that has not reported must not count as capable",
},
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cluster := fmt.Sprintf("cluster-%d.proxy.example", i)
for j, reported := range tt.reported {
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
ID: fmt.Sprintf("proxy-%d-%d", i, j),
ClusterAddress: cluster,
Status: proxy.StatusConnected,
LastSeen: time.Now(),
Capabilities: proxy.Capabilities{SupportsAppsec: reported},
}))
}
got := store.GetClusterSupportsAppSec(ctx, cluster)
if tt.wantAppSec == nil {
assert.Nil(t, got, tt.wantAssertion)
return
}
require.NotNil(t, got, tt.wantAssertion)
assert.Equal(t, *tt.wantAppSec, *got, tt.wantAssertion)
})
}
})
}
// AppSec and IP reputation are separate endpoints, so a cluster can have either
// without the other. Gating one on the other would silently disable a feature
// the operator configured.
func TestSqlStore_ClusterCapabilitiesAreIndependent(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
const cluster = "independent.proxy.example"
yes, no := true, false
require.NoError(t, store.SaveProxy(ctx, &proxy.Proxy{
ID: "proxy-independent",
ClusterAddress: cluster,
Status: proxy.StatusConnected,
LastSeen: time.Now(),
Capabilities: proxy.Capabilities{
SupportsAppsec: &yes,
SupportsCrowdsec: &no,
},
}))
appsec := store.GetClusterSupportsAppSec(ctx, cluster)
crowdsec := store.GetClusterSupportsCrowdSec(ctx, cluster)
require.NotNil(t, appsec)
require.NotNil(t, crowdsec)
assert.True(t, *appsec, "AppSec must not be gated on CrowdSec")
assert.False(t, *crowdsec, "CrowdSec must not be implied by AppSec")
})
}

View File

@@ -44,42 +44,3 @@ func TestSqlStore_GetAccount_PrivateServiceRoundtrip(t *testing.T) {
assert.Equal(t, []string{"grp-admins", "grp-ops"}, got.AccessGroups)
})
}
// Restrictions are stored as a JSON blob, and the Postgres read path lists
// columns by hand: a mode that is not read there is silently off on Postgres
// while working in SQLite dev.
func TestSqlStore_GetAccount_ServiceRestrictionsRoundtrip(t *testing.T) {
if os.Getenv("CI") == "true" && (runtime.GOOS == "darwin" || runtime.GOOS == "windows") {
t.Skip("skip CI tests on darwin and windows")
}
runTestForAllEngines(t, "", func(t *testing.T, store Store) {
ctx := context.Background()
account := newAccountWithId(ctx, "account_svc_restrictions", "testuser", "")
require.NoError(t, store.SaveAccount(ctx, account))
svc := &rpservice.Service{
ID: "svc-restrictions",
AccountID: account.Id,
Name: "restricted-svc",
Domain: "restricted.example",
Enabled: true,
Mode: rpservice.ModeHTTP,
Restrictions: rpservice.AccessRestrictions{
AllowedCIDRs: []string{"203.0.113.0/24"},
CrowdSecMode: "observe",
AppSecMode: "enforce",
},
}
require.NoError(t, store.CreateService(ctx, svc))
loaded, err := store.GetAccount(ctx, account.Id)
require.NoError(t, err)
require.Len(t, loaded.Services, 1)
got := loaded.Services[0].Restrictions
assert.Equal(t, []string{"203.0.113.0/24"}, got.AllowedCIDRs)
assert.Equal(t, "observe", got.CrowdSecMode)
assert.Equal(t, "enforce", got.AppSecMode)
})
}

View File

@@ -321,7 +321,6 @@ type Store interface {
GetClusterSupportsCustomPorts(ctx context.Context, clusterAddr string) *bool
GetClusterRequireSubdomain(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool
GetClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
CleanupStaleProxies(ctx context.Context, inactivityDuration time.Duration) error
GetAllProxies(ctx context.Context) ([]*proxy.Proxy, error)
@@ -651,14 +650,6 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
func(db *gorm.DB) error {
return migration.DropIndex[proxy.Proxy](ctx, db, "idx_proxy_account_id_unique")
},
// Post-auto so the per-bucket cost columns already exist when the legacy
// aggregates are folded into them and dropped.
func(db *gorm.DB) error {
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkAccessLog](ctx, db)
},
func(db *gorm.DB) error {
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
},
}
}

View File

@@ -1835,20 +1835,6 @@ func (mr *MockStoreMockRecorder) GetClusterRequireSubdomain(ctx, clusterAddr int
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterRequireSubdomain", reflect.TypeOf((*MockStore)(nil).GetClusterRequireSubdomain), ctx, clusterAddr)
}
// GetClusterSupportsAppSec mocks base method.
func (m *MockStore) GetClusterSupportsAppSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetClusterSupportsAppSec", ctx, clusterAddr)
ret0, _ := ret[0].(*bool)
return ret0
}
// GetClusterSupportsAppSec indicates an expected call of GetClusterSupportsAppSec.
func (mr *MockStoreMockRecorder) GetClusterSupportsAppSec(ctx, clusterAddr interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetClusterSupportsAppSec", reflect.TypeOf((*MockStore)(nil).GetClusterSupportsAppSec), ctx, clusterAddr)
}
// GetClusterSupportsCrowdSec mocks base method.
func (m *MockStore) GetClusterSupportsCrowdSec(ctx context.Context, clusterAddr string) *bool {
m.ctrl.T.Helper()

View File

@@ -18,8 +18,6 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
proxydomain "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -1082,7 +1080,6 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
peersExists := make(map[string]struct{})
rules := make([]*FirewallRule, 0)
peers := make([]*nbpeer.Peer, 0)
targetComponent := targetPeer.ToComponent()
return func(rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int) {
for _, peer := range groupPeers {
@@ -1118,10 +1115,10 @@ func (a *Account) connResourcesGenerator(ctx context.Context, targetPeer *nbpeer
if len(rule.Ports) == 0 && len(rule.PortRanges) == 0 {
rules = append(rules, &fr)
} else {
rules = append(rules, ExpandPortsAndRanges(fr, rule, targetComponent)...)
rules = append(rules, ExpandPortsAndRanges(fr, rule, targetPeer)...)
}
rules = AppendIPv6FirewallRule(rules, rulesExists, peer.ToComponent(), targetComponent, rule, FirewallRuleContext{
rules = AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, FirewallRuleContext{
Direction: direction,
DirStr: strconv.Itoa(direction),
ProtocolStr: string(protocol),
@@ -1281,7 +1278,7 @@ func (a *Account) getRouteFirewallRules(ctx context.Context, peerID string, poli
return fwRules
}
func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*ComponentPeer {
func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID string, distributionPeers map[string]struct{}, validatedPeersMap map[string]struct{}) []*nbpeer.Peer {
distPeersWithPolicy := make(map[string]struct{})
for _, id := range rule.Sources {
group := a.Groups[id]
@@ -1308,13 +1305,13 @@ func (a *Account) getRulePeers(rule *PolicyRule, postureChecks []string, peerID
}
}
distributionGroupPeers := make([]*ComponentPeer, 0, len(distPeersWithPolicy))
distributionGroupPeers := make([]*nbpeer.Peer, 0, len(distPeersWithPolicy))
for pID := range distPeersWithPolicy {
peer := a.Peers[pID]
if peer == nil {
continue
}
distributionGroupPeers = append(distributionGroupPeers, peer.ToComponent())
distributionGroupPeers = append(distributionGroupPeers, peer)
}
return distributionGroupPeers
}
@@ -1799,66 +1796,3 @@ func filterZoneRecordsForPeers(peer *nbpeer.Peer, customZone nbdns.CustomZone, p
return filteredRecords
}
// filterPeerAppliedZones filters account zones based on the peer's group membership
func filterPeerAppliedZones(ctx context.Context, accountZones []*zones.Zone, peerGroups LookupMap) []nbdns.CustomZone {
var customZones []nbdns.CustomZone
if len(peerGroups) == 0 {
return customZones
}
for _, zone := range accountZones {
if !zone.Enabled || len(zone.Records) == 0 {
continue
}
hasAccess := false
for _, distGroupID := range zone.DistributionGroups {
if _, found := peerGroups[distGroupID]; found {
hasAccess = true
break
}
}
if !hasAccess {
continue
}
simpleRecords := make([]nbdns.SimpleRecord, 0, len(zone.Records))
for _, record := range zone.Records {
var recordType int
rData := record.Content
switch record.Type {
case records.RecordTypeA:
recordType = int(dns.TypeA)
case records.RecordTypeAAAA:
recordType = int(dns.TypeAAAA)
case records.RecordTypeCNAME:
recordType = int(dns.TypeCNAME)
rData = dns.Fqdn(record.Content)
default:
log.WithContext(ctx).Warnf("unknown DNS record type %s for record %s", record.Type, record.ID)
continue
}
simpleRecords = append(simpleRecords, nbdns.SimpleRecord{
Name: dns.Fqdn(record.Name),
Type: recordType,
Class: nbdns.DefaultClass,
TTL: record.TTL,
RData: rData,
})
}
customZones = append(customZones, nbdns.CustomZone{
Domain: dns.Fqdn(zone.Domain),
Records: simpleRecords,
SearchDomainDisabled: !zone.EnableSearchDomain,
NonAuthoritative: true,
})
}
return customZones
}

View File

@@ -2,7 +2,6 @@ package types
import (
"context"
"slices"
"time"
log "github.com/sirupsen/logrus"
@@ -11,7 +10,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/zones"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
"github.com/netbirdio/netbird/management/server/telemetry"
"github.com/netbirdio/netbird/route"
)
// GetPeerNetworkMapResult dispatches to either the legacy-NetworkMap path or
@@ -92,6 +90,9 @@ func (a *Account) GetPeerNetworkMapFromComponents(
return nm
}
// GetPeerNetworkMapComponents builds the account's slim twin store and computes
// the peer's components on it. The calculation itself lives on
// networkmap.NetworkMapData and never touches the Account.
func (a *Account) GetPeerNetworkMapComponents(
ctx context.Context,
peerID string,
@@ -102,623 +103,6 @@ func (a *Account) GetPeerNetworkMapComponents(
routers map[string]map[string]*routerTypes.NetworkRouter,
groupIDToUserIDs map[string][]string,
) *NetworkMapComponents {
peer := a.Peers[peerID]
// this can never happen, things are very wrong if it did
// TODO (dmitri) maybe consider using invariants?
if peer == nil {
log.WithField("peer id", peerID).Error("NetworkMapComponents are computed for a peer missing from the account")
return EmptyNetworkMapComponents(&NetworkMapComponents{
PeerID: peerID,
Network: a.Network.Copy(),
// must include the target peer as it's required on the client
Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
})
}
if _, ok := validatedPeersMap[peerID]; !ok {
// Mirror legacy graceful-degrade: GetPeerNetworkMapFromComponents
// returns &NetworkMap{Network: a.Network.Copy()} when components is
// nil. Match that floor so the receiving client always sees the
// account Network identifier, not a fully-empty envelope.
return EmptyNetworkMapComponents(&NetworkMapComponents{
PeerID: peerID,
Network: a.Network.Copy(),
// must include the target peer as it's required on the client
Peers: map[string]*ComponentPeer{peerID: peer.ToComponent()},
})
}
components := &NetworkMapComponents{
PeerID: peerID,
Network: a.Network.Copy(),
NameServerGroups: make([]*nbdns.NameServerGroup, 0),
CustomZoneDomain: peersCustomZone.Domain,
ResourcePoliciesMap: make(map[string][]*Policy),
RoutersMap: make(map[string]map[string]*ComponentRouter),
NetworkResources: make([]*ComponentResource, 0),
PostureFailedPeers: make(map[string]map[string]struct{}, len(a.PostureChecks)),
RouterPeers: make(map[string]*ComponentPeer),
NetworkXIDToPublicID: make(map[string]string, len(a.Networks)),
PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
}
for _, n := range a.Networks {
if n != nil {
components.NetworkXIDToPublicID[n.ID] = n.PublicID
}
}
for _, pc := range a.PostureChecks {
if pc != nil {
components.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
}
}
components.AccountSettings = &AccountSettingsInfo{
PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled,
PeerLoginExpiration: a.Settings.PeerLoginExpiration,
PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
PeerInactivityExpiration: a.Settings.PeerInactivityExpiration,
}
components.DNSSettings = &a.DNSSettings
// relevantPeers always contains the target peer (peerID)
relevantPeers, relevantGroups, relevantPolicies, relevantRoutes, sshReqs := a.getPeersGroupsPoliciesRoutes(ctx, peerID, peer.SSHEnabled, validatedPeersMap, &components.PostureFailedPeers)
if len(sshReqs.neededGroupIDs) > 0 {
components.GroupIDToUserIDs = filterGroupIDToUserIDs(groupIDToUserIDs, sshReqs.neededGroupIDs)
}
if sshReqs.needAllowedUserIDs {
components.AllowedUserIDs = a.getAllowedUserIDs()
}
components.Peers = relevantPeers
components.Groups = GroupsToComponent(relevantGroups)
components.Policies = relevantPolicies
components.Routes = relevantRoutes
components.AllDNSRecords = filterDNSRecordsByPeers(peersCustomZone.Records, relevantPeers, peer.SupportsIPv6() && peer.IPv6.IsValid())
peerGroups := a.GetPeerGroups(peerID)
components.AccountZones = filterPeerAppliedZones(ctx, accountZones, peerGroups)
components.AccountZones = append(components.AccountZones, a.SynthesizePrivateServiceZones(peerID)...)
for _, nsGroup := range a.NameServerGroups {
if nsGroup.Enabled {
for _, gID := range nsGroup.Groups {
if _, found := relevantGroups[gID]; found {
components.NameServerGroups = append(components.NameServerGroups, nsGroup)
break
}
}
}
}
for _, resource := range a.NetworkResources {
if !resource.Enabled {
continue
}
policies, exists := resourcePolicies[resource.ID]
if !exists {
continue
}
addSourcePeers := false
networkRoutingPeers, routerExists := routers[resource.NetworkID]
if routerExists {
if _, ok := networkRoutingPeers[peerID]; ok {
addSourcePeers = true
}
}
for _, policy := range policies {
if addSourcePeers {
var peers []string
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
peers = []string{policy.Rules[0].SourceResource.ID}
} else {
peers = a.getUniquePeerIDsFromGroupsIDs(ctx, policy.SourceGroups())
}
for _, pID := range a.getPostureValidPeersSaveFailed(peers, policy.SourcePostureChecks, validatedPeersMap, &components.PostureFailedPeers) {
if _, exists := components.Peers[pID]; !exists {
components.Peers[pID] = a.GetPeer(pID).ToComponent()
}
}
} else {
peerInSources := false
if policy.Rules[0].SourceResource.Type == ResourceTypePeer && policy.Rules[0].SourceResource.ID != "" {
peerInSources = policy.Rules[0].SourceResource.ID == peerID
} else {
for _, groupID := range policy.SourceGroups() {
if group := a.GetGroup(groupID); group != nil && slices.Contains(group.Peers, peerID) {
peerInSources = true
break
}
}
}
if !peerInSources {
continue
}
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, policy.SourcePostureChecks, peerID)
if !isValid && len(pname) > 0 {
if _, ok := components.PostureFailedPeers[pname]; !ok {
components.PostureFailedPeers[pname] = make(map[string]struct{})
}
components.PostureFailedPeers[pname][peer.ID] = struct{}{}
continue
}
addSourcePeers = true
}
for _, rule := range policy.Rules {
for _, srcGroupID := range rule.Sources {
if g := a.Groups[srcGroupID]; g != nil {
if _, exists := components.Groups[srcGroupID]; !exists {
components.Groups[srcGroupID] = g.ToComponent()
}
}
}
for _, dstGroupID := range rule.Destinations {
if g := a.Groups[dstGroupID]; g != nil {
if _, exists := components.Groups[dstGroupID]; !exists {
components.Groups[dstGroupID] = g.ToComponent()
}
}
}
}
components.ResourcePoliciesMap[resource.ID] = policies
}
// Only expose router peers and the per-network routers_map when this
// target peer actually has access to the resource (either as a router
// itself or via a policy that includes it as a source). Without this
// gate, every peer's envelope was leaking router peers of every
// network in the account — accounts with many tenants/networks
// shipped tens of unrelated peers in `peers[]` and `routers_map`.
if addSourcePeers {
components.RoutersMap[resource.NetworkID] = routerTypes.ToComponentMap(networkRoutingPeers)
for peerIDKey := range networkRoutingPeers {
if p := a.Peers[peerIDKey]; p != nil {
cp := components.RouterPeers[peerIDKey]
if cp == nil {
cp = p.ToComponent()
components.RouterPeers[peerIDKey] = cp
}
if _, exists := components.Peers[peerIDKey]; !exists {
if _, validated := validatedPeersMap[peerIDKey]; validated {
components.Peers[peerIDKey] = cp
}
}
}
}
components.NetworkResources = append(components.NetworkResources, resource.ToComponent())
}
}
filterGroupPeers(&components.Groups, components.Peers)
filterPostureFailedPeers(&components.PostureFailedPeers, components.Policies, components.ResourcePoliciesMap, components.Peers)
return components
}
type sshRequirements struct {
neededGroupIDs map[string]struct{}
needAllowedUserIDs bool
}
func (a *Account) getPeersGroupsPoliciesRoutes(
ctx context.Context,
peerID string,
peerSSHEnabled bool,
validatedPeersMap map[string]struct{},
postureFailedPeers *map[string]map[string]struct{},
) (map[string]*ComponentPeer, map[string]*Group, []*Policy, []*route.Route, sshRequirements) {
relevantPeerIDs := make(map[string]*ComponentPeer, len(a.Peers)/4)
relevantGroupIDs := make(map[string]*Group, len(a.Groups)/4)
relevantPolicies := make([]*Policy, 0, len(a.Policies))
relevantRoutes := make([]*route.Route, 0, len(a.Routes))
sshReqs := sshRequirements{neededGroupIDs: make(map[string]struct{})}
relevantPeerIDs[peerID] = a.GetPeer(peerID).ToComponent()
peerGroupSet := make(map[string]struct{}, 8)
for groupID, group := range a.Groups {
if slices.Contains(group.Peers, peerID) {
relevantGroupIDs[groupID] = a.GetGroup(groupID)
peerGroupSet[groupID] = struct{}{}
}
}
routeAccessControlGroups := make(map[string]struct{})
for _, r := range a.Routes {
if r == nil {
continue
}
relevant := r.Peer == peerID
if !relevant {
for _, groupID := range r.PeerGroups {
if _, ok := peerGroupSet[groupID]; ok {
relevant = true
break
}
}
}
if !relevant && r.Enabled {
for _, groupID := range r.Groups {
if _, ok := peerGroupSet[groupID]; ok {
relevant = true
break
}
}
}
if !relevant {
continue
}
for _, groupID := range r.PeerGroups {
relevantGroupIDs[groupID] = a.GetGroup(groupID)
}
for _, groupID := range r.Groups {
relevantGroupIDs[groupID] = a.GetGroup(groupID)
}
if r.Enabled {
for _, groupID := range r.AccessControlGroups {
relevantGroupIDs[groupID] = a.GetGroup(groupID)
routeAccessControlGroups[groupID] = struct{}{}
}
}
// Include route advertisers in relevantPeerIDs. The envelope
// encoder writes route.peer_index by looking up r.Peer in the
// shipped peers list; if the advertiser is policy-isolated from
// the target peer (no rule edge between them), it would otherwise
// be omitted and the decoder would fail to resolve r.Peer, leaving
// the client without a WG tunnel target for this route. Legacy
// NetworkMap.Routes shipped the WG public key inline, so the
// equivalence path doesn't surface this — but the dependency is
// real once a client actually tries to use the route.
// Gate by validatedPeersMap so non-validated advertisers stay out
// (matches the network-resource router behaviour at the bottom of
// this loop, and the legacy invariant that only validated peers
// reach a client's view).
if r.Peer != "" {
if _, ok := validatedPeersMap[r.Peer]; ok {
if p := a.GetPeer(r.Peer); p != nil {
relevantPeerIDs[r.Peer] = p.ToComponent()
}
}
}
for _, groupID := range r.PeerGroups {
g := a.GetGroup(groupID)
if g == nil {
continue
}
for _, pid := range g.Peers {
if _, exists := relevantPeerIDs[pid]; exists {
continue
}
if _, ok := validatedPeersMap[pid]; !ok {
continue
}
if p := a.GetPeer(pid); p != nil {
relevantPeerIDs[pid] = p.ToComponent()
}
}
}
relevantRoutes = append(relevantRoutes, r)
}
for _, policy := range a.Policies {
if !policy.Enabled {
continue
}
policyRelevant := false
for _, rule := range policy.Rules {
if !rule.Enabled {
continue
}
if len(routeAccessControlGroups) > 0 {
for _, destGroupID := range rule.Destinations {
if _, needed := routeAccessControlGroups[destGroupID]; needed {
policyRelevant = true
for _, srcGroupID := range rule.Sources {
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
}
for _, dstGroupID := range rule.Destinations {
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
}
break
}
}
}
var sourcePeers, destinationPeers []string
var peerInSources, peerInDestinations bool
if rule.SourceResource.Type == ResourceTypePeer && rule.SourceResource.ID != "" {
sourcePeers = []string{rule.SourceResource.ID}
if rule.SourceResource.ID == peerID {
peerInSources = true
}
} else {
sourcePeers, peerInSources = a.getPeersFromGroups(ctx, rule.Sources, peerID, policy.SourcePostureChecks, validatedPeersMap, postureFailedPeers)
}
if rule.DestinationResource.Type == ResourceTypePeer && rule.DestinationResource.ID != "" {
destinationPeers = []string{rule.DestinationResource.ID}
if rule.DestinationResource.ID == peerID {
peerInDestinations = true
}
} else {
destinationPeers, peerInDestinations = a.getPeersFromGroups(ctx, rule.Destinations, peerID, nil, validatedPeersMap, postureFailedPeers)
}
if peerInSources {
policyRelevant = true
for _, pid := range destinationPeers {
if _, exists := relevantPeerIDs[pid]; !exists {
relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
}
}
for _, dstGroupID := range rule.Destinations {
relevantGroupIDs[dstGroupID] = a.GetGroup(dstGroupID)
}
}
if peerInDestinations {
policyRelevant = true
for _, pid := range sourcePeers {
if _, exists := relevantPeerIDs[pid]; !exists {
relevantPeerIDs[pid] = a.GetPeer(pid).ToComponent()
}
}
for _, srcGroupID := range rule.Sources {
relevantGroupIDs[srcGroupID] = a.GetGroup(srcGroupID)
}
if rule.Protocol == PolicyRuleProtocolNetbirdSSH {
switch {
case len(rule.AuthorizedGroups) > 0:
for groupID := range rule.AuthorizedGroups {
sshReqs.neededGroupIDs[groupID] = struct{}{}
}
case rule.AuthorizedUser != "":
default:
sshReqs.needAllowedUserIDs = true
}
} else if PolicyRuleImpliesLegacySSH(rule) && peerSSHEnabled {
sshReqs.needAllowedUserIDs = true
}
}
}
if policyRelevant {
relevantPolicies = append(relevantPolicies, policy)
}
}
return relevantPeerIDs, relevantGroupIDs, relevantPolicies, relevantRoutes, sshReqs
}
func (a *Account) getPeersFromGroups(ctx context.Context, groups []string, peerID string, sourcePostureChecksIDs []string,
validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) ([]string, bool) {
peerInGroups := false
filteredPeerIDs := make([]string, 0, len(groups))
seenPeerIds := make(map[string]struct{}, len(groups))
for _, gid := range groups {
group := a.GetGroup(gid)
if group == nil {
continue
}
if group.IsGroupAll() || len(groups) == 1 {
filteredPeerIDs = make([]string, 0, len(group.Peers))
peerInGroups = false
for _, pid := range group.Peers {
peer, ok := a.Peers[pid]
if !ok || peer == nil {
continue
}
if _, ok := validatedPeersMap[peer.ID]; !ok {
continue
}
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
if !isValid && len(pname) > 0 {
if _, ok := (*postureFailedPeers)[pname]; !ok {
(*postureFailedPeers)[pname] = make(map[string]struct{})
}
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
continue
}
if peer.ID == peerID {
peerInGroups = true
continue
}
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
}
return filteredPeerIDs, peerInGroups
}
for _, pid := range group.Peers {
if _, seen := seenPeerIds[pid]; seen {
continue
}
seenPeerIds[pid] = struct{}{}
peer, ok := a.Peers[pid]
if !ok || peer == nil {
continue
}
if _, ok := validatedPeersMap[peer.ID]; !ok {
continue
}
isValid, pname := a.validatePostureChecksOnPeerGetFailed(ctx, sourcePostureChecksIDs, peer.ID)
if !isValid && len(pname) > 0 {
if _, ok := (*postureFailedPeers)[pname]; !ok {
(*postureFailedPeers)[pname] = make(map[string]struct{})
}
(*postureFailedPeers)[pname][peer.ID] = struct{}{}
continue
}
if peer.ID == peerID {
peerInGroups = true
continue
}
filteredPeerIDs = append(filteredPeerIDs, peer.ID)
}
}
return filteredPeerIDs, peerInGroups
}
func (a *Account) validatePostureChecksOnPeerGetFailed(ctx context.Context, sourcePostureChecksID []string, peerID string) (bool, string) {
peer, ok := a.Peers[peerID]
if !ok || peer == nil {
return false, ""
}
for _, postureChecksID := range sourcePostureChecksID {
postureChecks := a.GetPostureChecks(postureChecksID)
if postureChecks == nil {
continue
}
for _, check := range postureChecks.GetChecks() {
isValid, _ := check.Check(ctx, *peer)
if !isValid {
return false, postureChecksID
}
}
}
return true, ""
}
func (a *Account) getPostureValidPeersSaveFailed(inputPeers []string, postureChecksIDs []string, validatedPeersMap map[string]struct{}, postureFailedPeers *map[string]map[string]struct{}) []string {
var dest []string
for _, peerID := range inputPeers {
if _, validated := validatedPeersMap[peerID]; !validated {
continue
}
valid, pname := a.validatePostureChecksOnPeerGetFailed(context.Background(), postureChecksIDs, peerID)
if valid {
dest = append(dest, peerID)
continue
}
if _, ok := (*postureFailedPeers)[pname]; !ok {
(*postureFailedPeers)[pname] = make(map[string]struct{})
}
(*postureFailedPeers)[pname][peerID] = struct{}{}
}
return dest
}
// filterGroupPeers trims each group's Peers slice to only those peers that
// also appear in `peers`. Groups whose filtered list is empty are NOT
// deleted from the map — they're kept so the components wire encoder can
// still resolve seq references from routes/policies/access-control groups
// that name them. Calculate() tolerates groups with empty Peers (the inner
// loops simply iterate zero times), so retaining them is behaviourally a
// no-op for the legacy path that consumes the same NetworkMapComponents.
func filterGroupPeers(groups *map[string]*ComponentGroup, peers map[string]*ComponentPeer) {
for groupID, groupInfo := range *groups {
filteredPeers := make([]string, 0, len(groupInfo.Peers))
for _, pid := range groupInfo.Peers {
if _, exists := peers[pid]; exists {
filteredPeers = append(filteredPeers, pid)
}
}
if len(filteredPeers) != len(groupInfo.Peers) {
ng := *groupInfo
ng.Peers = filteredPeers
(*groups)[groupID] = &ng
}
}
}
func filterPostureFailedPeers(postureFailedPeers *map[string]map[string]struct{}, policies []*Policy, resourcePoliciesMap map[string][]*Policy, peers map[string]*ComponentPeer) {
if len(*postureFailedPeers) == 0 {
return
}
referencedPostureChecks := make(map[string]struct{})
for _, policy := range policies {
for _, checkID := range policy.SourcePostureChecks {
referencedPostureChecks[checkID] = struct{}{}
}
}
for _, resPolicies := range resourcePoliciesMap {
for _, policy := range resPolicies {
for _, checkID := range policy.SourcePostureChecks {
referencedPostureChecks[checkID] = struct{}{}
}
}
}
for checkID, failedPeers := range *postureFailedPeers {
if _, referenced := referencedPostureChecks[checkID]; !referenced {
delete(*postureFailedPeers, checkID)
continue
}
for peerID := range failedPeers {
if _, exists := peers[peerID]; !exists {
delete(failedPeers, peerID)
}
}
if len(failedPeers) == 0 {
delete(*postureFailedPeers, checkID)
}
}
}
func filterDNSRecordsByPeers(records []nbdns.SimpleRecord, peers map[string]*ComponentPeer, includeIPv6 bool) []nbdns.SimpleRecord {
if len(records) == 0 || len(peers) == 0 {
return nil
}
// Include both v4 and v6 addresses so AAAA records (whose RData is an IPv6
// address) are not filtered out when peers have IPv6 assigned. When the
// requesting peer doesn't have IPv6, omit v6 IPs so AAAA records get dropped.
peerIPs := make(map[string]struct{}, len(peers)*2)
for _, peer := range peers {
if peer == nil {
continue
}
peerIPs[peer.IP.String()] = struct{}{}
if includeIPv6 && peer.IPv6.IsValid() {
peerIPs[peer.IPv6.String()] = struct{}{}
}
}
filteredRecords := make([]nbdns.SimpleRecord, 0, len(records))
for _, record := range records {
if _, exists := peerIPs[record.RData]; exists {
filteredRecords = append(filteredRecords, record)
}
}
return filteredRecords
}
func filterGroupIDToUserIDs(fullMap map[string][]string, neededGroupIDs map[string]struct{}) map[string][]string {
if len(neededGroupIDs) == 0 {
return nil
}
filtered := make(map[string][]string, len(neededGroupIDs))
for groupID := range neededGroupIDs {
if users, ok := fullMap[groupID]; ok {
filtered[groupID] = users
}
}
return filtered
nmd := a.toNetworkMapData(accountZones, validatedPeersMap, resourcePolicies, routers, groupIDToUserIDs)
return nmd.GetPeerNetworkMapComponents(peerID, toTwinCustomZone(peersCustomZone))
}

View File

@@ -0,0 +1,515 @@
package types
import (
"github.com/miekg/dns"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/posture"
nbroute "github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/networkmap"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
// toNetworkMapData builds the slim twin store from the account once per
// account. The per-peer components calculation then runs on the twin.
func (a *Account) toNetworkMapData(
accountZones []*zones.Zone,
validatedPeersMap map[string]struct{},
resourcePolicies map[string][]*Policy,
routers map[string]map[string]*routerTypes.NetworkRouter,
groupIDToUserIDs map[string][]string,
) *networkmap.NetworkMapData {
nmd := &networkmap.NetworkMapData{
Peers: make(map[string]*nmdata.Peer, len(a.Peers)),
Groups: make(map[string]*nmdata.Group, len(a.Groups)),
Policies: make([]*nmdata.Policy, 0, len(a.Policies)),
Routes: make([]*nmdata.Route, 0, len(a.Routes)),
NameServerGroups: make([]*nmdata.NameServerGroup, 0, len(a.NameServerGroups)),
NetworkResources: make([]*nmdata.NetworkResource, 0, len(a.NetworkResources)),
PostureChecks: make(map[string]*nmdata.PostureChecks, len(a.PostureChecks)),
ResourcePolicies: make(map[string][]*nmdata.Policy, len(resourcePolicies)),
Routers: make(map[string]map[string]*nmdata.NetworkRouter, len(routers)),
ValidatedPeers: validatedPeersMap,
GroupIDToUserIDs: groupIDToUserIDs,
AllowedUserIDs: a.getAllowedUserIDs(),
NetworkXIDToPublicID: make(map[string]string, len(a.Networks)),
PostureCheckXIDToPublicID: make(map[string]string, len(a.PostureChecks)),
}
if a.Network != nil {
nmd.Network = TwinNetwork(a.Network)
}
nmd.DNSSettings = &nmdata.DNSSettings{DisabledManagementGroups: a.DNSSettings.DisabledManagementGroups}
if a.Settings != nil {
nmd.AccountSettings = &nmdata.AccountSettingsInfo{
PeerLoginExpirationEnabled: a.Settings.PeerLoginExpirationEnabled,
PeerLoginExpiration: a.Settings.PeerLoginExpiration,
PeerInactivityExpirationEnabled: a.Settings.PeerInactivityExpirationEnabled,
PeerInactivityExpiration: a.Settings.PeerInactivityExpiration,
}
}
for id, p := range a.Peers {
nmd.Peers[id] = twinPeer(p)
}
for id, g := range a.Groups {
nmd.Groups[id] = twinGroup(g)
}
policyCache := make(map[string]*nmdata.Policy, len(a.Policies))
twinPol := func(p *Policy) *nmdata.Policy {
if p == nil {
return nil
}
if tp, ok := policyCache[p.ID]; ok {
return tp
}
tp := twinPolicy(p)
policyCache[p.ID] = tp
return tp
}
for _, p := range a.Policies {
nmd.Policies = append(nmd.Policies, twinPol(p))
}
for resID, pols := range resourcePolicies {
twinPols := make([]*nmdata.Policy, 0, len(pols))
for _, p := range pols {
twinPols = append(twinPols, twinPol(p))
}
nmd.ResourcePolicies[resID] = twinPols
}
for _, r := range a.Routes {
if r == nil {
continue
}
nmd.Routes = append(nmd.Routes, twinRoute(r))
}
for _, nsg := range a.NameServerGroups {
nmd.NameServerGroups = append(nmd.NameServerGroups, twinNSG(nsg))
}
for _, res := range a.NetworkResources {
nmd.NetworkResources = append(nmd.NetworkResources, twinNetworkResource(res))
}
for _, pc := range a.PostureChecks {
if pc != nil {
nmd.PostureChecks[pc.ID] = twinPostureChecks(pc)
nmd.PostureCheckXIDToPublicID[pc.ID] = pc.PublicID
}
}
for _, n := range a.Networks {
if n != nil {
nmd.NetworkXIDToPublicID[n.ID] = n.PublicID
}
}
for networkID, inner := range routers {
twinInner := make(map[string]*nmdata.NetworkRouter, len(inner))
for peerID, router := range inner {
twinInner[peerID] = twinRouter(router)
}
nmd.Routers[networkID] = twinInner
}
nmd.AppliedZoneCandidates = buildAppliedZoneCandidates(accountZones)
nmd.PrivateServiceCandidates = a.buildPrivateServiceCandidates()
return nmd
}
func twinPeer(p *nbpeer.Peer) *nmdata.Peer {
if p == nil {
return nil
}
networkAddresses := make([]nmdata.NetworkAddress, 0, len(p.Meta.NetworkAddresses))
for _, na := range p.Meta.NetworkAddresses {
networkAddresses = append(networkAddresses, nmdata.NetworkAddress{NetIP: na.NetIP})
}
files := make([]nmdata.File, 0, len(p.Meta.Files))
for _, f := range p.Meta.Files {
files = append(files, nmdata.File{Path: f.Path, ProcessIsRunning: f.ProcessIsRunning})
}
return &nmdata.Peer{
ID: p.ID,
Key: p.Key,
SSHKey: p.SSHKey,
DNSLabel: p.DNSLabel,
UserID: p.UserID,
SSHEnabled: p.SSHEnabled,
LoginExpirationEnabled: p.LoginExpirationEnabled,
LastLogin: p.LastLogin,
IP: p.IP,
IPv6: p.IPv6,
Meta: nmdata.PeerSystemMeta{
WtVersion: p.Meta.WtVersion,
GoOS: p.Meta.GoOS,
OSVersion: p.Meta.OSVersion,
KernelVersion: p.Meta.KernelVersion,
NetworkAddresses: networkAddresses,
Files: files,
Capabilities: p.Meta.Capabilities,
Flags: nmdata.Flags{
ServerSSHAllowed: p.Meta.Flags.ServerSSHAllowed,
DisableIPv6: p.Meta.Flags.DisableIPv6,
},
},
Location: nmdata.PeerLocation{
CountryCode: p.Location.CountryCode,
CityName: p.Location.CityName,
ConnectionIP: p.Location.ConnectionIP,
},
}
}
// TwinPeer converts a real peer to its slim nmdata twin. Exported for the
// port-forwarding integration, which builds proxy NetworkMaps holding twins.
func TwinPeer(p *nbpeer.Peer) *nmdata.Peer {
return twinPeer(p)
}
func twinPeers(peers []*nbpeer.Peer) []*nmdata.Peer {
out := make([]*nmdata.Peer, len(peers))
for i, p := range peers {
out[i] = twinPeer(p)
}
return out
}
func twinGroup(g *Group) *nmdata.Group {
if g == nil {
return nil
}
return &nmdata.Group{
Name: g.Name,
PublicID: g.PublicID,
Peers: g.Peers,
}
}
func twinPolicy(p *Policy) *nmdata.Policy {
if p == nil {
return nil
}
rules := make([]*nmdata.PolicyRule, 0, len(p.Rules))
for _, r := range p.Rules {
rules = append(rules, twinRule(r))
}
return &nmdata.Policy{
ID: p.ID,
PublicID: p.PublicID,
Enabled: p.Enabled,
SourcePostureChecks: p.SourcePostureChecks,
Rules: rules,
}
}
func twinRule(r *PolicyRule) *nmdata.PolicyRule {
if r == nil {
return nil
}
var portRanges []nmdata.RulePortRange
if r.PortRanges != nil {
portRanges = make([]nmdata.RulePortRange, len(r.PortRanges))
for i, pr := range r.PortRanges {
portRanges[i] = nmdata.RulePortRange{Start: pr.Start, End: pr.End}
}
}
return &nmdata.PolicyRule{
ID: r.ID,
PolicyID: r.PolicyID,
Enabled: r.Enabled,
Action: string(r.Action),
Protocol: string(r.Protocol),
Bidirectional: r.Bidirectional,
Sources: r.Sources,
Destinations: r.Destinations,
SourceResource: nmdata.Resource{ID: r.SourceResource.ID, Type: string(r.SourceResource.Type)},
DestinationResource: nmdata.Resource{ID: r.DestinationResource.ID, Type: string(r.DestinationResource.Type)},
Ports: r.Ports,
PortRanges: portRanges,
AuthorizedGroups: r.AuthorizedGroups,
AuthorizedUser: r.AuthorizedUser,
}
}
func twinRoute(r *nbroute.Route) *nmdata.Route {
return &nmdata.Route{
ID: string(r.ID),
AccountID: r.AccountID,
PublicID: r.PublicID,
Network: r.Network,
Domains: r.Domains,
KeepRoute: r.KeepRoute,
NetID: string(r.NetID),
Description: r.Description,
Peer: r.Peer,
PeerID: r.PeerID,
PeerGroups: r.PeerGroups,
NetworkType: int(r.NetworkType),
Masquerade: r.Masquerade,
Metric: r.Metric,
Enabled: r.Enabled,
Groups: r.Groups,
AccessControlGroups: r.AccessControlGroups,
SkipAutoApply: r.SkipAutoApply,
}
}
// TwinRoute converts a real *route.Route to its slim nmdata twin. Exported for
// tests that assert against twin routes returned in a NetworkMap.
func TwinRoute(r *nbroute.Route) *nmdata.Route {
return twinRoute(r)
}
func twinNetworkResource(r *resourceTypes.NetworkResource) *nmdata.NetworkResource {
if r == nil {
return nil
}
return &nmdata.NetworkResource{
ID: r.ID,
NetworkID: r.NetworkID,
AccountID: r.AccountID,
PublicID: r.PublicID,
Name: r.Name,
Description: r.Description,
Type: string(r.Type),
Address: r.Address,
Domain: r.Domain,
Prefix: r.Prefix,
Enabled: r.Enabled,
}
}
func twinRouter(r *routerTypes.NetworkRouter) *nmdata.NetworkRouter {
if r == nil {
return nil
}
return &nmdata.NetworkRouter{
PublicID: r.PublicID,
PeerGroups: r.PeerGroups,
Masquerade: r.Masquerade,
Metric: r.Metric,
Enabled: r.Enabled,
}
}
func twinNSG(n *nbdns.NameServerGroup) *nmdata.NameServerGroup {
if n == nil {
return nil
}
nameServers := make([]nmdata.NameServer, 0, len(n.NameServers))
for _, ns := range n.NameServers {
nameServers = append(nameServers, nmdata.NameServer{
IP: ns.IP,
NSType: int(ns.NSType),
Port: ns.Port,
})
}
return &nmdata.NameServerGroup{
ID: n.ID,
PublicID: n.PublicID,
Name: n.Name,
Description: n.Description,
NameServers: nameServers,
Groups: n.Groups,
Primary: n.Primary,
Domains: n.Domains,
Enabled: n.Enabled,
SearchDomainsEnabled: n.SearchDomainsEnabled,
}
}
// TwinNetwork converts a real *Network to its slim twin. Exported for the
// graceful-degrade path that builds a minimal NetworkMapComponents directly.
func TwinNetwork(n *Network) *nmdata.Network {
nc := n.Copy()
return &nmdata.Network{
Identifier: nc.Identifier,
Net: nc.Net,
NetV6: nc.NetV6,
Dns: nc.Dns,
Serial: nc.Serial,
}
}
func twinPostureChecks(pc *posture.Checks) *nmdata.PostureChecks {
if pc == nil {
return nil
}
out := &nmdata.PostureChecks{ID: pc.ID}
def := pc.Checks
if def.NBVersionCheck != nil {
out.Checks.NBVersionCheck = &nmdata.NBVersionCheck{MinVersion: def.NBVersionCheck.MinVersion}
}
if def.OSVersionCheck != nil {
oc := &nmdata.OSVersionCheck{}
if def.OSVersionCheck.Android != nil {
oc.Android = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Android.MinVersion}
}
if def.OSVersionCheck.Darwin != nil {
oc.Darwin = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Darwin.MinVersion}
}
if def.OSVersionCheck.Ios != nil {
oc.Ios = &nmdata.MinVersionCheck{MinVersion: def.OSVersionCheck.Ios.MinVersion}
}
if def.OSVersionCheck.Linux != nil {
oc.Linux = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Linux.MinKernelVersion}
}
if def.OSVersionCheck.Windows != nil {
oc.Windows = &nmdata.MinKernelVersionCheck{MinKernelVersion: def.OSVersionCheck.Windows.MinKernelVersion}
}
out.Checks.OSVersionCheck = oc
}
if def.GeoLocationCheck != nil {
gc := &nmdata.GeoLocationCheck{Action: def.GeoLocationCheck.Action}
for _, loc := range def.GeoLocationCheck.Locations {
gc.Locations = append(gc.Locations, nmdata.GeoLocation{CountryCode: loc.CountryCode, CityName: loc.CityName})
}
out.Checks.GeoLocationCheck = gc
}
if def.PeerNetworkRangeCheck != nil {
out.Checks.PeerNetworkRangeCheck = &nmdata.PeerNetworkRangeCheck{
Action: def.PeerNetworkRangeCheck.Action,
Ranges: def.PeerNetworkRangeCheck.Ranges,
}
}
if def.ProcessCheck != nil {
procs := make([]nmdata.Process, 0, len(def.ProcessCheck.Processes))
for _, p := range def.ProcessCheck.Processes {
procs = append(procs, nmdata.Process{LinuxPath: p.LinuxPath, MacPath: p.MacPath, WindowsPath: p.WindowsPath})
}
out.Checks.ProcessCheck = &nmdata.ProcessCheck{Processes: procs}
}
return out
}
// buildAppliedZoneCandidates precomputes the account-level custom DNS zones
// (record conversion) once; the per-peer distribution-group gate runs in the
// components calc. Mirrors the account-level half of filterPeerAppliedZones.
func buildAppliedZoneCandidates(accountZones []*zones.Zone) []networkmap.AppliedZoneCandidate {
var out []networkmap.AppliedZoneCandidate
for _, zone := range accountZones {
if !zone.Enabled || len(zone.Records) == 0 {
continue
}
simpleRecords := make([]nmdata.SimpleRecord, 0, len(zone.Records))
for _, record := range zone.Records {
var recordType int
rData := record.Content
switch record.Type {
case records.RecordTypeA:
recordType = int(dns.TypeA)
case records.RecordTypeAAAA:
recordType = int(dns.TypeAAAA)
case records.RecordTypeCNAME:
recordType = int(dns.TypeCNAME)
rData = dns.Fqdn(record.Content)
default:
continue
}
simpleRecords = append(simpleRecords, nmdata.SimpleRecord{
Name: dns.Fqdn(record.Name),
Type: recordType,
Class: nbdns.DefaultClass,
TTL: record.TTL,
RData: rData,
})
}
out = append(out, networkmap.AppliedZoneCandidate{
DistributionGroups: zone.DistributionGroups,
Zone: nmdata.CustomZone{
Domain: dns.Fqdn(zone.Domain),
Records: simpleRecords,
SearchDomainDisabled: !zone.EnableSearchDomain,
NonAuthoritative: true,
},
})
}
return out
}
// buildPrivateServiceCandidates precomputes the connected-proxy A records per
// private service (account-level); the per-peer access-group gate + apex merge
// run in the components calc. Mirrors the account-level half of
// SynthesizePrivateServiceZones.
func (a *Account) buildPrivateServiceCandidates() []networkmap.PrivateServiceCandidate {
if len(a.Services) == 0 {
return nil
}
proxyPeersByCluster := a.GetProxyPeers()
if len(proxyPeersByCluster) == 0 {
return nil
}
var out []networkmap.PrivateServiceCandidate
for _, svc := range a.Services {
if svc == nil || !svc.Enabled || !svc.Private {
continue
}
if len(svc.AccessGroups) == 0 {
continue
}
proxyPeers := proxyPeersByCluster[svc.ProxyCluster]
if len(proxyPeers) == 0 {
continue
}
apex := a.privateServiceDomainZone(svc)
if apex == "" {
continue
}
var recs []nmdata.SimpleRecord
for _, p := range proxyPeers {
if p == nil || !p.IP.IsValid() {
continue
}
if p.Status == nil || !p.Status.Connected {
continue
}
recs = append(recs, nmdata.SimpleRecord{
Name: dns.Fqdn(svc.Domain),
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: privateServiceDNSRecordTTL,
RData: p.IP.String(),
})
}
if len(recs) == 0 {
continue
}
out = append(out, networkmap.PrivateServiceCandidate{
AccessGroups: svc.AccessGroups,
Zone: nmdata.CustomZone{
Domain: dns.Fqdn(apex),
Records: recs,
NonAuthoritative: true,
SearchDomainDisabled: true,
},
})
}
return out
}
func toTwinCustomZone(z nbdns.CustomZone) nmdata.CustomZone {
records := make([]nmdata.SimpleRecord, 0, len(z.Records))
for _, r := range z.Records {
records = append(records, nmdata.SimpleRecord{
Name: r.Name,
Type: r.Type,
Class: r.Class,
TTL: r.TTL,
RData: r.RData,
})
}
return nmdata.CustomZone{
Domain: z.Domain,
Records: records,
SearchDomainDisabled: z.SearchDomainDisabled,
NonAuthoritative: z.NonAuthoritative,
}
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
@@ -48,7 +49,7 @@ func TestPrivateService_NetworkMap_UserPeer_AndProxyPeer(t *testing.T) {
})
}
func netmapPeerIDs(peers []*ComponentPeer) []string {
func netmapPeerIDs(peers []*nmdata.Peer) []string {
ids := make([]string, 0, len(peers))
for _, p := range peers {
if p == nil {

View File

@@ -13,8 +13,6 @@ import (
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
"github.com/netbirdio/netbird/management/internals/modules/zones"
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
routerTypes "github.com/netbirdio/netbird/management/server/networks/routers/types"
networkTypes "github.com/netbirdio/netbird/management/server/networks/types"
@@ -666,7 +664,7 @@ func Test_ExpandPortsAndRanges_SSHRuleExpansion(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer.ToComponent())
result := ExpandPortsAndRanges(tt.base, tt.rule, tt.peer)
var ports []string
for _, fr := range result {
@@ -1040,518 +1038,6 @@ func Test_FilterZoneRecordsForPeers(t *testing.T) {
}
}
func Test_filterPeerAppliedZones(t *testing.T) {
ctx := context.Background()
tests := []struct {
name string
accountZones []*zones.Zone
peerGroups LookupMap
expected []nbdns.CustomZone
}{
{
name: "empty peer groups returns empty custom zones",
accountZones: []*zones.Zone{},
peerGroups: LookupMap{},
expected: []nbdns.CustomZone{},
},
{
name: "peer has access to zone with A record",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "example.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.example.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "example.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.example.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "peer has access to zone with search domain enabled",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "internal.local",
Enabled: true,
EnableSearchDomain: true,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "api.internal.local",
Type: records.RecordTypeA,
Content: "10.0.0.1",
TTL: 600,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "internal.local.",
Records: []nbdns.SimpleRecord{
{
Name: "api.internal.local.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 600,
RData: "10.0.0.1",
},
},
SearchDomainDisabled: false,
},
},
},
{
name: "peer has no access to zone",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "private.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group2"},
Records: []*records.Record{
{
ID: "record1",
Name: "secret.private.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{},
},
{
name: "disabled zone is filtered out",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "disabled.com",
Enabled: false,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.disabled.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{},
},
{
name: "zone with no records is filtered out",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "empty.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{},
},
{
name: "peer has access via multiple groups",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "multi.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1", "group2", "group3"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.multi.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group2": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "multi.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.multi.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "multiple zones with mixed access",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "allowed.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.allowed.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
{
ID: "zone2",
Domain: "denied.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group2"},
Records: []*records.Record{
{
ID: "record2",
Name: "www.denied.com",
Type: records.RecordTypeA,
Content: "192.168.1.2",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "allowed.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.allowed.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "zone with multiple record types",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "mixed.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.mixed.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
{
ID: "record2",
Name: "ipv6.mixed.com",
Type: records.RecordTypeAAAA,
Content: "2001:db8::1",
TTL: 600,
},
{
ID: "record3",
Name: "alias.mixed.com",
Type: records.RecordTypeCNAME,
Content: "www.mixed.com",
TTL: 900,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "mixed.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.mixed.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
{
Name: "ipv6.mixed.com.",
Type: int(dns.TypeAAAA),
Class: nbdns.DefaultClass,
TTL: 600,
RData: "2001:db8::1",
},
{
Name: "alias.mixed.com.",
Type: int(dns.TypeCNAME),
Class: nbdns.DefaultClass,
TTL: 900,
RData: "www.mixed.com.",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "multiple zones both accessible",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "first.com",
Enabled: true,
EnableSearchDomain: true,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.first.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
{
ID: "zone2",
Domain: "second.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record2",
Name: "www.second.com",
Type: records.RecordTypeA,
Content: "192.168.1.2",
TTL: 600,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "first.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.first.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
},
SearchDomainDisabled: false,
},
{
Domain: "second.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.second.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 600,
RData: "192.168.1.2",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "zone with multiple records of same type",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "multi-a.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.multi-a.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
{
ID: "record2",
Name: "www.multi-a.com",
Type: records.RecordTypeA,
Content: "192.168.1.2",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "multi-a.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.multi-a.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
{
Name: "www.multi-a.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.2",
},
},
SearchDomainDisabled: true,
},
},
},
{
name: "peer in multiple groups accessing different zones",
accountZones: []*zones.Zone{
{
ID: "zone1",
Domain: "zone1.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group1"},
Records: []*records.Record{
{
ID: "record1",
Name: "www.zone1.com",
Type: records.RecordTypeA,
Content: "192.168.1.1",
TTL: 300,
},
},
},
{
ID: "zone2",
Domain: "zone2.com",
Enabled: true,
EnableSearchDomain: false,
DistributionGroups: []string{"group2"},
Records: []*records.Record{
{
ID: "record2",
Name: "www.zone2.com",
Type: records.RecordTypeA,
Content: "192.168.1.2",
TTL: 300,
},
},
},
},
peerGroups: LookupMap{"group1": struct{}{}, "group2": struct{}{}},
expected: []nbdns.CustomZone{
{
Domain: "zone1.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.zone1.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.1",
},
},
SearchDomainDisabled: true,
},
{
Domain: "zone2.com.",
Records: []nbdns.SimpleRecord{
{
Name: "www.zone2.com.",
Type: int(dns.TypeA),
Class: nbdns.DefaultClass,
TTL: 300,
RData: "192.168.1.2",
},
},
SearchDomainDisabled: true,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filterPeerAppliedZones(ctx, tt.accountZones, tt.peerGroups)
require.Equal(t, len(tt.expected), len(result), "number of custom zones should match")
for i, expectedZone := range tt.expected {
assert.Equal(t, expectedZone.Domain, result[i].Domain, "domain should match")
assert.Equal(t, expectedZone.SearchDomainDisabled, result[i].SearchDomainDisabled, "search domain disabled flag should match")
assert.Equal(t, len(expectedZone.Records), len(result[i].Records), "number of records should match")
for j, expectedRecord := range expectedZone.Records {
assert.Equal(t, expectedRecord.Name, result[i].Records[j].Name, "record name should match")
assert.Equal(t, expectedRecord.Type, result[i].Records[j].Type, "record type should match")
assert.Equal(t, expectedRecord.Class, result[i].Records[j].Class, "record class should match")
assert.Equal(t, expectedRecord.TTL, result[i].Records[j].TTL, "record TTL should match")
assert.Equal(t, expectedRecord.RData, result[i].Records[j].RData, "record RData should match")
}
}
})
}
}
func TestInjectPrivateServicePolicies_ProxyPeerGetsInboundRule(t *testing.T) {
ctx := context.Background()

View File

@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
nbroute "github.com/netbirdio/netbird/route"
sharedtypes "github.com/netbirdio/netbird/shared/management/types"
)
@@ -17,6 +18,9 @@ type DNSSettings = sharedtypes.DNSSettings
type FirewallRule = sharedtypes.FirewallRule
type Group = sharedtypes.Group
type GroupPeer = sharedtypes.GroupPeer
type Network = sharedtypes.Network
type NetworkMap = sharedtypes.NetworkMap
type ForwardingRule = sharedtypes.ForwardingRule
@@ -38,18 +42,6 @@ type RouteFirewallRule = sharedtypes.RouteFirewallRule
type NetworkMapComponents = sharedtypes.NetworkMapComponents
type ComponentPeer = sharedtypes.ComponentPeer
type ComponentGroup = sharedtypes.ComponentGroup
type ComponentRouter = sharedtypes.ComponentRouter
type ComponentResource = sharedtypes.ComponentResource
type ComponentResourceType = sharedtypes.ComponentResourceType
const (
ComponentResourceHost = sharedtypes.ComponentResourceHost
ComponentResourceSubnet = sharedtypes.ComponentResourceSubnet
ComponentResourceDomain = sharedtypes.ComponentResourceDomain
)
var EmptyNetworkMapComponents = sharedtypes.EmptyNetworkMapComponents
type AccountSettingsInfo = sharedtypes.AccountSettingsInfo
@@ -60,7 +52,12 @@ type NetworkMapComponentsCompact = sharedtypes.NetworkMapComponentsCompact
type LookupMap = sharedtypes.LookupMap
type FirewallRuleContext = sharedtypes.FirewallRuleContext
const GroupAllName = sharedtypes.GroupAllName
const (
GroupIssuedAPI = sharedtypes.GroupIssuedAPI
GroupIssuedJWT = sharedtypes.GroupIssuedJWT
GroupIssuedIntegration = sharedtypes.GroupIssuedIntegration
GroupAllName = sharedtypes.GroupAllName
)
// Function forwarders preserve types.X(...) call sites that previously
// resolved to package-local funcs. Plain forwarders (not var aliases) keep
@@ -70,20 +67,24 @@ func PolicyRuleImpliesLegacySSH(rule *PolicyRule) bool {
return sharedtypes.PolicyRuleImpliesLegacySSH(rule)
}
func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *ComponentPeer) []*FirewallRule {
return sharedtypes.ExpandPortsAndRanges(base, rule, peer)
// ExpandPortsAndRanges / AppendIPv6FirewallRule / GenerateRouteFirewallRules
// forward to the shared twin-typed helpers, converting the real types the
// legacy Account calc still uses to nmdata twins at this boundary.
func ExpandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
return sharedtypes.ExpandPortsAndRanges(base, twinRule(rule), twinPeer(peer))
}
func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *ComponentPeer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, peer, targetPeer, rule, rc)
func AppendIPv6FirewallRule(rules []*FirewallRule, rulesExists map[string]struct{}, peer, targetPeer *nbpeer.Peer, rule *PolicyRule, rc FirewallRuleContext) []*FirewallRule {
return sharedtypes.AppendIPv6FirewallRule(rules, rulesExists, twinPeer(peer), twinPeer(targetPeer), twinRule(rule), rc)
}
func CalculateNetworkMapFromComponents(ctx context.Context, components *NetworkMapComponents) *NetworkMap {
return sharedtypes.CalculateNetworkMapFromComponents(ctx, components)
}
func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*ComponentPeer, direction int, includeIPv6 bool) []*RouteFirewallRule {
return sharedtypes.GenerateRouteFirewallRules(ctx, route, rule, groupPeers, direction, includeIPv6)
func GenerateRouteFirewallRules(ctx context.Context, route *nbroute.Route, rule *PolicyRule, groupPeers []*nbpeer.Peer, direction int, includeIPv6 bool) []*RouteFirewallRule {
return sharedtypes.GenerateRouteFirewallRules(ctx, twinRoute(route), twinRule(rule), twinPeers(groupPeers), direction, includeIPv6)
}
func AllocateIPv6Subnet(r *rand.Rand) net.IPNet {

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
nbpeer "github.com/netbirdio/netbird/management/server/peer"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func TestNetworkMapComponents_IPv6EndToEnd(t *testing.T) {
@@ -105,7 +105,7 @@ func TestNetworkMapComponents_RemotePeerWithoutCapability(t *testing.T) {
require.NotNil(t, nm)
t.Run("AllowedIPs include remote v6", func(t *testing.T) {
var dst *types.ComponentPeer
var dst *nmdata.Peer
for _, p := range nm.Peers {
if p.ID == "peer-dst-1" {
dst = p

View File

@@ -18,6 +18,7 @@ import (
"github.com/netbirdio/netbird/management/server/posture"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/networkmap/nmdata"
)
func networkMapFromComponents(t *testing.T, account *types.Account, peerID string, validatedPeers map[string]struct{}) *types.NetworkMap {
@@ -49,7 +50,7 @@ func allPeersValidated(account *types.Account, excludePeerIDs ...string) map[str
return validated
}
func peerIDs(peers []*types.ComponentPeer) []string {
func peerIDs(peers []*nmdata.Peer) []string {
ids := make([]string, len(peers))
for i, p := range peers {
ids[i] = p.ID
@@ -625,7 +626,7 @@ func TestNetworkMapComponents_DomainNetworkResource(t *testing.T) {
var hasDomainRoute bool
for _, r := range nm.Routes {
if r.NetworkType == route.DomainNetwork && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
if r.NetworkType == int(route.DomainNetwork) && len(r.Domains) > 0 && r.Domains[0].SafeString() == "api.example.com" {
hasDomainRoute = true
}
}

View File

@@ -19,3 +19,34 @@ func Difference(a, b []string) []string {
func ToPtr[T any](value T) *T {
return &value
}
type comparableObject[T any] interface {
Equal(other T) bool
}
func MergeUnique[T comparableObject[T]](arr1, arr2 []T) []T {
var result []T
for _, item := range arr1 {
if !contains(result, item) {
result = append(result, item)
}
}
for _, item := range arr2 {
if !contains(result, item) {
result = append(result, item)
}
}
return result
}
func contains[T comparableObject[T]](slice []T, element T) bool {
for _, item := range slice {
if item.Equal(element) {
return true
}
}
return false
}

View File

@@ -1,4 +1,4 @@
package types
package util
import (
"testing"
@@ -17,7 +17,7 @@ func (t testObject) Equal(other testObject) bool {
func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
arr1 := []testObject{{value: 1}, {value: 2}}
arr2 := []testObject{{value: 2}, {value: 3}}
result := mergeUnique(arr1, arr2)
result := MergeUnique(arr1, arr2)
assert.Len(t, result, 3)
assert.Contains(t, result, testObject{value: 1})
assert.Contains(t, result, testObject{value: 2})
@@ -27,14 +27,14 @@ func Test_MergeUniqueArraysWithoutDuplicates(t *testing.T) {
func Test_MergeUniqueHandlesEmptyArrays(t *testing.T) {
arr1 := []testObject{}
arr2 := []testObject{}
result := mergeUnique(arr1, arr2)
result := MergeUnique(arr1, arr2)
assert.Empty(t, result)
}
func Test_MergeUniqueHandlesOneEmptyArray(t *testing.T) {
arr1 := []testObject{{value: 1}, {value: 2}}
arr2 := []testObject{}
result := mergeUnique(arr1, arr2)
result := MergeUnique(arr1, arr2)
assert.Len(t, result, 2)
assert.Contains(t, result, testObject{value: 1})
assert.Contains(t, result, testObject{value: 2})

View File

@@ -79,11 +79,6 @@ var (
geoDataDir string
crowdsecAPIURL string
crowdsecAPIKey string
appsecURL string
appsecTimeout time.Duration
appsecMaxBodyBytes int64
captureBudgetBytes int64
appsecMaxConcurrent int
)
var rootCmd = &cobra.Command{
@@ -130,11 +125,6 @@ func init() {
rootCmd.Flags().StringVar(&geoDataDir, "geo-data-dir", envStringOrDefault("NB_PROXY_GEO_DATA_DIR", "/var/lib/netbird/geolocation"), "Directory for the GeoLite2 MMDB file (auto-downloaded if missing)")
rootCmd.Flags().StringVar(&crowdsecAPIURL, "crowdsec-api-url", envStringOrDefault("NB_PROXY_CROWDSEC_API_URL", ""), "CrowdSec LAPI URL for IP reputation checks")
rootCmd.Flags().StringVar(&crowdsecAPIKey, "crowdsec-api-key", envStringOrDefault("NB_PROXY_CROWDSEC_API_KEY", ""), "CrowdSec bouncer API key")
rootCmd.Flags().StringVar(&appsecURL, "crowdsec-appsec-url", envStringOrDefault("NB_PROXY_CROWDSEC_APPSEC_URL", ""), "CrowdSec AppSec (WAF) endpoint for HTTP request inspection, e.g. http://127.0.0.1:7422/ (reuses the bouncer API key)")
rootCmd.Flags().DurationVar(&appsecTimeout, "crowdsec-appsec-timeout", envDurationOrDefault("NB_PROXY_CROWDSEC_APPSEC_TIMEOUT", 0), "Timeout for a single AppSec inspection call (0 = 200ms)")
rootCmd.Flags().IntVar(&appsecMaxConcurrent, "crowdsec-appsec-max-concurrent", int(envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_CONCURRENT", 0)), "Cap on AppSec inspections in flight; further requests are denied in enforce mode rather than queued (0 = 256, negative = no cap)")
rootCmd.Flags().Int64Var(&captureBudgetBytes, "capture-budget-bytes", envInt64OrDefault("NB_PROXY_CAPTURE_BUDGET_BYTES", 0), "Total in-flight request-body buffering across the proxy, shared by AppSec inspection and agent-network capture (0 = 256MiB)")
rootCmd.Flags().Int64Var(&appsecMaxBodyBytes, "crowdsec-appsec-max-body-bytes", envInt64OrDefault("NB_PROXY_CROWDSEC_APPSEC_MAX_BODY_BYTES", 0), "Cap on the request body mirrored to AppSec (0 = 64KiB, negative = headers and URI only)")
}
// Execute runs the root command.
@@ -228,59 +218,47 @@ func runServer(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := proxy.New(ctx, serverConfig(logger, proxyToken, parsedTrustedProxies, perf))
srv := proxy.New(ctx, proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: parsedTrustedProxies,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
})
return srv.ListenAndServe(ctx, addr)
}
// serverConfig maps the parsed flags and environment onto the proxy config.
// Kept separate from runServer so registering a new flag does not grow the
// startup path.
func serverConfig(logger *log.Logger, proxyToken string, trustedProxyList *trustedproxy.List, perf embed.Performance) proxy.Config {
return proxy.Config{
ListenAddr: addr,
Logger: logger,
Version: Version,
ManagementAddress: mgmtAddr,
ProxyURL: proxyDomain,
ProxyToken: proxyToken,
CertificateDirectory: certDir,
CertificateFile: certFile,
CertificateKeyFile: certKeyFile,
GenerateACMECertificates: acmeCerts,
ACMEChallengeAddress: acmeAddr,
ACMEDirectory: acmeDir,
ACMEEABKID: acmeEABKID,
ACMEEABHMACKey: acmeEABHMACKey,
ACMEChallengeType: acmeChallengeType,
DebugEndpointEnabled: debugEndpoint,
DebugEndpointAddress: debugEndpointAddr,
HealthAddr: healthAddr,
ForwardedProto: forwardedProto,
TrustedProxies: trustedProxyList,
CertLockMethod: nbacme.CertLockMethod(certLockMethod),
WildcardCertDir: wildcardCertDir,
WireguardPort: wgPort,
Performance: perf,
ProxyProtocol: proxyProtocol,
PreSharedKey: preSharedKey,
SupportsCustomPorts: supportsCustomPorts,
RequireSubdomain: requireSubdomain,
Private: private,
MaxDialTimeout: maxDialTimeout,
MaxSessionIdleTimeout: maxSessionIdleTimeout,
MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0),
GeoDataDir: geoDataDir,
CrowdSecAPIURL: crowdsecAPIURL,
CrowdSecAPIKey: crowdsecAPIKey,
CrowdSecAppSecURL: appsecURL,
CrowdSecAppSecTimeout: appsecTimeout,
CrowdSecAppSecMaxBodyBytes: appsecMaxBodyBytes,
CrowdSecAppSecMaxConcurrent: appsecMaxConcurrent,
MiddlewareCaptureBudgetBytes: captureBudgetBytes,
}
}
func envBoolOrDefault(key string, def bool) bool {
v, exists := os.LookupEnv(key)
if !exists {
@@ -315,19 +293,6 @@ func envUint16OrDefault(key string, def uint16) uint16 {
return uint16(parsed)
}
func envInt64OrDefault(key string, def int64) int64 {
v, exists := os.LookupEnv(key)
if !exists {
return def
}
parsed, err := strconv.ParseInt(v, 10, 64)
if err != nil {
log.Warnf("parse %s=%q: %v, using default %d", key, v, err, def)
return def
}
return parsed
}
func envDurationOrDefault(key string, def time.Duration) time.Duration {
v, exists := os.LookupEnv(key)
if !exists {

View File

@@ -221,21 +221,14 @@ func (l *Logger) allowDenyLog(serviceID types.ServiceID, reason string) bool {
// proxy/internal/middleware/keys.go — only the dimensions management needs to
// record a usage row (provider / model / tokens / cost / groups).
var usageMetadataKeys = map[string]struct{}{
"llm.provider": {},
"llm.model": {},
"llm.resolved_provider_id": {},
"llm.input_tokens": {},
"llm.output_tokens": {},
"llm.total_tokens": {},
"llm.cached_input_tokens": {},
"llm.cache_creation_tokens": {},
"cost.usd_input": {},
"cost.usd_cached_input": {},
"cost.usd_cache_creation": {},
"cost.usd_output": {},
"cost.usd_total": {},
"cost.usd_cache": {},
"llm.authorising_groups": {},
"llm.provider": {},
"llm.model": {},
"llm.resolved_provider_id": {},
"llm.input_tokens": {},
"llm.output_tokens": {},
"llm.total_tokens": {},
"cost.usd_total": {},
"llm.authorising_groups": {},
}
// stripAgentNetworkEntryForUsage returns the entry reduced to what's needed to

View File

@@ -1,163 +0,0 @@
package appsec
import (
"bytes"
"errors"
"io"
"mime"
"net/http"
"net/url"
"slices"
"strings"
)
// bufferBody reads up to limit+1 bytes from r.Body and always restores r.Body so
// the request stays forwardable. oversize reports that the body exceeded limit, in
// which case the returned prefix must not be used for inspection: the bytes are
// only read so they can be replayed to the backend.
func bufferBody(r *http.Request, limit int64) (body []byte, oversize bool, err error) {
original := r.Body
buf, readErr := io.ReadAll(io.LimitReader(original, limit+1))
if readErr != nil && !errors.Is(readErr, io.EOF) {
// Restore what was read so a downstream retry sees a consistent stream,
// then surface the failure.
r.Body = replay(buf, original)
return nil, false, readErr
}
if int64(len(buf)) > limit {
r.Body = replay(buf, original)
return nil, true, nil
}
// The whole body is buffered, so the original is drained and can be closed.
// A close error on a drained read-only body does not invalidate the bytes.
_ = original.Close()
r.Body = io.NopCloser(bytes.NewReader(buf))
// Framing is deliberately left as the client sent it. Rewriting a chunked
// request to a fixed Content-Length here would be invisible to the client
// but not to the rest of the chain: a later body capture with a smaller cap
// sees a known length over its cap and skips capture entirely, where an
// unknown length would have given it a truncated prefix. Inspecting a
// request must not change what any other layer gets to inspect.
return buf, false, nil
}
// replay returns a ReadCloser that yields the already-read prefix followed by
// the remainder of the original stream, and closes the original.
func replay(prefix []byte, rest io.ReadCloser) io.ReadCloser {
return struct {
io.Reader
io.Closer
}{
Reader: io.MultiReader(bytes.NewReader(prefix), rest),
Closer: rest,
}
}
// redactedPlaceholder replaces a credential value in the mirrored body. It is
// inert for rule matching, and its fixed length leaks nothing about the secret.
const redactedPlaceholder = "redacted"
// redactFormFields returns the body to mirror for a URL-encoded form, with the
// values of the named fields replaced. The proxy's own password / PIN login
// form posts to the service path itself, so without this the plaintext
// credential would reach the Security Engine.
//
// Only the credential values are removed, never the whole body: dropping the
// body outright would let a caller exempt any payload from inspection just by
// appending a field named "password". Everything else in the form stays
// inspectable, which is the point.
//
// Returns body unchanged when it is not a URL-encoded form or carries none of
// the fields.
//
// Substitution happens on the raw bytes rather than by re-encoding parsed
// values. Re-encoding would drop pairs that url.ParseQuery rejects, so a
// payload hidden in a malformed pair alongside a credential-named field would
// never be inspected while a tolerant backend parser still acted on it. Working
// byte-wise also avoids reordering keys and normalizing escapes, so the engine
// sees the same bytes the backend will.
//
// Field names match case-sensitively, on purpose: the caller passes the exact
// names the login handler reads via r.FormValue, and that lookup is itself
// case-sensitive. A "Password" field is therefore never a credential as far as
// the proxy is concerned, and redacting it would only blind the WAF to a value
// the proxy does not own.
func redactFormFields(contentType string, body []byte, fields []string) []byte {
if len(fields) == 0 || len(body) == 0 {
return body
}
media, _, err := mime.ParseMediaType(contentType)
if err != nil || media != "application/x-www-form-urlencoded" {
return body
}
return redactURLEncoded(body, fields)
}
// redactURLEncoded replaces the values of the named keys in a URL-encoded
// key/value sequence, the shared syntax of a query string and a form body.
func redactURLEncoded(raw []byte, fields []string) []byte {
// Split on "&" only, matching how Go's form parser delimits pairs.
segments := bytes.Split(raw, []byte("&"))
redacted := false
for i, segment := range segments {
rawKey, _, hasValue := bytes.Cut(segment, []byte("="))
if !hasValue {
continue
}
// Compare the decoded name, so an escaped spelling of the field
// ("pass%77ord") is redacted too: the reader decodes before looking it
// up. A key that fails to decode never reaches that reader either,
// since the parser drops the pair.
name, err := url.QueryUnescape(string(rawKey))
if err != nil || !slices.Contains(fields, name) {
continue
}
// Keep the key bytes as sent and replace only the value. Assigning a
// fresh slice leaves raw untouched, which matters: the caller restored
// the request body from the same buffer.
segments[i] = []byte(string(rawKey) + "=" + redactedPlaceholder)
redacted = true
}
if !redacted {
return raw
}
return bytes.Join(segments, []byte("&"))
}
// redactQuery replaces the values of the named query parameters in a raw query
// string, leaving every other byte as sent.
func redactQuery(rawQuery string, params []string) string {
if len(params) == 0 || rawQuery == "" {
return rawQuery
}
return string(redactURLEncoded([]byte(rawQuery), params))
}
// redactCookieHeader replaces the values of the named cookies in a Cookie
// header, keeping the others intact: cookies are a zone WAF rules match on, so
// dropping the whole header would cost real coverage.
func redactCookieHeader(value string, names []string) string {
if len(names) == 0 || value == "" {
return value
}
parts := strings.Split(value, ";")
redacted := false
for i, part := range parts {
name, _, hasValue := strings.Cut(part, "=")
if !hasValue {
continue
}
// Cookie names are case-sensitive and are not percent-decoded.
if !slices.Contains(names, strings.TrimSpace(name)) {
continue
}
parts[i] = name + "=" + redactedPlaceholder
redacted = true
}
if !redacted {
return value
}
return strings.Join(parts, ";")
}

View File

@@ -1,571 +0,0 @@
// Package appsec implements the CrowdSec AppSec (WAF) side of the remediation
// component protocol: each inspected HTTP request is mirrored to the Security
// Engine's AppSec endpoint, which replies with an allow / ban / captcha verdict
// for that request.
//
// This is a separate endpoint from the LAPI decision stream used by the
// crowdsec package: LAPI answers "is this IP known bad", AppSec answers "is
// this request an attack". The two are configured and enabled independently.
package appsec
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/proxy/internal/netutil"
"github.com/netbirdio/netbird/proxy/internal/restrict"
)
// Header names the AppSec component reads off the mirrored request. IP, URI and
// Verb are mandatory: the engine answers 500 when any of them is missing.
const (
headerAPIKey = "X-Crowdsec-Appsec-Api-Key" //nolint:gosec // G101: a header name, not a credential
headerIP = "X-Crowdsec-Appsec-Ip"
headerURI = "X-Crowdsec-Appsec-Uri"
headerVerb = "X-Crowdsec-Appsec-Verb"
headerHost = "X-Crowdsec-Appsec-Host"
headerUserAgent = "X-Crowdsec-Appsec-User-Agent"
headerHTTPVersion = "X-Crowdsec-Appsec-Http-Version"
headerTransactionID = "X-Crowdsec-Appsec-Transaction-Id"
)
// headerPrefix covers every protocol header. Any client-supplied header in this
// namespace is dropped before forwarding so a caller cannot influence the
// engine's view of its own address, or replay an API key.
const headerPrefix = "X-Crowdsec-Appsec-"
// Remediation actions the engine can return.
const (
actionAllow = "allow"
actionBan = "ban"
actionCaptcha = "captcha"
)
const (
// DefaultTimeout matches the 200ms budget CrowdSec's remediation component
// spec sets for the blocking AppSec call.
DefaultTimeout = 200 * time.Millisecond
// MinTimeout and MaxTimeout bound the configured inspection timeout.
// Inspection is synchronous, so the upper bound is what keeps a
// mis-set value from parking every request to an inspected service on a
// slow engine; the lower bound keeps the call from timing out before the
// engine can realistically answer. Mirrors the per-middleware bounds the
// proxy already applies to in-path calls.
MinTimeout = 10 * time.Millisecond
MaxTimeout = 5 * time.Second
// DefaultMaxBodyBytes caps the request body mirrored to the engine.
// Requests with a larger body are inspected on headers and URI only.
DefaultMaxBodyBytes int64 = 64 << 10
// DefaultMaxConcurrent bounds inspections in flight toward the engine. The
// point is to fail fast instead of parking a goroutine per request for the
// whole timeout once the engine is saturated: a slow engine otherwise turns
// a traffic burst into a pile of waiters that all time out anyway. Sized so
// a healthy engine (single-digit milliseconds per call) never reaches it.
DefaultMaxConcurrent = 256
// MaxConcurrentLimit is the ceiling for that bound.
MaxConcurrentLimit = 4096
// MaxBodyBytesLimit is the ceiling for that cap. A single request can hold
// this much in memory; the shared Budget is what bounds the total across
// concurrent requests. Matches the proxy-wide body-capture ceiling.
MaxBodyBytesLimit int64 = 8 << 20
// maxResponseBytes bounds how much of a verdict response is read. The
// engine answers with a two-field JSON object, so anything beyond this is
// not a response we can act on.
maxResponseBytes int64 = 4 << 10
)
// Reasons the request body was not mirrored. Reported so an access-log reader
// can distinguish "inspected and clean" from "never inspected", and so an
// oversize opt-out is visible rather than silent.
const (
BypassOversize = "oversize"
BypassUpgrade = "upgrade"
BypassDisabled = "disabled"
BypassBudget = "budget_exhausted"
)
// ErrUnavailable reports that the engine could not produce a verdict: the call
// failed, timed out, or the engine rejected it (401 bad key, 500 malformed).
// Distinguished from a block verdict so the caller can apply the per-service
// mode: enforce fails closed, observe allows.
var ErrUnavailable = errors.New("appsec engine unavailable")
// Config configures a Client.
type Config struct {
// URL is the AppSec endpoint, e.g. http://127.0.0.1:7422/.
URL string
// APIKey is the CrowdSec bouncer API key. The AppSec component validates it
// against LAPI, so the same key used for the decision stream works here.
APIKey string
// Timeout bounds a single inspection call. Zero means DefaultTimeout.
Timeout time.Duration
// MaxBodyBytes caps the mirrored request body. Zero means
// DefaultMaxBodyBytes; negative disables body forwarding entirely.
MaxBodyBytes int64
// MaxConcurrent bounds inspections in flight toward the engine. Zero means
// DefaultMaxConcurrent; negative disables the bound.
MaxConcurrent int
// Budget bounds the total body buffering in flight across all inspected
// requests. Nil disables that ceiling, which leaves the worst case at
// MaxBodyBytes times the concurrent request count; callers serving
// untrusted traffic should share the proxy-wide capture budget here.
Budget Budget
Logger *log.Entry
}
// Budget is the shared allowance for in-flight body buffering. Acquire reports
// whether n bytes could be reserved; every successful Acquire is matched by a
// Release of the same n. Satisfied by the proxy's capture budget, so AppSec and
// the middleware body tap draw down one pool rather than two independent ones.
type Budget interface {
Acquire(n int64) bool
Release(n int64)
}
// Client mirrors HTTP requests to a CrowdSec AppSec endpoint. It holds no
// per-service state and is safe for concurrent use.
type Client struct {
url string
apiKey string
maxBodyBytes int64
// sem bounds in-flight inspections. Nil when the bound is disabled.
sem chan struct{}
budget Budget
http *http.Client
logger *log.Entry
}
// New validates the config and returns a Client. The endpoint is not contacted
// here: the engine may come up after the proxy.
func New(cfg Config) (*Client, error) {
if cfg.URL == "" {
return nil, errors.New("appsec url is empty")
}
if cfg.APIKey == "" {
return nil, errors.New("appsec api key is empty")
}
parsed, err := url.Parse(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse appsec url: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("appsec url scheme %q is not http(s)", parsed.Scheme)
}
if parsed.Host == "" {
return nil, errors.New("appsec url has no host")
}
logger := cfg.Logger
if logger == nil {
logger = log.NewEntry(log.StandardLogger())
}
timeout := cfg.Timeout
switch {
case timeout <= 0:
timeout = DefaultTimeout
case timeout < MinTimeout:
logger.Warnf("appsec timeout %s is below the minimum, using %s", timeout, MinTimeout)
timeout = MinTimeout
case timeout > MaxTimeout:
logger.Warnf("appsec timeout %s exceeds the maximum, using %s", timeout, MaxTimeout)
timeout = MaxTimeout
}
// A negative cap is meaningful: forward no body at all.
maxBody := cfg.MaxBodyBytes
switch {
case maxBody == 0:
maxBody = DefaultMaxBodyBytes
case maxBody > MaxBodyBytesLimit:
logger.Warnf("appsec max body %d exceeds the maximum, using %d", maxBody, MaxBodyBytesLimit)
maxBody = MaxBodyBytesLimit
}
maxConcurrent := cfg.MaxConcurrent
switch {
case maxConcurrent == 0:
maxConcurrent = DefaultMaxConcurrent
case maxConcurrent > MaxConcurrentLimit:
logger.Warnf("appsec max concurrent %d exceeds the maximum, using %d", maxConcurrent, MaxConcurrentLimit)
maxConcurrent = MaxConcurrentLimit
}
var sem chan struct{}
if maxConcurrent > 0 {
sem = make(chan struct{}, maxConcurrent)
}
return &Client{
url: cfg.URL,
apiKey: cfg.APIKey,
maxBodyBytes: maxBody,
sem: sem,
budget: cfg.Budget,
logger: logger,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
},
},
}, nil
}
// Request is one inspection request.
type Request struct {
// HTTP is the in-flight client request. Inspect buffers and restores its
// body, so the request stays forwardable afterwards.
HTTP *http.Request
// ClientIP is the resolved client address (after trusted-proxy handling).
ClientIP netip.Addr
// TransactionID correlates the engine's alert with the proxy's access log
// entry. Empty lets the engine generate its own UUID.
TransactionID string
// RedactBodyFields lists form fields whose values are replaced before the
// body is mirrored. Used to keep credentials submitted to the proxy's own
// login form out of the engine while still inspecting the rest.
RedactBodyFields []string
// RedactHeaders, RedactCookies and RedactQueryParams name the credentials
// the proxy already withholds from backends: the header-auth values, its
// session cookie, and the OIDC session token. The engine logs and alerts on
// what it inspects, so mirroring them there would reintroduce the leak the
// upstream strippers exist to prevent. Only the values are replaced, so the
// surrounding headers, cookies and query stay inspectable.
RedactHeaders []string
RedactCookies []string
RedactQueryParams []string
}
// Result is the outcome of an inspection.
type Result struct {
Verdict restrict.Verdict
// BodyBypass names why the request body was not mirrored, empty when it
// was (or when the request had none). The engine still saw the headers and
// URI, so this is a coverage note, not a failure.
BodyBypass string
// Release returns the buffered body's budget reservation. Never nil, so it
// is always safe to defer. It must run only once the request has been
// served, not when Inspect returns: the buffer stays alive as r.Body for
// the backend to read, so releasing earlier would let the budget admit
// buffering that is still resident.
Release func()
}
// noopRelease is the Release for inspections that reserved no budget.
func noopRelease() {}
// Inspect mirrors r to the AppSec engine and returns its verdict. A nil error
// with restrict.Allow means the request passed. On failure it returns
// DenyAppSecUnavailable wrapped with ErrUnavailable; the caller decides whether
// that blocks, based on the per-service mode.
func (c *Client) Inspect(ctx context.Context, req Request) (Result, error) {
if c == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, ErrUnavailable
}
if req.HTTP == nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease}, fmt.Errorf("%w: nil request", ErrUnavailable)
}
// release is carried out to the caller rather than deferred here: the
// buffered body outlives this call as r.Body.
if !c.acquireSlot() {
// Deny rather than wave through: a flood must not be a way to switch
// inspection off. Enforce blocks, observe logs and allows, exactly as
// for an unreachable engine.
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: noopRelease},
fmt.Errorf("%w: %d inspections already in flight", ErrUnavailable, cap(c.sem))
}
defer c.releaseSlot()
body, bypass, release, err := c.readBody(req)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, Release: release}, fmt.Errorf("%w: read body: %w", ErrUnavailable, err)
}
outbound, err := c.buildRequest(ctx, req, body)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
resp, err := c.http.Do(outbound)
if err != nil {
return Result{Verdict: restrict.DenyAppSecUnavailable, BodyBypass: bypass, Release: release}, fmt.Errorf("%w: %w", ErrUnavailable, err)
}
defer func() {
// Drain before closing. net/http only returns a connection to the idle
// pool once its body is read to EOF; closing with bytes outstanding
// discards it. Every verdict carries a JSON body, so skipping this
// would cost a fresh handshake per inspected request, inside the
// timeout budget.
if _, err := io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes)); err != nil {
c.logger.Tracef("drain appsec response body: %v", err)
}
if err := resp.Body.Close(); err != nil {
c.logger.Tracef("close appsec response body: %v", err)
}
}()
verdict, err := c.verdict(resp)
return Result{Verdict: verdict, BodyBypass: bypass, Release: release}, err
}
// acquireSlot takes an in-flight slot without blocking, reporting false when
// the engine is already at capacity.
func (c *Client) acquireSlot() bool {
if c.sem == nil {
return true
}
select {
case c.sem <- struct{}{}:
return true
default:
return false
}
}
// releaseSlot returns the slot. Scoped to the engine call, not the request: the
// buffered body outlives the call but the engine's attention does not.
func (c *Client) releaseSlot() {
if c.sem == nil {
return
}
select {
case <-c.sem:
default:
}
}
// readBody buffers the body so it can be mirrored, always restoring it on the
// original request. Returns nil when there is no body to forward: no body at
// all, an upgrade request, or a body over the cap. A login form is forwarded
// with its credential values redacted rather than suppressed.
// release is never nil; the caller invokes it once the request has been served.
func (c *Client) readBody(req Request) (body []byte, bypass string, release func(), err error) {
r := req.HTTP
if r.Body == nil || r.Body == http.NoBody {
return nil, "", noopRelease, nil
}
if c.maxBodyBytes < 0 {
return nil, BypassDisabled, noopRelease, nil
}
// A genuine upgrade request carries no body to inspect (net/http hands us
// http.NoBody, caught above); the hijacked stream is reached through
// Hijacker, never r.Body. The test has to be the forwarder's own, because a
// looser one would skip inspection for requests the forwarder still
// delivers to the backend with their body intact.
if netutil.IsUpgradeRequest(r.Header) {
return nil, BypassUpgrade, noopRelease, nil
}
// A Content-Length over the cap is known to be too large before reading.
if r.ContentLength > c.maxBodyBytes {
return nil, BypassOversize, noopRelease, nil
}
// Reserve the whole cap rather than the eventual length: the reservation
// has to be made before the body is read, and until then the only bound
// known is the cap. Skipping inspection when the pool is drained keeps a
// burst of large bodies from being an out-of-memory lever; the bypass is
// recorded so the gap in coverage is visible.
release = noopRelease
if c.budget != nil {
if !c.budget.Acquire(c.maxBodyBytes) {
c.logger.Debugf("appsec buffer budget exhausted, inspecting headers and URI only")
return nil, BypassBudget, noopRelease, nil
}
var once sync.Once
release = func() { once.Do(func() { c.budget.Release(c.maxBodyBytes) }) }
}
buffered, oversize, err := bufferBody(r, c.maxBodyBytes)
if err != nil {
// bufferBody restored r.Body from the bytes it did read, so the
// reservation stays held until the caller releases it.
return nil, "", release, err
}
// An oversize body was only partially read: a truncated prefix changes the
// engine's verdict in both directions, so inspect headers and URI only.
if oversize {
return nil, BypassOversize, release, nil
}
return redactFormFields(r.Header.Get("Content-Type"), buffered, req.RedactBodyFields), "", release, nil
}
// buildRequest assembles the mirrored request. Per the protocol it is a GET
// when there is no body and a POST otherwise; bytes.Reader gives the outbound
// request an accurate Content-Length, which the engine relies on to read the
// body at all.
func (c *Client) buildRequest(ctx context.Context, req Request, body []byte) (*http.Request, error) {
method := http.MethodGet
var payload io.Reader
if len(body) > 0 {
method = http.MethodPost
payload = bytes.NewReader(body)
}
outbound, err := http.NewRequestWithContext(ctx, method, c.url, payload)
if err != nil {
return nil, fmt.Errorf("build appsec request: %w", err)
}
r := req.HTTP
copyInspectableHeaders(outbound.Header, r.Header)
redactSecrets(outbound.Header, req)
outbound.Header.Set(headerAPIKey, c.apiKey)
outbound.Header.Set(headerIP, req.ClientIP.Unmap().String())
outbound.Header.Set(headerURI, mirroredURI(r.URL, req.RedactQueryParams))
outbound.Header.Set(headerVerb, r.Method)
outbound.Header.Set(headerHost, r.Host)
if ua := r.UserAgent(); ua != "" {
outbound.Header.Set(headerUserAgent, ua)
}
outbound.Header.Set(headerHTTPVersion, httpVersion(r))
if req.TransactionID != "" {
outbound.Header.Set(headerTransactionID, req.TransactionID)
}
return outbound, nil
}
// verdict maps the engine's response to a restrict.Verdict. 200 is a pass and
// 401/500 are engine-side failures; every other status carries a remediation in
// the body. The blocked status code is operator-configurable
// (blocked_http_code), so the action field decides, not the status.
func (c *Client) verdict(resp *http.Response) (restrict.Verdict, error) {
switch resp.StatusCode {
case http.StatusUnauthorized:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: rejected api key", ErrUnavailable)
case http.StatusInternalServerError:
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: engine error", ErrUnavailable)
}
// Every status, 200 included, has to carry a decodable remediation. Taking a
// bare 200 as a pass would mean a URL pointing at anything that answers 200
// (a health endpoint, a load balancer's default page) silently allows every
// request while the service reports itself as enforcing.
var decoded struct {
Action string `json:"action"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, maxResponseBytes)).Decode(&decoded); err != nil {
// Every remediation carries a decodable action, so a response without
// one is not a verdict: most often the URL points at something that is
// not the AppSec endpoint, which answers 404 with HTML. Reported as
// unavailable rather than a ban so the access log names the real fault
// instead of sending an operator hunting for a rule that never fired.
// Enforce still blocks either way; only the recorded reason differs.
return restrict.DenyAppSecUnavailable, fmt.Errorf("%w: undecodable response (status %d): %w", ErrUnavailable, resp.StatusCode, err)
}
switch decoded.Action {
case actionAllow:
return restrict.Allow, nil
case actionCaptcha:
return restrict.DenyAppSecCaptcha, nil
case actionBan:
return restrict.DenyAppSecBan, nil
case "":
// Decodable JSON without a remediation is not a verdict either: the
// endpoint answered, but not as the engine. Same reasoning as an
// undecodable body, and the same reason to point at configuration.
return restrict.DenyAppSecUnavailable,
fmt.Errorf("%w: response carried no remediation (status %d)", ErrUnavailable, resp.StatusCode)
default:
// A remediation we do not implement still means the engine flagged the
// request, so deny.
c.logger.Debugf("unknown appsec action %q (status %d), treating as ban", decoded.Action, resp.StatusCode)
return restrict.DenyAppSecBan, nil
}
}
// copyInspectableHeaders copies the client's headers, which are what the WAF
// rules actually match on, dropping hop-by-hop headers that describe the
// proxy-to-engine connection rather than the client request, and any header in
// the AppSec protocol namespace.
func copyInspectableHeaders(dst, src http.Header) {
for name, values := range src {
if hopByHopHeaders[http.CanonicalHeaderKey(name)] {
continue
}
if strings.HasPrefix(http.CanonicalHeaderKey(name), headerPrefix) {
continue
}
dst[http.CanonicalHeaderKey(name)] = append([]string(nil), values...)
}
// Content-Length describes the mirrored payload, not the client's: net/http
// sets it from the body we actually attach. Content-Type is kept either way
// so rules matching on it still fire when the body was not forwarded.
dst.Del("Content-Length")
}
// redactSecrets replaces the credential values the proxy withholds from
// backends, so the mirrored copy does not carry them either.
func redactSecrets(dst http.Header, req Request) {
for _, name := range req.RedactHeaders {
// Presence, not Get: a header whose first value is empty still carries
// its later values to the engine, while the upstream strip deletes the
// name outright. Set collapses every value into the placeholder.
if len(dst.Values(name)) > 0 {
dst.Set(name, redactedPlaceholder)
}
}
// Every Cookie line, not just the first: a client may send several, and Get
// would leave the session cookie in any later one mirrored in the clear.
if cookies := dst.Values("Cookie"); len(cookies) > 0 {
redacted := make([]string, len(cookies))
for i, cookie := range cookies {
redacted[i] = redactCookieHeader(cookie, req.RedactCookies)
}
dst["Cookie"] = redacted
}
}
// mirroredURI renders the request target for the URI header, with the named
// query parameter values replaced.
func mirroredURI(u *url.URL, redactParams []string) string {
uri := u.RequestURI()
if u.RawQuery == "" || len(redactParams) == 0 {
return uri
}
redacted := redactQuery(u.RawQuery, redactParams)
if redacted == u.RawQuery {
return uri
}
// RequestURI is path + "?" + RawQuery; swap only the query part so the
// path keeps its original encoding.
return strings.TrimSuffix(uri, u.RawQuery) + redacted
}
var hopByHopHeaders = map[string]bool{
"Connection": true,
"Keep-Alive": true,
"Proxy-Authenticate": true,
"Proxy-Authorization": true,
"Proxy-Connection": true,
"Te": true,
"Trailer": true,
"Transfer-Encoding": true,
"Upgrade": true,
}
// httpVersion renders the two-digit form the engine parses ("11", "20").
func httpVersion(r *http.Request) string {
major, minor := r.ProtoMajor, r.ProtoMinor
if major < 0 || major > 9 || minor < 0 || minor > 9 {
return ""
}
return fmt.Sprintf("%d%d", major, minor)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,306 +0,0 @@
package auth
import (
"crypto/ed25519"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
)
// appsecEngine is a stub AppSec component returning a fixed remediation.
func appsecEngine(t *testing.T, status int, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
if body != "" {
_, _ = w.Write([]byte(body))
}
}))
t.Cleanup(srv.Close)
return srv
}
// serveWithAppSec runs a request through the middleware for a domain configured
// with the given AppSec mode, returning the response and the captured metadata.
func serveWithAppSec(t *testing.T, mode restrict.AppSecMode, client *appsec.Client, r *http.Request) (*httptest.ResponseRecorder, map[string]string, bool) {
t.Helper()
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: mode,
}))
reached := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
}))
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, r)
return rec, cd.GetMetadata(), reached
}
func appsecRequest() *http.Request {
r := httptest.NewRequest(http.MethodGet, "http://svc.example.com/?x=/etc/passwd", nil)
r.Host = "svc.example.com"
r.RemoteAddr = "203.0.113.7:44444"
return r
}
func TestCheckAppSec_EnforceBlocksBannedRequest(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.NotContains(t, meta, "appsec_mode", "enforce is the default, only observe is annotated")
}
func TestCheckAppSec_ObserveAllowsAndRecordsVerdict(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban","http_status":403}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached, "observe mode must not block")
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_AllowedRequestPasses(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow","http_status":200}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.NotContains(t, meta, "appsec_verdict", "a clean request records no verdict")
}
func TestCheckAppSec_OffSkipsInspection(t *testing.T) {
// An engine that would ban everything; the mode must keep us away from it.
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecOff, client, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Empty(t, meta)
}
func TestCheckAppSec_EnforceFailsClosedWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, nil, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code,
"enforce with no configured endpoint must deny rather than pass traffic uninspected")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_ObserveAllowsWithoutClient(t *testing.T) {
rec, meta, reached := serveWithAppSec(t, restrict.AppSecObserve, nil, appsecRequest())
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
assert.Equal(t, "observe", meta["appsec_mode"])
}
func TestCheckAppSec_EnforceFailsClosedWhenEngineUnreachable(t *testing.T) {
client, err := appsec.New(appsec.Config{URL: "http://127.0.0.1:1/", APIKey: "k"})
require.NoError(t, err)
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, appsecRequest())
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
func TestCheckAppSec_InspectsOverlayTraffic(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
// Requests arriving over the WireGuard overlay skip the geo and IP-reputation
// checks, but request content is just as inspectable.
r := appsecRequest()
r = r.WithContext(types.WithOverlayOrigin(r.Context()))
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code, "overlay traffic must still be inspected")
assert.False(t, reached)
assert.Equal(t, "appsec_ban", meta["appsec_verdict"])
}
func TestCheckAppSec_UnresolvableClientIPFailsClosed(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
client, err := appsec.New(appsec.Config{URL: srv.URL, APIKey: "k"})
require.NoError(t, err)
r := appsecRequest()
r.RemoteAddr = "not-an-address"
rec, meta, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.Equal(t, http.StatusForbidden, rec.Code,
"the engine requires a client address; a request we cannot attribute must not pass")
assert.False(t, reached)
assert.Equal(t, "appsec_unavailable", meta["appsec_verdict"])
}
// The redaction sets are resolved from the domain's schemes at registration, so
// what AppSec withholds cannot drift from what those schemes actually accept.
func TestAddDomain_ResolvesRedactionSetsFromSchemes(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
Schemes: []Scheme{
NewPassword(nil, "svc-1", "acct-1"),
NewHeader(nil, "svc-1", "acct-1", "X-Api-Key"),
},
SessionPublicKey: base64.StdEncoding.EncodeToString(make([]byte, ed25519.PublicKeySize)),
SessionExpiration: time.Hour,
AppSecMode: restrict.AppSecEnforce,
}))
mw.domainsMux.RLock()
config := mw.domains["svc.example.com"]
mw.domainsMux.RUnlock()
assert.Equal(t, []string{"password"}, config.redactBodyFields)
assert.Equal(t, []string{"X-Api-Key"}, config.redactHeaders)
// r.FormValue merges the query into the form, so a credential passed there
// authenticates and must be redacted alongside the OIDC session token.
assert.Equal(t, []string{"session_token", "password"}, config.redactQueryParams)
}
// countingBudget records reservations so a test can observe when the
// middleware hands them back.
type countingBudget struct {
mu sync.Mutex
total int64
used int64
maxAtOnce int64
}
func (b *countingBudget) Acquire(n int64) bool {
b.mu.Lock()
defer b.mu.Unlock()
if b.used+n > b.total {
return false
}
b.used += n
if b.used > b.maxAtOnce {
b.maxAtOnce = b.used
}
return true
}
func (b *countingBudget) Release(n int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.used -= n
}
func (b *countingBudget) inUse() int64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.used
}
// The buffered body stays alive as r.Body until the backend has read it, so
// Protect must hold the reservation for the whole request and return it only
// once the handler chain has unwound. Releasing inside Inspect would let the
// budget admit buffering that is still resident.
func TestProtect_AppSecBudgetHeldForRequestAndReleasedAfter(t *testing.T) {
srv := appsecEngine(t, http.StatusOK, `{"action":"allow"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
mw := NewMiddleware(log.StandardLogger(), nil, nil)
mw.SetAppSec(client)
require.NoError(t, mw.AddDomain("svc.example.com", DomainSettings{
AccountID: types.AccountID("acct-1"),
ServiceID: types.ServiceID("svc-1"),
AppSecMode: restrict.AppSecEnforce,
}))
var inHandler int64
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The backend reads the buffered body here, so the reservation must
// still be held at this point.
inHandler = budget.inUse()
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
cd := proxy.NewCapturedData("req-1")
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
handler.ServeHTTP(httptest.NewRecorder(), r)
assert.Equal(t, int64(4096), inHandler, "the reservation must be held while the backend reads the body")
assert.Equal(t, int64(0), budget.inUse(), "Protect must release the reservation once the request is served")
}
// A denied request never reaches the backend, but Protect still has to hand the
// reservation back or the pool leaks one cap per blocked request.
func TestProtect_AppSecBudgetReleasedOnDeny(t *testing.T) {
srv := appsecEngine(t, http.StatusForbidden, `{"action":"ban"}`)
budget := &countingBudget{total: 1 << 20}
client, err := appsec.New(appsec.Config{
URL: srv.URL,
APIKey: "k",
MaxBodyBytes: 4096,
Budget: budget,
})
require.NoError(t, err)
r := httptest.NewRequest(http.MethodPost, "http://svc.example.com/", strings.NewReader("payload"))
r.Host = "svc.example.com"
rec, _, reached := serveWithAppSec(t, restrict.AppSecEnforce, client, r)
assert.False(t, reached, "a banned request must not reach the backend")
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.Equal(t, int64(0), budget.inUse(), "a blocked request must not leak its reservation")
}

View File

@@ -39,11 +39,6 @@ func (Header) Type() auth.Method {
return auth.MethodHeader
}
// HeaderName returns the request header this scheme reads its credential from.
func (h Header) HeaderName() string {
return h.headerName
}
// Authenticate checks for the configured header in the request. If absent,
// returns empty (unauthenticated). If present, validates via gRPC.
func (h Header) Authenticate(r *http.Request) (string, string, error) {

View File

@@ -18,7 +18,6 @@ import (
"google.golang.org/grpc"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
"github.com/netbirdio/netbird/proxy/internal/types"
@@ -26,11 +25,6 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionCookieNames is the cookie set AppSec redacts before mirroring: the
// proxy's session cookie is a bearer credential for the service, and the
// reverse proxy already strips it before forwarding upstream.
var sessionCookieNames = []string{auth.SessionCookieName}
// errValidationUnavailable indicates that session validation failed due to
// an infrastructure error (e.g. gRPC unavailable), not an invalid token.
var errValidationUnavailable = errors.New("session validation unavailable")
@@ -65,14 +59,6 @@ type DomainConfig struct {
IPRestrictions *restrict.Filter
// Private routes the domain through ValidateTunnelPeer; failure → 403.
Private bool
// AppSecMode enables CrowdSec AppSec request inspection for this domain.
AppSecMode restrict.AppSecMode
// redact* name the credentials this domain's schemes accept, resolved once
// at registration. AppSec replaces their values before mirroring a request,
// matching what the reverse proxy strips before forwarding upstream.
redactBodyFields []string
redactHeaders []string
redactQueryParams []string
}
type validationResult struct {
@@ -96,9 +82,6 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
// appsec is the shared CrowdSec AppSec client, nil when the proxy has no
// AppSec endpoint configured. Set once during startup, before serving.
appsec *appsec.Client
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -116,12 +99,6 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
}
}
// SetAppSec installs the shared CrowdSec AppSec client. Must be called during
// startup, before the middleware serves any request.
func (mw *Middleware) SetAppSec(client *appsec.Client) {
mw.appsec = client
}
// Protect wraps next with per-domain authentication and IP restriction checks.
// Requests whose Host is not registered pass through unchanged.
func (mw *Middleware) Protect(next http.Handler) http.Handler {
@@ -146,14 +123,6 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
// Deferred, not released here: the inspected body stays alive as r.Body
// until the backend has read it, which happens inside next.ServeHTTP.
appSecAllowed, releaseAppSec := mw.checkAppSec(w, r, config)
defer releaseAppSec()
if !appSecAllowed {
return
}
// Private services bypass operator schemes and gate on tunnel peer.
if config.Private {
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
@@ -293,134 +262,6 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
return false
}
// checkAppSec mirrors the request to the CrowdSec AppSec engine when the domain
// enables inspection. Returns false when the request was blocked and a response
// has been written.
//
// The returned release frees the body-buffering budget the inspection reserved
// and is never nil. It must run only after the request has been served, since
// the buffered body stays alive as r.Body for the backend to read.
//
// Every non-allow remediation blocks with 403, captcha included: the proxy has
// no challenge flow to serve. The distinct verdict is still recorded so the
// access log shows which remediation the engine actually chose.
//
// Unlike the geo and IP-reputation checks, this runs for overlay traffic too:
// AppSec inspects request content, which is just as meaningful when the caller
// reached the proxy through the WireGuard tunnel.
func (mw *Middleware) checkAppSec(w http.ResponseWriter, r *http.Request, config DomainConfig) (bool, func()) {
if !config.AppSecMode.Enabled() {
return true, func() {}
}
verdict, release := mw.inspectAppSec(r, config)
if verdict == restrict.Allow {
return true, release
}
observe := config.AppSecMode == restrict.AppSecObserve
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_verdict", verdict.String())
if observe {
cd.SetMetadata("appsec_mode", "observe")
}
}
if observe {
mw.logger.Debugf("AppSec observe: would block %s for %s (%s)", r.RemoteAddr, r.Host, verdict)
return true, release
}
mw.markDenied(r, verdict.String())
mw.logger.Debugf("AppSec: %s for %s %s", verdict, r.Host, r.RemoteAddr)
http.Error(w, "Forbidden", http.StatusForbidden)
return false, release
}
// inspectAppSec runs the AppSec call and returns its verdict. Failures come
// back as DenyAppSecUnavailable regardless of mode so observe mode still
// records that inspection did not happen; the caller decides what blocks. The
// returned release is never nil.
func (mw *Middleware) inspectAppSec(r *http.Request, config DomainConfig) (restrict.Verdict, func()) {
// Mode requested but the proxy has no AppSec endpoint configured. Management
// gates this on the cluster capability; a stale mapping can still arrive.
if mw.appsec == nil {
mw.logger.Debugf("AppSec mode %q requested for %s but no AppSec endpoint is configured", config.AppSecMode, r.Host)
return restrict.DenyAppSecUnavailable, func() {}
}
clientIP := mw.resolveClientIP(r)
if !clientIP.IsValid() {
// The engine requires a client address, and a request whose source we
// cannot establish is exactly the kind we must not wave through.
mw.logger.Debugf("AppSec: cannot resolve client address for %q", r.RemoteAddr)
return restrict.DenyAppSecUnavailable, func() {}
}
req := appsec.Request{
HTTP: r,
ClientIP: clientIP,
RedactBodyFields: config.redactBodyFields,
RedactHeaders: config.redactHeaders,
RedactCookies: sessionCookieNames,
RedactQueryParams: config.redactQueryParams,
}
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
req.TransactionID = cd.GetRequestID()
}
result, err := mw.appsec.Inspect(r.Context(), req)
if err != nil {
mw.logger.Debugf("AppSec inspection failed for %s: %v", r.Host, err)
}
// Record when the body went uninspected: headers and URI were still
// checked, but an operator reading the log should not read a clean verdict
// as "the payload was examined". Oversize is reachable by padding, so its
// absence from the log would hide a deliberate opt-out.
if result.BodyBypass != "" {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("appsec_body_bypass", result.BodyBypass)
}
}
return result.Verdict, result.Release
}
// credentialFormFields lists the login form fields whose values are redacted
// from the mirrored body, so a password or PIN submitted to the proxy's own
// login form never reaches the Security Engine.
func credentialFormFields(schemes []Scheme) []string {
var fields []string
for _, s := range schemes {
switch s.Type() {
case auth.MethodPassword:
fields = append(fields, passwordFormId)
case auth.MethodPIN:
fields = append(fields, pinFormId)
}
}
return fields
}
// credentialHeaders lists the request headers whose values are redacted from
// the mirrored request. A header-auth scheme carries a session token the proxy
// validates and never forwards upstream, so the engine must not see it either.
func credentialHeaders(schemes []Scheme) []string {
var names []string
for _, s := range schemes {
// Structural, not a concrete Header assertion: if the scheme is ever
// registered as a pointer, a type assertion would quietly stop matching
// and the header would start reaching the engine again.
named, ok := s.(interface{ HeaderName() string })
if !ok {
continue
}
if name := named.HeaderName(); name != "" {
names = append(names, name)
}
}
return names
}
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
@@ -440,18 +281,12 @@ func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
return addr.Unmap()
}
// markDenied records the deny reason on the captured data so the access log
// attributes the response to the proxy rather than the backend.
func (mw *Middleware) markDenied(r *http.Request, reason string) {
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(reason)
}
}
// blockIPRestriction sets captured data fields for an IP-restriction block event.
func (mw *Middleware) blockIPRestriction(r *http.Request, reason string) {
mw.markDenied(r, reason)
mw.logger.Debugf("IP restriction: %s for %s", reason, r.RemoteAddr)
}
@@ -802,61 +637,45 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
case auth.MethodPassword:
return r.FormValue("password") != ""
case auth.MethodOIDC:
return r.URL.Query().Get(sessionTokenParam) != ""
return r.URL.Query().Get("session_token") != ""
}
return false
}
// DomainSettings is the per-domain configuration AddDomain applies.
type DomainSettings struct {
Schemes []Scheme
// SessionPublicKey is the base64-encoded ed25519 key used to verify session
// cookies. Required when Schemes is non-empty.
SessionPublicKey string
SessionExpiration time.Duration
AccountID types.AccountID
ServiceID types.ServiceID
IPRestrictions *restrict.Filter
// Private forces ValidateTunnelPeer enforcement (403 on failure) regardless
// of the schemes list.
Private bool
AppSecMode restrict.AppSecMode
}
// AddDomain registers authentication schemes for the given domain. With schemes
// a valid session public key is required.
func (mw *Middleware) AddDomain(domain string, settings DomainSettings) error {
credentialFields := credentialFormFields(settings.Schemes)
config := DomainConfig{
AccountID: settings.AccountID,
ServiceID: settings.ServiceID,
IPRestrictions: settings.IPRestrictions,
Private: settings.Private,
AppSecMode: settings.AppSecMode,
redactBodyFields: credentialFields,
redactHeaders: credentialHeaders(settings.Schemes),
// A credential can arrive in the query too: r.FormValue merges the URL
// query into the form, so "?password=..." authenticates just as a form
// post does and must not be mirrored in the clear either.
redactQueryParams: append([]string{sessionTokenParam}, credentialFields...),
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
return nil
}
if len(settings.Schemes) > 0 {
pubKeyBytes, err := base64.StdEncoding.DecodeString(settings.SessionPublicKey)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
config.Schemes = settings.Schemes
config.SessionPublicKey = pubKeyBytes
config.SessionExpiration = settings.SessionExpiration
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = config
mw.domains[domain] = DomainConfig{
Schemes: schemes,
SessionPublicKey: pubKeyBytes,
SessionExpiration: expiration,
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
}
return nil
}
@@ -911,10 +730,10 @@ func (mw *Middleware) validateSessionToken(ctx context.Context, host, token stri
// parameter removed so it doesn't linger in the browser's address bar or history.
func stripSessionTokenParam(u *url.URL) string {
q := u.Query()
if !q.Has(sessionTokenParam) {
if !q.Has("session_token") {
return u.RequestURI()
}
q.Del(sessionTokenParam)
q.Del("session_token")
clean := *u
clean.RawQuery = q.Encode()
return clean.RequestURI()

View File

@@ -64,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
require.NoError(t, err)
mw.domainsMux.RLock()
@@ -81,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -95,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "not-valid-base64!!!", SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "decode session public key")
@@ -110,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: shortKey, SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid session public key size")
@@ -123,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
require.NoError(t, err, "domains with no auth schemes should not require a key")
mw.domainsMux.RLock()
@@ -139,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp2.PublicKey, SessionExpiration: 2 * time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
mw.domainsMux.RLock()
config := mw.domains["example.com"]
@@ -156,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
mw.RemoveDomain("example.com")
@@ -180,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain("example.com", DomainSettings{SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -197,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -218,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -239,7 +239,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
@@ -272,7 +272,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
groups := []string{"engineering", "sre"}
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
@@ -337,7 +337,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
kp := generateTestKeyPair(t)
// Private service: no operator schemes — auth gates solely on the tunnel peer.
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
@@ -377,7 +377,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
}}
mw := NewMiddleware(log.StandardLogger(), validator, nil)
kp := generateTestKeyPair(t)
require.NoError(t, mw.AddDomain("agent.example.com", DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
cd := proxy.NewCapturedData("")
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
@@ -405,7 +405,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Sign a token that expired 1 second ago.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
@@ -431,7 +431,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Token signed for a different domain audience.
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -458,7 +458,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
kp2 := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp1.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
// Token signed with a different private key.
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
@@ -495,7 +495,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -548,7 +548,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -584,7 +584,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{pinScheme, passwordScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
var backendCalled bool
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -614,7 +614,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
return "invalid-jwt-token", "", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -638,7 +638,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
key := base64.StdEncoding.EncodeToString(randomBytes)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
err = mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: key, SessionExpiration: time.Hour})
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
}
@@ -647,10 +647,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
// Attempt to overwrite with an invalid key.
err := mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: "bad", SessionExpiration: time.Hour})
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
require.Error(t, err)
// The original valid config should still be intact.
@@ -674,7 +674,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -701,7 +701,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
return "", "password", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -728,7 +728,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -815,7 +815,8 @@ func TestWasCredentialSubmitted(t *testing.T) {
func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -850,7 +851,8 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
// trusted proxies), checkIPRestrictions should use that IP, not RemoteAddr.
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -890,7 +892,8 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
// Geo is nil, country restrictions are configured: must deny (fail-close).
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -913,10 +916,11 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{
AllowedCIDRs: []string{"100.64.0.0/10"},
AllowedCountries: []string{"US"},
}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -949,7 +953,8 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
err := mw.AddDomain("example.com", DomainSettings{AccountID: "acc1", ServiceID: "svc1", IPRestrictions: restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})})
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
require.NoError(t, err)
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -977,7 +982,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
return "", oidcURL, nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1006,7 +1011,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{oidcScheme, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1050,7 +1055,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
capturedData := proxy.NewCapturedData("")
@@ -1093,7 +1098,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
// Also add a PIN scheme so we can verify fallthrough behavior.
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr, pinScheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1113,7 +1118,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
return &proto.AuthenticateResponse{Success: false}, nil
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
capturedData := proxy.NewCapturedData("")
handler := mw.Protect(newPassthroughHandler())
@@ -1136,7 +1141,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
return nil, errors.New("gRPC unavailable")
}}
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1153,7 +1158,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
kp := generateTestKeyPair(t)
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -1213,7 +1218,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
// Single Header scheme (as if one entry existed), but the mock checks both values.
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{hdr}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acc1", ServiceID: "svc1"}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
var backendCalled bool
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -1271,7 +1276,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1295,7 +1300,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
return "", "https://idp.example.com/authorize", nil
},
}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1315,7 +1320,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1345,7 +1350,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())
@@ -1380,7 +1385,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
kp := generateTestKeyPair(t)
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", DomainSettings{Schemes: []Scheme{scheme}, SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour}))
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
handler := mw.Protect(newPassthroughHandler())

View File

@@ -13,10 +13,6 @@ import (
"github.com/netbirdio/netbird/shared/management/proto"
)
// sessionTokenParam is the query parameter the management server uses to hand
// the minted session token back to the proxy after an OIDC login.
const sessionTokenParam = "session_token"
type urlGenerator interface {
GetOIDCURL(context.Context, *proto.GetOIDCURLRequest, ...grpc.CallOption) (*proto.GetOIDCURLResponse, error)
}
@@ -47,7 +43,7 @@ func (o OIDC) Authenticate(r *http.Request) (string, string, error) {
// Check for the session_token query param (from OIDC redirects).
// The management server passes the token in the URL because it cannot set
// cookies for the proxy's domain (cookies are domain-scoped per RFC 6265).
if token := r.URL.Query().Get(sessionTokenParam); token != "" {
if token := r.URL.Query().Get("session_token"); token != "" {
return token, "", nil
}

View File

@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
t.Helper()
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc.example", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1"}))
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
return mw
}
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("svc-a.example", DomainSettings{AccountID: "acct-a", ServiceID: "svc-a"}))
require.NoError(t, mw.AddDomain("svc-b.example", DomainSettings{AccountID: "acct-b", ServiceID: "svc-b"}))
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
// The fast-path requires the inbound-listener marker on the context.
// The peerstore lookup itself is account-agnostic at this level
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
mw := NewMiddleware(log.New(), nil, nil)
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
},
}
mw := NewMiddleware(log.New(), validator, nil)
require.NoError(t, mw.AddDomain("private.svc", DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", Private: true}))
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
called := false
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {

View File

@@ -56,12 +56,10 @@ type bedrockResponse struct {
OutputTokens int64 `json:"output_tokens"`
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
// Converse — camelCase; cache buckets are additive to inputTokens (AWS names the write bucket cacheWriteInputTokens).
InputTokensCamel int64 `json:"inputTokens"`
OutputTokensCamel int64 `json:"outputTokens"`
TotalTokensCamel int64 `json:"totalTokens"`
CacheReadTokensCamel int64 `json:"cacheReadInputTokens"`
CacheWriteTokensCamel int64 `json:"cacheWriteInputTokens"`
// Converse — camelCase.
InputTokensCamel int64 `json:"inputTokens"`
OutputTokensCamel int64 `json:"outputTokens"`
TotalTokensCamel int64 `json:"totalTokens"`
} `json:"usage"`
}
@@ -85,18 +83,16 @@ func (BedrockParser) ParseResponse(status int, contentType string, body []byte)
}
inTok := firstNonZero(resp.Usage.InputTokens, resp.Usage.InputTokensCamel)
outTok := firstNonZero(resp.Usage.OutputTokens, resp.Usage.OutputTokensCamel)
cacheRead := firstNonZero(resp.Usage.CacheReadInputTokens, resp.Usage.CacheReadTokensCamel)
cacheWrite := firstNonZero(resp.Usage.CacheCreationInputTokens, resp.Usage.CacheWriteTokensCamel)
total := resp.Usage.TotalTokensCamel
if total == 0 {
total = inTok + outTok + cacheRead + cacheWrite
total = inTok + outTok + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens
}
return Usage{
InputTokens: inTok,
OutputTokens: outTok,
TotalTokens: total,
CachedInputTokens: cacheRead,
CacheCreationTokens: cacheWrite,
CachedInputTokens: resp.Usage.CacheReadInputTokens,
CacheCreationTokens: resp.Usage.CacheCreationInputTokens,
}, nil
}

View File

@@ -26,18 +26,6 @@ func TestBedrockParser_ParseResponse_Converse(t *testing.T) {
require.Equal(t, int64(14), u.TotalTokens, "converse uses provider total")
}
// Converse camelCase cache fields must land in the billed Usage buckets, same as the InvokeModel snake_case fields.
func TestBedrockParser_ParseResponse_ConverseCacheBuckets(t *testing.T) {
body := []byte(`{"usage":{"inputTokens":11,"outputTokens":3,"cacheReadInputTokens":7,"cacheWriteInputTokens":9}}`)
u, err := BedrockParser{}.ParseResponse(200, "application/json", body)
require.NoError(t, err)
require.Equal(t, int64(11), u.InputTokens, "converse input tokens")
require.Equal(t, int64(3), u.OutputTokens, "converse output tokens")
require.Equal(t, int64(7), u.CachedInputTokens, "converse cache-read tokens")
require.Equal(t, int64(9), u.CacheCreationTokens, "converse cache-write tokens")
require.Equal(t, int64(11+3+7+9), u.TotalTokens, "total backfill is additive when the provider omits totalTokens")
}
func TestBedrockParser_ParseResponse_StreamingUnsupported(t *testing.T) {
_, err := BedrockParser{}.ParseResponse(200, "application/vnd.amazon.eventstream", []byte("binary"))
require.ErrorIs(t, err, ErrStreamingUnsupported, "event-stream must route to the streaming accumulator")

View File

@@ -128,46 +128,6 @@ type Table struct {
// - Other providers: cached and cacheCreation are ignored; cost is
// inTokens*InputPer1K + outTokens*OutputPer1K.
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) {
c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation)
return c.TotalUSD, ok
}
// Costs is a per-request cost split. The four per-bucket fields are the base
// of the breakdown — one per token bucket the provider bills separately — and
// the two aggregates are derived from them:
//
// TotalUSD = InputUSD + CachedInputUSD + CacheCreationUSD + OutputUSD
// CacheUSD = CachedInputUSD + CacheCreationUSD
//
// InputUSD is always the cost of the *non-cached* input bucket, for both
// provider shapes: on OpenAI the cached subset is carved out of inTokens and
// billed as CachedInputUSD, so the two never double-count. Buckets a provider
// doesn't bill are zero, which keeps the identities above true everywhere.
type Costs struct {
InputUSD float64
CachedInputUSD float64
CacheCreationUSD float64
OutputUSD float64
TotalUSD float64
CacheUSD float64
}
// newCosts assembles a split from its per-bucket parts, deriving the two
// aggregates so TotalUSD and CacheUSD can never drift from the breakdown.
func newCosts(input, cachedInput, cacheCreation, output float64) Costs {
return Costs{
InputUSD: input,
CachedInputUSD: cachedInput,
CacheCreationUSD: cacheCreation,
OutputUSD: output,
TotalUSD: input + cachedInput + cacheCreation + output,
CacheUSD: cachedInput + cacheCreation,
}
}
// Costs returns the estimated USD cost split for the given token counts, with
// the same semantics as Cost.
func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) {
// Clamp negatives to zero before any pricing math so a malformed
// upstream count can never produce a negative cost.
if inTokens < 0 {
@@ -183,15 +143,15 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput,
cacheCreation = 0
}
if t == nil {
return Costs{}, false
return 0, false
}
byModel, ok := t.entries[provider]
if !ok {
return Costs{}, false
return 0, false
}
entry, ok := byModel[model]
if !ok {
return Costs{}, false
return 0, false
}
output := (float64(outTokens) / 1000.0) * entry.OutputPer1K
switch provider {
@@ -208,7 +168,7 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput,
}
nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K
cached := float64(clamped) / 1000.0 * cachedRate
return newCosts(nonCached, cached, 0, output), true
return nonCached + cached + output, true
case "anthropic", "bedrock":
// Bedrock-Anthropic returns the same additive cache buckets as
// first-party Anthropic; non-Anthropic Bedrock models simply report
@@ -224,10 +184,10 @@ func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput,
input := float64(inTokens) / 1000.0 * entry.InputPer1K
read := float64(cachedInput) / 1000.0 * readRate
create := float64(cacheCreation) / 1000.0 * createRate
return newCosts(input, read, create, output), true
return input + read + create + output, true
default:
input := float64(inTokens) / 1000.0 * entry.InputPer1K
return newCosts(input, 0, 0, output), true
return input + output, true
}
}

View File

@@ -21,8 +21,6 @@ import (
"strconv"
"strings"
"sync"
"github.com/netbirdio/netbird/proxy/internal/netutil"
)
// MaxRoutingScanBytes bounds how far ScanRoutingFields will read into a
@@ -36,6 +34,7 @@ const MaxRoutingScanBytes int64 = 32 << 20
// metadata key by the chain when a request body is not surfaced.
const (
BypassUpgradeHeader = "upgrade_header"
BypassConnectionUpgrd = "connection_upgrade"
BypassContentType = "content_type_not_allowed"
BypassBudget = "capture_budget_exhausted"
BypassNoConfig = "no_capture_config"
@@ -126,13 +125,12 @@ func CaptureRequest(r *http.Request, cfg *Config, b Budget) (body []byte, trunca
if cfg.MaxRequestBytes <= 0 {
return nil, false, 0, BypassCapZero, release, nil
}
// The predicate has to be the forwarder's own: a looser one (either header
// on its own) skips capture for requests the forwarder still delivers to
// the upstream with their body intact, which hides them from every
// deny-capable middleware in the chain.
if netutil.IsUpgradeRequest(r.Header) {
if r.Header.Get("Upgrade") != "" {
return nil, false, 0, BypassUpgradeHeader, release, nil
}
if strings.EqualFold(r.Header.Get("Connection"), "upgrade") {
return nil, false, 0, BypassConnectionUpgrd, release, nil
}
if !contentTypeAllowed(r.Header.Get("Content-Type"), cfg.ContentTypes) {
return nil, false, 0, BypassContentType, release, nil
}

View File

@@ -1,329 +0,0 @@
package builtin_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"strconv"
"testing"
"github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/middleware"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_request_parser"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser"
)
// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing
// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split.
func TestCostCalculation_ProviderMatrix(t *testing.T) {
// Empty data dir → embedded defaults, like a proxy with no pricing override.
builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil)
reqMW, err := llm_request_parser.Factory{}.New(nil)
require.NoError(t, err, "build llm_request_parser")
respMW, err := llm_response_parser.Factory{}.New(nil)
require.NoError(t, err, "build llm_response_parser")
costMW, err := cost_meter.Factory{}.New(nil)
require.NoError(t, err, "build cost_meter")
t.Cleanup(func() { _ = costMW.Close() })
const jsonCT = "application/json"
const sseCT = "text/event-stream"
const awsCT = "application/vnd.amazon.eventstream"
cases := []struct {
name string
url string
reqBody []byte
respCT string
respBody []byte
wantProvider string
wantModel string
wantCost float64 // exact expected USD; ignored when wantSkip is set
wantCacheCost float64 // expected cost.usd_cache portion of wantCost
wantSkip string // expected cost.skipped reason, "" when priced
}{
{
// gpt-4o-mini $0.15/$0.60 per MTok: 1000×0.15/1M + 500×0.60/1M.
name: "openai chat completions",
url: "https://api.openai.com/v1/chat/completions",
reqBody: []byte(`{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"choices":[{"message":{"content":"pong"}}],"usage":{"prompt_tokens":1000,"completion_tokens":500,"total_tokens":1500}}`),
wantProvider: "openai",
wantModel: "gpt-4o-mini",
wantCost: 0.00045,
},
{
// OpenAI cached tokens are a SUBSET of prompt_tokens at a discount; gpt-4o $2.50/$10 per MTok, cached $1.25/M:
// 250×2.5/1M + 750×1.25/1M + 500×10/1M.
name: "openai cached subset discount",
url: "https://api.openai.com/v1/chat/completions",
reqBody: []byte(`{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":750}}}`),
wantProvider: "openai",
wantModel: "gpt-4o",
wantCost: 0.0065625,
wantCacheCost: 0.0009375,
},
{
// OpenAI streaming: usage rides the final SSE frame.
name: "openai chat SSE stream",
url: "https://api.openai.com/v1/chat/completions",
reqBody: []byte(`{"model":"gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
respCT: sseCT,
respBody: sseBody(`{"choices":[{"delta":{"content":"po"}}]}`, `{"choices":[{"delta":{"content":"ng"}}]}`, `{"choices":[],"usage":{"prompt_tokens":1000,"completion_tokens":500}}`, "[DONE]"),
wantProvider: "openai",
wantModel: "gpt-4o-mini",
wantCost: 0.00045,
},
{
// Mistral speaks the OpenAI shape: mistral-large-latest $0.50/$1.50 per MTok.
name: "mistral via openai shape",
url: "https://api.mistral.ai/v1/chat/completions",
reqBody: []byte(`{"model":"mistral-large-latest","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":1000}}`),
wantProvider: "openai",
wantModel: "mistral-large-latest",
wantCost: 0.002,
},
{
// The field report, minus caching: Bedrock Sonnet 4.6 $3/$15 per MTok, 3×3/1M + 1514×15/1M = $0.022719.
// Also covers inference-profile normalization of the region-prefixed versioned id in the URL.
name: "bedrock invoke — reported scenario, no cache",
url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke",
reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":3,"output_tokens":1514}}`),
wantProvider: "bedrock",
wantModel: "anthropic.claude-sonnet-4-6",
wantCost: 0.022719,
},
{
// The field report as observed: the FIRST call of a session also wrote a 30,528-token prompt cache at
// 1.25× input ($3.75/M): 0.022719 + 30528×3.75/1M = $0.137199 — the reported $0.1372.
name: "bedrock invoke — reported scenario with cache write",
url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke",
reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"input_tokens":3,"output_tokens":1514,"cache_creation_input_tokens":30528,"cache_read_input_tokens":0}}`),
wantProvider: "bedrock",
wantModel: "anthropic.claude-sonnet-4-6",
wantCost: 0.137199,
wantCacheCost: 0.11448,
},
{
// Same numbers over the InvokeModel event-stream: message_start carries input + cache, message_delta the output.
name: "bedrock invoke stream with cache write",
url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/global.anthropic.claude-sonnet-4-6-20260115-v1:0/invoke-with-response-stream",
reqBody: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
respCT: awsCT,
respBody: bedrockInvokeStream(t, `{"type":"message_start","message":{"usage":{"input_tokens":3,"output_tokens":1,"cache_creation_input_tokens":30528}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":1514}}`),
wantProvider: "bedrock",
wantModel: "anthropic.claude-sonnet-4-6",
wantCost: 0.137199,
wantCacheCost: 0.11448,
},
{
// Converse camelCase usage incl. cache buckets. Haiku 4.5 $1/$5 per MTok, read $0.10/M, write $1.25/M:
// 50×1/1M + 100×5/1M + 2000×0.1/1M + 1000×1.25/1M = $0.002.
name: "bedrock converse with cache buckets",
url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse",
reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`),
respCT: jsonCT,
respBody: []byte(`{"output":{"message":{"content":[{"text":"pong"}]}},"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`),
wantProvider: "bedrock",
wantModel: "anthropic.claude-haiku-4-5",
wantCost: 0.002,
wantCacheCost: 0.00145,
},
{
// Same numbers over converse-stream: usage rides the trailing metadata frame.
name: "bedrock converse stream with cache buckets",
url: "https://bedrock-runtime.eu-central-1.amazonaws.com/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse-stream",
reqBody: []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`),
respCT: awsCT,
respBody: bedrockConverseStream(t,
`{"delta":{"text":"pong"}}`,
`{"usage":{"inputTokens":50,"outputTokens":100,"totalTokens":3150,"cacheReadInputTokens":2000,"cacheWriteInputTokens":1000}}`,
),
wantProvider: "bedrock",
wantModel: "anthropic.claude-haiku-4-5",
wantCost: 0.002,
wantCacheCost: 0.00145,
},
{
// First-party Anthropic, additive cache buckets. Sonnet 4.6:
// 256×3/1M + 200×15/1M + 768×0.3/1M + 512×3.75/1M.
name: "anthropic messages with cache buckets",
url: "https://api.anthropic.com/v1/messages",
reqBody: []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"content":[{"type":"text","text":"pong"}],"usage":{"input_tokens":256,"output_tokens":200,"cache_read_input_tokens":768,"cache_creation_input_tokens":512}}`),
wantProvider: "anthropic",
wantModel: "claude-sonnet-4-6",
wantCost: 0.0059184,
wantCacheCost: 0.0021504,
},
{
// Anthropic SSE: input from message_start, output from message_delta. Haiku 4.5: 1000×1/1M + 2000×5/1M.
name: "anthropic SSE stream",
url: "https://api.anthropic.com/v1/messages",
reqBody: []byte(`{"model":"claude-haiku-4-5","stream":true,"messages":[{"role":"user","content":"hi"}]}`),
respCT: sseCT,
respBody: sseBody(`{"type":"message_start","message":{"usage":{"input_tokens":1000,"output_tokens":2}}}`, `{"type":"content_block_delta","delta":{"type":"text_delta","text":"pong"}}`, `{"type":"message_delta","usage":{"output_tokens":2000}}`, `{"type":"message_stop"}`),
wantProvider: "anthropic",
wantModel: "claude-haiku-4-5",
wantCost: 0.011,
},
{
// Kimi's Anthropic-compatible endpoint: kimi-k3 $3/$15 per MTok under the anthropic table.
name: "kimi anthropic shape",
url: "https://api.moonshot.ai/anthropic/v1/messages",
reqBody: []byte(`{"model":"kimi-k3","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"input_tokens":1000,"output_tokens":1000}}`),
wantProvider: "anthropic",
wantModel: "kimi-k3",
wantCost: 0.018,
},
{
// Vertex path-routed model with "@version" stripped; Anthropic-on-Vertex priced under the anthropic table.
name: "vertex anthropic path-routed",
url: "https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/anthropic/models/claude-sonnet-4-6@20260115:rawPredict",
reqBody: []byte(`{"anthropic_version":"vertex-2023-10-16","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"input_tokens":200,"output_tokens":100}}`),
wantProvider: "anthropic",
wantModel: "claude-sonnet-4-6",
wantCost: 0.0021,
},
{
// Gateway-prefixed model ids are not in the pricing table: the meter must SKIP, never guess a rate.
name: "gateway-prefixed model skips pricing",
url: "https://gateway.example.com/v1/chat/completions",
reqBody: []byte(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}`),
respCT: jsonCT,
respBody: []byte(`{"usage":{"prompt_tokens":1000,"completion_tokens":500}}`),
wantProvider: "openai",
wantModel: "openai/gpt-4o-mini",
wantSkip: "unknown_model",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := &middleware.Input{
Method: "POST",
URL: tc.url,
Headers: []middleware.KV{{Key: "Content-Type", Value: "application/json"}},
Body: tc.reqBody,
}
reqOut, err := reqMW.Invoke(context.Background(), in)
require.NoError(t, err, "request parser")
in.Metadata = append(in.Metadata, reqOut.Metadata...)
require.Equal(t, tc.wantProvider, metaKV(in.Metadata, middleware.KeyLLMProvider), "detected provider")
require.Equal(t, tc.wantModel, metaKV(in.Metadata, middleware.KeyLLMModel), "detected (normalized) model")
in.Status = 200
in.RespHeaders = []middleware.KV{{Key: "Content-Type", Value: tc.respCT}}
in.RespBody = tc.respBody
respOut, err := respMW.Invoke(context.Background(), in)
require.NoError(t, err, "response parser")
in.Metadata = append(in.Metadata, respOut.Metadata...)
costOut, err := costMW.Invoke(context.Background(), in)
require.NoError(t, err, "cost meter")
if tc.wantSkip != "" {
assert.Equal(t, tc.wantSkip, metaKV(costOut.Metadata, middleware.KeyCostSkipped), "expected cost skip reason")
assert.Empty(t, metaKV(costOut.Metadata, middleware.KeyCostUSDTotal), "no cost may be emitted on skip")
return
}
raw := metaKV(costOut.Metadata, middleware.KeyCostUSDTotal)
require.NotEmpty(t, raw, "cost.usd_total must be emitted; skip=%q", metaKV(costOut.Metadata, middleware.KeyCostSkipped))
got, err := strconv.ParseFloat(raw, 64)
require.NoError(t, err, "cost must be a float")
// cost.usd_total is rendered with %.6f: allow half of the last printed digit on top of float error.
assert.InDelta(t, tc.wantCost, got, 5.1e-7, "USD cost for %s", tc.name)
rawCache := metaKV(costOut.Metadata, middleware.KeyCostUSDCache)
require.NotEmpty(t, rawCache, "cost.usd_cache must be emitted next to cost.usd_total")
gotCache, err := strconv.ParseFloat(rawCache, 64)
require.NoError(t, err, "cache cost must be a float")
assert.InDelta(t, tc.wantCacheCost, gotCache, 5.1e-7, "cache USD cost for %s", tc.name)
})
}
}
// metaKV returns the value for key in kvs, or "" when absent.
func metaKV(kvs []middleware.KV, key string) string {
for _, kv := range kvs {
if kv.Key == key {
return kv.Value
}
}
return ""
}
// sseBody renders data frames as a text/event-stream body.
func sseBody(frames ...string) []byte {
var b bytes.Buffer
for _, f := range frames {
b.WriteString("data: ")
b.WriteString(f)
b.WriteString("\n\n")
}
return b.Bytes()
}
// awsFrame encodes one AWS event-stream frame with the given :event-type.
func awsFrame(t *testing.T, eventType string, payload []byte) []byte {
t.Helper()
var buf bytes.Buffer
enc := eventstream.NewEncoder()
require.NoError(t, enc.Encode(&buf, eventstream.Message{
Headers: eventstream.Headers{{Name: ":event-type", Value: eventstream.StringValue(eventType)}},
Payload: payload,
}), "encode event-stream frame")
return buf.Bytes()
}
// bedrockInvokeStream builds an invoke-with-response-stream body: each "chunk" frame wraps a base64 Anthropic event.
func bedrockInvokeStream(t *testing.T, events ...string) []byte {
t.Helper()
var body bytes.Buffer
for _, ev := range events {
wrap, err := json.Marshal(map[string]string{"bytes": base64.StdEncoding.EncodeToString([]byte(ev))})
require.NoError(t, err)
body.Write(awsFrame(t, "chunk", wrap))
}
return body.Bytes()
}
// bedrockConverseStream builds a converse-stream body: contentBlockDelta frames plus a trailing metadata usage frame.
func bedrockConverseStream(t *testing.T, deltas ...string) []byte {
t.Helper()
var body bytes.Buffer
for i, ev := range deltas {
eventType := "contentBlockDelta"
if i == len(deltas)-1 {
eventType = "metadata"
}
body.Write(awsFrame(t, eventType, []byte(ev)))
}
return body.Bytes()
}

View File

@@ -32,12 +32,7 @@ const (
)
var metadataKeys = []string{
middleware.KeyCostUSDInput,
middleware.KeyCostUSDCachedInput,
middleware.KeyCostUSDCacheCreation,
middleware.KeyCostUSDOutput,
middleware.KeyCostUSDTotal,
middleware.KeyCostUSDCache,
middleware.KeyCostSkipped,
}
@@ -145,38 +140,18 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
}
table := m.loader.Get()
costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
cost, ok := table.Cost(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
if !ok {
out.Metadata = skip(skipUnknownModel)
return out, nil
}
// Per-bucket costs first: they're the base of the breakdown, and the two
// aggregates that follow are derived from exactly these four values.
out.Metadata = []middleware.KV{
{Key: middleware.KeyCostUSDInput, Value: usd(costs.InputUSD)},
{Key: middleware.KeyCostUSDCachedInput, Value: usd(costs.CachedInputUSD)},
{Key: middleware.KeyCostUSDCacheCreation, Value: usd(costs.CacheCreationUSD)},
{Key: middleware.KeyCostUSDOutput, Value: usd(costs.OutputUSD)},
{Key: middleware.KeyCostUSDTotal, Value: usd(costs.TotalUSD)},
{Key: middleware.KeyCostUSDCache, Value: usd(costs.CacheUSD)},
{Key: middleware.KeyCostUSDTotal, Value: fmt.Sprintf("%.6f", cost)},
}
return out, nil
}
// usd renders a cost as the fixed-precision string every cost.usd_* key
// carries, so the per-bucket values and the aggregates round identically.
//
// 9 decimals, not 6: these values are summed downstream — per request, per
// session, and per usage bucket — so the rounding step is applied once per
// bucket per row and then accumulated. At 6 decimals a single row loses up to
// 2e-6 across its four buckets (enough to break a 1e-6 reconciliation against
// published rates), and a bucket smaller than half a microdollar quantises to
// zero outright: 16 cache-read tokens on a cheap model is 1.6e-9, so summing
// 10k such rows reports 0.02 instead of 0.016. Nano-dollar precision keeps the
// per-row error ~1000x below the smallest realistic bucket.
func usd(v float64) string { return fmt.Sprintf("%.9f", v) }
// skip returns a single-entry metadata slice carrying the given skip
// reason under KeyCostSkipped.
func skip(reason string) []middleware.KV {

View File

@@ -67,15 +67,7 @@ func TestMiddleware_StaticSurface(t *testing.T) {
assert.NoError(t, mw.Close(), "Close on stateless middleware is a no-op")
keys := mw.MetadataKeys()
expected := []string{
middleware.KeyCostUSDInput,
middleware.KeyCostUSDCachedInput,
middleware.KeyCostUSDCacheCreation,
middleware.KeyCostUSDOutput,
middleware.KeyCostUSDTotal,
middleware.KeyCostUSDCache,
middleware.KeyCostSkipped,
}
expected := []string{middleware.KeyCostUSDTotal, middleware.KeyCostSkipped}
assert.Equal(t, expected, keys, "metadata key allowlist must match the spec")
}
@@ -113,7 +105,7 @@ func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "cost.usd_total must be emitted for known model")
assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format")
assert.Equal(t, "0.000750", value, "0.00015 + 0.0006 per 1k tokens, 6-decimal format")
}
func TestFactory_PricingPathOverride(t *testing.T) {
@@ -137,7 +129,7 @@ func TestFactory_PricingPathOverride(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "cost.usd_total must be emitted with custom pricing path")
assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format")
assert.Equal(t, "0.015000", value, "2*0.0025 + 1*0.01 = 0.015 with 6-decimal format")
}
func TestInvoke_ComputesCostForKnownModel(t *testing.T) {
@@ -156,7 +148,7 @@ func TestInvoke_ComputesCostForKnownModel(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "cost.usd_total must be emitted")
assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format")
assert.Equal(t, "0.018000", value, "0.003 + 0.015 = 0.018 with 6-decimal format")
_, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped)
assert.False(t, skipped, "cost.skipped must not be set when cost is computed")
}
@@ -365,25 +357,8 @@ func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "cached subset path must produce a cost — never a skip")
// 250 non-cached at 0.0025/1k + 750 cached at 0.00125/1k + 500 output at 0.01/1k.
assert.Equal(t, "0.006562500", value,
assert.Equal(t, "0.006563", value,
"cached subset must be billed at the discount rate, non-cached at the full rate; never double-billed")
cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache)
require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total")
// 750 cached at 0.00125/1k = 0.0009375.
assert.Equal(t, "0.000937500", cache, "cache cost is the discounted cost of the cached subset")
// Per-bucket breakdown. On OpenAI the cached subset is carved out of the
// input bucket, so input covers only the 250 non-cached tokens — the two
// must never double-count the same 750 tokens.
assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000625000",
"input bucket bills only the non-cached remainder")
assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000937500",
"cached-input bucket bills the discounted subset")
assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.000000000",
"OpenAI has no cache-write bucket")
assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.005000000",
"output bucket bills 500 tokens at 0.01/1k")
}
// TestInvoke_AnthropicCacheBucketsAdditive proves the Anthropic
@@ -409,33 +384,9 @@ func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
// 256 input * 0.003 + 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 + 200 output * 0.015
// = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184.
assert.Equal(t, "0.005918400", value,
// = 0.000768 + 0.0002304 + 0.00192 + 0.003 = 0.0059184 → "0.005918" with 6-decimal format.
assert.Equal(t, "0.005918", value,
"each Anthropic input bucket must bill at its own rate — cache_read cheap, cache_creation expensive, regular input mid")
cache, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDCache)
require.True(t, ok, "cost.usd_cache must be emitted alongside cost.usd_total")
// 768 cache_read * 0.0003 + 512 cache_creation * 0.00375 = 0.0021504.
assert.Equal(t, "0.002150400", cache, "cache cost sums the read and creation buckets")
// Per-bucket breakdown: four separately-billed buckets, each at its own rate.
assertBucket(t, out.Metadata, middleware.KeyCostUSDInput, "0.000768000",
"input bucket bills 256 tokens at 0.003/1k")
assertBucket(t, out.Metadata, middleware.KeyCostUSDCachedInput, "0.000230400",
"cache-read bucket bills 768 tokens at the cheap 0.0003/1k")
assertBucket(t, out.Metadata, middleware.KeyCostUSDCacheCreation, "0.001920000",
"cache-write bucket bills 512 tokens at the expensive 0.00375/1k")
assertBucket(t, out.Metadata, middleware.KeyCostUSDOutput, "0.003000000",
"output bucket bills 200 tokens at 0.015/1k")
}
// assertBucket asserts one per-bucket cost key carries the expected
// 6-decimal value.
func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) {
t.Helper()
got, ok := metaValue(t, md, key)
require.Truef(t, ok, "%s must be emitted", key)
assert.Equal(t, want, got, msg)
}
// TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the
@@ -460,7 +411,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
// 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075
assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed")
assert.Equal(t, "0.007500", value, "no cached metadata = same cost as before the feature landed")
}
// TestInvoke_UnparseableCachedTokensSkippedSilently proves the
@@ -484,7 +435,7 @@ func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) {
require.NoError(t, err)
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "garbage cache metadata must NOT switch the response from a cost to a skip — fall back to 0 cached")
assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path")
assert.Equal(t, "0.007500", value, "same as the no-cached-metadata path")
}
// TestMiddleware_CloseCancelsReloader proves Close stops the per-instance

View File

@@ -69,18 +69,15 @@ func applyBedrockInvokeChunk(payload []byte, usage *llm.Usage, completion *strin
}
// converseStreamEvent captures the Converse stream frames carrying completion
// text (contentBlockDelta) and the final token usage (metadata). Cache buckets
// are additive to inputTokens (AWS write bucket: cacheWriteInputTokens).
// text (contentBlockDelta) and the final token usage (metadata).
type converseStreamEvent struct {
Delta *struct {
Text string `json:"text"`
} `json:"delta"`
Usage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalTokens int64 `json:"totalTokens"`
CacheReadTokens int64 `json:"cacheReadInputTokens"`
CacheWriteTokens int64 `json:"cacheWriteInputTokens"`
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalTokens int64 `json:"totalTokens"`
} `json:"usage"`
}
@@ -108,12 +105,6 @@ func applyConverseStreamEvent(eventType string, payload []byte, usage *llm.Usage
if ev.Usage.TotalTokens > 0 {
usage.TotalTokens = ev.Usage.TotalTokens
}
if ev.Usage.CacheReadTokens > 0 {
usage.CachedInputTokens = ev.Usage.CacheReadTokens
}
if ev.Usage.CacheWriteTokens > 0 {
usage.CacheCreationTokens = ev.Usage.CacheWriteTokens
}
}
}
}

View File

@@ -66,24 +66,6 @@ func TestAccumulateBedrockStream_Converse(t *testing.T) {
require.Equal(t, "pong", completion, "converse text deltas concatenated")
}
// The converse-stream metadata frame's camelCase cache fields must reach the billed cache buckets.
func TestAccumulateBedrockStream_ConverseCacheBuckets(t *testing.T) {
var body bytes.Buffer
body.Write(bedrockFrame(t, "contentBlockDelta", mustJSON(t, map[string]any{"delta": map[string]any{"text": "pong"}})))
body.Write(bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{
"inputTokens": 11, "outputTokens": 3, "totalTokens": 30,
"cacheReadInputTokens": 7, "cacheWriteInputTokens": 9,
}})))
usage, completion := accumulateBedrockStream(body.Bytes())
require.Equal(t, int64(11), usage.InputTokens, "input tokens from metadata frame")
require.Equal(t, int64(3), usage.OutputTokens, "output tokens from metadata frame")
require.Equal(t, int64(7), usage.CachedInputTokens, "cache-read tokens from metadata frame")
require.Equal(t, int64(9), usage.CacheCreationTokens, "cache-write tokens from metadata frame")
require.Equal(t, int64(30), usage.TotalTokens, "provider-reported total wins")
require.Equal(t, "pong", completion)
}
func TestAccumulateBedrockStream_Truncated(t *testing.T) {
// A body cut mid-frame must not panic; partial usage is returned.
full := bedrockFrame(t, "metadata", mustJSON(t, map[string]any{"usage": map[string]any{"inputTokens": 11, "outputTokens": 3}}))

View File

@@ -75,19 +75,8 @@ const (
KeyLLMAttributionGroupID = "llm.attribution_group_id"
KeyLLMAttributionWindowS = "llm.attribution_window_seconds"
// Cost metering (emitted by cost_meter). The four per-bucket keys are the
// base of the breakdown — one per token bucket the provider bills
// separately — and the two aggregates below are derived from them:
// usd_total is their sum, usd_cache is cached_input + cache_creation.
KeyCostUSDInput = "cost.usd_input"
// KeyCostUSDCachedInput is the cost of the cache-read bucket (Anthropic cache_read; OpenAI's discounted cached subset of input).
KeyCostUSDCachedInput = "cost.usd_cached_input"
// KeyCostUSDCacheCreation is the cost of the cache-write bucket. Zero for providers without one.
KeyCostUSDCacheCreation = "cost.usd_cache_creation"
KeyCostUSDOutput = "cost.usd_output"
KeyCostUSDTotal = "cost.usd_total"
// KeyCostUSDCache is the portion of cost.usd_total billed for prompt-cache buckets (cache read/creation, or OpenAI's cached input subset).
KeyCostUSDCache = "cost.usd_cache"
// Cost metering (emitted by cost_meter).
KeyCostUSDTotal = "cost.usd_total"
KeyCostSkipped = "cost.skipped"
// Framework-emitted error markers. Use the mw.<id>.* prefix to

View File

@@ -1,23 +0,0 @@
package netutil
import (
"net/http"
"golang.org/x/net/http/httpguts"
)
// IsUpgradeRequest reports whether r is a protocol-upgrade request, using the
// same predicate httputil.ReverseProxy applies when it decides to hand the
// connection over instead of proxying normally.
//
// Matching the forwarder exactly matters for anything that inspects a request
// before it is proxied: a looser test (an Upgrade header on its own, say) marks
// a request as an upgrade and skips inspection, while the forwarder still
// delivers it to the backend as an ordinary request with its body intact. That
// gap is a body-inspection bypass reachable by adding one header.
func IsUpgradeRequest(h http.Header) bool {
if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
return false
}
return h.Get("Upgrade") != ""
}

View File

@@ -50,37 +50,6 @@ const (
CrowdSecObserve CrowdSecMode = "observe"
)
// AppSecMode is the per-service CrowdSec AppSec (WAF) enforcement mode.
type AppSecMode string
const (
// AppSecOff disables request inspection.
AppSecOff AppSecMode = ""
// AppSecEnforce blocks requests the engine flags, and fails closed when the
// engine is unreachable.
AppSecEnforce AppSecMode = "enforce"
// AppSecObserve records the verdict without blocking.
AppSecObserve AppSecMode = "observe"
)
// ParseAppSecMode maps a wire value to an AppSecMode. Unrecognized values map
// to AppSecOff so a typo never turns inspection into an unintended block.
func ParseAppSecMode(s string) AppSecMode {
switch AppSecMode(s) {
case AppSecEnforce:
return AppSecEnforce
case AppSecObserve:
return AppSecObserve
default:
return AppSecOff
}
}
// Enabled reports whether the mode asks for request inspection.
func (m AppSecMode) Enabled() bool {
return m == AppSecEnforce || m == AppSecObserve
}
// Filter evaluates IP restrictions. CIDR checks are performed first
// (cheap), followed by country lookups (more expensive) only when needed.
type Filter struct {
@@ -177,13 +146,6 @@ const (
// DenyCrowdSecUnavailable indicates enforce mode but the bouncer has not
// completed its initial sync.
DenyCrowdSecUnavailable
// DenyAppSecBan indicates a CrowdSec AppSec "ban" remediation.
DenyAppSecBan
// DenyAppSecCaptcha indicates a CrowdSec AppSec "captcha" remediation.
DenyAppSecCaptcha
// DenyAppSecUnavailable indicates enforce mode but the AppSec engine could
// not produce a verdict (unreachable, timed out, or it rejected the call).
DenyAppSecUnavailable
)
// String returns the deny reason string matching the HTTP auth mechanism names.
@@ -205,12 +167,6 @@ func (v Verdict) String() string {
return "crowdsec_throttle"
case DenyCrowdSecUnavailable:
return "crowdsec_unavailable"
case DenyAppSecBan:
return "appsec_ban"
case DenyAppSecCaptcha:
return "appsec_captcha"
case DenyAppSecUnavailable:
return "appsec_unavailable"
default:
return "unknown"
}
@@ -226,16 +182,6 @@ func (v Verdict) IsCrowdSec() bool {
}
}
// IsAppSec returns true when the verdict originates from an AppSec inspection.
func (v Verdict) IsAppSec() bool {
switch v {
case DenyAppSecBan, DenyAppSecCaptcha, DenyAppSecUnavailable:
return true
default:
return false
}
}
// IsObserveOnly returns true when v is a CrowdSec verdict and the filter is in
// observe mode. Callers should log the verdict but not block the request.
func (f *Filter) IsObserveOnly(v Verdict) bool {

View File

@@ -126,23 +126,6 @@ type Config struct {
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables
// CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint. Empty disables
// HTTP request inspection.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call. Zero falls
// back to the internal default.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to AppSec.
// Zero falls back to the internal default; negative forwards no body.
CrowdSecAppSecMaxBodyBytes int64
// CrowdSecAppSecMaxConcurrent bounds AppSec inspections in flight toward
// the engine. Zero falls back to the internal default; negative removes
// the bound.
CrowdSecAppSecMaxConcurrent int
// MiddlewareCaptureBudgetBytes bounds the total request-body buffering in
// flight across the proxy, shared by AppSec inspection and the
// agent-network capture. Zero falls back to the internal default.
MiddlewareCaptureBudgetBytes int64
}
// New builds a Server from cfg without performing any I/O. No goroutines
@@ -152,47 +135,42 @@ type Config struct {
// directly) byte-for-byte equivalent.
func New(ctx context.Context, cfg Config) *Server {
return &Server{
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
CrowdSecAppSecURL: cfg.CrowdSecAppSecURL,
CrowdSecAppSecTimeout: cfg.CrowdSecAppSecTimeout,
CrowdSecAppSecMaxBodyBytes: cfg.CrowdSecAppSecMaxBodyBytes,
CrowdSecAppSecMaxConcurrent: cfg.CrowdSecAppSecMaxConcurrent,
MiddlewareCaptureBudgetBytes: cfg.MiddlewareCaptureBudgetBytes,
ctx: ctx,
ListenAddr: cfg.ListenAddr,
ID: cfg.ID,
Logger: cfg.Logger,
Version: cfg.Version,
ProxyURL: cfg.ProxyURL,
ManagementAddress: cfg.ManagementAddress,
ProxyToken: cfg.ProxyToken,
CertificateDirectory: cfg.CertificateDirectory,
CertificateFile: cfg.CertificateFile,
CertificateKeyFile: cfg.CertificateKeyFile,
GenerateACMECertificates: cfg.GenerateACMECertificates,
ACMEChallengeAddress: cfg.ACMEChallengeAddress,
ACMEDirectory: cfg.ACMEDirectory,
ACMEEABKID: cfg.ACMEEABKID,
ACMEEABHMACKey: cfg.ACMEEABHMACKey,
ACMEChallengeType: cfg.ACMEChallengeType,
CertLockMethod: cfg.CertLockMethod,
WildcardCertDir: cfg.WildcardCertDir,
DebugEndpointEnabled: cfg.DebugEndpointEnabled,
DebugEndpointAddress: cfg.DebugEndpointAddress,
HealthAddress: cfg.HealthAddr,
ForwardedProto: cfg.ForwardedProto,
TrustedProxies: cfg.TrustedProxies,
WireguardPort: cfg.WireguardPort,
ProxyProtocol: cfg.ProxyProtocol,
PreSharedKey: cfg.PreSharedKey,
Performance: cfg.Performance,
SupportsCustomPorts: cfg.SupportsCustomPorts,
RequireSubdomain: cfg.RequireSubdomain,
Private: cfg.Private,
MaxDialTimeout: cfg.MaxDialTimeout,
MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout,
MappingBatchWatchdog: cfg.MappingBatchWatchdog,
GeoDataDir: cfg.GeoDataDir,
CrowdSecAPIURL: cfg.CrowdSecAPIURL,
CrowdSecAPIKey: cfg.CrowdSecAPIKey,
}
}

View File

@@ -1,45 +0,0 @@
package proxy
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// New maps Config onto Server field by field, and a field left out of that
// literal still compiles: the knob is simply parsed and then dropped, so an
// operator setting it sees the default with no error anywhere. These assertions
// are the only thing standing between a new setting and that silent no-op.
func TestNew_ForwardsOperatorTuning(t *testing.T) {
cfg := Config{
ManagementAddress: "http://localhost:8080",
ProxyToken: "token",
CrowdSecAPIURL: "http://crowdsec:8080/",
CrowdSecAPIKey: "key",
CrowdSecAppSecURL: "http://crowdsec:7422/",
CrowdSecAppSecTimeout: 321 * time.Millisecond,
CrowdSecAppSecMaxBodyBytes: 4321,
CrowdSecAppSecMaxConcurrent: 17,
MiddlewareCaptureBudgetBytes: 5 << 20,
MaxDialTimeout: 7 * time.Second,
MaxSessionIdleTimeout: 11 * time.Second,
GeoDataDir: "/var/lib/geo",
}
srv := New(context.Background(), cfg)
require.NotNil(t, srv)
assert.Equal(t, cfg.CrowdSecAPIURL, srv.CrowdSecAPIURL)
assert.Equal(t, cfg.CrowdSecAPIKey, srv.CrowdSecAPIKey)
assert.Equal(t, cfg.CrowdSecAppSecURL, srv.CrowdSecAppSecURL)
assert.Equal(t, cfg.CrowdSecAppSecTimeout, srv.CrowdSecAppSecTimeout)
assert.Equal(t, cfg.CrowdSecAppSecMaxBodyBytes, srv.CrowdSecAppSecMaxBodyBytes)
assert.Equal(t, cfg.CrowdSecAppSecMaxConcurrent, srv.CrowdSecAppSecMaxConcurrent)
assert.Equal(t, cfg.MiddlewareCaptureBudgetBytes, srv.MiddlewareCaptureBudgetBytes)
assert.Equal(t, cfg.MaxDialTimeout, srv.MaxDialTimeout)
assert.Equal(t, cfg.MaxSessionIdleTimeout, srv.MaxSessionIdleTimeout)
assert.Equal(t, cfg.GeoDataDir, srv.GeoDataDir)
}

View File

@@ -240,10 +240,6 @@ func (m *testProxyManager) ClusterSupportsCrowdSec(_ context.Context, _ string)
return nil
}
func (m *testProxyManager) ClusterSupportsAppSec(_ context.Context, _ string) *bool {
return nil
}
func (m *testProxyManager) ClusterSupportsPrivate(_ context.Context, _ string) *bool {
return nil
}
@@ -566,11 +562,16 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
addMappingCalls.Add(1)
// Apply to real auth middleware (idempotent)
err := authMw.AddDomain(mapping.GetDomain(), auth.DomainSettings{
AccountID: proxytypes.AccountID(mapping.GetAccountId()),
ServiceID: proxytypes.ServiceID(mapping.GetId()),
Private: mapping.GetPrivate(),
})
err := authMw.AddDomain(
mapping.GetDomain(),
nil,
"",
0,
proxytypes.AccountID(mapping.GetAccountId()),
proxytypes.ServiceID(mapping.GetId()),
nil,
mapping.GetPrivate(),
)
require.NoError(t, err)
// Apply to real proxy (idempotent)

View File

@@ -45,7 +45,6 @@ import (
"github.com/netbirdio/netbird/client/embed"
"github.com/netbirdio/netbird/proxy/internal/accesslog"
"github.com/netbirdio/netbird/proxy/internal/acme"
"github.com/netbirdio/netbird/proxy/internal/appsec"
"github.com/netbirdio/netbird/proxy/internal/auth"
"github.com/netbirdio/netbird/proxy/internal/certwatch"
"github.com/netbirdio/netbird/proxy/internal/conntrack"
@@ -127,10 +126,6 @@ type Server struct {
crowdsecMu sync.Mutex
crowdsecServices map[types.ServiceID]bool
// appsecClient is the shared CrowdSec AppSec client, nil when no AppSec
// endpoint is configured. Stateless, so it needs no per-service lifecycle.
appsecClient *appsec.Client
// routerReady is closed once mainRouter is fully initialized.
// The mapping worker waits on this before processing updates.
routerReady chan struct{}
@@ -243,20 +238,6 @@ type Server struct {
CrowdSecAPIURL string
// CrowdSecAPIKey is the CrowdSec bouncer API key. Empty disables CrowdSec.
CrowdSecAPIKey string
// CrowdSecAppSecURL is the CrowdSec AppSec (WAF) endpoint, e.g.
// http://127.0.0.1:7422/. Empty disables request inspection. Requires
// CrowdSecAPIKey, which the AppSec component validates against LAPI.
CrowdSecAppSecURL string
// CrowdSecAppSecTimeout bounds a single AppSec inspection call.
// Zero means appsec.DefaultTimeout.
CrowdSecAppSecTimeout time.Duration
// CrowdSecAppSecMaxBodyBytes caps the request body mirrored to the AppSec
// engine. Zero means appsec.DefaultMaxBodyBytes; negative disables body
// forwarding, leaving header and URI inspection.
CrowdSecAppSecMaxBodyBytes int64
// CrowdSecAppSecMaxConcurrent bounds AppSec inspections in flight. Zero
// means appsec.DefaultMaxConcurrent; negative removes the bound.
CrowdSecAppSecMaxConcurrent int
// MaxSessionIdleTimeout caps the per-service session idle timeout.
// Zero means no cap (the proxy honors whatever management sends).
// Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments.
@@ -403,13 +384,6 @@ func (s *Server) Start(ctx context.Context) error {
s.crowdsecRegistry = crowdsec.NewRegistry(s.CrowdSecAPIURL, s.CrowdSecAPIKey, log.NewEntry(s.Logger))
s.crowdsecServices = make(map[types.ServiceID]bool)
// Must precede the mapping worker: the worker opens the management stream
// and reports proxyCapabilities, which reads appsecClient. Building it
// afterwards would both race the read and, when the worker won, advertise
// the proxy as AppSec-incapable for the lifetime of that stream.
if err := s.initAppSec(); err != nil {
return err
}
go s.newManagementMappingWorker(runCtx, s.mgmtClient)
@@ -437,7 +411,6 @@ func (s *Server) Start(ctx context.Context) error {
}()
s.auth = auth.NewMiddleware(s.Logger, s.mgmtClient, s.geo)
s.auth.SetAppSec(s.appsecClient)
s.accessLog = accesslog.NewLogger(s.mgmtClient, s.Logger, s.TrustedProxies)
s.startDebugEndpoint()
@@ -1301,7 +1274,6 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr
func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
supportsCrowdSec := s.crowdsecRegistry.Available()
supportsAppSec := s.appsecClient != nil
privateCapability := s.Private
// Always true: this build enforces ProxyMapping.private via the auth middleware.
supportsPrivateService := true
@@ -1309,7 +1281,6 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities {
SupportsCustomPorts: &s.SupportsCustomPorts,
RequireSubdomain: &s.RequireSubdomain,
SupportsCrowdsec: &supportsCrowdSec,
SupportsAppsec: &supportsAppSec,
Private: &privateCapability,
SupportsPrivateService: &supportsPrivateService,
}
@@ -1937,67 +1908,6 @@ func (s *Server) parseRestrictions(mapping *proto.ProxyMapping) *restrict.Filter
})
}
// initAppSec builds the shared AppSec client when an endpoint is configured.
// A configured-but-invalid endpoint is a startup error rather than a silent
// downgrade: services asking for enforce would otherwise fail closed on every
// request with no indication why.
//
// Runs before the management stream opens so the reported capability is stable;
// the auth middleware picks the client up separately once it exists.
func (s *Server) initAppSec() error {
if s.CrowdSecAppSecURL == "" {
return nil
}
if s.CrowdSecAPIKey == "" {
return errors.New("crowdsec appsec url is set but the crowdsec api key is empty")
}
// Share the middleware capture budget rather than opening a second pool:
// AppSec buffers before authentication, so its ceiling has to count against
// the same proxy-wide allowance the body tap draws from.
var budget appsec.Budget
if s.middlewareManager != nil {
budget = s.middlewareManager.Budget()
}
client, err := appsec.New(appsec.Config{
URL: s.CrowdSecAppSecURL,
APIKey: s.CrowdSecAPIKey,
Timeout: s.CrowdSecAppSecTimeout,
MaxBodyBytes: s.CrowdSecAppSecMaxBodyBytes,
MaxConcurrent: s.CrowdSecAppSecMaxConcurrent,
Budget: budget,
Logger: log.NewEntry(s.Logger),
})
if err != nil {
return fmt.Errorf("init crowdsec appsec: %w", err)
}
s.appsecClient = client
s.Logger.Infof("CrowdSec AppSec inspection available at %s", s.CrowdSecAppSecURL)
return nil
}
// appSecMode resolves the per-service AppSec mode. A service asking for
// inspection on a proxy with no AppSec endpoint keeps its mode so the auth
// middleware fails closed for enforce, mirroring the CrowdSec behavior.
func (s *Server) appSecMode(mapping *proto.ProxyMapping) restrict.AppSecMode {
raw := mapping.GetAccessRestrictions().GetAppsecMode()
mode := restrict.ParseAppSecMode(raw)
// An unrecognized value disables inspection, which is the safe default but a
// silent one: with a newer management and an older proxy, a mode this build
// does not know would look identical to "off" on a service the operator set
// to enforce. Say so rather than leaving it to be discovered.
if mode == restrict.AppSecOff && raw != "" && raw != "off" {
s.Logger.Warnf("service %s requests unrecognized AppSec mode %q; this build supports %q and %q, so inspection is disabled",
mapping.GetId(), raw, restrict.AppSecEnforce, restrict.AppSecObserve)
}
if mode.Enabled() && s.appsecClient == nil {
s.Logger.Warnf("service %s requests AppSec mode %q but proxy has no AppSec endpoint configured", mapping.GetId(), mode)
}
return mode
}
// releaseCrowdSec releases the CrowdSec bouncer reference for the given
// service if it had one.
func (s *Server) releaseCrowdSec(svcID types.ServiceID) {
@@ -2164,17 +2074,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
settings := auth.DomainSettings{
Schemes: schemes,
SessionPublicKey: mapping.GetAuth().GetSessionKey(),
SessionExpiration: maxSessionAge,
AccountID: accountID,
ServiceID: svcID,
IPRestrictions: ipRestrictions,
Private: mapping.GetPrivate(),
AppSecMode: s.appSecMode(mapping),
}
if err := s.auth.AddDomain(mapping.GetDomain(), settings); err != nil {
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)

Some files were not shown because too many files have changed in this diff Show More