Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
lint-and-check:
name: Lint & type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx svelte-kit sync
- run: npx svelte-check --tsconfig ./tsconfig.json

test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx vitest run

build:
name: Build (static)
runs-on: ubuntu-latest
needs: [lint-and-check, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx vite build
- name: Verify pre-rendered routes exist
run: |
for route in index.html settings/index.html nodes/index.html users/index.html; do
test -f "build/${route}" || { echo "MISSING: build/${route}"; exit 1; }
echo "OK: build/${route}"
done
- name: Verify SPA fallback exists
run: |
test -f "build/200.html" || { echo "MISSING: build/200.html"; exit 1; }
echo "OK: build/200.html"

docker-build:
name: Docker build (smoke test)
runs-on: ubuntu-latest
needs: [lint-and-check, test]
steps:
- uses: actions/checkout@v4
- name: Build Docker image
run: docker build -t headscale-admin:ci .
- name: Verify Caddy serves routes
run: |
docker run -d --name ha-ci -p 8080:80 headscale-admin:ci
sleep 2
for path in /admin/ /admin/nodes/ /admin/settings/ /admin/users/ /admin/nonexistent/; do
status=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:8080${path}")
if [ "$status" != "200" ]; then
echo "FAIL: $path returned $status"
docker logs ha-ci
exit 1
fi
echo "OK: $path → $status"
done
docker rm -f ha-ci

e2e:
name: E2E tests (dev server)
runs-on: ubuntu-latest
needs: [lint-and-check, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install chromium --with-deps
- run: npx playwright test

e2e-docker:
name: E2E tests (production Docker)
runs-on: ubuntu-latest
needs: [docker-build]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npx playwright install chromium --with-deps
- name: Build Docker image
run: docker build -t headscale-admin:e2e .
- name: Start container and mock API
run: |
docker run -d --name ha-e2e -p 8080:80 headscale-admin:e2e
node e2e/mock-api.mjs &
echo $! > /tmp/mock-api.pid
# Wait for both services to be ready (up to 30s)
for i in $(seq 1 30); do
if curl -sf http://localhost:8080/admin/ > /dev/null && \
curl -sf http://localhost:8081/healthz > /dev/null 2>&1; then
echo "Services ready after ${i}s"
break
fi
sleep 1
done
# Verify the container is up even without a /healthz on mock
curl -sf http://localhost:8080/admin/ || { echo "Container not ready"; docker logs ha-e2e; exit 1; }
- name: Run E2E tests against production build
run: npx playwright test --config playwright.docker.config.ts
env:
CI: true
- name: Cleanup
if: always()
run: |
kill $(cat /tmp/mock-api.pid) 2>/dev/null || true
docker rm -f ha-e2e 2>/dev/null || true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ TRAEFIK
.svelte-kit
build/

# Playwright
test-results/
playwright-report/

# Logs
logs
*.log
Expand Down
2 changes: 1 addition & 1 deletion Caddyfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
:{$PORT:80}
root * /app
encode gzip zstd
try_files {path}.html {path}
try_files {path}.html {path} {path}/index.html {$ENDPOINT:/}/200.html
file_server
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ FROM caddy:latest
ARG ENDPOINT
ARG PORT
ENV PORT=${PORT}
ENV ENDPOINT=${ENDPOINT}

WORKDIR /app

Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ headscale-admin is still in active development and will evolve in tandem with he

- No known issues at this time.

### Authentication Flow

When headscale-admin is opened without stored API credentials, a login
prompt is shown as a modal overlay on top of whichever page was requested.
After entering valid credentials the modal dismisses and the user remains on
the original page — there is no redirect.

Credentials are stored in the browser's `localStorage` and can be changed at
any time from the **Settings** page. If the stored key becomes invalid
during a session, a toast notification appears and the user can navigate to
Settings to correct it.

### Securing headscale-admin

Please note that headscale-admin is an entirely stateless application. The static files hosted on a server do not perform any interaction with the headscale API or backend. headscale-admin only provides the application scaffolding which facilitates interactions from the client's browser to the headscale API. There are no sessions, API tokens, or any other sensitive information passed to or from the web server hosting headscale-admin. For this reason, security beyond SSL certificates is unnecessary, though you may choose to do so simply for the sake of hiding the application. Any static headscale UI offers functionality that can be trivially replicated with cURL or other web request utilities. **The security of headscale lies in your API token being securely preserved.** That said, here are some common recommendations:
Expand Down
123 changes: 123 additions & 0 deletions e2e/docker.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* E2E tests that run against the production Docker build (Caddy + /admin base path).
* Used by the `e2e-docker` CI job via playwright.docker.config.ts.
*
* All page.goto() calls use relative paths (e.g. './nodes/') so they resolve
* correctly against the baseURL 'http://localhost:8080/admin/'.
*/
import { test, expect, type Page } from '@playwright/test';

const MOCK_URL = 'http://localhost:8081';
const API_KEY = 'test-api-key';

async function seedAuth(page: Page) {
await page.goto('./');
await page.evaluate(
([url, key]) => {
localStorage.setItem('apiUrl', JSON.stringify(url));
localStorage.setItem('apiKey', JSON.stringify(key));
localStorage.setItem(
'apiKeyInfo',
JSON.stringify({
authorized: true,
expires: new Date(Date.now() + 86_400_000 * 90).toISOString(),
informedUnauthorized: false,
informedExpiringSoon: false,
}),
);
},
[MOCK_URL, API_KEY],
);
await page.reload();
await page.locator('[data-testid="app-shell"]').waitFor({ timeout: 10000 });
}

test.describe('production routing (Docker / Caddy)', () => {
test.beforeEach(async ({ page }) => {
await seedAuth(page);
});

test('root /admin/ renders dashboard', async ({ page }) => {
await page.goto('./');
await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 });
});

test('/admin/nodes/ renders node list', async ({ page }) => {
await page.goto('./nodes/');
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });
});

