diff --git a/.github/workflows/sync-deploy-templates.yml b/.github/workflows/sync-deploy-templates.yml new file mode 100644 index 000000000..da40f0123 --- /dev/null +++ b/.github/workflows/sync-deploy-templates.yml @@ -0,0 +1,34 @@ +name: Sync deploy templates to S3 + +on: + push: + branches: [main] + paths: + - "templates/reverse-proxy/**" + +permissions: + id-token: write + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.DEPLOY_TEMPLATES_ROLE_ARN }} + aws-region: eu-central-1 + + - name: Validate CloudFormation template + run: | + aws cloudformation validate-template \ + --template-body file://templates/reverse-proxy/netbird-proxy-cfn.yaml + + - name: Upload CloudFormation template + run: | + aws s3 cp templates/reverse-proxy/netbird-proxy-cfn.yaml \ + s3://netbird-deploy-templates/templates/netbird-proxy-cfn.yaml diff --git a/.github/workflows/test-cloud-deploy.yml b/.github/workflows/test-cloud-deploy.yml new file mode 100644 index 000000000..dce7b461a --- /dev/null +++ b/.github/workflows/test-cloud-deploy.yml @@ -0,0 +1,78 @@ +name: Test cloud deploy config + +on: + pull_request: + paths: + - "docker/init_react_envs.sh" + - "templates/reverse-proxy/**" + - "src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx" + - ".github/workflows/test-cloud-deploy.yml" + +permissions: + contents: read + +jobs: + csp-connect-src: + name: CSP allows cloud provider APIs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install nginx + run: | + sudo apt-get update + sudo apt-get install -y nginx gettext-base + + - name: Prepare nginx layout expected by the init script + run: | + sudo mkdir -p /etc/nginx/http.d /usr/share/nginx/html + echo 'add_header Content-Security-Policy "placeholder" always;' | sudo tee /etc/nginx/http.d/default.conf + sudo touch /usr/share/nginx/html/OidcTrustedDomains.js.tmpl + sudo systemctl start nginx + + - name: Run init script with production-like env + env: + AUTH_AUTHORITY: https://auth.example.com + AUTH_CLIENT_ID: test-client + AUTH_AUDIENCE: test-audience + AUTH_SUPPORTED_SCOPES: openid profile email + USE_AUTH0: "false" + NETBIRD_MGMT_API_ENDPOINT: https://api.example.com + run: sudo -E bash docker/init_react_envs.sh + + - name: Assert provider APIs are in connect-src + run: | + CSP=$(grep -o 'Content-Security-Policy "[^"]*"' /etc/nginx/http.d/default.conf) + echo "Generated CSP: $CSP" + CONNECT_SRC=$(echo "$CSP" | sed 's/.*connect-src \([^;]*\);.*/\1/') + echo "connect-src: $CONNECT_SRC" + status=0 + for origin in https://api.hetzner.cloud https://api.digitalocean.com; do + if echo "$CONNECT_SRC" | grep -qF "$origin"; then + echo "OK: $origin" + else + echo "MISSING in connect-src: $origin" + status=1 + fi + done + exit $status + + cfn-template-url: + name: CloudFormation template URL is live + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract template URL from component and check it + run: | + URL=$(grep -o 'https://[^"]*netbird-proxy-cfn\.yaml' src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx) + echo "CFN_TEMPLATE_URL: $URL" + test -n "$URL" + # Non-blocking: the template is published by sync-deploy-templates on + # the default branch, so it may not exist yet for a PR that only edits + # the URL. Warn instead of failing the check. + if curl -sfI "$URL" >/dev/null; then + echo "OK: template URL is live" + else + echo "::warning::CloudFormation template URL is not reachable yet: $URL" + fi diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index 3d4a70863..75e26da7c 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -80,7 +80,7 @@ echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" FIRST_PARTY_CSP="pkgs.netbird.io" FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io" THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net" -THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com" +THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com https://api.hetzner.cloud https://api.digitalocean.com" THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com" CSP_DOMAINS="" diff --git a/src/app/(dashboard)/reverse-proxy/clusters/page.tsx b/src/app/(dashboard)/reverse-proxy/clusters/page.tsx index f6582726d..ef15f9c79 100644 --- a/src/app/(dashboard)/reverse-proxy/clusters/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/clusters/page.tsx @@ -40,9 +40,9 @@ export default function ReverseProxyClustersPage() {

