Compare commits

..
Author SHA1 Message Date
riccardom b786c3395a Drop the self-hosted caveat from the Debug Bundle Upload help text
The client no longer keeps the bundle on the peer when a self-hosted deployment
publishes no destination: netbirdio/netbird now falls back to the upload service
NetBird runs for every deployment, so the field's help text described behaviour
that is not there.
2026-09-11 12:44:57 +02:00
riccardom 3c009ddc0d Add the debug-bundle upload destination to the Clients settings
A debug bundle carries peer logs, routes, DNS and firewall state, and until now
every client path that uploads one without a human picking a destination sent it
to the service NetBird runs, with no way for a self-hosted operator to point it
elsewhere. netbirdio/netbird publishes the destination to peers from the account
settings; this exposes that setting.

Empty keeps the current behaviour for cloud accounts and, for a self-hosted
deployment, makes the peers keep the bundle locally instead. The URL is
validated as https-with-host on the client too, matching what the API accepts:
the peer fetches an upload URL from this endpoint and then PUTs the bundle to
whatever comes back, so a plaintext hop would expose both.
2026-09-09 09:38:55 +02:00
75 changed files with 3233 additions and 2074 deletions

No files matched your search

-29
View File
@@ -98,24 +98,6 @@ jobs:
type=ref,event=pr
type=sha
- name: Docker meta (rootless)
id: meta_rootless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKERHUB_IMAGE }}
${{ env.GHCR_IMAGE }}
flavor: |
latest=false
suffix=-rootless,onlatest=true
tags: |
type=schedule
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
type=ref,event=pr
type=sha
- name: Log in to Docker Hub
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
@@ -141,18 +123,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Build and push rootless Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.rootless
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64,linux/arm
tags: ${{ steps.meta_rootless.outputs.tags }}
labels: ${{ steps.meta_rootless.outputs.labels }}
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
+12 -24
View File
@@ -11,7 +11,6 @@
# ./build.sh # build + export netbird-dashboard.tar.gz
# IMAGE_TAG=v1 ./build.sh # override the image tag
# SKIP_BUILD=1 ./build.sh # reuse an existing out/ (skip npm build)
# ROOTLESS=1 ./build.sh # build the amd64-rootless image variant
#
set -euo pipefail
@@ -20,26 +19,8 @@ cd "$(dirname "$0")"
IMAGE_NAME="${IMAGE_NAME:-netbird-dashboard}"
IMAGE_TAG="${IMAGE_TAG:-amd64}"
DOCKERFILE="docker/Dockerfile"
CONTAINER_PORT=80
OUTPUT_SUFFIX=""
case "${ROOTLESS:-0}" in
0) ;;
1)
DOCKERFILE="docker/Dockerfile.rootless"
CONTAINER_PORT=8080
OUTPUT_SUFFIX="-rootless"
[[ "${IMAGE_TAG}" == *-rootless ]] || IMAGE_TAG="${IMAGE_TAG}-rootless"
;;
*)
echo "ERROR: ROOTLESS must be 0 or 1." >&2
exit 1
;;
esac
IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}${OUTPUT_SUFFIX}.tar.gz}"
OUTPUT="${OUTPUT:-${IMAGE_NAME}.tar.gz}"
# Build the static export (out/) unless told to reuse an existing one. Next 16
# no longer runs ESLint during `next build`; types are checked by the build via
@@ -58,10 +39,17 @@ if [[ ! -d out ]]; then
exit 1
fi
# The .dockerignore lives under docker/, but Docker reads it from the build
# context root. Stage it for the duration of the build so node_modules/.next
# don't get shipped into the build context.
cleanup() { rm -f .dockerignore; }
trap cleanup EXIT
cp docker/.dockerignore .dockerignore
echo "==> Building ${IMAGE} for linux/amd64"
docker build \
--platform linux/amd64 \
-f "${DOCKERFILE}" \
-f docker/Dockerfile \
-t "${IMAGE}" \
.
@@ -72,13 +60,13 @@ echo "==> Done: ${OUTPUT} ($(du -h "${OUTPUT}" | cut -f1))"
echo
echo "Copy and run on the server:"
echo " scp ${OUTPUT} user@your-server:/tmp/"
echo " docker load < /tmp/${OUTPUT##*/}"
echo " docker load < /tmp/${OUTPUT}"
echo
echo "Run (nginx serves on container port ${CONTAINER_PORT}). Set the runtime env vars the"
echo "Run (nginx serves on container port 80). Set the runtime env vars the"
echo "dashboard templates at startup — for the Agent Network onboarding test,"
echo "flip NETBIRD_AGENT_NETWORK_ONLY=true:"
echo " docker run -d --name ${IMAGE_NAME} --restart unless-stopped \\"
echo " -p 8080:${CONTAINER_PORT} \\"
echo " -p 8080:80 \\"
echo " -e NETBIRD_MGMT_API_ENDPOINT=https://your-mgmt:443 \\"
echo " -e AUTH_AUTHORITY=https://your-idp/realms/netbird \\"
echo " -e AUTH_CLIENT_ID=netbird-dashboard \\"
+1 -2
View File
@@ -24,6 +24,5 @@
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS",
"announcement": "$NETBIRD_ANNOUNCEMENT"
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
}
File renamed without changes.
-29
View File
@@ -1,29 +0,0 @@
FROM alpine:3.24
RUN apk add --no-cache bash ca-certificates gettext nginx tzdata && \
rm -rf /var/cache/apk/* && \
mkdir -p /tmp/nginx
STOPSIGNAL SIGINT
EXPOSE 8080
WORKDIR /usr/share/nginx/html
COPY docker/default.conf /etc/nginx/http.d/default.conf
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/rootless.conf /etc/nginx/http.d/rootless.conf
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
COPY out/ /usr/share/nginx/html/
RUN sed -i \
-e 's/listen 80 default_server;/listen 8080 default_server;/' \
-e 's/listen \[::\]:80 default_server;/listen [::]:8080 default_server;/' \
/etc/nginx/http.d/default.conf && \
sed -i '/^user nginx;$/d' /etc/nginx/nginx.conf && \
chmod +x /usr/local/init_react_envs.sh && \
chgrp -R 0 /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx && \
chmod -R g=u /etc/nginx/http.d /usr/share/nginx/html /tmp/nginx
USER 10001:0
ENTRYPOINT ["/usr/local/init_react_envs.sh"]
CMD ["/usr/sbin/nginx", "-e", "/dev/stderr", "-g", "pid /tmp/nginx.pid;"]
+10 -46
View File
@@ -1,59 +1,23 @@
# NetBird Dashboard
NetBird Dashboard is NetBird's management server UI.
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
## Tags
```latest``` ```vX.X.X``` not available yet.
Each published image tag also has a rootless variant with the `-rootless`
suffix. For example, `main` is also published as `main-rootless`.
```main``` built on every PR being merged to the repository
## How to use this image
HTTP:
HTTP run:
```shell
docker run -d --rm -p 80:80 netbirdio/dashboard:main
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
```
Using an SSL certificate from Let's Encrypt:
Using SSL certificate from Let's Encrypt®:
```shell
docker run -d --rm -p 80:80 -p 443:443 \
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
netbirdio/dashboard:main
```
For certificate generation, the server needs a public IP and a domain name
pointing to it.
### Rootless and OpenShift
The rootless image listens on port `8080`, runs as UID `10001` by default, and
supports OpenShift's arbitrary UID when the container retains GID `0`:
```shell
docker run -d --rm \
--user 12345:0 \
-p 8080:8080 \
-e AUTH_AUTHORITY=https://idp.example.com \
-e AUTH_CLIENT_ID=netbird-dashboard \
-e AUTH_AUDIENCE=netbird-dashboard \
-e AUTH_SUPPORTED_SCOPES='openid profile email' \
-e USE_AUTH0=false \
-e NETBIRD_MGMT_API_ENDPOINT=https://api.example.com \
netbirdio/dashboard:main-rootless
```
The rootless image does not include Certbot or expose HTTPS. Terminate TLS at
the OpenShift Route, Ingress, or another reverse proxy.
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
## Environment variables
- `NGINX_SSL_PORT` changes the port that Nginx listens to. Defaults to `443`.
- `LETSENCRYPT_DOMAIN` enables Certbot for the specified domain. Defaults to
`none`.
- `LETSENCRYPT_EMAIL` is used to register the certificate request. Defaults to
`example@local`.
These certificate variables apply only to the standard image.
* ```NGINX_SSL_PORT``` Changes the port that Nginx listens to. Defaults to ```443```
* ```LETSENCRYPT_DOMAIN``` Enables Certbot`s client execution for the specified domain. Defaults to ```none```
* ```LETSENCRYPT_EMAIL``` Email used in Certbot`s client execution to register the certificate request. Defaults to ```example@local```
+1 -9
View File
@@ -73,8 +73,6 @@ export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
# A permanent banner for this deployment, shown to every user; empty for none.
export NETBIRD_ANNOUNCEMENT=${NETBIRD_ANNOUNCEMENT}
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
@@ -165,7 +163,7 @@ sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /
}
# 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_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS \$\$NETBIRD_ANNOUNCEMENT"
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_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_AGENT_NETWORK_ONLY \$\$NETBIRD_AGENT_NETWORK_ENABLED \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
@@ -175,12 +173,6 @@ for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
rm "$f".copy
done
# When a command is provided, initialization happens before the server starts.
# The rootless image uses this path so nginx never needs to be reloaded.
if (( $# > 0 )); then
exec "$@"
fi
# Reload nginx so the patched CSP header takes effect.
# supervisord starts nginx (priority 100) before this script (priority 201) and
# never reloads it, so without this nginx keeps serving the static default.conf
-7
View File
@@ -1,7 +0,0 @@
# nginx includes this file inside the http context.
# All runtime state stays in a directory writable by the arbitrary UID used by OpenShift.
client_body_temp_path /tmp/nginx/client_temp;
proxy_temp_path /tmp/nginx/proxy_temp;
fastcgi_temp_path /tmp/nginx/fastcgi_temp;
uwsgi_temp_path /tmp/nginx/uwsgi_temp;
scgi_temp_path /tmp/nginx/scgi_temp;
-1
View File
@@ -1,7 +1,6 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
+1 -1
View File
@@ -19,7 +19,7 @@ check_docker_compose() {
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
$DOCKER_COMPOSE_COMMAND down --volumes
rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
rm -rf proxy-certs proxy-certs-no-ports
rm -f ../../.test-config.json ../playwright.env.json
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
+1 -45
View File
@@ -532,7 +532,7 @@ initEnvironment() {
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
echo "You can use the following commands:"
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
echo " rm -f docker-compose.yml Caddyfile agentgateway-stub.Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
exit 1
fi
@@ -540,7 +540,6 @@ initEnvironment() {
echo Rendering initial files...
renderDockerCompose > docker-compose.yml
renderCaddyfile > Caddyfile
renderAgentGatewayStub > agentgateway-stub.Caddyfile
renderZitadelEnv > zitadel.env
echo "" > turnserver.conf
echo "" > management.json
@@ -613,30 +612,6 @@ initEnvironment() {
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
}
# Saving an Agent Network provider makes management call the upstream and
# refuses the save unless it answers a model listing, so a provider spec needs
# a reachable OpenAI-compatible endpoint. agentgateway is self-hosted and has
# no public one; this stub stands in for it, inside the compose network where
# management can reach it and without depending on a vendor being up.
renderAgentGatewayStub() {
cat <<'EOF'
{
admin off
auto_https off
}
:8088 {
handle /v1/models {
header Content-Type application/json
respond `{"object":"list","data":[{"id":"agentgateway-e2e-model","object":"model","owned_by":"netbird-e2e"}]}` 200
}
handle {
respond 404
}
}
EOF
}
renderCaddyfile() {
cat <<EOF
{
@@ -843,15 +818,6 @@ services:
volumes:
- netbird_caddy_data:/data
- ./Caddyfile:/etc/caddy/Caddyfile
# Stand-in upstream for the Agent Network provider specs: answers the model
# listing management probes before it accepts a provider save. Reachable
# only from inside the compose network, at http://agentgateway-stub:8088.
agentgateway-stub:
image: caddy
restart: unless-stopped
networks: [ netbird ]
volumes:
- ./agentgateway-stub.Caddyfile:/etc/caddy/Caddyfile
# Management
management:
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
@@ -926,16 +892,6 @@ services:
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
# Declare the private capability so this cluster can host the agent
# network endpoint. That endpoint is a private service — reachable only
# from connected peers — and management refuses to bootstrap it onto a
# cluster without a private-capable proxy; the connect-provider wizard
# reads the same flag (supports_private) to pick its cluster. Without
# it no cluster qualifies and the wizard stops at the provider tab,
# which strands every spec that walks through it. The no-ports proxy
# below stays centralised, so the pick has to skip past a cluster that
# does not qualify.
- NB_PROXY_PRIVATE=true
volumes:
- ./proxy-certs:/certs:ro
command: [
+1 -1
View File
@@ -33,7 +33,7 @@ export async function loginToApp(
// Use locators that match either outcome — Playwright auto-waits.
const appReady = page.getByTestId("left-navigation-item").first();
const setupModal = page.getByTestId("setup-netbird-modal");
const approvalPending = page.getByTestId("pending-approval");
const approvalPending = page.getByText("User Approval Pending");
const onboarding = page.getByText("Add new device to your network");
const selectAccount = page.getByText("Select account");
const loginInput = page.locator("input[id=loginName]");
-25
View File
@@ -24,31 +24,6 @@ export async function openControlCenter(page: Page, tab?: FlowView) {
await dismissBlockingOverlays(page);
}
/**
* Opens the groups view already showing `groupName`.
*
* The view picks its own group on first render (getFirstGroup: the highest
* peers_count that is a policy source), and that ranking runs over every group
* in the account — including ones a spec on the other worker seeded moments
* ago. Deleting the spec's own fixtures first is not enough to pin it, so
* select the group by name and let the view rebuild around it.
*/
export async function openGroupView(page: Page, groupName: string) {
await openControlCenter(page, "groups");
const selector = canvasNode(page, "select-group-node");
await expect(selector).toBeVisible();
// Unforced on purpose: the opening fitView animates the canvas, and the
// actionability check is what waits for the node to stop moving.
await selector.click();
await page.getByTestId("select-dropdown-search").fill(groupName);
await page
.locator('[role="option"]')
.filter({ hasText: groupName })
.first()
.click({ force: true });
await expect(selector).toContainText(groupName);
}
export async function switchFlowView(page: Page, view: FlowView) {
await dismissBlockingOverlays(page);
await page.getByTestId(`cc-flow-${view}`).click({ force: true });
@@ -59,12 +59,7 @@ test.describe
.getByTestId("agent-network-provider-option-agentgateway")
.click({ force: true });
// Saving a provider makes management call the upstream, and it refuses
// the save with a 422 unless the upstream answers a model listing — so a
// made-up host cannot be used here. agentgateway is self-hosted and has
// no public endpoint to point at, so create-test-env.sh runs a stub that
// answers that probe inside the compose network.
const upstreamURL = "http://agentgateway-stub:8088";
const upstreamURL = "https://agentgateway.e2e.example";
await page
.getByTestId("agent-network-provider-upstream-url")
.fill(upstreamURL);
@@ -1,206 +0,0 @@
/**
* Agent Network bootstrap-cluster spec.
*
* The account's agent network endpoint is assigned once, from the proxy
* cluster the connect-provider wizard pins it to, and it is immutable after
* that. Not every live cluster can host it: the endpoint is a private service,
* reachable only from connected peers and authorised by their tunnel identity,
* which only a cluster with private capabilities can serve. Management
* reports that per cluster as supports_private and refuses a
* bootstrap onto a cluster without it.
*
* This spec pins the wizard's half of that contract: it picks a
* private-capable cluster even when a centralised one is listed first, and it
* blocks setup — with a message naming what is missing — when no listed
* cluster qualifies. Both the settings and domains routes are mocked, so the
* assertions do not depend on which clusters the test backend happens to have
* connected, and no provider is created against it.
*/
import { type Browser, expect, type Page, test } from "@playwright/test";
import { loginToApp } from "../helpers/auth";
const AGENT_NETWORK_CONFIG_KEY = "netbird-test-agent-network";
// Listed first, and unusable: a centralised proxy cluster. Picking it is the
// bug this spec guards against.
const CENTRAL_CLUSTER = "central.proxy.netbird.io";
// Listed second, and the only valid pick.
const PRIVATE_CLUSTER = "private.proxy.netbird.io";
const UNBOOTSTRAPPED_DEFAULTS = {
endpoint: "",
proxy_address: "",
dedicated: false,
enable_log_collection: true,
enable_prompt_collection: false,
redact_pii: false,
access_log_retention_days: 30,
};
const BOOTSTRAPPED_SETTINGS = {
...UNBOOTSTRAPPED_DEFAULTS,
endpoint: `violet.${PRIVATE_CLUSTER}`,
proxy_address: PRIVATE_CLUSTER,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
/** A free (cluster-backed) domain as /reverse-proxies/domains reports it. */
function freeDomain(name: string, supportsPrivate: boolean) {
return {
id: name,
domain: name,
validated: true,
type: "free",
supports_private: supportsPrivate,
supports_custom_ports: true,
require_subdomain: true,
};
}
async function json(route: import("@playwright/test").Route, body: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
}
/**
* Opens the providers page for an account that has not been bootstrapped,
* with the given clusters on the domains endpoint. Returns the page plus a
* promise-friendly getter for the bootstrap POST body, which is captured and
* answered locally so the real backend is never mutated.
*/
async function openWizard(
browser: Browser,
domains: ReturnType<typeof freeDomain>[],
): Promise<{ page: Page; close: () => Promise<void> }> {
const context = await browser.newContext({
storageState: "e2e/fixtures/auth/owner.json",
});
try {
await context.addInitScript(
([key, value]) => {
try {
window.localStorage.setItem(key as string, value as string);
} catch (e) {}
},
[AGENT_NETWORK_CONFIG_KEY, "enabled"],
);
const page = await context.newPage();
await page.route("**/api/agent-network/settings", (route) => {
const method = route.request().method();
if (method === "GET") return json(route, UNBOOTSTRAPPED_DEFAULTS);
if (method === "POST") return json(route, BOOTSTRAPPED_SETTINGS);
return route.continue();
});
await page.route("**/api/reverse-proxies/domains", (route) =>
route.request().method() === "GET"
? json(route, domains)
: route.continue(),
);
// Keep the create hermetic: the wizard only reaches this once the
// bootstrap it does first has succeeded.
await page.route("**/api/agent-network/providers", (route) =>
route.request().method() === "POST"
? json(route, { id: "prov-e2e", name: "e2e-provider" })
: route.continue(),
);
await loginToApp(page, "owner");
await page.goto("/agent-network/providers");
await page.keyboard.press("Escape");
return { page, close: () => context.close() };
} catch (e) {
await context.close();
throw e;
}
}
/** Fills the provider tab with the minimum a create needs. */
async function fillProviderTab(page: Page) {
await page
.getByPlaceholder("https://api.openai.com")
.fill("https://api.openai.com");
await page.getByPlaceholder("sk-...").first().fill("sk-e2e-bootstrap-key");
}
test.describe("Agent Network bootstrap cluster @agent-network", () => {
test("bootstraps onto a private-capable cluster, skipping a centralised one listed first", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
freeDomain(PRIVATE_CLUSTER, true),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
// The unusable cluster is listed, but it is not a blocker: a usable one
// exists, so the wizard proceeds without the warning.
await expect(
page.getByTestId("agent-network-no-cluster-callout"),
).toHaveCount(0);
await fillProviderTab(page);
const bootstrap = page.waitForResponse(
(resp) =>
resp.url().includes("/agent-network/settings") &&
resp.request().method() === "POST",
{ timeout: 30_000 },
);
await page
.getByRole("button", { name: "Continue" })
.click({ force: true });
await page
.getByRole("button", { name: "Connect Provider" })
.last()
.click({ force: true });
const payload = (await bootstrap).request().postDataJSON();
expect(payload.proxy_address).toBe(PRIVATE_CLUSTER);
} finally {
await close();
}
});
test("blocks setup when no listed cluster has private capabilities", async ({
browser,
}) => {
const { page, close } = await openWizard(browser, [
freeDomain(CENTRAL_CLUSTER, false),
]);
try {
await page
.getByRole("button", { name: "Connect Provider" })
.first()
.click({ force: true });
const callout = page.getByTestId("agent-network-no-cluster-callout");
await expect(callout).toBeVisible({ timeout: 10_000 });
await expect(callout).toContainText("private capabilities");
// The fix lives in the agent network's own Clusters section, not the
// generic Reverse Proxy page, so that is where the callout sends users.
await expect(callout.getByRole("link")).toHaveAttribute(
"href",
"/agent-network/configuration?tab=clusters",
);
// With nothing to pin to there is no bootstrap to make, so the wizard
// cannot advance — rather than sending a request management refuses.
await fillProviderTab(page);
await expect(
page.getByRole("button", { name: "Continue" }),
).toBeDisabled();
} finally {
await close();
}
});
});
@@ -364,17 +364,13 @@ test.describe.serial("Control Center Draft Matrix @control-center", () => {
}) => {
const group = await place(page, "group", 0.6, 0.5);
await expectChangeCount(page, 1);
// React Flow ignores delete keys while an input is focused, and the panel's
// search takes focus on click, so blur it before pressing Backspace.
await group.click();
await expect(group).toHaveClass(/selected/);
// React Flow ignores delete keys while an input is focused, and the panel
// the click opens takes focus into its search — from an effect, about
// 100ms later. Blurring before that lands leaves the input to claim focus
// afterwards and swallow the Backspace, so wait for the focus, then drop
// it.
const panelSearch = page.locator("#cc-group-panel input").first();
await expect(panelSearch).toBeFocused();
await panelSearch.blur();
await expect(panelSearch).not.toBeFocused();
await page.evaluate(
() => (document.activeElement as HTMLElement | null)?.blur(),
);
await page.keyboard.press("Backspace");
await expect(canvasNode(page, "group-new-")).toHaveCount(0);
await expectChangeCount(page, 0);
@@ -19,7 +19,6 @@ import {
dismissBlockingOverlays,
enterDraft,
openControlCenter,
openGroupView,
resetDraftState,
reviewButton,
} from "../helpers/control-center";
@@ -38,6 +37,7 @@ test.describe
await deleteGroupsByPrefix(page, PREFIX);
});
// Clean-slated so the draft group view's auto-select is deterministic.
async function seedPolicyGroupView(page: Page) {
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -49,9 +49,7 @@ test.describe
src.id,
dst.id,
);
// Selected by name: the view's own pick ranks every group in the account,
// so a peer-bearing group from another spec on the other worker wins it.
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
await enterDraft(page);
const policyNode = canvasNode(page, `policy-${policy.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
+14 -7
View File
@@ -18,7 +18,6 @@ import {
clickContextMenuItem,
dismissBlockingOverlays,
openControlCenter,
openGroupView,
resetDraftState,
switchFlowView,
} from "../helpers/control-center";
@@ -48,6 +47,8 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
});
async function seedPolicyAndOpenGroupView(page: Page, enabled = true) {
// The group view auto-selects a group that HAS a policy, so stale policies
// would make the rendered group non-deterministic.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -63,10 +64,15 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
const policyNode = canvasNode(page, `policy-${policy.id}`);
// Selecting src by name rather than trusting the view's own pick: the
// auto-select ranks every group in the account, so a peer-bearing group
// another spec seeded on the other worker outranks this one.
await openGroupView(page, src.name);
// A stale SWR read or a build/fit race can fail to place the node.
for (let attempt = 0; attempt < 3; attempt++) {
await openControlCenter(page, "groups");
const shown = await policyNode
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (shown) break;
}
await expect(policyNode).toBeVisible({ timeout: 10_000 });
return { src, dst, policy, policyNode };
}
@@ -506,6 +512,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
test("Should save group resource membership in live mode (correct payload)", async ({
dashboardAsOwner: page,
}) => {
// Clean slate so the group view deterministically lands on our group.
await deletePoliciesBySubstring(page, PREFIX);
await deleteGroupsByPrefix(page, PREFIX);
@@ -522,7 +529,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
[],
);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const groupNode = canvasNode(page, `group-${dst.id}`);
await expect(groupNode).toBeVisible({ timeout: 15_000 });
@@ -603,7 +610,7 @@ test.describe.serial("Control Center Live Mode @control-center", () => {
);
await createPolicy(page, generateRandomName(PREFIX), src.id, d2.id);
await openGroupView(page, src.name);
await openControlCenter(page, "groups");
const policyNode = canvasNode(page, `policy-${p1.id}`);
await expect(policyNode).toBeVisible({ timeout: 15_000 });
await expect(
+1 -1
View File
@@ -102,7 +102,7 @@ async function loginAndSave(
const skipButton = page.locator("button[name=skip]");
const appNav = page.getByTestId("left-navigation-item").first();
const modal = page.getByTestId("setup-netbird-modal");
const approval = page.getByTestId("pending-approval");
const approval = page.getByText("User Approval Pending");
let after_login: "2fa" | "app" | "modal" | "approval";
try {
@@ -20,7 +20,7 @@ test.describe.serial("User Approval & Billing Admin @team", () => {
});
const page = await context.newPage();
await loginToApp(page, "user");
await expect(page.getByTestId("pending-approval")).toBeVisible();
await expect(page.getByText("User Approval Pending")).toBeVisible();
await context.close();
});
Loaded 20 of 75 files, more files were not shown because too many files have changed in this diff. Show more