Compare commits

..
387 changed files with 8398 additions and 52796 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["next/core-web-vitals","prettier"],
"plugins": ["simple-import-sort"],
"rules": {
"simple-import-sort/imports": [
"warn",
{
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
}
],
"simple-import-sort/exports": "warn"
}
}
+2 -34
View File
@@ -98,33 +98,13 @@ 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
with:
username: ${{ secrets.NB_DOCKER_USER }}
password: ${{ secrets.NB_DOCKER_TOKEN }}
- name: Log in to the GitHub Container registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -136,23 +116,11 @@ jobs:
with:
context: .
file: docker/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
push: true
platforms: linux/amd64,linux/arm64,linux/arm
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: |
- run: |
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
echo '${{ steps.meta_rootless.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
-81
View File
@@ -1,81 +0,0 @@
name: Bump netbird wasm client
on:
workflow_dispatch:
inputs:
tag:
description: 'NetBird release tag (e.g. v0.78.0)'
required: true
type: string
concurrency:
group: ${{ github.workflow }}-${{ inputs.tag }}
cancel-in-progress: false
jobs:
bump:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
token: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
- name: Configure git
run: |
git config --global user.email "dev@netbird.io"
git config --global user.name "netbirddev"
# The tag rides in env and is validated before use, so a crafted dispatch
# input cannot expand into shell source or reach the sed expression.
- name: Update the default wasm path
id: bump
env:
TAG: ${{ inputs.tag }}
run: |
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Refusing tag '$TAG': expected vMAJOR.MINOR.PATCH"
exit 1
fi
sed -i "s|https://pkgs.netbird.io/wasm/client/v[0-9.]*|https://pkgs.netbird.io/wasm/client/${TAG}|" src/utils/config.ts
branch="bump-netbird-wasm-${TAG#v}"
echo "branch=${branch}" >> "$GITHUB_OUTPUT"
# The branch name is derived from the tag, so a re-dispatch of the
# same release would collide with the branch and pull request an
# earlier run left behind.
if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Branch ${branch} already exists, ${TAG} was handled by an earlier run"
exit 0
fi
git checkout -b "${branch}"
git add src/utils/config.ts
if git diff --cached --quiet; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "Default wasm path already at ${TAG}, nothing to do"
exit 0
fi
echo "skip=false" >> "$GITHUB_OUTPUT"
git commit -m "Bump the WASM client to ${TAG}"
git push --set-upstream origin "${branch}"
- name: Create pull request
if: steps.bump.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.NETBIRD_DEV_BOT_GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
BRANCH: ${{ steps.bump.outputs.branch }}
run: |
gh pr create \
--base main \
--head "$BRANCH" \
--title "Bump the WASM client to ${TAG}" \
--body "Automated wasm path bump triggered by netbird release ${TAG}."
+3 -47
View File
@@ -101,61 +101,17 @@ jobs:
echo "Using reverse-proxy tag: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Restore geolocation database cache
id: geo-cache
uses: actions/cache/restore@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
- name: Build dashboard and set up test environment
- name: Setup test environment
env:
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
# The docker environment (image pulls, Zitadel bootstrap) is
# network-bound and the Next build is CPU-bound, so they overlap.
# The build reads .test-config.json via the @/config/test alias,
# and create-test-env.sh renders that file only after the Zitadel
# bootstrap — so the build waits for it before starting.
run: |
(cd ./e2e/environment && bash create-test-env.sh) > env-setup.log 2>&1 &
ENV_PID=$!
until [ -s .test-config.json ] && jq empty .test-config.json 2>/dev/null; do
if ! kill -0 "$ENV_PID" 2>/dev/null; then break; fi
sleep 2
done
if ! [ -s .test-config.json ]; then
echo "::error::create-test-env.sh exited before rendering .test-config.json"
cat env-setup.log
exit 1
fi
# Lowest CPU priority: the containers must win the scheduler, or
# the build can starve management past the proxy-registration wait.
if ! nice -n 19 env APP_ENV=test npx next build; then
kill "$ENV_PID" 2>/dev/null || true
exit 1
fi
if ! wait "$ENV_PID"; then
echo "::error::Test environment setup failed"
cat env-setup.log
exit 1
fi
cat env-setup.log
# Saved right after env setup rather than in a post step, so a later
# test failure doesn't lose the cache.
- name: Save geolocation database cache
if: always() && steps.geo-cache.outputs.cache-hit != 'true' && hashFiles('e2e/environment/geo-cache/**') != ''
uses: actions/cache/save@v4
with:
path: e2e/environment/geo-cache
key: geolite-dbs-v1
run: cd ./e2e/environment && bash create-test-env.sh
- name: Run Playwright tests
id: playwright
run: |
set -o pipefail
npx playwright test --config=e2e/playwright.config.ts 2>&1 | tee playwright-output.log
npm run test:ci 2>&1 | tee playwright-output.log
- name: Append Playwright summary to job summary
if: always() && hashFiles('e2e/test-results/results.json') != ''
-32
View File
@@ -1,32 +0,0 @@
name: Unit Tests
on:
push:
branches:
- main
pull_request:
# Cancel superseded runs on the same ref so a new push doesn't queue behind an
# outdated one (mirrors build_and_push.yml).
concurrency:
group: unit-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
vitest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: setup-node
uses: actions/setup-node@v5
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
-1
View File
@@ -44,7 +44,6 @@ e2e/test-results/
e2e/playwright-report/
/test-results/
/playwright-report/
playwright-report*.zip
.configs/.local-config.zitadel.json
.configs/.staging-config.json
.configs/.temp-config.json
+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 \\"
File renamed without changes.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM alpine:3.24
FROM alpine:3.14
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
nginx curl supervisor certbot-nginx && \
-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```
-6
View File
@@ -173,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
@@ -218,7 +218,6 @@ await visitByNavigation(page, "Policies"); // Click child
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
| Control Center | `control-center-*.spec.ts` (`-draft`, `-draft-connections`, `-draft-matrix`, `-draft-extras`, `-draft-install`, `-draft-remove`, `-draft-shortcuts`, `-deploy`, `-drilldown`, `-live`, `-peers`) | `@control-center` |
## Debugging
+1 -4
View File
@@ -1,7 +1,6 @@
# Ignore zitadel environment
.env
Caddyfile
agentgateway-stub.Caddyfile
management.json
turnserver.conf
zitadel.env
@@ -10,6 +9,4 @@ proxy-no-ports.env
proxy-certs/
proxy-certs-no-ports/
docker-compose.yml
/machinekey
geo-cache/
geo-dump/
/machinekey
+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
+3 -87
View File
@@ -61,13 +61,7 @@ wait_proxy_cluster() {
SERVICE_NAME=${1:-reverse-proxy}
echo -n "Waiting for $SERVICE_NAME to register with management "
set +e
# 480s: management downloads its geolocation databases on first boot
# (the proxy specs need /locations/countries, so it cannot be disabled
# there), and a slow pkgs.netbird.io can stall that well past the old
# 120s budget. The proxies skip their download entirely via
# NB_PROXY_DISABLE_GEOLOCATION. The loop exits as soon as the sync
# lands, so healthy runs pay nothing.
local attempts=240
local attempts=60
local i
for ((i = 1; i <= attempts; i++)); do
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
@@ -532,7 +526,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 +534,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
@@ -596,8 +589,6 @@ initEnvironment() {
echo -e "\nCreating proxy access tokens...\n"
init_proxy_tokens
exportGeoDatabases
echo -e "\nStarting reverse proxy services...\n"
$DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports
@@ -613,30 +604,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
{
@@ -793,41 +760,7 @@ ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DA
EOF
}
# Copies management's geolocation databases out of its data volume into
# ./geo-cache so CI can cache them across runs. Management has finished
# loading them by the time its API serves requests. A no-op when the
# cache already holds both files, and never fatal: a failed export just
# means the next run downloads again.
exportGeoDatabases() {
mkdir -p geo-cache
if compgen -G "geo-cache/GeoLite2-City_*.mmdb" > /dev/null \
&& compgen -G "geo-cache/geonames_*.db" > /dev/null; then
return
fi
echo -e "\nExporting geolocation databases for caching...\n"
rm -rf geo-dump
if $DOCKER_COMPOSE_COMMAND cp management:/var/lib/netbird ./geo-dump; then
cp geo-dump/GeoLite2-City_*.mmdb geo-dump/geonames_*.db geo-cache/ 2>/dev/null \
|| echo "WARN: no geolocation databases found in management data dir"
rm -rf geo-dump
else
echo "WARN: could not copy management data dir; geolocation databases not cached"
fi
}
renderDockerCompose() {
# Cached geolocation databases (restored by CI into ./geo-cache) are
# mounted into management's data dir so it skips the slow first-boot
# download from pkgs.netbird.io. With no cache the mounts are omitted
# and management downloads as before.
local geo_mounts=""
local f
for f in geo-cache/GeoLite2-City_*.mmdb geo-cache/geonames_*.db; do
[ -f "$f" ] || continue
geo_mounts+="
- ./${f}:/var/lib/netbird/$(basename "$f")"
done
cat <<EOF
version: "3.4"
services:
@@ -843,15 +776,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}
@@ -870,7 +794,7 @@ services:
- NB_TRAFFIC_FLOW_INTERVAL=20s
- NB_SINGLE_INSTANCE_MODE=true
volumes:
- netbird_management:/var/lib/netbird${geo_mounts}
- netbird_management:/var/lib/netbird
- ./management.json:/etc/netbird/management.json
command: [
"--port", "80",
@@ -921,11 +845,6 @@ services:
networks: [netbird]
env_file:
- ./proxy.env
environment:
# No spec exercises country-based enforcement, and skipping the
# GeoLite2 download removes a startup stall of up to 2 minutes
# when pkgs.netbird.io is slow.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
- ./proxy-certs:/certs:ro
command: [
@@ -947,9 +866,6 @@ services:
networks: [netbird]
env_file:
- ./proxy-no-ports.env
environment:
# See the primary proxy: no spec needs geo enforcement.
- NB_PROXY_DISABLE_GEOLOCATION=true
volumes:
# Distinct cert dir so this proxy has a distinct identity from the
# primary proxy; a shared cert makes both register under the same
+82 -269
View File
File diff suppressed because it is too large. Load diff
+1 -9
View File
@@ -117,15 +117,7 @@ export async function loginToApp(
* Use this instead of page.goto() for in-app navigation after loginToApp().
*/
export async function navigateTo(page: Page, path: string) {
// A client-side route update still in flight from the previous test (the
// dashboard page is shared per worker) aborts the navigation. Retry once —
// by then the app-driven navigation has landed.
try {
await page.goto(path, { waitUntil: "domcontentloaded" });
} catch (e) {
if (!String(e).includes("ERR_ABORTED")) throw e;
await page.goto(path, { waitUntil: "domcontentloaded" });
}
await page.goto(path, { waitUntil: "domcontentloaded" });
const modal = page.getByTestId("setup-netbird-modal");
try {
await modal.waitFor({ state: "visible", timeout: 3_000 });
-260
View File
@@ -1,260 +0,0 @@
import { Page, Locator, expect } from "@playwright/test";
import { navigateTo } from "./auth";
import { clearScrollLock, setTestEdition } from "./utils";
// The test build's billing modal backdrop swallows canvas clicks.
async function disableCloudBilling(page: Page) {
await setTestEdition(page, "licensed");
}
export async function dismissBlockingOverlays(page: Page) {
await clearScrollLock(page);
}
export type FlowView = "peers" | "users" | "groups" | "networks";
export async function openControlCenter(page: Page, tab?: FlowView) {
await disableCloudBilling(page);
await navigateTo(
page,
tab ? `/control-center?tab=${tab}` : "/control-center",
);
// The pane hides behind cc-prefit until the first fitView.
await expect(page.locator(".react-flow__pane")).toBeVisible();
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 });
await expect(page.getByTestId(`cc-flow-${view}`)).toHaveAttribute(
"data-state",
"active",
);
}
export async function enterDraft(page: Page) {
await dismissBlockingOverlays(page);
await page.getByTestId("cc-mode-draft").click({ force: true });
await page.getByTestId("cc-draft-use-current-option").click({ force: true });
// Waiting for the Add button also guarantees draft mode is fully active.
await expect(page.getByTestId("cc-toolbar-add")).toBeVisible();
}
// Draft state is React-only, so a full page load discards it.
export async function resetDraftState(page: Page) {
await openControlCenter(page);
}
export async function readDraftChanges(page: Page): Promise<any[]> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftChanges?: any[] }).__ccDraftChanges ??
[],
);
}
async function centerOf(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("element has no layout");
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
}
async function mouseDrag(
page: Page,
from: { x: number; y: number },
to: { x: number; y: number },
) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
// Intermediate moves so drag thresholds see a realistic pointer path.
const steps = 12;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
}
await page.mouse.up();
// Let the drop handler commit the new node before callers assert on it.
await page.waitForTimeout(120);
}
// Networks share the Resources tab.
function categoryForTemplate(templateTestId: string): string {
if (templateTestId.includes("peer")) return "peers";
if (templateTestId.includes("policy")) return "policies";
if (templateTestId.includes("group")) return "groups";
return "resources";
}
/** The panel drags via raw pointer events, not HTML5 DnD. */
export async function dragTemplateToCanvas(
page: Page,
templateTestId: string,
target?: { x: number; y: number },
opts: { search?: string } = {},
) {
const item = page.getByTestId(templateTestId);
// The panel only fades out, so read the toggle's state from aria-pressed.
const addButton = page.getByTestId("cc-toolbar-add");
if ((await addButton.getAttribute("aria-pressed")) !== "true") {
await addButton.click();
await expect(addButton).toHaveAttribute("aria-pressed", "true");
}
// Dispatch directly: the tab's hover tooltip intercepts a real pointer click.
await page
.getByTestId(`cc-category-${categoryForTemplate(templateTestId)}`)
.dispatchEvent("click");
// Picking a category clears the search box, so fill it after the switch.
if (opts.search) {
await page.getByPlaceholder(/Search components/).fill(opts.search);
}
await expect(item).toBeVisible();
await item.scrollIntoViewIfNeeded();
const pane = page.locator(".react-flow__pane");
const paneBox = await pane.boundingBox();
if (!paneBox) throw new Error("canvas pane not laid out");
const to = target ?? {
// Land right of the panel, roughly canvas center.
x: paneBox.x + paneBox.width * 0.6,
y: paneBox.y + paneBox.height * 0.5,
};
await mouseDrag(page, await centerOf(item), to);
// A resource drop opens an editor modal that Escape would dismiss.
if (!templateTestId.includes("resource")) {
await page.keyboard.press("Escape");
}
}
export function canvasNode(page: Page, dataIdPrefix: string) {
return page.locator(`.react-flow__node[data-id^="${dataIdPrefix}"]`);
}
/** `sr` (right) makes the node a policy SOURCE, `sl` (left) a DESTINATION. */
export async function connectNodes(
page: Page,
source: Locator,
target: Locator,
handleSide: "sr" | "sl" = "sr",
) {
// Connect handles fade in on node hover, so wait for a stable position.
await source.hover();
const handle = source.locator(
`.react-flow__handle[data-handleid="${handleSide}-connect"]`,
);
await expect(handle).toBeVisible();
await page.waitForTimeout(100);
// The target's handle only becomes connectable mid-drag, so pace and dwell.
const from = await centerOf(handle);
const to = await centerOf(target);
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.waitForTimeout(50);
const steps = 20;
for (let i = 1; i <= steps; i++) {
await page.mouse.move(
from.x + ((to.x - from.x) * i) / steps,
from.y + ((to.y - from.y) * i) / steps,
);
await page.waitForTimeout(12);
}
await page.mouse.move(to.x, to.y);
await page.waitForTimeout(80);
await page.mouse.up();
await page.waitForTimeout(120);
}
export async function panePoint(page: Page, fx: number, fy: number) {
const pane = await page.locator(".react-flow__pane").boundingBox();
if (!pane) throw new Error("canvas pane not laid out");
return { x: pane.x + pane.width * fx, y: pane.y + pane.height * fy };
}
export async function createViaCanvasMenu(
page: Page,
action: string,
at?: { fx: number; fy: number },
) {
const point = await panePoint(page, at?.fx ?? 0.6, at?.fy ?? 0.5);
await page.mouse.move(point.x, point.y);
await page.mouse.click(point.x, point.y, { button: "right" });
const menu = page.getByTestId("cc-canvas-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId(`cc-canvas-menu-${action}`).click();
}
export async function readDraftCanvas(page: Page): Promise<any | null> {
return await page.evaluate(
() =>
(window as unknown as { __ccDraftCanvas?: unknown }).__ccDraftCanvas ??
null,
);
}
export async function dragNodeOnto(page: Page, node: Locator, target: Locator) {
await mouseDrag(page, await centerOf(node), await centerOf(target));
}
export async function clickContextMenuItem(
page: Page,
node: Locator,
action: string,
) {
await dismissBlockingOverlays(page);
// The menu's items detach when the node underneath it updates, so retry.
await expect(async () => {
await node.click({ button: "right" });
const menu = page.getByTestId("cc-node-context-menu");
await expect(menu).toBeVisible({ timeout: 2000 });
await menu.getByTestId(`cc-menu-${action}`).click({ timeout: 2000 });
}).toPass();
}
export function reviewButton(page: Page) {
return page.getByTestId("cc-draft-review");
}
export async function expectChangeCount(page: Page, count: number) {
if (count === 0) {
await expect(reviewButton(page)).toBeDisabled();
} else {
await expect(reviewButton(page)).toBeEnabled();
await expect(reviewButton(page)).toContainText(String(count));
}
}
/** Assumes both policy sides are already filled in. */
export async function submitCreatePolicyModal(page: Page, name?: string) {
await page.getByTestId("policy-continue").click();
await page.getByTestId("policy-continue").click();
if (name) {
await page.getByTestId("policy-name").fill(name);
}
await page.getByTestId("submit-policy").click();
}
Loaded 20 of 387 files, more files were not shown because too many files have changed in this diff. Show more