mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-17 12:09:58 +00:00
* fix(proxy): gate tunnel-peer fast-path on inbound listener marker
forwardWithTunnelPeer previously accepted any RFC1918 / ULA / CGNAT
source IP, so a public client whose address happened to fall in those
ranges could bypass the configured operator auth scheme by colliding
with a known tunnel IP. The fast-path is now gated on
TunnelLookupFromContext(r.Context()) being present — that context value
is attached only by the per-account inbound (overlay) listener, so the
host-facing listener never enters this branch.
Tests updated to reflect the new requirement: requests that don't
carry the inbound marker now fall through to the regular auth flow.
* fix(proxy): harden inbound listener resource + startup-ctx handling
Three correctness fixes on the per-account inbound path, with tests:
- Close the logrus ErrorLog PipeWriter on tearDown. WriterLevel hands
back an *io.PipeWriter backed by a pipe + scanner goroutine that the
caller owns; the two writers per account (https + plain) were never
closed, leaking the pipe and goroutine on every teardown.
- Run the post-Start hooks on context.Background(). runClientStartup
is launched in a goroutine from AddPeer and was inheriting the
caller's request-scoped ctx, so a cancelled request could abort the
inbound bring-up or fail the management status notification. The
tail is split into notifyClientReady so the contract is testable.
Tests cover the PipeWriter close behaviour and assert the readyHandler
+ NotifyStatus calls receive a non-cancelled background context.
* feat(proxy): short-circuit peer-own-target loops with 421
When a peer that hosts the target of a private service dials its own
service URL the request was being looped through the proxy and back
over WireGuard to the same peer — twice the WG round-trip for no
benefit, with no signal to the caller that something was wrong.
Add isSelfTargetLoop to ReverseProxy.ServeHTTP: when the request
arrived on the per-account overlay listener (IsOverlayOrigin) and the
source tunnel IP matches the target host, refuse the request with 421
Misdirected Request and a body pointing the operator at the backend
directly.
The gate is scoped to overlay origin so requests on the public
listener that happen to share a source IP with the target host are
forwarded normally.
* fix(management): private-service validation + tunnel-IP lookup semantics
- Require an explicit port for L4 cluster targets. validateL4Target
exempted TargetTypeCluster from the port check, but buildPathMappings
serializes every L4 target via net.JoinHostPort(host, port) — port=0
shipped a ":0" upstream. Cluster targets use the same Host/Port
fields, so the same requirement applies.
- GetPeerByIP returns NotFound on a tunnel-IP miss instead of mapping
every error to Internal. The proxy's ValidateTunnelPeer probes IPs
that legitimately aren't in the roster; the miss is expected and now
distinguishable from a real store failure.
- Thread ctx into getClusterCapability's gorm query so a cancelled
request doesn't keep the store busy.
Tests updated for the L4-cluster port requirement and the GetPeerByIP
NotFound path.
* fix(client): include offlinePeers in PeerStateByIP lookup
ReplaceOfflinePeers moves peers into d.offlinePeers but PeerStateByIP
only scanned d.peers. Callers (the local DNS filter via
localPeerConnectivity, embed.Client.IdentityForIP used by the
proxy's tunnel-peer validator) were treating known-but-offline peers
as unknown, which:
- causes the DNS filter to keep returning records pointing at peers
that have no live tunnel, AND
- makes the proxy's local-roster check deny a request from such a
peer rather than letting the cached management RPC carry the
authorisation decision.
Search both slices in PeerStateByIP. Adds a unit test for the IPv4
and IPv6 offline-match paths.
* fix(rest): reject empty Delete path params in reverse-proxy clients
ReverseProxyClustersAPI.Delete and ReverseProxyTokensAPI.Delete passed
the path parameter into url.PathEscape without an empty check.
PathEscape("") returns "" which collapses the request onto the
collection endpoint ("/api/reverse-proxies/clusters/" /
"/api/reverse-proxies/proxy-tokens/"), so a caller bug delete with no
id reached a routable URL with surprising semantics (typically 405).
Short-circuit with a typed error before the request is built. Tests
mount a handler on the collection path that fails the test if hit, so
the regression is impossible to reintroduce silently.
* chore(api,ci,docs,test): private-service schema, proto-check, fixups
Non-functional cleanups and contract/CI hardening around the
private-service work:
API schema (openapi.yml):
- Require a non-empty access_groups and mode=http when private=true,
on both Service and ServiceRequest, mirroring
validatePrivateRequirements. mode stays optional-but-constrained
(empty defaults to http server-side), matching runtime.
CI (proto-version-check.yml):
- Cover renamed .pb.go files (read base via previous_filename).
- Match protoc-gen-go-grpc version headers (optional "- " prefix and
-gen-go-grpc suffix) so grpc-generated files are in scope.
Docs / comments:
- Reword Config field docs to say defaults are applied at Server.Start
(initDefaults), not New.
- Rename the obsolete --private-inbound flag to --private across
comments and the proto doc.
Pre-existing test fixups surfaced by review:
- Repair the integration-tagged validate_session_test.go (SignToken
signature growth + new Manager interface methods).
- Fix the CI-skip boolean precedence so Windows isn't skipped
unconditionally.
- Guard the router.HTTPListener type assertion with comma-ok.
* fix(proxy): background ctx for already-started AddPeer notification
The earlier ctx fix covered the async runClientStartup path but missed
the synchronous branch: when a service is added to an already-started
client, AddPeer called NotifyStatus with the caller's request-scoped
ctx. A cancelled request/stream could drop the connected notification
to management. Use context.Background() here too, matching
notifyClientReady.
Extends TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus to
pass a pre-cancelled caller ctx and assert the notification still ran
on a non-cancelled context.
* use the cmd context for roundtripper
543 lines
19 KiB
Go
543 lines
19 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httputil"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/proxy/auth"
|
|
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
|
|
"github.com/netbirdio/netbird/proxy/internal/types"
|
|
"github.com/netbirdio/netbird/proxy/web"
|
|
)
|
|
|
|
type ReverseProxy struct {
|
|
transport http.RoundTripper
|
|
// forwardedProto overrides the X-Forwarded-Proto header value.
|
|
// Valid values: "auto" (detect from TLS), "http", "https".
|
|
forwardedProto string
|
|
// trustedProxies is a list of IP prefixes for trusted upstream proxies.
|
|
// When the direct connection comes from a trusted proxy, forwarding
|
|
// headers are preserved and appended to instead of being stripped.
|
|
trustedProxies []netip.Prefix
|
|
mappingsMux sync.RWMutex
|
|
mappings map[string]Mapping
|
|
logger *log.Logger
|
|
}
|
|
|
|
// NewReverseProxy configures a new NetBird ReverseProxy.
|
|
// This is a wrapper around an httputil.ReverseProxy set
|
|
// to dynamically route requests based on internal mapping
|
|
// between requested URLs and targets.
|
|
// The internal mappings can be modified using the AddMapping
|
|
// and RemoveMapping functions.
|
|
func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trustedProxies []netip.Prefix, logger *log.Logger) *ReverseProxy {
|
|
if logger == nil {
|
|
logger = log.StandardLogger()
|
|
}
|
|
return &ReverseProxy{
|
|
transport: transport,
|
|
forwardedProto: forwardedProto,
|
|
trustedProxies: trustedProxies,
|
|
mappings: make(map[string]Mapping),
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
result, exists := p.findTargetForRequest(r)
|
|
if !exists {
|
|
if cd := CapturedDataFromContext(r.Context()); cd != nil {
|
|
cd.SetOrigin(OriginNoRoute)
|
|
}
|
|
requestID := getRequestID(r)
|
|
web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found",
|
|
"The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.",
|
|
requestID, web.ErrorStatus{Proxy: true, Destination: false})
|
|
return
|
|
}
|
|
|
|
// Loop guard for private services: a peer that hosts the target
|
|
// dialing its own service URL would round-trip its own traffic
|
|
// through the proxy and back over WG to itself. Refuse the request
|
|
// with 421 (Misdirected Request) so the caller sees an explicit
|
|
// error instead of silently doubling tunnel traffic.
|
|
if p.isSelfTargetLoop(r, result.target.URL) {
|
|
if cd := CapturedDataFromContext(r.Context()); cd != nil {
|
|
cd.SetOrigin(OriginNoRoute)
|
|
}
|
|
requestID := getRequestID(r)
|
|
web.ServeErrorPage(w, r, http.StatusMisdirectedRequest, "Loop Detected",
|
|
"This peer is the target of the requested service. Reach the backend directly instead of dialing the public service URL from the same machine.",
|
|
requestID, web.ErrorStatus{Proxy: true, Destination: false})
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
// Set the account ID in the context for the roundtripper to use.
|
|
ctx = roundtrip.WithAccountID(ctx, result.accountID)
|
|
|
|
// Populate captured data if it exists (allows middleware to read after handler completes).
|
|
// This solves the problem of passing data UP the middleware chain: we put a mutable struct
|
|
// pointer in the context, and mutate the struct here so outer middleware can read it.
|
|
if capturedData := CapturedDataFromContext(ctx); capturedData != nil {
|
|
capturedData.SetServiceID(result.serviceID)
|
|
capturedData.SetAccountID(result.accountID)
|
|
}
|
|
|
|
pt := result.target
|
|
|
|
if pt.SkipTLSVerify {
|
|
ctx = roundtrip.WithSkipTLSVerify(ctx)
|
|
}
|
|
if pt.RequestTimeout > 0 {
|
|
ctx = types.WithDialTimeout(ctx, pt.RequestTimeout)
|
|
}
|
|
if pt.DirectUpstream {
|
|
ctx = roundtrip.WithDirectUpstream(ctx)
|
|
}
|
|
|
|
rewriteMatchedPath := result.matchedPath
|
|
if pt.PathRewrite == PathRewritePreserve {
|
|
rewriteMatchedPath = ""
|
|
}
|
|
|
|
rp := &httputil.ReverseProxy{
|
|
Rewrite: p.rewriteFunc(pt.URL, rewriteMatchedPath, result.passHostHeader, pt.PathRewrite, pt.CustomHeaders, result.stripAuthHeaders),
|
|
Transport: p.transport,
|
|
FlushInterval: -1,
|
|
ErrorHandler: p.proxyErrorHandler,
|
|
}
|
|
if result.rewriteRedirects {
|
|
rp.ModifyResponse = p.rewriteLocationFunc(pt.URL, rewriteMatchedPath, r) //nolint:bodyclose
|
|
}
|
|
rp.ServeHTTP(w, r.WithContext(ctx))
|
|
}
|
|
|
|
// isSelfTargetLoop reports whether an overlay-origin request is about to
|
|
// be forwarded back to the very peer that initiated it. The detection
|
|
// is intentionally narrow: it only fires when the request arrived on
|
|
// the per-account inbound (overlay) listener (so we're confident the
|
|
// source address is the caller's tunnel IP), and only when the resolved
|
|
// target host matches that tunnel IP. Catching this here returns 421 to
|
|
// the caller instead of letting the proxy round-trip its own traffic
|
|
// over WG twice.
|
|
func (p *ReverseProxy) isSelfTargetLoop(r *http.Request, target *url.URL) bool {
|
|
if target == nil {
|
|
return false
|
|
}
|
|
if !types.IsOverlayOrigin(r.Context()) {
|
|
return false
|
|
}
|
|
srcIP := extractHostIP(r.RemoteAddr)
|
|
if !srcIP.IsValid() {
|
|
return false
|
|
}
|
|
targetIP, err := netip.ParseAddr(target.Hostname())
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return srcIP.Unmap() == targetIP.Unmap()
|
|
}
|
|
|
|
// rewriteFunc returns a Rewrite function for httputil.ReverseProxy that rewrites
|
|
// inbound requests to target the backend service while setting security-relevant
|
|
// forwarding headers and stripping proxy authentication credentials.
|
|
// When passHostHeader is true, the original client Host header is preserved
|
|
// instead of being rewritten to the backend's address.
|
|
// The pathRewrite parameter controls how the request path is transformed.
|
|
func (p *ReverseProxy) rewriteFunc(target *url.URL, matchedPath string, passHostHeader bool, pathRewrite PathRewriteMode, customHeaders map[string]string, stripAuthHeaders []string) func(r *httputil.ProxyRequest) {
|
|
return func(r *httputil.ProxyRequest) {
|
|
switch pathRewrite {
|
|
case PathRewritePreserve:
|
|
// Keep the full original request path as-is.
|
|
default:
|
|
if matchedPath != "" && matchedPath != "/" {
|
|
// Strip the matched path prefix from the incoming request path before
|
|
// SetURL joins it with the target's base path, avoiding path duplication.
|
|
r.Out.URL.Path = strings.TrimPrefix(r.Out.URL.Path, matchedPath)
|
|
if r.Out.URL.Path == "" {
|
|
r.Out.URL.Path = "/"
|
|
}
|
|
r.Out.URL.RawPath = ""
|
|
}
|
|
}
|
|
|
|
r.SetURL(target)
|
|
if passHostHeader {
|
|
r.Out.Host = r.In.Host
|
|
} else {
|
|
r.Out.Host = target.Host
|
|
}
|
|
|
|
for _, h := range stripAuthHeaders {
|
|
r.Out.Header.Del(h)
|
|
}
|
|
|
|
for k, v := range customHeaders {
|
|
r.Out.Header.Set(k, v)
|
|
}
|
|
|
|
stampNetBirdIdentity(r)
|
|
|
|
clientIP := extractHostIP(r.In.RemoteAddr)
|
|
|
|
if isTrustedAddr(clientIP, p.trustedProxies) {
|
|
p.setTrustedForwardingHeaders(r, clientIP)
|
|
} else {
|
|
p.setUntrustedForwardingHeaders(r, clientIP)
|
|
}
|
|
|
|
stripSessionCookie(r)
|
|
stripSessionTokenQuery(r)
|
|
}
|
|
}
|
|
|
|
// rewriteLocationFunc returns a ModifyResponse function that rewrites Location
|
|
// headers in backend responses when they point to the backend's address,
|
|
// replacing them with the public-facing host and scheme.
|
|
func (p *ReverseProxy) rewriteLocationFunc(target *url.URL, matchedPath string, inReq *http.Request) func(*http.Response) error {
|
|
publicHost := inReq.Host
|
|
publicScheme := auth.ResolveProto(p.forwardedProto, inReq.TLS)
|
|
|
|
return func(resp *http.Response) error {
|
|
location := resp.Header.Get("Location")
|
|
if location == "" {
|
|
return nil
|
|
}
|
|
|
|
locURL, err := url.Parse(location)
|
|
if err != nil {
|
|
return fmt.Errorf("parse Location header %q: %w", location, err)
|
|
}
|
|
|
|
// Only rewrite absolute URLs that point to the backend.
|
|
if locURL.Host == "" || !hostsEqual(locURL, target) {
|
|
return nil
|
|
}
|
|
|
|
locURL.Host = publicHost
|
|
locURL.Scheme = publicScheme
|
|
|
|
// Re-add the stripped path prefix so the client reaches the correct route.
|
|
// TrimRight prevents double slashes when matchedPath has a trailing slash.
|
|
if matchedPath != "" && matchedPath != "/" {
|
|
locURL.Path = strings.TrimRight(matchedPath, "/") + "/" + strings.TrimLeft(locURL.Path, "/")
|
|
}
|
|
|
|
resp.Header.Set("Location", locURL.String())
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// hostsEqual compares two URL authorities, normalizing default ports per
|
|
// RFC 3986 Section 6.2.3 (https://443 == https, http://80 == http).
|
|
func hostsEqual(a, b *url.URL) bool {
|
|
return normalizeHost(a) == normalizeHost(b)
|
|
}
|
|
|
|
// normalizeHost strips the port from a URL's Host field if it matches the
|
|
// scheme's default port (443 for https, 80 for http).
|
|
func normalizeHost(u *url.URL) string {
|
|
host, port, err := net.SplitHostPort(u.Host)
|
|
if err != nil {
|
|
return u.Host
|
|
}
|
|
if (u.Scheme == "https" && port == "443") || (u.Scheme == "http" && port == "80") {
|
|
return host
|
|
}
|
|
return u.Host
|
|
}
|
|
|
|
// setTrustedForwardingHeaders appends to the existing forwarding header chain
|
|
// and preserves upstream-provided headers when the direct connection is from
|
|
// a trusted proxy.
|
|
func (p *ReverseProxy) setTrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) {
|
|
ipStr := clientIP.String()
|
|
|
|
// Append the direct connection IP to the existing X-Forwarded-For chain.
|
|
if existing := r.In.Header.Get("X-Forwarded-For"); existing != "" {
|
|
r.Out.Header.Set("X-Forwarded-For", existing+", "+ipStr)
|
|
} else {
|
|
r.Out.Header.Set("X-Forwarded-For", ipStr)
|
|
}
|
|
|
|
// Preserve upstream X-Real-IP if present; otherwise resolve through the chain.
|
|
if realIP := r.In.Header.Get("X-Real-IP"); realIP != "" {
|
|
r.Out.Header.Set("X-Real-IP", realIP)
|
|
} else {
|
|
resolved := ResolveClientIP(r.In.RemoteAddr, r.In.Header.Get("X-Forwarded-For"), p.trustedProxies)
|
|
r.Out.Header.Set("X-Real-IP", resolved.String())
|
|
}
|
|
|
|
// Preserve upstream X-Forwarded-Host if present.
|
|
if fwdHost := r.In.Header.Get("X-Forwarded-Host"); fwdHost != "" {
|
|
r.Out.Header.Set("X-Forwarded-Host", fwdHost)
|
|
} else {
|
|
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
|
|
}
|
|
|
|
// Trust upstream X-Forwarded-Proto; fall back to local resolution.
|
|
if fwdProto := r.In.Header.Get("X-Forwarded-Proto"); fwdProto != "" {
|
|
r.Out.Header.Set("X-Forwarded-Proto", fwdProto)
|
|
} else {
|
|
r.Out.Header.Set("X-Forwarded-Proto", auth.ResolveProto(p.forwardedProto, r.In.TLS))
|
|
}
|
|
|
|
// Trust upstream X-Forwarded-Port; fall back to local computation.
|
|
if fwdPort := r.In.Header.Get("X-Forwarded-Port"); fwdPort != "" {
|
|
r.Out.Header.Set("X-Forwarded-Port", fwdPort)
|
|
} else {
|
|
resolvedProto := r.Out.Header.Get("X-Forwarded-Proto")
|
|
r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, resolvedProto))
|
|
}
|
|
}
|
|
|
|
// setUntrustedForwardingHeaders strips all incoming forwarding headers and
|
|
// sets them fresh based on the direct connection. This is the default
|
|
// behavior when no trusted proxies are configured or the direct connection
|
|
// is from an untrusted source.
|
|
func (p *ReverseProxy) setUntrustedForwardingHeaders(r *httputil.ProxyRequest, clientIP netip.Addr) {
|
|
ipStr := clientIP.String()
|
|
proto := auth.ResolveProto(p.forwardedProto, r.In.TLS)
|
|
r.Out.Header.Set("X-Forwarded-For", ipStr)
|
|
r.Out.Header.Set("X-Real-IP", ipStr)
|
|
r.Out.Header.Set("X-Forwarded-Host", r.In.Host)
|
|
r.Out.Header.Set("X-Forwarded-Proto", proto)
|
|
r.Out.Header.Set("X-Forwarded-Port", extractForwardedPort(r.In.Host, proto))
|
|
}
|
|
|
|
// stripSessionCookie removes the proxy's session cookie from the outgoing
|
|
// request while preserving all other cookies.
|
|
func stripSessionCookie(r *httputil.ProxyRequest) {
|
|
cookies := r.In.Cookies()
|
|
r.Out.Header.Del("Cookie")
|
|
for _, c := range cookies {
|
|
if c.Name != auth.SessionCookieName {
|
|
r.Out.AddCookie(c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// stripSessionTokenQuery removes the OIDC session_token query parameter from
|
|
// the outgoing URL to prevent credential leakage to backends.
|
|
func stripSessionTokenQuery(r *httputil.ProxyRequest) {
|
|
q := r.Out.URL.Query()
|
|
if q.Has("session_token") {
|
|
q.Del("session_token")
|
|
r.Out.URL.RawQuery = q.Encode()
|
|
}
|
|
}
|
|
|
|
// extractForwardedPort returns the port from the Host header if present,
|
|
// otherwise defaults to the standard port for the resolved protocol.
|
|
func extractForwardedPort(host, resolvedProto string) string {
|
|
_, port, err := net.SplitHostPort(host)
|
|
if err == nil && port != "" {
|
|
return port
|
|
}
|
|
if resolvedProto == "https" {
|
|
return "443"
|
|
}
|
|
return "80"
|
|
}
|
|
|
|
// proxyErrorHandler handles errors from the reverse proxy and serves
|
|
// user-friendly error pages instead of raw error responses.
|
|
func (p *ReverseProxy) proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
|
if cd := CapturedDataFromContext(r.Context()); cd != nil {
|
|
cd.SetOrigin(OriginProxyError)
|
|
}
|
|
requestID := getRequestID(r)
|
|
clientIP := getClientIP(r)
|
|
title, message, code, status := classifyProxyError(err)
|
|
|
|
p.logger.Warnf("proxy error: request_id=%s client_ip=%s method=%s host=%s path=%s status=%d title=%q err=%v",
|
|
requestID, clientIP, r.Method, r.Host, r.URL.Path, code, title, err)
|
|
|
|
web.ServeErrorPage(w, r, code, title, message, requestID, status)
|
|
}
|
|
|
|
// getClientIP retrieves the resolved client IP string from context.
|
|
func getClientIP(r *http.Request) string {
|
|
if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil {
|
|
if ip := capturedData.GetClientIP(); ip.IsValid() {
|
|
return ip.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getRequestID retrieves the request ID from context or returns empty string.
|
|
func getRequestID(r *http.Request) string {
|
|
if capturedData := CapturedDataFromContext(r.Context()); capturedData != nil {
|
|
return capturedData.GetRequestID()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// classifyProxyError determines the appropriate error title, message, HTTP
|
|
// status code, and component status based on the error type.
|
|
func classifyProxyError(err error) (title, message string, code int, status web.ErrorStatus) {
|
|
switch {
|
|
case errors.Is(err, context.DeadlineExceeded),
|
|
isNetTimeout(err):
|
|
return "Request Timeout",
|
|
"The request timed out while trying to reach the service. Please refresh the page and try again.",
|
|
http.StatusGatewayTimeout,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
|
|
case errors.Is(err, context.Canceled):
|
|
return "Request Canceled",
|
|
"The request was canceled before it could be completed. Please refresh the page and try again.",
|
|
http.StatusBadGateway,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
|
|
case errors.Is(err, roundtrip.ErrNoAccountID):
|
|
return "Configuration Error",
|
|
"The request could not be processed due to a configuration issue. Please refresh the page and try again.",
|
|
http.StatusInternalServerError,
|
|
web.ErrorStatus{Proxy: false, Destination: false}
|
|
|
|
case errors.Is(err, roundtrip.ErrNoPeerConnection),
|
|
errors.Is(err, roundtrip.ErrClientStartFailed):
|
|
return "Proxy Not Connected",
|
|
"The proxy is not connected to the NetBird network. Please try again later or contact your administrator.",
|
|
http.StatusBadGateway,
|
|
web.ErrorStatus{Proxy: false, Destination: false}
|
|
|
|
case errors.Is(err, roundtrip.ErrTooManyInflight):
|
|
return "Service Overloaded",
|
|
"The service is currently handling too many requests. Please try again shortly.",
|
|
http.StatusServiceUnavailable,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
|
|
case isConnectionRefused(err):
|
|
return "Service Unavailable",
|
|
"The connection to the service was refused. Please verify that the service is running and try again.",
|
|
http.StatusBadGateway,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
|
|
case isHostUnreachable(err):
|
|
return "Peer Not Connected",
|
|
"The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.",
|
|
http.StatusBadGateway,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
}
|
|
|
|
return "Connection Error",
|
|
"An unexpected error occurred while connecting to the service. Please try again later.",
|
|
http.StatusBadGateway,
|
|
web.ErrorStatus{Proxy: true, Destination: false}
|
|
}
|
|
|
|
// isConnectionRefused checks for connection refused errors by inspecting
|
|
// the inner error of a *net.OpError. This handles both standard net errors
|
|
// (where the inner error is a *os.SyscallError with "connection refused")
|
|
// and gVisor netstack errors ("connection was refused").
|
|
func isConnectionRefused(err error) bool {
|
|
return opErrorContains(err, "refused")
|
|
}
|
|
|
|
// isHostUnreachable checks for host/network unreachable errors by inspecting
|
|
// the inner error of a *net.OpError. Covers standard net ("no route to host",
|
|
// "network is unreachable") and gVisor ("host is unreachable", etc.).
|
|
func isHostUnreachable(err error) bool {
|
|
return opErrorContains(err, "unreachable") || opErrorContains(err, "no route to host")
|
|
}
|
|
|
|
// isNetTimeout checks whether the error is a network timeout using the
|
|
// net.Error interface.
|
|
func isNetTimeout(err error) bool {
|
|
var netErr net.Error
|
|
return errors.As(err, &netErr) && netErr.Timeout()
|
|
}
|
|
|
|
// opErrorContains extracts the inner error from a *net.OpError and checks
|
|
// whether its message contains the given substring. This handles gVisor
|
|
// netstack errors which wrap tcpip errors as plain strings rather than
|
|
// syscall.Errno values.
|
|
func opErrorContains(err error, substr string) bool {
|
|
var opErr *net.OpError
|
|
if errors.As(err, &opErr) && opErr.Err != nil {
|
|
return strings.Contains(opErr.Err.Error(), substr)
|
|
}
|
|
return false
|
|
}
|
|
|
|
const (
|
|
// headerNetBirdUser carries the authenticated user's display identity
|
|
// (email when the peer is attached to a user, else peer name) onto
|
|
// upstream requests. Stripped from inbound requests before stamping
|
|
// so a client can't spoof identity by setting the header themselves.
|
|
headerNetBirdUser = "X-NetBird-User"
|
|
// headerNetBirdGroups carries the user's group display names as a
|
|
// comma-separated list. Falls back to group IDs at positions where a
|
|
// name wasn't available at session-mint time. Labels containing a
|
|
// comma or any non-printable byte are dropped at stamp time so the
|
|
// list is unambiguously splittable by consumers.
|
|
headerNetBirdGroups = "X-NetBird-Groups"
|
|
)
|
|
|
|
// isHeaderValueSafe reports whether v is a valid RFC 7230 field-value:
|
|
// VCHAR (0x21-0x7E), SP (0x20), or HTAB (0x09). Empty values are
|
|
// rejected; the caller decides whether to omit the header entirely.
|
|
func isHeaderValueSafe(v string) bool {
|
|
if v == "" {
|
|
return false
|
|
}
|
|
for i := 0; i < len(v); i++ {
|
|
c := v[i]
|
|
if c == '\t' || (c >= 0x20 && c <= 0x7E) {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// stampNetBirdIdentity injects authenticated identity onto outbound
|
|
// requests as X-NetBird-User and X-NetBird-Groups. Always strips any
|
|
// client-sent values first (anti-spoof). Skips when the request didn't
|
|
// carry CapturedData (early-path errors, internal endpoints).
|
|
func stampNetBirdIdentity(r *httputil.ProxyRequest) {
|
|
r.Out.Header.Del(headerNetBirdUser)
|
|
r.Out.Header.Del(headerNetBirdGroups)
|
|
|
|
cd := CapturedDataFromContext(r.In.Context())
|
|
if cd == nil {
|
|
return
|
|
}
|
|
if email := cd.GetUserEmail(); isHeaderValueSafe(email) {
|
|
r.Out.Header.Set(headerNetBirdUser, email)
|
|
}
|
|
groupIDs := cd.GetUserGroups()
|
|
if len(groupIDs) == 0 {
|
|
return
|
|
}
|
|
groupNames := cd.GetUserGroupNames()
|
|
labels := make([]string, 0, len(groupIDs))
|
|
for i, id := range groupIDs {
|
|
label := id
|
|
if i < len(groupNames) && groupNames[i] != "" {
|
|
label = groupNames[i]
|
|
}
|
|
if !isHeaderValueSafe(label) || strings.ContainsRune(label, ',') {
|
|
continue
|
|
}
|
|
labels = append(labels, label)
|
|
}
|
|
if len(labels) > 0 {
|
|
r.Out.Header.Set(headerNetBirdGroups, strings.Join(labels, ","))
|
|
}
|
|
}
|