mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 12:09:58 +00:00
* [management,proxy] Agent network: per-account LLM gateway (policy, metering, multi-provider) (#6555) * [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly. * Update getting started to point to rc when agent network enabled * Add a reference to a commercial license * Fix docs localhost link * Fix docs localhost link * Add private services domain note * [management] Add agent-network telemetry metrics (#6561) Surface agent-network adoption and usage in the self-hosted metrics worker: distinct accounts, providers, policies, budget rules, accounts with log collection enabled, and aggregated input/output tokens plus cost. Tokens and cost are summed from agent_network_request_usage (the always-written per-request ledger) so the figures are accurate regardless of the log-collection toggle and carry no double-counting. All values come from a handful of indexed aggregate queries run only on the worker's periodic tick. Adds store.AgentNetworkMetrics with GetAgentNetworkMetrics on the Store interface, the SqlStore implementation, and a zero-valued FileStore stub. * Update NetBird server and proxy image versions to 0.74.0-rc.2 * [management,proxy] Reduce agent-network cognitive complexity (#6566) Address the SonarCloud quality-gate findings in new agent-network code by extracting focused helpers. No behavior change. - synthesizer.go: split buildIdentityInjectConfigJSON into per-shape rule builders; extract mergeGuardrail from mergeGuardrails to cut nesting depth. - llm_identity_inject: extract injectionEmitsAnything validation predicate from New. - llm_response_parser/streaming.go: extract applyOpenAIStreamUsage and applyAnthropicStreamUsage (via a named anthropicStreamUsage type) and simplify the OpenAI scanner loop. - reverseproxy.go: decompose ServeHTTP into serveRouteError, buildTargetContext, serveDirect, serveWithChain, captureRequestForChain, serveDeny, newResponseWriter, observeResponse, and forwardUpstream, preserving the defer ordering so response observation still reads the captured writer before it is released. * [management] Move agent-network access-log ingest into the agentnetwork module (#6568) The agent-network access-log ingest path (metaKey wire contract, flatten, usage derivation, and the dual-write of the usage ledger + settings-gated full row) lived in the reverseproxy accesslogs manager, even though the agentnetwork module already owns the rest of that domain — types, read (ListAccessLogs / GetUsageOverview), the budget-counter writes, and retention cleanup. Move it next to the rest: a stateless agentnetwork.IngestAccessLog(ctx, store, entry) that the reverseproxy SaveAccessLog delegates to when the entry is agent-network. Removes the agentNetworkTypes import from the reverseproxy manager. No behavior change; the write/read table separation is unchanged. Adds real-store coverage for the disable->enable log-collection toggle (usage ledger always written, full row gated) plus the metadata parse and group-dedup helpers, which previously had no dedicated tests. * Add session view support in the access log * [management,proxy] Container-based agent-network e2e harness (#6577) * [e2e] Add container-based agent-network e2e harness (Pillar 1) Introduce a self-contained, OIDC-free e2e harness that stands up NetBird in containers, so suites no longer depend on the hand-maintained Tilt stack or a real IdP. - harness brings up the combined server (management + signal + relay + STUN + embedded IdP) in a single container built from combined/Dockerfile.multistage, and mints an admin PAT through the unauthenticated /api/setup bootstrap (NB_SETUP_PAT_ENABLED). API access goes through the existing shared/management/client/rest typed client. - the image is built via the docker CLI (BuildKit) so the Dockerfile's cache mounts are honored; testcontainers then runs the tagged image. - everything is behind the `e2e` build tag so normal builds and unit tests never pull in testcontainers. Adds BuildKit cache mounts to combined/Dockerfile.multistage so source changes recompile incrementally rather than from scratch. Pillar 1 proven by TestCombinedBootstrap: server builds, boots, mints a PAT, and the PAT authenticates a real management API call. * [e2e] Add management-side agent-network scenarios (Pillar 2) Port the API-driven agent-network scenarios from the bash suites to Go, sharing one combined server per package run (TestMain) with each test owning its resource cleanup. Drives the /api/agent-network/* endpoints through the shared REST client's NewRequest primitive with the generated api types. Scenarios: - provider lifecycle (create/get/list/delete + 404 after delete) - provider validation (missing api_key, unknown catalog id → 4xx) - settings collection-toggle round-trip with cluster/subdomain immutability - policy window floor (reject <60s enabled limit, accept at 60s) - consumption read endpoint returns an array All deterministic and dependency-free (dummy provider keys; no upstream calls), so they run headless in CI. * [e2e] Add live chat-through-proxy scenario (Pillar 3) Stand up the full agent-network data path in containers and drive a real chat-completion through the gateway: - harness: a shared docker network (combined server reachable by alias), a proxy container built from the published reverse-proxy image (NB_PROXY_PRIVATE, NB_PROXY_ALLOW_INSECURE, NB_RELAY_TRANSPORT=ws to match the combined server's WS-multiplexed relay) with a generated self-signed wildcard cert, and a netbird client container that joins via a setup key. - the combined image, proxy image, and client image default to the published rc.2 releases (overridable via NB_E2E_*_IMAGE; a bare local tag is built from source instead). Geolocation download is disabled so the server starts without external fetches. - one shared domain is used for the management exposed address, the proxy domain, and the agent-network cluster; the proxy token is minted via the server CLI (global) to match the manual install. TestChatCompletionThroughProxy provisions provider+policy+group+setup key, runs proxy+client, drives an OpenAI chat-completion through the tunnel, and asserts a 200 plus the ingested access-log row. Requires OPENAI_TOKEN (skips otherwise). The provider must be created with enabled=true explicitly — the create default is false despite the API doc. * [e2e] Run the live chat scenario across a provider matrix Replace the single-provider chat test with a data-driven matrix that runs the same scenario through every provider whose credentials are present in the environment (keys/URLs sourced from ~/.llm-keys locally, Actions secrets in CI): - OpenAI (chat), Anthropic (messages), Vercel, OpenRouter, Cloudflare (OpenAI-compatible gateways), and Bedrock (path-routed, bearer, via the messages shape) — covering both wire shapes and the gateway routing. - all providers are created enabled with a unique model string so the proxy's connect-time snapshot carries them all and model->provider routing is unambiguous (provider toggles after connect don't reconcile to a connected proxy). - the client supports both wire shapes (/v1/chat/completions and /v1/messages); Cloudflare gets the openai provider segment appended to its gateway URL. Each provider must return 200 through the tunnel and produce an ingested access-log row. Vertex is intentionally excluded from the uniform matrix: it needs a bespoke rawPredict request shape rather than the shared chat/messages path, so it warrants a dedicated scenario. * [ci] Add manual workflow for the agent-network e2e suite The e2e suite (build tag `e2e`) stands up the combined server + proxy + client in Docker and drives live chat-completions, so it is slow and needs provider credentials. Gate it out of normal CI (it already is, via the build tag) and run it on demand via workflow_dispatch. Provider scenarios skip when their secret is unset, so it degrades gracefully. * [e2e] Add Vertex to the provider matrix; run e2e on ubuntu-latest Vertex (Anthropic-on-Vertex) doesn't share the chat/messages wire shapes: the model travels in a rawPredict path and the proxy mints the service account's OAuth token. Add a Vertex client method that posts /v1/projects/<project>/locations/<region>/publishers/anthropic/models/<model>:rawPredict with the Vertex anthropic_version body, and wire it into the matrix as a path-routed provider (created without a models array). It is keyed off GOOGLE_VERTEX_SA_BASE64 + GOOGLE_VERTEX_PROJECT (region defaults to "global", model to a pinned claude snapshot, both overridable). Also bump the e2e workflow runner to ubuntu-latest and add the Vertex secrets. * Add docker/docker and docker/go-connections as direct dependencies in go.mod * [ci] Trigger agent-network e2e workflow on push to main and pull requests * [e2e] Fix proxy cert permission denied on Linux CI runners The proxy bind-mounts a temp dir of self-signed certs. MkdirTemp creates it 0700 and the key was 0600, which Docker Desktop on macOS ignores but a non-root proxy container on Linux runners cannot traverse/read, so the cert watcher failed with "open /certs/tls.crt: permission denied" and the container exited. Widen the cert dir to 0755 and write the throwaway key 0644 so the proxy uid can read the bind-mounted material. * [e2e] Build images from source by default instead of pulling rc.2 The agent-network code under test lives in this branch, so the e2e should exercise it rather than a frozen published release. Flip the harness default: combined/proxy/client are now built from their in-repo Dockerfiles (combined/Dockerfile.multistage, proxy/Dockerfile.multistage, e2e/harness/Dockerfile.client) under local tags. Pulling a published image stays available by setting NB_E2E_*_IMAGE to a registry reference. Builds now go through buildx --load so the Dockerfile cache mounts are honored and the result is loaded for testcontainers. The CI workflow adds a container-driver builder and a local layer cache (NB_E2E_BUILDX_CACHE) persisted via actions/cache, which caches the base/apt/dep-download layers across runs. The Go compile still re-runs each time, as BuildKit mount caches cannot be exported to the GitHub cache. * [e2e] Cover real providers in lifecycle + assert real consumption metering - TestProviderLifecycle now runs per available real provider (create → get → list → delete → 404) instead of a single dummy provider, exercising each catalog's create and field round-trip. Create is offline, so it stays fast and burns no provider quota; falls back to a synthetic OpenAI provider when no keys are set. - TestProvidersMatrix attaches a token limit (high caps, 60s window) to its policy, which switches on usage metering, and asserts consumption rows are recorded with positive token counts after the live traffic. Consumption is account-scoped (keyed by source group / user and window, not per provider), so the assertion is aggregate. - TestProviderValidation gains invalid-upstream and blank-name cases. Create validation is uniform across catalogs (no per-provider required-field rules), so per-provider rejection cases would be redundant. * [e2e] Assert session id propagates per provider Each matrix request now sends a unique session id as the universal x-session-id header and asserts it round-trips into that provider's access-log row. This guards the session-grouping contract end to end for every provider (header extraction runs in llm_request_parser ahead of the parser-specific body extraction, so it is provider-agnostic). * [e2e] Drop accidentally committed sync-phases dashboard netbird-sync-phases.json was swept into the Pillar 1 commit by a broad git add; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Remove it from the branch so the PR diff is scoped to the e2e changes. * [e2e] Revert accidentally committed sync-phase ingest spec The netbird_sync_phase measurement spec in metrics ingest was swept into the Pillar 1 commit; it belongs to the unrelated sync-phases metrics work, not this e2e harness. Its emission side never landed here, so the spec was orphaned anyway. Restore ingest/main.go to its origin/main state. * Fix golint issues * Fix sonar * Add access log session test * Fix access log tests --------- Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com>
1622 lines
48 KiB
Go
1622 lines
48 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rs/xid"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
|
|
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
|
"github.com/netbirdio/netbird/shared/hash/argon2id"
|
|
"github.com/netbirdio/netbird/util/crypt"
|
|
|
|
"github.com/netbirdio/netbird/shared/management/http/api"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
type Operation string
|
|
|
|
const (
|
|
Create Operation = "create"
|
|
Update Operation = "update"
|
|
Delete Operation = "delete"
|
|
)
|
|
|
|
type Status string
|
|
type TargetType string
|
|
|
|
const (
|
|
StatusPending Status = "pending"
|
|
StatusActive Status = "active"
|
|
StatusTunnelNotCreated Status = "tunnel_not_created"
|
|
StatusCertificatePending Status = "certificate_pending"
|
|
StatusCertificateFailed Status = "certificate_failed"
|
|
StatusError Status = "error"
|
|
|
|
TargetTypePeer TargetType = "peer"
|
|
TargetTypeHost TargetType = "host"
|
|
TargetTypeDomain TargetType = "domain"
|
|
TargetTypeSubnet TargetType = "subnet"
|
|
TargetTypeCluster TargetType = "cluster"
|
|
|
|
SourcePermanent = "permanent"
|
|
SourceEphemeral = "ephemeral"
|
|
)
|
|
|
|
type TargetOptions struct {
|
|
SkipTLSVerify bool `json:"skip_tls_verify"`
|
|
RequestTimeout time.Duration `json:"request_timeout,omitempty"`
|
|
SessionIdleTimeout time.Duration `json:"session_idle_timeout,omitempty"`
|
|
PathRewrite PathRewriteMode `json:"path_rewrite,omitempty"`
|
|
CustomHeaders map[string]string `gorm:"serializer:json" json:"custom_headers,omitempty"`
|
|
// DirectUpstream bypasses the proxy's embedded NetBird client and dials
|
|
// the target via the proxy host's network stack. Useful for upstreams
|
|
// reachable without WireGuard (public APIs, LAN services, localhost
|
|
// sidecars). Default false.
|
|
DirectUpstream bool `json:"direct_upstream,omitempty"`
|
|
// Middlewares carries per-target agent-network middleware configs. Empty
|
|
// for private and operator-defined services; populated only by the
|
|
// agent-network synthesizer.
|
|
Middlewares []MiddlewareConfig `gorm:"serializer:json" json:"middlewares,omitempty"`
|
|
CaptureMaxRequestBytes int64 `json:"capture_max_request_bytes,omitempty"`
|
|
CaptureMaxResponseBytes int64 `json:"capture_max_response_bytes,omitempty"`
|
|
CaptureContentTypes []string `gorm:"serializer:json" json:"capture_content_types,omitempty"`
|
|
// AgentNetwork marks targets synthesised from Agent Network state. The
|
|
// proxy uses it to gate agent-network-specific behaviour (access log
|
|
// tagging, observability, etc.).
|
|
AgentNetwork bool `json:"agent_network,omitempty"`
|
|
// DisableAccessLog suppresses the per-request access-log emission for this
|
|
// target. Defaults false to preserve access-log behaviour for every
|
|
// non-agent-network target. The agent-network synthesizer sets this true
|
|
// only when the account's EnableLogCollection toggle is off.
|
|
DisableAccessLog bool `json:"disable_access_log,omitempty"`
|
|
}
|
|
|
|
// MiddlewareSlot mirrors proto.MiddlewareSlot / middleware.Slot.
|
|
type MiddlewareSlot string
|
|
|
|
const (
|
|
MiddlewareSlotOnRequest MiddlewareSlot = "on_request"
|
|
MiddlewareSlotOnResponse MiddlewareSlot = "on_response"
|
|
MiddlewareSlotTerminal MiddlewareSlot = "terminal"
|
|
)
|
|
|
|
// MiddlewareFailMode mirrors proto.MiddlewareConfig_FailMode.
|
|
type MiddlewareFailMode string
|
|
|
|
const (
|
|
MiddlewareFailOpen MiddlewareFailMode = "fail_open"
|
|
MiddlewareFailClosed MiddlewareFailMode = "fail_closed"
|
|
)
|
|
|
|
// MiddlewareConfig is the per-target configuration for a single
|
|
// middleware instance. Mirrors proto.MiddlewareConfig.
|
|
type MiddlewareConfig struct {
|
|
ID string `json:"id"`
|
|
Enabled bool `json:"enabled"`
|
|
Slot MiddlewareSlot `json:"slot"`
|
|
ConfigJSON []byte `json:"config_json,omitempty"`
|
|
FailMode MiddlewareFailMode `json:"fail_mode,omitempty"`
|
|
TimeoutMs int32 `json:"timeout_ms,omitempty"`
|
|
CanMutate bool `json:"can_mutate"`
|
|
}
|
|
|
|
type Target struct {
|
|
ID uint `gorm:"primaryKey" json:"-"`
|
|
AccountID string `gorm:"index:idx_target_account;not null" json:"-"`
|
|
ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"`
|
|
Path *string `json:"path,omitempty"`
|
|
Host string `json:"host"`
|
|
Port uint16 `gorm:"index:idx_target_port" json:"port"`
|
|
Protocol string `gorm:"index:idx_target_protocol" json:"protocol"`
|
|
TargetId string `gorm:"index:idx_target_id" json:"target_id"`
|
|
TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"`
|
|
Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"`
|
|
Options TargetOptions `gorm:"embedded" json:"options"`
|
|
ProxyProtocol bool `json:"proxy_protocol"`
|
|
}
|
|
|
|
type PasswordAuthConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type PINAuthConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Pin string `json:"pin"`
|
|
}
|
|
|
|
type BearerAuthConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"`
|
|
}
|
|
|
|
// HeaderAuthConfig defines a static header-value auth check.
|
|
// The proxy compares the incoming header value against the stored hash.
|
|
type HeaderAuthConfig struct {
|
|
Enabled bool `json:"enabled"`
|
|
Header string `json:"header"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
type AuthConfig struct {
|
|
PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"`
|
|
PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"`
|
|
BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"`
|
|
HeaderAuths []*HeaderAuthConfig `json:"header_auths,omitempty" gorm:"serializer:json"`
|
|
}
|
|
|
|
// AccessRestrictions controls who can connect to the service based on IP or geography.
|
|
type AccessRestrictions struct {
|
|
AllowedCIDRs []string `json:"allowed_cidrs,omitempty" gorm:"serializer:json"`
|
|
BlockedCIDRs []string `json:"blocked_cidrs,omitempty" gorm:"serializer:json"`
|
|
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"`
|
|
}
|
|
|
|
// Copy returns a deep copy of the AccessRestrictions.
|
|
func (r AccessRestrictions) Copy() AccessRestrictions {
|
|
return AccessRestrictions{
|
|
AllowedCIDRs: slices.Clone(r.AllowedCIDRs),
|
|
BlockedCIDRs: slices.Clone(r.BlockedCIDRs),
|
|
AllowedCountries: slices.Clone(r.AllowedCountries),
|
|
BlockedCountries: slices.Clone(r.BlockedCountries),
|
|
CrowdSecMode: r.CrowdSecMode,
|
|
}
|
|
}
|
|
|
|
func (a *AuthConfig) HashSecrets() error {
|
|
if a.PasswordAuth != nil && a.PasswordAuth.Enabled && a.PasswordAuth.Password != "" {
|
|
hashedPassword, err := argon2id.Hash(a.PasswordAuth.Password)
|
|
if err != nil {
|
|
return fmt.Errorf("hash password: %w", err)
|
|
}
|
|
a.PasswordAuth.Password = hashedPassword
|
|
}
|
|
|
|
if a.PinAuth != nil && a.PinAuth.Enabled && a.PinAuth.Pin != "" {
|
|
hashedPin, err := argon2id.Hash(a.PinAuth.Pin)
|
|
if err != nil {
|
|
return fmt.Errorf("hash pin: %w", err)
|
|
}
|
|
a.PinAuth.Pin = hashedPin
|
|
}
|
|
|
|
for i, h := range a.HeaderAuths {
|
|
if h != nil && h.Enabled && h.Value != "" {
|
|
hashedValue, err := argon2id.Hash(h.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("hash header auth[%d] value: %w", i, err)
|
|
}
|
|
h.Value = hashedValue
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *AuthConfig) ClearSecrets() {
|
|
if a.PasswordAuth != nil {
|
|
a.PasswordAuth.Password = ""
|
|
}
|
|
if a.PinAuth != nil {
|
|
a.PinAuth.Pin = ""
|
|
}
|
|
for _, h := range a.HeaderAuths {
|
|
if h != nil {
|
|
h.Value = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
type Meta struct {
|
|
CreatedAt time.Time
|
|
CertificateIssuedAt *time.Time
|
|
Status string
|
|
LastRenewedAt *time.Time
|
|
}
|
|
|
|
type Service struct {
|
|
ID string `gorm:"primaryKey"`
|
|
AccountID string `gorm:"index"`
|
|
Name string
|
|
Domain string `gorm:"type:varchar(255);uniqueIndex"`
|
|
ProxyCluster string `gorm:"index"`
|
|
Targets []*Target `gorm:"foreignKey:ServiceID;constraint:OnDelete:CASCADE"`
|
|
Enabled bool
|
|
Terminated bool
|
|
PassHostHeader bool
|
|
RewriteRedirects bool
|
|
Auth AuthConfig `gorm:"serializer:json"`
|
|
Restrictions AccessRestrictions `gorm:"serializer:json"`
|
|
Meta Meta `gorm:"embedded;embeddedPrefix:meta_"`
|
|
SessionPrivateKey string `gorm:"column:session_private_key"`
|
|
SessionPublicKey string `gorm:"column:session_public_key"`
|
|
Source string `gorm:"default:'permanent';index:idx_service_source_peer"`
|
|
SourcePeer string `gorm:"index:idx_service_source_peer"`
|
|
// Mode determines the service type: "http", "tcp", "udp", or "tls".
|
|
Mode string `gorm:"default:'http'"`
|
|
ListenPort uint16
|
|
PortAutoAssigned bool
|
|
// Private marks the service as NetBird-only: auth via ValidateTunnelPeer against AccessGroups instead of SSO. HTTP-only.
|
|
Private bool
|
|
// AccessGroups is the group ID allowlist for inbound peers on private services. Mutually exclusive with bearer SSO.
|
|
AccessGroups []string `json:"access_groups,omitempty" gorm:"serializer:json"`
|
|
}
|
|
|
|
// InitNewRecord generates a new unique ID and resets metadata for a newly created
|
|
// Service record. This overwrites any existing ID and Meta fields and should
|
|
// only be called during initial creation, not for updates.
|
|
func (s *Service) InitNewRecord() {
|
|
s.ID = xid.New().String()
|
|
s.Meta = Meta{
|
|
CreatedAt: time.Now(),
|
|
Status: string(StatusPending),
|
|
}
|
|
}
|
|
|
|
func (s *Service) ToAPIResponse() *api.Service {
|
|
authConfig := api.ServiceAuthConfig{}
|
|
|
|
if s.Auth.PasswordAuth != nil {
|
|
authConfig.PasswordAuth = &api.PasswordAuthConfig{
|
|
Enabled: s.Auth.PasswordAuth.Enabled,
|
|
}
|
|
}
|
|
|
|
if s.Auth.PinAuth != nil {
|
|
authConfig.PinAuth = &api.PINAuthConfig{
|
|
Enabled: s.Auth.PinAuth.Enabled,
|
|
}
|
|
}
|
|
|
|
if s.Auth.BearerAuth != nil {
|
|
authConfig.BearerAuth = &api.BearerAuthConfig{
|
|
Enabled: s.Auth.BearerAuth.Enabled,
|
|
DistributionGroups: &s.Auth.BearerAuth.DistributionGroups,
|
|
}
|
|
}
|
|
|
|
if len(s.Auth.HeaderAuths) > 0 {
|
|
apiHeaders := make([]api.HeaderAuthConfig, 0, len(s.Auth.HeaderAuths))
|
|
for _, h := range s.Auth.HeaderAuths {
|
|
if h == nil {
|
|
continue
|
|
}
|
|
apiHeaders = append(apiHeaders, api.HeaderAuthConfig{
|
|
Enabled: h.Enabled,
|
|
Header: h.Header,
|
|
})
|
|
}
|
|
authConfig.HeaderAuths = &apiHeaders
|
|
}
|
|
|
|
// Convert internal targets to API targets
|
|
apiTargets := make([]api.ServiceTarget, 0, len(s.Targets))
|
|
for _, target := range s.Targets {
|
|
st := api.ServiceTarget{
|
|
Path: target.Path,
|
|
Host: &target.Host,
|
|
Port: int(target.Port),
|
|
Protocol: api.ServiceTargetProtocol(target.Protocol),
|
|
TargetId: target.TargetId,
|
|
TargetType: api.ServiceTargetTargetType(target.TargetType),
|
|
Enabled: target.Enabled && !s.Terminated,
|
|
}
|
|
opts := targetOptionsToAPI(target.Options)
|
|
if opts == nil {
|
|
opts = &api.ServiceTargetOptions{}
|
|
}
|
|
if target.ProxyProtocol {
|
|
opts.ProxyProtocol = &target.ProxyProtocol
|
|
}
|
|
st.Options = opts
|
|
apiTargets = append(apiTargets, st)
|
|
}
|
|
|
|
meta := api.ServiceMeta{
|
|
CreatedAt: s.Meta.CreatedAt,
|
|
Status: api.ServiceMetaStatus(s.Meta.Status),
|
|
}
|
|
|
|
if s.Meta.CertificateIssuedAt != nil {
|
|
meta.CertificateIssuedAt = s.Meta.CertificateIssuedAt
|
|
}
|
|
|
|
mode := api.ServiceMode(s.Mode)
|
|
listenPort := int(s.ListenPort)
|
|
|
|
resp := &api.Service{
|
|
Id: s.ID,
|
|
Name: s.Name,
|
|
Domain: s.Domain,
|
|
Targets: apiTargets,
|
|
Enabled: s.Enabled && !s.Terminated,
|
|
Terminated: &s.Terminated,
|
|
PassHostHeader: &s.PassHostHeader,
|
|
RewriteRedirects: &s.RewriteRedirects,
|
|
Auth: authConfig,
|
|
AccessRestrictions: restrictionsToAPI(s.Restrictions),
|
|
Meta: meta,
|
|
Mode: &mode,
|
|
ListenPort: &listenPort,
|
|
PortAutoAssigned: &s.PortAutoAssigned,
|
|
Private: &s.Private,
|
|
}
|
|
|
|
if len(s.AccessGroups) > 0 {
|
|
groups := append([]string(nil), s.AccessGroups...)
|
|
resp.AccessGroups = &groups
|
|
}
|
|
|
|
if s.ProxyCluster != "" {
|
|
resp.ProxyCluster = &s.ProxyCluster
|
|
}
|
|
|
|
return resp
|
|
}
|
|
|
|
// ToProtoMapping converts the service into the wire format the proxy consumes.
|
|
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
|
|
pathMappings := s.buildPathMappings()
|
|
|
|
auth := &proto.Authentication{
|
|
SessionKey: s.SessionPublicKey,
|
|
MaxSessionAgeSeconds: int64((time.Hour * 24).Seconds()),
|
|
}
|
|
|
|
if s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled {
|
|
auth.Password = true
|
|
}
|
|
|
|
if s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled {
|
|
auth.Pin = true
|
|
}
|
|
|
|
if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
|
|
auth.Oidc = true
|
|
}
|
|
|
|
for _, h := range s.Auth.HeaderAuths {
|
|
if h != nil && h.Enabled {
|
|
auth.HeaderAuths = append(auth.HeaderAuths, &proto.HeaderAuth{
|
|
Header: h.Header,
|
|
HashedValue: h.Value,
|
|
})
|
|
}
|
|
}
|
|
|
|
mapping := &proto.ProxyMapping{
|
|
Type: operationToProtoType(operation),
|
|
Id: s.ID,
|
|
Domain: s.Domain,
|
|
Path: pathMappings,
|
|
AuthToken: authToken,
|
|
Auth: auth,
|
|
AccountId: s.AccountID,
|
|
PassHostHeader: s.PassHostHeader,
|
|
RewriteRedirects: s.RewriteRedirects,
|
|
Mode: s.Mode,
|
|
ListenPort: int32(s.ListenPort), //nolint:gosec
|
|
Private: s.Private,
|
|
}
|
|
|
|
if r := restrictionsToProto(s.Restrictions); r != nil {
|
|
mapping.AccessRestrictions = r
|
|
}
|
|
|
|
return mapping
|
|
}
|
|
|
|
// buildPathMappings constructs PathMapping entries from targets.
|
|
// For HTTP/HTTPS, each target becomes a path-based route with a full URL.
|
|
// For L4/TLS, a single target maps to a host:port address.
|
|
func (s *Service) buildPathMappings() []*proto.PathMapping {
|
|
pathMappings := make([]*proto.PathMapping, 0, len(s.Targets))
|
|
for _, target := range s.Targets {
|
|
if !target.Enabled {
|
|
continue
|
|
}
|
|
|
|
if IsL4Protocol(s.Mode) {
|
|
pm := &proto.PathMapping{
|
|
Target: net.JoinHostPort(target.Host, strconv.FormatUint(uint64(target.Port), 10)),
|
|
}
|
|
opts := l4TargetOptionsToProto(target)
|
|
if opts != nil {
|
|
pm.Options = opts
|
|
}
|
|
pathMappings = append(pathMappings, pm)
|
|
continue
|
|
}
|
|
|
|
// HTTP/HTTPS: build full URL
|
|
hostNoBrackets := strings.TrimSuffix(strings.TrimPrefix(target.Host, "["), "]")
|
|
targetURL := url.URL{
|
|
Scheme: target.Protocol,
|
|
Host: bracketIPv6Host(hostNoBrackets),
|
|
Path: "/",
|
|
}
|
|
if target.Port > 0 && !isDefaultPort(target.Protocol, target.Port) {
|
|
targetURL.Host = net.JoinHostPort(hostNoBrackets, strconv.FormatUint(uint64(target.Port), 10))
|
|
}
|
|
|
|
path := "/"
|
|
if target.Path != nil {
|
|
path = *target.Path
|
|
}
|
|
|
|
pm := &proto.PathMapping{
|
|
Path: path,
|
|
Target: targetURL.String(),
|
|
}
|
|
pm.Options = targetOptionsToProto(target.Options)
|
|
pathMappings = append(pathMappings, pm)
|
|
}
|
|
return pathMappings
|
|
}
|
|
|
|
// bracketIPv6Host wraps host in square brackets when it is an IPv6 literal, as
|
|
// required for the Host field of net/url.URL (RFC 3986 §3.2.2). v4-mapped IPv6
|
|
// addresses are bracketed too since their textual form contains colons.
|
|
func bracketIPv6Host(host string) string {
|
|
if strings.HasPrefix(host, "[") {
|
|
return host
|
|
}
|
|
if addr, err := netip.ParseAddr(host); err == nil && addr.Is6() {
|
|
return "[" + host + "]"
|
|
}
|
|
return host
|
|
}
|
|
|
|
func operationToProtoType(op Operation) proto.ProxyMappingUpdateType {
|
|
switch op {
|
|
case Create:
|
|
return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
|
case Update:
|
|
return proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
|
case Delete:
|
|
return proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
|
default:
|
|
panic(fmt.Sprintf("unknown operation type: %v", op))
|
|
}
|
|
}
|
|
|
|
// isDefaultPort reports whether port is the standard default for the given scheme
|
|
// (443 for https, 80 for http).
|
|
func isDefaultPort(scheme string, port uint16) bool {
|
|
return (scheme == TargetProtoHTTPS && port == 443) || (scheme == TargetProtoHTTP && port == 80)
|
|
}
|
|
|
|
// PathRewriteMode controls how the request path is rewritten before forwarding.
|
|
type PathRewriteMode string
|
|
|
|
const (
|
|
PathRewritePreserve PathRewriteMode = "preserve"
|
|
)
|
|
|
|
func pathRewriteToProto(mode PathRewriteMode) proto.PathRewriteMode {
|
|
switch mode {
|
|
case PathRewritePreserve:
|
|
return proto.PathRewriteMode_PATH_REWRITE_PRESERVE
|
|
default:
|
|
return proto.PathRewriteMode_PATH_REWRITE_DEFAULT
|
|
}
|
|
}
|
|
|
|
func targetOptionsToAPI(opts TargetOptions) *api.ServiceTargetOptions {
|
|
if !opts.SkipTLSVerify && opts.RequestTimeout == 0 && opts.SessionIdleTimeout == 0 &&
|
|
opts.PathRewrite == "" && len(opts.CustomHeaders) == 0 && !opts.DirectUpstream {
|
|
return nil
|
|
}
|
|
apiOpts := &api.ServiceTargetOptions{}
|
|
if opts.SkipTLSVerify {
|
|
apiOpts.SkipTlsVerify = &opts.SkipTLSVerify
|
|
}
|
|
if opts.RequestTimeout != 0 {
|
|
s := opts.RequestTimeout.String()
|
|
apiOpts.RequestTimeout = &s
|
|
}
|
|
if opts.SessionIdleTimeout != 0 {
|
|
s := opts.SessionIdleTimeout.String()
|
|
apiOpts.SessionIdleTimeout = &s
|
|
}
|
|
if opts.PathRewrite != "" {
|
|
pr := api.ServiceTargetOptionsPathRewrite(opts.PathRewrite)
|
|
apiOpts.PathRewrite = &pr
|
|
}
|
|
if len(opts.CustomHeaders) > 0 {
|
|
apiOpts.CustomHeaders = &opts.CustomHeaders
|
|
}
|
|
if opts.DirectUpstream {
|
|
apiOpts.DirectUpstream = &opts.DirectUpstream
|
|
}
|
|
return apiOpts
|
|
}
|
|
|
|
func targetOptionsToProto(opts TargetOptions) *proto.PathTargetOptions {
|
|
if !opts.SkipTLSVerify && opts.PathRewrite == "" && opts.RequestTimeout == 0 &&
|
|
len(opts.CustomHeaders) == 0 && !opts.DirectUpstream &&
|
|
len(opts.Middlewares) == 0 && opts.CaptureMaxRequestBytes == 0 &&
|
|
opts.CaptureMaxResponseBytes == 0 && len(opts.CaptureContentTypes) == 0 &&
|
|
!opts.AgentNetwork && !opts.DisableAccessLog {
|
|
return nil
|
|
}
|
|
popts := &proto.PathTargetOptions{
|
|
SkipTlsVerify: opts.SkipTLSVerify,
|
|
PathRewrite: pathRewriteToProto(opts.PathRewrite),
|
|
CustomHeaders: opts.CustomHeaders,
|
|
DirectUpstream: opts.DirectUpstream,
|
|
AgentNetwork: opts.AgentNetwork,
|
|
DisableAccessLog: opts.DisableAccessLog,
|
|
}
|
|
if opts.RequestTimeout != 0 {
|
|
popts.RequestTimeout = durationpb.New(opts.RequestTimeout)
|
|
}
|
|
if len(opts.Middlewares) > 0 {
|
|
popts.Middlewares = middlewaresToProto(opts.Middlewares)
|
|
}
|
|
popts.CaptureMaxRequestBytes = opts.CaptureMaxRequestBytes
|
|
popts.CaptureMaxResponseBytes = opts.CaptureMaxResponseBytes
|
|
if len(opts.CaptureContentTypes) > 0 {
|
|
popts.CaptureContentTypes = append([]string(nil), opts.CaptureContentTypes...)
|
|
}
|
|
return popts
|
|
}
|
|
|
|
// middlewaresToProto converts the internal middleware slice to the proto
|
|
// representation sent to the proxy via the mapping stream.
|
|
func middlewaresToProto(in []MiddlewareConfig) []*proto.MiddlewareConfig {
|
|
out := make([]*proto.MiddlewareConfig, 0, len(in))
|
|
for _, m := range in {
|
|
pm := &proto.MiddlewareConfig{
|
|
Id: m.ID,
|
|
Enabled: m.Enabled,
|
|
Slot: middlewareSlotToProto(m.Slot),
|
|
ConfigJson: append([]byte(nil), m.ConfigJSON...),
|
|
CanMutate: m.CanMutate,
|
|
FailMode: middlewareFailModeToProto(m.FailMode),
|
|
}
|
|
if m.TimeoutMs > 0 {
|
|
pm.Timeout = durationpb.New(time.Duration(m.TimeoutMs) * time.Millisecond)
|
|
}
|
|
out = append(out, pm)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func middlewareSlotToProto(s MiddlewareSlot) proto.MiddlewareSlot {
|
|
switch s {
|
|
case MiddlewareSlotOnRequest:
|
|
return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST
|
|
case MiddlewareSlotOnResponse:
|
|
return proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE
|
|
case MiddlewareSlotTerminal:
|
|
return proto.MiddlewareSlot_MIDDLEWARE_SLOT_TERMINAL
|
|
default:
|
|
return proto.MiddlewareSlot_MIDDLEWARE_SLOT_UNSPECIFIED
|
|
}
|
|
}
|
|
|
|
func middlewareFailModeToProto(m MiddlewareFailMode) proto.MiddlewareConfig_FailMode {
|
|
if m == MiddlewareFailClosed {
|
|
return proto.MiddlewareConfig_FAIL_CLOSED
|
|
}
|
|
return proto.MiddlewareConfig_FAIL_OPEN
|
|
}
|
|
|
|
// l4TargetOptionsToProto converts L4-relevant target options to proto.
|
|
func l4TargetOptionsToProto(target *Target) *proto.PathTargetOptions {
|
|
if !target.ProxyProtocol && target.Options.RequestTimeout == 0 && target.Options.SessionIdleTimeout == 0 {
|
|
return nil
|
|
}
|
|
opts := &proto.PathTargetOptions{
|
|
ProxyProtocol: target.ProxyProtocol,
|
|
}
|
|
if target.Options.RequestTimeout > 0 {
|
|
opts.RequestTimeout = durationpb.New(target.Options.RequestTimeout)
|
|
}
|
|
if target.Options.SessionIdleTimeout > 0 {
|
|
opts.SessionIdleTimeout = durationpb.New(target.Options.SessionIdleTimeout)
|
|
}
|
|
return opts
|
|
}
|
|
|
|
func targetOptionsFromAPI(idx int, o *api.ServiceTargetOptions) (TargetOptions, error) {
|
|
var opts TargetOptions
|
|
if o.SkipTlsVerify != nil {
|
|
opts.SkipTLSVerify = *o.SkipTlsVerify
|
|
}
|
|
if o.RequestTimeout != nil {
|
|
d, err := time.ParseDuration(*o.RequestTimeout)
|
|
if err != nil {
|
|
return opts, fmt.Errorf("target %d: parse request_timeout %q: %w", idx, *o.RequestTimeout, err)
|
|
}
|
|
opts.RequestTimeout = d
|
|
}
|
|
if o.SessionIdleTimeout != nil {
|
|
d, err := time.ParseDuration(*o.SessionIdleTimeout)
|
|
if err != nil {
|
|
return opts, fmt.Errorf("target %d: parse session_idle_timeout %q: %w", idx, *o.SessionIdleTimeout, err)
|
|
}
|
|
opts.SessionIdleTimeout = d
|
|
}
|
|
if o.PathRewrite != nil {
|
|
opts.PathRewrite = PathRewriteMode(*o.PathRewrite)
|
|
}
|
|
if o.CustomHeaders != nil {
|
|
opts.CustomHeaders = *o.CustomHeaders
|
|
}
|
|
if o.DirectUpstream != nil {
|
|
opts.DirectUpstream = *o.DirectUpstream
|
|
}
|
|
return opts, nil
|
|
}
|
|
|
|
func (s *Service) FromAPIRequest(req *api.ServiceRequest, accountID string) error {
|
|
s.Name = req.Name
|
|
s.Domain = req.Domain
|
|
s.AccountID = accountID
|
|
|
|
if req.Mode != nil {
|
|
s.Mode = string(*req.Mode)
|
|
}
|
|
if req.ListenPort != nil {
|
|
s.ListenPort = uint16(*req.ListenPort) //nolint:gosec
|
|
}
|
|
if req.Private != nil {
|
|
s.Private = *req.Private
|
|
}
|
|
if req.AccessGroups != nil {
|
|
s.AccessGroups = append([]string(nil), *req.AccessGroups...)
|
|
} else {
|
|
s.AccessGroups = nil
|
|
}
|
|
|
|
targets, err := targetsFromAPI(accountID, req.Targets)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Targets = targets
|
|
s.Enabled = req.Enabled
|
|
|
|
if req.PassHostHeader != nil {
|
|
s.PassHostHeader = *req.PassHostHeader
|
|
}
|
|
if req.RewriteRedirects != nil {
|
|
s.RewriteRedirects = *req.RewriteRedirects
|
|
}
|
|
|
|
if req.Auth != nil {
|
|
s.Auth = authFromAPI(req.Auth)
|
|
}
|
|
|
|
if req.AccessRestrictions != nil {
|
|
restrictions, err := restrictionsFromAPI(req.AccessRestrictions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.Restrictions = restrictions
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Target, error) {
|
|
var apiTargets []api.ServiceTarget
|
|
if apiTargetsPtr != nil {
|
|
apiTargets = *apiTargetsPtr
|
|
}
|
|
|
|
targets := make([]*Target, 0, len(apiTargets))
|
|
for i, apiTarget := range apiTargets {
|
|
target := &Target{
|
|
AccountID: accountID,
|
|
Path: apiTarget.Path,
|
|
Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer
|
|
Protocol: string(apiTarget.Protocol),
|
|
TargetId: apiTarget.TargetId,
|
|
TargetType: TargetType(apiTarget.TargetType),
|
|
Enabled: apiTarget.Enabled,
|
|
}
|
|
if apiTarget.Host != nil {
|
|
target.Host = *apiTarget.Host
|
|
}
|
|
if apiTarget.Options != nil {
|
|
opts, err := targetOptionsFromAPI(i, apiTarget.Options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
target.Options = opts
|
|
if apiTarget.Options.ProxyProtocol != nil {
|
|
target.ProxyProtocol = *apiTarget.Options.ProxyProtocol
|
|
}
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func authFromAPI(reqAuth *api.ServiceAuthConfig) AuthConfig {
|
|
var auth AuthConfig
|
|
if reqAuth.PasswordAuth != nil {
|
|
auth.PasswordAuth = &PasswordAuthConfig{
|
|
Enabled: reqAuth.PasswordAuth.Enabled,
|
|
Password: reqAuth.PasswordAuth.Password,
|
|
}
|
|
}
|
|
if reqAuth.PinAuth != nil {
|
|
auth.PinAuth = &PINAuthConfig{
|
|
Enabled: reqAuth.PinAuth.Enabled,
|
|
Pin: reqAuth.PinAuth.Pin,
|
|
}
|
|
}
|
|
if reqAuth.BearerAuth != nil {
|
|
bearerAuth := &BearerAuthConfig{
|
|
Enabled: reqAuth.BearerAuth.Enabled,
|
|
}
|
|
if reqAuth.BearerAuth.DistributionGroups != nil {
|
|
bearerAuth.DistributionGroups = *reqAuth.BearerAuth.DistributionGroups
|
|
}
|
|
auth.BearerAuth = bearerAuth
|
|
}
|
|
if reqAuth.HeaderAuths != nil {
|
|
for _, h := range *reqAuth.HeaderAuths {
|
|
auth.HeaderAuths = append(auth.HeaderAuths, &HeaderAuthConfig{
|
|
Enabled: h.Enabled,
|
|
Header: h.Header,
|
|
Value: h.Value,
|
|
})
|
|
}
|
|
}
|
|
return auth
|
|
}
|
|
|
|
func restrictionsFromAPI(r *api.AccessRestrictions) (AccessRestrictions, error) {
|
|
if r == nil {
|
|
return AccessRestrictions{}, nil
|
|
}
|
|
var res AccessRestrictions
|
|
if r.AllowedCidrs != nil {
|
|
res.AllowedCIDRs = *r.AllowedCidrs
|
|
}
|
|
if r.BlockedCidrs != nil {
|
|
res.BlockedCIDRs = *r.BlockedCidrs
|
|
}
|
|
if r.AllowedCountries != nil {
|
|
res.AllowedCountries = *r.AllowedCountries
|
|
}
|
|
if r.BlockedCountries != nil {
|
|
res.BlockedCountries = *r.BlockedCountries
|
|
}
|
|
if r.CrowdsecMode != nil {
|
|
if !r.CrowdsecMode.Valid() {
|
|
return AccessRestrictions{}, fmt.Errorf("invalid crowdsec_mode %q", *r.CrowdsecMode)
|
|
}
|
|
res.CrowdSecMode = string(*r.CrowdsecMode)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
func restrictionsToAPI(r AccessRestrictions) *api.AccessRestrictions {
|
|
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
|
|
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
|
|
r.CrowdSecMode == "" {
|
|
return nil
|
|
}
|
|
res := &api.AccessRestrictions{}
|
|
if len(r.AllowedCIDRs) > 0 {
|
|
res.AllowedCidrs = &r.AllowedCIDRs
|
|
}
|
|
if len(r.BlockedCIDRs) > 0 {
|
|
res.BlockedCidrs = &r.BlockedCIDRs
|
|
}
|
|
if len(r.AllowedCountries) > 0 {
|
|
res.AllowedCountries = &r.AllowedCountries
|
|
}
|
|
if len(r.BlockedCountries) > 0 {
|
|
res.BlockedCountries = &r.BlockedCountries
|
|
}
|
|
if r.CrowdSecMode != "" {
|
|
mode := api.AccessRestrictionsCrowdsecMode(r.CrowdSecMode)
|
|
res.CrowdsecMode = &mode
|
|
}
|
|
return res
|
|
}
|
|
|
|
func restrictionsToProto(r AccessRestrictions) *proto.AccessRestrictions {
|
|
if len(r.AllowedCIDRs) == 0 && len(r.BlockedCIDRs) == 0 &&
|
|
len(r.AllowedCountries) == 0 && len(r.BlockedCountries) == 0 &&
|
|
r.CrowdSecMode == "" {
|
|
return nil
|
|
}
|
|
return &proto.AccessRestrictions{
|
|
AllowedCidrs: r.AllowedCIDRs,
|
|
BlockedCidrs: r.BlockedCIDRs,
|
|
AllowedCountries: r.AllowedCountries,
|
|
BlockedCountries: r.BlockedCountries,
|
|
CrowdsecMode: r.CrowdSecMode,
|
|
}
|
|
}
|
|
|
|
func (s *Service) Validate() error {
|
|
if s.Name == "" {
|
|
return errors.New("service name is required")
|
|
}
|
|
if len(s.Name) > 255 {
|
|
return errors.New("service name exceeds maximum length of 255 characters")
|
|
}
|
|
|
|
if len(s.Targets) == 0 {
|
|
return errors.New("at least one target is required")
|
|
}
|
|
|
|
if s.Mode == "" {
|
|
s.Mode = ModeHTTP
|
|
}
|
|
|
|
if err := validateHeaderAuths(s.Auth.HeaderAuths); err != nil {
|
|
return err
|
|
}
|
|
if err := validateAccessRestrictions(&s.Restrictions); err != nil {
|
|
return err
|
|
}
|
|
if err := s.validatePrivateRequirements(); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch s.Mode {
|
|
case ModeHTTP:
|
|
return s.validateHTTPMode()
|
|
case ModeTCP, ModeUDP:
|
|
return s.validateTCPUDPMode()
|
|
case ModeTLS:
|
|
return s.validateTLSMode()
|
|
default:
|
|
return fmt.Errorf("unsupported mode %q", s.Mode)
|
|
}
|
|
}
|
|
|
|
// validatePrivateRequirements enforces the private-service contract: HTTP mode, ≥1 access group, no bearer auth.
|
|
func (s *Service) validatePrivateRequirements() error {
|
|
if !s.Private {
|
|
return nil
|
|
}
|
|
if s.Mode != "" && s.Mode != ModeHTTP {
|
|
return fmt.Errorf("private services only support HTTP mode, got %q", s.Mode)
|
|
}
|
|
if len(s.AccessGroups) == 0 {
|
|
return errors.New("private services require at least one access group")
|
|
}
|
|
if s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled {
|
|
return errors.New("private services cannot enable bearer auth (SSO): NetBird-only access and SSO are mutually exclusive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) validateHTTPMode() error {
|
|
if s.Domain == "" {
|
|
return errors.New("service domain is required")
|
|
}
|
|
if s.ListenPort != 0 {
|
|
return errors.New("listen_port is not supported for HTTP services")
|
|
}
|
|
return s.validateHTTPTargets()
|
|
}
|
|
|
|
func (s *Service) validateTCPUDPMode() error {
|
|
if s.Domain == "" {
|
|
return errors.New("domain is required for TCP/UDP services (used for cluster derivation)")
|
|
}
|
|
if s.isAuthEnabled() {
|
|
return errors.New("auth is not supported for TCP/UDP services")
|
|
}
|
|
if len(s.Targets) != 1 {
|
|
return errors.New("TCP/UDP services must have exactly one target")
|
|
}
|
|
if s.Mode == ModeUDP && s.Targets[0].ProxyProtocol {
|
|
return errors.New("proxy_protocol is not supported for UDP services")
|
|
}
|
|
return s.validateL4Target(s.Targets[0])
|
|
}
|
|
|
|
func (s *Service) validateTLSMode() error {
|
|
if s.Domain == "" {
|
|
return errors.New("domain is required for TLS services (used for SNI matching)")
|
|
}
|
|
if s.isAuthEnabled() {
|
|
return errors.New("auth is not supported for TLS services")
|
|
}
|
|
if s.ListenPort == 0 {
|
|
return errors.New("listen_port is required for TLS services")
|
|
}
|
|
if len(s.Targets) != 1 {
|
|
return errors.New("TLS services must have exactly one target")
|
|
}
|
|
return s.validateL4Target(s.Targets[0])
|
|
}
|
|
|
|
func (s *Service) validateHTTPTargets() error {
|
|
for i, target := range s.Targets {
|
|
switch target.TargetType {
|
|
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
|
// Host is normally overwritten by replaceHostByLookup with the
|
|
// resolved peer IP / resource address; operator-supplied values
|
|
// are honored only when DirectUpstream is set. Validate the
|
|
// override here so misconfigured hosts fail fast at API time.
|
|
if err := validateDirectUpstreamHost(i, target); err != nil {
|
|
return err
|
|
}
|
|
case TargetTypeSubnet:
|
|
if target.Host == "" {
|
|
return fmt.Errorf("target %d has empty host but target_type is %q", i, target.TargetType)
|
|
}
|
|
case TargetTypeCluster:
|
|
if err := validateClusterTarget(i, target); err != nil {
|
|
return err
|
|
}
|
|
default:
|
|
return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType)
|
|
}
|
|
if target.TargetId == "" {
|
|
return fmt.Errorf("target %d has empty target_id", i)
|
|
}
|
|
if target.ProxyProtocol {
|
|
return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i)
|
|
}
|
|
if err := validateTargetOptions(i, &target.Options); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateClusterTarget cluster targets should not have empty hosts and should have direct upstream enabled.
|
|
func validateClusterTarget(idx int, target *Target) error {
|
|
host := strings.TrimSpace(target.Host)
|
|
if host == "" {
|
|
return fmt.Errorf("target %d: has empty host", idx)
|
|
}
|
|
if !target.Options.DirectUpstream {
|
|
return fmt.Errorf("target %d: %s has direct upstream disabled", idx, target.Host)
|
|
}
|
|
return validateDirectUpstreamHost(idx, target)
|
|
}
|
|
|
|
// validateDirectUpstreamHost validates the operator-supplied Host on a
|
|
// peer/host/domain target when DirectUpstream is set. Empty Host is
|
|
// allowed — the lookup fills in the default peer IP / resource address.
|
|
// Without DirectUpstream the Host value is silently overwritten by
|
|
// replaceHostByLookup, so we don't validate it (preserves the historical
|
|
// behaviour where APIs accepted any value and dropped it). Non-empty
|
|
// Host with DirectUpstream must look like a hostname or IP and must
|
|
// not carry a port (port lives on Target.Port).
|
|
func validateDirectUpstreamHost(idx int, target *Target) error {
|
|
if !target.Options.DirectUpstream {
|
|
return nil
|
|
}
|
|
host := strings.TrimSpace(target.Host)
|
|
if host == "" {
|
|
return nil
|
|
}
|
|
if strings.ContainsAny(host, " \t/") {
|
|
return fmt.Errorf("target %d: host %q contains invalid characters", idx, host)
|
|
}
|
|
if _, _, err := net.SplitHostPort(host); err == nil {
|
|
return fmt.Errorf("target %d: host %q must not include a port (set target.port instead)", idx, host)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) validateL4Target(target *Target) error {
|
|
// L4 services have a single target; per-target disable is meaningless
|
|
// (use the service-level Enabled flag instead). Force it on so that
|
|
// buildPathMappings always includes the target in the proto.
|
|
target.Enabled = true
|
|
|
|
if target.TargetId == "" {
|
|
return errors.New("target_id is required for L4 services")
|
|
}
|
|
// Cluster targets resolve their upstream host:port from the target's
|
|
// own Host/Port fields just like the other L4 types — buildPathMappings
|
|
// emits net.JoinHostPort(target.Host, target.Port) for every L4
|
|
// target, so allowing port=0 here would let ":0" reach the proxy.
|
|
if target.Port == 0 {
|
|
return errors.New("target port is required for L4 services")
|
|
}
|
|
switch target.TargetType {
|
|
case TargetTypePeer, TargetTypeHost, TargetTypeDomain:
|
|
if err := validateDirectUpstreamHost(0, target); err != nil {
|
|
return err
|
|
}
|
|
case TargetTypeSubnet:
|
|
if target.Host == "" {
|
|
return errors.New("target host is required for subnet targets")
|
|
}
|
|
case TargetTypeCluster:
|
|
// target_id carries the cluster address; the proxy resolves
|
|
// the upstream at request time.
|
|
default:
|
|
return fmt.Errorf("invalid target_type %q for L4 service", target.TargetType)
|
|
}
|
|
if target.Path != nil && *target.Path != "" && *target.Path != "/" {
|
|
return errors.New("path is not supported for L4 services")
|
|
}
|
|
if target.Options.SessionIdleTimeout < 0 {
|
|
return errors.New("session_idle_timeout must be positive for L4 services")
|
|
}
|
|
if target.Options.RequestTimeout < 0 {
|
|
return errors.New("request_timeout must be positive for L4 services")
|
|
}
|
|
if target.Options.SkipTLSVerify {
|
|
return errors.New("skip_tls_verify is not supported for L4 services")
|
|
}
|
|
if target.Options.PathRewrite != "" {
|
|
return errors.New("path_rewrite is not supported for L4 services")
|
|
}
|
|
if len(target.Options.CustomHeaders) > 0 {
|
|
return errors.New("custom_headers is not supported for L4 services")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Service mode constants.
|
|
const (
|
|
ModeHTTP = "http"
|
|
ModeTCP = "tcp"
|
|
ModeUDP = "udp"
|
|
ModeTLS = "tls"
|
|
)
|
|
|
|
// Target protocol constants (URL scheme for backend connections).
|
|
const (
|
|
TargetProtoHTTP = "http"
|
|
TargetProtoHTTPS = "https"
|
|
TargetProtoTCP = "tcp"
|
|
TargetProtoUDP = "udp"
|
|
)
|
|
|
|
// IsL4Protocol returns true if the mode requires port-based routing (TCP, UDP, or TLS).
|
|
func IsL4Protocol(mode string) bool {
|
|
return mode == ModeTCP || mode == ModeUDP || mode == ModeTLS
|
|
}
|
|
|
|
// IsPortBasedProtocol returns true if the mode relies on dedicated port allocation.
|
|
// TLS is excluded because it uses SNI routing and can share ports with other TLS services.
|
|
func IsPortBasedProtocol(mode string) bool {
|
|
return mode == ModeTCP || mode == ModeUDP
|
|
}
|
|
|
|
const (
|
|
maxCustomHeaders = 16
|
|
maxHeaderKeyLen = 128
|
|
maxHeaderValueLen = 4096
|
|
)
|
|
|
|
// httpHeaderNameRe matches valid HTTP header field names per RFC 7230 token definition.
|
|
var httpHeaderNameRe = regexp.MustCompile(`^[!#$%&'*+\-.^_` + "`" + `|~0-9A-Za-z]+$`)
|
|
|
|
// hopByHopHeaders are headers that must not be set as custom headers
|
|
// because they are connection-level and stripped by the proxy.
|
|
var hopByHopHeaders = map[string]struct{}{
|
|
"Connection": {},
|
|
"Keep-Alive": {},
|
|
"Proxy-Authenticate": {},
|
|
"Proxy-Authorization": {},
|
|
"Proxy-Connection": {},
|
|
"Te": {},
|
|
"Trailer": {},
|
|
"Transfer-Encoding": {},
|
|
"Upgrade": {},
|
|
}
|
|
|
|
// reservedHeaders are set authoritatively by the proxy or control HTTP framing
|
|
// and cannot be overridden.
|
|
var reservedHeaders = map[string]struct{}{
|
|
"Content-Length": {},
|
|
"Content-Type": {},
|
|
"Cookie": {},
|
|
"Forwarded": {},
|
|
"X-Forwarded-For": {},
|
|
"X-Forwarded-Host": {},
|
|
"X-Forwarded-Port": {},
|
|
"X-Forwarded-Proto": {},
|
|
"X-Real-Ip": {},
|
|
}
|
|
|
|
func validateTargetOptions(idx int, opts *TargetOptions) error {
|
|
if opts.PathRewrite != "" && opts.PathRewrite != PathRewritePreserve {
|
|
return fmt.Errorf("target %d: unknown path_rewrite mode %q", idx, opts.PathRewrite)
|
|
}
|
|
|
|
if opts.RequestTimeout < 0 {
|
|
return fmt.Errorf("target %d: request_timeout must be positive", idx)
|
|
}
|
|
|
|
if opts.SessionIdleTimeout < 0 {
|
|
return fmt.Errorf("target %d: session_idle_timeout must be positive", idx)
|
|
}
|
|
|
|
if err := validateCustomHeaders(idx, opts.CustomHeaders); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func validateCustomHeaders(idx int, headers map[string]string) error {
|
|
if len(headers) > maxCustomHeaders {
|
|
return fmt.Errorf("target %d: custom_headers count %d exceeds maximum of %d", idx, len(headers), maxCustomHeaders)
|
|
}
|
|
seen := make(map[string]string, len(headers))
|
|
for key, value := range headers {
|
|
if !httpHeaderNameRe.MatchString(key) {
|
|
return fmt.Errorf("target %d: custom header key %q is not a valid HTTP header name", idx, key)
|
|
}
|
|
if len(key) > maxHeaderKeyLen {
|
|
return fmt.Errorf("target %d: custom header key %q exceeds maximum length of %d", idx, key, maxHeaderKeyLen)
|
|
}
|
|
if len(value) > maxHeaderValueLen {
|
|
return fmt.Errorf("target %d: custom header %q value exceeds maximum length of %d", idx, key, maxHeaderValueLen)
|
|
}
|
|
if containsCRLF(key) || containsCRLF(value) {
|
|
return fmt.Errorf("target %d: custom header %q contains invalid characters", idx, key)
|
|
}
|
|
canonical := http.CanonicalHeaderKey(key)
|
|
if prev, ok := seen[canonical]; ok {
|
|
return fmt.Errorf("target %d: custom header keys %q and %q collide (both canonicalize to %q)", idx, prev, key, canonical)
|
|
}
|
|
seen[canonical] = key
|
|
if _, ok := hopByHopHeaders[canonical]; ok {
|
|
return fmt.Errorf("target %d: custom header %q is a hop-by-hop header and cannot be set", idx, key)
|
|
}
|
|
if _, ok := reservedHeaders[canonical]; ok {
|
|
return fmt.Errorf("target %d: custom header %q is managed by the proxy and cannot be overridden", idx, key)
|
|
}
|
|
if canonical == "Host" {
|
|
return fmt.Errorf("target %d: use pass_host_header instead of setting Host as a custom header", idx)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsCRLF(s string) bool {
|
|
return strings.ContainsAny(s, "\r\n")
|
|
}
|
|
|
|
func validateHeaderAuths(headers []*HeaderAuthConfig) error {
|
|
for i, h := range headers {
|
|
if h == nil || !h.Enabled {
|
|
continue
|
|
}
|
|
if h.Header == "" {
|
|
return fmt.Errorf("header_auths[%d]: header name is required", i)
|
|
}
|
|
if !httpHeaderNameRe.MatchString(h.Header) {
|
|
return fmt.Errorf("header_auths[%d]: header name %q is not a valid HTTP header name", i, h.Header)
|
|
}
|
|
canonical := http.CanonicalHeaderKey(h.Header)
|
|
if _, ok := hopByHopHeaders[canonical]; ok {
|
|
return fmt.Errorf("header_auths[%d]: header %q is a hop-by-hop header and cannot be used for auth", i, h.Header)
|
|
}
|
|
if _, ok := reservedHeaders[canonical]; ok {
|
|
return fmt.Errorf("header_auths[%d]: header %q is managed by the proxy and cannot be used for auth", i, h.Header)
|
|
}
|
|
if canonical == "Host" {
|
|
return fmt.Errorf("header_auths[%d]: Host header cannot be used for auth", i)
|
|
}
|
|
if len(h.Value) > maxHeaderValueLen {
|
|
return fmt.Errorf("header_auths[%d]: value exceeds maximum length of %d", i, maxHeaderValueLen)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
maxCIDREntries = 200
|
|
maxCountryEntries = 50
|
|
)
|
|
|
|
// validateAccessRestrictions validates and normalizes access restriction
|
|
// entries. Country codes are uppercased in place.
|
|
func validateCrowdSecMode(mode string) error {
|
|
switch mode {
|
|
case "", "off", "enforce", "observe":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("crowdsec_mode %q is invalid", mode)
|
|
}
|
|
}
|
|
|
|
func validateAccessRestrictions(r *AccessRestrictions) error {
|
|
if err := validateCrowdSecMode(r.CrowdSecMode); err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(r.AllowedCIDRs) > maxCIDREntries {
|
|
return fmt.Errorf("allowed_cidrs: exceeds maximum of %d entries", maxCIDREntries)
|
|
}
|
|
if len(r.BlockedCIDRs) > maxCIDREntries {
|
|
return fmt.Errorf("blocked_cidrs: exceeds maximum of %d entries", maxCIDREntries)
|
|
}
|
|
if len(r.AllowedCountries) > maxCountryEntries {
|
|
return fmt.Errorf("allowed_countries: exceeds maximum of %d entries", maxCountryEntries)
|
|
}
|
|
if len(r.BlockedCountries) > maxCountryEntries {
|
|
return fmt.Errorf("blocked_countries: exceeds maximum of %d entries", maxCountryEntries)
|
|
}
|
|
|
|
if err := validateCIDRList("allowed_cidrs", r.AllowedCIDRs); err != nil {
|
|
return err
|
|
}
|
|
if err := validateCIDRList("blocked_cidrs", r.BlockedCIDRs); err != nil {
|
|
return err
|
|
}
|
|
if err := normalizeCountryList("allowed_countries", r.AllowedCountries); err != nil {
|
|
return err
|
|
}
|
|
return normalizeCountryList("blocked_countries", r.BlockedCountries)
|
|
}
|
|
|
|
func validateCIDRList(field string, cidrs []string) error {
|
|
for i, raw := range cidrs {
|
|
prefix, err := netip.ParsePrefix(raw)
|
|
if err != nil {
|
|
return fmt.Errorf("%s[%d]: %w", field, i, err)
|
|
}
|
|
if prefix != prefix.Masked() {
|
|
return fmt.Errorf("%s[%d]: %q has host bits set, use %s instead", field, i, raw, prefix.Masked())
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeCountryList(field string, codes []string) error {
|
|
for i, code := range codes {
|
|
if len(code) != 2 {
|
|
return fmt.Errorf("%s[%d]: %q must be a 2-letter ISO 3166-1 alpha-2 code", field, i, code)
|
|
}
|
|
codes[i] = strings.ToUpper(code)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) EventMeta() map[string]any {
|
|
meta := map[string]any{
|
|
"name": s.Name,
|
|
"domain": s.Domain,
|
|
"proxy_cluster": s.ProxyCluster,
|
|
"source": s.Source,
|
|
"auth": s.isAuthEnabled(),
|
|
"mode": s.Mode,
|
|
}
|
|
|
|
if s.ListenPort != 0 {
|
|
meta["listen_port"] = s.ListenPort
|
|
}
|
|
|
|
if len(s.Targets) > 0 {
|
|
t := s.Targets[0]
|
|
if t.ProxyProtocol {
|
|
meta["proxy_protocol"] = true
|
|
}
|
|
if t.Options.RequestTimeout != 0 {
|
|
meta["request_timeout"] = t.Options.RequestTimeout.String()
|
|
}
|
|
if t.Options.SessionIdleTimeout != 0 {
|
|
meta["session_idle_timeout"] = t.Options.SessionIdleTimeout.String()
|
|
}
|
|
}
|
|
|
|
return meta
|
|
}
|
|
|
|
func (s *Service) isAuthEnabled() bool {
|
|
if (s.Auth.PasswordAuth != nil && s.Auth.PasswordAuth.Enabled) ||
|
|
(s.Auth.PinAuth != nil && s.Auth.PinAuth.Enabled) ||
|
|
(s.Auth.BearerAuth != nil && s.Auth.BearerAuth.Enabled) {
|
|
return true
|
|
}
|
|
for _, h := range s.Auth.HeaderAuths {
|
|
if h != nil && h.Enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Service) Copy() *Service {
|
|
targets := make([]*Target, len(s.Targets))
|
|
for i, target := range s.Targets {
|
|
targetCopy := *target
|
|
if target.Path != nil {
|
|
p := *target.Path
|
|
targetCopy.Path = &p
|
|
}
|
|
if len(target.Options.CustomHeaders) > 0 {
|
|
targetCopy.Options.CustomHeaders = make(map[string]string, len(target.Options.CustomHeaders))
|
|
for k, v := range target.Options.CustomHeaders {
|
|
targetCopy.Options.CustomHeaders[k] = v
|
|
}
|
|
}
|
|
targets[i] = &targetCopy
|
|
}
|
|
|
|
authCopy := s.Auth
|
|
if s.Auth.PasswordAuth != nil {
|
|
pa := *s.Auth.PasswordAuth
|
|
authCopy.PasswordAuth = &pa
|
|
}
|
|
if s.Auth.PinAuth != nil {
|
|
pa := *s.Auth.PinAuth
|
|
authCopy.PinAuth = &pa
|
|
}
|
|
if s.Auth.BearerAuth != nil {
|
|
ba := *s.Auth.BearerAuth
|
|
if len(s.Auth.BearerAuth.DistributionGroups) > 0 {
|
|
ba.DistributionGroups = make([]string, len(s.Auth.BearerAuth.DistributionGroups))
|
|
copy(ba.DistributionGroups, s.Auth.BearerAuth.DistributionGroups)
|
|
}
|
|
authCopy.BearerAuth = &ba
|
|
}
|
|
if len(s.Auth.HeaderAuths) > 0 {
|
|
authCopy.HeaderAuths = make([]*HeaderAuthConfig, len(s.Auth.HeaderAuths))
|
|
for i, h := range s.Auth.HeaderAuths {
|
|
if h == nil {
|
|
continue
|
|
}
|
|
hCopy := *h
|
|
authCopy.HeaderAuths[i] = &hCopy
|
|
}
|
|
}
|
|
|
|
var accessGroups []string
|
|
if len(s.AccessGroups) > 0 {
|
|
accessGroups = append([]string(nil), s.AccessGroups...)
|
|
}
|
|
|
|
return &Service{
|
|
ID: s.ID,
|
|
AccountID: s.AccountID,
|
|
Name: s.Name,
|
|
Domain: s.Domain,
|
|
ProxyCluster: s.ProxyCluster,
|
|
Targets: targets,
|
|
Enabled: s.Enabled,
|
|
Terminated: s.Terminated,
|
|
PassHostHeader: s.PassHostHeader,
|
|
RewriteRedirects: s.RewriteRedirects,
|
|
Auth: authCopy,
|
|
Restrictions: s.Restrictions.Copy(),
|
|
Meta: s.Meta,
|
|
SessionPrivateKey: s.SessionPrivateKey,
|
|
SessionPublicKey: s.SessionPublicKey,
|
|
Source: s.Source,
|
|
SourcePeer: s.SourcePeer,
|
|
Mode: s.Mode,
|
|
ListenPort: s.ListenPort,
|
|
PortAutoAssigned: s.PortAutoAssigned,
|
|
Private: s.Private,
|
|
AccessGroups: accessGroups,
|
|
}
|
|
}
|
|
|
|
func (s *Service) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
|
if enc == nil {
|
|
return nil
|
|
}
|
|
|
|
if s.SessionPrivateKey != "" {
|
|
var err error
|
|
s.SessionPrivateKey, err = enc.Encrypt(s.SessionPrivateKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
|
if enc == nil {
|
|
return nil
|
|
}
|
|
|
|
if s.SessionPrivateKey != "" {
|
|
var err error
|
|
s.SessionPrivateKey, err = enc.Decrypt(s.SessionPrivateKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
|
|
|
|
const alphanumCharset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
|
|
var validNamePrefix = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)
|
|
|
|
// ExposeServiceRequest contains the parameters for creating a peer-initiated expose service.
|
|
type ExposeServiceRequest struct {
|
|
NamePrefix string
|
|
Port uint16
|
|
Mode string
|
|
// TargetProtocol is the protocol used to connect to the peer backend.
|
|
// For HTTP mode: "http" (default) or "https". For L4 modes: "tcp" or "udp".
|
|
TargetProtocol string
|
|
Domain string
|
|
Pin string
|
|
Password string
|
|
UserGroups []string
|
|
ListenPort uint16
|
|
}
|
|
|
|
// Validate checks all fields of the expose request.
|
|
func (r *ExposeServiceRequest) Validate() error {
|
|
if r == nil {
|
|
return errors.New("request cannot be nil")
|
|
}
|
|
|
|
if r.Port == 0 {
|
|
return fmt.Errorf("port must be between 1 and 65535, got %d", r.Port)
|
|
}
|
|
|
|
switch r.Mode {
|
|
case ModeHTTP, ModeTCP, ModeUDP, ModeTLS:
|
|
default:
|
|
return fmt.Errorf("unsupported mode %q", r.Mode)
|
|
}
|
|
|
|
if IsL4Protocol(r.Mode) {
|
|
if r.Pin != "" || r.Password != "" || len(r.UserGroups) > 0 {
|
|
return fmt.Errorf("authentication is not supported for %s mode", r.Mode)
|
|
}
|
|
}
|
|
|
|
if r.Pin != "" && !pinRegexp.MatchString(r.Pin) {
|
|
return errors.New("invalid pin: must be exactly 6 digits")
|
|
}
|
|
|
|
for _, g := range r.UserGroups {
|
|
if g == "" {
|
|
return errors.New("user group name cannot be empty")
|
|
}
|
|
}
|
|
|
|
if r.NamePrefix != "" && !validNamePrefix.MatchString(r.NamePrefix) {
|
|
return fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", r.NamePrefix)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToService builds a Service from the expose request.
|
|
func (r *ExposeServiceRequest) ToService(accountID, peerID, serviceName string) *Service {
|
|
svc := &Service{
|
|
AccountID: accountID,
|
|
Name: serviceName,
|
|
Mode: r.Mode,
|
|
Enabled: true,
|
|
}
|
|
|
|
// If domain is empty, CreateServiceFromPeer generates a unique subdomain.
|
|
// When explicitly provided, the service name is prepended as a subdomain.
|
|
if r.Domain != "" {
|
|
svc.Domain = serviceName + "." + r.Domain
|
|
}
|
|
|
|
if IsL4Protocol(r.Mode) {
|
|
svc.ListenPort = r.Port
|
|
if r.ListenPort > 0 {
|
|
svc.ListenPort = r.ListenPort
|
|
}
|
|
}
|
|
|
|
var targetProto string
|
|
switch {
|
|
case !IsL4Protocol(r.Mode):
|
|
targetProto = TargetProtoHTTP
|
|
if r.TargetProtocol != "" {
|
|
targetProto = r.TargetProtocol
|
|
}
|
|
case r.Mode == ModeUDP:
|
|
targetProto = TargetProtoUDP
|
|
default:
|
|
targetProto = TargetProtoTCP
|
|
}
|
|
svc.Targets = []*Target{
|
|
{
|
|
AccountID: accountID,
|
|
Port: r.Port,
|
|
Protocol: targetProto,
|
|
TargetId: peerID,
|
|
TargetType: TargetTypePeer,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
if r.Pin != "" {
|
|
svc.Auth.PinAuth = &PINAuthConfig{
|
|
Enabled: true,
|
|
Pin: r.Pin,
|
|
}
|
|
}
|
|
|
|
if r.Password != "" {
|
|
svc.Auth.PasswordAuth = &PasswordAuthConfig{
|
|
Enabled: true,
|
|
Password: r.Password,
|
|
}
|
|
}
|
|
|
|
if len(r.UserGroups) > 0 {
|
|
svc.Auth.BearerAuth = &BearerAuthConfig{
|
|
Enabled: true,
|
|
DistributionGroups: r.UserGroups,
|
|
}
|
|
}
|
|
|
|
return svc
|
|
}
|
|
|
|
// ExposeServiceResponse contains the result of a successful peer expose creation.
|
|
type ExposeServiceResponse struct {
|
|
ServiceName string
|
|
ServiceURL string
|
|
Domain string
|
|
PortAutoAssigned bool
|
|
}
|
|
|
|
// GenerateExposeName generates a random service name for peer-exposed services.
|
|
// The prefix, if provided, must be a valid DNS label component (lowercase alphanumeric and hyphens).
|
|
func GenerateExposeName(prefix string) (string, error) {
|
|
if prefix != "" && !validNamePrefix.MatchString(prefix) {
|
|
return "", fmt.Errorf("invalid name prefix %q: must be lowercase alphanumeric with optional hyphens, 1-32 characters", prefix)
|
|
}
|
|
|
|
suffixLen := 12
|
|
if prefix != "" {
|
|
suffixLen = 4
|
|
}
|
|
|
|
suffix, err := randomAlphanumeric(suffixLen)
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate random name: %w", err)
|
|
}
|
|
|
|
if prefix == "" {
|
|
return suffix, nil
|
|
}
|
|
return prefix + "-" + suffix, nil
|
|
}
|
|
|
|
func randomAlphanumeric(n int) (string, error) {
|
|
result := make([]byte, n)
|
|
charsetLen := big.NewInt(int64(len(alphanumCharset)))
|
|
for i := range result {
|
|
idx, err := rand.Int(rand.Reader, charsetLen)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
result[i] = alphanumCharset[idx.Int64()]
|
|
}
|
|
return string(result), nil
|
|
}
|