Merge remote-tracking branch 'origin/prototype/reverse-proxy' into prototype/reverse-proxy

This commit is contained in:
pascal
2026-02-05 15:22:39 +01:00
19 changed files with 259 additions and 66 deletions

View File

@@ -24,10 +24,13 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
// headers that we wish to use to gather that information on the request. // headers that we wish to use to gather that information on the request.
sourceIp := extractSourceIP(r) sourceIp := extractSourceIP(r)
// Generate request ID early so it can be used by error pages.
requestID := xid.New().String()
// Create a mutable struct to capture data from downstream handlers. // Create a mutable struct to capture data from downstream handlers.
// We pass a pointer in the context - the pointer itself flows down immutably, // We pass a pointer in the context - the pointer itself flows down immutably,
// but the struct it points to can be mutated by inner handlers. // but the struct it points to can be mutated by inner handlers.
capturedData := &proxy.CapturedData{} capturedData := &proxy.CapturedData{RequestID: requestID}
ctx := proxy.WithCapturedData(r.Context(), capturedData) ctx := proxy.WithCapturedData(r.Context(), capturedData)
start := time.Now() start := time.Now()
@@ -41,7 +44,7 @@ func (l *Logger) Middleware(next http.Handler) http.Handler {
} }
entry := logEntry{ entry := logEntry{
ID: xid.New().String(), ID: requestID,
ServiceId: capturedData.GetServiceId(), ServiceId: capturedData.GetServiceId(),
AccountID: string(capturedData.GetAccountId()), AccountID: string(capturedData.GetAccountId()),
Host: host, Host: host,

View File

@@ -19,10 +19,18 @@ const (
// to pass data back up the middleware chain. // to pass data back up the middleware chain.
type CapturedData struct { type CapturedData struct {
mu sync.RWMutex mu sync.RWMutex
RequestID string
ServiceId string ServiceId string
AccountId types.AccountID AccountId types.AccountID
} }
// GetRequestID safely gets the request ID
func (c *CapturedData) GetRequestID() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.RequestID
}
// SetServiceId safely sets the service ID // SetServiceId safely sets the service ID
func (c *CapturedData) SetServiceId(serviceId string) { func (c *CapturedData) SetServiceId(serviceId string) {
c.mu.Lock() c.mu.Lock()

View File

@@ -41,8 +41,10 @@ func NewReverseProxy(transport http.RoundTripper, logger *log.Logger) *ReversePr
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
target, serviceId, accountID, exists := p.findTargetForRequest(r) target, serviceId, accountID, exists := p.findTargetForRequest(r)
if !exists { if !exists {
requestID := getRequestID(r)
web.ServeErrorPage(w, r, http.StatusNotFound, "Service Not Found", 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.") "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, Peer: false, Destination: false})
return return
} }
@@ -71,37 +73,73 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// proxyErrorHandler handles errors from the reverse proxy and serves // proxyErrorHandler handles errors from the reverse proxy and serves
// user-friendly error pages instead of raw error responses. // user-friendly error pages instead of raw error responses.
func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) { func proxyErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
title, message, code := classifyProxyError(err) requestID := getRequestID(r)
web.ServeErrorPage(w, r, code, title, message) title, message, code, status := classifyProxyError(err)
web.ServeErrorPage(w, r, code, title, message, requestID, status)
} }
// classifyProxyError determines the appropriate error title, message, and HTTP // getRequestID retrieves the request ID from context or returns empty string.
// status code based on the error type. func getRequestID(r *http.Request) string {
func classifyProxyError(err error) (title, message string, code int) { 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) {
errStr := err.Error()
switch { switch {
case errors.Is(err, context.DeadlineExceeded): case errors.Is(err, context.DeadlineExceeded):
return "Request Timeout", return "Request Timeout",
"The request timed out while trying to reach the service. Please refresh the page and try again.", "The request timed out while trying to reach the service. Please refresh the page and try again.",
http.StatusGatewayTimeout http.StatusGatewayTimeout,
web.ErrorStatus{Proxy: true, Peer: true, Destination: false}
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
return "Request Canceled", return "Request Canceled",
"The request was canceled before it could be completed. Please refresh the page and try again.", "The request was canceled before it could be completed. Please refresh the page and try again.",
http.StatusBadGateway http.StatusBadGateway,
web.ErrorStatus{Proxy: true, Peer: true, Destination: false}
case errors.Is(err, roundtrip.ErrNoAccountID): case errors.Is(err, roundtrip.ErrNoAccountID):
return "Configuration Error", return "Configuration Error",
"The request could not be processed due to a configuration issue. Please refresh the page and try again.", "The request could not be processed due to a configuration issue. Please refresh the page and try again.",
http.StatusInternalServerError http.StatusInternalServerError,
web.ErrorStatus{Proxy: false, Peer: false, Destination: false}
case strings.Contains(err.Error(), "connection refused"): case strings.Contains(errStr, "no peer connection found"),
strings.Contains(errStr, "start netbird client"),
strings.Contains(errStr, "engine not started"),
strings.Contains(errStr, "get net:"):
// The proxy peer (embedded client) is not connected
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, Peer: false, Destination: false}
case strings.Contains(errStr, "connection refused"):
// Routing peer connected but destination service refused the connection
return "Service Unavailable", return "Service Unavailable",
"The connection to the service was refused. Please verify that the service is running and try again.", "The connection to the service was refused. Please verify that the service is running and try again.",
http.StatusBadGateway http.StatusBadGateway,
web.ErrorStatus{Proxy: true, Peer: true, Destination: false}
default: case strings.Contains(errStr, "no route to host"),
strings.Contains(errStr, "network is unreachable"),
strings.Contains(errStr, "i/o timeout"):
// Peer is not reachable
return "Peer Not Connected", 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.", "The connection to the peer could not be established. Please ensure the peer is running and connected to the NetBird network.",
http.StatusBadGateway http.StatusBadGateway,
web.ErrorStatus{Proxy: true, Peer: false, Destination: false}
} }
// Unknown error - log it and show generic message
return "Connection Error",
"An unexpected error occurred while connecting to the service. Please try again later.",
http.StatusBadGateway,
web.ErrorStatus{Proxy: true, Peer: false, Destination: false}
} }

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

