Compare commits

..
Author SHA1 Message Date
Eduard Gert 02d2deb5bd Add env to disable CSP 2026-02-11 14:19:18 +01:00
Eduard Gert 69582b2867 Reload nginx 2026-02-11 13:45:05 +01:00
Eduard Gert 4e227d6efb Fix lets encrypt domain 2026-02-11 12:44:53 +01:00
Eduard Gert 2ad96f2aee Fix auth audience 2026-02-11 12:43:51 +01:00
Eduard Gert 22b28ff189 Add csp 2026-02-11 11:42:30 +01:00
205 changed files with 2276 additions and 16716 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__
-105
View File
@@ -1,105 +0,0 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied âś…"
-2
View File
@@ -37,8 +37,6 @@ next-env.d.ts
# config
.local-config.json
.test-config.json
cypress.env.json
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json
+3 -3
View File
@@ -1,9 +1,9 @@
[
{
"tag": "New",
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
"link": "https://docs.netbird.io/manage/reverse-proxy",
"linkText": "Learn more",
"text": "Custom DNS Zones for Private Network Resolution",
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
"linkText": "Read Release Article",
"variant": "important",
"isExternal": true,
"closeable": true,
+12 -2
View File
@@ -14,14 +14,24 @@ server {
location / {
try_files $uri $uri.html $uri/ =404;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
add_header Last-Modified "";
expires off;
}
error_page 404 /404.html;
location = /404.html {
internal;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
add_header Last-Modified "";
expires off;
}
}
+88 -1
View File
@@ -62,9 +62,93 @@ export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
export NETBIRD_CSP=${NETBIRD_CSP}
export NETBIRD_CSP_CONNECT_SRC=${NETBIRD_CSP_CONNECT_SRC}
export NETBIRD_DISABLE_CSP=${NETBIRD_DISABLE_CSP}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
# Build CSP
if [[ "${NETBIRD_DISABLE_CSP}" == "true" ]]; then
echo "CSP disabled via NETBIRD_DISABLE_CSP"
sed -i '/add_header Content-Security-Policy/d' /etc/nginx/http.d/default.conf
else
FIRST_PARTY_CSP="https://pkgs.netbird.io"
FIRST_PARTY_CSP_CONNECT_SRC="$NETBIRD_CSP_CONNECT_SRC"
THIRD_PARTY_CSP=""
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/"
THIRD_PARTY_CSP_SCRIPT_SRC=""
CSP_DOMAINS=""
CSP_DOMAINS_CONNECT_SRC=""
if [[ -n "${NETBIRD_CSP}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP"
fi
# Add AUTH_AUTHORITY to CSP
if [[ -n "${AUTH_AUTHORITY}" ]]; then
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
fi
# Add AUTH_AUDIENCE to CSP
if [[ -n "${AUTH_AUDIENCE}" && "${AUTH_AUDIENCE}" != "none" && "${AUTH_AUDIENCE}" == *.* ]]; then
if [[ "${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"* ]]; then
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE"
else
CSP_DOMAINS="$CSP_DOMAINS https://$AUTH_AUDIENCE"
fi
fi
# Add NETBIRD_MGMT_API_ENDPOINT to CSP
if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
MGMT_HOST=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1)
if [[ -n "$MGMT_HOST" ]]; then
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_HOST"
elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_HOST"
fi
fi
fi
# Add LETSENCRYPT_DOMAIN to CSP
if [[ -n "${LETSENCRYPT_DOMAIN}" && "${LETSENCRYPT_DOMAIN}" != "none" ]]; then
if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then
CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN ws://*.$LETSENCRYPT_DOMAIN"
else
CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN"
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN wss://*.$LETSENCRYPT_DOMAIN"
fi
fi
CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC"
CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP"
CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC"
# Remove duplicates
CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//')
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ' | sed 's/ $//')
# Update CSP in nginx config
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
# Escape sed special characters in replacement string (& | \)
CSP_HEADER=$(printf '%s' "$CSP_HEADER" | sed -e 's/[\\&|]/\\&/g')
echo "CSP header: $CSP_HEADER"
# Replace CSP header in nginx config
sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || {
echo "Failed to replace CSP header"
}
fi
# replace ENVs in the config
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
@@ -74,4 +158,7 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
cp "$f" "$f".copy
envsubst "$ENV_STR" < "$f".copy > "$f"
rm "$f".copy
done
done
# Reload nginx to pick up the updated CSP and config
nginx -s reload
+1 -4
View File
@@ -5,9 +5,6 @@ const nextConfig = {
unoptimized: true,
},
reactStrictMode: false,
experimental: {
externalDir: true,
},
env: {
APP_ENV: process.env.APP_ENV || "production",
NEXT_PUBLIC_DASHBOARD_VERSION:
@@ -15,4 +12,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
module.exports = nextConfig;
+106 -2394
View File
File diff suppressed because it is too large. Load diff
+5 -8
View File
@@ -11,6 +11,7 @@
"dev": "next dev -p 3000",
"turbo": "next dev -p 3000 --turbo",
"build": "next build",
"postbuild": "node postbuild.js",
"start": "next start",
"lint": "next lint",
"cypress:open": "cypress open"
@@ -18,7 +19,6 @@
"dependencies": {
"@axa-fr/react-oidc": "^7.26.3",
"@dagrejs/dagre": "^1.1.5",
"@netbirdio/explain": "^0.1.5",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
@@ -50,7 +50,7 @@
"@xterm/xterm": "^5.5.0",
"@xyflow/react": "^12.8.4",
"autoprefixer": "^10",
"commander": "^14.0.3","chart.js": "^4.4.8",
"chart.js": "^4.4.8",
"chroma-js": "^3.1.2",
"class-variance-authority": "^0.7.0",
"classnames": "^2.5.1",
@@ -68,25 +68,22 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"lucide-react": "^0.539.0",
"next": "^16.1.6",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.4",
"react-ga4": "^2.1.0",
"react-hot-toast": "^2.4.1",
"react-hotjar": "^6.3.1",
"react-hotkeys-hook": "^4.4.1",
"react-icons": "^5.5.0",
"react-jwt": "^1.2.0",
"react-loading-skeleton": "^3.3.1",
"react-markdown": "^10.1.0",
"react-responsive": "^9.0.2",
"react-virtuoso": "^4.9.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"swr": "^2.2.4",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
+96
View File
@@ -0,0 +1,96 @@
const { resolve, join } = require("path");
const { createHash } = require("crypto");
const {
readFileSync,
writeFileSync,
mkdirSync,
readdirSync,
statSync,
} = require("fs");
process.env.NODE_ENV = "production";
const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER";
console.log("Starting post-build script to extract inline scripts...");
// Function to find HTML files recursively
function findHtmlFiles(dir) {
const files = [];
const entries = readdirSync(dir);
for (const entry of entries) {
const fullPath = join(dir, entry);
const stat = statSync(fullPath);
if (stat.isDirectory()) {
files.push(...findHtmlFiles(fullPath));
} else if (entry.endsWith(".html")) {
files.push(fullPath);
}
}
return files;
}
// For Next.js export output, the files are in the 'out' directory
const baseDir = resolve("out");
const htmlFiles = findHtmlFiles(baseDir);
console.log(`Found ${htmlFiles.length} .html files to process`);
// Ensure assets directory exists
const assetsDir = `${baseDir}/assets`;
mkdirSync(assetsDir, { recursive: true });
htmlFiles.forEach((file) => {
// Read file contents
const contents = readFileSync(file, "utf8");
const scripts = [];
// Extract inline scripts
const newFile = contents.replace(
/<script(?![^>]*src)([^>]*)>(.+?)<\/script>/gs,
(match, attributes, scriptContent) => {
// Skip if script has src attribute (external script)
if (attributes.includes("src=")) {
return match;
}
const addPlaceholderString = scripts.length === 0;
const cleanedScript = scriptContent.trim();
if (cleanedScript) {
scripts.push(
`${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`,
);
}
return addPlaceholderString ? PLACEHOLDER : "";
},
);
// Early exit if no inline scripts found
if (!scripts.length) {
console.log(`No inline scripts found`);
return;
}
// Combine scripts and create hash
const chunk = scripts.join("\n");
const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8);
const chunkFileName = `chunk.${hash}.js`;
const chunkPath = `${assetsDir}/${chunkFileName}`;
// Write the chunk file
writeFileSync(chunkPath, chunk, "utf8");
// Replace placeholder string with script tag
const updatedFile = newFile.replace(
PLACEHOLDER,
`<script src="/assets/${chunkFileName}" crossorigin=""></script>`,
);
// Write updated HTML file
writeFileSync(file, updatedFile, "utf8");
});
console.log("Post-build script completed successfully!");
@@ -6,5 +6,5 @@ import React from "react";
export default function Redirect() {
useRedirect("/events/audit");
return <FullScreenLoading fullScreen={false} />;
return <FullScreenLoading height={"auto"} />;
}
+1 -1
View File
@@ -11,5 +11,5 @@ export default function DNS() {
router.push("/dns/nameservers");
}, [router]);
return <FullScreenLoading fullScreen={false} />;
return <FullScreenLoading height={"auto"} />;
}
-80
View File
@@ -1,80 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import dayjs from "dayjs";
import { ExternalLinkIcon } from "lucide-react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import React, { useMemo } from "react";
import ActivityIcon from "@/assets/icons/ActivityIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
import PageContainer from "@/layouts/PageContainer";
import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable";
import { usePortalElement } from "@hooks/usePortalElement";
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
export default function ProxyEventsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
const defaultFilters = useMemo(
() => ({
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
end_date: dayjs().endOf("day").toISOString(),
sort_by: "timestamp",
sort_order: "desc",
}),
[],
);
return (
<PageContainer>
<div className="p-default py-6">
<Breadcrumbs>
<Breadcrumbs.Item
label="Activity"
disabled
icon={<ActivityIcon size={13} />}
/>
<Breadcrumbs.Item
href="/events/proxy"
label="Proxy Events"
icon={<ReverseProxyIcon size={15} />}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Proxy Events</h1>
<Paragraph>
View access logs for your reverse proxy services, including allowed
and denied requests.
</Paragraph>
<Paragraph>
Learn more about{" "}
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
Proxy Events <ExternalLinkIcon size={12} />
</InlineLink>{" "}
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page="Proxy Events"
hasAccess={permission?.services?.read}
>
<ServerPaginationProvider
url="/events/proxy"
defaultPageSize={10}
defaultFilters={defaultFilters}
>
<ReverseProxyEventsTable headingTarget={portalTarget} />
</ServerPaginationProvider>
</RestrictedAccess>
</PageContainer>
);
}
+1 -1
View File
@@ -61,7 +61,7 @@ export default function NetworkRoutes() {
in our documentation.
</Paragraph>
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
<Callout className={"max-w-xl mt-3"} variant={"warning"}>
<span>
We recommend using the new Networks concept to easier visualise
and manage access to your resources.{" "}
+54 -135
View File
@@ -2,7 +2,6 @@
import Breadcrumbs from "@components/Breadcrumbs";
import Button from "@components/Button";
import ExplainButton from "@components/ExplainButton";
import Card from "@components/Card";
import {
DropdownMenu,
@@ -13,13 +12,14 @@ import {
} from "@components/DropdownMenu";
import FullTooltip from "@components/FullTooltip";
import InlineLink from "@components/InlineLink";
import Separator from "@components/Separator";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { cn, singularize } from "@utils/helpers";
import { cn } from "@utils/helpers";
import {
ArrowUpRightIcon,
HelpCircle,
Layers3Icon,
MoreVertical,
PencilLineIcon,
ServerIcon,
@@ -28,29 +28,19 @@ import {
Trash2,
} from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useMemo } from "react";
import useUrlTab from "@/hooks/useUrlTab";
import React, { useMemo, useState } from "react";
import { useSWRConfig } from "swr";
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
import { Network } from "@/interfaces/Network";
import PageContainer from "@/layouts/PageContainer";
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
import {
NetworkProvider,
useNetworksContext,
} from "@/modules/networks/NetworkProvider";
import { ResourcesTabContent } from "@/modules/networks/resources/ResourcesTabContent";
import { NetworkRoutingPeersTabContent } from "@/modules/networks/routing-peers/NetworkRoutingPeersTabContent";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection";
import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection";
export default function NetworkDetailPage() {
const queryParameter = useSearchParams();
@@ -63,34 +53,17 @@ export default function NetworkDetailPage() {
useRedirect("/networks", false, !networkId);
return network && !isLoading ? (
<ReverseProxiesProvider initialNetwork={network}>
<NetworkOverview network={network} />
</ReverseProxiesProvider>
<NetworkOverview network={network} />
) : (
<SkeletonNetwork />
<FullScreenLoading />
);
}
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
const { permission } = usePermissions();
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
NetworkResource[]
>(`/networks/${network.id}/resources`);
const { data: routers, isLoading: isRoutersLoading } = useFetchApi<
NetworkRouter[]
>(`/networks/${network.id}/routers`);
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const services = useMemo(
() => flattenReverseProxies({ reverseProxies, network }),
[reverseProxies, network],
);
const [tab, setTab] = useUrlTab(
["resources", "routing-peers", "services"],
"resources",
);
const [networkModal, setNetworkModal] = useState(false);
const { mutate } = useSWRConfig();
const isActive = !!(
network?.routing_peers_count && network.routing_peers_count > 0
@@ -98,108 +71,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
return (
<PageContainer>
<NetworkAccessControlProvider>
<NetworkProvider network={network}>
<div>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<NetworkProvider network={network}>
<div className={"p-default py-6 mb-4"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/networks"}
label={"Networks"}
disabled={!permission.networks.read}
icon={<NetworkRoutesIcon size={13} />}
/>
<Breadcrumbs.Item
href={"/network"}
label={network.name}
active={true}
/>
</Breadcrumbs>
<div className={"flex justify-between max-w-6xl"}>
<div className={"flex justify-between max-w-6xl"}>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
>
<div
className={"w-full lg:w-1/2 flex justify-between items-center"}
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<div
className={cn(
"flex items-center w-full",
!network.description && "gap-2",
)}
>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
<div className="flex items-center gap-2">
<ExplainButton />
<NetworkActions />
</div>
<NetworkInformationSquare
name={network.name}
active={isActive}
size={"lg"}
description={network.description}
/>
</div>
</div>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
<NetworkProvider network={network}>
<NetworkActions />
</NetworkProvider>
</div>
</div>
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"resources"}>
<Layers3Icon size={14} />
{singularize("Resources", network?.resources?.length)}
</TabsTrigger>
<TabsTrigger value={"routing-peers"}>
<PeerIcon
size={12}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Routing Peers", network?.routing_peers_count)}
</TabsTrigger>
<TabsTrigger value={"services"}>
<ReverseProxyIcon
size={16}
className={
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
}
/>
{singularize("Services", services.length)}
</TabsTrigger>
</TabsList>
<TabsContent value={"resources"} className={"pb-8"}>
<ResourcesTabContent
data={resources}
isLoading={isResourcesLoading}
/>
</TabsContent>
<TabsContent value={"routing-peers"} className={"pb-8"}>
<NetworkRoutingPeersTabContent
routers={routers}
isLoading={isRoutersLoading}
/>
</TabsContent>
<TabsContent value={"services"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={services}
isLoading={isServicesLoading}
/>
</TabsContent>
</Tabs>
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
<NetworkInformationCard network={network} />
</div>
</NetworkProvider>
</NetworkAccessControlProvider>
</div>
<Separator />
<ResourcesSection network={network} />
<div className={"h-3"} />
<Separator />
<NetworkRoutingPeersSection network={network} />
</NetworkProvider>
</PageContainer>
);
}
@@ -288,7 +209,6 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
<Card.List>
<Card.ListItem
tooltip={false}
data-nb-explain="High Availability"
label={
<>
<ServerIcon size={16} />
@@ -334,7 +254,6 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
/>
<Card.ListItem
tooltip={false}
data-nb-explain="Active Policies"
label={
policyCount > 0 ? (
<>
+121 -227
View File
@@ -26,7 +26,6 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import TextWithTooltip from "@components/ui/TextWithTooltip";
import useRedirect from "@hooks/useRedirect";
import useFetchApi from "@utils/api";
import { singularize } from "@utils/helpers";
import dayjs from "dayjs";
import { isEmpty, trim } from "lodash";
import {
@@ -37,14 +36,13 @@ import {
FlagIcon,
Globe,
History,
ListIcon,
MapPin,
MonitorSmartphoneIcon,
NetworkIcon,
PencilIcon,
RadioTowerIcon,
TimerResetIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { toASCII } from "punycode";
import React, { useMemo, useState } from "react";
@@ -54,27 +52,21 @@ import RoundedFlag from "@/assets/countries/RoundedFlag";
import CircleIcon from "@/assets/icons/CircleIcon";
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
import PeerIcon from "@/assets/icons/PeerIcon";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { useCountries } from "@/contexts/CountryProvider";
import PeerProvider, { usePeer } from "@/contexts/PeerProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import RoutesProvider from "@/contexts/RoutesProvider";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { Group } from "@/interfaces/Group";
import type { Peer } from "@/interfaces/Peer";
import PageContainer from "@/layouts/PageContainer";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection";
import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection";
import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection";
import ReverseProxiesProvider, {
flattenReverseProxies,
useReverseProxies,
} from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent";
import { PeerSSHToggle } from "@/modules/peer/PeerSSHToggle";
import { RDPButton } from "@/modules/remote-access/rdp/RDPButton";
import { SSHButton } from "@/modules/remote-access/ssh/SSHButton";
import Link from "next/link";
import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings";
export default function PeerPage() {
@@ -107,12 +99,10 @@ export default function PeerPage() {
/>
);
return peer && peer.id && !isLoading ? (
<ReverseProxiesProvider initialPeer={peer}>
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peer?.id} />
</PeerProvider>
</ReverseProxiesProvider>
return peer && !isLoading ? (
<PeerProvider peer={peer} key={peerId} isPeerDetailPage={true}>
<PeerOverview key={peer?.id} />
</PeerProvider>
) : (
<FullScreenLoading />
);
@@ -124,60 +114,38 @@ function PeerOverview() {
return (
<PageContainer>
<RoutesProvider>
<PeerSettingsProvider>
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item label={peer.ip} active />
</Breadcrumbs>
<PeerHeader />
</div>
<PeerOverviewTabs />
</PeerSettingsProvider>
<div className={"p-default py-6 pb-0"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/peers"}
label={"Peers"}
icon={<PeerIcon size={13} />}
/>
<Breadcrumbs.Item label={peer.ip} active />
</Breadcrumbs>
<PeerGeneralInformation />
</div>
<PeerOverviewTabs />
</RoutesProvider>
</PageContainer>
);
}
type PeerSettingsContextType = {
selectedGroups: Group[];
setSelectedGroups: React.Dispatch<React.SetStateAction<Group[]>>;
hasChanges: boolean;
updatePeer: (newName?: string) => Promise<void>;
name: string;
setName: (name: string) => void;
tab: string;
setTab: (tab: string) => void;
};
const PeerSettingsContext = React.createContext<PeerSettingsContextType | null>(
null,
);
const usePeerSettings = () => {
const context = React.useContext(PeerSettingsContext);
if (!context) {
throw new Error("usePeerSettings must be used within PeerSettingsProvider");
}
return context;
};
const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
const PeerGeneralInformation = () => {
const router = useRouter();
const { mutate } = useSWRConfig();
const { peer, peerGroups, update } = usePeer();
const { permission } = usePermissions();
const { peer, user, peerGroups, update } = usePeer();
const [name, setName] = useState(peer.name);
const [tab, setTab] = useState("overview");
const [showEditNameModal, setShowEditNameModal] = useState(false);
const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] =
useGroupHelper({
initial: peerGroups?.filter((g) => g?.name !== "All"),
peer,
});
/**
* Detect if there are changes in the peer information, if there are changes, then enable the save button.
*/
const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([
selectedGroups,
]);
@@ -207,31 +175,7 @@ const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => {
});
};
return (
<PeerSettingsContext.Provider
value={{
selectedGroups,
setSelectedGroups,
hasChanges,
updatePeer,
name,
setName,
tab,
setTab,
}}
>
{children}
</PeerSettingsContext.Provider>
);
};
const PeerHeader = () => {
const router = useRouter();
const { peer, user } = usePeer();
const { permission } = usePermissions();
const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings();
const [showEditNameModal, setShowEditNameModal] = useState(false);
const isOverviewTab = tab === "overview";
return (
<>
@@ -292,145 +236,49 @@ const PeerHeader = () => {
</div>
)}
</div>
{isOverviewTab && (
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => router.push("/peers")}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={
!hasChanges ||
!permission.peers.update ||
!permission.groups.update
}
>
Save Changes
</Button>
</div>
)}
</div>
</>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies();
const { tab, setTab } = usePeerSettings();
const flatTargets = useMemo(
() => flattenReverseProxies({ reverseProxies, peer }),
[reverseProxies, peer],
);
return (
<Tabs
defaultValue={tab}
onValueChange={setTab}
value={tab}
className={"pt-4 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
<TabsTrigger value={"overview"}>
<ListIcon size={16} />
Overview
</TabsTrigger>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.services?.read && (
<TabsTrigger value={"reverse-proxies"}>
<ReverseProxyIcon
size={16}
className="fill-nb-gray-400 group-data-[state=active]/trigger:fill-netbird"
/>
{singularize("Services", flatTargets.length)}
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
<TabsContent value={"overview"} className={"pb-8"}>
<PeerOverviewTabContent />
</TabsContent>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer?.id && permission.services?.read && (
<TabsContent value={"reverse-proxies"} className={"pb-8"}>
<ReverseProxyFlatTargetsTabContent
targets={flatTargets}
isLoading={isServicesLoading}
hideResourceColumn
emptyTableTitle={"This peer has no services"}
emptyTableDescription={
"Add your services to this peer and securely expose them through NetBird's reverse proxy"
<div className={"flex gap-4"}>
<Button
variant={"default"}
className={"w-full"}
onClick={() => router.push("/peers")}
>
Cancel
</Button>
<Button
variant={"primary"}
className={"w-full"}
onClick={() => updatePeer()}
disabled={
!hasChanges || !permission.peers.read || !permission.groups.update
}
/>
</TabsContent>
)}
>
Save Changes
</Button>
</div>
</div>
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
const PeerOverviewTabContent = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const { selectedGroups, setSelectedGroups } = usePeerSettings();
return (
<div className={"px-8"}>
<div
className={
"flex-wrap xl:flex-nowrap flex gap-10 w-full items-start pt-2 max-w-6xl"
"flex-wrap xl:flex-nowrap flex gap-10 w-full mt-5 max-w-6xl items-start"
}
>
<PeerInformationCard peer={peer} />
<div className={"flex flex-col gap-8 lg:w-1/2 transition-all"}>
<div className={"flex flex-col gap-6 lg:w-1/2 transition-all"}>
<PeerExpirationSettings />
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
{permission.groups.read && (
<div>
<Label>Assigned Groups</Label>
@@ -446,21 +294,67 @@ const PeerOverviewTabContent = () => {
/>
</div>
)}
<PeerSSHToggle />
{/* Remote Access Buttons */}
<div>
<Label>Remote Access</Label>
<HelpText>Connect directly to this peer via SSH or RDP.</HelpText>
<div className="flex gap-3">
<SSHButton peer={peer} />
<RDPButton peer={peer} />
</div>
</div>
</div>
</div>
</div>
</>
);
};
const PeerOverviewTabs = () => {
const { peer } = usePeer();
const { permission } = usePermissions();
const [tab, setTab] = useState(
permission.routes.read ? "network-routes" : "accessible-peers",
);
return (
<Tabs
defaultValue={tab}
onValueChange={(v) => setTab(v)}
value={tab}
className={"pt-10 pb-0 mb-0"}
>
<TabsList justify={"start"} className={"px-8"}>
{permission.routes.read && (
<TabsTrigger value={"network-routes"}>
<NetworkIcon size={16} />
Network Routes
</TabsTrigger>
)}
{peer?.id && permission.peers.read && (
<TabsTrigger value={"accessible-peers"}>
<MonitorSmartphoneIcon size={16} />
Accessible Peers
</TabsTrigger>
)}
{peer?.id && permission.peers.delete && (
<TabsTrigger value={"peer-job"}>
<RadioTowerIcon size={16} />
Remote Jobs
</TabsTrigger>
)}
</TabsList>
{permission.routes.read && (
<TabsContent value={"network-routes"} className={"pb-8"}>
<PeerNetworkRoutesSection peer={peer} />
</TabsContent>
)}
{peer?.id && permission.peers.read && (
<TabsContent value={"accessible-peers"} className={"pb-8"}>
<AccessiblePeersSection peerID={peer.id} />
</TabsContent>
)}
{peer.id && permission.peers.delete && (
<TabsContent value={"peer-job"} className={"pb-8"}>
<PeerRemoteJobsSection peerID={peer.id} />
</TabsContent>
)}
</Tabs>
);
};
@@ -647,9 +541,9 @@ function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) {
peer.connected
? "just now"
: dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") +
" (" +
dayjs().to(peer.last_seen) +
")"
" (" +
dayjs().to(peer.last_seen) +
")"
}
/>
+1 -1
View File
@@ -105,7 +105,7 @@ function PeersBlockedView() {
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
<div
className={
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40"
}
>
<SetupModalContent header={false} footer={false} />
@@ -1,8 +0,0 @@
import { globalMetaTitle } from "@utils/meta";
import type { Metadata } from "next";
import BlankLayout from "@/layouts/BlankLayout";
export const metadata: Metadata = {
title: `Custom Domains - Reverse Proxy - ${globalMetaTitle}`,
};
export default BlankLayout;
@@ -1,70 +0,0 @@
"use client";
import Breadcrumbs from "@components/Breadcrumbs";
import InlineLink from "@components/InlineLink";
import Paragraph from "@components/Paragraph";
import SkeletonTable from "@components/skeletons/SkeletonTable";
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
import { usePortalElement } from "@hooks/usePortalElement";
import { ExternalLinkIcon } from "lucide-react";
import React, { lazy, Suspense } from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import { usePermissions } from "@/contexts/PermissionsProvider";
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProxy";
import PageContainer from "@/layouts/PageContainer";
const CustomDomainsTable = lazy(
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
);
export default function ReverseProxyCustomDomainsPage() {
const { permission } = usePermissions();
const { ref: headingRef, portalTarget } =
usePortalElement<HTMLHeadingElement>();
return (
<PageContainer>
<div className={"p-default py-6"}>
<Breadcrumbs>
<Breadcrumbs.Item
href={"/reverse-proxy/services"}
label={"Reverse Proxy"}
icon={<ReverseProxyIcon size={16} />}
/>
<Breadcrumbs.Item
href={"/reverse-proxy/custom-domains"}
label={"Custom Domains"}
active={true}
/>
</Breadcrumbs>
<h1 ref={headingRef}>Domains</h1>
<Paragraph>
Add and manage custom domains for your reverse proxy services.
</Paragraph>
<Paragraph>
Learn more about
<InlineLink
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
target={"_blank"}
>
Custom Domains
<ExternalLinkIcon size={12} />
</InlineLink>
in our documentation.
</Paragraph>
</div>
<RestrictedAccess
page={"Custom Domains"}
hasAccess={permission?.services?.read}
>
<ReverseProxiesProvider>
<Suspense fallback={<SkeletonTable />}>
<CustomDomainsTable headingTarget={portalTarget} />
</Suspense>
</ReverseProxiesProvider>
</RestrictedAccess>
</PageContainer>
);
}
@@ -1,15 +0,0 @@
"use client";
import FullScreenLoading from "@components/ui/FullScreenLoading";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function ReverseProxyRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/reverse-proxy/services");
}, [router]);
return <FullScreenLoading fullScreen={false} />;
}
Loaded 20 of 205 files, more files were not shown because too many files have changed in this diff. Show more