Clusters

- Proxy clusters that route inbound traffic to your services. Shared - clusters are deployed at the server level; account clusters are - self-hosted on your own infrastructure.{" "} + Proxy clusters route inbound traffic to your services. Shared clusters + are run by the platform; account clusters (self-hosted) run on your + own infrastructure.{" "} Learn more diff --git a/src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx b/src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx new file mode 100644 index 000000000..6dfae9747 --- /dev/null +++ b/src/modules/reverse-proxy/clusters/ClusterCloudDeploy.tsx @@ -0,0 +1,937 @@ +import Button from "@components/Button"; +import { Callout } from "@components/Callout"; +import CardTable from "@components/CardTable"; +import Code from "@components/Code"; +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import HelpText from "@components/HelpText"; +import { HelpTooltip } from "@components/HelpTooltip"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; +import { notify } from "@components/Notification"; +import { SelectDropdown } from "@components/select/SelectDropdown"; +import { + CheckCircle2, + ExternalLinkIcon, + Loader2, + RocketIcon, +} from "lucide-react"; +import React, { useEffect, useMemo, useState } from "react"; +import { useApiCall } from "@/utils/api"; +import { ReverseProxyCluster } from "@/interfaces/ReverseProxy"; + +// Synced from templates/reverse-proxy/netbird-proxy-cfn.yaml by the +// sync-deploy-templates workflow. +const CFN_TEMPLATE_URL = + "https://netbird-deploy-templates.s3.eu-central-1.amazonaws.com/templates/netbird-proxy-cfn.yaml"; + +const DEFAULT_WIREGUARD_PORT = 51820; + +const DIGITALOCEAN_REGIONS = [ + { value: "fra1", label: "Frankfurt, Germany (fra1)" }, + { value: "ams3", label: "Amsterdam, Netherlands (ams3)" }, + { value: "lon1", label: "London, UK (lon1)" }, + { value: "nyc3", label: "New York, USA (nyc3)" }, + { value: "sfo3", label: "San Francisco, USA (sfo3)" }, + { value: "tor1", label: "Toronto, Canada (tor1)" }, + { value: "sgp1", label: "Singapore (sgp1)" }, + { value: "blr1", label: "Bangalore, India (blr1)" }, + { value: "syd1", label: "Sydney, Australia (syd1)" }, +]; + +const DIGITALOCEAN_SIZES = [ + { value: "s-1vcpu-1gb", label: "Basic - 1 vCPU / 1 GB" }, + { value: "s-1vcpu-2gb", label: "Basic - 1 vCPU / 2 GB" }, + { value: "s-2vcpu-2gb", label: "Basic - 2 vCPU / 2 GB" }, + { value: "s-2vcpu-4gb", label: "Basic - 2 vCPU / 4 GB" }, +]; + +const AWS_REGIONS = [ + { value: "us-east-1", label: "US East (N. Virginia)" }, + { value: "us-east-2", label: "US East (Ohio)" }, + { value: "us-west-2", label: "US West (Oregon)" }, + { value: "eu-central-1", label: "Europe (Frankfurt)" }, + { value: "eu-west-1", label: "Europe (Ireland)" }, + { value: "eu-west-2", label: "Europe (London)" }, + { value: "ap-southeast-1", label: "Asia Pacific (Singapore)" }, + { value: "ap-northeast-1", label: "Asia Pacific (Tokyo)" }, +]; + +export type CloudProvider = "hetzner" | "digitalocean" | "aws"; + +type Props = { + provider: CloudProvider; + domain: string; + token: string; + managementUrl: string; + isGeneratingToken: boolean; + // Fires once the deployed proxy registers and connects, so the modal can + // gate its "Finish Setup" action on real completion. + onRegistered?: () => void; +}; + +// buildCloudInit renders the canonical bootstrap with the same environment as +// the Docker snippet in the cluster modal, for use as VM user data. When a +// root password is given, it is set for provider web-console access while +// SSH password login stays disabled. +const buildCloudInit = ( + domain: string, + token: string, + managementUrl: string, + rootPassword?: string, +) => `#cloud-config +${ + rootPassword + ? `ssh_pwauth: false +chpasswd: + expire: false + users: + - name: root + password: ${rootPassword} + type: text +` + : "" +}write_files: + - path: /opt/netbird-proxy/env + permissions: "0600" + content: | + NB_PROXY_TOKEN=${token} + NB_PROXY_DOMAIN=${domain} + NB_PROXY_MANAGEMENT_ADDRESS=${managementUrl} + NB_PROXY_ACME_CERTIFICATES=true + NB_PROXY_CERTIFICATE_DIRECTORY=/certs + NB_PROXY_ALLOW_INSECURE=true + NB_PROXY_PRIVATE=true + NB_PROXY_LOG_LEVEL=info + NB_PROXY_ADDRESS=:443 + NB_PROXY_WG_PORT=${DEFAULT_WIREGUARD_PORT} + - path: /opt/netbird-proxy/docker-compose.yml + permissions: "0644" + content: | + services: + reverse-proxy: + image: netbirdio/reverse-proxy:latest + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "500m" + max-file: "2" + ports: + - "80:80" + - "443:443" + - "${DEFAULT_WIREGUARD_PORT}:${DEFAULT_WIREGUARD_PORT}/udp" + env_file: + - /opt/netbird-proxy/env + volumes: + - proxy_certs:/certs + volumes: + proxy_certs: +runcmd: + - curl -fsSL https://get.docker.com | sh + - docker compose -f /opt/netbird-proxy/docker-compose.yml up -d +`; + +export const ClusterCloudDeploy = ({ provider, ...props }: Props) => { + if (provider === "hetzner") return ; + if (provider === "digitalocean") return ; + return ; +}; + +type ProviderProps = Omit; + +type HetznerServerType = { + id: number; + name: string; + cores: number; + memory: number; + deprecated: boolean; +}; + +type HetznerCatalog = { + locations: { name: string; label: string }[]; + availableTypeIds: Record; + serverTypes: HetznerServerType[]; + sshKeys: { id: number; name: string }[]; +}; + +// fetchHetznerCatalog loads the current locations, server types, and +// per-location availability from the Hetzner API, excluding deprecated +// server types, so the dropdowns always offer valid combinations. +const fetchHetznerCatalog = async (token: string): Promise => { + const headers = { Authorization: `Bearer ${token}` }; + const [typesRes, dcsRes, keysRes] = await Promise.all([ + fetch("https://api.hetzner.cloud/v1/server_types?per_page=50", { headers }), + fetch("https://api.hetzner.cloud/v1/datacenters?per_page=50", { headers }), + fetch("https://api.hetzner.cloud/v1/ssh_keys?per_page=50", { headers }), + ]); + const typesBody = await typesRes.json().catch(() => null); + if (!typesRes.ok) { + throw new Error( + typesBody?.error?.message ?? + `Hetzner API error (HTTP ${typesRes.status})`, + ); + } + const dcsBody = await dcsRes.json().catch(() => null); + if (!dcsRes.ok) { + throw new Error( + dcsBody?.error?.message ?? `Hetzner API error (HTTP ${dcsRes.status})`, + ); + } + const keysBody = await keysRes.json().catch(() => null); + if (!keysRes.ok) { + throw new Error( + keysBody?.error?.message ?? `Hetzner API error (HTTP ${keysRes.status})`, + ); + } + + const serverTypes = ((typesBody?.server_types ?? []) as HetznerServerType[]) + .filter((t) => !t.deprecated) + .sort((a, b) => a.memory - b.memory || a.cores - b.cores); + + type Datacenter = { + location?: { name?: string; city?: string; country?: string }; + server_types?: { available?: number[] }; + }; + const locationLabels = new Map(); + const availableTypeIds: Record = {}; + for (const dc of (dcsBody?.datacenters ?? []) as Datacenter[]) { + const name = dc.location?.name; + if (!name) continue; + locationLabels.set( + name, + `${dc.location?.city}, ${dc.location?.country} (${name})`, + ); + const ids = new Set(availableTypeIds[name] ?? []); + for (const id of dc.server_types?.available ?? []) { + ids.add(id); + } + availableTypeIds[name] = Array.from(ids); + } + + const locations = Array.from(locationLabels.entries()) + .map(([name, label]) => ({ name, label })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const sshKeys = ( + (keysBody?.ssh_keys ?? []) as { id: number; name: string }[] + ).map((k) => ({ id: k.id, name: k.name })); + + return { locations, availableTypeIds, serverTypes, sshKeys }; +}; + +type DeploySuccessProps = { + resourceLabel: string; + name: string; + ip: string; + isStaticIP: boolean; + domain: string; + ipPendingNote?: string; + onRegistered?: () => void; + children?: React.ReactNode; +}; + +// RegistrationCheck polls the management API until the cluster for the given +// domain reports a connected proxy. +const RegistrationCheck = ({ + domain, + onRegistered, +}: { + domain: string; + onRegistered?: () => void; +}) => { + const clustersRequest = useApiCall( + "/reverse-proxies/clusters", + true, + ); + const [registered, setRegistered] = useState(false); + + useEffect(() => { + if (registered) { + onRegistered?.(); + return; + } + let attempts = 0; + const timer = setInterval(() => { + attempts += 1; + if (attempts > 120) { + clearInterval(timer); + return; + } + clustersRequest + .get() + .then((clusters) => { + const cluster = clusters?.find((c) => c.address === domain); + if (cluster?.online && cluster.connected_proxies > 0) { + setRegistered(true); + } + }) + .catch(() => { + // Polling failures are retried on the next tick. + }); + }, 5000); + return () => clearInterval(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [registered, domain]); + + return ( +
+ {registered ? ( + <> + + + Proxy registered with NetBird and connected. + + + ) : ( + <> + + + Waiting for the proxy to register with NetBird... + + + )} +
+ ); +}; + +// DeploySuccess shows the deployment result: the DNS records to create for +// the new instance IP and a live check that the proxy registered with the +// NetBird management service. +const DeploySuccess = ({ + resourceLabel, + name, + ip, + isStaticIP, + domain, + ipPendingNote, + onRegistered, + children, +}: DeploySuccessProps) => { + return ( +
+ + {resourceLabel} {name}{" "} + was created + {ip ? ( + <> + {" "} + with {isStaticIP ? "static IP" : "IP"}{" "} + {ip} and is still + bootstrapping. Meanwhile, add the DNS records below. + + ) : ( + <> {ipPendingNote} + )} + + {ip && ( +
+ + + Point these records at the new {resourceLabel.toLowerCase()}. The + proxy gets its certificate once they resolve. + + + + Type + Name + Content + + + + A + + {domain} + + + {ip} + + + + CNAME + + {`*.${domain}`} + + + {domain} + + + + +
+ )} + {children} + +
+ ); +}; + +const HetznerDeploy = ({ + domain, + token, + managementUrl, + isGeneratingToken, + onRegistered, +}: ProviderProps) => { + const [hetznerToken, setHetznerToken] = useState(""); + const [catalog, setCatalog] = useState(null); + const [catalogError, setCatalogError] = useState(""); + const [isLoadingCatalog, setIsLoadingCatalog] = useState(false); + const [location, setLocation] = useState(""); + const [serverType, setServerType] = useState(""); + const [sshKeyId, setSshKeyId] = useState(""); + const [staticIP, setStaticIP] = useState(true); + const [isDeploying, setIsDeploying] = useState(false); + const [serverIP, setServerIP] = useState(""); + + const serverName = useMemo(() => { + const label = domain.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); + return `netbird-proxy-${label}`.slice(0, 63).replace(/-+$/, ""); + }, [domain]); + + // Load the live catalog once a plausible token is entered, debounced so we + // don't call the API on every keystroke. + useEffect(() => { + const apiToken = hetznerToken.trim(); + if (apiToken.length < 32) { + setCatalog(null); + setCatalogError(""); + return; + } + const timer = setTimeout(() => { + setIsLoadingCatalog(true); + setCatalogError(""); + fetchHetznerCatalog(apiToken) + .then(setCatalog) + .catch((err) => { + setCatalog(null); + setCatalogError( + err instanceof Error ? err.message : "request failed", + ); + }) + .finally(() => setIsLoadingCatalog(false)); + }, 600); + return () => clearTimeout(timer); + }, [hetznerToken]); + + const locationOptions = useMemo( + () => + catalog?.locations.map((l) => ({ value: l.name, label: l.label })) ?? [], + [catalog], + ); + + const serverTypeOptions = useMemo(() => { + if (!catalog || !location) return []; + const ids = catalog.availableTypeIds[location] ?? []; + return catalog.serverTypes + .filter((t) => ids.includes(t.id)) + .map((t) => ({ + value: t.name, + label: `${t.name.toUpperCase()} - ${t.cores} vCPU / ${Math.round( + t.memory, + )} GB`, + })); + }, [catalog, location]); + + useEffect(() => { + if (!catalog) return; + if (!catalog.locations.some((l) => l.name === location)) { + const preferred = + catalog.locations.find((l) => l.name === "nbg1") ?? + catalog.locations[0]; + setLocation(preferred?.name ?? ""); + } + }, [catalog, location]); + + useEffect(() => { + if (!catalog || !location) return; + if (!serverTypeOptions.some((o) => o.value === serverType)) { + setServerType(serverTypeOptions[0]?.value ?? ""); + } + }, [catalog, location, serverTypeOptions, serverType]); + + useEffect(() => { + if (!catalog) return; + if (!catalog.sshKeys.some((k) => String(k.id) === sshKeyId)) { + setSshKeyId(catalog.sshKeys[0] ? String(catalog.sshKeys[0].id) : ""); + } + }, [catalog, sshKeyId]); + + const deploy = async () => { + setIsDeploying(true); + const apiToken = hetznerToken.trim(); + const promise = fetch("https://api.hetzner.cloud/v1/servers", { + method: "POST", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: serverName, + server_type: serverType, + image: "ubuntu-24.04", + location, + ssh_keys: sshKeyId ? [Number(sshKeyId)] : [], + user_data: buildCloudInit(domain, token, managementUrl), + }), + }) + .then(async (res) => { + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + body?.error?.message ?? `Hetzner API error (HTTP ${res.status})`, + ); + } + setServerIP(body?.server?.public_net?.ipv4?.ip ?? ""); + const primaryIPId = body?.server?.public_net?.ipv4?.id; + if (staticIP && primaryIPId) { + const ipRes = await fetch( + `https://api.hetzner.cloud/v1/primary_ips/${primaryIPId}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${apiToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ auto_delete: false }), + }, + ); + if (!ipRes.ok) { + const ipBody = await ipRes.json().catch(() => null); + throw new Error( + `server created, but keeping its IP static failed: ${ + ipBody?.error?.message ?? `HTTP ${ipRes.status}` + }`, + ); + } + } + }) + .finally(() => setIsDeploying(false)); + + notify({ + title: "Hetzner Deployment", + description: "Failed to create the Hetzner server", + promise, + loadingMessage: "Creating Hetzner server...", + showOnlyError: true, + preventSuccessToast: true, + }); + }; + + if (serverIP) { + return ( + + ); + } + + return ( +
+
+ + + Create a read & write API token. It is never stored by NetBird. + + ) => + setHetznerToken(e.target.value) + } + /> +
+ {catalogError && ( + + Could not load Hetzner options: {catalogError} + + )} + {catalog ? ( + <> +
+
+ + setLocation(v as string)} + options={locationOptions} + /> +
+
+ + setServerType(v as string)} + options={serverTypeOptions} + /> +
+
+
+ + {catalog.sshKeys.length > 0 ? ( + setSshKeyId(v as string)} + options={catalog.sshKeys.map((k) => ({ + value: String(k.id), + label: k.name, + }))} + /> + ) : ( + + No SSH keys found in this Hetzner project. Add one in the + Hetzner Console first if you need SSH access to the server. + + )} +
+ + + ) : ( + + {isLoadingCatalog + ? "Loading available locations and server types..." + : "Enter your API token to load the available locations and server types."} + + )} + +
+ ); +}; + +// waitForDropletIP polls the droplet until its public IPv4 is assigned. +const waitForDropletIP = async (dropletId: number, doToken: string) => { + for (let attempt = 0; attempt < 18; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + const res = await fetch( + `https://api.digitalocean.com/v2/droplets/${dropletId}`, + { headers: { Authorization: `Bearer ${doToken}` } }, + ); + const body = await res.json().catch(() => null); + const ip = body?.droplet?.networks?.v4?.find( + (net: { type: string }) => net.type === "public", + )?.ip_address; + if (ip) return ip as string; + } + return ""; +}; + +// generateRootPassword creates a random droplet password from an alphanumeric +// set without ambiguous characters. +const generateRootPassword = () => { + const charset = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; + const values = new Uint32Array(20); + crypto.getRandomValues(values); + return Array.from(values, (v) => charset[v % charset.length]).join(""); +}; + +const DigitalOceanDeploy = ({ + domain, + token, + managementUrl, + isGeneratingToken, + onRegistered, +}: ProviderProps) => { + const [rootPassword] = useState(generateRootPassword); + const [doToken, setDoToken] = useState(""); + const [region, setRegion] = useState("fra1"); + const [size, setSize] = useState("s-1vcpu-2gb"); + const [staticIP, setStaticIP] = useState(true); + const [isDeploying, setIsDeploying] = useState(false); + const [isCreated, setIsCreated] = useState(false); + const [dropletIP, setDropletIP] = useState(""); + const [reservedIP, setReservedIP] = useState(""); + + const dropletName = useMemo(() => { + const label = domain.replace(/[^a-zA-Z0-9.-]/g, "-").toLowerCase(); + return `netbird-proxy-${label}`.slice(0, 63).replace(/[-.]+$/, ""); + }, [domain]); + + const deploy = async () => { + setIsDeploying(true); + const promise = fetch("https://api.digitalocean.com/v2/droplets", { + method: "POST", + headers: { + Authorization: `Bearer ${doToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: dropletName, + region, + size, + image: "ubuntu-24-04-x64", + user_data: buildCloudInit(domain, token, managementUrl, rootPassword), + tags: ["netbird-proxy"], + }), + }) + .then(async (res) => { + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new Error( + body?.message ?? `DigitalOcean API error (HTTP ${res.status})`, + ); + } + setIsCreated(true); + const dropletId = body?.droplet?.id; + const ip = await waitForDropletIP(dropletId, doToken); + setDropletIP(ip); + if (staticIP && dropletId) { + const ipRes = await fetch( + "https://api.digitalocean.com/v2/reserved_ips", + { + method: "POST", + headers: { + Authorization: `Bearer ${doToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ droplet_id: dropletId }), + }, + ); + const ipBody = await ipRes.json().catch(() => null); + if (!ipRes.ok) { + throw new Error( + `droplet created, but reserving a static IP failed: ${ + ipBody?.message ?? `HTTP ${ipRes.status}` + }`, + ); + } + setReservedIP(ipBody?.reserved_ip?.ip ?? ""); + } + }) + .finally(() => setIsDeploying(false)); + + notify({ + title: "DigitalOcean Deployment", + description: "Failed to create the DigitalOcean droplet", + promise, + loadingMessage: "Creating DigitalOcean droplet...", + showOnlyError: true, + preventSuccessToast: true, + }); + }; + + if (isCreated) { + return ( + +
+ + + Use it with the Droplet Web Console. Copy it now - it is not stored + anywhere. + + + {rootPassword} + +
+
+ ); + } + + return ( +
+
+ + + Create a token with write access. It is never stored by NetBird. + + ) => + setDoToken(e.target.value) + } + /> +
+
+
+ + setRegion(v as string)} + options={DIGITALOCEAN_REGIONS} + /> +
+
+ + setSize(v as string)} + options={DIGITALOCEAN_SIZES} + /> +
+
+ + +
+ ); +}; + +const AWSDeploy = ({ + domain, + token, + managementUrl, + isGeneratingToken, + onRegistered, +}: ProviderProps) => { + const [region, setRegion] = useState("eu-central-1"); + const [launched, setLaunched] = useState(false); + + const launchUrl = useMemo(() => { + const params = new URLSearchParams({ + templateURL: CFN_TEMPLATE_URL, + stackName: "netbird-proxy", + param_ProxyDomain: domain, + param_ManagementURL: managementUrl, + }); + return `https://console.aws.amazon.com/cloudformation/home?region=${region}#/stacks/quickcreate?${params.toString()}`; + }, [region, domain, managementUrl]); + + return ( +
+
+ + + Copy this token for AWS stack creation. It is excluded from outputs and + logs. + + + + {isGeneratingToken ? "Generating proxy token..." : token} + + +
+
+ + setRegion(v as string)} + options={AWS_REGIONS} + /> +
+ + + The AWS Console opens with a prefilled form. Paste the token, create the + stack, then point your DNS records to the PublicIP output. + + {launched && ( + + )} +
+ ); +}; diff --git a/src/modules/reverse-proxy/clusters/ClustersModal.tsx b/src/modules/reverse-proxy/clusters/ClustersModal.tsx index f446c6133..9c6805092 100644 --- a/src/modules/reverse-proxy/clusters/ClustersModal.tsx +++ b/src/modules/reverse-proxy/clusters/ClustersModal.tsx @@ -24,7 +24,7 @@ import { ServerIcon, SquareTerminalIcon, } from "lucide-react"; -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import { useApiCall } from "@/utils/api"; import { cn, validator } from "@utils/helpers"; @@ -36,13 +36,23 @@ import { REVERSE_PROXY_SELFHOSTED_ROUTING_DOCS_LINK, ReverseProxyClusterToken, } from "@/interfaces/ReverseProxy"; +import { + ClusterCloudDeploy, + CloudProvider, +} from "@/modules/reverse-proxy/clusters/ClusterCloudDeploy"; type Props = { open: boolean; onOpenChange: (open: boolean) => void; }; -type DeployMethod = "docker" | "compose" | "kubernetes"; +type DeployMethod = + | "docker" + | "compose" + | "kubernetes" + | "hetzner" + | "digitalocean" + | "aws"; const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -78,6 +88,7 @@ export const ClustersModal = ({ open, onOpenChange }: Props) => { const [token, setToken] = useState(""); const [isGeneratingToken, setIsGeneratingToken] = useState(true); const [deployMethod, setDeployMethod] = useState("docker"); + const [proxyRegistered, setProxyRegistered] = useState(false); const tokenRequest = useApiCall( "/reverse-proxies/proxy-tokens", @@ -96,9 +107,10 @@ export const ClustersModal = ({ open, onOpenChange }: Props) => { return ""; }, [domain]); - const managementUrl = isNetBirdCloud() - ? "https://api.netbird.io" - : GRPC_API_ORIGIN || ""; + // Same convention as the add-peer modals: prefer the configured gRPC + // endpoint so self-hosted and stage deployments point at their own + // management service; fall back to the cloud default. + const managementUrl = GRPC_API_ORIGIN || "https://api.netbird.io:443"; const tokenValue = token || ""; @@ -205,21 +217,64 @@ spec: const deployment = { docker: { label: "Docker", - title: "Run the Proxy with Docker", + title: "Run with Docker", command: dockerCommand, }, compose: { label: "Docker Compose", - title: "Run the Proxy with Docker Compose", + title: "Run with Docker Compose", command: composeCommand, }, kubernetes: { label: "Kubernetes", - title: "Deploy the Proxy on Kubernetes", + title: "Deploy on Kubernetes", command: kubernetesCommand, }, + hetzner: { + label: "Hetzner Cloud", + title: "Deploy on Hetzner Cloud", + command: "", + }, + digitalocean: { + label: "DigitalOcean", + title: "Deploy on DigitalOcean", + command: "", + }, + aws: { + label: "AWS CloudFormation", + title: "Deploy on AWS", + command: "", + }, }[deployMethod]; + const isCloudDeploy = ["hetzner", "digitalocean", "aws"].includes( + deployMethod, + ); + + // Cloud one-click deploys provision the server and surface the DNS records + // (with the real IP) after deployment, so the standalone DNS step only + // applies to the manual paths. Bounce off it if the method flips to cloud. + useEffect(() => { + if (isCloudDeploy && tab === "dns") setTab("install"); + }, [isCloudDeploy, tab]); + + // A new deploy target means the proxy has not registered yet, so drop any + // prior completion state that would otherwise unlock "Finish Setup". + useEffect(() => { + setProxyRegistered(false); + }, [domain, deployMethod]); + + const deployDescription = + deployMethod === "digitalocean" + ? "Launch a droplet to run the proxy." + : deployMethod === "aws" + ? "Launch a dedicated AWS server to run the proxy." + : isCloudDeploy + ? "Launch a cloud server to run the proxy." + : deployMethod === "kubernetes" + ? "Apply this manifest to start the proxy." + : "Run on your machine to start the proxy."; + const generateToken = useCallback(async () => { setIsGeneratingToken(true); const promise = tokenRequest @@ -261,7 +316,7 @@ spec: } title={"Setup Cluster"} - description={"Setup a proxy cluster"} + description={"Setup a proxy cluster on infra you own"} color={"netbird"} /> @@ -274,19 +329,21 @@ spec: Domain - - - DNS Records - + {!isCloudDeploy && ( + + + DNS Records + + )} - Run the Proxy + {isCloudDeploy ? "Deploy" : "Run the Proxy"} @@ -307,27 +364,45 @@ spec: } /> - - In order to run the proxy, please make sure your machine meets - the following requirements: -
    -
  • - - Publicly accessible IP address - -
  • -
  • - Docker{" "} - installed and running -
  • -
  • - - Port 80 and 443 - {" "} - open and not in use -
  • -
-
+
+ + {deployDescription} + setDeployMethod(v as DeployMethod)} + options={[ + { value: "docker", label: "Docker" }, + { value: "compose", label: "Docker Compose" }, + { value: "kubernetes", label: "Kubernetes" }, + { value: "hetzner", label: "Hetzner Cloud" }, + { value: "digitalocean", label: "DigitalOcean" }, + { value: "aws", label: "AWS CloudFormation" }, + ]} + /> +
+ {!isCloudDeploy && ( + + In order to run the proxy, please make sure your machine meets + the following requirements: +
    +
  • + + Publicly accessible IP address + +
  • +
  • + Docker{" "} + installed and running +
  • +
  • + + Port 80 and 443 + {" "} + open and not in use +
  • +
+
+ )} @@ -372,26 +447,9 @@ spec:
-
-
- - - {deployMethod === "kubernetes" - ? "Apply the following manifest to your cluster to start the proxy." - : "Run the following on your machine to start the proxy."} - -
-
- setDeployMethod(v as DeployMethod)} - options={[ - { value: "docker", label: "Docker" }, - { value: "compose", label: "Docker Compose" }, - { value: "kubernetes", label: "Kubernetes" }, - ]} - /> -
+
+ + {deployDescription}
{!isNetBirdCloud() && ( @@ -410,39 +468,52 @@ spec: )} - - {isGeneratingToken && ( -
- - Generating proxy token... -
- )} - - {renderHighlightedCommand(deployment.command, [ - managementUrl, - domain, - tokenValue, - ])} -
- - - Need to fine-tune the proxy? See all available  - - environment variables - - - + {isCloudDeploy ? ( + setProxyRegistered(true)} + /> + ) : ( + <> + + {isGeneratingToken && ( +
+ + Generating proxy token... +
+ )} + + {renderHighlightedCommand(deployment.command, [ + managementUrl, + domain, + tokenValue, + ])} +
+ + + Need to fine-tune the proxy? See all available  + + environment variables + + + + + )}
@@ -468,7 +539,7 @@ spec: - diff --git a/src/modules/reverse-proxy/clusters/ClustersTable.tsx b/src/modules/reverse-proxy/clusters/ClustersTable.tsx index a1679167e..cceff9ba6 100644 --- a/src/modules/reverse-proxy/clusters/ClustersTable.tsx +++ b/src/modules/reverse-proxy/clusters/ClustersTable.tsx @@ -201,7 +201,7 @@ export default function ClustersTable({ headingTarget }: Readonly) { } title={"No clusters available"} description={ - "There are no shared clusters connected to your account and no self-hosted clusters configured. Set up a self-hosted cluster to route traffic through your own infrastructure — see the documentation linked above for setup steps." + "Set up a cluster to route traffic through your own infrastructure." } button={