test('/admin/users/ renders user list', async ({ page }) => {
await page.goto('./users/');
await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 });
});

test('refresh on /admin/nodes/ keeps page and content', async ({ page }) => {
await page.goto('./nodes/');
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });
await page.reload();
await expect(page).toHaveURL(/\/admin\/nodes\/?/);
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });
});

test('refresh on /admin/users/ keeps page and content', async ({ page }) => {
await page.goto('./users/');
await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 });
await page.reload();
await expect(page).toHaveURL(/\/admin\/users\/?/);
await expect(page.getByText('alice')).toBeVisible({ timeout: 10000 });
});

test('direct URL access /admin/nodes/ does not give blank page', async ({ page }) => {
// Opens a fresh tab directly at the path (simulates typing URL or bookmark)
await page.goto('./nodes/');
await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });
});

test('unknown route /admin/nonexistent/ serves 200 (SPA fallback, no white screen)', async ({ page }) => {
// Caddy should serve 200.html as the SPA fallback; the app should mount
const response = await page.goto('./nonexistent/');
expect(response?.status()).toBe(200);
await expect(page.locator('[data-testid="app-shell"]')).toBeVisible({ timeout: 10000 });
});
});

test.describe('authentication flow (Docker)', () => {
test.beforeEach(async ({ page }) => {
// Clear credentials so we start unauthenticated
await page.goto('./');
await page.evaluate(() => {
localStorage.removeItem('apiUrl');
localStorage.removeItem('apiKey');
localStorage.removeItem('apiKeyInfo');
});
await page.reload();
});

test('ApiKeyPrompt appears on /admin/ when unauthenticated', async ({ page }) => {
await expect(page.getByRole('button', { name: /connect/i })).toBeVisible({ timeout: 5000 });
});

test('entering credentials dismisses modal and shows dashboard', async ({ page }) => {
await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL);
await page.getByRole('textbox', { name: 'API Key' }).fill(API_KEY);
await page.getByRole('button', { name: /connect/i }).click();
await expect(page.getByRole('button', { name: /connect/i })).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/total users/i)).toBeVisible({ timeout: 10000 });
});

test('refresh after auth stays on current page with content', async ({ page }) => {
// Authenticate on the nodes page
await page.goto('./nodes/');
await page.getByRole('textbox', { name: 'API URL' }).fill(MOCK_URL);
await page.getByRole('textbox', { name: 'API Key' }).fill(API_KEY);
await page.getByRole('button', { name: /connect/i }).click();
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });

// Refresh — should stay on nodes page, no white screen
await page.reload();
await expect(page).toHaveURL(/\/admin\/nodes\/?/);
await expect(page.getByText('alice-laptop')).toBeVisible({ timeout: 10000 });
});
});
Loading
Loading