9
proxy/web/dist/assets/index.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

1
proxy/web/dist/assets/style.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -2,12 +2,12 @@
<html lang="en" class="dark"> <html lang="en" class="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="/assets/favicon-Cv-2QvSV.ico" /> <link rel="icon" type="image/x-icon" href="/assets/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NetBird Service</title> <title>NetBird Service</title>
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<script type="module" crossorigin src="/assets/index-ClfM9m3s.js"></script> <script type="module" crossorigin src="/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/assets/style-B1NSEbha.css"> <link rel="stylesheet" crossorigin href="/assets/style.css">
</head> </head>
<body> <body>
<!-- Go template variables injected here --> <!-- Go template variables injected here -->

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import {BookText, RotateCw, Server, Globe, UserIcon, WaypointsIcon} from "lucide-react";
import { Title } from "@/components/Title";
import { Description } from "@/components/Description";
import Button from "@/components/Button";
import { PoweredByNetBird } from "@/components/PoweredByNetBird";
import { StatusCard } from "@/components/StatusCard";
import type { ErrorData } from "@/data";
export function ErrorPage({ code, title, message, proxy = true, peer = true, destination = true, requestId }: ErrorData) {
useEffect(() => {
document.title = `${title} - NetBird Service`;
}, [title]);
const [timestamp] = useState(() => new Date().toISOString());
return (
<main className="flex flex-col items-center mt-24 px-4 max-w-3xl mx-auto">
{/* Error Code */}
<div className="text-sm text-netbird font-normal font-mono mb-3 z-10 relative">
Error {code}
</div>
{/* Title */}
<Title className="text-3xl!">{title}</Title>
{/* Description */}
<Description className="mt-2 mb-8 max-w-md">{message}</Description>
{/* Status Cards */}
<div className="hidden sm:flex items-start justify-center w-full mt-6 mb-16 z-10 relative">
<StatusCard icon={UserIcon} label="You" line={false} />
<StatusCard icon={WaypointsIcon} label="Proxy" success={proxy} />
<StatusCard icon={Server} label="Peer" success={peer} />
<StatusCard icon={Globe} label="Destination" success={destination} />
</div>
{/* Buttons */}
<div className="flex gap-3 justify-center items-center mb-6 z-10 relative">
<Button variant="primary" onClick={() => window.location.reload()}>
<RotateCw size={16} />
Refresh Page
</Button>
<Button
variant="secondary"
onClick={() => window.open("https://docs.netbird.io", "_blank")}
>
<BookText size={16} />
Documentation
</Button>
</div>
{/* Request Info */}
<div className="text-center text-xs text-nb-gray-300 uppercase z-10 relative font-mono flex flex-col sm:flex-row gap-2 sm:gap-10 mt-4 mb-3">
<div>
<span className="text-nb-gray-400">REQUEST-ID:</span> {requestId}
</div>
<div>
<span className="text-nb-gray-400">TIMESTAMP:</span> {timestamp}
</div>
</div>
<PoweredByNetBird />
</main>
);
}

View File

@@ -0,0 +1,26 @@
import { X } from "lucide-react";
interface ConnectionLineProps {
success?: boolean;
}
export function ConnectionLine({ success = true }: ConnectionLineProps) {
if (success) {
return (
<div className="flex-1 flex items-center justify-center h-12 w-full px-5">
<div className="w-full border-t-2 border-dashed border-green-500" />
</div>
);
}
return (
<div className="flex-1 flex items-center justify-center h-12 min-w-10 px-5 relative">
<div className="w-full border-t-2 border-dashed border-nb-gray-900" />
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 rounded-full flex items-center justify-center">
<X size={18} className="text-netbird" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { LucideIcon } from "lucide-react";
import { ConnectionLine } from "./ConnectionLine";
interface StatusCardProps {
icon: LucideIcon;
label: string;
detail?: string;
success?: boolean;
line?: boolean;
}
export function StatusCard({
icon: Icon,
label,
detail,
success = true,
line = true,
}: StatusCardProps) {
return (
<>
{line && <ConnectionLine success={success} />}
<div className="flex flex-col items-center gap-2">
<div className="w-14 h-14 rounded-md flex items-center justify-center from-nb-gray-940 to-nb-gray-930/70 bg-gradient-to-br border border-nb-gray-910">
<Icon size={20} className="text-nb-gray-200" />
</div>
<span className="text-sm text-nb-gray-200 font-normal mt-1">{label}</span>
<span className={`text-xs font-medium uppercase ${success ? "text-green-500" : "text-netbird"}`}>
{success ? "Connected" : "Unreachable"}
</span>
{detail && (
<span className="text-xs text-nb-gray-400 truncate text-center">
{detail}
</span>
)}
</div>
</>
);
}

View File

@@ -9,6 +9,10 @@ export interface ErrorData {
code: number code: number
title: string title: string
message: string message: string
proxy?: boolean
peer?: boolean
destination?: boolean
requestId?: string
} }
// Data injected by Go templates // Data injected by Go templates
@@ -25,5 +29,27 @@ declare global {
} }
export function getData(): Data { export function getData(): Data {
return window.__DATA__ ?? {} const data = window.__DATA__ ?? {}
// Dev mode: allow ?page=error query param to preview error page
if (import.meta.env.DEV) {
const params = new URLSearchParams(window.location.search)
const page = params.get('page')
if (page === 'error') {
return {
...data,
page: 'error',
error: data.error ?? {
code: 503,
title: 'Service Unavailable',
message: 'The service you are trying to access is temporarily unavailable. Please try again later.',
proxy: true,
peer: false,
destination: false,
},
}
}
}
return data
} }

View File

@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { ErrorPage } from './pages/ErrorPage.tsx' import { ErrorPage } from './ErrorPage.tsx'
import { getData } from '@/data' import { getData } from '@/data'
const data = getData() const data = getData()

View File

@@ -1,42 +0,0 @@
import { useEffect } from "react";
import { BookText, RotateCw } from "lucide-react";
import { Title } from "@/components/Title";
import { Description } from "@/components/Description";
import { PoweredByNetBird } from "@/components/PoweredByNetBird";
import { Card } from "@/components/Card";
import Button from "@/components/Button";
import type { ErrorData } from "@/data";
export function ErrorPage({ code, title, message }: ErrorData) {
useEffect(() => {
document.title = `${title} - NetBird Service`;
}, [title]);
return (
<main className="flex flex-col items-center mt-40 px-4 max-w-xl mx-auto">
<Card className="text-center">
<div className="text-5xl font-bold text-nb-gray-200 mb-4">{code}</div>
<Title>{title}</Title>
<Description className="mt-2">{message}</Description>
<div className="mt-6 flex gap-3 justify-center">
<Button
variant="primary"
onClick={() => window.location.reload()}
>
<RotateCw size={16} />
Refresh Page
</Button>
<Button
variant="secondary"
onClick={() => window.open("https://docs.netbird.io", "_blank")}
>
<BookText size={16} />
Documentation
</Button>
</div>
</Card>
<PoweredByNetBird />
</main>
);
}

View File

@@ -20,5 +20,12 @@ export default defineConfig({
outDir: 'dist', outDir: 'dist',
assetsDir: 'assets', assetsDir: 'assets',
cssCodeSplit: false, cssCodeSplit: false,
rollupOptions: {
output: {
entryFileNames: 'assets/index.js',
chunkFileNames: 'assets/[name].js',
assetFileNames: 'assets/[name][extname]',
},
},
}, },
}) })

View File

@@ -108,14 +108,25 @@ func ServeHTTP(w http.ResponseWriter, r *http.Request, data any, statusCode ...i
w.Write(buf.Bytes()) w.Write(buf.Bytes())
} }
// ErrorStatus represents the connection status for each component in the error page.
type ErrorStatus struct {
Proxy bool
Peer bool
Destination bool
}
// ServeErrorPage renders a user-friendly error page with the given details. // ServeErrorPage renders a user-friendly error page with the given details.
func ServeErrorPage(w http.ResponseWriter, r *http.Request, code int, title, message string) { func ServeErrorPage(w http.ResponseWriter, r *http.Request, code int, title, message, requestID string, status ErrorStatus) {
ServeHTTP(w, r, map[string]any{ ServeHTTP(w, r, map[string]any{
"page": "error", "page": "error",
"error": map[string]any{ "error": map[string]any{
"code": code, "code": code,
"title": title, "title": title,
"message": message, "message": message,
"proxy": status.Proxy,
"peer": status.Peer,
"destination": status.Destination,
"requestId": requestID,
}, },
}, code) }, code)
} }