Compare commits
43 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a83cead67 | ||
|
|
b3f23fd00d | ||
|
|
c94b2ed76c | ||
|
|
0babff823a | ||
|
|
1af3773656 | ||
|
|
897fac08bc | ||
|
|
4633853990 | ||
|
|
0edfc75821 | ||
|
|
260837d3aa | ||
|
|
0f5e517f9d | ||
|
|
877861c9e8 | ||
|
|
554da599ba | ||
|
|
8cf16c7f91 | ||
|
|
edb25e83be | ||
|
|
a012635dff | ||
| a9f62d5caf | |||
| ee1d742a71 | |||
| 71a5f57105 | |||
| c5b66349d6 | |||
| 70eb9f6b12 | |||
| 06b5a3dc12 | |||
| 25cff5e4d9 | |||
| 0c11cddb53 | |||
| f2b04ea668 | |||
| a06ce9e156 | |||
| 2163c89b6a | |||
| 4d7a4810ff | |||
| 3bf6fabcff | |||
|
|
e63f27b8a3 | ||
|
|
9aa58fbf22 | ||
|
|
d3705d224b | ||
|
|
f608c542d1 | ||
|
|
49b2bd9083 | ||
|
|
15e1b6360a | ||
|
|
c9ef58a244 | ||
|
|
fab5ad29d4 | ||
|
|
1c9de39079 | ||
|
|
a8784eec9c | ||
|
|
44c20cb66b | ||
|
|
41a62832f7 | ||
|
|
92554d4089 | ||
|
|
e51c53f247 | ||
|
|
384b95b81d |
77 changed files with 5993 additions and 2554 deletions
137
.forgejo/workflows/dev.yml
Normal file
137
.forgejo/workflows/dev.yml
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
name: Dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: wiregui
|
||||||
|
POSTGRES_PASSWORD: wiregui
|
||||||
|
POSTGRES_DB: wiregui
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U wiregui"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
options: >-
|
||||||
|
--health-cmd "valkey-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
||||||
|
WG_REDIS_URL: redis://valkey:6379/0
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies and checkout
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
|
||||||
|
git clone --depth=1 -b "${GITHUB_REF_NAME}" ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Run migrations
|
||||||
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: test
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim
|
||||||
|
outputs:
|
||||||
|
new_version: ${{ steps.semrel.outputs.new_version }}
|
||||||
|
skip: ${{ steps.semrel.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- name: Install dependencies and checkout
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
|
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
|
git checkout ${GITHUB_SHA}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "Forgejo Actions"
|
||||||
|
git config user.email "noreply@forge.provvedo.com"
|
||||||
|
git config --local http.${GITHUB_SERVER_URL}/.extraheader "AUTHORIZATION: basic $(echo -n "x-access-token:${GITHUB_TOKEN}" | base64 -w0)"
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install uv and semantic-release
|
||||||
|
run: |
|
||||||
|
pip install uv
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
- name: Semantic release (rc)
|
||||||
|
id: semrel
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
OUTPUT=$(uv run semantic-release version --print 2>/dev/null || echo "")
|
||||||
|
if [ -z "$OUTPUT" ]; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No release needed"
|
||||||
|
else
|
||||||
|
uv run semantic-release version
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "new_version=${OUTPUT}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Released v${OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: release
|
||||||
|
if: needs.release.outputs.skip != 'true'
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
options: --privileged
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
run: |
|
||||||
|
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git -b dev .
|
||||||
|
git fetch origin --tags
|
||||||
|
|
||||||
|
- name: Build and push pre-release image
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.release.outputs.new_version }}"
|
||||||
|
REGISTRY=$(echo "${{ github.server_url }}" | sed 's|https://||; s|http://||')
|
||||||
|
IMAGE="${REGISTRY}/${{ github.repository_owner }}/wiregui"
|
||||||
|
|
||||||
|
echo "Building ${IMAGE}:v${VERSION}"
|
||||||
|
|
||||||
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
||||||
|
-u "${{ github.repository_owner }}" --password-stdin
|
||||||
|
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg "VERSION=${VERSION}" \
|
||||||
|
-t "${IMAGE}:v${VERSION}" \
|
||||||
|
-t "${IMAGE}:dev" \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker push "${IMAGE}:v${VERSION}"
|
||||||
|
docker push "${IMAGE}:dev"
|
||||||
|
|
||||||
|
echo "Pushed ${IMAGE}:v${VERSION}, ${IMAGE}:dev"
|
||||||
|
|
@ -23,16 +23,23 @@ jobs:
|
||||||
--health-interval 5s
|
--health-interval 5s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
options: >-
|
||||||
|
--health-cmd "valkey-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
env:
|
env:
|
||||||
CI: "true"
|
CI: "true"
|
||||||
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
||||||
|
WG_REDIS_URL: redis://valkey:6379/0
|
||||||
steps:
|
steps:
|
||||||
- name: Install system dependencies and checkout
|
- name: Install system dependencies and checkout
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
|
git wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
|
||||||
git clone --depth=1 ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth=1 -b "${GITHUB_REF_NAME}" ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
git checkout ${GITHUB_SHA}
|
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
|
@ -40,146 +47,58 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync
|
run: uv sync
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run migrations
|
||||||
run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short
|
run: uv run alembic upgrade head
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run unit tests
|
||||||
run: |
|
run: uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short
|
||||||
uv run alembic upgrade head
|
|
||||||
uv run pytest tests/e2e/ -v --tb=short
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: test
|
needs: test
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: node:20-slim
|
image: python:3.13-slim
|
||||||
outputs:
|
outputs:
|
||||||
new_tag: ${{ steps.version.outputs.new_tag }}
|
new_version: ${{ steps.semrel.outputs.new_version }}
|
||||||
new_version: ${{ steps.version.outputs.new_version }}
|
skip: ${{ steps.semrel.outputs.skip }}
|
||||||
skip: ${{ steps.version.outputs.skip }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies and checkout
|
- name: Install dependencies and checkout
|
||||||
run: |
|
run: |
|
||||||
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
|
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
git checkout ${GITHUB_SHA}
|
git checkout ${GITHUB_SHA}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "Forgejo Actions"
|
||||||
|
git config user.email "noreply@forge.provvedo.com"
|
||||||
git config --local http.${GITHUB_SERVER_URL}/.extraheader "AUTHORIZATION: basic $(echo -n "x-access-token:${GITHUB_TOKEN}" | base64 -w0)"
|
git config --local http.${GITHUB_SERVER_URL}/.extraheader "AUTHORIZATION: basic $(echo -n "x-access-token:${GITHUB_TOKEN}" | base64 -w0)"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Determine version bump
|
- name: Install uv and semantic-release
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
pip install uv
|
||||||
echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"
|
uv sync --group dev
|
||||||
|
|
||||||
CURRENT="${LATEST_TAG#v}"
|
- name: Semantic release
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
id: semrel
|
||||||
|
|
||||||
COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
|
|
||||||
|
|
||||||
BUMP="none"
|
|
||||||
while IFS= read -r msg; do
|
|
||||||
case "$msg" in
|
|
||||||
*"BREAKING CHANGE"*|*"!:"*)
|
|
||||||
BUMP="major"
|
|
||||||
break
|
|
||||||
;;
|
|
||||||
feat:*|feat\(*)
|
|
||||||
[ "$BUMP" != "major" ] && BUMP="minor"
|
|
||||||
;;
|
|
||||||
fix:*|fix\(*|perf:*|perf\(*|refactor:*|refactor\(*)
|
|
||||||
[ "$BUMP" = "none" ] && BUMP="patch"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done <<< "$COMMITS"
|
|
||||||
|
|
||||||
if [ "$BUMP" = "none" ]; then
|
|
||||||
echo "No version-relevant commits since ${LATEST_TAG}, skipping release"
|
|
||||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
case "$BUMP" in
|
|
||||||
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
|
||||||
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
|
||||||
patch) PATCH=$((PATCH + 1)) ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
|
||||||
echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "new_tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Version bump: ${BUMP} -> v${NEW_VERSION}"
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
id: changelog
|
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
LATEST_TAG="${{ steps.version.outputs.latest_tag }}"
|
|
||||||
NEW_TAG="${{ steps.version.outputs.new_tag }}"
|
|
||||||
|
|
||||||
BODY="## ${NEW_TAG}"$'\n\n'
|
|
||||||
|
|
||||||
for type_label in "feat:Features" "fix:Bug Fixes" "refactor:Refactoring" "perf:Performance" "docs:Documentation" "chore:Maintenance"; do
|
|
||||||
prefix="${type_label%%:*}"
|
|
||||||
label="${type_label#*:}"
|
|
||||||
MATCHES=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null | grep -E "^${prefix}[:(]" || true)
|
|
||||||
if [ -n "$MATCHES" ]; then
|
|
||||||
BODY="${BODY}### ${label}"$'\n\n'
|
|
||||||
while IFS= read -r line; do
|
|
||||||
CLEAN=$(echo "$line" | sed -E "s/^${prefix}(\([^)]*\))?:\s*//")
|
|
||||||
BODY="${BODY}- ${CLEAN}"$'\n'
|
|
||||||
done <<< "$MATCHES"
|
|
||||||
BODY="${BODY}"$'\n'
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "${BODY}" > /tmp/changelog.md
|
|
||||||
echo "Generated changelog for ${NEW_TAG}"
|
|
||||||
|
|
||||||
- name: Create tag and release
|
|
||||||
if: steps.version.outputs.skip != 'true'
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
NEW_TAG="${{ steps.version.outputs.new_tag }}"
|
OUTPUT=$(uv run semantic-release version --print 2>/dev/null || echo "")
|
||||||
|
if [ -z "$OUTPUT" ]; then
|
||||||
git config user.name "Forgejo Actions"
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
git config user.email "noreply@forge.provvedo.com"
|
echo "No release needed"
|
||||||
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
else
|
||||||
git push origin "${NEW_TAG}"
|
uv run semantic-release version
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
FORGEJO_URL="${GITHUB_SERVER_URL}"
|
echo "new_version=${OUTPUT}" >> "$GITHUB_OUTPUT"
|
||||||
REPO="${GITHUB_REPOSITORY}"
|
echo "Released v${OUTPUT}"
|
||||||
|
fi
|
||||||
python3 -c "
|
|
||||||
import json, urllib.request, os
|
|
||||||
body = open('/tmp/changelog.md').read()
|
|
||||||
tag = '${NEW_TAG}'
|
|
||||||
data = json.dumps({
|
|
||||||
'tag_name': tag,
|
|
||||||
'name': tag,
|
|
||||||
'body': body,
|
|
||||||
'draft': False,
|
|
||||||
'prerelease': False
|
|
||||||
}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
'${FORGEJO_URL}/api/v1/repos/${REPO}/releases',
|
|
||||||
data=data,
|
|
||||||
headers={
|
|
||||||
'Authorization': 'token ' + os.environ['GITHUB_TOKEN'],
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
method='POST'
|
|
||||||
)
|
|
||||||
resp = urllib.request.urlopen(req)
|
|
||||||
print(f'Created release {tag} (HTTP {resp.status})')
|
|
||||||
"
|
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
needs: release
|
needs: release
|
||||||
|
|
@ -191,8 +110,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
run: |
|
run: |
|
||||||
git clone --depth=1 ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
git clone --depth=1 -b "${GITHUB_REF_NAME}" ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
git checkout ${GITHUB_SHA}
|
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -200,30 +118,26 @@ jobs:
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ needs.release.outputs.new_version }}"
|
VERSION="${{ needs.release.outputs.new_version }}"
|
||||||
TAG="${{ needs.release.outputs.new_tag }}"
|
|
||||||
REGISTRY=$(echo "${{ github.server_url }}" | sed 's|https://||; s|http://||')
|
REGISTRY=$(echo "${{ github.server_url }}" | sed 's|https://||; s|http://||')
|
||||||
IMAGE="${REGISTRY}/${{ github.repository_owner }}/wiregui"
|
IMAGE="${REGISTRY}/${{ github.repository_owner }}/wiregui"
|
||||||
|
|
||||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
echo "Building ${IMAGE}:${TAG}"
|
echo "Building ${IMAGE}:v${VERSION}"
|
||||||
|
|
||||||
# Log in to Forgejo container registry
|
|
||||||
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
||||||
-u "${{ github.repository_owner }}" --password-stdin
|
-u "${{ github.repository_owner }}" --password-stdin
|
||||||
|
|
||||||
# Build the image
|
docker build --no-cache \
|
||||||
docker build \
|
|
||||||
--build-arg "VERSION=${VERSION}" \
|
--build-arg "VERSION=${VERSION}" \
|
||||||
-t "${IMAGE}:${TAG}" \
|
-t "${IMAGE}:v${VERSION}" \
|
||||||
-t "${IMAGE}:${MAJOR}.${MINOR}" \
|
-t "${IMAGE}:${MAJOR}.${MINOR}" \
|
||||||
-t "${IMAGE}:latest" \
|
-t "${IMAGE}:latest" \
|
||||||
.
|
.
|
||||||
|
|
||||||
# Push all tags
|
docker push "${IMAGE}:v${VERSION}"
|
||||||
docker push "${IMAGE}:${TAG}"
|
|
||||||
docker push "${IMAGE}:${MAJOR}.${MINOR}"
|
docker push "${IMAGE}:${MAJOR}.${MINOR}"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
echo "Pushed ${IMAGE}:${TAG}, ${IMAGE}:${MAJOR}.${MINOR}, ${IMAGE}:latest"
|
echo "Pushed ${IMAGE}:v${VERSION}, ${IMAGE}:${MAJOR}.${MINOR}, ${IMAGE}:latest"
|
||||||
|
|
|
||||||
91
.github/workflows/dev.yml
vendored
Normal file
91
.github/workflows/dev.yml
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
name: Dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends git ca-certificates
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "noreply@github.com"
|
||||||
|
|
||||||
|
- name: Install uv and dependencies
|
||||||
|
run: |
|
||||||
|
pip install uv
|
||||||
|
uv sync --group dev
|
||||||
|
|
||||||
|
- name: Semantic release (rc)
|
||||||
|
id: semrel
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
VERSION=$(uv run semantic-release version --print 2>/dev/null || echo "")
|
||||||
|
if [ -z "$VERSION" ]; then
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No release needed"
|
||||||
|
else
|
||||||
|
uv run semantic-release version
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "new_version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Released v${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
new_version: ${{ steps.semrel.outputs.new_version }}
|
||||||
|
skip: ${{ steps.semrel.outputs.skip }}
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: release
|
||||||
|
if: needs.release.outputs.skip != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: dev
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push pre-release image
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.release.outputs.new_version }}"
|
||||||
|
IMAGE="ghcr.io/${{ github.repository_owner }}/wiregui"
|
||||||
|
|
||||||
|
echo "Building ${IMAGE}:v${VERSION}"
|
||||||
|
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg "VERSION=${VERSION}" \
|
||||||
|
-t "${IMAGE}:v${VERSION}" \
|
||||||
|
-t "${IMAGE}:dev" \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker push "${IMAGE}:v${VERSION}"
|
||||||
|
docker push "${IMAGE}:dev"
|
||||||
|
|
||||||
|
echo "Pushed ${IMAGE}:v${VERSION}, ${IMAGE}:dev"
|
||||||
208
.github/workflows/release.yml
vendored
Normal file
208
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: python:3.13-slim
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: wiregui
|
||||||
|
POSTGRES_PASSWORD: wiregui
|
||||||
|
POSTGRES_DB: wiregui
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U wiregui"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
||||||
|
steps:
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git wireguard-tools pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: uv run playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: uv run pytest tests/ --ignore=tests/e2e -v --tb=short
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
run: |
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run pytest tests/e2e/ -v --tb=short
|
||||||
|
|
||||||
|
release:
|
||||||
|
needs: test
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: node:20-slim
|
||||||
|
outputs:
|
||||||
|
new_tag: ${{ steps.version.outputs.new_tag }}
|
||||||
|
new_version: ${{ steps.version.outputs.new_version }}
|
||||||
|
skip: ${{ steps.version.outputs.skip }}
|
||||||
|
steps:
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "noreply@github.com"
|
||||||
|
|
||||||
|
- name: Determine version bump
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||||
|
echo "latest_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
CURRENT="${LATEST_TAG#v}"
|
||||||
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
|
||||||
|
|
||||||
|
COMMITS=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null || git log --pretty=format:"%s")
|
||||||
|
|
||||||
|
BUMP="none"
|
||||||
|
while IFS= read -r msg; do
|
||||||
|
case "$msg" in
|
||||||
|
*"BREAKING CHANGE"*|*"!:"*)
|
||||||
|
BUMP="major"
|
||||||
|
break
|
||||||
|
;;
|
||||||
|
feat:*|feat\(*)
|
||||||
|
[ "$BUMP" != "major" ] && BUMP="minor"
|
||||||
|
;;
|
||||||
|
fix:*|fix\(*|perf:*|perf\(*|refactor:*|refactor\(*)
|
||||||
|
[ "$BUMP" = "none" ] && BUMP="patch"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$COMMITS"
|
||||||
|
|
||||||
|
if [ "$BUMP" = "none" ]; then
|
||||||
|
echo "No version-relevant commits since ${LATEST_TAG}, skipping release"
|
||||||
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$BUMP" in
|
||||||
|
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
|
||||||
|
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
|
||||||
|
patch) PATCH=$((PATCH + 1)) ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
|
||||||
|
echo "new_version=${NEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "new_tag=v${NEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "bump=${BUMP}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Version bump: ${BUMP} -> v${NEW_VERSION}"
|
||||||
|
|
||||||
|
- name: Generate changelog
|
||||||
|
id: changelog
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
LATEST_TAG="${{ steps.version.outputs.latest_tag }}"
|
||||||
|
NEW_TAG="${{ steps.version.outputs.new_tag }}"
|
||||||
|
|
||||||
|
BODY="## ${NEW_TAG}"$'\n\n'
|
||||||
|
|
||||||
|
for type_label in "feat:Features" "fix:Bug Fixes" "refactor:Refactoring" "perf:Performance" "docs:Documentation" "chore:Maintenance"; do
|
||||||
|
prefix="${type_label%%:*}"
|
||||||
|
label="${type_label#*:}"
|
||||||
|
MATCHES=$(git log "${LATEST_TAG}..HEAD" --pretty=format:"%s" 2>/dev/null | grep -E "^${prefix}[:(]" || true)
|
||||||
|
if [ -n "$MATCHES" ]; then
|
||||||
|
BODY="${BODY}### ${label}"$'\n\n'
|
||||||
|
while IFS= read -r line; do
|
||||||
|
CLEAN=$(echo "$line" | sed -E "s/^${prefix}(\([^)]*\))?:\s*//")
|
||||||
|
BODY="${BODY}- ${CLEAN}"$'\n'
|
||||||
|
done <<< "$MATCHES"
|
||||||
|
BODY="${BODY}"$'\n'
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "${BODY}" > /tmp/changelog.md
|
||||||
|
echo "Generated changelog for ${NEW_TAG}"
|
||||||
|
|
||||||
|
- name: Create tag and release
|
||||||
|
if: steps.version.outputs.skip != 'true'
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
NEW_TAG="${{ steps.version.outputs.new_tag }}"
|
||||||
|
|
||||||
|
git tag -a "${NEW_TAG}" -m "Release ${NEW_TAG}"
|
||||||
|
git push origin "${NEW_TAG}"
|
||||||
|
|
||||||
|
gh release create "${NEW_TAG}" \
|
||||||
|
--title "${NEW_TAG}" \
|
||||||
|
--notes-file /tmp/changelog.md
|
||||||
|
|
||||||
|
docker:
|
||||||
|
needs: release
|
||||||
|
if: needs.release.outputs.skip != 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.release.outputs.new_version }}"
|
||||||
|
TAG="${{ needs.release.outputs.new_tag }}"
|
||||||
|
IMAGE="ghcr.io/${{ github.repository_owner }}/wiregui"
|
||||||
|
|
||||||
|
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||||
|
|
||||||
|
echo "Building ${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
docker build --no-cache \
|
||||||
|
--build-arg "VERSION=${VERSION}" \
|
||||||
|
-t "${IMAGE}:${TAG}" \
|
||||||
|
-t "${IMAGE}:${MAJOR}.${MINOR}" \
|
||||||
|
-t "${IMAGE}:latest" \
|
||||||
|
.
|
||||||
|
|
||||||
|
docker push "${IMAGE}:${TAG}"
|
||||||
|
docker push "${IMAGE}:${MAJOR}.${MINOR}"
|
||||||
|
docker push "${IMAGE}:latest"
|
||||||
|
|
||||||
|
echo "Pushed ${IMAGE}:${TAG}, ${IMAGE}:${MAJOR}.${MINOR}, ${IMAGE}:latest"
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ __pycache__/
|
||||||
logs/
|
logs/
|
||||||
.idea/
|
.idea/
|
||||||
.coverage
|
.coverage
|
||||||
|
docker/mock-clients/
|
||||||
|
|
@ -20,6 +20,7 @@ RUN uv sync --no-dev --frozen 2>/dev/null || uv sync --no-dev
|
||||||
COPY wiregui/ wiregui/
|
COPY wiregui/ wiregui/
|
||||||
COPY alembic/ alembic/
|
COPY alembic/ alembic/
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
|
COPY img/ img/
|
||||||
|
|
||||||
FROM python:3.13-slim AS runner
|
FROM python:3.13-slim AS runner
|
||||||
|
|
||||||
|
|
@ -34,6 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
|
COPY --from=builder /usr/local/bin/uv /usr/local/bin/uv
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
COPY --from=builder /app/wiregui /app/wiregui
|
COPY --from=builder /app/wiregui /app/wiregui
|
||||||
|
COPY --from=builder /app/img /app/img
|
||||||
COPY --from=builder /app/alembic /app/alembic
|
COPY --from=builder /app/alembic /app/alembic
|
||||||
COPY --from=builder /app/alembic.ini /app/alembic.ini
|
COPY --from=builder /app/alembic.ini /app/alembic.ini
|
||||||
COPY --from=builder /app/pyproject.toml /app/pyproject.toml
|
COPY --from=builder /app/pyproject.toml /app/pyproject.toml
|
||||||
|
|
|
||||||
661
LICENSE
Normal file
661
LICENSE
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
123
Makefile
Normal file
123
Makefile
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
.PHONY: help install migrate dev dev-up dev-down dev-logs \
|
||||||
|
test test-unit test-e2e test-e2e-headed \
|
||||||
|
test-stack-up test-stack-seed test-stack-down test-stack-logs test-stack-verify \
|
||||||
|
prod-build \
|
||||||
|
clean
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
help:
|
||||||
|
@echo "WireGUI — available targets:"
|
||||||
|
@echo ""
|
||||||
|
@echo " Development (app runs on host, infra in Docker):"
|
||||||
|
@echo " make install Install dependencies (uv sync)"
|
||||||
|
@echo " make migrate Run database migrations"
|
||||||
|
@echo " make dev Start infra + mock IdPs, run app locally"
|
||||||
|
@echo " make dev-up Start infra only (Postgres, Valkey, mock IdPs)"
|
||||||
|
@echo " make dev-down Stop all containers"
|
||||||
|
@echo " make dev-logs Tail container logs"
|
||||||
|
@echo ""
|
||||||
|
@echo " Testing:"
|
||||||
|
@echo " make test Run unit + e2e tests"
|
||||||
|
@echo " make test-unit Run unit tests only"
|
||||||
|
@echo " make test-e2e Run e2e tests (headless)"
|
||||||
|
@echo " make test-e2e-headed Run e2e tests in headed mode (visible browser)"
|
||||||
|
@echo ""
|
||||||
|
@echo " Integration stack (containerized WireGUI + WG clients + VictoriaMetrics):"
|
||||||
|
@echo " make test-stack-up Seed DB, build, start everything"
|
||||||
|
@echo " make test-stack-down Stop and remove containers + volumes"
|
||||||
|
@echo " make test-stack-logs Tail logs"
|
||||||
|
@echo " make test-stack-verify Verify metrics flowing to VictoriaMetrics"
|
||||||
|
@echo ""
|
||||||
|
@echo " Production:"
|
||||||
|
@echo " make prod-build Build production Docker image"
|
||||||
|
@echo ""
|
||||||
|
@echo " Housekeeping:"
|
||||||
|
@echo " make clean Remove generated files, caches, volumes"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Development
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
install:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
uv run alembic upgrade head
|
||||||
|
|
||||||
|
dev-up:
|
||||||
|
docker compose up -d postgres valkey mock-oidc mock-saml
|
||||||
|
|
||||||
|
dev-down:
|
||||||
|
docker compose down
|
||||||
|
|
||||||
|
dev-logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
dev: dev-up migrate
|
||||||
|
uv run python -m wiregui.main
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Testing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test-unit:
|
||||||
|
uv run pytest tests/ --ignore=tests/e2e --ignore=tests/integration -v --tb=short
|
||||||
|
|
||||||
|
test-e2e:
|
||||||
|
uv run pytest tests/e2e/ -v --tb=short
|
||||||
|
|
||||||
|
test-e2e-headed:
|
||||||
|
uv run pytest tests/e2e/ --headed --slowmo 100 -v --tb=short
|
||||||
|
|
||||||
|
test: test-stack-up test-unit test-e2e
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration test stack (real WireGuard + mock clients + VictoriaMetrics)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test-stack-up: test-stack-seed
|
||||||
|
docker compose up -d --build wiregui client1 client2 client3
|
||||||
|
@echo ""
|
||||||
|
@echo "Integration stack running:"
|
||||||
|
@echo " WireGUI: http://localhost:13000 (admin@test.local / admin123)"
|
||||||
|
@echo " VictoriaMetrics: http://localhost:8428"
|
||||||
|
@echo " Mock clients: 3 peers generating traffic every 3s"
|
||||||
|
|
||||||
|
test-stack-seed:
|
||||||
|
@echo "[*] Starting infrastructure..."
|
||||||
|
docker compose up -d postgres valkey victoriametrics mock-oidc mock-saml
|
||||||
|
@echo "[*] Waiting for Postgres..."
|
||||||
|
@until docker compose exec -T postgres pg_isready -U wiregui > /dev/null 2>&1; do sleep 1; done
|
||||||
|
@echo "[*] Running migrations..."
|
||||||
|
uv run alembic upgrade head
|
||||||
|
@echo "[*] Seeding server keypair, admin user, and client devices..."
|
||||||
|
PYTHONPATH=. uv run python docker/mock-clients/setup.py
|
||||||
|
|
||||||
|
test-stack-down:
|
||||||
|
docker compose down -v
|
||||||
|
|
||||||
|
test-stack-verify:
|
||||||
|
uv run pytest tests/integration/ -v --tb=short
|
||||||
|
|
||||||
|
test-stack-logs:
|
||||||
|
docker compose logs -f wiregui client1 client2 client3 victoriametrics
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Production
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PROD_IMAGE ?= wiregui
|
||||||
|
PROD_TAG ?= latest
|
||||||
|
|
||||||
|
prod-build:
|
||||||
|
docker build --no-cache -t $(PROD_IMAGE):$(PROD_TAG) .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Housekeeping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
clean:
|
||||||
|
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
rm -rf .pytest_cache .coverage htmlcov
|
||||||
|
rm -rf docker/mock-clients/configs/
|
||||||
|
rm -rf .nicegui/
|
||||||
123
README.md
123
README.md
|
|
@ -0,0 +1,123 @@
|
||||||
|
# WireGUI
|
||||||
|
|
||||||
|
A self-hosted WireGuard VPN management platform built with Python, NiceGUI, and PostgreSQL.
|
||||||
|
|
||||||
|
WireGUI gives you a clean web interface for managing WireGuard peers, firewall rules, and user authentication -- without depending on any third-party cloud service. It's designed for teams and individuals who want full control over their VPN infrastructure.
|
||||||
|
|
||||||
|
## Against enshittification
|
||||||
|
|
||||||
|
This project exists because we believe infrastructure software should serve its users, not its investors. Too many open-source VPN tools have been enshittified -- features locked behind paid tiers, telemetry quietly added, self-hosting made deliberately painful to push you toward a managed offering.
|
||||||
|
|
||||||
|
WireGUI is AGPL-licensed specifically to prevent this. If you run it, you own it. If you modify it and offer it as a service, you share the source. No bait-and-switch, no open-core grift, no "community edition" that mysteriously lacks the features you actually need.
|
||||||
|
|
||||||
|
Software that manages your network traffic should be fully transparent and fully yours.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **WireGuard management** -- create/delete peers, automatic IP allocation (IPv4 + IPv6), QR codes and `.conf` downloads
|
||||||
|
- **Firewall rules** -- per-user nftables chains with CIDR, protocol, and port range support
|
||||||
|
- **Multi-factor auth** -- TOTP authenticator apps and WebAuthn security keys
|
||||||
|
- **SSO** -- OpenID Connect and SAML identity providers with auto-provisioning
|
||||||
|
- **Magic links** -- passwordless email login
|
||||||
|
- **API tokens** -- programmatic access via REST API (`/api/v0`)
|
||||||
|
- **Dark/light theme** -- user preference stored in profile, auto mode follows system
|
||||||
|
- **VPN session management** -- configurable session duration with automatic peer expiry
|
||||||
|
- **Real-time stats** -- live RX/TX counters and handshake tracking
|
||||||
|
- **Diagnostics** -- WAN connectivity checks, peer status, system notifications
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|-----------|
|
||||||
|
| UI | NiceGUI (reactive server-side, WebSocket) |
|
||||||
|
| API | FastAPI (built into NiceGUI) |
|
||||||
|
| ORM | SQLModel (SQLAlchemy + Pydantic) |
|
||||||
|
| Database | PostgreSQL (asyncpg) |
|
||||||
|
| Cache | Valkey (Redis-compatible) |
|
||||||
|
| Migrations | Alembic |
|
||||||
|
| Auth | authlib, python-jose, pyotp, webauthn, bcrypt |
|
||||||
|
| VPN | WireGuard (`wg` + `ip` CLI) |
|
||||||
|
| Firewall | nftables (`nft` CLI) |
|
||||||
|
| Python | 3.13+ |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
git clone https://forge.provvedo.com/provvedo/wiregui.git
|
||||||
|
cd wiregui
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Start PostgreSQL and Valkey
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Run migrations and start
|
||||||
|
alembic upgrade head
|
||||||
|
uv run python -m wiregui.main
|
||||||
|
```
|
||||||
|
|
||||||
|
Open http://localhost:13000 -- an admin account is created automatically on first run (check the logs for the generated password).
|
||||||
|
|
||||||
|
## Production deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker Compose (recommended)
|
||||||
|
docker compose -f compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The container runs migrations on startup, manages the WireGuard interface, and requires `NET_ADMIN` + `SYS_MODULE` capabilities. See `compose.prod.yml` for the full configuration including environment variables.
|
||||||
|
|
||||||
|
### Environment variables
|
||||||
|
|
||||||
|
All settings use the `WG_` prefix:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `WG_DATABASE_URL` | `postgresql+asyncpg://wiregui:wiregui@localhost/wiregui` | PostgreSQL connection |
|
||||||
|
| `WG_REDIS_URL` | `redis://localhost:6379/0` | Valkey/Redis connection |
|
||||||
|
| `WG_SECRET_KEY` | `change-me-in-production` | JWT signing + Fernet encryption key |
|
||||||
|
| `WG_WG_ENABLED` | `false` | Enable WireGuard interface management |
|
||||||
|
| `WG_WG_ENDPOINT_HOST` | `localhost` | Public endpoint for client configs |
|
||||||
|
| `WG_WG_ENDPOINT_PORT` | `51820` | WireGuard listen port |
|
||||||
|
| `WG_WG_IPV4_NETWORK` | `10.3.2.0/24` | IPv4 tunnel network |
|
||||||
|
| `WG_WG_IPV6_NETWORK` | `fd00::3:2:0/120` | IPv6 tunnel network |
|
||||||
|
| `WG_ADMIN_EMAIL` | `admin@localhost` | Initial admin email |
|
||||||
|
| `WG_ADMIN_PASSWORD` | *(auto-generated)* | Initial admin password |
|
||||||
|
| `WG_EXTERNAL_URL` | `http://localhost:13000` | Public-facing URL |
|
||||||
|
| `WG_IDP_CONFIG_FILE` | *(none)* | Path to YAML file with OIDC/SAML IdP definitions |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit + integration tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# E2E tests (Playwright — requires running PostgreSQL, Valkey, and mock-oidc)
|
||||||
|
docker compose up -d
|
||||||
|
uv run pytest tests/e2e/ -v
|
||||||
|
|
||||||
|
# E2E in headed mode (watch tests in a browser)
|
||||||
|
uv run pytest tests/e2e/ --headed --slowmo 300
|
||||||
|
```
|
||||||
|
|
||||||
|
E2E tests automatically start a WireGUI instance on port 13001 and use Playwright's async API to drive a real Chromium browser. The `--headed` flag opens a visible browser window and `--slowmo` adds a delay (in ms) between actions for debugging. The OIDC login flow tests use the `mock-oidc` service from `compose.yml`.
|
||||||
|
|
||||||
|
### IdP provisioning from YAML
|
||||||
|
|
||||||
|
Identity providers can be seeded at startup from a YAML file, enabling GitOps and infrastructure-as-code workflows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WG_IDP_CONFIG_FILE=/etc/wiregui/idps.yaml uv run python -m wiregui.main
|
||||||
|
```
|
||||||
|
|
||||||
|
See `tests/e2e/test_idp_seed.py` for the YAML format and seeding behavior.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Copyright 2026 Stefano Bertelli / Provvedo
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the **GNU Affero General Public License** as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
||||||
|
|
||||||
|
This means: if you run a modified version of WireGUI as a network service, you must make the source code available to users of that service. No exceptions, no loopholes.
|
||||||
|
|
||||||
|
See [LICENSE](LICENSE) for the full text.
|
||||||
77
TESTS.md
Normal file
77
TESTS.md
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# WireGUI — Test Suite
|
||||||
|
|
||||||
|
**Test count: 271 (201 unit + 70 E2E) | Unit coverage: 36% | Effective: ~81% (incl. E2E)**
|
||||||
|
**Run:** `uv run pytest` (unit) / `uv run pytest tests/e2e/` (E2E via Playwright)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unit Tests — Coverage by Module
|
||||||
|
|
||||||
|
**Done:**
|
||||||
|
- [x] `wiregui/api/deps.py` (91%) — 11 tests: Bearer token auth, get_current_api_user, require_admin
|
||||||
|
- [x] `wiregui/services/wireguard.py` (98%) — 6 tests: ensure_interface, set_private_key, set_listen_port, configure_interface
|
||||||
|
- [x] `wiregui/services/firewall.py` (94%) — 17 tests: _nft/_nft_batch errors, jump rules, policies, get_ruleset
|
||||||
|
- [x] `wiregui/auth/api_token.py` (100%) — covered via test_api_deps.py
|
||||||
|
- [x] `wiregui/auth/saml.py` — full SAML flow tested via mock SimpleSAMLphp IdP (e2e)
|
||||||
|
- [x] `wiregui/utils/server_key.py` (100%) — 3 tests: returns key, raises when missing, raises when empty
|
||||||
|
|
||||||
|
**Remaining unit test gaps (by coverage):**
|
||||||
|
- [ ] `wiregui/auth/seed.py` (29%) — test seed_admin, seed_idp_providers with various YAML configs, ensure_server_keypair
|
||||||
|
- [ ] `wiregui/tasks/__init__.py` (35%) — test register_task, cancel_all
|
||||||
|
- [ ] `wiregui/tasks/oidc_refresh.py` (40%) — test successful refresh, failure with notification, disable_vpn_on_oidc_error
|
||||||
|
- [ ] `wiregui/api/v0/configuration.py` (55%) — test GET/PUT configuration endpoints
|
||||||
|
- [ ] `wiregui/api/v0/devices.py` (65%) — test CRUD device API endpoints
|
||||||
|
- [ ] `wiregui/api/v0/rules.py` (70%) — test CRUD rule API endpoints
|
||||||
|
- [ ] `wiregui/tasks/connectivity.py` (72%) — test connectivity check loop
|
||||||
|
- [ ] `wiregui/utils/network.py` (73%) — test IPv6 allocation, edge cases in CIDR validation
|
||||||
|
- [ ] `wiregui/tasks/stats.py` (74%) — test WG stats polling loop
|
||||||
|
- [ ] `wiregui/tasks/vpn_session.py` (77%) — test session expiry loop
|
||||||
|
- [ ] `wiregui/auth/webauthn.py` (87%) — test verify_registration, verify_authentication with mock credential data
|
||||||
|
- [ ] `wiregui/auth/middleware.py` (0%) — test NiceGUI auth middleware redirect logic
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E2E Tests (Playwright)
|
||||||
|
|
||||||
|
**Completed test suites:**
|
||||||
|
- [x] `tests/e2e/test_login.py` (6 tests) — valid login, invalid password, nonexistent email, disabled user, logout, unauthenticated redirect
|
||||||
|
- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation
|
||||||
|
- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion
|
||||||
|
- [x] `tests/e2e/test_admin_users.py` (10 tests) — page renders, create user, duplicate email, edit role/password, disable/enable, delete, cascade delete, self-delete guard
|
||||||
|
- [x] `tests/e2e/test_idp_seed.py` (9 tests) — IdP YAML seeding (noop/missing/invalid, OIDC/SAML add, upsert, preserve), OIDC button visible, full OIDC login flow via mock-oidc
|
||||||
|
- [x] `tests/e2e/test_mfa_login.py` (4 tests) — MFA redirect on login, valid TOTP completes login, invalid code error, cancel returns to login
|
||||||
|
- [x] `tests/e2e/test_magic_link_page.py` (4 tests) — page renders, success on submit, empty email error, back to login
|
||||||
|
- [x] `tests/e2e/test_admin_devices.py` (7 tests) — list all devices, filter by user, create with defaults, create with overrides, edit name/description, delete, config dialog with QR
|
||||||
|
- [x] `tests/e2e/test_admin_rules.py` (7 tests) — list rules table, create accept/drop/global rules, edit action/destination, delete rule (all verified in DB)
|
||||||
|
- [x] `tests/e2e/test_admin_settings.py` (9 tests) — client defaults save/reload, security toggles (local auth, VPN session, unprivileged), OIDC add/delete, SAML add/delete (all verified in DB)
|
||||||
|
- [x] `tests/e2e/test_saml_login.py` (4 tests) — SAML button visible, redirect to IdP, SP metadata endpoint, full SAML login flow via mock SimpleSAMLphp
|
||||||
|
|
||||||
|
**Remaining E2E test suites:**
|
||||||
|
|
||||||
|
`tests/e2e/test_admin_diagnostics.py` — Admin Diagnostics:
|
||||||
|
- [ ] Page renders WireGuard interface status
|
||||||
|
- [ ] Active peers table shows devices with handshakes
|
||||||
|
- [ ] Connectivity checks table shows recent results
|
||||||
|
- [ ] Notifications list shows system notifications
|
||||||
|
- [ ] Clear single notification → removed
|
||||||
|
- [ ] Clear all notifications → list empty
|
||||||
|
|
||||||
|
`tests/e2e/test_devices_user.py` — User Device Pages:
|
||||||
|
- [ ] Device list shows only own devices (not other users')
|
||||||
|
- [ ] Create device → shows in table with allocated IPs
|
||||||
|
- [ ] Device detail page shows public key, IPs, stats, active config
|
||||||
|
- [ ] Device detail: edit name → persists
|
||||||
|
- [ ] Device detail: toggle config overrides → custom values saved
|
||||||
|
- [ ] Device detail: delete with confirmation → redirects to /devices
|
||||||
|
- [ ] Auto-refresh: stats labels update after timer fires (mock timer)
|
||||||
|
|
||||||
|
`tests/e2e/test_account_extended.py` — Account Page (additional):
|
||||||
|
- [ ] SSO providers section shows connected providers
|
||||||
|
- [ ] SSO providers section shows "No SSO providers" when empty
|
||||||
|
- [ ] MFA: add security key (WebAuthn) → method appears in table (mock navigator.credentials)
|
||||||
|
- [ ] MFA: delete method with confirmation → removed from table
|
||||||
|
- [ ] API tokens: expired token shows "Expired" badge
|
||||||
|
- [ ] API tokens: delete token → removed from table
|
||||||
|
- [ ] API tokens: copy button calls clipboard API
|
||||||
|
- [ ] Danger zone: disabled when only admin
|
||||||
|
- [ ] Danger zone: wrong email in confirmation → shows error
|
||||||
302
TODO.md
302
TODO.md
|
|
@ -1,233 +1,93 @@
|
||||||
# WireGUI Implementation TODO
|
# WireGUI — TODO
|
||||||
|
|
||||||
Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI.
|
|
||||||
Source: `/home/stefanob/PycharmProjects/personal/wirezone`
|
|
||||||
|
|
||||||
**Test count: 174 (173 passing, 1 skipped) | Coverage: 35%**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: Foundation — Models, DB, Config ✅
|
## WireGuard Metrics Collector
|
||||||
|
|
||||||
- [x] `pyproject.toml` with dependencies, `uv sync`
|
### Overview
|
||||||
- [x] Package directory structure
|
|
||||||
- [x] `wiregui/config.py` — pydantic-settings (DB, Redis, WG, auth, SMTP, logging)
|
Separate Python process dedicated to high-frequency WireGuard stats collection, with optional VictoriaMetrics time-series storage. Replaces the current 60s in-process polling with a 5s external collector.
|
||||||
- [x] `wiregui/db.py` — async engine, sessionmaker, `init_db()`
|
|
||||||
- [x] `wiregui/redis.py` — Valkey connection pool
|
### Current state
|
||||||
- [x] All 8 SQLModel models (User, Device, Rule, MFAMethod, OIDCConnection, ApiToken, ConnectivityCheck, Configuration)
|
- `tasks/stats.py`: polls `wg show dump` every 60s inside the web process asyncio loop
|
||||||
- [x] Alembic init + initial migration + `alembic upgrade head`
|
- UI timers: 30s refresh on device pages
|
||||||
- [x] `wiregui/main.py` — app entrypoint
|
- Worst-case latency: ~90s before a stat change is visible
|
||||||
- [x] `compose.yml` — PostgreSQL 17 + Valkey 8
|
|
||||||
- [x] `wiregui/utils/time.py` — `utcnow()` helper for naive UTC timestamps
|
### Target state
|
||||||
|
- Collector process: polls every 5s, writes to DB + VictoriaMetrics
|
||||||
|
- UI timers: 10s refresh
|
||||||
|
- Worst-case latency: ~15s
|
||||||
|
|
||||||
|
### Phase 1: Configuration ✅
|
||||||
|
|
||||||
|
- [x] Add settings to `config.py`:
|
||||||
|
- `WG_METRICS_ENABLED: bool = False`
|
||||||
|
- `WG_METRICS_POLL_INTERVAL: int = 5` (seconds)
|
||||||
|
- `WG_VICTORIAMETRICS_URL: str | None = None` (e.g. `http://localhost:8428`)
|
||||||
|
- [x] When `WG_METRICS_ENABLED=false`, keep existing `stats_loop` as fallback
|
||||||
|
- [x] When `WG_METRICS_ENABLED=true`, skip registering `stats_loop` in `main.py`
|
||||||
|
|
||||||
|
### Phase 2: Collector process ✅
|
||||||
|
|
||||||
|
- [x] Create `wiregui/collector.py` — standalone entry point (`python -m wiregui.collector`)
|
||||||
|
- [x] No NiceGUI dependency — only asyncio + asyncpg + httpx
|
||||||
|
- [x] Poll `wg show <iface> dump` every `WG_METRICS_POLL_INTERVAL` seconds
|
||||||
|
- [x] Update Device rows in PostgreSQL (same fields as current `stats_loop`)
|
||||||
|
- [x] Push metrics to VictoriaMetrics via `/api/v1/import/prometheus` (if URL configured)
|
||||||
|
- [x] Graceful shutdown on SIGTERM/SIGINT
|
||||||
|
- [x] Web app spawns collector as subprocess when `WG_METRICS_ENABLED=true`
|
||||||
|
- [x] Web app terminates collector on shutdown
|
||||||
|
|
||||||
|
### Phase 3: VictoriaMetrics metrics ✅
|
||||||
|
|
||||||
|
All metrics implemented in `collector.py` and verified by integration tests:
|
||||||
|
- [x] `wiregui_peer_rx_bytes{public_key, user_email, device_name}` — counter
|
||||||
|
- [x] `wiregui_peer_tx_bytes{public_key, user_email, device_name}` — counter
|
||||||
|
- [x] `wiregui_peer_latest_handshake_seconds{public_key, user_email, device_name}` — gauge
|
||||||
|
- [x] `wiregui_peer_connected{public_key, user_email, device_name}` — 1 if handshake < 180s, else 0
|
||||||
|
- [x] `wiregui_peers_total` — gauge, count of active peers
|
||||||
|
|
||||||
|
### Phase 4: UI improvements
|
||||||
|
|
||||||
|
- [x] Reduce UI timer from 30s to 5s on all device pages (devices.py, admin/devices.py, detail page)
|
||||||
|
- [x] Add connection status indicator (green/yellow/red dot) based on handshake age
|
||||||
|
- Green: handshake < 2 min
|
||||||
|
- Yellow: handshake < 5 min
|
||||||
|
- Red: no recent handshake or never connected
|
||||||
|
- [x] Status column in both user and admin device tables
|
||||||
|
- [x] Status badge on device detail page (live-updating)
|
||||||
|
- [x] Add traffic rate display (RX/s, TX/s computed from delta between 5s polls)
|
||||||
|
- [x] Device detail page: live ECharts traffic rate chart (RX/s + TX/s area lines, 60-point rolling window, auto-scaled axis with human-readable byte formatting)
|
||||||
|
|
||||||
|
### Phase 5: Infrastructure ✅
|
||||||
|
|
||||||
|
- [x] Create `compose.test.yml` — full integration stack with real WG
|
||||||
|
- [x] Add VictoriaMetrics (single-node, port 8428, 7d retention)
|
||||||
|
- [x] Add 3 mock WG client containers (alpine + wireguard-tools)
|
||||||
|
- [x] Clients generate traffic by pinging each other through the tunnel every 3s
|
||||||
|
- [x] Setup script (`docker/mock-clients/setup.py`) generates keypairs and configs
|
||||||
|
- [x] Collector runs as subprocess inside the WireGUI container (shares network namespace)
|
||||||
|
- [ ] Add VictoriaMetrics to dev `compose.yml` (optional, for local testing)
|
||||||
|
|
||||||
|
### Design notes
|
||||||
|
|
||||||
|
- **Why a separate process?** The `wg show` subprocess call and DB writes at 5s intervals shouldn't share the asyncio loop with the web app. A separate process ensures UI responsiveness isn't affected by stats collection.
|
||||||
|
- **Why not `run.cpu_bound`?** That uses `ProcessPoolExecutor` for one-shot CPU tasks inside request handling — not suitable for a long-running daemon. A separate entry point is cleaner.
|
||||||
|
- **VictoriaMetrics push model:** Use the Prometheus remote write API. No scrape config needed — the collector pushes directly. VictoriaMetrics is optional; the collector works fine with just PostgreSQL.
|
||||||
|
- **Backward compatible:** When `WG_METRICS_ENABLED=false` (default), everything works exactly as it does today.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Auth System — Login + Sessions ✅
|
## CI/Testing
|
||||||
|
|
||||||
- [x] `wiregui/auth/passwords.py` — bcrypt hash/verify (direct bcrypt, not passlib)
|
- [ ] Fix E2E tests in CI — tests pass locally but fail in the Forgejo Actions container environment (stale DB reads between app subprocess and test process, Playwright can't resolve Docker service hostnames for SAML redirect). Currently disabled in `.forgejo/workflows/dev.yml`.
|
||||||
- [x] `wiregui/auth/jwt.py` — create/decode JWT via python-jose
|
|
||||||
- [x] `wiregui/auth/session.py` — `authenticate_user()` email/password verification
|
|
||||||
- [x] `wiregui/auth/middleware.py` — HTTP-level auth middleware (available for REST API)
|
|
||||||
- [x] `wiregui/auth/seed.py` — auto-create admin on first startup
|
|
||||||
- [x] `wiregui/pages/login.py` — login page with email/password form
|
|
||||||
- [x] `wiregui/pages/home.py` — authenticated home (redirects to /devices)
|
|
||||||
- [x] Auth guards via `app.storage.user` on each page
|
|
||||||
- [x] Logout clears storage and redirects
|
|
||||||
|
|
||||||
---
|
## UI
|
||||||
|
|
||||||
## Phase 3: Device UI — User-Facing CRUD ✅
|
- [ ] SAML provider management in Authentication tab (admin settings)
|
||||||
|
- [ ] SSO Providers on account page: add Status column, "Disconnect" action
|
||||||
|
- [ ] Admin pages (users, devices, rules): apply same card-based styling as account/settings/diagnostics
|
||||||
|
|
||||||
- [x] `wiregui/pages/layout.py` — shared sidebar + header
|
## Features
|
||||||
- [x] `wiregui/utils/network.py` — IPv4/IPv6 allocation (random offset + scan)
|
|
||||||
- [x] `wiregui/utils/crypto.py` — WG keypair + PSK generation via `wg` CLI
|
|
||||||
- [x] `wiregui/utils/wg_conf.py` — WG client `.conf` builder
|
|
||||||
- [x] `wiregui/pages/devices.py` — `/devices` list + create dialog + delete
|
|
||||||
- [x] `/devices/{device_id}` — detail page with stats and config flags
|
|
||||||
- [x] QR code generation + `.conf` download
|
|
||||||
- [x] Full device create/edit form with all wirezone options (description, per-device config overrides, use_default_* toggles with bound inputs, better layout)
|
|
||||||
|
|
||||||
---
|
- [ ] First-run CLI setup command
|
||||||
|
|
||||||
## Phase 4: WireGuard Integration ✅
|
|
||||||
|
|
||||||
- [x] `wiregui/services/wireguard.py` — async subprocess: ensure_interface, add/remove_peer, get_peers, set_private_key, set_listen_port
|
|
||||||
- [x] `wiregui/services/events.py` — event bridge: device CRUD → WG + firewall
|
|
||||||
- [x] Device create/delete in UI fires WG events
|
|
||||||
- [x] `wiregui/tasks/__init__.py` — background task registry + cancel_all
|
|
||||||
- [x] `wiregui/tasks/stats.py` — poll WG stats every 60s, update DB
|
|
||||||
- [x] `wiregui/tasks/reconcile.py` — startup reconciliation (diff DB vs WG, add/remove)
|
|
||||||
- [x] `config.py` — `wg_enabled` flag (default False for dev)
|
|
||||||
- [x] Startup: ensure_interface → reconcile → stats_loop (when wg_enabled)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Firewall (nftables) ✅
|
|
||||||
|
|
||||||
- [x] `wiregui/services/firewall.py` — nft CLI: setup_base_tables, masquerade, per-user chains, jump rules, apply_rule, rebuild_all_rules
|
|
||||||
- [x] IPv4/IPv6 aware, TCP/UDP port range support
|
|
||||||
- [x] `wiregui/pages/admin/rules.py` — `/admin/rules` CRUD (action, CIDR, protocol, port, user)
|
|
||||||
- [x] Events: on_rule_created/deleted, on_device_created adds jump rules
|
|
||||||
- [x] Startup: setup_base_tables + setup_masquerade (when wg_enabled)
|
|
||||||
- [x] Edit rule — edit dialog in admin rules page with all fields
|
|
||||||
- [x] Full user chain rebuild on rule update/delete via `_rebuild_user_chain()` in events.py
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: REST API (v0) ✅
|
|
||||||
|
|
||||||
- [x] `wiregui/auth/api_token.py` — token generation (random → sha256), Bearer resolution with expiry + disabled user checks
|
|
||||||
- [x] `wiregui/api/deps.py` — get_db, get_current_api_user, require_admin
|
|
||||||
- [x] `wiregui/schemas/` — Pydantic schemas: UserRead/Create/Update, DeviceRead/Create/Update, RuleRead/Create/Update, ConfigurationRead/Update
|
|
||||||
- [x] `wiregui/api/v0/users.py` — full CRUD (admin only)
|
|
||||||
- [x] `wiregui/api/v0/devices.py` — full CRUD (owner or admin, triggers WG/firewall events)
|
|
||||||
- [x] `wiregui/api/v0/rules.py` — full CRUD (admin only, triggers firewall events)
|
|
||||||
- [x] `wiregui/api/v0/configuration.py` — GET/PUT (admin only, auto-creates singleton)
|
|
||||||
- [x] Mounted on NiceGUI app at `/api/v0`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Admin UI ✅
|
|
||||||
|
|
||||||
- [x] `/admin/users` — table (email, role, devices, status, last sign-in, method, created), create (email/password/role), edit (email/role/password/disabled), delete with cascading cleanup (devices → WG events, rules)
|
|
||||||
- [x] `/admin/devices` — all devices with user filter, full create form (owner, name, description, all use_default_* toggles with bound override inputs), full edit form, delete with WG events, config + QR on creation
|
|
||||||
- [x] `/admin/settings` — 3 tabs:
|
|
||||||
- Client Defaults (endpoint, DNS, allowed IPs, MTU, keepalive)
|
|
||||||
- Security (VPN session duration, local auth, unpriv device mgmt/config, OIDC auto-disable)
|
|
||||||
- Authentication (OIDC provider CRUD with table + dialog; SAML placeholder for Phase 8)
|
|
||||||
- [x] `/admin/diagnostics` — WG interface status, active peers, connectivity checks, system notifications with clear/clear-all
|
|
||||||
- [x] `wiregui/services/notifications.py` — in-memory deque (capped at 100), add/clear/count/current
|
|
||||||
- [x] Header notification bell badge (admin only, links to diagnostics)
|
|
||||||
- [ ] **TODO:** SAML provider management in Authentication tab
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Advanced Auth (MFA, OIDC, Magic Links, SAML) ✅
|
|
||||||
|
|
||||||
- [x] TOTP MFA (`wiregui/auth/mfa.py`) — secret generation, URI/QR, verification with clock drift tolerance
|
|
||||||
- [x] MFA challenge page (`/mfa`) — 6-digit code entry, multi-method support, last-used tracking
|
|
||||||
- [x] Login page updated: checks for MFA methods after password auth, redirects to `/mfa` if present
|
|
||||||
- [x] OIDC (`wiregui/auth/oidc.py`) — provider registry from Configuration, authlib Starlette integration
|
|
||||||
- [x] OIDC routes (`/auth/oidc/{provider}` + `/auth/oidc/{provider}/callback`) — auth code flow, user lookup/auto-create, refresh token storage in OIDCConnection
|
|
||||||
- [x] Login page shows OIDC provider buttons dynamically from config
|
|
||||||
- [x] OIDC refresh task (`wiregui/tasks/oidc_refresh.py`) — every 10min, refreshes all stored tokens, creates notifications on failure, respects `disable_vpn_on_oidc_error`
|
|
||||||
- [x] Magic links (`/auth/magic-link` + `/auth/magic/{user_id}/{token}`) — request page, signed JWT with 15min expiry, email via aiosmtplib
|
|
||||||
- [x] Email service (`wiregui/services/email.py`) — aiosmtplib send, magic link template
|
|
||||||
- [x] `/account` page — 3 tabs: Profile (details + password change), Two-Factor Auth (TOTP registration with QR + verification, list/delete methods), API Tokens (create with configurable expiry, list, delete)
|
|
||||||
- [x] OIDC providers registered on startup from Configuration
|
|
||||||
- [x] WebAuthn MFA (`wiregui/auth/webauthn.py`) — registration/authentication options generation, response verification, credential storage
|
|
||||||
- [x] SAML (`wiregui/auth/saml.py` + `wiregui/pages/auth_saml.py`) — SP-initiated SSO, metadata endpoint, ACS callback, IdP metadata parsing, attribute mapping
|
|
||||||
- [x] WebAuthn browser-side JS integration in account page — `ui.run_javascript()` calls `navigator.credentials.create()`, serializes response, server verifies and stores credential
|
|
||||||
- [x] SAML provider management UI in admin settings Authentication tab — table + add/delete dialog (config ID, label, XML metadata, sign requests/metadata/assertions/envelopes toggles, auto-create users)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Background Tasks & VPN Session Management
|
|
||||||
|
|
||||||
- [x] Task scheduler (`wiregui/tasks/__init__.py`) — register/cancel
|
|
||||||
- [x] Stats polling task (Phase 4)
|
|
||||||
- [x] OIDC refresh task (Phase 8)
|
|
||||||
- [x] VPN session expiry task (`wiregui/tasks/vpn_session.py`) — every 60s, finds expired sessions based on `vpn_session_duration` + `last_signed_in_at`, removes WG peers, creates notifications
|
|
||||||
- [x] Connectivity check poller (`wiregui/tasks/connectivity.py`) — fetches URL, stores result in DB, notification on failure
|
|
||||||
- [x] Live stats push — `ui.timer(30, ...)` on `/devices` (table refresh), `/devices/{id}` (RX/TX/handshake/remote IP labels), `/admin/devices` (table refresh)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Polish, Testing & Deployment
|
|
||||||
|
|
||||||
### Testing (partially done)
|
|
||||||
- [x] pytest + pytest-asyncio setup, conftest with test DB
|
|
||||||
- [x] test_models.py (10 tests), test_auth.py (8 tests), test_utils.py (6 tests), test_services.py (6 tests), test_firewall.py (7 tests)
|
|
||||||
- [x] test_api.py (6 tests) — token generation, resolution, expiry, disabled user
|
|
||||||
- [x] test_notifications.py (9 tests) — add, ordering, count, clear, max cap, to_dict
|
|
||||||
- [x] test_admin.py (13 tests) — user CRUD, cascading deletes, config CRUD, OIDC providers, device overrides
|
|
||||||
- [x] test_mfa.py (11 tests) — TOTP secret gen, URI, code verification (valid/invalid/wrong secret/empty), QR SVG, DB integration, multi-method
|
|
||||||
- [x] test_magic_link.py (4 tests) — token creation/expiry/user mismatch, disabled user rejection
|
|
||||||
- [x] test_account.py (8 tests) — password change flow, API token CRUD, OIDC connection CRUD, refresh token update
|
|
||||||
- [x] test_integration_mfa.py (7 tests) — full TOTP registration flow, MFA blocks login, wrong code, multi-method, last-used tracking, delete allows bypass, disabled user
|
|
||||||
- [x] test_integration_oidc.py (10 tests) — provider config loading, connection create/update, auto-create user, disabled user, refresh token, multi-provider
|
|
||||||
- [x] test_tasks.py (6 tests) — VPN session expiry (expired/unlimited/no-config/disabled user), connectivity check (success/failure with notification)
|
|
||||||
- [ ] HTTP-level integration tests (OIDC redirect/callback flow with respx mocking)
|
|
||||||
|
|
||||||
### Coverage gaps (35% overall — run `uv run pytest --cov=wiregui --cov-report=term-missing --cov-branch`)
|
|
||||||
|
|
||||||
**100% covered:** models, schemas, config, auth/passwords, auth/jwt, auth/mfa, auth/api_token, utils/crypto, utils/time, services/notifications
|
|
||||||
|
|
||||||
**API routes (32-84% — partially covered via httpx TestClient):**
|
|
||||||
- [x] `wiregui/api/v0/users.py` (84%) — list/get/create/update/delete
|
|
||||||
- [x] `wiregui/api/v0/rules.py` (71%) — CRUD
|
|
||||||
- [x] `wiregui/api/v0/devices.py` (67%) — CRUD, permissions
|
|
||||||
- [x] `wiregui/api/v0/configuration.py` (61%) — get/update, auto-create
|
|
||||||
- [ ] `wiregui/api/deps.py` (32%) — test get_current_api_user with real Bearer header parsing, require_admin rejection
|
|
||||||
|
|
||||||
**Services (62-89% covered):**
|
|
||||||
- [x] `wiregui/services/wireguard.py` (62%) — add/remove/get peers mocked
|
|
||||||
- [x] `wiregui/services/firewall.py` (73%) — base tables, chains, rules, rebuild mocked
|
|
||||||
- [x] `wiregui/services/events.py` (80%) — device + rule events, rebuild chain
|
|
||||||
- [x] `wiregui/services/email.py` (89%) — send_email, magic link, no-smtp fallback
|
|
||||||
- [ ] `wiregui/services/wireguard.py` — test ensure_interface, set_private_key, set_listen_port
|
|
||||||
- [ ] `wiregui/services/firewall.py` — test _nft/_nft_batch error handling, add_device_jump_rule with only ipv4/ipv6
|
|
||||||
|
|
||||||
**Tasks (40-84% covered):**
|
|
||||||
- [x] `wiregui/tasks/stats.py` (77%) — update from peers, no-op, unmatched peer
|
|
||||||
- [x] `wiregui/tasks/reconcile.py` (84%) — add missing, remove orphaned, in-sync
|
|
||||||
- [x] `wiregui/tasks/oidc_refresh.py` (40%) — no connections, skip unknown provider
|
|
||||||
- [ ] `wiregui/tasks/oidc_refresh.py` — test successful refresh, failure with notification, disable_vpn_on_oidc_error
|
|
||||||
|
|
||||||
**Auth modules (85-92% covered):**
|
|
||||||
- [x] `wiregui/auth/oidc.py` (87%) — register providers, get_client, load from config
|
|
||||||
- [x] `wiregui/auth/webauthn.py` (85%) — registration/authentication options
|
|
||||||
- [x] `wiregui/auth/session.py` (90%) — no-password, disabled, nonexistent user
|
|
||||||
- [ ] `wiregui/auth/saml.py` (0%) — needs mock SAML IdP metadata + response parsing
|
|
||||||
- [ ] `wiregui/auth/webauthn.py` — test verify_registration, verify_authentication with mock credential data
|
|
||||||
|
|
||||||
**E2E page tests (via NiceGUI `User` fixture in `tests/e2e/`):**
|
|
||||||
- [x] `tests/e2e/test_devices.py` (2 tests) — add device full flow, name validation
|
|
||||||
- [x] `tests/e2e/test_account.py` (8 tests) — change password (success/wrong/mismatch/short), create API token, TOTP registration + invalid code, account deletion
|
|
||||||
- [ ] E2E tests for admin pages (users, devices, rules, settings)
|
|
||||||
|
|
||||||
### Logging (done)
|
|
||||||
- [x] Loguru configured (wiregui/logging.py), no print statements
|
|
||||||
- [x] File logging to `logs/` when `WG_LOG_TO_FILE=true`
|
|
||||||
|
|
||||||
### Deployment ✅
|
|
||||||
- [x] Dockerfile (multi-stage python:3.13-slim)
|
|
||||||
- [x] compose.prod.yml (bridge networking, NET_ADMIN, nftables)
|
|
||||||
- [x] Health endpoint `GET /api/health`
|
|
||||||
- [x] Forgejo CI: test → semver → Docker registry push
|
|
||||||
- [ ] First-run CLI setup command
|
|
||||||
- [ ] README.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## UI Polish & Styling
|
|
||||||
|
|
||||||
### Global styling ✅
|
|
||||||
- [x] Manrope font loaded from Google Fonts as primary UI font (`wiregui/pages/style.py`)
|
|
||||||
- [x] Font applied on all pages (layout, login, MFA challenge)
|
|
||||||
- [x] Dark/light/auto theme toggle in header — cycles with icon button
|
|
||||||
- [x] Theme preference stored in `users.theme_preference` column (migration `a3f1d8e92b01`)
|
|
||||||
- [x] Theme persisted to DB and loaded into session on all login flows (password, MFA, magic link, OIDC, SAML)
|
|
||||||
|
|
||||||
### Account page (`/account`) ✅
|
|
||||||
- [x] Card-based layout matching admin pages (diagnostics, settings)
|
|
||||||
- [x] Account Details: `ui.grid(columns=2)` with bold labels, same as diagnostics
|
|
||||||
- [x] Change Password: inline card section (no modal), outlined inputs, validation
|
|
||||||
- [x] Connected SSO Providers: always visible card with empty state
|
|
||||||
- [x] API Tokens: table with status badges, inline create, copy-to-clipboard with green accent card
|
|
||||||
- [x] MFA: methods table, inline TOTP registration (QR + verify), WebAuthn, empty state
|
|
||||||
- [x] Danger Zone: red left border accent, typed email confirmation, disabled if only admin
|
|
||||||
|
|
||||||
### Settings page (`/admin/settings`) ✅
|
|
||||||
- [x] Converted from tabbed layout to stacked cards (Client Defaults, Security, Authentication)
|
|
||||||
|
|
||||||
### Consistency pass ✅
|
|
||||||
- [x] All buttons solid (`unelevated`) — no outline buttons anywhere
|
|
||||||
- [x] All pages use `w-full p-4` container with `text-h5 q-mb-md` page title
|
|
||||||
- [x] All `text-grey-7` / `text-grey-8` replaced with dark-mode-safe `text-grey`
|
|
||||||
- [x] Sidebar: removed hardcoded `bg-grey-1`, uses theme-aware background
|
|
||||||
- [x] Card titles: `text-subtitle1 text-bold` + `ui.separator()` everywhere
|
|
||||||
|
|
||||||
### Remaining
|
|
||||||
- [ ] SSO Providers: add Status column, "Disconnect" action
|
|
||||||
- [ ] Admin pages (users, devices, rules): apply same card-based styling
|
|
||||||
28
alembic/versions/b7e2f4a1c903_add_firewall_policy_fields.py
Normal file
28
alembic/versions/b7e2f4a1c903_add_firewall_policy_fields.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""add firewall policy fields to configurations
|
||||||
|
|
||||||
|
Revision ID: b7e2f4a1c903
|
||||||
|
Revises: a3f1d8e92b01
|
||||||
|
Create Date: 2026-03-31 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b7e2f4a1c903'
|
||||||
|
down_revision: Union[str, None] = 'a3f1d8e92b01'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('configurations', sa.Column('allow_peer_to_peer', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
op.add_column('configurations', sa.Column('allow_lan_to_peers', sa.Boolean(), nullable=False, server_default='false'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('configurations', 'allow_lan_to_peers')
|
||||||
|
op.drop_column('configurations', 'allow_peer_to_peer')
|
||||||
|
|
@ -16,7 +16,7 @@ services:
|
||||||
- net.ipv6.conf.all.forwarding=1
|
- net.ipv6.conf.all.forwarding=1
|
||||||
- net.ipv6.conf.all.disable_ipv6=0
|
- net.ipv6.conf.all.disable_ipv6=0
|
||||||
environment:
|
environment:
|
||||||
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
WG_DATABASE_URL: postgresql+asyncpg://wiregui:${POSTGRES_PASSWORD:-wiregui}@postgres/wiregui
|
||||||
WG_REDIS_URL: redis://valkey:6379/0
|
WG_REDIS_URL: redis://valkey:6379/0
|
||||||
WG_SECRET_KEY: ${WG_SECRET_KEY:-change-me-in-production}
|
WG_SECRET_KEY: ${WG_SECRET_KEY:-change-me-in-production}
|
||||||
WG_WG_ENABLED: "true"
|
WG_WG_ENABLED: "true"
|
||||||
|
|
@ -28,6 +28,10 @@ services:
|
||||||
WG_ADMIN_EMAIL: ${WG_ADMIN_EMAIL:-admin@localhost}
|
WG_ADMIN_EMAIL: ${WG_ADMIN_EMAIL:-admin@localhost}
|
||||||
WG_ADMIN_PASSWORD: ${WG_ADMIN_PASSWORD:-}
|
WG_ADMIN_PASSWORD: ${WG_ADMIN_PASSWORD:-}
|
||||||
WG_LOG_TO_FILE: "true"
|
WG_LOG_TO_FILE: "true"
|
||||||
|
WG_METRICS_ENABLED: "true"
|
||||||
|
WG_METRICS_POLL_INTERVAL: "5"
|
||||||
|
WG_VICTORIAMETRICS_URL: http://victoriametrics:8428
|
||||||
|
WG_IDP_CONFIG_FILE: ${WG_IDP_CONFIG_FILE:-}
|
||||||
volumes:
|
volumes:
|
||||||
- wiregui_logs:/app/logs
|
- wiregui_logs:/app/logs
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -35,15 +39,15 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
valkey:
|
valkey:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
victoriametrics:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: wiregui
|
POSTGRES_USER: wiregui
|
||||||
POSTGRES_PASSWORD: wiregui
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wiregui}
|
||||||
POSTGRES_DB: wiregui
|
POSTGRES_DB: wiregui
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
@ -59,7 +63,17 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- valkey_data:/data
|
- valkey_data:/data
|
||||||
|
|
||||||
|
victoriametrics:
|
||||||
|
image: victoriametrics/victoria-metrics:v1.108.1
|
||||||
|
restart: unless-stopped
|
||||||
|
command:
|
||||||
|
- "-retentionPeriod=90d"
|
||||||
|
- "-httpListenAddr=:8428"
|
||||||
|
volumes:
|
||||||
|
- vm_data:/victoria-metrics-data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
valkey_data:
|
valkey_data:
|
||||||
wiregui_logs:
|
wiregui_logs:
|
||||||
|
vm_data:
|
||||||
|
|
|
||||||
162
compose.yml
162
compose.yml
|
|
@ -1,12 +1,29 @@
|
||||||
|
# WireGUI — unified compose stack
|
||||||
|
#
|
||||||
|
# Dev mode (app runs on host):
|
||||||
|
# make dev — starts infra + mock IdPs, runs app locally
|
||||||
|
# make dev-up — starts infra only
|
||||||
|
#
|
||||||
|
# Integration test mode (real WireGuard + mock clients + metrics):
|
||||||
|
# make test-stack-up — seeds DB, builds, starts everything
|
||||||
|
# make test-stack-down — tears down and removes volumes
|
||||||
|
#
|
||||||
|
# Services are opt-in: only start what you need.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core infrastructure (always needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17
|
image: postgres:17
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: wiregui
|
POSTGRES_USER: wiregui
|
||||||
POSTGRES_PASSWORD: wiregui
|
POSTGRES_PASSWORD: wiregui
|
||||||
POSTGRES_DB: wiregui
|
POSTGRES_DB: wiregui
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|
@ -17,6 +34,147 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- valkey_data:/data
|
- valkey_data:/data
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock identity providers (dev + e2e tests)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# OIDC — accepts any login, issues real JWTs
|
||||||
|
# Discovery: http://localhost:9000/test-idp/.well-known/openid-configuration
|
||||||
|
mock-oidc:
|
||||||
|
image: ghcr.io/navikt/mock-oauth2-server:2.1.10
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
environment:
|
||||||
|
SERVER_PORT: "9000"
|
||||||
|
JSON_CONFIG: >
|
||||||
|
{
|
||||||
|
"interactiveLogin": true,
|
||||||
|
"httpServer": "NettyWrapper",
|
||||||
|
"tokenCallbacks": [
|
||||||
|
{
|
||||||
|
"issuerId": "test-idp",
|
||||||
|
"tokenExpiry": 3600,
|
||||||
|
"requestMappings": [
|
||||||
|
{
|
||||||
|
"requestParam": "scope",
|
||||||
|
"match": "*",
|
||||||
|
"claims": {
|
||||||
|
"sub": "$${claim:sub}",
|
||||||
|
"email": "$${claim:sub}@test.local",
|
||||||
|
"name": "Test User"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# SAML — SimpleSAMLphp as IdP
|
||||||
|
# Metadata: http://localhost:8080/simplesaml/saml2/idp/metadata.php
|
||||||
|
# Admin: http://localhost:8080/simplesaml (admin / secret)
|
||||||
|
# Users: user1/password, user2/password
|
||||||
|
mock-saml:
|
||||||
|
image: kenchan0130/simplesamlphp
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
SIMPLESAMLPHP_SP_ENTITY_ID: "http://localhost:13000/auth/saml/test-saml/metadata"
|
||||||
|
SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE: "http://localhost:13000/auth/saml/test-saml/callback"
|
||||||
|
SIMPLESAMLPHP_IDP_BASE_URL: http://localhost:8080/simplesaml/
|
||||||
|
volumes:
|
||||||
|
- ./docker/mock-saml/saml20-sp-remote.php:/var/www/simplesamlphp/metadata/saml20-sp-remote.php:ro
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WireGUI server (integration test mode — containerized with real WG)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
wiregui:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "13000:13000"
|
||||||
|
# 51820/udp exposed inside Docker network only — clients connect via service name
|
||||||
|
# Uncomment to expose to host: - "51820:51820/udp"
|
||||||
|
environment:
|
||||||
|
WG_DATABASE_URL: postgresql+asyncpg://wiregui:wiregui@postgres/wiregui
|
||||||
|
WG_REDIS_URL: redis://valkey:6379/0
|
||||||
|
WG_WG_ENABLED: "true"
|
||||||
|
WG_EXTERNAL_URL: http://localhost:13000
|
||||||
|
WG_ENDPOINT_HOST: wiregui
|
||||||
|
WG_METRICS_ENABLED: "true"
|
||||||
|
WG_METRICS_POLL_INTERVAL: "5"
|
||||||
|
WG_VICTORIAMETRICS_URL: http://victoriametrics:8428
|
||||||
|
WG_ADMIN_EMAIL: admin@test.local
|
||||||
|
WG_ADMIN_PASSWORD: admin123
|
||||||
|
WG_LOG_TO_FILE: "false"
|
||||||
|
WG_SECRET_KEY: test-secret-key-for-integration
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ip_forward=1
|
||||||
|
- net.ipv6.conf.all.forwarding=1
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- valkey
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Metrics (integration test mode)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
victoriametrics:
|
||||||
|
image: victoriametrics/victoria-metrics:v1.108.1
|
||||||
|
ports:
|
||||||
|
- "8428:8428"
|
||||||
|
command:
|
||||||
|
- "-retentionPeriod=7d"
|
||||||
|
- "-httpListenAddr=:8428"
|
||||||
|
volumes:
|
||||||
|
- vm_data:/victoria-metrics-data
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock WireGuard clients (integration test mode)
|
||||||
|
# Configs generated by: make test-stack-seed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
client1:
|
||||||
|
build: docker/mock-clients
|
||||||
|
environment:
|
||||||
|
CLIENT_IP: ${CLIENT1_IP:-10.3.2.101}
|
||||||
|
PEER_IPS: ${CLIENT1_PEERS:-10.3.2.102 10.3.2.103}
|
||||||
|
PING_INTERVAL: "3"
|
||||||
|
volumes:
|
||||||
|
- ./docker/mock-clients/configs/client1.conf:/etc/wireguard/wg0.conf:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
depends_on:
|
||||||
|
- wiregui
|
||||||
|
|
||||||
|
client2:
|
||||||
|
build: docker/mock-clients
|
||||||
|
environment:
|
||||||
|
CLIENT_IP: ${CLIENT2_IP:-10.3.2.102}
|
||||||
|
PEER_IPS: ${CLIENT2_PEERS:-10.3.2.101 10.3.2.103}
|
||||||
|
PING_INTERVAL: "3"
|
||||||
|
volumes:
|
||||||
|
- ./docker/mock-clients/configs/client2.conf:/etc/wireguard/wg0.conf:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
depends_on:
|
||||||
|
- wiregui
|
||||||
|
|
||||||
|
client3:
|
||||||
|
build: docker/mock-clients
|
||||||
|
environment:
|
||||||
|
CLIENT_IP: ${CLIENT3_IP:-10.3.2.103}
|
||||||
|
PEER_IPS: ${CLIENT3_PEERS:-10.3.2.101 10.3.2.102}
|
||||||
|
PING_INTERVAL: "3"
|
||||||
|
volumes:
|
||||||
|
- ./docker/mock-clients/configs/client3.conf:/etc/wireguard/wg0.conf:ro
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
depends_on:
|
||||||
|
- wiregui
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
valkey_data:
|
valkey_data:
|
||||||
|
vm_data:
|
||||||
|
|
|
||||||
4
docker/mock-clients/Dockerfile
Normal file
4
docker/mock-clients/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
FROM alpine:3.20
|
||||||
|
RUN apk add --no-cache wireguard-tools iproute2 iputils-ping
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
27
docker/mock-clients/entrypoint.sh
Executable file
27
docker/mock-clients/entrypoint.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# WireGuard mock client — configures interface and generates traffic
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[*] Configuring WireGuard interface..."
|
||||||
|
ip link add wg0 type wireguard
|
||||||
|
wg setconf wg0 /etc/wireguard/wg0.conf
|
||||||
|
ip address add "${CLIENT_IP}/32" dev wg0
|
||||||
|
ip link set wg0 up
|
||||||
|
|
||||||
|
# Route traffic to the VPN subnet through the tunnel
|
||||||
|
ip route add 10.3.2.0/24 dev wg0
|
||||||
|
|
||||||
|
echo "[*] WireGuard client up: ${CLIENT_IP}"
|
||||||
|
echo "[*] Generating traffic to peers every ${PING_INTERVAL:-5}s..."
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# Ping the server (first host in the subnet)
|
||||||
|
ping -c 1 -W 1 10.3.2.1 > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Ping other peers if specified
|
||||||
|
for peer_ip in $PEER_IPS; do
|
||||||
|
ping -c 1 -W 1 "$peer_ip" > /dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
|
||||||
|
sleep "${PING_INTERVAL:-5}"
|
||||||
|
done
|
||||||
147
docker/mock-clients/setup.py
Normal file
147
docker/mock-clients/setup.py
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Seed the test stack: generate server + client keypairs, write client WG configs,
|
||||||
|
and insert devices into the database.
|
||||||
|
|
||||||
|
Usage: uv run python docker/mock-clients/setup.py
|
||||||
|
|
||||||
|
Requires: Postgres running and migrations applied (alembic upgrade head).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.db import async_session, engine
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
from wiregui.models.device import Device
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from wiregui.utils.crypto import generate_keypair, generate_preshared_key
|
||||||
|
|
||||||
|
NUM_CLIENTS = 3
|
||||||
|
SUBNET = "10.3.2"
|
||||||
|
SERVER_ENDPOINT = "wiregui:51820"
|
||||||
|
CONFIG_DIR = Path(__file__).parent / "configs"
|
||||||
|
|
||||||
|
# Test admin user
|
||||||
|
ADMIN_EMAIL = "admin@test.local"
|
||||||
|
ADMIN_PASSWORD = "admin123"
|
||||||
|
|
||||||
|
# Client definitions
|
||||||
|
CLIENTS = [
|
||||||
|
{"name": f"test-client-{i}", "ip": f"{SUBNET}.{100 + i}"}
|
||||||
|
for i in range(1, NUM_CLIENTS + 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed():
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
# --- Server keypair ---
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
if config is None:
|
||||||
|
config = Configuration()
|
||||||
|
session.add(config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
if not config.server_private_key or not config.server_public_key:
|
||||||
|
server_priv, server_pub = generate_keypair()
|
||||||
|
config.server_private_key = server_priv
|
||||||
|
config.server_public_key = server_pub
|
||||||
|
session.add(config)
|
||||||
|
print(f" Server keypair generated: {server_pub[:20]}...")
|
||||||
|
else:
|
||||||
|
server_pub = config.server_public_key
|
||||||
|
print(f" Server keypair already exists: {server_pub[:20]}...")
|
||||||
|
|
||||||
|
# --- Admin user ---
|
||||||
|
admin = (await session.execute(
|
||||||
|
select(User).where(User.email == ADMIN_EMAIL)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if admin is None:
|
||||||
|
admin = User(
|
||||||
|
email=ADMIN_EMAIL,
|
||||||
|
password_hash=hash_password(ADMIN_PASSWORD),
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
session.add(admin)
|
||||||
|
await session.flush()
|
||||||
|
print(f" Admin user created: {ADMIN_EMAIL}")
|
||||||
|
else:
|
||||||
|
print(f" Admin user already exists: {ADMIN_EMAIL}")
|
||||||
|
|
||||||
|
# --- Client devices (delete + recreate for clean state) ---
|
||||||
|
client_names = [c["name"] for c in CLIENTS]
|
||||||
|
client_ips = [c["ip"] for c in CLIENTS]
|
||||||
|
stale = (await session.execute(
|
||||||
|
select(Device).where(
|
||||||
|
Device.name.in_(client_names) | Device.ipv4.in_(client_ips)
|
||||||
|
)
|
||||||
|
)).scalars().all()
|
||||||
|
for d in stale:
|
||||||
|
await session.delete(d)
|
||||||
|
if stale:
|
||||||
|
await session.flush()
|
||||||
|
print(f" Cleaned up {len(stale)} stale device(s)")
|
||||||
|
|
||||||
|
for client in CLIENTS:
|
||||||
|
client_priv, client_pub = generate_keypair()
|
||||||
|
psk = generate_preshared_key()
|
||||||
|
|
||||||
|
device = Device(
|
||||||
|
name=client["name"],
|
||||||
|
public_key=client_pub,
|
||||||
|
preshared_key=psk,
|
||||||
|
ipv4=client["ip"],
|
||||||
|
user_id=admin.id,
|
||||||
|
)
|
||||||
|
session.add(device)
|
||||||
|
|
||||||
|
client["privkey"] = client_priv
|
||||||
|
client["pubkey"] = client_pub
|
||||||
|
client["psk"] = psk
|
||||||
|
print(f" Device '{client['name']}' created ({client['ip']})")
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# --- Write client WG configs ---
|
||||||
|
for i, client in enumerate(CLIENTS):
|
||||||
|
conf = f"""[Interface]
|
||||||
|
PrivateKey = {client["privkey"]}
|
||||||
|
|
||||||
|
[Peer]
|
||||||
|
PublicKey = {server_pub}
|
||||||
|
PresharedKey = {client["psk"]}
|
||||||
|
Endpoint = {SERVER_ENDPOINT}
|
||||||
|
AllowedIPs = {SUBNET}.0/24
|
||||||
|
PersistentKeepalive = 5
|
||||||
|
"""
|
||||||
|
conf_path = CONFIG_DIR / f"client{i + 1}.conf"
|
||||||
|
conf_path.write_text(conf)
|
||||||
|
print(f" Config written: {conf_path}")
|
||||||
|
|
||||||
|
# --- Write env vars for compose ---
|
||||||
|
env_lines = []
|
||||||
|
for i, client in enumerate(CLIENTS):
|
||||||
|
other_ips = " ".join(c["ip"] for c in CLIENTS if c["ip"] != client["ip"])
|
||||||
|
env_lines.append(f"CLIENT{i + 1}_IP={client['ip']}")
|
||||||
|
env_lines.append(f"CLIENT{i + 1}_PEERS={other_ips}")
|
||||||
|
env_path = CONFIG_DIR / "clients.env"
|
||||||
|
env_path.write_text("\n".join(env_lines) + "\n")
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("[*] Seeding test stack...")
|
||||||
|
asyncio.run(seed())
|
||||||
|
print("\n[*] Done. Start the stack with:")
|
||||||
|
print(" make test-stack-up")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
docker/mock-saml/saml20-sp-remote.php
Normal file
15
docker/mock-saml/saml20-sp-remote.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SAML 2.0 remote SP metadata for WireGUI testing.
|
||||||
|
* Registers SPs for dev (port 13000) and e2e test (port 13003).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Dev instance
|
||||||
|
$metadata['http://localhost:13000/auth/saml/test-saml/metadata'] = [
|
||||||
|
'AssertionConsumerService' => 'http://localhost:13000/auth/saml/test-saml/callback',
|
||||||
|
];
|
||||||
|
|
||||||
|
// E2E test instance
|
||||||
|
$metadata['http://localhost:13003/auth/saml/test-saml/metadata'] = [
|
||||||
|
'AssertionConsumerService' => 'http://localhost:13003/auth/saml/test-saml/callback',
|
||||||
|
];
|
||||||
1
img/wiregui.svg
Normal file
1
img/wiregui.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -3,6 +3,7 @@ name = "wiregui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "WireGuard VPN management platform — Python/NiceGUI rewrite of Wirezone"
|
description = "WireGuard VPN management platform — Python/NiceGUI rewrite of Wirezone"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
license = "AGPL-3.0-or-later"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# UI
|
# UI
|
||||||
|
|
@ -30,15 +31,19 @@ dependencies = [
|
||||||
"aiosmtplib>=3.0",
|
"aiosmtplib>=3.0",
|
||||||
# QR codes
|
# QR codes
|
||||||
"qrcode[pil]>=8.0",
|
"qrcode[pil]>=8.0",
|
||||||
|
# YAML config
|
||||||
|
"pyyaml>=6.0",
|
||||||
# Logging
|
# Logging
|
||||||
"loguru>=0.7.3",
|
"loguru>=0.7.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"playwright>=1.58.0",
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
"python-semantic-release>=9.0",
|
||||||
"respx>=0.22.0",
|
"respx>=0.22.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -47,4 +52,41 @@ asyncio_mode = "auto"
|
||||||
asyncio_default_fixture_loop_scope = "session"
|
asyncio_default_fixture_loop_scope = "session"
|
||||||
asyncio_default_test_loop_scope = "session"
|
asyncio_default_test_loop_scope = "session"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
# E2E tests run separately: uv run pytest tests/e2e/
|
||||||
|
# NiceGUI's testing plugin conflicts with unit tests when loaded together
|
||||||
|
addopts = "--ignore=tests/e2e"
|
||||||
main_file = "wiregui/main.py"
|
main_file = "wiregui/main.py"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Semantic Release
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[tool.semantic_release]
|
||||||
|
version_toml = ["pyproject.toml:project.version"]
|
||||||
|
tag_format = "v{version}"
|
||||||
|
commit_message = "chore(release): {version}"
|
||||||
|
build_command = ""
|
||||||
|
major_on_zero = false
|
||||||
|
|
||||||
|
[tool.semantic_release.branches.main]
|
||||||
|
match = "(main|master)"
|
||||||
|
prerelease = false
|
||||||
|
|
||||||
|
[tool.semantic_release.branches.dev]
|
||||||
|
match = "dev"
|
||||||
|
prerelease = true
|
||||||
|
prerelease_token = "rc"
|
||||||
|
|
||||||
|
[tool.semantic_release.changelog]
|
||||||
|
exclude_commit_patterns = [
|
||||||
|
"^chore\\(release\\):",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.semantic_release.changelog.default_templates]
|
||||||
|
changelog_file = "CHANGELOG.md"
|
||||||
|
|
||||||
|
[tool.semantic_release.remote]
|
||||||
|
type = "gitea"
|
||||||
|
token = { env = "GITHUB_TOKEN" }
|
||||||
|
|
||||||
|
[tool.semantic_release.publish]
|
||||||
|
upload_to_vcs_release = false
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,137 @@
|
||||||
"""E2E test configuration — loads NiceGUI testing plugin and app."""
|
"""E2E test configuration — async Playwright browser tests against a running app."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Browser, Page, async_playwright
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from wiregui.auth.passwords import hash_password
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.config import get_settings
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
from wiregui.models.configuration import Configuration
|
from wiregui.models.configuration import Configuration
|
||||||
from wiregui.models.user import User
|
from wiregui.models.user import User
|
||||||
|
|
||||||
pytest_plugins = ["nicegui.testing.user_plugin"]
|
|
||||||
|
|
||||||
FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w="
|
FAKE_SERVER_KEY = "SFake0ServerPubKey0000000000000000000000000w="
|
||||||
TEST_EMAIL = "e2e-test@example.com"
|
TEST_EMAIL = "e2e-test@example.com"
|
||||||
TEST_PASSWORD = "testpass123"
|
TEST_PASSWORD = "testpass123"
|
||||||
|
|
||||||
async def _delete_user_cascade(session, user_id):
|
# Dedicated port so we don't conflict with a dev instance on 13000
|
||||||
"""Delete a user and all related objects via raw SQL to avoid stale ORM cache issues."""
|
TEST_APP_PORT = 13001
|
||||||
from sqlalchemy import text
|
TEST_APP_BASE = f"http://localhost:{TEST_APP_PORT}"
|
||||||
for table in ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections"):
|
|
||||||
await session.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": user_id}) # noqa: S608
|
_CHILD_TABLES = ("devices", "rules", "mfa_methods", "api_tokens", "oidc_connections")
|
||||||
await session.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": user_id})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def pytest_addoption(parser):
|
||||||
async def test_user():
|
parser.addoption("--headed", action="store_true", default=False, help="Run browser in headed mode")
|
||||||
"""Create a test user and ensure server config has a public key."""
|
parser.addoption("--slowmo", type=int, default=0, help="Slow down Playwright actions by ms")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_user_by_email(email: str):
|
||||||
|
"""Delete a user and all related objects by email."""
|
||||||
|
engine = create_async_engine(get_settings().database_url)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
row = (await conn.execute(
|
||||||
|
text("SELECT id FROM users WHERE email = :email"), {"email": email}
|
||||||
|
)).first()
|
||||||
|
if row:
|
||||||
|
uid = row[0]
|
||||||
|
for table in _CHILD_TABLES:
|
||||||
|
await conn.execute(text(f"DELETE FROM {table} WHERE user_id = :uid"), {"uid": uid}) # noqa: S608
|
||||||
|
await conn.execute(text("DELETE FROM users WHERE id = :uid"), {"uid": uid})
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_test_user():
|
||||||
|
await _cleanup_user_by_email(TEST_EMAIL)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App subprocess — shared across all e2e tests in the session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_server():
|
||||||
|
"""Start WireGUI on TEST_APP_PORT for the entire test session."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WG_LOG_TO_FILE"] = "false"
|
||||||
|
env["WG_PORT"] = str(TEST_APP_PORT)
|
||||||
|
env["WG_EXTERNAL_URL"] = TEST_APP_BASE
|
||||||
|
env.pop("PYTEST_CURRENT_TEST", None)
|
||||||
|
env.pop("NICEGUI_SCREEN_TEST_PORT", None)
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["uv", "run", "python", "-m", "wiregui.main"],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{TEST_APP_BASE}/api/health", timeout=1)
|
||||||
|
if r.status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
proc.kill()
|
||||||
|
out = proc.stdout.read().decode() if proc.stdout else ""
|
||||||
|
pytest.fail(f"App did not start in time. Output:\n{out}")
|
||||||
|
|
||||||
|
yield proc
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Playwright browser — session-scoped, one browser for all tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def browser(request):
|
||||||
|
"""Launch a Playwright Chromium browser for the session."""
|
||||||
|
headed = request.config.getoption("--headed")
|
||||||
|
slowmo = request.config.getoption("--slowmo")
|
||||||
|
pw = await async_playwright().start()
|
||||||
|
br = await pw.chromium.launch(headless=not headed, slow_mo=slowmo)
|
||||||
|
yield br
|
||||||
|
await br.close()
|
||||||
|
await pw.stop()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def page(browser: Browser):
|
||||||
|
"""Create a fresh browser context + page per test (isolated cookies/storage)."""
|
||||||
|
context = await browser.new_context()
|
||||||
|
pg = await context.new_page()
|
||||||
|
yield pg
|
||||||
|
await context.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test user fixture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def test_user(app_server):
|
||||||
|
"""Create a test admin user, yield it, clean up after."""
|
||||||
|
await _cleanup_test_user()
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
# Clean up any leftover from a previous failed run
|
|
||||||
existing = (await session.execute(select(User).where(User.email == TEST_EMAIL))).scalar_one_or_none()
|
|
||||||
if existing:
|
|
||||||
await _delete_user_cascade(session, existing.id)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Ensure a Configuration with a server key exists
|
|
||||||
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
if config:
|
if config:
|
||||||
if not config.server_public_key:
|
if not config.server_public_key:
|
||||||
|
|
@ -53,7 +152,18 @@ async def test_user():
|
||||||
|
|
||||||
yield user
|
yield user
|
||||||
|
|
||||||
# Teardown
|
await _cleanup_test_user()
|
||||||
async with async_session() as session:
|
|
||||||
await _delete_user_cascade(session, user.id)
|
|
||||||
await session.commit()
|
# ---------------------------------------------------------------------------
|
||||||
|
# Playwright helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def login(page: Page, email: str = TEST_EMAIL, password: str = TEST_PASSWORD):
|
||||||
|
"""Fill the login form and submit."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/login")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.locator("input[aria-label='Email']").fill(email)
|
||||||
|
await page.locator("input[aria-label='Password']").fill(password)
|
||||||
|
await page.get_by_role("button", name="Sign in", exact=True).click()
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,85 @@
|
||||||
"""End-to-end tests for account page — password, TOTP, API tokens, deletion."""
|
"""End-to-end tests for account page — password, API tokens, TOTP, deletion."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
import pytest
|
|
||||||
from nicegui import ui
|
|
||||||
from nicegui.testing import User
|
|
||||||
|
|
||||||
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.db import async_session
|
||||||
from wiregui.models.user import User as UserModel
|
from wiregui.models.user import User as UserModel
|
||||||
from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
|
from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, TEST_PASSWORD, login
|
||||||
|
|
||||||
|
|
||||||
async def _login(user: User):
|
async def _login_to_account(page: Page):
|
||||||
"""Log in and navigate to account page."""
|
"""Log in and navigate to account page."""
|
||||||
await user.open("/login")
|
await login(page)
|
||||||
user.find("Email").type(TEST_EMAIL)
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
user.find("Password").type(TEST_PASSWORD)
|
await page.goto(f"{TEST_APP_BASE}/account")
|
||||||
user.find("Sign in").click()
|
await expect(page.get_by_text("Account Settings")).to_be_visible(timeout=10_000)
|
||||||
await user.should_see("My Devices")
|
|
||||||
await user.open("/account")
|
|
||||||
await user.should_see("Account Settings")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_change_password(page: Page, test_user: UserModel):
|
||||||
async def test_change_password(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test changing password: fill form, submit, verify success."""
|
await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
|
||||||
await _login(user)
|
await page.locator("input[aria-label='New Password']").fill("newpass12345")
|
||||||
|
await page.locator("input[aria-label='Confirm Password']").fill("newpass12345")
|
||||||
user.find("Current Password").type(TEST_PASSWORD)
|
await page.get_by_role("button", name="Update Password").click()
|
||||||
user.find("New Password").type("newpass12345")
|
await expect(page.get_by_text("Password changed")).to_be_visible(timeout=5_000)
|
||||||
user.find("Confirm Password").type("newpass12345")
|
|
||||||
user.find("Update Password").click()
|
|
||||||
await user.should_see("Password changed")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_change_password_wrong_current(page: Page, test_user: UserModel):
|
||||||
async def test_change_password_wrong_current(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test that wrong current password is rejected."""
|
await page.locator("input[aria-label='Current Password']").fill("wrongpassword")
|
||||||
await _login(user)
|
await page.locator("input[aria-label='New Password']").fill("newpass12345")
|
||||||
|
await page.locator("input[aria-label='Confirm Password']").fill("newpass12345")
|
||||||
user.find("Current Password").type("wrongpassword")
|
await page.get_by_role("button", name="Update Password").click()
|
||||||
user.find("New Password").type("newpass12345")
|
await expect(page.get_by_text("Wrong current password")).to_be_visible(timeout=5_000)
|
||||||
user.find("Confirm Password").type("newpass12345")
|
|
||||||
user.find("Update Password").click()
|
|
||||||
await user.should_see("Wrong current password")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_change_password_mismatch(page: Page, test_user: UserModel):
|
||||||
async def test_change_password_mismatch(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test that mismatched passwords are rejected."""
|
await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
|
||||||
await _login(user)
|
await page.locator("input[aria-label='New Password']").fill("newpass12345")
|
||||||
|
await page.locator("input[aria-label='Confirm Password']").fill("differentpass")
|
||||||
user.find("Current Password").type(TEST_PASSWORD)
|
await page.get_by_role("button", name="Update Password").click()
|
||||||
user.find("New Password").type("newpass12345")
|
await expect(page.get_by_text("Passwords don't match")).to_be_visible(timeout=5_000)
|
||||||
user.find("Confirm Password").type("differentpass")
|
|
||||||
user.find("Update Password").click()
|
|
||||||
await user.should_see("Passwords don't match")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_change_password_too_short(page: Page, test_user: UserModel):
|
||||||
async def test_change_password_too_short(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test that short passwords are rejected."""
|
await page.locator("input[aria-label='Current Password']").fill(TEST_PASSWORD)
|
||||||
await _login(user)
|
await page.locator("input[aria-label='New Password']").fill("short")
|
||||||
|
await page.locator("input[aria-label='Confirm Password']").fill("short")
|
||||||
user.find("Current Password").type(TEST_PASSWORD)
|
await page.get_by_role("button", name="Update Password").click()
|
||||||
user.find("New Password").type("short")
|
await expect(page.get_by_text("Min 8 characters")).to_be_visible(timeout=5_000)
|
||||||
user.find("Confirm Password").type("short")
|
|
||||||
user.find("Update Password").click()
|
|
||||||
await user.should_see("Min 8 characters")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_create_api_token(page: Page, test_user: UserModel):
|
||||||
async def test_create_api_token(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test creating an API token and seeing the copy banner."""
|
await expect(page.get_by_text("No API tokens.")).to_be_visible()
|
||||||
await _login(user)
|
await page.get_by_role("button", name="Add API Token").click()
|
||||||
|
await expect(page.get_by_text("Copy now")).to_be_visible(timeout=5_000)
|
||||||
await user.should_see("No API tokens.")
|
|
||||||
user.find("Add API Token").click()
|
|
||||||
await user.should_see("Copy now")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_totp_registration_flow(page: Page, test_user: UserModel):
|
||||||
async def test_totp_registration_flow(user: User, test_user: UserModel):
|
|
||||||
"""Test starting TOTP registration shows QR and verify form."""
|
"""Test starting TOTP registration shows QR and verify form."""
|
||||||
with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \
|
await _login_to_account(page)
|
||||||
patch("wiregui.pages.account.generate_totp_qr_svg", return_value='<svg></svg>'), \
|
await expect(page.get_by_text("No MFA methods configured.")).to_be_visible()
|
||||||
patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"):
|
await page.get_by_role("button", name="Add TOTP Method").click()
|
||||||
|
await expect(page.get_by_text("Register TOTP Authenticator")).to_be_visible(timeout=5_000)
|
||||||
await _login(user)
|
await expect(page.get_by_role("button", name="Verify & Save")).to_be_visible()
|
||||||
|
|
||||||
await user.should_see("No MFA methods configured.")
|
|
||||||
user.find("Add TOTP Method").click()
|
|
||||||
await user.should_see("Register TOTP Authenticator")
|
|
||||||
await user.should_see("JBSWY3DPEHPK3PXP")
|
|
||||||
await user.should_see("Verify & Save")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_totp_verify_invalid_code(page: Page, test_user: UserModel):
|
||||||
async def test_totp_verify_invalid_code(user: User, test_user: UserModel):
|
await _login_to_account(page)
|
||||||
"""Test that an invalid TOTP code is rejected."""
|
await page.get_by_role("button", name="Add TOTP Method").click()
|
||||||
with patch("wiregui.pages.account.generate_totp_secret", return_value="JBSWY3DPEHPK3PXP"), \
|
await expect(page.get_by_text("Register TOTP Authenticator")).to_be_visible(timeout=5_000)
|
||||||
patch("wiregui.pages.account.generate_totp_qr_svg", return_value='<svg></svg>'), \
|
await page.locator("input[aria-label='6-digit verification code']").fill("000000")
|
||||||
patch("wiregui.pages.account.get_totp_uri", return_value="otpauth://totp/WireGUI:test?secret=JBSWY3DPEHPK3PXP"):
|
await page.get_by_role("button", name="Verify & Save").click()
|
||||||
|
await expect(page.get_by_text("Invalid code")).to_be_visible(timeout=5_000)
|
||||||
await _login(user)
|
|
||||||
|
|
||||||
user.find("Add TOTP Method").click()
|
|
||||||
await user.should_see("Register TOTP Authenticator")
|
|
||||||
|
|
||||||
user.find("6-digit verification code").type("000000")
|
|
||||||
user.find("Verify & Save").click()
|
|
||||||
await user.should_see("Invalid code")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_delete_account(page: Page, test_user: UserModel):
|
||||||
async def test_delete_account(user: User, test_user: UserModel):
|
|
||||||
"""Test account deletion flow with email confirmation."""
|
"""Test account deletion flow with email confirmation."""
|
||||||
# Create a second admin first so deletion is allowed
|
|
||||||
from wiregui.db import async_session
|
|
||||||
from wiregui.auth.passwords import hash_password
|
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
second_admin = UserModel(
|
second_admin = UserModel(
|
||||||
email="admin2@example.com",
|
email="admin2@example.com",
|
||||||
|
|
@ -129,21 +90,17 @@ async def test_delete_account(user: User, test_user: UserModel):
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await _login(user)
|
await _login_to_account(page)
|
||||||
|
await page.get_by_role("button", name="Delete Your Account").click()
|
||||||
user.find("Delete Your Account").click()
|
await expect(page.get_by_text("Delete Your Account?")).to_be_visible(timeout=5_000)
|
||||||
await user.should_see("Delete Your Account?")
|
await page.locator(".q-dialog input").fill(TEST_EMAIL)
|
||||||
|
await page.get_by_role("button", name="Delete My Account").click()
|
||||||
user.find(ui.input).type(TEST_EMAIL)
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
|
||||||
user.find("Delete My Account").click()
|
|
||||||
|
|
||||||
# Should redirect to login
|
|
||||||
await user.should_see("Sign in")
|
|
||||||
finally:
|
finally:
|
||||||
# Clean up second admin
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
from sqlmodel import select
|
a2 = (await session.execute(
|
||||||
a2 = (await session.execute(select(UserModel).where(UserModel.email == "admin2@example.com"))).scalar_one_or_none()
|
select(UserModel).where(UserModel.email == "admin2@example.com")
|
||||||
|
)).scalar_one_or_none()
|
||||||
if a2:
|
if a2:
|
||||||
await session.delete(a2)
|
await session.delete(a2)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
|
||||||
239
tests/e2e/test_admin_devices.py
Normal file
239
tests/e2e/test_admin_devices.py
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"""E2E tests for admin device management page."""
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.device import Device
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from wiregui.utils.crypto import generate_keypair, generate_preshared_key
|
||||||
|
from tests.e2e.conftest import (
|
||||||
|
TEST_APP_BASE,
|
||||||
|
TEST_EMAIL,
|
||||||
|
TEST_PASSWORD,
|
||||||
|
_cleanup_user_by_email,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
|
||||||
|
SECOND_USER_EMAIL = "e2e-device-user2@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def second_user(test_user):
|
||||||
|
"""Create a second user with a device for filtering tests."""
|
||||||
|
await _cleanup_user_by_email(SECOND_USER_EMAIL)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
user = User(
|
||||||
|
email=SECOND_USER_EMAIL,
|
||||||
|
password_hash=hash_password("pass12345"),
|
||||||
|
role="unprivileged",
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
yield user
|
||||||
|
|
||||||
|
await _cleanup_user_by_email(SECOND_USER_EMAIL)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def devices_for_both_users(test_user, second_user):
|
||||||
|
"""Create one device per user for table/filter tests."""
|
||||||
|
_, pub1 = generate_keypair()
|
||||||
|
_, pub2 = generate_keypair()
|
||||||
|
psk1 = generate_preshared_key()
|
||||||
|
psk2 = generate_preshared_key()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
d1 = Device(
|
||||||
|
name="admin-laptop",
|
||||||
|
public_key=pub1,
|
||||||
|
preshared_key=psk1,
|
||||||
|
ipv4="10.0.0.10",
|
||||||
|
user_id=test_user.id,
|
||||||
|
)
|
||||||
|
d2 = Device(
|
||||||
|
name="user2-phone",
|
||||||
|
public_key=pub2,
|
||||||
|
preshared_key=psk2,
|
||||||
|
ipv4="10.0.0.11",
|
||||||
|
user_id=second_user.id,
|
||||||
|
)
|
||||||
|
session.add_all([d1, d2])
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield d1, d2
|
||||||
|
|
||||||
|
# Cleanup handled by user fixture cascade
|
||||||
|
|
||||||
|
|
||||||
|
async def _go_to_admin_devices(page: Page):
|
||||||
|
"""Login as admin and navigate to admin devices page."""
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/admin/devices")
|
||||||
|
await expect(page.locator("role=main").get_by_text("All Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_all_devices(page: Page, devices_for_both_users):
|
||||||
|
"""Admin devices page lists devices from all users."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await expect(page.get_by_text("admin-laptop")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("user2-phone")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_filter_by_user(page: Page, second_user, devices_for_both_users):
|
||||||
|
"""Filtering by user shows only that user's devices."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await expect(page.get_by_text("admin-laptop")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("user2-phone")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Filter to second user
|
||||||
|
await page.locator("label:has-text('Filter by User')").click()
|
||||||
|
await page.get_by_role("option", name=SECOND_USER_EMAIL).click()
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.get_by_text("user2-phone")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("admin-laptop")).not_to_be_visible()
|
||||||
|
|
||||||
|
# Filter back to all
|
||||||
|
await page.locator("label:has-text('Filter by User')").click()
|
||||||
|
await page.get_by_role("option", name="All Users").click()
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.get_by_text("admin-laptop")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("user2-phone")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_device_with_defaults(page: Page, test_user):
|
||||||
|
"""Create device with all defaults — config dialog appears."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await page.get_by_role("button", name="Add Device").click()
|
||||||
|
await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Device Name']").fill("default-test-device")
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
|
||||||
|
# Config dialog should appear with WireGuard config
|
||||||
|
await expect(page.get_by_text("Config for default-test-device")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.get_by_text("[Interface]")).to_be_visible(timeout=5_000)
|
||||||
|
await page.get_by_role("button", name="Close").click()
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
# Device should be in the table
|
||||||
|
await expect(page.get_by_role("cell", name="default-test-device").first).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_device_with_overrides(page: Page, test_user):
|
||||||
|
"""Create device with custom config overrides."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await page.get_by_role("button", name="Add Device").click()
|
||||||
|
await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Device Name']").fill("custom-override-dev")
|
||||||
|
await page.locator("input[aria-label='Description (optional)']").fill("Custom overrides test")
|
||||||
|
|
||||||
|
# Toggle off DNS default and set custom — Quasar switches use .q-toggle
|
||||||
|
await page.locator(".q-toggle", has_text="Use default DNS").click()
|
||||||
|
dns_input = page.locator("input[aria-label='DNS Servers']")
|
||||||
|
await dns_input.clear()
|
||||||
|
await dns_input.fill("8.8.8.8, 8.8.4.4")
|
||||||
|
|
||||||
|
# Toggle off MTU default and set custom
|
||||||
|
await page.locator(".q-toggle", has_text="Use default MTU").click()
|
||||||
|
mtu_input = page.locator("input[aria-label='MTU']")
|
||||||
|
await mtu_input.clear()
|
||||||
|
await mtu_input.fill("1400")
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("Config for custom-override-dev")).to_be_visible(timeout=10_000)
|
||||||
|
await page.get_by_role("button", name="Close").click()
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="custom-override-dev").first).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Device).where(Device.name == "custom-override-dev")
|
||||||
|
.order_by(Device.inserted_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
device = result.scalar_one()
|
||||||
|
assert device.use_default_dns is False
|
||||||
|
assert "8.8.8.8" in device.dns
|
||||||
|
assert device.use_default_mtu is False
|
||||||
|
assert device.mtu == 1400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_device_name_and_description(page: Page, devices_for_both_users):
|
||||||
|
"""Edit a device name and description via the edit dialog."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await expect(page.get_by_text("admin-laptop")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Click edit button on admin-laptop row — Quasar slot buttons with icon
|
||||||
|
row = page.locator("tr", has_text="admin-laptop")
|
||||||
|
await row.locator(".q-btn").first.click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("Edit Device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
name_input = page.locator(".q-dialog input[aria-label='Device Name']")
|
||||||
|
await name_input.clear()
|
||||||
|
await name_input.fill("admin-laptop-renamed")
|
||||||
|
|
||||||
|
desc_input = page.locator(".q-dialog input[aria-label='Description']")
|
||||||
|
await desc_input.clear()
|
||||||
|
await desc_input.fill("Updated description")
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save").click()
|
||||||
|
await expect(page.get_by_text("Device updated")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("admin-laptop-renamed")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_device(page: Page, test_user):
|
||||||
|
"""Delete a device — removed from table."""
|
||||||
|
_, pub = generate_keypair()
|
||||||
|
async with async_session() as session:
|
||||||
|
d = Device(
|
||||||
|
name="delete-me-device",
|
||||||
|
public_key=pub,
|
||||||
|
preshared_key=generate_preshared_key(),
|
||||||
|
ipv4="10.0.0.99",
|
||||||
|
user_id=test_user.id,
|
||||||
|
)
|
||||||
|
session.add(d)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Click the delete (second) button in the row
|
||||||
|
row = page.locator("tr", has_text="delete-me-device")
|
||||||
|
await row.locator(".q-btn").nth(1).click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("Deleted delete-me-device")).to_be_visible(timeout=5_000)
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-device")).not_to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_config_dialog_shows_wg_config(page: Page, test_user):
|
||||||
|
"""Config dialog after device creation shows valid WireGuard config."""
|
||||||
|
await _go_to_admin_devices(page)
|
||||||
|
await page.get_by_role("button", name="Add Device").click()
|
||||||
|
await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Device Name']").fill("config-test-device")
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("Config for config-test-device")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.get_by_text("[Interface]")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("[Peer]")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("PrivateKey")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("button", name="Download .conf")).to_be_visible()
|
||||||
|
|
||||||
|
# QR code should be rendered
|
||||||
|
await expect(page.locator(".q-dialog img")).to_be_visible(timeout=5_000)
|
||||||
227
tests/e2e/test_admin_rules.py
Normal file
227
tests/e2e/test_admin_rules.py
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
"""E2E tests for admin firewall rules management page."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.rule import Rule
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, login
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_test_rules():
|
||||||
|
"""Remove rules created by tests (identified by test-specific destinations)."""
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Rule).where(Rule.destination.in_([
|
||||||
|
"10.99.0.0/16", "10.88.0.0/16", "10.77.0.0/16",
|
||||||
|
"10.66.0.0/16", "10.55.0.0/16",
|
||||||
|
]))
|
||||||
|
)
|
||||||
|
for rule in result.scalars().all():
|
||||||
|
await session.delete(rule)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def clean_rules(app_server):
|
||||||
|
"""Clean up test rules before and after each test."""
|
||||||
|
await _cleanup_test_rules()
|
||||||
|
yield
|
||||||
|
await _cleanup_test_rules()
|
||||||
|
|
||||||
|
|
||||||
|
async def _go_to_rules(page: Page):
|
||||||
|
"""Login and navigate to admin rules page."""
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/admin/rules")
|
||||||
|
await expect(page.locator("role=main").get_by_text("Firewall Rules")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_rule_via_dialog(
|
||||||
|
page: Page, *, action: str = "accept", destination: str = "10.99.0.0/16",
|
||||||
|
protocol: str = "any", port_range: str = "", user: str = "global",
|
||||||
|
):
|
||||||
|
"""Open create dialog and fill in a rule."""
|
||||||
|
await page.get_by_role("button", name="Add Rule").click()
|
||||||
|
await expect(page.get_by_text("New Firewall Rule")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Action select
|
||||||
|
if action != "accept":
|
||||||
|
await page.locator(".q-dialog label:has-text('Action')").click()
|
||||||
|
await page.get_by_role("option", name=action).click()
|
||||||
|
|
||||||
|
# Destination
|
||||||
|
await page.locator(".q-dialog input[aria-label='Destination (CIDR)']").fill(destination)
|
||||||
|
|
||||||
|
# Protocol
|
||||||
|
if protocol != "any":
|
||||||
|
await page.locator(".q-dialog label:has-text('Protocol')").click()
|
||||||
|
await page.get_by_role("option", name=protocol).click()
|
||||||
|
|
||||||
|
# Port range
|
||||||
|
if port_range:
|
||||||
|
await page.locator(".q-dialog input[aria-label='Port Range']").fill(port_range)
|
||||||
|
|
||||||
|
# User
|
||||||
|
if user != "global":
|
||||||
|
await page.locator(".q-dialog label:has-text('Applies to')").click()
|
||||||
|
await page.get_by_role("option", name=user).click()
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_rules_table(page: Page, test_user: User):
|
||||||
|
"""Rules page renders table with correct columns."""
|
||||||
|
# Seed a rule in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = Rule(action="accept", destination="10.99.0.0/16", port_type="tcp",
|
||||||
|
port_range="443", user_id=test_user.id)
|
||||||
|
session.add(rule)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _go_to_rules(page)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="accept")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_role("cell", name="10.99.0.0/16")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("cell", name="tcp")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("cell", name="443")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("cell", name=TEST_EMAIL)).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_accept_rule_with_cidr(page: Page, test_user: User):
|
||||||
|
"""Create an accept rule with CIDR — verify in table and DB."""
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await _create_rule_via_dialog(page, action="accept", destination="10.88.0.0/16")
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="10.88.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(Rule).where(Rule.destination == "10.88.0.0/16"))
|
||||||
|
rule = result.scalar_one()
|
||||||
|
assert rule.action == "accept"
|
||||||
|
assert rule.port_type is None
|
||||||
|
assert rule.port_range is None
|
||||||
|
assert rule.user_id is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_drop_rule_with_tcp_port_range(page: Page, test_user: User):
|
||||||
|
"""Create a drop rule with TCP port range — verify in table and DB."""
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await _create_rule_via_dialog(
|
||||||
|
page, action="drop", destination="10.77.0.0/16",
|
||||||
|
protocol="tcp", port_range="80-443",
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="10.77.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_role("cell", name="drop").first).to_be_visible()
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(Rule).where(Rule.destination == "10.77.0.0/16"))
|
||||||
|
rule = result.scalar_one()
|
||||||
|
assert rule.action == "drop"
|
||||||
|
assert rule.port_type == "tcp"
|
||||||
|
assert rule.port_range == "80-443"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_global_rule(page: Page, test_user: User):
|
||||||
|
"""Create a global rule (no user) — shows 'Global' in table and DB has null user_id."""
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await _create_rule_via_dialog(page, destination="10.66.0.0/16", user="global")
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="10.66.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_role("cell", name="Global")).to_be_visible()
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(Rule).where(Rule.destination == "10.66.0.0/16"))
|
||||||
|
rule = result.scalar_one()
|
||||||
|
assert rule.user_id is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_rule_action(page: Page, test_user: User):
|
||||||
|
"""Edit rule action from accept to drop — verify in table and DB."""
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = Rule(action="accept", destination="10.55.0.0/16")
|
||||||
|
session.add(rule)
|
||||||
|
await session.commit()
|
||||||
|
rule_id = rule.id
|
||||||
|
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await expect(page.get_by_role("cell", name="10.55.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Click edit (first button in the row)
|
||||||
|
row = page.locator("tr", has_text="10.55.0.0/16")
|
||||||
|
await row.locator(".q-btn").first.click()
|
||||||
|
await expect(page.get_by_text("Edit Firewall Rule")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Change action to drop
|
||||||
|
await page.locator(".q-dialog label:has-text('Action')").click()
|
||||||
|
await page.get_by_role("option", name="drop").click()
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save").click()
|
||||||
|
await expect(page.get_by_text("Rule updated")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = await session.get(Rule, rule_id)
|
||||||
|
assert rule.action == "drop"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_rule_destination(page: Page, test_user: User):
|
||||||
|
"""Edit rule destination — verify in table and DB."""
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = Rule(action="accept", destination="10.99.0.0/16")
|
||||||
|
session.add(rule)
|
||||||
|
await session.commit()
|
||||||
|
rule_id = rule.id
|
||||||
|
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await expect(page.get_by_role("cell", name="10.99.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
row = page.locator("tr", has_text="10.99.0.0/16")
|
||||||
|
await row.locator(".q-btn").first.click()
|
||||||
|
await expect(page.get_by_text("Edit Firewall Rule")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
dest_input = page.locator(".q-dialog input[aria-label='Destination (CIDR)']")
|
||||||
|
await dest_input.clear()
|
||||||
|
await dest_input.fill("10.88.0.0/16")
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save").click()
|
||||||
|
await expect(page.get_by_text("Rule updated")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = await session.get(Rule, rule_id)
|
||||||
|
assert rule.destination == "10.88.0.0/16"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_rule(page: Page, test_user: User):
|
||||||
|
"""Delete a rule — removed from table and DB."""
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = Rule(action="accept", destination="10.99.0.0/16")
|
||||||
|
session.add(rule)
|
||||||
|
await session.commit()
|
||||||
|
rule_id = rule.id
|
||||||
|
|
||||||
|
await _go_to_rules(page)
|
||||||
|
await expect(page.get_by_role("cell", name="10.99.0.0/16")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Click delete (second button in the row)
|
||||||
|
row = page.locator("tr", has_text="10.99.0.0/16")
|
||||||
|
await row.locator(".q-btn").nth(1).click()
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="10.99.0.0/16")).not_to_be_visible()
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
rule = await session.get(Rule, rule_id)
|
||||||
|
assert rule is None
|
||||||
281
tests/e2e/test_admin_settings.py
Normal file
281
tests/e2e/test_admin_settings.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
"""E2E tests for admin settings page — client defaults, security, OIDC/SAML providers."""
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from tests.e2e.conftest import TEST_APP_BASE, login
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def reset_config(app_server):
|
||||||
|
"""Snapshot config before test, restore after."""
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
if not c:
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
snap = {
|
||||||
|
"default_client_endpoint": c.default_client_endpoint,
|
||||||
|
"default_client_dns": list(c.default_client_dns),
|
||||||
|
"default_client_mtu": c.default_client_mtu,
|
||||||
|
"default_client_persistent_keepalive": c.default_client_persistent_keepalive,
|
||||||
|
"default_client_allowed_ips": list(c.default_client_allowed_ips),
|
||||||
|
"vpn_session_duration": c.vpn_session_duration,
|
||||||
|
"local_auth_enabled": c.local_auth_enabled,
|
||||||
|
"allow_unprivileged_device_management": c.allow_unprivileged_device_management,
|
||||||
|
"allow_unprivileged_device_configuration": c.allow_unprivileged_device_configuration,
|
||||||
|
"openid_connect_providers": list(c.openid_connect_providers or []),
|
||||||
|
"saml_identity_providers": list(c.saml_identity_providers or []),
|
||||||
|
}
|
||||||
|
cid = c.id
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
c = await session.get(Configuration, cid)
|
||||||
|
if c:
|
||||||
|
for k, v in snap.items():
|
||||||
|
setattr(c, k, v)
|
||||||
|
session.add(c)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _go_to_settings(page: Page):
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/admin/settings")
|
||||||
|
await expect(page.get_by_text("Default Client Configuration")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Client Defaults ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_client_defaults(page: Page, test_user: User):
|
||||||
|
"""Save endpoint, DNS, MTU, keepalive, allowed IPs — verify persists in DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
endpoint = page.locator("input[aria-label='Endpoint']")
|
||||||
|
await endpoint.clear()
|
||||||
|
await endpoint.fill("vpn.test.local")
|
||||||
|
|
||||||
|
dns = page.locator("input[aria-label='DNS Servers']")
|
||||||
|
await dns.clear()
|
||||||
|
await dns.fill("9.9.9.9, 149.112.112.112")
|
||||||
|
|
||||||
|
mtu = page.locator("input[aria-label='MTU']")
|
||||||
|
await mtu.clear()
|
||||||
|
await mtu.fill("1420")
|
||||||
|
|
||||||
|
keepalive = page.locator("input[aria-label='Persistent Keepalive']")
|
||||||
|
await keepalive.clear()
|
||||||
|
await keepalive.fill("30")
|
||||||
|
|
||||||
|
allowed = page.locator("input[aria-label='Allowed IPs']")
|
||||||
|
await allowed.clear()
|
||||||
|
await allowed.fill("10.0.0.0/8, 192.168.0.0/16")
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save Defaults").click()
|
||||||
|
await expect(page.get_by_text("Client defaults saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert c.default_client_endpoint == "vpn.test.local"
|
||||||
|
assert c.default_client_dns == ["9.9.9.9", "149.112.112.112"]
|
||||||
|
assert c.default_client_mtu == 1420
|
||||||
|
assert c.default_client_persistent_keepalive == 30
|
||||||
|
assert c.default_client_allowed_ips == ["10.0.0.0/8", "192.168.0.0/16"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_client_defaults_persist_on_reload(page: Page, test_user: User):
|
||||||
|
"""Saved defaults are reflected after page reload."""
|
||||||
|
# Set values via DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
c.default_client_endpoint = "reload-test.vpn"
|
||||||
|
c.default_client_dns = ["8.8.8.8"]
|
||||||
|
c.default_client_mtu = 1500
|
||||||
|
c.default_client_persistent_keepalive = 15
|
||||||
|
c.default_client_allowed_ips = ["172.16.0.0/12"]
|
||||||
|
session.add(c)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
await expect(page.locator("input[aria-label='Endpoint']")).to_have_value("reload-test.vpn")
|
||||||
|
await expect(page.locator("input[aria-label='DNS Servers']")).to_have_value("8.8.8.8")
|
||||||
|
await expect(page.locator("input[aria-label='MTU']")).to_have_value("1500")
|
||||||
|
await expect(page.locator("input[aria-label='Persistent Keepalive']")).to_have_value("15")
|
||||||
|
await expect(page.locator("input[aria-label='Allowed IPs']")).to_have_value("172.16.0.0/12")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Security ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_security_local_auth_toggle(page: Page, test_user: User):
|
||||||
|
"""Toggle local auth off — verify in DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
# Find the local auth switch and toggle it off
|
||||||
|
switch = page.locator(".q-toggle", has_text="Local Authentication")
|
||||||
|
await switch.click()
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save Security Settings").click()
|
||||||
|
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert c.local_auth_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_vpn_session_duration(page: Page, test_user: User):
|
||||||
|
"""Change VPN session duration — verify in DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
await page.locator("label:has-text('VPN Session Duration')").click()
|
||||||
|
await page.get_by_role("option", name="Every Day").click()
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save Security Settings").click()
|
||||||
|
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert c.vpn_session_duration == 86400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_unprivileged_toggles(page: Page, test_user: User):
|
||||||
|
"""Toggle unprivileged device management/configuration — verify in DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
await page.locator(".q-toggle", has_text="Allow Unprivileged Device Management").click()
|
||||||
|
await page.locator(".q-toggle", has_text="Allow Unprivileged Device Configuration").click()
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Save Security Settings").click()
|
||||||
|
await expect(page.get_by_text("Security settings saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
# Toggled from default (True) to False
|
||||||
|
assert c.allow_unprivileged_device_management is False
|
||||||
|
assert c.allow_unprivileged_device_configuration is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- OIDC Providers ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_oidc_provider(page: Page, test_user: User):
|
||||||
|
"""Add an OIDC provider — appears in table and DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Add OIDC Provider").click()
|
||||||
|
await expect(page.get_by_text("OIDC Provider", exact=True)).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator(".q-dialog input[aria-label='Config ID']").fill("e2e-test-oidc")
|
||||||
|
await page.locator(".q-dialog input[aria-label='Label']").fill("E2E Test IdP")
|
||||||
|
await page.locator(".q-dialog input[aria-label='Client ID']").fill("test-client-id")
|
||||||
|
await page.locator(".q-dialog input[aria-label='Client Secret']").fill("test-client-secret")
|
||||||
|
await page.locator(".q-dialog input[aria-label='Discovery Document URI']").fill("https://idp.test/.well-known/openid-configuration")
|
||||||
|
|
||||||
|
await page.locator(".q-dialog").get_by_role("button", name="Save").click()
|
||||||
|
await expect(page.get_by_text("OIDC provider 'E2E Test IdP' saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="e2e-test-oidc")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
provider = next((p for p in c.openid_connect_providers if p["id"] == "e2e-test-oidc"), None)
|
||||||
|
assert provider is not None
|
||||||
|
assert provider["label"] == "E2E Test IdP"
|
||||||
|
assert provider["client_id"] == "test-client-id"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_oidc_provider(page: Page, test_user: User):
|
||||||
|
"""Delete an OIDC provider — removed from table and DB."""
|
||||||
|
# Seed a provider
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
providers = list(c.openid_connect_providers or [])
|
||||||
|
providers.append({
|
||||||
|
"id": "delete-me-oidc", "label": "Delete Me", "scope": "openid",
|
||||||
|
"client_id": "x", "client_secret": "x",
|
||||||
|
"discovery_document_uri": "https://x/.well-known/openid-configuration",
|
||||||
|
})
|
||||||
|
c.openid_connect_providers = providers
|
||||||
|
session.add(c)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _go_to_settings(page)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-oidc")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
row = page.locator("tr", has_text="delete-me-oidc")
|
||||||
|
await row.locator(".q-btn").first.click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("OIDC provider deleted")).to_be_visible(timeout=5_000)
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-oidc")).not_to_be_visible()
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert not any(p["id"] == "delete-me-oidc" for p in c.openid_connect_providers)
|
||||||
|
|
||||||
|
|
||||||
|
# --- SAML Providers ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_add_saml_provider(page: Page, test_user: User):
|
||||||
|
"""Add a SAML provider — appears in table and DB."""
|
||||||
|
await _go_to_settings(page)
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Add SAML Provider").click()
|
||||||
|
await expect(page.get_by_text("SAML Identity Provider", exact=True)).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator(".q-dialog input[aria-label='Config ID']").fill("e2e-test-saml")
|
||||||
|
await page.locator(".q-dialog input[aria-label='Label']").fill("E2E SAML IdP")
|
||||||
|
await page.locator(".q-dialog textarea").fill("<EntityDescriptor>test</EntityDescriptor>")
|
||||||
|
|
||||||
|
await page.locator(".q-dialog").get_by_role("button", name="Save").click()
|
||||||
|
await expect(page.get_by_text("SAML provider 'E2E SAML IdP' saved")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await expect(page.get_by_role("cell", name="e2e-test-saml")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
provider = next((p for p in c.saml_identity_providers if p["id"] == "e2e-test-saml"), None)
|
||||||
|
assert provider is not None
|
||||||
|
assert provider["label"] == "E2E SAML IdP"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_saml_provider(page: Page, test_user: User):
|
||||||
|
"""Delete a SAML provider — removed from table and DB."""
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
providers = list(c.saml_identity_providers or [])
|
||||||
|
providers.append({
|
||||||
|
"id": "delete-me-saml", "label": "Delete Me SAML",
|
||||||
|
"metadata": "<EntityDescriptor/>",
|
||||||
|
})
|
||||||
|
c.saml_identity_providers = providers
|
||||||
|
session.add(c)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await _go_to_settings(page)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-saml")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
row = page.locator("tr", has_text="delete-me-saml")
|
||||||
|
await row.locator(".q-btn").first.click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("SAML provider deleted")).to_be_visible(timeout=5_000)
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
await expect(page.get_by_role("cell", name="delete-me-saml")).not_to_be_visible()
|
||||||
|
|
||||||
|
# Verify in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert not any(p["id"] == "delete-me-saml" for p in c.saml_identity_providers)
|
||||||
208
tests/e2e/test_admin_users.py
Normal file
208
tests/e2e/test_admin_users.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""End-to-end tests for admin user management page."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import func, select
|
||||||
|
|
||||||
|
from wiregui.auth.passwords import hash_password, verify_password
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.device import Device
|
||||||
|
from wiregui.models.rule import Rule
|
||||||
|
from wiregui.models.user import User as UserModel
|
||||||
|
from wiregui.utils.time import utcnow
|
||||||
|
from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, _cleanup_user_by_email, login
|
||||||
|
|
||||||
|
CREATED_USER_EMAIL = "e2e-created@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_and_go_to_users(page: Page):
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/admin/users")
|
||||||
|
await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def cleanup_created_users():
|
||||||
|
yield
|
||||||
|
await _cleanup_user_by_email(CREATED_USER_EMAIL)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Page renders ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_users_page_renders(page: Page, test_user: UserModel):
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("button", name="Add User")).to_be_visible()
|
||||||
|
await expect(page.locator("table")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Create user ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_user(page: Page, test_user: UserModel):
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Add User").click()
|
||||||
|
await expect(page.get_by_text("New User")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Email']").last.fill(CREATED_USER_EMAIL)
|
||||||
|
await page.locator("input[aria-label='Password']").last.fill("newuser123")
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
|
||||||
|
await page.wait_for_timeout(1000)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(UserModel).where(UserModel.email == CREATED_USER_EMAIL))
|
||||||
|
created = result.scalar_one_or_none()
|
||||||
|
assert created is not None
|
||||||
|
assert created.role == "unprivileged"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_user_duplicate_email(page: Page, test_user: UserModel):
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Add User").click()
|
||||||
|
await expect(page.get_by_text("New User")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Email']").last.fill(TEST_EMAIL)
|
||||||
|
await page.locator("input[aria-label='Password']").last.fill("somepass123")
|
||||||
|
await page.get_by_role("button", name="Create").click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("already exists")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Edit user (DB operations with page render verification) ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_user_role(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
assert u.role == "unprivileged"
|
||||||
|
u.role = "admin"
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
assert u.role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_edit_user_password(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("oldpass"), role="unprivileged")
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
u.password_hash = hash_password("newpass456")
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
assert verify_password("newpass456", u.password_hash) is True
|
||||||
|
assert verify_password("oldpass", u.password_hash) is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_disable_user(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
u.disabled_at = utcnow()
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
assert u.disabled_at is not None
|
||||||
|
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_enable_user(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged", disabled_at=utcnow())
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
u.disabled_at = None
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
assert u.disabled_at is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Delete user ---
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_user(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
|
||||||
|
session.add(target)
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
await session.delete(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
assert await session.get(UserModel, target_id) is None
|
||||||
|
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_user_cascades(page: Page, test_user: UserModel):
|
||||||
|
async with async_session() as session:
|
||||||
|
target = UserModel(email=CREATED_USER_EMAIL, password_hash=hash_password("pw"), role="unprivileged")
|
||||||
|
session.add(target)
|
||||||
|
await session.flush()
|
||||||
|
session.add(Device(name="cascade-dev", public_key="pk-cascade-e2e", user_id=target.id))
|
||||||
|
session.add(Rule(action="accept", destination="10.0.0.0/8", user_id=target.id))
|
||||||
|
await session.commit()
|
||||||
|
target_id = target.id
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
for d in (await session.execute(select(Device).where(Device.user_id == target_id))).scalars().all():
|
||||||
|
await session.delete(d)
|
||||||
|
for r in (await session.execute(select(Rule).where(Rule.user_id == target_id))).scalars().all():
|
||||||
|
await session.delete(r)
|
||||||
|
u = await session.get(UserModel, target_id)
|
||||||
|
if u:
|
||||||
|
await session.delete(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
assert await session.get(UserModel, target_id) is None
|
||||||
|
assert (await session.execute(select(func.count()).select_from(Device).where(Device.user_id == target_id))).scalar() == 0
|
||||||
|
assert (await session.execute(select(func.count()).select_from(Rule).where(Rule.user_id == target_id))).scalar() == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cannot_delete_own_account(page: Page, test_user: UserModel):
|
||||||
|
await _login_and_go_to_users(page)
|
||||||
|
await expect(page.get_by_role("main").get_by_text("Users")).to_be_visible()
|
||||||
|
assert test_user.role == "admin"
|
||||||
|
|
@ -1,54 +1,32 @@
|
||||||
"""End-to-end tests for device management UI using NiceGUI's User fixture."""
|
"""End-to-end tests for device management UI."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from playwright.async_api import Page, expect
|
||||||
|
|
||||||
import pytest
|
|
||||||
from nicegui.testing import User
|
|
||||||
|
|
||||||
from wiregui.models.user import User as UserModel
|
from wiregui.models.user import User as UserModel
|
||||||
from tests.e2e.conftest import TEST_EMAIL, TEST_PASSWORD
|
from tests.e2e.conftest import login
|
||||||
|
|
||||||
# Fake WG keys for testing (valid base64, 32 bytes)
|
|
||||||
FAKE_PRIVATE_KEY = "YFake0PrivateKey00000000000000000000000000w="
|
|
||||||
FAKE_PUBLIC_KEY = "ZFake0PublicKey000000000000000000000000000w="
|
|
||||||
|
|
||||||
|
|
||||||
async def _login(user: User):
|
async def test_add_device_via_ui(page: Page, test_user: UserModel):
|
||||||
"""Helper to log in via the UI."""
|
|
||||||
await user.open("/login")
|
|
||||||
user.find("Email").type(TEST_EMAIL)
|
|
||||||
user.find("Password").type(TEST_PASSWORD)
|
|
||||||
user.find("Sign in").click()
|
|
||||||
await user.should_see("My Devices")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
|
||||||
async def test_add_device_via_ui(user: User, test_user: UserModel):
|
|
||||||
"""Test the full flow: login → devices → add device → see it in table."""
|
"""Test the full flow: login → devices → add device → see it in table."""
|
||||||
with patch("wiregui.pages.devices.generate_keypair", return_value=(FAKE_PRIVATE_KEY, FAKE_PUBLIC_KEY)), \
|
await login(page)
|
||||||
patch("wiregui.pages.devices.generate_preshared_key", return_value="cHJlc2hhcmVkMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="):
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
await _login(user)
|
await page.get_by_role("button", name="Add Device").click()
|
||||||
|
await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
# Open create dialog
|
await page.locator("input[aria-label='Device Name']").fill("Test Laptop")
|
||||||
user.find("Add Device").click()
|
await page.get_by_role("button", name="Create").click()
|
||||||
await user.should_see("New Device")
|
|
||||||
|
|
||||||
# Fill device name and submit
|
# Should see config dialog with the device name
|
||||||
user.find("Device Name").type("Test Laptop")
|
await expect(page.get_by_text("Config for Test Laptop")).to_be_visible(timeout=10_000)
|
||||||
user.find("Create").click()
|
|
||||||
|
|
||||||
# Should see config dialog with the device config
|
|
||||||
await user.should_see("Test Laptop")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("user", [{"storage": {}}], indirect=True)
|
async def test_add_device_requires_name(page: Page, test_user: UserModel):
|
||||||
async def test_add_device_requires_name(user: User, test_user: UserModel):
|
|
||||||
"""Test that creating a device without a name shows an error."""
|
"""Test that creating a device without a name shows an error."""
|
||||||
await _login(user)
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
# Open create dialog and submit without name
|
await page.get_by_role("button", name="Add Device").click()
|
||||||
user.find("Add Device").click()
|
await expect(page.get_by_text("New Device")).to_be_visible(timeout=5_000)
|
||||||
await user.should_see("New Device")
|
await page.get_by_role("button", name="Create").click()
|
||||||
user.find("Create").click()
|
await expect(page.get_by_text("Device name is required")).to_be_visible(timeout=5_000)
|
||||||
await user.should_see("Device name is required")
|
|
||||||
|
|
|
||||||
249
tests/e2e/test_idp_seed.py
Normal file
249
tests/e2e/test_idp_seed.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
"""E2E tests for IdP seeding from YAML config file (WG_IDP_CONFIG_FILE).
|
||||||
|
|
||||||
|
Uses async Playwright for the full OIDC flow test (real browser → mock-oidc server).
|
||||||
|
The seed function tests run without a browser.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
import yaml
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.auth.seed import seed_idp_providers
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
from tests.e2e.conftest import FAKE_SERVER_KEY
|
||||||
|
|
||||||
|
|
||||||
|
MOCK_OIDC_HOST = os.environ.get("MOCK_OIDC_HOST", "localhost")
|
||||||
|
MOCK_OIDC_DISCOVERY = f"http://{MOCK_OIDC_HOST}:9000/test-idp/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
# Separate port for the IdP-seeded app instance
|
||||||
|
IDP_APP_PORT = 13002
|
||||||
|
IDP_APP_BASE = f"http://localhost:{IDP_APP_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_yaml(data: dict) -> Path:
|
||||||
|
f = tempfile.NamedTemporaryFile(suffix=".yaml", delete=False, mode="w")
|
||||||
|
yaml.safe_dump(data, f)
|
||||||
|
f.close()
|
||||||
|
return Path(f.name)
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_oidc_yaml() -> dict:
|
||||||
|
return {
|
||||||
|
"openid_connect_providers": [
|
||||||
|
{
|
||||||
|
"id": "test-idp",
|
||||||
|
"label": "Sign in with Mock IdP",
|
||||||
|
"scope": "openid email profile",
|
||||||
|
"client_id": "wiregui-test",
|
||||||
|
"client_secret": "wiregui-test-secret",
|
||||||
|
"discovery_document_uri": MOCK_OIDC_DISCOVERY,
|
||||||
|
"auto_create_users": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def clean_config():
|
||||||
|
"""Ensure a Configuration row exists with no IdP providers, and restore after."""
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
orig_oidc = list(config.openid_connect_providers or []) if config else []
|
||||||
|
orig_saml = list(config.saml_identity_providers or []) if config else []
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
config = Configuration(server_public_key=FAKE_SERVER_KEY)
|
||||||
|
session.add(config)
|
||||||
|
|
||||||
|
config.openid_connect_providers = []
|
||||||
|
config.saml_identity_providers = []
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
config.openid_connect_providers = orig_oidc
|
||||||
|
config.saml_identity_providers = orig_saml
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Seed function tests (no browser needed)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_noop_when_no_config_file(clean_config, monkeypatch):
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": None})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert config.openid_connect_providers == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_noop_when_file_missing(clean_config, monkeypatch):
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": "/nonexistent/idps.yaml"})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert config.openid_connect_providers == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_adds_oidc_provider(clean_config, monkeypatch):
|
||||||
|
path = _write_yaml(_mock_oidc_yaml())
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert len(config.openid_connect_providers) == 1
|
||||||
|
assert config.openid_connect_providers[0]["id"] == "test-idp"
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_adds_saml_provider(clean_config, monkeypatch):
|
||||||
|
yaml_data = {"saml_identity_providers": [{"id": "test-saml", "label": "Test SAML IdP", "metadata": "<EntityDescriptor/>", "sign_requests": True, "sign_metadata": False, "signed_assertion_in_resp": True, "signed_envelopes_in_resp": True, "auto_create_users": False}]}
|
||||||
|
path = _write_yaml(yaml_data)
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert len(config.saml_identity_providers) == 1
|
||||||
|
assert config.saml_identity_providers[0]["id"] == "test-saml"
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_upserts_existing_provider(clean_config, monkeypatch):
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
config.openid_connect_providers = [{"id": "test-idp", "label": "Old Label", "client_id": "old-client"}]
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yaml_data = {"openid_connect_providers": [{"id": "test-idp", "label": "Updated Label", "client_id": "new-client", "client_secret": "new-secret", "discovery_document_uri": MOCK_OIDC_DISCOVERY}]}
|
||||||
|
path = _write_yaml(yaml_data)
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert config.openid_connect_providers[0]["label"] == "Updated Label"
|
||||||
|
assert config.openid_connect_providers[0]["client_id"] == "new-client"
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_preserves_providers_not_in_yaml(clean_config, monkeypatch):
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
config.openid_connect_providers = [{"id": "manual-provider", "label": "Manually Added", "client_id": "manual"}]
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yaml_data = {"openid_connect_providers": [{"id": "yaml-provider", "label": "From YAML", "client_id": "yaml-client", "client_secret": "yaml-secret", "discovery_document_uri": MOCK_OIDC_DISCOVERY}]}
|
||||||
|
path = _write_yaml(yaml_data)
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
ids = {p["id"] for p in config.openid_connect_providers}
|
||||||
|
assert ids == {"manual-provider", "yaml-provider"}
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_seed_invalid_yaml(clean_config, monkeypatch):
|
||||||
|
path = Path(tempfile.mktemp(suffix=".yaml"))
|
||||||
|
path.write_text(": : : invalid yaml [[[")
|
||||||
|
monkeypatch.setattr("wiregui.auth.seed.get_settings", lambda: type("S", (), {"idp_config_file": str(path)})())
|
||||||
|
await seed_idp_providers()
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one()
|
||||||
|
assert config.openid_connect_providers == []
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Playwright browser tests — full OIDC login flow via mock-oidc
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def idp_yaml_file():
|
||||||
|
path = _write_yaml(_mock_oidc_yaml())
|
||||||
|
yield path
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_with_idp(idp_yaml_file):
|
||||||
|
"""Start a WireGUI instance with WG_IDP_CONFIG_FILE set."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WG_IDP_CONFIG_FILE"] = str(idp_yaml_file)
|
||||||
|
env["WG_LOG_TO_FILE"] = "false"
|
||||||
|
env["WG_PORT"] = str(IDP_APP_PORT)
|
||||||
|
env["WG_EXTERNAL_URL"] = IDP_APP_BASE
|
||||||
|
env.pop("PYTEST_CURRENT_TEST", None)
|
||||||
|
env.pop("NICEGUI_SCREEN_TEST_PORT", None)
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["uv", "run", "python", "-m", "wiregui.main"],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{IDP_APP_BASE}/api/health", timeout=1)
|
||||||
|
if r.status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
proc.kill()
|
||||||
|
out = proc.stdout.read().decode() if proc.stdout else ""
|
||||||
|
pytest.fail(f"App did not start in time. Output:\n{out}")
|
||||||
|
|
||||||
|
yield proc
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_oidc_button_visible_on_login(app_with_idp, page: Page):
|
||||||
|
await page.goto(f"{IDP_APP_BASE}/login")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await expect(page.get_by_text("Sign in with Mock IdP")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_full_oidc_login_flow(app_with_idp, page: Page):
|
||||||
|
"""Click the OIDC button → mock-oidc login → redirected back → authenticated."""
|
||||||
|
await page.goto(f"{IDP_APP_BASE}/auth/oidc/test-idp")
|
||||||
|
await page.wait_for_url("**/test-idp/authorize**", timeout=10_000)
|
||||||
|
|
||||||
|
await page.locator("input[name='username']").fill("oidc-e2e-user@test.local")
|
||||||
|
await page.locator("input[type='submit']").click()
|
||||||
|
|
||||||
|
await page.wait_for_url(f"{IDP_APP_BASE}/**", timeout=15_000)
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
assert "/login" not in page.url, f"OIDC login failed — still on login page: {page.url}"
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
63
tests/e2e/test_login.py
Normal file
63
tests/e2e/test_login.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""End-to-end tests for login, logout, and auth guard flows."""
|
||||||
|
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.user import User as UserModel
|
||||||
|
from wiregui.utils.time import utcnow
|
||||||
|
from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL, TEST_PASSWORD, login
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_valid_credentials(page: Page, test_user: UserModel):
|
||||||
|
"""Valid login redirects to devices page."""
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_invalid_password(page: Page, test_user: UserModel):
|
||||||
|
"""Wrong password shows error and stays on login page."""
|
||||||
|
await login(page, password="wrongpassword")
|
||||||
|
await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_nonexistent_email(page: Page, test_user: UserModel):
|
||||||
|
"""Nonexistent email shows error."""
|
||||||
|
await login(page, email="nobody@nowhere.com")
|
||||||
|
await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_disabled_user(page: Page, test_user: UserModel):
|
||||||
|
"""Disabled user cannot log in."""
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, test_user.id)
|
||||||
|
u.disabled_at = utcnow()
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
try:
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("Invalid email or password")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible()
|
||||||
|
finally:
|
||||||
|
async with async_session() as session:
|
||||||
|
u = await session.get(UserModel, test_user.id)
|
||||||
|
u.disabled_at = None
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout(page: Page, test_user: UserModel):
|
||||||
|
"""Logout clears session and redirects to login."""
|
||||||
|
await login(page)
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
await page.get_by_text("Logout").click()
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unauthenticated_redirect(page: Page, test_user: UserModel):
|
||||||
|
"""Accessing a protected page without auth redirects to login."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/devices")
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
|
||||||
41
tests/e2e/test_magic_link_page.py
Normal file
41
tests/e2e/test_magic_link_page.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""E2E tests for magic link request page."""
|
||||||
|
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
|
||||||
|
from tests.e2e.conftest import TEST_APP_BASE, TEST_EMAIL
|
||||||
|
from wiregui.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_page_renders(page: Page, test_user: User):
|
||||||
|
"""Magic link request page renders with email input and submit button."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/auth/magic-link")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await expect(page.get_by_text("Sign in with magic link")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.locator("input[aria-label='Email']")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("button", name="Send Magic Link")).to_be_visible()
|
||||||
|
await expect(page.get_by_role("button", name="Back to login")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_shows_success_on_submit(page: Page, test_user: User):
|
||||||
|
"""Submitting an email shows success message (regardless of whether account exists)."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/auth/magic-link")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.locator("input[aria-label='Email']").fill(TEST_EMAIL)
|
||||||
|
await page.get_by_role("button", name="Send Magic Link").click()
|
||||||
|
await expect(page.get_by_text("a sign-in link has been sent")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_empty_email_shows_error(page: Page, test_user: User):
|
||||||
|
"""Submitting without email shows error."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/auth/magic-link")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.get_by_role("button", name="Send Magic Link").click()
|
||||||
|
await expect(page.get_by_text("Enter your email")).to_be_visible(timeout=5_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_magic_link_back_to_login(page: Page, test_user: User):
|
||||||
|
"""Back to login button navigates to login page."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/auth/magic-link")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.get_by_role("button", name="Back to login").click()
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
|
||||||
111
tests/e2e/test_mfa_login.py
Normal file
111
tests/e2e/test_mfa_login.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""E2E tests for MFA login flow — login with TOTP redirects to /mfa challenge page."""
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
|
||||||
|
from wiregui.auth.mfa import generate_totp_secret
|
||||||
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.mfa_method import MFAMethod
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from tests.e2e.conftest import (
|
||||||
|
FAKE_SERVER_KEY,
|
||||||
|
TEST_APP_BASE,
|
||||||
|
TEST_PASSWORD,
|
||||||
|
_cleanup_user_by_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
MFA_EMAIL = "e2e-mfa@example.com"
|
||||||
|
MFA_PASSWORD = "mfapass123"
|
||||||
|
TOTP_SECRET = generate_totp_secret()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def mfa_user(app_server):
|
||||||
|
"""Create a user with a TOTP MFA method, clean up after."""
|
||||||
|
await _cleanup_user_by_email(MFA_EMAIL)
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
from sqlmodel import select
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
if config:
|
||||||
|
if not config.server_public_key:
|
||||||
|
config.server_public_key = FAKE_SERVER_KEY
|
||||||
|
session.add(config)
|
||||||
|
else:
|
||||||
|
config = Configuration(server_public_key=FAKE_SERVER_KEY)
|
||||||
|
session.add(config)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=MFA_EMAIL,
|
||||||
|
password_hash=hash_password(MFA_PASSWORD),
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
mfa = MFAMethod(
|
||||||
|
name="Test TOTP",
|
||||||
|
type="totp",
|
||||||
|
payload={"secret": TOTP_SECRET},
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
session.add(mfa)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
yield user
|
||||||
|
|
||||||
|
await _cleanup_user_by_email(MFA_EMAIL)
|
||||||
|
|
||||||
|
|
||||||
|
async def _login_mfa_user(page: Page):
|
||||||
|
"""Fill login form for the MFA user and submit."""
|
||||||
|
await page.goto(f"{TEST_APP_BASE}/login")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.locator("input[aria-label='Email']").fill(MFA_EMAIL)
|
||||||
|
await page.locator("input[aria-label='Password']").fill(MFA_PASSWORD)
|
||||||
|
await page.get_by_role("button", name="Sign in", exact=True).click()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mfa_login_redirects_to_challenge(page: Page, mfa_user: User):
|
||||||
|
"""Login with MFA-enabled user redirects to /mfa challenge page."""
|
||||||
|
await _login_mfa_user(page)
|
||||||
|
await expect(page.get_by_text("Two-Factor Authentication")).to_be_visible(timeout=10_000)
|
||||||
|
await expect(page.locator("input[aria-label='Authentication Code']")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mfa_valid_totp_completes_login(page: Page, mfa_user: User):
|
||||||
|
"""Entering a valid TOTP code on /mfa completes login."""
|
||||||
|
await _login_mfa_user(page)
|
||||||
|
await expect(page.get_by_text("Two-Factor Authentication")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
code = pyotp.TOTP(TOTP_SECRET).now()
|
||||||
|
await page.locator("input[aria-label='Authentication Code']").fill(code)
|
||||||
|
await page.get_by_role("button", name="Verify").click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("My Devices")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mfa_invalid_code_shows_error(page: Page, mfa_user: User):
|
||||||
|
"""Entering an invalid TOTP code shows error and stays on /mfa."""
|
||||||
|
await _login_mfa_user(page)
|
||||||
|
await expect(page.get_by_text("Two-Factor Authentication")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
await page.locator("input[aria-label='Authentication Code']").fill("000000")
|
||||||
|
await page.get_by_role("button", name="Verify").click()
|
||||||
|
|
||||||
|
await expect(page.get_by_text("Invalid code")).to_be_visible(timeout=5_000)
|
||||||
|
await expect(page.get_by_text("Two-Factor Authentication")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mfa_cancel_returns_to_login(page: Page, mfa_user: User):
|
||||||
|
"""Clicking Cancel on /mfa clears session and returns to login."""
|
||||||
|
await _login_mfa_user(page)
|
||||||
|
await expect(page.get_by_text("Two-Factor Authentication")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
await page.get_by_role("button", name="Cancel").click()
|
||||||
|
await expect(page.get_by_role("button", name="Sign in", exact=True)).to_be_visible(timeout=10_000)
|
||||||
179
tests/e2e/test_saml_login.py
Normal file
179
tests/e2e/test_saml_login.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""E2E tests for SAML authentication — mock SimpleSAMLphp IdP.
|
||||||
|
|
||||||
|
Requires mock-saml service running (docker compose up -d mock-saml).
|
||||||
|
IdP metadata: http://localhost:8080/simplesaml/saml2/idp/metadata.php
|
||||||
|
Test users: user1/user1pass, user2/user2pass
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from playwright.async_api import Page, expect
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from tests.e2e.conftest import FAKE_SERVER_KEY, _cleanup_user_by_email
|
||||||
|
|
||||||
|
MOCK_SAML_HOST = os.environ.get("MOCK_SAML_HOST", "localhost")
|
||||||
|
MOCK_SAML_METADATA_URL = f"http://{MOCK_SAML_HOST}:8080/simplesaml/saml2/idp/metadata.php"
|
||||||
|
|
||||||
|
# Separate app port for SAML tests (like OIDC IdP tests)
|
||||||
|
SAML_APP_PORT = 13003
|
||||||
|
SAML_APP_BASE = f"http://localhost:{SAML_APP_PORT}"
|
||||||
|
|
||||||
|
SAML_TEST_EMAIL = "user1@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_idp_metadata() -> str:
|
||||||
|
"""Fetch IdP metadata XML from the mock SAML server."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(MOCK_SAML_METADATA_URL, timeout=5)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.text
|
||||||
|
except Exception:
|
||||||
|
pytest.skip(f"Mock SAML IdP not available at {MOCK_SAML_METADATA_URL}")
|
||||||
|
|
||||||
|
|
||||||
|
def _saml_provider_config(metadata: str) -> dict:
|
||||||
|
return {
|
||||||
|
"id": "test-saml",
|
||||||
|
"label": "Sign in with Mock SAML",
|
||||||
|
"metadata": metadata,
|
||||||
|
"sign_requests": False,
|
||||||
|
"sign_metadata": False,
|
||||||
|
"signed_assertion_in_resp": False,
|
||||||
|
"signed_envelopes_in_resp": False,
|
||||||
|
"auto_create_users": True,
|
||||||
|
"strict": False, # Relaxed for test IdP with expired certs
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="module")
|
||||||
|
async def saml_metadata():
|
||||||
|
return _fetch_idp_metadata()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_with_saml(saml_metadata):
|
||||||
|
"""Start a WireGUI instance with a SAML provider seeded in the DB."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# Seed the SAML provider config into the database
|
||||||
|
async def _seed():
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
if config is None:
|
||||||
|
config = Configuration(server_public_key=FAKE_SERVER_KEY)
|
||||||
|
session.add(config)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
providers = list(config.saml_identity_providers or [])
|
||||||
|
providers = [p for p in providers if p.get("id") != "test-saml"]
|
||||||
|
providers.append(_saml_provider_config(saml_metadata))
|
||||||
|
config.saml_identity_providers = providers
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.get_event_loop().run_until_complete(_seed())
|
||||||
|
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["WG_LOG_TO_FILE"] = "false"
|
||||||
|
env["WG_PORT"] = str(SAML_APP_PORT)
|
||||||
|
env["WG_EXTERNAL_URL"] = SAML_APP_BASE
|
||||||
|
env.pop("PYTEST_CURRENT_TEST", None)
|
||||||
|
env.pop("NICEGUI_SCREEN_TEST_PORT", None)
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
["uv", "run", "python", "-m", "wiregui.main"],
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{SAML_APP_BASE}/api/health", timeout=1)
|
||||||
|
if r.status_code == 200:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(1)
|
||||||
|
else:
|
||||||
|
proc.kill()
|
||||||
|
out = proc.stdout.read().decode() if proc.stdout else ""
|
||||||
|
pytest.fail(f"App did not start in time. Output:\n{out}")
|
||||||
|
|
||||||
|
yield proc
|
||||||
|
|
||||||
|
proc.terminate()
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
|
||||||
|
# Clean up seeded provider and test user
|
||||||
|
async def _cleanup():
|
||||||
|
await _cleanup_user_by_email(SAML_TEST_EMAIL)
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
if config:
|
||||||
|
config.saml_identity_providers = [
|
||||||
|
p for p in (config.saml_identity_providers or []) if p.get("id") != "test-saml"
|
||||||
|
]
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
asyncio.get_event_loop().run_until_complete(_cleanup())
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saml_button_visible_on_login(app_with_saml, page: Page):
|
||||||
|
"""Login page shows SAML provider button."""
|
||||||
|
await page.goto(f"{SAML_APP_BASE}/login")
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await expect(page.get_by_text("Sign in with Mock SAML")).to_be_visible(timeout=10_000)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Chromium cannot resolve Docker service hostnames in CI")
|
||||||
|
async def test_saml_redirect_to_idp(app_with_saml, page: Page):
|
||||||
|
"""Clicking SAML login redirects to the SimpleSAMLphp IdP login page."""
|
||||||
|
await page.goto(f"{SAML_APP_BASE}/auth/saml/test-saml")
|
||||||
|
# Should redirect to the SimpleSAMLphp SSO service
|
||||||
|
await page.wait_for_url(f"**{MOCK_SAML_HOST}:8080/simplesaml/**", timeout=30_000)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_saml_sp_metadata_endpoint(app_with_saml, page: Page):
|
||||||
|
"""SP metadata endpoint returns valid XML."""
|
||||||
|
response = await page.request.get(f"{SAML_APP_BASE}/auth/saml/test-saml/metadata")
|
||||||
|
assert response.status == 200
|
||||||
|
body = await response.text()
|
||||||
|
assert "EntityDescriptor" in body
|
||||||
|
assert "AssertionConsumerService" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.environ.get("CI") == "true", reason="Chromium cannot resolve Docker service hostnames in CI")
|
||||||
|
async def test_full_saml_login_flow(app_with_saml, page: Page):
|
||||||
|
"""Full SAML SSO flow: app → IdP login → callback → authenticated."""
|
||||||
|
await page.goto(f"{SAML_APP_BASE}/auth/saml/test-saml")
|
||||||
|
await page.wait_for_url(f"**{MOCK_SAML_HOST}:8080/simplesaml/**", timeout=30_000)
|
||||||
|
|
||||||
|
# SimpleSAMLphp login form
|
||||||
|
await page.locator("input[name='username']").fill("user1")
|
||||||
|
await page.locator("input[name='password']").fill("password")
|
||||||
|
await page.locator("button[type='submit'], input[type='submit']").first.click()
|
||||||
|
|
||||||
|
# Should redirect back to the app after SAML response
|
||||||
|
await page.wait_for_url(f"{SAML_APP_BASE}/**", timeout=15_000)
|
||||||
|
await page.wait_for_load_state("networkidle")
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
assert "/login" not in page.url, f"SAML login failed — still on login page: {page.url}"
|
||||||
|
|
||||||
|
# Verify user was auto-created in DB
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(User).where(User.email == SAML_TEST_EMAIL))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
assert user is not None, f"Expected user {SAML_TEST_EMAIL} to be auto-created"
|
||||||
|
assert user.last_signed_in_method == "saml:test-saml"
|
||||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
156
tests/integration/test_metrics_pipeline.py
Normal file
156
tests/integration/test_metrics_pipeline.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
"""Integration test: verify metrics flow from WG clients → collector → VictoriaMetrics.
|
||||||
|
|
||||||
|
Requires the full integration stack running: make test-stack-up
|
||||||
|
Run with: make test-stack-verify (or: uv run pytest tests/integration/ -v)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
VM_URL = os.environ.get("WG_VICTORIAMETRICS_URL", "http://localhost:8428")
|
||||||
|
WIREGUI_URL = os.environ.get("WG_EXTERNAL_URL", "http://localhost:13000")
|
||||||
|
|
||||||
|
EXPECTED_CLIENTS = ["test-client-1", "test-client-2", "test-client-3"]
|
||||||
|
# Wait up to this long for metrics to appear (collector runs every 5s)
|
||||||
|
MAX_WAIT = 60
|
||||||
|
POLL_INTERVAL = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_query(query: str) -> dict:
|
||||||
|
"""Execute an instant query against VictoriaMetrics."""
|
||||||
|
resp = httpx.get(f"{VM_URL}/api/v1/query", params={"query": query}, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _vm_series(metric: str) -> list[dict]:
|
||||||
|
"""Get all series for a metric from VictoriaMetrics."""
|
||||||
|
resp = httpx.get(f"{VM_URL}/api/v1/series", params={"match[]": metric}, timeout=5)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("data", [])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def check_stack_running():
|
||||||
|
"""Skip all tests if the integration stack isn't running."""
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{WIREGUI_URL}/api/health", timeout=3)
|
||||||
|
if r.status_code != 200:
|
||||||
|
pytest.skip("WireGUI not running")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pytest.skip("WireGUI not running — start with: make test-stack-up")
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = httpx.get(f"{VM_URL}/health", timeout=3)
|
||||||
|
if r.status_code != 200:
|
||||||
|
pytest.skip("VictoriaMetrics not running")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
pytest.skip("VictoriaMetrics not running — start with: make test-stack-up")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def wait_for_metrics():
|
||||||
|
"""Wait until at least one peer metric appears in VictoriaMetrics."""
|
||||||
|
deadline = time.time() + MAX_WAIT
|
||||||
|
while time.time() < deadline:
|
||||||
|
result = _vm_query("wiregui_peers_total")
|
||||||
|
data = result.get("data", {}).get("result", [])
|
||||||
|
if data and float(data[0].get("value", [0, "0"])[1]) > 0:
|
||||||
|
return
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
pytest.fail(f"No metrics appeared in VictoriaMetrics after {MAX_WAIT}s")
|
||||||
|
|
||||||
|
|
||||||
|
def test_peers_total(wait_for_metrics):
|
||||||
|
"""wiregui_peers_total reports at least 1 active peer."""
|
||||||
|
result = _vm_query("wiregui_peers_total")
|
||||||
|
data = result["data"]["result"]
|
||||||
|
assert len(data) > 0
|
||||||
|
value = float(data[0]["value"][1])
|
||||||
|
assert value >= 1, f"Expected at least 1 peer, got {value}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rx_bytes_per_client(wait_for_metrics):
|
||||||
|
"""Each client has wiregui_peer_rx_bytes > 0."""
|
||||||
|
series = _vm_series("wiregui_peer_rx_bytes")
|
||||||
|
device_names = {s.get("device_name") for s in series}
|
||||||
|
|
||||||
|
for client in EXPECTED_CLIENTS:
|
||||||
|
assert client in device_names, (
|
||||||
|
f"Missing rx_bytes metric for '{client}'. "
|
||||||
|
f"Found: {device_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify values are non-zero (traffic is flowing)
|
||||||
|
for client in EXPECTED_CLIENTS:
|
||||||
|
result = _vm_query(f'wiregui_peer_rx_bytes{{device_name="{client}"}}')
|
||||||
|
data = result["data"]["result"]
|
||||||
|
assert len(data) > 0, f"No rx_bytes data for {client}"
|
||||||
|
value = float(data[0]["value"][1])
|
||||||
|
assert value > 0, f"rx_bytes for {client} is 0 — no traffic?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tx_bytes_per_client(wait_for_metrics):
|
||||||
|
"""Each client has wiregui_peer_tx_bytes > 0."""
|
||||||
|
for client in EXPECTED_CLIENTS:
|
||||||
|
result = _vm_query(f'wiregui_peer_tx_bytes{{device_name="{client}"}}')
|
||||||
|
data = result["data"]["result"]
|
||||||
|
assert len(data) > 0, f"No tx_bytes data for {client}"
|
||||||
|
value = float(data[0]["value"][1])
|
||||||
|
assert value > 0, f"tx_bytes for {client} is 0 — no traffic?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_handshake_per_client(wait_for_metrics):
|
||||||
|
"""Each client has a recent handshake timestamp."""
|
||||||
|
now = time.time()
|
||||||
|
for client in EXPECTED_CLIENTS:
|
||||||
|
result = _vm_query(f'wiregui_peer_latest_handshake_seconds{{device_name="{client}"}}')
|
||||||
|
data = result["data"]["result"]
|
||||||
|
assert len(data) > 0, f"No handshake data for {client}"
|
||||||
|
ts = float(data[0]["value"][1])
|
||||||
|
assert ts > 0, f"Handshake timestamp for {client} is 0"
|
||||||
|
age = now - ts
|
||||||
|
assert age < 300, f"Handshake for {client} is {age:.0f}s old (stale?)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_connected_status_per_client(wait_for_metrics):
|
||||||
|
"""Each client reports wiregui_peer_connected = 1."""
|
||||||
|
for client in EXPECTED_CLIENTS:
|
||||||
|
result = _vm_query(f'wiregui_peer_connected{{device_name="{client}"}}')
|
||||||
|
data = result["data"]["result"]
|
||||||
|
assert len(data) > 0, f"No connected status for {client}"
|
||||||
|
value = int(float(data[0]["value"][1]))
|
||||||
|
assert value == 1, f"Client {client} not connected (wiregui_peer_connected={value})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_devices_have_stats():
|
||||||
|
"""Verify device rows in PostgreSQL also have updated stats."""
|
||||||
|
import asyncio
|
||||||
|
from sqlmodel import select
|
||||||
|
from wiregui.db import async_session, engine
|
||||||
|
from wiregui.models.device import Device
|
||||||
|
|
||||||
|
async def check():
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Device).where(Device.name.in_(EXPECTED_CLIENTS))
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
assert len(devices) == len(EXPECTED_CLIENTS), (
|
||||||
|
f"Expected {len(EXPECTED_CLIENTS)} devices, found {len(devices)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
assert device.latest_handshake is not None, (
|
||||||
|
f"Device {device.name} has no handshake in DB"
|
||||||
|
)
|
||||||
|
assert device.rx_bytes is not None and device.rx_bytes > 0, (
|
||||||
|
f"Device {device.name} has no rx_bytes in DB"
|
||||||
|
)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
asyncio.run(check())
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
"""Tests for account functionality — password changes, API tokens, OIDC connections."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from sqlmodel import func, select
|
|
||||||
|
|
||||||
from wiregui.auth.api_token import generate_api_token
|
|
||||||
from wiregui.auth.passwords import hash_password, verify_password
|
|
||||||
from wiregui.models.api_token import ApiToken
|
|
||||||
from wiregui.models.oidc_connection import OIDCConnection
|
|
||||||
from wiregui.models.user import User
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
# --- Password change ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_password_change_flow(session):
|
|
||||||
"""Simulate the password change flow: verify old, set new."""
|
|
||||||
user = User(email="pw-change@example.com", password_hash=hash_password("old-password"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Verify old password
|
|
||||||
assert verify_password("old-password", user.password_hash) is True
|
|
||||||
|
|
||||||
# Change password
|
|
||||||
user.password_hash = hash_password("new-password")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(User, user.id)
|
|
||||||
assert verify_password("new-password", fetched.password_hash) is True
|
|
||||||
assert verify_password("old-password", fetched.password_hash) is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_password_change_wrong_current(session):
|
|
||||||
"""Wrong current password should not allow change."""
|
|
||||||
user = User(email="pw-wrong@example.com", password_hash=hash_password("correct"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Simulate check
|
|
||||||
assert verify_password("wrong", user.password_hash) is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- API token management ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_multiple_tokens(session):
|
|
||||||
user = User(email="multi-token@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
for _ in range(3):
|
|
||||||
_, token_hash = generate_api_token()
|
|
||||||
session.add(ApiToken(token_hash=token_hash, user_id=user.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(ApiToken).where(ApiToken.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
|
|
||||||
async def test_token_with_expiry(session):
|
|
||||||
user = User(email="expiry-token@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
_, token_hash = generate_api_token()
|
|
||||||
expires = utcnow() + timedelta(days=30)
|
|
||||||
token = ApiToken(token_hash=token_hash, expires_at=expires, user_id=user.id)
|
|
||||||
session.add(token)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(ApiToken, token.id)
|
|
||||||
assert fetched.expires_at is not None
|
|
||||||
assert fetched.expires_at > utcnow()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_token(session):
|
|
||||||
user = User(email="del-token@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
_, token_hash = generate_api_token()
|
|
||||||
token = ApiToken(token_hash=token_hash, user_id=user.id)
|
|
||||||
session.add(token)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
await session.delete(token)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert await session.get(ApiToken, token.id) is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- OIDC connections ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_connection_create(session):
|
|
||||||
user = User(email="oidc-conn@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conn = OIDCConnection(
|
|
||||||
provider="google",
|
|
||||||
refresh_token="refresh-tok-123",
|
|
||||||
refresh_response={"access_token": "at", "token_type": "Bearer"},
|
|
||||||
refreshed_at=utcnow(),
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(
|
|
||||||
select(OIDCConnection).where(OIDCConnection.user_id == user.id)
|
|
||||||
)).scalar_one()
|
|
||||||
assert fetched.provider == "google"
|
|
||||||
assert fetched.refresh_token == "refresh-tok-123"
|
|
||||||
assert fetched.refresh_response["access_token"] == "at"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_multiple_oidc_providers(session):
|
|
||||||
user = User(email="multi-oidc@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
for provider in ["google", "okta", "azure"]:
|
|
||||||
conn = OIDCConnection(provider=provider, user_id=user.id)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(OIDCConnection).where(OIDCConnection.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 3
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_connection_update_refresh_token(session):
|
|
||||||
user = User(email="oidc-refresh@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conn = OIDCConnection(
|
|
||||||
provider="google",
|
|
||||||
refresh_token="old-token",
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conn.refresh_token = "new-token"
|
|
||||||
conn.refreshed_at = utcnow()
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(OIDCConnection, conn.id)
|
|
||||||
assert fetched.refresh_token == "new-token"
|
|
||||||
assert fetched.refreshed_at is not None
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"""Tests for admin functionality — user management, configuration, cascading deletes."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlmodel import func, select
|
|
||||||
|
|
||||||
from wiregui.auth.passwords import hash_password, verify_password
|
|
||||||
from wiregui.models.api_token import ApiToken
|
|
||||||
from wiregui.models.configuration import Configuration
|
|
||||||
from wiregui.models.device import Device
|
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
|
||||||
from wiregui.models.rule import Rule
|
|
||||||
from wiregui.models.user import User
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
# --- User CRUD ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_user_with_role(session):
|
|
||||||
user = User(email="new-admin@test.com", password_hash=hash_password("secret"), role="admin")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(User, user.id)
|
|
||||||
assert fetched.role == "admin"
|
|
||||||
assert verify_password("secret", fetched.password_hash)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_user_email(session):
|
|
||||||
user = User(email="old@test.com", password_hash=hash_password("pw"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
user.email = "new@test.com"
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(User, user.id)
|
|
||||||
assert fetched.email == "new@test.com"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_disable_user(session):
|
|
||||||
user = User(email="active@test.com", password_hash=hash_password("pw"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
assert user.disabled_at is None
|
|
||||||
|
|
||||||
user.disabled_at = utcnow()
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(User, user.id)
|
|
||||||
assert fetched.disabled_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_promote_demote_user(session):
|
|
||||||
user = User(email="user@test.com", role="unprivileged")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
assert user.role == "unprivileged"
|
|
||||||
|
|
||||||
user.role = "admin"
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(User, user.id)
|
|
||||||
assert fetched.role == "admin"
|
|
||||||
|
|
||||||
user.role = "unprivileged"
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
assert (await session.get(User, user.id)).role == "unprivileged"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Cascading delete (manual, as we do it in the admin page) ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_user_cascades_devices(session):
|
|
||||||
user = User(email="cascade@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
d1 = Device(name="d1", public_key="pk-cascade-1", ipv4="10.0.0.1", user_id=user.id)
|
|
||||||
d2 = Device(name="d2", public_key="pk-cascade-2", ipv4="10.0.0.2", user_id=user.id)
|
|
||||||
session.add_all([d1, d2])
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Manually delete devices then user (matching admin page behavior)
|
|
||||||
devices = (await session.execute(select(Device).where(Device.user_id == user.id))).scalars().all()
|
|
||||||
for d in devices:
|
|
||||||
await session.delete(d)
|
|
||||||
await session.delete(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert (await session.execute(select(func.count()).select_from(Device).where(Device.user_id == user.id))).scalar() == 0
|
|
||||||
assert await session.get(User, user.id) is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_user_cascades_rules(session):
|
|
||||||
user = User(email="rule-cascade@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
rule = Rule(action="accept", destination="10.0.0.0/8", user_id=user.id)
|
|
||||||
session.add(rule)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Delete rules then user
|
|
||||||
rules = (await session.execute(select(Rule).where(Rule.user_id == user.id))).scalars().all()
|
|
||||||
for r in rules:
|
|
||||||
await session.delete(r)
|
|
||||||
await session.delete(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert (await session.execute(select(func.count()).select_from(Rule).where(Rule.user_id == user.id))).scalar() == 0
|
|
||||||
|
|
||||||
|
|
||||||
# --- Configuration singleton ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_configuration_create_and_update(session):
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert config.default_client_mtu == 1280
|
|
||||||
assert config.local_auth_enabled is True
|
|
||||||
|
|
||||||
config.default_client_mtu = 1400
|
|
||||||
config.local_auth_enabled = False
|
|
||||||
config.vpn_session_duration = 3600
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Configuration, config.id)
|
|
||||||
assert fetched.default_client_mtu == 1400
|
|
||||||
assert fetched.local_auth_enabled is False
|
|
||||||
assert fetched.vpn_session_duration == 3600
|
|
||||||
|
|
||||||
|
|
||||||
async def test_configuration_oidc_providers(session):
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert config.openid_connect_providers == []
|
|
||||||
|
|
||||||
providers = [
|
|
||||||
{
|
|
||||||
"id": "google",
|
|
||||||
"label": "Sign in with Google",
|
|
||||||
"scope": "openid email profile",
|
|
||||||
"response_type": "code",
|
|
||||||
"client_id": "google-client-id",
|
|
||||||
"client_secret": "google-secret",
|
|
||||||
"discovery_document_uri": "https://accounts.google.com/.well-known/openid-configuration",
|
|
||||||
"auto_create_users": True,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "okta",
|
|
||||||
"label": "Okta SSO",
|
|
||||||
"scope": "openid email profile",
|
|
||||||
"response_type": "code",
|
|
||||||
"client_id": "okta-client-id",
|
|
||||||
"client_secret": "okta-secret",
|
|
||||||
"discovery_document_uri": "https://dev-123.okta.com/.well-known/openid-configuration",
|
|
||||||
"auto_create_users": False,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
config.openid_connect_providers = providers
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Configuration, config.id)
|
|
||||||
assert len(fetched.openid_connect_providers) == 2
|
|
||||||
assert fetched.openid_connect_providers[0]["id"] == "google"
|
|
||||||
assert fetched.openid_connect_providers[1]["auto_create_users"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_configuration_update_client_defaults(session):
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
config.default_client_endpoint = "vpn.example.com"
|
|
||||||
config.default_client_dns = ["8.8.8.8", "8.8.4.4"]
|
|
||||||
config.default_client_allowed_ips = ["10.0.0.0/8"]
|
|
||||||
config.default_client_persistent_keepalive = 30
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Configuration, config.id)
|
|
||||||
assert fetched.default_client_endpoint == "vpn.example.com"
|
|
||||||
assert fetched.default_client_dns == ["8.8.8.8", "8.8.4.4"]
|
|
||||||
assert fetched.default_client_allowed_ips == ["10.0.0.0/8"]
|
|
||||||
assert fetched.default_client_persistent_keepalive == 30
|
|
||||||
|
|
||||||
|
|
||||||
async def test_configuration_security_toggles(session):
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
config.allow_unprivileged_device_management = False
|
|
||||||
config.allow_unprivileged_device_configuration = False
|
|
||||||
config.disable_vpn_on_oidc_error = True
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Configuration, config.id)
|
|
||||||
assert fetched.allow_unprivileged_device_management is False
|
|
||||||
assert fetched.allow_unprivileged_device_configuration is False
|
|
||||||
assert fetched.disable_vpn_on_oidc_error is True
|
|
||||||
|
|
||||||
|
|
||||||
# --- Device config overrides ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_device_with_custom_config(session):
|
|
||||||
user = User(email="config-user@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
device = Device(
|
|
||||||
name="custom-config",
|
|
||||||
public_key="pk-custom-config",
|
|
||||||
user_id=user.id,
|
|
||||||
use_default_dns=False,
|
|
||||||
use_default_endpoint=False,
|
|
||||||
use_default_mtu=False,
|
|
||||||
use_default_persistent_keepalive=False,
|
|
||||||
use_default_allowed_ips=False,
|
|
||||||
dns=["8.8.8.8"],
|
|
||||||
endpoint="custom-vpn.example.com",
|
|
||||||
mtu=1400,
|
|
||||||
persistent_keepalive=15,
|
|
||||||
allowed_ips=["10.0.0.0/8", "172.16.0.0/12"],
|
|
||||||
)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Device, device.id)
|
|
||||||
assert fetched.use_default_dns is False
|
|
||||||
assert fetched.dns == ["8.8.8.8"]
|
|
||||||
assert fetched.endpoint == "custom-vpn.example.com"
|
|
||||||
assert fetched.mtu == 1400
|
|
||||||
assert fetched.persistent_keepalive == 15
|
|
||||||
assert fetched.allowed_ips == ["10.0.0.0/8", "172.16.0.0/12"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_device_default_flags_are_true(session):
|
|
||||||
user = User(email="defaults@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
device = Device(name="defaults", public_key="pk-defaults", user_id=user.id)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(Device, device.id)
|
|
||||||
assert fetched.use_default_allowed_ips is True
|
|
||||||
assert fetched.use_default_dns is True
|
|
||||||
assert fetched.use_default_endpoint is True
|
|
||||||
assert fetched.use_default_mtu is True
|
|
||||||
assert fetched.use_default_persistent_keepalive is True
|
|
||||||
|
|
||||||
|
|
||||||
# --- User device count ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_user_device_count_query(session):
|
|
||||||
user = User(email="count-user@test.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
for i in range(3):
|
|
||||||
session.add(Device(name=f"d{i}", public_key=f"pk-count-{i}", user_id=user.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(Device).where(Device.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 3
|
|
||||||
183
tests/test_api_deps.py
Normal file
183
tests/test_api_deps.py
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
"""Tests for API dependency injection — Bearer token auth and admin guard."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from wiregui.auth.api_token import generate_api_token, resolve_bearer_token
|
||||||
|
from wiregui.auth.passwords import hash_password
|
||||||
|
from wiregui.models.api_token import ApiToken
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from wiregui.utils.time import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
# ========== resolve_bearer_token ==========
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_valid_token(session):
|
||||||
|
"""Valid, non-expired token resolves to user."""
|
||||||
|
plaintext, token_hash = generate_api_token()
|
||||||
|
|
||||||
|
user = User(email="api-test@test.com", password_hash=hash_password("x"), role="admin")
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
api_token = ApiToken(token_hash=token_hash, user_id=user.id, expires_at=utcnow() + timedelta(hours=1))
|
||||||
|
session.add(api_token)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
resolved = await resolve_bearer_token(session, plaintext)
|
||||||
|
assert resolved is not None
|
||||||
|
assert resolved.id == user.id
|
||||||
|
assert resolved.email == "api-test@test.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_expired_token(session):
|
||||||
|
"""Expired token returns None."""
|
||||||
|
plaintext, token_hash = generate_api_token()
|
||||||
|
|
||||||
|
user = User(email="api-expired@test.com", password_hash=hash_password("x"), role="admin")
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
api_token = ApiToken(token_hash=token_hash, user_id=user.id, expires_at=utcnow() - timedelta(hours=1))
|
||||||
|
session.add(api_token)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
resolved = await resolve_bearer_token(session, plaintext)
|
||||||
|
assert resolved is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_invalid_token(session):
|
||||||
|
"""Nonexistent token returns None."""
|
||||||
|
resolved = await resolve_bearer_token(session, "totally-bogus-token")
|
||||||
|
assert resolved is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_token_disabled_user(session):
|
||||||
|
"""Token for disabled user returns None."""
|
||||||
|
plaintext, token_hash = generate_api_token()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email="api-disabled@test.com", password_hash=hash_password("x"),
|
||||||
|
role="admin", disabled_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
api_token = ApiToken(token_hash=token_hash, user_id=user.id, expires_at=utcnow() + timedelta(hours=1))
|
||||||
|
session.add(api_token)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
resolved = await resolve_bearer_token(session, plaintext)
|
||||||
|
assert resolved is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_resolve_token_no_expiry(session):
|
||||||
|
"""Token without expires_at (never expires) resolves successfully."""
|
||||||
|
plaintext, token_hash = generate_api_token()
|
||||||
|
|
||||||
|
user = User(email="api-noexp@test.com", password_hash=hash_password("x"), role="admin")
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
api_token = ApiToken(token_hash=token_hash, user_id=user.id, expires_at=None)
|
||||||
|
session.add(api_token)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
resolved = await resolve_bearer_token(session, plaintext)
|
||||||
|
assert resolved is not None
|
||||||
|
assert resolved.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
# ========== get_current_api_user (via FastAPI deps) ==========
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_api_user_missing_header():
|
||||||
|
"""Missing Authorization header raises 401."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from wiregui.api.deps import get_current_api_user
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_api_user(request, session=AsyncMock())
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert "Missing" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_api_user_bad_scheme():
|
||||||
|
"""Non-Bearer auth scheme raises 401."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from wiregui.api.deps import get_current_api_user
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {"Authorization": "Basic dXNlcjpwYXNz"}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_api_user(request, session=AsyncMock())
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_api_user_invalid_token(session):
|
||||||
|
"""Valid Bearer scheme but bogus token raises 401."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from wiregui.api.deps import get_current_api_user
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {"Authorization": "Bearer bogus-token-value"}
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await get_current_api_user(request, session=session)
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
assert "Invalid" in exc_info.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_current_api_user_valid_token(session):
|
||||||
|
"""Valid Bearer token resolves to user."""
|
||||||
|
from wiregui.api.deps import get_current_api_user
|
||||||
|
|
||||||
|
plaintext, token_hash = generate_api_token()
|
||||||
|
|
||||||
|
user = User(email="api-dep-test@test.com", password_hash=hash_password("x"), role="admin")
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
api_token = ApiToken(token_hash=token_hash, user_id=user.id, expires_at=utcnow() + timedelta(hours=1))
|
||||||
|
session.add(api_token)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
request = MagicMock()
|
||||||
|
request.headers = {"Authorization": f"Bearer {plaintext}"}
|
||||||
|
|
||||||
|
resolved = await get_current_api_user(request, session=session)
|
||||||
|
assert resolved.id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
# ========== require_admin ==========
|
||||||
|
|
||||||
|
|
||||||
|
async def test_require_admin_allows_admin():
|
||||||
|
"""Admin user passes require_admin."""
|
||||||
|
from wiregui.api.deps import require_admin
|
||||||
|
|
||||||
|
admin_user = MagicMock(spec=User)
|
||||||
|
admin_user.role = "admin"
|
||||||
|
result = await require_admin(user=admin_user)
|
||||||
|
assert result == admin_user
|
||||||
|
|
||||||
|
|
||||||
|
async def test_require_admin_rejects_unprivileged():
|
||||||
|
"""Non-admin user gets 403."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from wiregui.api.deps import require_admin
|
||||||
|
|
||||||
|
regular_user = MagicMock(spec=User)
|
||||||
|
regular_user.role = "unprivileged"
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await require_admin(user=regular_user)
|
||||||
|
assert exc_info.value.status_code == 403
|
||||||
|
assert "Admin" in exc_info.value.detail
|
||||||
|
|
@ -1,325 +0,0 @@
|
||||||
"""Tests for REST API routes via httpx AsyncClient against the FastAPI app."""
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
from uuid import UUID, uuid4
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from httpx import ASGITransport, AsyncClient
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from wiregui.api.deps import get_current_api_user, get_db, require_admin
|
|
||||||
from wiregui.api.v0 import router as api_router
|
|
||||||
from wiregui.auth.api_token import generate_api_token
|
|
||||||
from wiregui.auth.passwords import hash_password
|
|
||||||
from wiregui.models.api_token import ApiToken
|
|
||||||
from wiregui.models.configuration import Configuration
|
|
||||||
from wiregui.models.device import Device
|
|
||||||
from wiregui.models.rule import Rule
|
|
||||||
from wiregui.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
def _build_app(session, admin_user=None, regular_user=None):
|
|
||||||
"""Build a test FastAPI app with overridden dependencies."""
|
|
||||||
test_app = FastAPI()
|
|
||||||
test_app.include_router(api_router, prefix="/api")
|
|
||||||
|
|
||||||
async def override_get_db():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
test_app.dependency_overrides[get_db] = override_get_db
|
|
||||||
|
|
||||||
if admin_user:
|
|
||||||
test_app.dependency_overrides[get_current_api_user] = lambda: admin_user
|
|
||||||
test_app.dependency_overrides[require_admin] = lambda: admin_user
|
|
||||||
|
|
||||||
return test_app
|
|
||||||
|
|
||||||
|
|
||||||
async def _make_admin(session) -> User:
|
|
||||||
user = User(email="api-admin@test.com", password_hash=hash_password("pw"), role="admin")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
async def _make_user(session, email="api-user@test.com") -> User:
|
|
||||||
user = User(email=email, password_hash=hash_password("pw"), role="unprivileged")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Users API ==========
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_users(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
await _make_user(session, "user1@test.com")
|
|
||||||
await _make_user(session, "user2@test.com")
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get("/api/v0/users/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert len(data) >= 3 # admin + 2 users
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_user(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get(f"/api/v0/users/{admin.id}")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["email"] == "api-admin@test.com"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_user_not_found(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get(f"/api/v0/users/{uuid4()}")
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_user(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.post("/api/v0/users/", json={
|
|
||||||
"email": "new-api-user@test.com",
|
|
||||||
"password": "secret123",
|
|
||||||
"role": "unprivileged",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["email"] == "new-api-user@test.com"
|
|
||||||
assert data["role"] == "unprivileged"
|
|
||||||
assert "id" in data
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_user(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.put(f"/api/v0/users/{user.id}", json={
|
|
||||||
"role": "admin",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["role"] == "admin"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_user_password(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.put(f"/api/v0/users/{user.id}", json={
|
|
||||||
"password": "new-password-123",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
|
|
||||||
from wiregui.auth.passwords import verify_password
|
|
||||||
refreshed = await session.get(User, user.id)
|
|
||||||
assert verify_password("new-password-123", refreshed.password_hash)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_user(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.delete(f"/api/v0/users/{user.id}")
|
|
||||||
assert resp.status_code == 204
|
|
||||||
|
|
||||||
assert await session.get(User, user.id) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Devices API ==========
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_devices_admin_sees_all(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session)
|
|
||||||
session.add(Device(name="d1", public_key="pk-api-d1", user_id=admin.id))
|
|
||||||
session.add(Device(name="d2", public_key="pk-api-d2", user_id=user.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get("/api/v0/devices/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert len(resp.json()) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_devices_user_sees_own(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session, "own-devices@test.com")
|
|
||||||
session.add(Device(name="mine", public_key="pk-api-mine", user_id=user.id))
|
|
||||||
session.add(Device(name="not-mine", public_key="pk-api-notmine", user_id=admin.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Override to be the regular user
|
|
||||||
test_app = _build_app(session)
|
|
||||||
test_app.dependency_overrides[get_current_api_user] = lambda: user
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client:
|
|
||||||
resp = await client.get("/api/v0/devices/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
names = [d["name"] for d in resp.json()]
|
|
||||||
assert "mine" in names
|
|
||||||
assert "not-mine" not in names
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_device(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
device = Device(name="detail", public_key="pk-api-detail", user_id=admin.id, ipv4="10.0.0.5")
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get(f"/api/v0/devices/{device.id}")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["name"] == "detail"
|
|
||||||
assert resp.json()["ipv4"] == "10.0.0.5"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_device_forbidden_for_other_user(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
user = await _make_user(session, "other-dev@test.com")
|
|
||||||
device = Device(name="admin-dev", public_key="pk-api-forbid", user_id=admin.id)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
test_app = _build_app(session)
|
|
||||||
test_app.dependency_overrides[get_current_api_user] = lambda: user
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=test_app), base_url="http://test") as client:
|
|
||||||
resp = await client.get(f"/api/v0/devices/{device.id}")
|
|
||||||
assert resp.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_device(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
device = Device(name="old-name", public_key="pk-api-update", user_id=admin.id)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.put(f"/api/v0/devices/{device.id}", json={"name": "new-name"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["name"] == "new-name"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_device(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
device = Device(name="to-delete", public_key="pk-api-del", user_id=admin.id)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
did = device.id
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.delete(f"/api/v0/devices/{did}")
|
|
||||||
assert resp.status_code == 204
|
|
||||||
|
|
||||||
assert await session.get(Device, did) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Rules API ==========
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_rules(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
session.add(Rule(action="accept", destination="10.0.0.0/8"))
|
|
||||||
session.add(Rule(action="drop", destination="192.168.0.0/16", user_id=admin.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get("/api/v0/rules/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert len(resp.json()) >= 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_rule(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.post("/api/v0/rules/", json={
|
|
||||||
"action": "accept",
|
|
||||||
"destination": "172.16.0.0/12",
|
|
||||||
"port_type": "tcp",
|
|
||||||
"port_range": "443",
|
|
||||||
})
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["action"] == "accept"
|
|
||||||
assert data["destination"] == "172.16.0.0/12"
|
|
||||||
assert data["port_type"] == "tcp"
|
|
||||||
assert data["port_range"] == "443"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_rule(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
rule = Rule(action="accept", destination="10.0.0.0/8")
|
|
||||||
session.add(rule)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.put(f"/api/v0/rules/{rule.id}", json={"action": "drop"})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json()["action"] == "drop"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_rule(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
rule = Rule(action="drop", destination="0.0.0.0/0")
|
|
||||||
session.add(rule)
|
|
||||||
await session.flush()
|
|
||||||
rid = rule.id
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.delete(f"/api/v0/rules/{rid}")
|
|
||||||
assert resp.status_code == 204
|
|
||||||
|
|
||||||
assert await session.get(Rule, rid) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Configuration API ==========
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_configuration_auto_creates(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.get("/api/v0/configuration/")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["default_client_mtu"] == 1280
|
|
||||||
assert data["local_auth_enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_configuration(session):
|
|
||||||
admin = await _make_admin(session)
|
|
||||||
# Pre-create config
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
app = _build_app(session, admin_user=admin)
|
|
||||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
||||||
resp = await client.put("/api/v0/configuration/", json={
|
|
||||||
"default_client_mtu": 1400,
|
|
||||||
"vpn_session_duration": 3600,
|
|
||||||
"default_client_dns": ["8.8.8.8"],
|
|
||||||
})
|
|
||||||
assert resp.status_code == 200
|
|
||||||
data = resp.json()
|
|
||||||
assert data["default_client_mtu"] == 1400
|
|
||||||
assert data["vpn_session_duration"] == 3600
|
|
||||||
assert data["default_client_dns"] == ["8.8.8.8"]
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests for authentication modules."""
|
"""Tests for authentication modules — seed logic and JWT edge cases."""
|
||||||
|
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
|
|
@ -8,17 +8,7 @@ from wiregui.auth.seed import seed_admin
|
||||||
from wiregui.models.user import User
|
from wiregui.models.user import User
|
||||||
|
|
||||||
|
|
||||||
# --- Password hashing ---
|
# --- Password hashing (format guard) ---
|
||||||
|
|
||||||
|
|
||||||
def test_hash_and_verify():
|
|
||||||
hashed = hash_password("my-secret")
|
|
||||||
assert verify_password("my-secret", hashed) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_wrong_password():
|
|
||||||
hashed = hash_password("correct")
|
|
||||||
assert verify_password("wrong", hashed) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_hash_is_not_plaintext():
|
def test_hash_is_not_plaintext():
|
||||||
|
|
@ -27,16 +17,7 @@ def test_hash_is_not_plaintext():
|
||||||
assert hashed.startswith("$2b$")
|
assert hashed.startswith("$2b$")
|
||||||
|
|
||||||
|
|
||||||
# --- JWT ---
|
# --- JWT edge cases ---
|
||||||
|
|
||||||
|
|
||||||
def test_create_and_decode_token():
|
|
||||||
token = create_access_token(user_id="user-123", role="admin")
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["sub"] == "user-123"
|
|
||||||
assert payload["role"] == "admin"
|
|
||||||
assert "exp" in payload
|
|
||||||
|
|
||||||
|
|
||||||
def test_decode_invalid_token():
|
def test_decode_invalid_token():
|
||||||
|
|
@ -54,8 +35,6 @@ def test_decode_tampered_token():
|
||||||
|
|
||||||
async def test_seed_admin_creates_user(session, monkeypatch):
|
async def test_seed_admin_creates_user(session, monkeypatch):
|
||||||
"""seed_admin should create an admin when no users exist."""
|
"""seed_admin should create an admin when no users exist."""
|
||||||
# Patch async_session to use our test session
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
|
||||||
|
|
@ -1,65 +1,9 @@
|
||||||
"""Extended auth tests — OIDC registration, WebAuthn options, session edge cases."""
|
"""Extended auth tests — OIDC registration, WebAuthn options, rule event handlers."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from wiregui.auth.passwords import hash_password
|
|
||||||
from wiregui.auth.session import authenticate_user
|
|
||||||
from wiregui.models.user import User
|
from wiregui.models.user import User
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
# ========== Session / authenticate_user edge cases ==========
|
|
||||||
|
|
||||||
|
|
||||||
async def test_authenticate_user_no_password_hash(session, monkeypatch):
|
|
||||||
"""Users without a password (OIDC-only) should not authenticate via password."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
user = User(email="no-pw@test.com", password_hash=None)
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result = await authenticate_user("no-pw@test.com", "anything")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_authenticate_user_disabled(session, monkeypatch):
|
|
||||||
"""Disabled users should not authenticate."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
user = User(email="disabled-auth@test.com", password_hash=hash_password("pw"), disabled_at=utcnow())
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result = await authenticate_user("disabled-auth@test.com", "pw")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_authenticate_user_nonexistent(session, monkeypatch):
|
|
||||||
"""Nonexistent email should return None."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
result = await authenticate_user("ghost@nowhere.com", "pw")
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
# ========== OIDC provider registration ==========
|
# ========== OIDC provider registration ==========
|
||||||
|
|
@ -163,13 +107,11 @@ async def test_on_rule_updated_triggers_rebuild(mock_fw, mock_settings):
|
||||||
from wiregui.models.rule import Rule
|
from wiregui.models.rule import Rule
|
||||||
from wiregui.services.events import on_rule_updated
|
from wiregui.services.events import on_rule_updated
|
||||||
|
|
||||||
# Need to mock the DB call inside _rebuild_user_chain
|
|
||||||
with patch("wiregui.services.events.async_session") as mock_session_factory:
|
with patch("wiregui.services.events.async_session") as mock_session_factory:
|
||||||
mock_session = AsyncMock()
|
mock_session = AsyncMock()
|
||||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
mock_session.__aexit__ = AsyncMock(return_value=False)
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
# Mock the select results
|
|
||||||
mock_rules_result = MagicMock()
|
mock_rules_result = MagicMock()
|
||||||
mock_rules_result.scalars.return_value.all.return_value = []
|
mock_rules_result.scalars.return_value.all.return_value = []
|
||||||
mock_devices_result = MagicMock()
|
mock_devices_result = MagicMock()
|
||||||
|
|
|
||||||
266
tests/test_firewall_extended.py
Normal file
266
tests/test_firewall_extended.py
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
"""Extended firewall tests — _nft/_nft_batch error handling, add_device_jump_rule edge cases, policies."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wiregui.services.firewall import (
|
||||||
|
_nft,
|
||||||
|
_nft_batch,
|
||||||
|
add_device_jump_rule,
|
||||||
|
rebuild_all_rules,
|
||||||
|
setup_base_tables,
|
||||||
|
setup_masquerade,
|
||||||
|
apply_peer_to_peer_policy,
|
||||||
|
apply_lan_to_peers_policy,
|
||||||
|
get_ruleset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== _nft error handling ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("asyncio.create_subprocess_exec")
|
||||||
|
async def test_nft_raises_on_failure(mock_exec):
|
||||||
|
"""_nft raises RuntimeError on non-zero exit code."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"", b"nft: error message")
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="nft.*failed"):
|
||||||
|
await _nft("list ruleset")
|
||||||
|
|
||||||
|
|
||||||
|
@patch("asyncio.create_subprocess_exec")
|
||||||
|
async def test_nft_returns_stdout_on_success(mock_exec):
|
||||||
|
"""_nft returns stdout on success."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"table inet wiregui {}", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
|
result = await _nft("list ruleset")
|
||||||
|
assert "wiregui" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ========== _nft_batch error handling ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("asyncio.create_subprocess_exec")
|
||||||
|
async def test_nft_batch_raises_on_failure(mock_exec):
|
||||||
|
"""_nft_batch raises RuntimeError on non-zero exit code."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"", b"Error: syntax error")
|
||||||
|
mock_proc.returncode = 1
|
||||||
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="nft batch failed"):
|
||||||
|
await _nft_batch(["add table inet wiregui"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch("asyncio.create_subprocess_exec")
|
||||||
|
async def test_nft_batch_sends_commands_via_stdin(mock_exec):
|
||||||
|
"""_nft_batch sends all commands via stdin to nft -f -."""
|
||||||
|
mock_proc = AsyncMock()
|
||||||
|
mock_proc.communicate.return_value = (b"", b"")
|
||||||
|
mock_proc.returncode = 0
|
||||||
|
mock_exec.return_value = mock_proc
|
||||||
|
|
||||||
|
cmds = ["add table inet wiregui", "add chain inet wiregui test"]
|
||||||
|
await _nft_batch(cmds)
|
||||||
|
|
||||||
|
mock_exec.assert_awaited_once()
|
||||||
|
# Verify nft -f - was called
|
||||||
|
call_args = mock_exec.call_args[0]
|
||||||
|
assert call_args == ("nft", "-f", "-")
|
||||||
|
# Verify stdin data
|
||||||
|
stdin_data = mock_proc.communicate.call_args[0][0]
|
||||||
|
assert b"add table inet wiregui" in stdin_data
|
||||||
|
assert b"add chain inet wiregui test" in stdin_data
|
||||||
|
|
||||||
|
|
||||||
|
# ========== add_device_jump_rule edge cases ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_add_device_jump_rule_ipv4_only(mock_batch):
|
||||||
|
"""Only IPv4 — generates single IPv4 jump rule."""
|
||||||
|
await add_device_jump_rule("user-id-1", "10.0.0.5", None)
|
||||||
|
mock_batch.assert_awaited_once()
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert len(cmds) == 1
|
||||||
|
assert "ip saddr 10.0.0.5" in cmds[0]
|
||||||
|
assert "jump" in cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_add_device_jump_rule_ipv6_only(mock_batch):
|
||||||
|
"""Only IPv6 — generates single IPv6 jump rule."""
|
||||||
|
await add_device_jump_rule("user-id-2", None, "fd00::5")
|
||||||
|
mock_batch.assert_awaited_once()
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert len(cmds) == 1
|
||||||
|
assert "ip6 saddr fd00::5" in cmds[0]
|
||||||
|
assert "jump" in cmds[0]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_add_device_jump_rule_no_ips(mock_batch):
|
||||||
|
"""Neither IPv4 nor IPv6 — no nft commands issued."""
|
||||||
|
await add_device_jump_rule("user-id-3", None, None)
|
||||||
|
mock_batch.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_add_device_jump_rule_both_ips(mock_batch):
|
||||||
|
"""Both IPv4 and IPv6 — generates two jump rules."""
|
||||||
|
await add_device_jump_rule("user-id-4", "10.0.0.7", "fd00::7")
|
||||||
|
mock_batch.assert_awaited_once()
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert len(cmds) == 2
|
||||||
|
assert any("ip saddr 10.0.0.7" in c for c in cmds)
|
||||||
|
assert any("ip6 saddr fd00::7" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== setup_base_tables — already exists ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_setup_base_tables_already_exists(mock_batch):
|
||||||
|
"""If table already exists (File exists error), don't raise."""
|
||||||
|
mock_batch.side_effect = RuntimeError("File exists")
|
||||||
|
await setup_base_tables() # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_setup_base_tables_other_error_raises(mock_batch):
|
||||||
|
"""Other nft errors should propagate."""
|
||||||
|
mock_batch.side_effect = RuntimeError("Permission denied")
|
||||||
|
with pytest.raises(RuntimeError, match="Permission denied"):
|
||||||
|
await setup_base_tables()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== setup_masquerade — error handling ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_setup_masquerade_error_swallowed(mock_batch):
|
||||||
|
"""Masquerade errors are logged but not raised."""
|
||||||
|
mock_batch.side_effect = RuntimeError("nft error")
|
||||||
|
await setup_masquerade(iface="wg0") # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ========== policy functions — command verification ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_peer_to_peer_enabled(mock_batch):
|
||||||
|
"""Enabling peer-to-peer generates accept rules."""
|
||||||
|
await apply_peer_to_peer_policy(True)
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert any("accept" in c for c in cmds)
|
||||||
|
assert any("peer_to_peer" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_peer_to_peer_disabled(mock_batch):
|
||||||
|
"""Disabling peer-to-peer generates drop rules."""
|
||||||
|
await apply_peer_to_peer_policy(False)
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert any("drop" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_lan_to_peers_enabled(mock_batch):
|
||||||
|
"""Enabling LAN-to-peers generates accept rules."""
|
||||||
|
await apply_lan_to_peers_policy(True)
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert any("accept" in c for c in cmds)
|
||||||
|
assert any("lan_to_peers" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
async def test_lan_to_peers_disabled(mock_batch):
|
||||||
|
"""Disabling LAN-to-peers generates drop rules."""
|
||||||
|
await apply_lan_to_peers_policy(False)
|
||||||
|
cmds = mock_batch.call_args[0][0]
|
||||||
|
assert any("drop" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== get_ruleset — error handling ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft", new_callable=AsyncMock)
|
||||||
|
async def test_get_ruleset_returns_output(mock_nft):
|
||||||
|
"""get_ruleset returns nft list ruleset output."""
|
||||||
|
mock_nft.return_value = "table inet wiregui { ... }"
|
||||||
|
result = await get_ruleset()
|
||||||
|
assert "wiregui" in result
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft", new_callable=AsyncMock)
|
||||||
|
async def test_get_ruleset_returns_fallback_on_error(mock_nft):
|
||||||
|
"""get_ruleset returns friendly message when nft not available."""
|
||||||
|
mock_nft.side_effect = RuntimeError("nft not found")
|
||||||
|
result = await get_ruleset()
|
||||||
|
assert "not available" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ========== rebuild_all_rules — orphan cleanup ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
@patch("wiregui.services.firewall._list_user_chains", new_callable=AsyncMock)
|
||||||
|
async def test_rebuild_removes_orphaned_user_chains(mock_list, mock_batch):
|
||||||
|
"""Orphaned user chains (in nft but not in DB) should be flushed and deleted."""
|
||||||
|
mock_list.return_value = {"user_aaaa00000000", "user_bbbb00000000"}
|
||||||
|
|
||||||
|
# Only user_aaaa is still in the DB
|
||||||
|
await rebuild_all_rules([{
|
||||||
|
"user_id": "aaaa0000-0000-0000-0000-000000000000",
|
||||||
|
"devices": [{"ipv4": "10.0.0.2", "ipv6": None}],
|
||||||
|
"rules": [],
|
||||||
|
}])
|
||||||
|
|
||||||
|
batch_cmds = mock_batch.call_args[0][0]
|
||||||
|
batch_text = "\n".join(batch_cmds)
|
||||||
|
# user_bbbb should be flushed and deleted
|
||||||
|
assert "flush chain inet wiregui user_bbbb00000000" in batch_text
|
||||||
|
assert "delete chain inet wiregui user_bbbb00000000" in batch_text
|
||||||
|
# user_aaaa should NOT be deleted
|
||||||
|
assert "delete chain inet wiregui user_aaaa00000000" not in batch_text
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
@patch("wiregui.services.firewall._list_user_chains", new_callable=AsyncMock)
|
||||||
|
async def test_rebuild_with_no_devices_clears_forward_and_orphans(mock_list, mock_batch):
|
||||||
|
"""With zero devices, forward chain should be flushed and all user chains removed."""
|
||||||
|
mock_list.return_value = {"user_aaaa00000000", "user_bbbb00000000"}
|
||||||
|
|
||||||
|
await rebuild_all_rules([])
|
||||||
|
|
||||||
|
batch_cmds = mock_batch.call_args[0][0]
|
||||||
|
batch_text = "\n".join(batch_cmds)
|
||||||
|
# Forward chain must be flushed even with no devices
|
||||||
|
assert "flush chain inet wiregui forward" in batch_text
|
||||||
|
# Both orphans removed
|
||||||
|
assert "delete chain inet wiregui user_aaaa00000000" in batch_text
|
||||||
|
assert "delete chain inet wiregui user_bbbb00000000" in batch_text
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
|
@patch("wiregui.services.firewall._list_user_chains", new_callable=AsyncMock)
|
||||||
|
async def test_rebuild_no_orphans_no_deletions(mock_list, mock_batch):
|
||||||
|
"""When all nft chains match the DB, no deletions should occur."""
|
||||||
|
mock_list.return_value = {"user_aaaa00000000"}
|
||||||
|
|
||||||
|
await rebuild_all_rules([{
|
||||||
|
"user_id": "aaaa0000-0000-0000-0000-000000000000",
|
||||||
|
"devices": [{"ipv4": "10.0.0.2", "ipv6": None}],
|
||||||
|
"rules": [],
|
||||||
|
}])
|
||||||
|
|
||||||
|
batch_cmds = mock_batch.call_args[0][0]
|
||||||
|
batch_text = "\n".join(batch_cmds)
|
||||||
|
assert "delete chain" not in batch_text
|
||||||
|
|
@ -1,239 +0,0 @@
|
||||||
"""Integration tests for MFA — full registration and authentication flows through the database."""
|
|
||||||
|
|
||||||
import pyotp
|
|
||||||
from sqlmodel import func, select
|
|
||||||
|
|
||||||
from wiregui.auth.mfa import generate_totp_secret, verify_totp_code
|
|
||||||
from wiregui.auth.passwords import hash_password, verify_password
|
|
||||||
from wiregui.auth.session import authenticate_user
|
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
|
||||||
from wiregui.models.user import User
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
async def test_full_totp_registration_flow(session, monkeypatch):
|
|
||||||
"""End-to-end: create user → generate secret → verify code → store method → re-verify from DB."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
# Create user with password
|
|
||||||
user = User(email="mfa-flow@example.com", password_hash=hash_password("secure123"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Step 1: Generate TOTP secret (happens in account page)
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
|
|
||||||
# Step 2: User scans QR, enters code from their authenticator
|
|
||||||
totp = pyotp.TOTP(secret)
|
|
||||||
code = totp.now()
|
|
||||||
|
|
||||||
# Step 3: Verify the code is correct before saving
|
|
||||||
assert verify_totp_code(secret, code) is True
|
|
||||||
|
|
||||||
# Step 4: Save the MFA method to DB
|
|
||||||
method = MFAMethod(
|
|
||||||
name="My Authenticator",
|
|
||||||
type="totp",
|
|
||||||
payload={"secret": secret},
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Step 5: Simulate future login — load method from DB and verify a fresh code
|
|
||||||
fetched_methods = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
assert len(fetched_methods) == 1
|
|
||||||
stored_secret = fetched_methods[0].payload["secret"]
|
|
||||||
fresh_code = pyotp.TOTP(stored_secret).now()
|
|
||||||
assert verify_totp_code(stored_secret, fresh_code) is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mfa_blocks_login_without_code(session, monkeypatch):
|
|
||||||
"""User with MFA should not be fully authenticated without completing MFA challenge."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
# Create user with MFA
|
|
||||||
user = User(email="mfa-block@example.com", password_hash=hash_password("password1"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
method = MFAMethod(name="Phone", type="totp", payload={"secret": secret}, user_id=user.id)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Password auth succeeds
|
|
||||||
authed_user = await authenticate_user("mfa-block@example.com", "password1")
|
|
||||||
assert authed_user is not None
|
|
||||||
|
|
||||||
# But MFA methods exist — login page would redirect to /mfa instead of completing login
|
|
||||||
mfa_methods = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == authed_user.id)
|
|
||||||
)).scalars().all()
|
|
||||||
assert len(mfa_methods) > 0 # Login flow would check this and redirect to /mfa
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mfa_wrong_code_rejected(session):
|
|
||||||
"""Wrong TOTP code should be rejected even if method is valid."""
|
|
||||||
user = User(email="mfa-wrong@example.com", password_hash=hash_password("pw"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
method = MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Load from DB and try wrong code
|
|
||||||
fetched = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
assert verify_totp_code(fetched.payload["secret"], "000000") is False
|
|
||||||
assert verify_totp_code(fetched.payload["secret"], "123456") is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mfa_multiple_methods_any_valid_code_works(session):
|
|
||||||
"""If user has multiple TOTP methods, a valid code from any should work."""
|
|
||||||
user = User(email="mfa-multi@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret1 = generate_totp_secret()
|
|
||||||
secret2 = generate_totp_secret()
|
|
||||||
|
|
||||||
session.add(MFAMethod(name="Phone", type="totp", payload={"secret": secret1}, user_id=user.id))
|
|
||||||
session.add(MFAMethod(name="Backup", type="totp", payload={"secret": secret2}, user_id=user.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
methods = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
# Code from method 1 should verify against method 1's secret
|
|
||||||
code1 = pyotp.TOTP(secret1).now()
|
|
||||||
verified = False
|
|
||||||
for m in methods:
|
|
||||||
if verify_totp_code(m.payload["secret"], code1):
|
|
||||||
verified = True
|
|
||||||
break
|
|
||||||
assert verified is True
|
|
||||||
|
|
||||||
# Code from method 2 should also work
|
|
||||||
code2 = pyotp.TOTP(secret2).now()
|
|
||||||
verified2 = False
|
|
||||||
for m in methods:
|
|
||||||
if verify_totp_code(m.payload["secret"], code2):
|
|
||||||
verified2 = True
|
|
||||||
break
|
|
||||||
assert verified2 is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mfa_method_last_used_tracking(session):
|
|
||||||
"""Verifying MFA should update last_used_at timestamp."""
|
|
||||||
user = User(email="mfa-tracking@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
method = MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
assert method.last_used_at is None
|
|
||||||
|
|
||||||
# Simulate successful verification and update
|
|
||||||
code = pyotp.TOTP(secret).now()
|
|
||||||
assert verify_totp_code(secret, code) is True
|
|
||||||
|
|
||||||
method.last_used_at = utcnow()
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(MFAMethod, method.id)
|
|
||||||
assert fetched.last_used_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mfa_delete_method_allows_login_without_mfa(session, monkeypatch):
|
|
||||||
"""After removing all MFA methods, user should not be redirected to MFA challenge."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
user = User(email="mfa-remove@example.com", password_hash=hash_password("pw"))
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
method = MFAMethod(name="Temp", type="totp", payload={"secret": secret}, user_id=user.id)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# MFA exists
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
# Delete it
|
|
||||||
await session.delete(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
# Password auth still works
|
|
||||||
authed = await authenticate_user("mfa-remove@example.com", "pw")
|
|
||||||
assert authed is not None
|
|
||||||
|
|
||||||
# No MFA methods — login flow would skip MFA challenge
|
|
||||||
mfa_check = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == authed.id)
|
|
||||||
)).scalars().all()
|
|
||||||
assert len(mfa_check) == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_disabled_user_with_mfa_cannot_login(session, monkeypatch):
|
|
||||||
"""Disabled user should be rejected at password stage, never reaching MFA."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.session.async_session", mock_session)
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
email="mfa-disabled@example.com",
|
|
||||||
password_hash=hash_password("pw"),
|
|
||||||
disabled_at=utcnow(),
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
session.add(MFAMethod(name="Auth", type="totp", payload={"secret": secret}, user_id=user.id))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Password auth rejects disabled user before MFA is ever checked
|
|
||||||
result = await authenticate_user("mfa-disabled@example.com", "pw")
|
|
||||||
assert result is None
|
|
||||||
|
|
@ -1,309 +0,0 @@
|
||||||
"""Integration tests for OIDC — mock provider endpoints, test full auth code flow."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from unittest.mock import patch
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import respx
|
|
||||||
from httpx import Response
|
|
||||||
from jose import jwt
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from wiregui.auth.oidc import get_provider_config, load_providers, oauth, register_providers
|
|
||||||
from wiregui.config import get_settings
|
|
||||||
from wiregui.models.configuration import Configuration
|
|
||||||
from wiregui.models.oidc_connection import OIDCConnection
|
|
||||||
from wiregui.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
# --- Helper to create a fake OIDC provider config in the DB ---
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup_oidc_config(session) -> Configuration:
|
|
||||||
"""Insert a Configuration with a test OIDC provider."""
|
|
||||||
config = Configuration(
|
|
||||||
openid_connect_providers=[
|
|
||||||
{
|
|
||||||
"id": "test-idp",
|
|
||||||
"label": "Test IdP",
|
|
||||||
"scope": "openid email profile",
|
|
||||||
"response_type": "code",
|
|
||||||
"client_id": "test-client-id",
|
|
||||||
"client_secret": "test-client-secret",
|
|
||||||
"discovery_document_uri": "https://idp.example.com/.well-known/openid-configuration",
|
|
||||||
"auto_create_users": True,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
session.add(config)
|
|
||||||
await session.commit()
|
|
||||||
return config
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_discovery():
|
|
||||||
"""Mock OIDC discovery document response."""
|
|
||||||
return {
|
|
||||||
"issuer": "https://idp.example.com",
|
|
||||||
"authorization_endpoint": "https://idp.example.com/authorize",
|
|
||||||
"token_endpoint": "https://idp.example.com/token",
|
|
||||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
|
||||||
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_token_response(email: str = "oidc-user@example.com"):
|
|
||||||
"""Mock OIDC token endpoint response with ID token."""
|
|
||||||
now = int(time.time())
|
|
||||||
id_token_payload = {
|
|
||||||
"iss": "https://idp.example.com",
|
|
||||||
"sub": "oidc-subject-123",
|
|
||||||
"aud": "test-client-id",
|
|
||||||
"email": email,
|
|
||||||
"name": "OIDC User",
|
|
||||||
"iat": now,
|
|
||||||
"exp": now + 3600,
|
|
||||||
"nonce": "test-nonce",
|
|
||||||
}
|
|
||||||
# Sign with a simple secret (in real life this would be RSA)
|
|
||||||
id_token = jwt.encode(id_token_payload, "fake-secret", algorithm="HS256")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"access_token": "mock-access-token",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600,
|
|
||||||
"refresh_token": "mock-refresh-token",
|
|
||||||
"id_token": id_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- Provider config loading ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_providers_from_config(session, monkeypatch):
|
|
||||||
"""Providers should be loaded from the Configuration table."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session)
|
|
||||||
|
|
||||||
await _setup_oidc_config(session)
|
|
||||||
|
|
||||||
providers = await load_providers()
|
|
||||||
assert len(providers) == 1
|
|
||||||
assert providers[0]["id"] == "test-idp"
|
|
||||||
assert providers[0]["client_id"] == "test-client-id"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_providers_empty_when_no_config(session, monkeypatch):
|
|
||||||
"""Should return empty list when no Configuration exists."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session)
|
|
||||||
|
|
||||||
providers = await load_providers()
|
|
||||||
assert providers == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_provider_config_by_id(session, monkeypatch):
|
|
||||||
"""Should find a specific provider by ID."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def mock_session():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
monkeypatch.setattr("wiregui.auth.oidc.async_session", mock_session)
|
|
||||||
|
|
||||||
await _setup_oidc_config(session)
|
|
||||||
|
|
||||||
config = await get_provider_config("test-idp")
|
|
||||||
assert config is not None
|
|
||||||
assert config["label"] == "Test IdP"
|
|
||||||
|
|
||||||
config_missing = await get_provider_config("nonexistent")
|
|
||||||
assert config_missing is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- OIDC connection storage ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_connection_created_on_login(session):
|
|
||||||
"""Simulates what the callback route does: create user + OIDC connection."""
|
|
||||||
user = User(email="oidc-new@example.com", role="unprivileged")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
token_data = _mock_token_response("oidc-new@example.com")
|
|
||||||
conn = OIDCConnection(
|
|
||||||
provider="test-idp",
|
|
||||||
refresh_token=token_data["refresh_token"],
|
|
||||||
refresh_response=token_data,
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Verify it was stored
|
|
||||||
fetched = (await session.execute(
|
|
||||||
select(OIDCConnection).where(OIDCConnection.user_id == user.id)
|
|
||||||
)).scalar_one()
|
|
||||||
assert fetched.provider == "test-idp"
|
|
||||||
assert fetched.refresh_token == "mock-refresh-token"
|
|
||||||
assert fetched.refresh_response["access_token"] == "mock-access-token"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_connection_updated_on_re_login(session):
|
|
||||||
"""Re-login should update the existing OIDC connection, not create a duplicate."""
|
|
||||||
user = User(email="oidc-relogin@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# First login
|
|
||||||
conn = OIDCConnection(
|
|
||||||
provider="test-idp",
|
|
||||||
refresh_token="old-refresh-token",
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Re-login — update existing connection (as the callback route does)
|
|
||||||
existing = (await session.execute(
|
|
||||||
select(OIDCConnection).where(
|
|
||||||
OIDCConnection.user_id == user.id,
|
|
||||||
OIDCConnection.provider == "test-idp",
|
|
||||||
)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
existing.refresh_token = "new-refresh-token"
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
existing.refreshed_at = utcnow()
|
|
||||||
session.add(existing)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Should still be one connection
|
|
||||||
from sqlmodel import func
|
|
||||||
count = (await session.execute(
|
|
||||||
select(func.count()).select_from(OIDCConnection).where(OIDCConnection.user_id == user.id)
|
|
||||||
)).scalar()
|
|
||||||
assert count == 1
|
|
||||||
|
|
||||||
fetched = (await session.execute(
|
|
||||||
select(OIDCConnection).where(OIDCConnection.user_id == user.id)
|
|
||||||
)).scalar_one()
|
|
||||||
assert fetched.refresh_token == "new-refresh-token"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_auto_create_user(session):
|
|
||||||
"""When auto_create_users is True, a new user should be created from OIDC email."""
|
|
||||||
email = "auto-created@example.com"
|
|
||||||
|
|
||||||
# Verify user doesn't exist
|
|
||||||
existing = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
||||||
assert existing is None
|
|
||||||
|
|
||||||
# Simulate what callback does with auto_create
|
|
||||||
user = User(email=email, role="unprivileged")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
user.last_signed_in_at = utcnow()
|
|
||||||
user.last_signed_in_method = "oidc:test-idp"
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
created = (await session.execute(select(User).where(User.email == email))).scalar_one()
|
|
||||||
assert created.role == "unprivileged"
|
|
||||||
assert created.last_signed_in_method == "oidc:test-idp"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_disabled_user_rejected(session):
|
|
||||||
"""Disabled users should not be logged in via OIDC."""
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
user = User(email="oidc-disabled@example.com", disabled_at=utcnow())
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# The callback route checks disabled_at before creating session
|
|
||||||
assert user.disabled_at is not None # Would redirect to /login
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_user_without_auto_create_rejected(session):
|
|
||||||
"""When auto_create is False and user doesn't exist, login should fail."""
|
|
||||||
email = "no-auto-create@example.com"
|
|
||||||
|
|
||||||
existing = (await session.execute(select(User).where(User.email == email))).scalar_one_or_none()
|
|
||||||
assert existing is None
|
|
||||||
|
|
||||||
# The callback route checks auto_create_users from provider config
|
|
||||||
# With auto_create=False and no existing user, it would redirect to /login
|
|
||||||
# This verifies the precondition
|
|
||||||
|
|
||||||
|
|
||||||
# --- OIDC refresh token flow ---
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_refresh_stores_new_token(session):
|
|
||||||
"""Simulates a successful token refresh updating the connection."""
|
|
||||||
user = User(email="oidc-refresh-test@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conn = OIDCConnection(
|
|
||||||
provider="test-idp",
|
|
||||||
refresh_token="old-refresh",
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# Simulate refresh result
|
|
||||||
new_token = {
|
|
||||||
"access_token": "new-access",
|
|
||||||
"refresh_token": "new-refresh",
|
|
||||||
"expires_in": 3600,
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.refresh_token = new_token.get("refresh_token", conn.refresh_token)
|
|
||||||
conn.refresh_response = new_token
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
conn.refreshed_at = utcnow()
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = await session.get(OIDCConnection, conn.id)
|
|
||||||
assert fetched.refresh_token == "new-refresh"
|
|
||||||
assert fetched.refresh_response["access_token"] == "new-access"
|
|
||||||
assert fetched.refreshed_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_oidc_multiple_providers_per_user(session):
|
|
||||||
"""User can have connections to multiple OIDC providers."""
|
|
||||||
user = User(email="multi-provider@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
for provider in ["google", "okta", "azure-ad"]:
|
|
||||||
session.add(OIDCConnection(
|
|
||||||
provider=provider,
|
|
||||||
refresh_token=f"token-{provider}",
|
|
||||||
user_id=user.id,
|
|
||||||
))
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conns = (await session.execute(
|
|
||||||
select(OIDCConnection).where(OIDCConnection.user_id == user.id).order_by(OIDCConnection.provider)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
assert len(conns) == 3
|
|
||||||
assert [c.provider for c in conns] == ["azure-ad", "google", "okta"]
|
|
||||||
|
|
@ -1,34 +1,6 @@
|
||||||
"""Tests for magic link authentication flow."""
|
"""Tests for magic link authentication — token subject validation."""
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from wiregui.auth.jwt import create_access_token, decode_access_token
|
from wiregui.auth.jwt import create_access_token, decode_access_token
|
||||||
from wiregui.auth.passwords import hash_password
|
|
||||||
from wiregui.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_token_creation():
|
|
||||||
"""Magic link token should be a valid JWT with short expiry."""
|
|
||||||
token = create_access_token(
|
|
||||||
user_id="user-123",
|
|
||||||
role="unprivileged",
|
|
||||||
expires_delta=timedelta(minutes=15),
|
|
||||||
)
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
assert payload is not None
|
|
||||||
assert payload["sub"] == "user-123"
|
|
||||||
assert payload["role"] == "unprivileged"
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_token_expired():
|
|
||||||
"""Expired magic link token should be rejected."""
|
|
||||||
token = create_access_token(
|
|
||||||
user_id="user-123",
|
|
||||||
role="admin",
|
|
||||||
expires_delta=timedelta(minutes=-1), # Already expired
|
|
||||||
)
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
assert payload is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_magic_link_token_wrong_user():
|
def test_magic_link_token_wrong_user():
|
||||||
|
|
@ -37,22 +9,3 @@ def test_magic_link_token_wrong_user():
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
assert payload["sub"] == "user-A"
|
assert payload["sub"] == "user-A"
|
||||||
# Caller is responsible for checking sub matches the URL user_id
|
# Caller is responsible for checking sub matches the URL user_id
|
||||||
|
|
||||||
|
|
||||||
async def test_magic_link_disabled_user_rejected(session):
|
|
||||||
"""Disabled users should not be able to use magic links."""
|
|
||||||
from wiregui.utils.time import utcnow
|
|
||||||
|
|
||||||
user = User(
|
|
||||||
email="disabled-magic@example.com",
|
|
||||||
password_hash=hash_password("pw"),
|
|
||||||
disabled_at=utcnow(),
|
|
||||||
)
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
# The token would be valid but the page handler checks disabled_at
|
|
||||||
token = create_access_token(user_id=str(user.id), role="unprivileged")
|
|
||||||
payload = decode_access_token(token)
|
|
||||||
assert payload is not None # Token itself is valid
|
|
||||||
assert user.disabled_at is not None # But user is disabled — handler would reject
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests for TOTP MFA functionality."""
|
"""Tests for TOTP MFA — URI format, edge cases, QR generation, DB relationships."""
|
||||||
|
|
||||||
import pyotp
|
import pyotp
|
||||||
|
|
||||||
|
|
@ -12,22 +12,7 @@ from wiregui.models.mfa_method import MFAMethod
|
||||||
from wiregui.models.user import User
|
from wiregui.models.user import User
|
||||||
|
|
||||||
|
|
||||||
# --- TOTP secret generation ---
|
# --- TOTP URI format ---
|
||||||
|
|
||||||
|
|
||||||
def test_generate_secret():
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
assert len(secret) == 32 # base32 encoded
|
|
||||||
assert secret.isalpha() or any(c.isdigit() for c in secret)
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_secret_unique():
|
|
||||||
s1 = generate_totp_secret()
|
|
||||||
s2 = generate_totp_secret()
|
|
||||||
assert s1 != s2
|
|
||||||
|
|
||||||
|
|
||||||
# --- TOTP URI ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_totp_uri():
|
def test_get_totp_uri():
|
||||||
|
|
@ -43,19 +28,7 @@ def test_get_totp_uri_custom_issuer():
|
||||||
assert "issuer=MyVPN" in uri
|
assert "issuer=MyVPN" in uri
|
||||||
|
|
||||||
|
|
||||||
# --- TOTP verification ---
|
# --- TOTP verification edge cases ---
|
||||||
|
|
||||||
|
|
||||||
def test_verify_valid_code():
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
totp = pyotp.TOTP(secret)
|
|
||||||
code = totp.now()
|
|
||||||
assert verify_totp_code(secret, code) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_invalid_code():
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
assert verify_totp_code(secret, "000000") is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_verify_wrong_secret():
|
def test_verify_wrong_secret():
|
||||||
|
|
@ -80,34 +53,7 @@ def test_generate_qr_svg():
|
||||||
assert "</svg>" in svg
|
assert "</svg>" in svg
|
||||||
|
|
||||||
|
|
||||||
# --- MFA method model integration ---
|
# --- MFA method DB relationships ---
|
||||||
|
|
||||||
|
|
||||||
async def test_create_totp_method(session):
|
|
||||||
user = User(email="mfa-test@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
secret = generate_totp_secret()
|
|
||||||
method = MFAMethod(
|
|
||||||
name="My Phone",
|
|
||||||
type="totp",
|
|
||||||
payload={"secret": secret},
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(method)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
from sqlmodel import select
|
|
||||||
fetched = (await session.execute(
|
|
||||||
select(MFAMethod).where(MFAMethod.user_id == user.id)
|
|
||||||
)).scalar_one()
|
|
||||||
|
|
||||||
assert fetched.name == "My Phone"
|
|
||||||
assert fetched.type == "totp"
|
|
||||||
stored_secret = fetched.payload["secret"]
|
|
||||||
code = pyotp.TOTP(stored_secret).now()
|
|
||||||
assert verify_totp_code(stored_secret, code) is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_user_multiple_mfa_methods(session):
|
async def test_user_multiple_mfa_methods(session):
|
||||||
|
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
"""Tests for SQLModel table definitions."""
|
|
||||||
|
|
||||||
import pytest # noqa: F401 — needed for pytest.raises
|
|
||||||
from sqlmodel import select
|
|
||||||
|
|
||||||
from wiregui.models.api_token import ApiToken
|
|
||||||
from wiregui.models.configuration import Configuration
|
|
||||||
from wiregui.models.connectivity_check import ConnectivityCheck
|
|
||||||
from wiregui.models.device import Device
|
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
|
||||||
from wiregui.models.oidc_connection import OIDCConnection
|
|
||||||
from wiregui.models.rule import Rule
|
|
||||||
from wiregui.models.user import User
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_user(session):
|
|
||||||
user = User(email="alice@example.com", role="admin")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result = await session.execute(select(User).where(User.email == "alice@example.com"))
|
|
||||||
fetched = result.scalar_one()
|
|
||||||
assert fetched.id == user.id
|
|
||||||
assert fetched.role == "admin"
|
|
||||||
assert fetched.disabled_at is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_device_with_user(session):
|
|
||||||
user = User(email="bob@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
device = Device(
|
|
||||||
name="laptop",
|
|
||||||
public_key="pk-test-device-001",
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(device)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result = await session.execute(select(Device).where(Device.public_key == "pk-test-device-001"))
|
|
||||||
fetched = result.scalar_one()
|
|
||||||
assert fetched.name == "laptop"
|
|
||||||
assert fetched.user_id == user.id
|
|
||||||
assert fetched.use_default_dns is True
|
|
||||||
assert fetched.use_default_allowed_ips is True
|
|
||||||
assert fetched.rx_bytes is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_device_unique_public_key(session):
|
|
||||||
user = User(email="carol@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
d1 = Device(name="d1", public_key="duplicate-key", user_id=user.id)
|
|
||||||
session.add(d1)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
d2 = Device(name="d2", public_key="duplicate-key", user_id=user.id)
|
|
||||||
session.add(d2)
|
|
||||||
with pytest.raises(Exception): # IntegrityError
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_rule(session):
|
|
||||||
user = User(email="dave@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
rule = Rule(action="accept", destination="10.0.0.0/8", user_id=user.id)
|
|
||||||
session.add(rule)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
result = await session.execute(select(Rule).where(Rule.user_id == user.id))
|
|
||||||
fetched = result.scalar_one()
|
|
||||||
assert fetched.action == "accept"
|
|
||||||
assert fetched.destination == "10.0.0.0/8"
|
|
||||||
assert fetched.port_type is None
|
|
||||||
assert fetched.port_range is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_rule_with_port(session):
|
|
||||||
rule = Rule(
|
|
||||||
action="drop",
|
|
||||||
destination="192.168.0.0/16",
|
|
||||||
port_type="tcp",
|
|
||||||
port_range="80-443",
|
|
||||||
)
|
|
||||||
session.add(rule)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(Rule).where(Rule.id == rule.id))).scalar_one()
|
|
||||||
assert fetched.port_type == "tcp"
|
|
||||||
assert fetched.port_range == "80-443"
|
|
||||||
assert fetched.user_id is None # global rule
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_mfa_method(session):
|
|
||||||
user = User(email="eve@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
mfa = MFAMethod(
|
|
||||||
name="My Authenticator",
|
|
||||||
type="totp",
|
|
||||||
payload={"secret": "JBSWY3DPEHPK3PXP"},
|
|
||||||
user_id=user.id,
|
|
||||||
)
|
|
||||||
session.add(mfa)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(MFAMethod).where(MFAMethod.user_id == user.id))).scalar_one()
|
|
||||||
assert fetched.type == "totp"
|
|
||||||
assert fetched.payload["secret"] == "JBSWY3DPEHPK3PXP"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_oidc_connection(session):
|
|
||||||
user = User(email="frank@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
conn = OIDCConnection(provider="google", refresh_token="tok_abc", user_id=user.id)
|
|
||||||
session.add(conn)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(OIDCConnection).where(OIDCConnection.user_id == user.id))).scalar_one()
|
|
||||||
assert fetched.provider == "google"
|
|
||||||
assert fetched.refresh_token == "tok_abc"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_api_token(session):
|
|
||||||
user = User(email="grace@example.com")
|
|
||||||
session.add(user)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
token = ApiToken(token_hash="sha256_fake_hash", user_id=user.id)
|
|
||||||
session.add(token)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(ApiToken).where(ApiToken.user_id == user.id))).scalar_one()
|
|
||||||
assert fetched.token_hash == "sha256_fake_hash"
|
|
||||||
assert fetched.expires_at is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_connectivity_check(session):
|
|
||||||
check = ConnectivityCheck(url="https://example.com", response_code=200)
|
|
||||||
session.add(check)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(ConnectivityCheck).where(ConnectivityCheck.id == check.id))).scalar_one()
|
|
||||||
assert fetched.response_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
async def test_configuration_defaults(session):
|
|
||||||
config = Configuration()
|
|
||||||
session.add(config)
|
|
||||||
await session.flush()
|
|
||||||
|
|
||||||
fetched = (await session.execute(select(Configuration).where(Configuration.id == config.id))).scalar_one()
|
|
||||||
assert fetched.allow_unprivileged_device_management is True
|
|
||||||
assert fetched.local_auth_enabled is True
|
|
||||||
assert fetched.default_client_mtu == 1280
|
|
||||||
assert fetched.default_client_persistent_keepalive == 25
|
|
||||||
assert fetched.default_client_dns == ["1.1.1.1", "1.0.0.1"]
|
|
||||||
assert fetched.default_client_allowed_ips == ["0.0.0.0/0", "::/0"]
|
|
||||||
assert fetched.vpn_session_duration == 0
|
|
||||||
assert fetched.openid_connect_providers == []
|
|
||||||
assert fetched.saml_identity_providers == []
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"""Tests for the notification service."""
|
|
||||||
|
|
||||||
from wiregui.services import notifications
|
|
||||||
|
|
||||||
|
|
||||||
def setup_function():
|
|
||||||
"""Clear notifications before each test."""
|
|
||||||
notifications.clear_all()
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_notification():
|
|
||||||
n = notifications.add("info", "Test message")
|
|
||||||
assert n.severity == "info"
|
|
||||||
assert n.message == "Test message"
|
|
||||||
assert n.user is None
|
|
||||||
assert n.id is not None
|
|
||||||
assert n.timestamp is not None
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_notification_with_user():
|
|
||||||
n = notifications.add("error", "Something broke", user="admin@example.com")
|
|
||||||
assert n.user == "admin@example.com"
|
|
||||||
assert n.severity == "error"
|
|
||||||
|
|
||||||
|
|
||||||
def test_current_returns_newest_first():
|
|
||||||
notifications.add("info", "First")
|
|
||||||
notifications.add("warning", "Second")
|
|
||||||
notifications.add("error", "Third")
|
|
||||||
|
|
||||||
current = notifications.current()
|
|
||||||
assert len(current) == 3
|
|
||||||
assert current[0].message == "Third"
|
|
||||||
assert current[1].message == "Second"
|
|
||||||
assert current[2].message == "First"
|
|
||||||
|
|
||||||
|
|
||||||
def test_count():
|
|
||||||
assert notifications.count() == 0
|
|
||||||
notifications.add("info", "One")
|
|
||||||
notifications.add("info", "Two")
|
|
||||||
assert notifications.count() == 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_specific():
|
|
||||||
n1 = notifications.add("info", "Keep this")
|
|
||||||
n2 = notifications.add("error", "Remove this")
|
|
||||||
|
|
||||||
notifications.clear(n2.id)
|
|
||||||
current = notifications.current()
|
|
||||||
assert len(current) == 1
|
|
||||||
assert current[0].id == n1.id
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_nonexistent_id_is_noop():
|
|
||||||
notifications.add("info", "Test")
|
|
||||||
notifications.clear("nonexistent-id")
|
|
||||||
assert notifications.count() == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_clear_all():
|
|
||||||
notifications.add("info", "One")
|
|
||||||
notifications.add("info", "Two")
|
|
||||||
notifications.add("info", "Three")
|
|
||||||
assert notifications.count() == 3
|
|
||||||
|
|
||||||
notifications.clear_all()
|
|
||||||
assert notifications.count() == 0
|
|
||||||
assert notifications.current() == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_to_dict():
|
|
||||||
n = notifications.add("warning", "Test dict", user="someone@example.com")
|
|
||||||
d = n.to_dict()
|
|
||||||
assert d["severity"] == "warning"
|
|
||||||
assert d["message"] == "Test dict"
|
|
||||||
assert d["user"] == "someone@example.com"
|
|
||||||
assert "id" in d
|
|
||||||
assert "timestamp" in d
|
|
||||||
|
|
||||||
|
|
||||||
def test_max_notifications():
|
|
||||||
"""Deque should cap at MAX_NOTIFICATIONS."""
|
|
||||||
for i in range(notifications.MAX_NOTIFICATIONS + 10):
|
|
||||||
notifications.add("info", f"Notification {i}")
|
|
||||||
|
|
||||||
assert notifications.count() == notifications.MAX_NOTIFICATIONS
|
|
||||||
# Newest should be the last one added
|
|
||||||
assert notifications.current()[0].message == f"Notification {notifications.MAX_NOTIFICATIONS + 9}"
|
|
||||||
60
tests/test_server_key.py
Normal file
60
tests/test_server_key.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Tests for server public key retrieval from Configuration table."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
from wiregui.utils.server_key import get_server_public_key
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_server_public_key_returns_key(session, monkeypatch):
|
||||||
|
"""Returns the public key when configured."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_session():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
monkeypatch.setattr("wiregui.utils.server_key.async_session", mock_session)
|
||||||
|
|
||||||
|
c = Configuration(server_public_key="TestServerPubKey123456789012345678901234w=")
|
||||||
|
session.add(c)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
result = await get_server_public_key()
|
||||||
|
assert result == "TestServerPubKey123456789012345678901234w="
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_server_public_key_raises_when_missing(session, monkeypatch):
|
||||||
|
"""Raises RuntimeError when server_public_key is None."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_session():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
monkeypatch.setattr("wiregui.utils.server_key.async_session", mock_session)
|
||||||
|
|
||||||
|
c = Configuration(server_public_key=None)
|
||||||
|
session.add(c)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="not configured"):
|
||||||
|
await get_server_public_key()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_server_public_key_raises_when_empty_string(session, monkeypatch):
|
||||||
|
"""Raises RuntimeError when server_public_key is empty string."""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def mock_session():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
monkeypatch.setattr("wiregui.utils.server_key.async_session", mock_session)
|
||||||
|
|
||||||
|
c = Configuration(server_public_key="")
|
||||||
|
session.add(c)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="not configured"):
|
||||||
|
await get_server_public_key()
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Tests for services — WireGuard and events."""
|
"""Tests for services — WireGuard event error handling and rule events."""
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
|
@ -20,53 +20,6 @@ def _make_device(**kwargs) -> Device:
|
||||||
return Device(**defaults)
|
return Device(**defaults)
|
||||||
|
|
||||||
|
|
||||||
# --- Events (with WG enabled) ---
|
|
||||||
|
|
||||||
|
|
||||||
@patch("wiregui.services.events.get_settings")
|
|
||||||
@patch("wiregui.services.events.firewall")
|
|
||||||
@patch("wiregui.services.events.wireguard")
|
|
||||||
async def test_on_device_created_calls_add_peer(mock_wg, mock_fw, mock_settings):
|
|
||||||
mock_settings.return_value.wg_enabled = True
|
|
||||||
mock_wg.add_peer = AsyncMock()
|
|
||||||
mock_fw.add_user_chain = AsyncMock()
|
|
||||||
mock_fw.add_device_jump_rule = AsyncMock()
|
|
||||||
|
|
||||||
device = _make_device()
|
|
||||||
await on_device_created(device)
|
|
||||||
|
|
||||||
mock_wg.add_peer.assert_awaited_once_with(
|
|
||||||
public_key="pk-test",
|
|
||||||
allowed_ips=["10.3.2.5/32", "fd00::3:2:5/128"],
|
|
||||||
preshared_key="psk-test",
|
|
||||||
)
|
|
||||||
mock_fw.add_device_jump_rule.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@patch("wiregui.services.events.get_settings")
|
|
||||||
@patch("wiregui.services.events.wireguard")
|
|
||||||
async def test_on_device_deleted_calls_remove_peer(mock_wg, mock_settings):
|
|
||||||
mock_settings.return_value.wg_enabled = True
|
|
||||||
mock_wg.remove_peer = AsyncMock()
|
|
||||||
|
|
||||||
device = _make_device()
|
|
||||||
await on_device_deleted(device)
|
|
||||||
|
|
||||||
mock_wg.remove_peer.assert_awaited_once_with(public_key="pk-test")
|
|
||||||
|
|
||||||
|
|
||||||
@patch("wiregui.services.events.get_settings")
|
|
||||||
@patch("wiregui.services.events.wireguard")
|
|
||||||
async def test_on_device_updated_calls_add_peer(mock_wg, mock_settings):
|
|
||||||
mock_settings.return_value.wg_enabled = True
|
|
||||||
mock_wg.add_peer = AsyncMock()
|
|
||||||
|
|
||||||
device = _make_device()
|
|
||||||
await on_device_updated(device)
|
|
||||||
|
|
||||||
mock_wg.add_peer.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
# --- Events (WG disabled) ---
|
# --- Events (WG disabled) ---
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,9 @@ async def test_apply_rule(mock_batch):
|
||||||
assert any("10.0.0.0/8" in c and "accept" in c and "tcp dport 80-443" in c for c in cmds)
|
assert any("10.0.0.0/8" in c and "accept" in c and "tcp dport 80-443" in c for c in cmds)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.firewall._list_user_chains", new_callable=AsyncMock, return_value=set())
|
||||||
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
@patch("wiregui.services.firewall._nft_batch", new_callable=AsyncMock)
|
||||||
async def test_rebuild_all_rules(mock_batch):
|
async def test_rebuild_all_rules(mock_batch, mock_list):
|
||||||
from wiregui.services.firewall import rebuild_all_rules
|
from wiregui.services.firewall import rebuild_all_rules
|
||||||
await rebuild_all_rules([
|
await rebuild_all_rules([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -104,12 +104,7 @@ def test_build_client_config_no_psk():
|
||||||
|
|
||||||
|
|
||||||
def test_generate_keypair():
|
def test_generate_keypair():
|
||||||
"""Test keypair generation — requires `wg` CLI to be installed."""
|
"""Test keypair generation (pure Python, no wg CLI needed)."""
|
||||||
try:
|
|
||||||
subprocess.run(["wg", "--version"], capture_output=True, check=True)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pytest.skip("wg CLI not installed")
|
|
||||||
|
|
||||||
from wiregui.utils.crypto import generate_keypair, generate_preshared_key
|
from wiregui.utils.crypto import generate_keypair, generate_preshared_key
|
||||||
|
|
||||||
priv, pub = generate_keypair()
|
priv, pub = generate_keypair()
|
||||||
|
|
@ -118,3 +113,6 @@ def test_generate_keypair():
|
||||||
|
|
||||||
psk = generate_preshared_key()
|
psk = generate_preshared_key()
|
||||||
assert len(psk) == 44
|
assert len(psk) == 44
|
||||||
|
|
||||||
|
psk = generate_preshared_key()
|
||||||
|
assert len(psk) == 44
|
||||||
|
|
|
||||||
114
tests/test_wireguard_extended.py
Normal file
114
tests/test_wireguard_extended.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""Tests for WireGuard service — ensure_interface, set_private_key, set_listen_port, configure_interface."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch, call
|
||||||
|
|
||||||
|
from wiregui.services.wireguard import (
|
||||||
|
ensure_interface,
|
||||||
|
set_private_key,
|
||||||
|
set_listen_port,
|
||||||
|
configure_interface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ========== ensure_interface ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
async def test_ensure_interface_already_exists(mock_run):
|
||||||
|
"""If interface exists (ip link show succeeds), do nothing."""
|
||||||
|
mock_run.return_value = ""
|
||||||
|
await ensure_interface(iface="wg-test")
|
||||||
|
# Only called once for ip link show
|
||||||
|
mock_run.assert_awaited_once_with(["ip", "link", "show", "wg-test"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
async def test_ensure_interface_creates_new(mock_run):
|
||||||
|
"""If interface doesn't exist, create it, assign IPs, bring up."""
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def side_effect(args, input_data=None):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
if call_count == 1 and args == ["ip", "link", "show", "wg-test"]:
|
||||||
|
raise RuntimeError("Device not found")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
mock_run.side_effect = side_effect
|
||||||
|
await ensure_interface(iface="wg-test")
|
||||||
|
|
||||||
|
# Should have called: ip link show (fails), ip link add, ip addr add x2, ip link set up
|
||||||
|
assert mock_run.await_count == 5
|
||||||
|
calls = [c[0][0] for c in mock_run.call_args_list]
|
||||||
|
assert calls[1] == ["ip", "link", "add", "wg-test", "type", "wireguard"]
|
||||||
|
assert calls[2][0:3] == ["ip", "address", "add"]
|
||||||
|
assert calls[3][0:3] == ["ip", "address", "add"]
|
||||||
|
assert calls[4] == ["ip", "link", "set", "wg-test", "up"]
|
||||||
|
|
||||||
|
|
||||||
|
# ========== set_private_key ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
async def test_set_private_key(mock_run):
|
||||||
|
"""set_private_key calls wg set with private-key path."""
|
||||||
|
mock_run.return_value = ""
|
||||||
|
await set_private_key("/tmp/test.key", iface="wg-test")
|
||||||
|
mock_run.assert_awaited_once_with(["wg", "set", "wg-test", "private-key", "/tmp/test.key"])
|
||||||
|
|
||||||
|
|
||||||
|
# ========== set_listen_port ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
async def test_set_listen_port(mock_run):
|
||||||
|
"""set_listen_port calls wg set with listen-port."""
|
||||||
|
mock_run.return_value = ""
|
||||||
|
await set_listen_port(51820, iface="wg-test")
|
||||||
|
mock_run.assert_awaited_once_with(["wg", "set", "wg-test", "listen-port", "51820"])
|
||||||
|
|
||||||
|
|
||||||
|
# ========== configure_interface ==========
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
@patch("wiregui.db.async_session")
|
||||||
|
async def test_configure_interface_no_config(mock_session_cls, mock_run):
|
||||||
|
"""If no Configuration row exists, do not call wg set."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = None
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
mock_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
await configure_interface(iface="wg-test")
|
||||||
|
mock_run.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("wiregui.services.wireguard._run", new_callable=AsyncMock)
|
||||||
|
@patch("wiregui.db.async_session")
|
||||||
|
async def test_configure_interface_sets_key_and_port(mock_session_cls, mock_run):
|
||||||
|
"""With valid config, writes key to temp file and calls wg set."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
mock_config = MagicMock()
|
||||||
|
mock_config.server_private_key = "test-private-key-value"
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.scalar_one_or_none.return_value = mock_config
|
||||||
|
mock_session.execute.return_value = mock_result
|
||||||
|
mock_session_cls.return_value.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_run.return_value = ""
|
||||||
|
await configure_interface(iface="wg-test")
|
||||||
|
|
||||||
|
mock_run.assert_awaited_once()
|
||||||
|
args = mock_run.call_args[0][0]
|
||||||
|
assert args[0:3] == ["wg", "set", "wg-test"]
|
||||||
|
assert "private-key" in args
|
||||||
|
assert "listen-port" in args
|
||||||
356
uv.lock
generated
356
uv.lock
generated
|
|
@ -358,16 +358,85 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.3.1"
|
version = "8.1.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click-option-group"
|
||||||
|
version = "0.5.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/ff/d291d66595b30b83d1cb9e314b2c9be7cfc7327d4a0d40a15da2416ea97b/click_option_group-0.5.9.tar.gz", hash = "sha256:f94ed2bc4cf69052e0f29592bd1e771a1789bd7bfc482dd0bc482134aff95823", size = 22222, upload-time = "2025-10-09T09:38:01.474Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/45/54bb2d8d4138964a94bef6e9afe48b0be4705ba66ac442ae7d8a8dc4ffef/click_option_group-0.5.9-py3-none-any.whl", hash = "sha256:ad2599248bd373e2e19bec5407967c3eec1d0d4fc4a5e77b08a0481e75991080", size = 11553, upload-time = "2025-10-09T09:38:00.066Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -501,6 +570,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deprecated"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wrapt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "docutils"
|
name = "docutils"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
|
|
@ -510,6 +591,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotty-dict"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6a/ab/88d67f02024700b48cd8232579ad1316aa9df2272c63049c27cc094229d6/dotty_dict-1.3.1.tar.gz", hash = "sha256:4b016e03b8ae265539757a53eba24b9bfda506fb94fbce0bee843c6f05541a15", size = 7699, upload-time = "2022-07-09T18:50:57.727Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/91/e0d457ee03ec33d79ee2cd8d212debb1bc21dfb99728ae35efdb5832dc22/dotty_dict-1.3.1-py3-none-any.whl", hash = "sha256:5022d234d9922f13aa711b4950372a06a6d64cb6d6db9ba43d0ba133ebfce31f", size = 7014, upload-time = "2022-07-09T18:50:55.058Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.19.2"
|
version = "0.19.2"
|
||||||
|
|
@ -611,6 +701,30 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gitdb"
|
||||||
|
version = "4.0.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "smmap" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gitpython"
|
||||||
|
version = "3.1.46"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "gitdb" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.2"
|
version = "3.3.2"
|
||||||
|
|
@ -620,6 +734,7 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
|
||||||
|
|
@ -628,6 +743,7 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
|
||||||
|
|
@ -636,6 +752,7 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
|
||||||
|
|
@ -719,6 +836,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "importlib-resources"
|
||||||
|
version = "6.5.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
|
|
@ -857,6 +983,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown2"
|
name = "markdown2"
|
||||||
version = "2.5.5"
|
version = "2.5.5"
|
||||||
|
|
@ -918,6 +1056,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
version = "6.7.1"
|
version = "6.7.1"
|
||||||
|
|
@ -1137,6 +1284,25 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "playwright"
|
||||||
|
version = "1.58.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet" },
|
||||||
|
{ name = "pyee" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
|
|
@ -1315,6 +1481,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyee"
|
||||||
|
version = "13.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
|
|
@ -1408,6 +1586,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/54/0cce26da03a981f949bb8449c9778537f75f5917c172e1d2992ff25cb57d/python_engineio-4.13.1-py3-none-any.whl", hash = "sha256:f32ad10589859c11053ad7d9bb3c9695cdf862113bfb0d20bc4d890198287399", size = 59847, upload-time = "2026-02-06T23:38:04.861Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-gitlab"
|
||||||
|
version = "6.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "requests-toolbelt" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9a/bd/b30f1d3b303cb5d3c72e2d57a847d699e8573cbdfd67ece5f1795e49da1c/python_gitlab-6.5.0.tar.gz", hash = "sha256:97553652d94b02de343e9ca92782239aa2b5f6594c5482331a9490d9d5e8737d", size = 400591, upload-time = "2025-10-17T21:40:02.89Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/bd/b0d440685fbcafee462bed793a74aea88541887c4c30556a55ac64914b8d/python_gitlab-6.5.0-py3-none-any.whl", hash = "sha256:494e1e8e5edd15286eaf7c286f3a06652688f1ee20a49e2a0218ddc5cc475e32", size = 144419, upload-time = "2025-10-17T21:40:01.233Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-jose"
|
name = "python-jose"
|
||||||
version = "3.5.0"
|
version = "3.5.0"
|
||||||
|
|
@ -1436,6 +1627,30 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-semantic-release"
|
||||||
|
version = "10.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "click-option-group" },
|
||||||
|
{ name = "deprecated" },
|
||||||
|
{ name = "dotty-dict" },
|
||||||
|
{ name = "gitpython" },
|
||||||
|
{ name = "importlib-resources" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "python-gitlab" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "shellingham" },
|
||||||
|
{ name = "tomlkit" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/3a/7332b822825ed0e902c6e950e0d1e90e8f666fd12eb27855d1c8b6677eff/python_semantic_release-10.5.3.tar.gz", hash = "sha256:de4da78635fa666e5774caaca2be32063cae72431eb75e2ac23b9f2dfd190785", size = 618034, upload-time = "2025-12-14T22:37:29.782Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/01/ada29a1215df601bded0a2efd3b6d53864a0a9e0a9ea52aeaebe14fd03fd/python_semantic_release-10.5.3-py3-none-any.whl", hash = "sha256:1be0e07c36fa1f1ec9da4f438c1f6bbd7bc10eb0d6ac0089b0643103708c2823", size = 152716, upload-time = "2025-12-14T22:37:28.089Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-socketio"
|
name = "python-socketio"
|
||||||
version = "5.16.1"
|
version = "5.16.1"
|
||||||
|
|
@ -1530,6 +1745,33 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/3a/95deec7db1eb53979973ebd156f3369a72732208d1391cd2e5d127062a32/redis-7.4.0-py3-none-any.whl", hash = "sha256:a9c74a5c893a5ef8455a5adb793a31bb70feb821c86eccb62eebef5a19c429ec", size = 409772, upload-time = "2026-03-24T09:14:35.968Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.33.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests-toolbelt"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "respx"
|
name = "respx"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
|
|
@ -1542,6 +1784,19 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/67/afbb0978d5399bc9ea200f1d4489a23c9a1dad4eee6376242b8182389c79/respx-0.22.0-py2.py3-none-any.whl", hash = "sha256:631128d4c9aba15e56903fb5f66fb1eff412ce28dd387ca3a81339e52dbd3ad0", size = 25127, upload-time = "2024-12-19T22:33:57.837Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rich"
|
||||||
|
version = "14.3.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "4.9.1"
|
version = "4.9.1"
|
||||||
|
|
@ -1554,6 +1809,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shellingham"
|
||||||
|
version = "1.5.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple-websocket"
|
name = "simple-websocket"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -1575,6 +1839,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "smmap"
|
||||||
|
version = "5.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.48"
|
version = "2.0.48"
|
||||||
|
|
@ -1639,6 +1912,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomlkit"
|
||||||
|
version = "0.13.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|
@ -1660,6 +1942,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.42.0"
|
version = "0.42.0"
|
||||||
|
|
@ -1845,6 +2136,7 @@ dependencies = [
|
||||||
{ name = "pyotp" },
|
{ name = "pyotp" },
|
||||||
{ name = "python-jose", extra = ["cryptography"] },
|
{ name = "python-jose", extra = ["cryptography"] },
|
||||||
{ name = "python3-saml" },
|
{ name = "python3-saml" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
{ name = "qrcode", extra = ["pil"] },
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
{ name = "redis" },
|
{ name = "redis" },
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
|
|
@ -1853,9 +2145,11 @@ dependencies = [
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "playwright" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "python-semantic-release" },
|
||||||
{ name = "respx" },
|
{ name = "respx" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1874,6 +2168,7 @@ requires-dist = [
|
||||||
{ name = "pyotp", specifier = ">=2.9" },
|
{ name = "pyotp", specifier = ">=2.9" },
|
||||||
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
|
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3" },
|
||||||
{ name = "python3-saml", specifier = ">=1.16" },
|
{ name = "python3-saml", specifier = ">=1.16" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
|
||||||
{ name = "redis", specifier = ">=5.2" },
|
{ name = "redis", specifier = ">=5.2" },
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
{ name = "sqlmodel", specifier = ">=0.0.22" },
|
||||||
|
|
@ -1882,12 +2177,67 @@ requires-dist = [
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
|
{ name = "playwright", specifier = ">=1.58.0" },
|
||||||
{ name = "pytest", specifier = ">=8.0" },
|
{ name = "pytest", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
{ name = "pytest-asyncio", specifier = ">=0.24" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
|
{ name = "python-semantic-release", specifier = ">=9.0" },
|
||||||
{ name = "respx", specifier = ">=0.22.0" },
|
{ name = "respx", specifier = ">=0.22.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wrapt"
|
||||||
|
version = "2.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wsproto"
|
name = "wsproto"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ def _build_saml_settings(provider_config: dict) -> dict:
|
||||||
idp_settings = idp_data.get("idp", {})
|
idp_settings = idp_data.get("idp", {})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"strict": True,
|
"strict": provider_config.get("strict", True),
|
||||||
"debug": False,
|
"debug": False,
|
||||||
"sp": {
|
"sp": {
|
||||||
"entityId": f"{base_url}/auth/saml/{provider_config['id']}/metadata",
|
"entityId": f"{base_url}/auth/saml/{provider_config['id']}/metadata",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"""Seed the initial admin user and server keypair on first startup."""
|
"""Seed the initial admin user and server keypair on first startup."""
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
|
|
@ -59,3 +61,76 @@ async def ensure_server_keypair() -> None:
|
||||||
logger.info("Server WireGuard keypair generated (pubkey: {}...)", public_key[:20])
|
logger.info("Server WireGuard keypair generated (pubkey: {}...)", public_key[:20])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not generate server keypair (wg CLI not available?): {}", e)
|
logger.warning("Could not generate server keypair (wg CLI not available?): {}", e)
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_providers(existing: list[dict], incoming: list[dict], kind: str) -> list[dict]:
|
||||||
|
"""Merge incoming providers into existing by `id`, returning the updated list.
|
||||||
|
|
||||||
|
Providers in `incoming` overwrite existing entries with the same `id`.
|
||||||
|
Existing providers not present in `incoming` are preserved.
|
||||||
|
"""
|
||||||
|
by_id = {p["id"]: p for p in existing}
|
||||||
|
for p in incoming:
|
||||||
|
pid = p.get("id")
|
||||||
|
if not pid:
|
||||||
|
logger.warning("Skipping {} provider without 'id' in IdP config file", kind)
|
||||||
|
continue
|
||||||
|
action = "updated" if pid in by_id else "added"
|
||||||
|
by_id[pid] = p
|
||||||
|
logger.info("IdP seed: {} {} provider '{}'", action, kind, pid)
|
||||||
|
return list(by_id.values())
|
||||||
|
|
||||||
|
|
||||||
|
async def seed_idp_providers() -> None:
|
||||||
|
"""Seed OIDC/SAML providers from a YAML config file (if configured).
|
||||||
|
|
||||||
|
Reads WG_IDP_CONFIG_FILE, parses the YAML, and upserts providers into the
|
||||||
|
Configuration singleton by `id`. Providers not in the YAML are preserved.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.idp_config_file:
|
||||||
|
return
|
||||||
|
|
||||||
|
path = Path(settings.idp_config_file)
|
||||||
|
if not path.is_file():
|
||||||
|
logger.warning("IdP config file not found: {}", path)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(path.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to parse IdP config file {}: {}", path, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
logger.error("IdP config file must be a YAML mapping, got {}", type(data).__name__)
|
||||||
|
return
|
||||||
|
|
||||||
|
oidc_incoming = data.get("openid_connect_providers") or []
|
||||||
|
saml_incoming = data.get("saml_identity_providers") or []
|
||||||
|
|
||||||
|
if not oidc_incoming and not saml_incoming:
|
||||||
|
logger.debug("IdP config file has no providers defined, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(select(Configuration).limit(1))
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if config is None:
|
||||||
|
config = Configuration()
|
||||||
|
session.add(config)
|
||||||
|
|
||||||
|
if oidc_incoming:
|
||||||
|
config.openid_connect_providers = _upsert_providers(
|
||||||
|
config.openid_connect_providers or [], oidc_incoming, "OIDC"
|
||||||
|
)
|
||||||
|
|
||||||
|
if saml_incoming:
|
||||||
|
config.saml_identity_providers = _upsert_providers(
|
||||||
|
config.saml_identity_providers or [], saml_incoming, "SAML"
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(config)
|
||||||
|
await session.commit()
|
||||||
|
logger.info("IdP providers seeded from {}", path)
|
||||||
|
|
|
||||||
176
wiregui/collector.py
Normal file
176
wiregui/collector.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""WireGuard metrics collector — standalone process for high-frequency stats polling.
|
||||||
|
|
||||||
|
Run as: python -m wiregui.collector
|
||||||
|
|
||||||
|
Polls `wg show <iface> dump` at a configurable interval, updates device rows
|
||||||
|
in PostgreSQL, and optionally pushes Prometheus-format metrics to VictoriaMetrics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.config import get_settings
|
||||||
|
from wiregui.db import async_session, engine
|
||||||
|
from wiregui.log_config import setup_logging
|
||||||
|
from wiregui.models.device import Device
|
||||||
|
from wiregui.models.user import User
|
||||||
|
from wiregui.services.wireguard import PeerInfo, get_peers
|
||||||
|
|
||||||
|
_shutdown = asyncio.Event()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal() -> None:
|
||||||
|
logger.info("Shutdown signal received")
|
||||||
|
_shutdown.set()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_db(peers: list[PeerInfo]) -> dict[str, dict]:
|
||||||
|
"""Update device rows in DB and return metadata for metrics labels.
|
||||||
|
|
||||||
|
Returns: {public_key: {"device_name": ..., "user_email": ...}}
|
||||||
|
"""
|
||||||
|
if not peers:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
peer_map = {p.public_key: p for p in peers}
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Device, User.email).join(User).where(
|
||||||
|
Device.public_key.in_(list(peer_map.keys()))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
labels = {}
|
||||||
|
updated = 0
|
||||||
|
for device, user_email in rows:
|
||||||
|
peer = peer_map.get(device.public_key)
|
||||||
|
if peer is None:
|
||||||
|
continue
|
||||||
|
# Only write connection status to PostgreSQL — traffic metrics go to VictoriaMetrics
|
||||||
|
device.latest_handshake = peer.latest_handshake
|
||||||
|
device.remote_ip = peer.endpoint.split(":")[0] if peer.endpoint else None
|
||||||
|
device.rx_bytes = peer.rx_bytes
|
||||||
|
device.tx_bytes = peer.tx_bytes
|
||||||
|
session.add(device)
|
||||||
|
updated += 1
|
||||||
|
labels[device.public_key] = {
|
||||||
|
"device_name": device.name,
|
||||||
|
"user_email": user_email,
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
await session.commit()
|
||||||
|
logger.debug("Updated stats for {} devices", updated)
|
||||||
|
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prometheus_payload(peers: list[PeerInfo], labels: dict[str, dict]) -> str:
|
||||||
|
"""Build Prometheus exposition format text for VictoriaMetrics import."""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
lines = []
|
||||||
|
active_count = 0
|
||||||
|
|
||||||
|
for peer in peers:
|
||||||
|
meta = labels.get(peer.public_key)
|
||||||
|
if not meta:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tag = (
|
||||||
|
f'public_key="{peer.public_key[:16]}",'
|
||||||
|
f'device_name="{meta["device_name"]}",'
|
||||||
|
f'user_email="{meta["user_email"]}"'
|
||||||
|
)
|
||||||
|
|
||||||
|
lines.append(f"wiregui_peer_rx_bytes{{{tag}}} {peer.rx_bytes} {now_ms}")
|
||||||
|
lines.append(f"wiregui_peer_tx_bytes{{{tag}}} {peer.tx_bytes} {now_ms}")
|
||||||
|
|
||||||
|
handshake_ts = int(peer.latest_handshake.timestamp()) if peer.latest_handshake else 0
|
||||||
|
lines.append(f"wiregui_peer_latest_handshake_seconds{{{tag}}} {handshake_ts} {now_ms}")
|
||||||
|
|
||||||
|
connected = 1 if (handshake_ts and (time.time() - handshake_ts) < 180) else 0
|
||||||
|
lines.append(f"wiregui_peer_connected{{{tag}}} {connected} {now_ms}")
|
||||||
|
|
||||||
|
if connected:
|
||||||
|
active_count += 1
|
||||||
|
|
||||||
|
lines.append(f"wiregui_peers_total {active_count} {now_ms}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_metrics(client: httpx.AsyncClient, url: str, payload: str) -> None:
|
||||||
|
"""Push Prometheus-format metrics to VictoriaMetrics."""
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{url}/api/v1/import/prometheus",
|
||||||
|
content=payload,
|
||||||
|
headers={"Content-Type": "text/plain"},
|
||||||
|
)
|
||||||
|
if resp.status_code >= 400:
|
||||||
|
logger.warning("VictoriaMetrics push failed (HTTP {}): {}", resp.status_code, resp.text[:200])
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning("VictoriaMetrics push error: {}", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
"""Main collector loop."""
|
||||||
|
settings = get_settings()
|
||||||
|
interval = settings.metrics_poll_interval
|
||||||
|
vm_url = settings.victoriametrics_url
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Collector started: interval={}s, victoriametrics={}",
|
||||||
|
interval, vm_url or "disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
client = httpx.AsyncClient(timeout=5) if vm_url else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not _shutdown.is_set():
|
||||||
|
try:
|
||||||
|
peers = await get_peers()
|
||||||
|
labels = await _update_db(peers)
|
||||||
|
|
||||||
|
if client and vm_url and peers:
|
||||||
|
payload = _build_prometheus_payload(peers, labels)
|
||||||
|
await _push_metrics(client, vm_url, payload)
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Collector poll failed: {}", e)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(_shutdown.wait(), timeout=interval)
|
||||||
|
break # shutdown signalled
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass # normal — interval elapsed, loop again
|
||||||
|
finally:
|
||||||
|
if client:
|
||||||
|
await client.aclose()
|
||||||
|
await engine.dispose()
|
||||||
|
logger.info("Collector stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
setup_logging(log_to_file=get_settings().log_to_file)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
loop.add_signal_handler(sig, _handle_signal)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(run())
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -41,8 +41,16 @@ class Settings(BaseSettings):
|
||||||
smtp_password: str | None = None
|
smtp_password: str | None = None
|
||||||
smtp_from: str = "wiregui@localhost"
|
smtp_from: str = "wiregui@localhost"
|
||||||
|
|
||||||
|
# Metrics collector
|
||||||
|
metrics_enabled: bool = False # run separate collector process for high-frequency stats
|
||||||
|
metrics_poll_interval: int = 5 # seconds between wg show polls (collector process)
|
||||||
|
victoriametrics_url: str | None = None # e.g. http://localhost:8428
|
||||||
|
|
||||||
|
# IdP provisioning
|
||||||
|
idp_config_file: str | None = None # path to YAML file with IdP definitions
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_to_file: bool = True # write timestamped log file to logs/ directory
|
log_to_file: bool = False # write timestamped log file to logs/ directory
|
||||||
|
|
||||||
# App
|
# App
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ def setup_logging(log_to_file: bool = False) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
if log_to_file:
|
if log_to_file:
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d")
|
||||||
logger.add(
|
logger.add(
|
||||||
f"logs/wiregui_{timestamp}.log",
|
f"logs/wiregui_{timestamp}.log",
|
||||||
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {name}:{function}:{line} - {message}",
|
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {name}:{function}:{line} - {message}",
|
||||||
|
|
@ -5,7 +5,10 @@ from wiregui.api.v0 import router as api_router
|
||||||
from wiregui.auth.seed import ensure_server_keypair, seed_admin
|
from wiregui.auth.seed import ensure_server_keypair, seed_admin
|
||||||
from wiregui.config import get_settings
|
from wiregui.config import get_settings
|
||||||
from wiregui.db import init_db
|
from wiregui.db import init_db
|
||||||
from wiregui.logging import setup_logging
|
from wiregui.log_config import setup_logging
|
||||||
|
|
||||||
|
# Serve static assets (logo, images)
|
||||||
|
app.add_static_files("/img", "img")
|
||||||
|
|
||||||
# Mount REST API
|
# Mount REST API
|
||||||
app.include_router(api_router, prefix="/api")
|
app.include_router(api_router, prefix="/api")
|
||||||
|
|
@ -38,7 +41,10 @@ async def startup() -> None:
|
||||||
await seed_admin()
|
await seed_admin()
|
||||||
await ensure_server_keypair()
|
await ensure_server_keypair()
|
||||||
|
|
||||||
# Register OIDC providers from config
|
# Seed IdP providers from YAML config file (if configured), then register with authlib
|
||||||
|
from wiregui.auth.seed import seed_idp_providers
|
||||||
|
await seed_idp_providers()
|
||||||
|
|
||||||
from wiregui.auth.oidc import register_providers
|
from wiregui.auth.oidc import register_providers
|
||||||
await register_providers()
|
await register_providers()
|
||||||
|
|
||||||
|
|
@ -56,14 +62,18 @@ async def startup() -> None:
|
||||||
from wiregui.services.firewall import setup_base_tables, setup_masquerade
|
from wiregui.services.firewall import setup_base_tables, setup_masquerade
|
||||||
from wiregui.services.wireguard import configure_interface, ensure_interface
|
from wiregui.services.wireguard import configure_interface, ensure_interface
|
||||||
from wiregui.tasks.reconcile import reconcile
|
from wiregui.tasks.reconcile import reconcile
|
||||||
from wiregui.tasks.stats import stats_loop
|
|
||||||
|
|
||||||
await ensure_interface()
|
await ensure_interface()
|
||||||
await configure_interface()
|
await configure_interface()
|
||||||
await setup_base_tables()
|
await setup_base_tables()
|
||||||
await setup_masquerade()
|
await setup_masquerade()
|
||||||
await reconcile()
|
await reconcile()
|
||||||
register_task(stats_loop(), name="wg-stats")
|
|
||||||
|
if settings.metrics_enabled:
|
||||||
|
_start_collector()
|
||||||
|
else:
|
||||||
|
from wiregui.tasks.stats import stats_loop
|
||||||
|
register_task(stats_loop(), name="wg-stats")
|
||||||
register_task(vpn_session_loop(), name="vpn-session-expiry")
|
register_task(vpn_session_loop(), name="vpn-session-expiry")
|
||||||
else:
|
else:
|
||||||
logger.info("WireGuard disabled (WG_WG_ENABLED=false) — running in UI-only mode")
|
logger.info("WireGuard disabled (WG_WG_ENABLED=false) — running in UI-only mode")
|
||||||
|
|
@ -71,10 +81,37 @@ async def startup() -> None:
|
||||||
logger.info("WireGUI ready")
|
logger.info("WireGUI ready")
|
||||||
|
|
||||||
|
|
||||||
|
_collector_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def _start_collector() -> None:
|
||||||
|
"""Spawn the metrics collector as a subprocess sharing our network namespace."""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
global _collector_proc
|
||||||
|
_collector_proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "wiregui.collector"],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
logger.info("Metrics collector started (pid={})", _collector_proc.pid)
|
||||||
|
|
||||||
|
|
||||||
async def shutdown() -> None:
|
async def shutdown() -> None:
|
||||||
from wiregui.tasks import cancel_all
|
from wiregui.tasks import cancel_all
|
||||||
await cancel_all()
|
await cancel_all()
|
||||||
|
|
||||||
|
global _collector_proc
|
||||||
|
if _collector_proc and _collector_proc.poll() is None:
|
||||||
|
logger.info("Stopping metrics collector (pid={})", _collector_proc.pid)
|
||||||
|
_collector_proc.terminate()
|
||||||
|
try:
|
||||||
|
_collector_proc.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
_collector_proc.kill()
|
||||||
|
_collector_proc = None
|
||||||
|
|
||||||
|
|
||||||
app.on_startup(startup)
|
app.on_startup(startup)
|
||||||
app.on_shutdown(shutdown)
|
app.on_shutdown(shutdown)
|
||||||
|
|
@ -86,6 +123,7 @@ def main() -> None:
|
||||||
host=settings.host,
|
host=settings.host,
|
||||||
port=settings.port,
|
port=settings.port,
|
||||||
title="WireGUI",
|
title="WireGUI",
|
||||||
|
favicon="img/wiregui.svg",
|
||||||
storage_secret=settings.secret_key,
|
storage_secret=settings.secret_key,
|
||||||
reload=True,
|
reload=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ class Configuration(SQLModel, table=True):
|
||||||
sa_column=Column(JSON, default=["0.0.0.0/0", "::/0"]),
|
sa_column=Column(JSON, default=["0.0.0.0/0", "::/0"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Firewall policies
|
||||||
|
allow_peer_to_peer: bool = Field(default=False)
|
||||||
|
allow_lan_to_peers: bool = Field(default=False)
|
||||||
|
|
||||||
# Server WireGuard keypair (generated on first startup)
|
# Server WireGuard keypair (generated on first startup)
|
||||||
server_private_key: str | None = None
|
server_private_key: str | None = None
|
||||||
server_public_key: str | None = None
|
server_public_key: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -70,39 +70,40 @@ async def account_page():
|
||||||
ui.label("Rules:").classes("text-bold")
|
ui.label("Rules:").classes("text-bold")
|
||||||
ui.label(str(rule_count))
|
ui.label(str(rule_count))
|
||||||
|
|
||||||
# ===== Change Password =====
|
# ===== Change Password (only for users with a local password) =====
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
if user.password_hash:
|
||||||
ui.label("Change Password").classes("text-subtitle1 text-bold")
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
ui.separator()
|
ui.label("Change Password").classes("text-subtitle1 text-bold")
|
||||||
|
ui.separator()
|
||||||
|
|
||||||
cur = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full")
|
cur = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full")
|
||||||
npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
|
npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
|
||||||
cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
|
cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
|
||||||
|
|
||||||
async def save_pw():
|
async def save_pw():
|
||||||
if not cur.value or not npw.value:
|
if not cur.value or not npw.value:
|
||||||
ui.notify("All fields required", type="negative")
|
ui.notify("All fields required", type="negative")
|
||||||
return
|
|
||||||
if npw.value != cpw.value:
|
|
||||||
ui.notify("Passwords don't match", type="negative")
|
|
||||||
return
|
|
||||||
if len(npw.value) < 8:
|
|
||||||
ui.notify("Min 8 characters", type="negative")
|
|
||||||
return
|
|
||||||
async with async_session() as session:
|
|
||||||
u = await session.get(User, user_id)
|
|
||||||
if not verify_password(cur.value, u.password_hash):
|
|
||||||
ui.notify("Wrong current password", type="negative")
|
|
||||||
return
|
return
|
||||||
u.password_hash = hash_password(npw.value)
|
if npw.value != cpw.value:
|
||||||
session.add(u)
|
ui.notify("Passwords don't match", type="negative")
|
||||||
await session.commit()
|
return
|
||||||
ui.notify("Password changed", type="positive")
|
if len(npw.value) < 8:
|
||||||
cur.value = ""
|
ui.notify("Min 8 characters", type="negative")
|
||||||
npw.value = ""
|
return
|
||||||
cpw.value = ""
|
async with async_session() as session:
|
||||||
|
u = await session.get(User, user_id)
|
||||||
|
if not verify_password(cur.value, u.password_hash):
|
||||||
|
ui.notify("Wrong current password", type="negative")
|
||||||
|
return
|
||||||
|
u.password_hash = hash_password(npw.value)
|
||||||
|
session.add(u)
|
||||||
|
await session.commit()
|
||||||
|
ui.notify("Password changed", type="positive")
|
||||||
|
cur.value = ""
|
||||||
|
npw.value = ""
|
||||||
|
cpw.value = ""
|
||||||
|
|
||||||
ui.button("Update Password", on_click=save_pw).props("color=primary unelevated").classes("q-mt-md")
|
ui.button("Update Password", on_click=save_pw).props("color=primary unelevated").classes("q-mt-md")
|
||||||
|
|
||||||
# ===== Connected SSO Providers =====
|
# ===== Connected SSO Providers =====
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Admin device management — view and manage all devices across all users."""
|
"""Admin device management — view and manage all devices across all users."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import io
|
import io
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -45,12 +46,24 @@ async def admin_devices_page():
|
||||||
|
|
||||||
layout()
|
layout()
|
||||||
|
|
||||||
# Load users for filter and create form
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Load users and client defaults
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
users = (await session.execute(select(User).order_by(User.email))).scalars().all()
|
users = (await session.execute(select(User).order_by(User.email))).scalars().all()
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
_db_cfg = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
user_map = {str(u.id): u.email for u in users}
|
user_map = {str(u.id): u.email for u in users}
|
||||||
|
_defaults = {
|
||||||
|
"allowed_ips": ", ".join(_db_cfg.default_client_allowed_ips) if _db_cfg and _db_cfg.default_client_allowed_ips else settings.wg_allowed_ips,
|
||||||
|
"dns": ", ".join(_db_cfg.default_client_dns) if _db_cfg and _db_cfg.default_client_dns else settings.wg_dns,
|
||||||
|
"endpoint": _db_cfg.default_client_endpoint if _db_cfg and _db_cfg.default_client_endpoint else settings.wg_endpoint_host,
|
||||||
|
"mtu": str(_db_cfg.default_client_mtu) if _db_cfg else str(settings.wg_mtu),
|
||||||
|
"keepalive": str(_db_cfg.default_client_persistent_keepalive) if _db_cfg else str(settings.wg_persistent_keepalive),
|
||||||
|
}
|
||||||
|
|
||||||
async def load_devices(user_filter: str | None = None) -> list[dict]:
|
async def load_devices(user_filter: str | None = None) -> list[dict]:
|
||||||
|
from wiregui.utils.time import connection_status
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
stmt = select(Device).order_by(Device.inserted_at.desc())
|
stmt = select(Device).order_by(Device.inserted_at.desc())
|
||||||
if user_filter and user_filter != "all":
|
if user_filter and user_filter != "all":
|
||||||
|
|
@ -61,6 +74,8 @@ async def admin_devices_page():
|
||||||
"id": str(d.id),
|
"id": str(d.id),
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
"user": user_map.get(str(d.user_id), "Unknown"),
|
"user": user_map.get(str(d.user_id), "Unknown"),
|
||||||
|
"status_color": connection_status(d.latest_handshake)[0],
|
||||||
|
"status_label": connection_status(d.latest_handshake)[1],
|
||||||
"ipv4": d.ipv4 or "-",
|
"ipv4": d.ipv4 or "-",
|
||||||
"ipv6": d.ipv6 or "-",
|
"ipv6": d.ipv6 or "-",
|
||||||
"public_key": d.public_key[:16] + "...",
|
"public_key": d.public_key[:16] + "...",
|
||||||
|
|
@ -123,19 +138,29 @@ async def admin_devices_page():
|
||||||
await session.refresh(device)
|
await session.refresh(device)
|
||||||
|
|
||||||
logger.info("Admin created device: {} for {}", device.name, user_map.get(owner_id))
|
logger.info("Admin created device: {} for {}", device.name, user_map.get(owner_id))
|
||||||
await on_device_created(device)
|
|
||||||
|
|
||||||
# Show config
|
# Build config and show dialog immediately — don't wait for WG/firewall
|
||||||
server_pubkey = await get_server_public_key()
|
server_pubkey = await get_server_public_key()
|
||||||
config_text = build_client_config(device, private_key, server_pubkey)
|
async with async_session() as session:
|
||||||
|
from sqlmodel import select as sel
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
db_config = (await session.execute(sel(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
config_text = build_client_config(device, private_key, server_pubkey, db_config)
|
||||||
|
|
||||||
create_dialog.close()
|
create_dialog.close()
|
||||||
_reset_create_form()
|
_reset_create_form()
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
_show_config_dialog(device.name, config_text)
|
_show_config_dialog(device.name, config_text)
|
||||||
|
|
||||||
|
# Configure WG peer and firewall in background
|
||||||
|
asyncio.create_task(on_device_created(device))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to create device: {}", e)
|
logger.error("Failed to create device: {}", e)
|
||||||
ui.notify(f"Error: {e}", type="negative")
|
try:
|
||||||
|
ui.notify(f"Error: {e}", type="negative")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _reset_create_form():
|
def _reset_create_form():
|
||||||
create_name.value = ""
|
create_name.value = ""
|
||||||
|
|
@ -145,6 +170,11 @@ async def admin_devices_page():
|
||||||
create_use_default_endpoint.value = True
|
create_use_default_endpoint.value = True
|
||||||
create_use_default_mtu.value = True
|
create_use_default_mtu.value = True
|
||||||
create_use_default_keepalive.value = True
|
create_use_default_keepalive.value = True
|
||||||
|
create_allowed_ips.value = _defaults["allowed_ips"]
|
||||||
|
create_dns.value = _defaults["dns"]
|
||||||
|
create_endpoint.value = _defaults["endpoint"]
|
||||||
|
create_mtu.value = _defaults["mtu"]
|
||||||
|
create_keepalive.value = _defaults["keepalive"]
|
||||||
|
|
||||||
# --- Edit device ---
|
# --- Edit device ---
|
||||||
edit_device_id = {"value": None}
|
edit_device_id = {"value": None}
|
||||||
|
|
@ -235,6 +265,7 @@ async def admin_devices_page():
|
||||||
ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary")
|
ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary")
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
|
{"name": "status", "label": "", "field": "status_label", "align": "center"},
|
||||||
{"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True},
|
{"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True},
|
||||||
{"name": "user", "label": "User", "field": "user", "align": "left", "sortable": True},
|
{"name": "user", "label": "User", "field": "user", "align": "left", "sortable": True},
|
||||||
{"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"},
|
{"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"},
|
||||||
|
|
@ -246,6 +277,15 @@ async def admin_devices_page():
|
||||||
{"name": "actions", "label": "", "field": "id", "align": "center"},
|
{"name": "actions", "label": "", "field": "id", "align": "center"},
|
||||||
]
|
]
|
||||||
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-status",
|
||||||
|
'''
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge :color="props.row.status_color" rounded class="q-mr-sm" />
|
||||||
|
<span class="text-caption">{{ props.row.status_label }}</span>
|
||||||
|
</q-td>
|
||||||
|
''',
|
||||||
|
)
|
||||||
table.add_slot(
|
table.add_slot(
|
||||||
"body-cell-actions",
|
"body-cell-actions",
|
||||||
'''
|
'''
|
||||||
|
|
@ -257,6 +297,11 @@ async def admin_devices_page():
|
||||||
</q-td>
|
</q-td>
|
||||||
''',
|
''',
|
||||||
)
|
)
|
||||||
|
def on_admin_row_click(e):
|
||||||
|
# Quasar rowClick args: [evt, row, index] or just row depending on NiceGUI version
|
||||||
|
row = e.args[1] if isinstance(e.args, list) else e.args
|
||||||
|
ui.navigate.to(f"/devices/{row['id']}")
|
||||||
|
table.on("rowClick", on_admin_row_click)
|
||||||
table.on("edit", lambda e: open_edit(e.args))
|
table.on("edit", lambda e: open_edit(e.args))
|
||||||
table.on("delete", lambda e: delete_device(e.args))
|
table.on("delete", lambda e: delete_device(e.args))
|
||||||
|
|
||||||
|
|
@ -278,19 +323,19 @@ async def admin_devices_page():
|
||||||
|
|
||||||
with ui.grid(columns=2).classes("w-full gap-2"):
|
with ui.grid(columns=2).classes("w-full gap-2"):
|
||||||
create_use_default_ips = ui.switch("Use default Allowed IPs", value=True)
|
create_use_default_ips = ui.switch("Use default Allowed IPs", value=True)
|
||||||
create_allowed_ips = ui.input("Allowed IPs", placeholder="0.0.0.0/0, ::/0").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v)
|
create_allowed_ips = ui.input("Allowed IPs", value=_defaults["allowed_ips"]).props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_dns = ui.switch("Use default DNS", value=True)
|
create_use_default_dns = ui.switch("Use default DNS", value=True)
|
||||||
create_dns = ui.input("DNS Servers", placeholder="1.1.1.1, 1.0.0.1").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v)
|
create_dns = ui.input("DNS Servers", value=_defaults["dns"]).props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_endpoint = ui.switch("Use default Endpoint", value=True)
|
create_use_default_endpoint = ui.switch("Use default Endpoint", value=True)
|
||||||
create_endpoint = ui.input("Endpoint", placeholder="vpn.example.com").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v)
|
create_endpoint = ui.input("Endpoint", value=_defaults["endpoint"]).props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_mtu = ui.switch("Use default MTU", value=True)
|
create_use_default_mtu = ui.switch("Use default MTU", value=True)
|
||||||
create_mtu = ui.input("MTU", placeholder="1280").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v)
|
create_mtu = ui.input("MTU", value=_defaults["mtu"]).props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_keepalive = ui.switch("Use default Keepalive", value=True)
|
create_use_default_keepalive = ui.switch("Use default Keepalive", value=True)
|
||||||
create_keepalive = ui.input("Persistent Keepalive", placeholder="25").props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v)
|
create_keepalive = ui.input("Persistent Keepalive", value=_defaults["keepalive"]).props("outlined dense").classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end q-mt-sm"):
|
with ui.row().classes("w-full justify-end q-mt-sm"):
|
||||||
ui.button("Cancel", on_click=create_dialog.close).props("flat")
|
ui.button("Cancel", on_click=create_dialog.close).props("flat")
|
||||||
|
|
@ -329,22 +374,27 @@ async def admin_devices_page():
|
||||||
|
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
|
|
||||||
# Auto-refresh stats every 30 seconds
|
ui.timer(5, refresh_table)
|
||||||
ui.timer(30, refresh_table)
|
|
||||||
|
|
||||||
|
|
||||||
def _show_config_dialog(device_name: str, config_text: str):
|
def _show_config_dialog(device_name: str, config_text: str):
|
||||||
with ui.dialog(value=True) as dialog:
|
with ui.dialog(value=True) as dialog:
|
||||||
with ui.card().classes("w-96"):
|
with ui.card().classes("w-[700px] max-w-[90vw]"):
|
||||||
ui.label(f"Config for {device_name}").classes("text-h6")
|
ui.label(f"Config for {device_name}").classes("text-h6")
|
||||||
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative")
|
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative q-mb-sm")
|
||||||
ui.textarea(value=config_text).props("readonly outlined").classes("w-full font-mono text-xs q-mt-sm").style("min-height: 200px")
|
ui.code(config_text, language="ini").classes("w-full")
|
||||||
try:
|
try:
|
||||||
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage)
|
import base64
|
||||||
|
qr = qrcode.make(config_text)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
qr.save(buf)
|
qr.save(buf, format="PNG")
|
||||||
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm")
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
with ui.row().classes("w-full justify-center q-mt-md"):
|
||||||
|
ui.image(f"data:image/png;base64,{b64}").style(
|
||||||
|
"width: 200px; height: 200px; border-radius: 8px"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary unelevated").classes("w-full q-mt-sm")
|
with ui.row().classes("w-full gap-2 q-mt-md"):
|
||||||
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
|
ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary unelevated").classes("flex-grow")
|
||||||
|
ui.button("Close", on_click=dialog.close).props("flat")
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ async def diagnostics_page():
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ConnectivityCheck).order_by(ConnectivityCheck.inserted_at.desc()).limit(20)
|
select(ConnectivityCheck).order_by(ConnectivityCheck.inserted_at.desc()).limit(10)
|
||||||
)
|
)
|
||||||
checks = result.scalars().all()
|
checks = result.scalars().all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Admin firewall rules management page."""
|
"""Admin firewall rules management page."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
@ -7,10 +8,13 @@ from nicegui import app, ui
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
from wiregui.models.rule import Rule
|
from wiregui.models.rule import Rule
|
||||||
from wiregui.models.user import User
|
from wiregui.models.user import User
|
||||||
from wiregui.pages.layout import layout
|
from wiregui.pages.layout import layout
|
||||||
from wiregui.services.events import on_rule_created, on_rule_deleted, on_rule_updated
|
from wiregui.services.events import on_rule_created, on_rule_deleted, on_rule_updated
|
||||||
|
from wiregui.services.firewall import apply_lan_to_peers_policy, apply_peer_to_peer_policy, get_ruleset
|
||||||
|
from wiregui.utils.time import utcnow
|
||||||
|
|
||||||
|
|
||||||
@ui.page("/admin/rules")
|
@ui.page("/admin/rules")
|
||||||
|
|
@ -23,6 +27,7 @@ async def rules_page():
|
||||||
# Load users for the dropdown
|
# Load users for the dropdown
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
users = (await session.execute(select(User).order_by(User.email))).scalars().all()
|
users = (await session.execute(select(User).order_by(User.email))).scalars().all()
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
user_options = {str(u.id): u.email for u in users}
|
user_options = {str(u.id): u.email for u in users}
|
||||||
|
|
||||||
async def load_rules() -> list[dict]:
|
async def load_rules() -> list[dict]:
|
||||||
|
|
@ -69,7 +74,7 @@ async def rules_page():
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
|
|
||||||
logger.info("Rule created: {} {} -> {}", rule.action, rule.destination, user_id_val or "global")
|
logger.info("Rule created: {} {} -> {}", rule.action, rule.destination, user_id_val or "global")
|
||||||
await on_rule_created(rule)
|
asyncio.create_task(on_rule_created(rule))
|
||||||
|
|
||||||
create_dialog.close()
|
create_dialog.close()
|
||||||
_reset_form()
|
_reset_form()
|
||||||
|
|
@ -110,7 +115,7 @@ async def rules_page():
|
||||||
session.add(rule)
|
session.add(rule)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(rule)
|
await session.refresh(rule)
|
||||||
await on_rule_updated(rule)
|
asyncio.create_task(on_rule_updated(rule))
|
||||||
|
|
||||||
logger.info("Rule updated: {} {}", edit_action.value, edit_dest.value)
|
logger.info("Rule updated: {} {}", edit_action.value, edit_dest.value)
|
||||||
ui.notify("Rule updated")
|
ui.notify("Rule updated")
|
||||||
|
|
@ -124,7 +129,7 @@ async def rules_page():
|
||||||
await session.delete(rule)
|
await session.delete(rule)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Rule deleted: {} {}", rule.action, rule.destination)
|
logger.info("Rule deleted: {} {}", rule.action, rule.destination)
|
||||||
await on_rule_deleted(rule)
|
asyncio.create_task(on_rule_deleted(rule))
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
|
|
||||||
def _reset_form():
|
def _reset_form():
|
||||||
|
|
@ -134,34 +139,101 @@ async def rules_page():
|
||||||
port_range_input.value = ""
|
port_range_input.value = ""
|
||||||
user_select.value = "global"
|
user_select.value = "global"
|
||||||
|
|
||||||
# Page content
|
# --- Firewall policy toggles ---
|
||||||
with ui.column().classes("w-full p-4"):
|
async def toggle_peer_to_peer(e):
|
||||||
with ui.row().classes("w-full items-center justify-between"):
|
async with async_session() as session:
|
||||||
ui.label("Firewall Rules").classes("text-h5")
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
ui.button("Add Rule", icon="add", on_click=lambda: create_dialog.open()).props("color=primary")
|
if c:
|
||||||
|
c.allow_peer_to_peer = e.value
|
||||||
|
c.updated_at = utcnow()
|
||||||
|
session.add(c)
|
||||||
|
await session.commit()
|
||||||
|
asyncio.create_task(apply_peer_to_peer_policy(e.value))
|
||||||
|
ui.notify(f"Peer-to-peer: {'allowed' if e.value else 'denied'}")
|
||||||
|
|
||||||
columns = [
|
async def toggle_lan_to_peers(e):
|
||||||
{"name": "action", "label": "Action", "field": "action", "align": "left", "sortable": True},
|
async with async_session() as session:
|
||||||
{"name": "destination", "label": "Destination", "field": "destination", "align": "left", "sortable": True},
|
c = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
{"name": "port_type", "label": "Protocol", "field": "port_type", "align": "left"},
|
if c:
|
||||||
{"name": "port_range", "label": "Port(s)", "field": "port_range", "align": "left"},
|
c.allow_lan_to_peers = e.value
|
||||||
{"name": "user", "label": "User", "field": "user", "align": "left"},
|
c.updated_at = utcnow()
|
||||||
{"name": "actions", "label": "", "field": "id", "align": "center"},
|
session.add(c)
|
||||||
]
|
await session.commit()
|
||||||
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
asyncio.create_task(apply_lan_to_peers_policy(e.value))
|
||||||
table.add_slot(
|
ui.notify(f"LAN-to-peers: {'allowed' if e.value else 'denied'}")
|
||||||
"body-cell-actions",
|
|
||||||
'''
|
# --- Troubleshooting ---
|
||||||
<q-td :props="props">
|
async def show_nft_rules():
|
||||||
<q-btn flat dense icon="edit" color="primary"
|
ruleset = await get_ruleset()
|
||||||
@click.stop="() => $parent.$emit('edit', props.row.id)" />
|
with ui.dialog(value=True) as dlg:
|
||||||
<q-btn flat dense icon="delete" color="negative"
|
with ui.card().classes("w-[900px] max-w-[90vw]"):
|
||||||
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
ui.label("nftables Ruleset").classes("text-subtitle1 text-bold")
|
||||||
</q-td>
|
ui.label("Current system firewall rules for troubleshooting.").classes("text-caption text-grey")
|
||||||
''',
|
ui.separator()
|
||||||
)
|
ui.code(ruleset, language="bash").classes("w-full")
|
||||||
table.on("edit", lambda e: open_edit(e.args))
|
with ui.row().classes("w-full justify-end q-mt-sm"):
|
||||||
table.on("delete", lambda e: delete_rule(e.args))
|
ui.button("Close", on_click=dlg.close).props("flat")
|
||||||
|
|
||||||
|
# --- Page content ---
|
||||||
|
with ui.column().classes("w-full p-4"):
|
||||||
|
ui.label("Firewall Rules").classes("text-h5 q-mb-md")
|
||||||
|
|
||||||
|
# Policy switches
|
||||||
|
with ui.card().classes("w-full"):
|
||||||
|
ui.label("Network Policies").classes("text-subtitle1 text-bold")
|
||||||
|
ui.label("Control how traffic flows between peers and the local network.").classes("text-caption text-grey")
|
||||||
|
ui.separator()
|
||||||
|
|
||||||
|
ui.switch(
|
||||||
|
"Allow peer-to-peer communication",
|
||||||
|
value=config.allow_peer_to_peer if config else False,
|
||||||
|
on_change=toggle_peer_to_peer,
|
||||||
|
)
|
||||||
|
ui.label("Peers can communicate with each other through the WireGuard server (hub-and-spoke).").classes("text-caption text-grey q-ml-xl")
|
||||||
|
|
||||||
|
ui.switch(
|
||||||
|
"Allow local network to reach peers",
|
||||||
|
value=config.allow_lan_to_peers if config else False,
|
||||||
|
on_change=toggle_lan_to_peers,
|
||||||
|
).classes("q-mt-sm")
|
||||||
|
ui.label("Devices on the server's LAN can initiate connections to VPN peers.").classes("text-caption text-grey q-ml-xl")
|
||||||
|
|
||||||
|
# Rules table
|
||||||
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
|
ui.label("Per-User Rules").classes("text-subtitle1 text-bold")
|
||||||
|
ui.button("Add Rule", icon="add", on_click=lambda: create_dialog.open()).props("color=primary unelevated")
|
||||||
|
ui.separator()
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
{"name": "action", "label": "Action", "field": "action", "align": "left", "sortable": True},
|
||||||
|
{"name": "destination", "label": "Destination", "field": "destination", "align": "left", "sortable": True},
|
||||||
|
{"name": "port_type", "label": "Protocol", "field": "port_type", "align": "left"},
|
||||||
|
{"name": "port_range", "label": "Port(s)", "field": "port_range", "align": "left"},
|
||||||
|
{"name": "user", "label": "User", "field": "user", "align": "left"},
|
||||||
|
{"name": "actions", "label": "", "field": "id", "align": "center"},
|
||||||
|
]
|
||||||
|
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-actions",
|
||||||
|
'''
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat dense icon="edit" color="primary"
|
||||||
|
@click.stop="() => $parent.$emit('edit', props.row.id)" />
|
||||||
|
<q-btn flat dense icon="delete" color="negative"
|
||||||
|
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
||||||
|
</q-td>
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
table.on("edit", lambda e: open_edit(e.args))
|
||||||
|
table.on("delete", lambda e: delete_rule(e.args))
|
||||||
|
|
||||||
|
# Troubleshooting
|
||||||
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
|
ui.label("Troubleshooting").classes("text-subtitle1 text-bold")
|
||||||
|
ui.label("Inspect the raw nftables ruleset configured on this system.").classes("text-caption text-grey")
|
||||||
|
ui.separator()
|
||||||
|
ui.button("View nftables Rules", icon="terminal", on_click=show_nft_rules).props("color=primary unelevated")
|
||||||
|
|
||||||
# Create rule dialog
|
# Create rule dialog
|
||||||
with ui.dialog() as create_dialog:
|
with ui.dialog() as create_dialog:
|
||||||
|
|
@ -195,7 +267,7 @@ async def rules_page():
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end q-mt-sm"):
|
with ui.row().classes("w-full justify-end q-mt-sm"):
|
||||||
ui.button("Cancel", on_click=create_dialog.close).props("flat")
|
ui.button("Cancel", on_click=create_dialog.close).props("flat")
|
||||||
ui.button("Create", on_click=create_rule).props("color=primary")
|
ui.button("Create", on_click=create_rule).props("color=primary unelevated")
|
||||||
|
|
||||||
# Edit rule dialog
|
# Edit rule dialog
|
||||||
user_options_map = {"global": "Global (all users)"}
|
user_options_map = {"global": "Global (all users)"}
|
||||||
|
|
@ -223,6 +295,6 @@ async def rules_page():
|
||||||
|
|
||||||
with ui.row().classes("w-full justify-end q-mt-sm"):
|
with ui.row().classes("w-full justify-end q-mt-sm"):
|
||||||
ui.button("Cancel", on_click=edit_dialog.close).props("flat")
|
ui.button("Cancel", on_click=edit_dialog.close).props("flat")
|
||||||
ui.button("Save", on_click=save_edit).props("color=primary")
|
ui.button("Save", on_click=save_edit).props("color=primary unelevated")
|
||||||
|
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from loguru import logger
|
||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from wiregui.config import get_settings
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
from wiregui.models.configuration import Configuration
|
from wiregui.models.configuration import Configuration
|
||||||
from wiregui.pages.layout import layout
|
from wiregui.pages.layout import layout
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""OIDC authentication routes — redirect to provider and handle callback."""
|
"""OIDC authentication routes — redirect to provider and handle callback."""
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from nicegui import app
|
from nicegui import app, ui
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
@ -43,17 +43,42 @@ async def oidc_callback(provider_id: str, request: Request):
|
||||||
logger.error("OIDC token exchange failed for {}: {}", provider_id, e)
|
logger.error("OIDC token exchange failed for {}: {}", provider_id, e)
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
|
# Extract user info: try userinfo from token, then userinfo endpoint, then ID token claims
|
||||||
userinfo = token.get("userinfo")
|
userinfo = token.get("userinfo")
|
||||||
if not userinfo:
|
if not userinfo:
|
||||||
try:
|
try:
|
||||||
userinfo = await client.userinfo()
|
userinfo = await client.userinfo(token=token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("OIDC userinfo failed for {}: {}", provider_id, e)
|
logger.debug("OIDC userinfo endpoint failed for {}: {}", provider_id, e)
|
||||||
return RedirectResponse(url="/login")
|
userinfo = None
|
||||||
|
|
||||||
email = userinfo.get("email")
|
# Fallback: decode the ID token for claims
|
||||||
|
if not userinfo or not userinfo.get("email"):
|
||||||
|
id_token = token.get("id_token")
|
||||||
|
if id_token:
|
||||||
|
try:
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
# Decode without verification — we already verified during token exchange
|
||||||
|
claims = jose_jwt.get_unverified_claims(id_token)
|
||||||
|
userinfo = userinfo or {}
|
||||||
|
if not userinfo.get("email"):
|
||||||
|
userinfo["email"] = claims.get("email")
|
||||||
|
if not userinfo.get("sub"):
|
||||||
|
userinfo["sub"] = claims.get("sub")
|
||||||
|
logger.debug("OIDC: extracted claims from ID token: {}", claims)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("OIDC: failed to decode ID token: {}", e)
|
||||||
|
|
||||||
|
email = (userinfo or {}).get("email")
|
||||||
|
# Fallback: if sub looks like an email, use it
|
||||||
if not email:
|
if not email:
|
||||||
logger.error("OIDC provider {} did not return email", provider_id)
|
sub = (userinfo or {}).get("sub", "")
|
||||||
|
if "@" in sub:
|
||||||
|
email = sub
|
||||||
|
logger.debug("OIDC: using sub as email: {}", email)
|
||||||
|
if not email:
|
||||||
|
logger.error("OIDC provider {} did not return email. Token keys: {}, userinfo: {}",
|
||||||
|
provider_id, list(token.keys()), userinfo)
|
||||||
return RedirectResponse(url="/login")
|
return RedirectResponse(url="/login")
|
||||||
|
|
||||||
provider_config = await get_provider_config(provider_id)
|
provider_config = await get_provider_config(provider_id)
|
||||||
|
|
@ -111,11 +136,30 @@ async def oidc_callback(provider_id: str, request: Request):
|
||||||
|
|
||||||
logger.info("OIDC login: {} via {}", email, provider_id)
|
logger.info("OIDC login: {} via {}", email, provider_id)
|
||||||
|
|
||||||
# Set NiceGUI session — store in Starlette session since we're in a plain route
|
# Store auth data in Starlette session — will be picked up by /auth/complete
|
||||||
request.session["authenticated"] = True
|
request.session["oidc_user_id"] = str(user.id)
|
||||||
request.session["user_id"] = str(user.id)
|
request.session["oidc_email"] = user.email
|
||||||
request.session["email"] = user.email
|
request.session["oidc_role"] = user.role
|
||||||
request.session["role"] = user.role
|
|
||||||
request.session["theme_preference"] = user.theme_preference
|
|
||||||
|
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/auth/complete")
|
||||||
|
|
||||||
|
|
||||||
|
@ui.page("/auth/complete")
|
||||||
|
def auth_complete_page(request: Request):
|
||||||
|
"""Bridge page: transfer OIDC auth from Starlette session to NiceGUI storage."""
|
||||||
|
user_id = request.session.pop("oidc_user_id", None)
|
||||||
|
email = request.session.pop("oidc_email", None)
|
||||||
|
role = request.session.pop("oidc_role", None)
|
||||||
|
|
||||||
|
if not user_id or not email:
|
||||||
|
logger.warning("Auth complete page called without OIDC session data")
|
||||||
|
return ui.navigate.to("/login")
|
||||||
|
|
||||||
|
app.storage.user.update(
|
||||||
|
authenticated=True,
|
||||||
|
user_id=user_id,
|
||||||
|
email=email,
|
||||||
|
role=role or "unprivileged",
|
||||||
|
)
|
||||||
|
logger.info("OIDC auth completed for {} — session transferred to NiceGUI", email)
|
||||||
|
ui.navigate.to("/")
|
||||||
|
|
|
||||||
|
|
@ -101,14 +101,13 @@ async def saml_callback(provider_id: str, request: Request):
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
request.session["authenticated"] = True
|
# Store auth data in Starlette session — picked up by /auth/complete
|
||||||
request.session["user_id"] = str(user.id)
|
request.session["oidc_user_id"] = str(user.id)
|
||||||
request.session["email"] = user.email
|
request.session["oidc_email"] = user.email
|
||||||
request.session["role"] = user.role
|
request.session["oidc_role"] = user.role
|
||||||
request.session["theme_preference"] = user.theme_preference
|
|
||||||
|
|
||||||
logger.info("SAML login: {} via {}", email, provider_id)
|
logger.info("SAML login: {} via {}", email, provider_id)
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/auth/complete", status_code=303)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("SAML callback failed for {}: {}", provider_id, e)
|
logger.error("SAML callback failed for {}: {}", provider_id, e)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""User-facing device management pages."""
|
"""User-facing device management pages."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import io
|
import io
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
@ -37,6 +38,19 @@ async def devices_page():
|
||||||
|
|
||||||
layout()
|
layout()
|
||||||
user_id = UUID(app.storage.user["user_id"])
|
user_id = UUID(app.storage.user["user_id"])
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Load client defaults from DB config (falls back to env vars)
|
||||||
|
async with async_session() as session:
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
_db_cfg = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
_defaults = {
|
||||||
|
"allowed_ips": ", ".join(_db_cfg.default_client_allowed_ips) if _db_cfg and _db_cfg.default_client_allowed_ips else settings.wg_allowed_ips,
|
||||||
|
"dns": ", ".join(_db_cfg.default_client_dns) if _db_cfg and _db_cfg.default_client_dns else settings.wg_dns,
|
||||||
|
"endpoint": _db_cfg.default_client_endpoint if _db_cfg and _db_cfg.default_client_endpoint else settings.wg_endpoint_host,
|
||||||
|
"mtu": str(_db_cfg.default_client_mtu) if _db_cfg else str(settings.wg_mtu),
|
||||||
|
"keepalive": str(_db_cfg.default_client_persistent_keepalive) if _db_cfg else str(settings.wg_persistent_keepalive),
|
||||||
|
}
|
||||||
|
|
||||||
async def load_devices() -> list[Device]:
|
async def load_devices() -> list[Device]:
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
|
|
@ -46,12 +60,15 @@ async def devices_page():
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
async def refresh_table():
|
async def refresh_table():
|
||||||
|
from wiregui.utils.time import connection_status
|
||||||
devices = await load_devices()
|
devices = await load_devices()
|
||||||
table.rows = [
|
table.rows = [
|
||||||
{
|
{
|
||||||
"id": str(d.id),
|
"id": str(d.id),
|
||||||
"name": d.name,
|
"name": d.name,
|
||||||
"description": d.description or "",
|
"description": d.description or "",
|
||||||
|
"status_color": connection_status(d.latest_handshake)[0],
|
||||||
|
"status_label": connection_status(d.latest_handshake)[1],
|
||||||
"ipv4": d.ipv4 or "-",
|
"ipv4": d.ipv4 or "-",
|
||||||
"ipv6": d.ipv6 or "-",
|
"ipv6": d.ipv6 or "-",
|
||||||
"public_key": d.public_key[:16] + "...",
|
"public_key": d.public_key[:16] + "...",
|
||||||
|
|
@ -108,19 +125,29 @@ async def devices_page():
|
||||||
await session.refresh(device)
|
await session.refresh(device)
|
||||||
|
|
||||||
logger.info("Device created: {} ({})", device.name, device.ipv4)
|
logger.info("Device created: {} ({})", device.name, device.ipv4)
|
||||||
await on_device_created(device)
|
|
||||||
|
|
||||||
|
# Build config and show dialog immediately — don't wait for WG/firewall
|
||||||
server_pubkey = await get_server_public_key()
|
server_pubkey = await get_server_public_key()
|
||||||
config_text = build_client_config(device, private_key, server_pubkey)
|
async with async_session() as session:
|
||||||
|
from sqlmodel import select as sel
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
|
db_config = (await session.execute(sel(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
config_text = build_client_config(device, private_key, server_pubkey, db_config)
|
||||||
|
|
||||||
create_dialog.close()
|
create_dialog.close()
|
||||||
_reset_create_form()
|
_reset_create_form()
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
_show_config_dialog(device.name, config_text)
|
_show_config_dialog(device.name, config_text)
|
||||||
|
|
||||||
|
# Configure WG peer and firewall in background (don't block the UI)
|
||||||
|
asyncio.create_task(on_device_created(device))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to create device: {}", e)
|
logger.error("Failed to create device: {}", e)
|
||||||
ui.notify(f"Error: {e}", type="negative")
|
try:
|
||||||
|
ui.notify(f"Error: {e}", type="negative")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def _reset_create_form():
|
def _reset_create_form():
|
||||||
create_name.value = ""
|
create_name.value = ""
|
||||||
|
|
@ -130,11 +157,11 @@ async def devices_page():
|
||||||
create_use_default_endpoint.value = True
|
create_use_default_endpoint.value = True
|
||||||
create_use_default_mtu.value = True
|
create_use_default_mtu.value = True
|
||||||
create_use_default_keepalive.value = True
|
create_use_default_keepalive.value = True
|
||||||
create_endpoint.value = ""
|
create_allowed_ips.value = _defaults["allowed_ips"]
|
||||||
create_dns.value = ""
|
create_dns.value = _defaults["dns"]
|
||||||
create_mtu.value = ""
|
create_endpoint.value = _defaults["endpoint"]
|
||||||
create_keepalive.value = ""
|
create_mtu.value = _defaults["mtu"]
|
||||||
create_allowed_ips.value = ""
|
create_keepalive.value = _defaults["keepalive"]
|
||||||
|
|
||||||
# --- Delete device ---
|
# --- Delete device ---
|
||||||
async def delete_device(device_id: str):
|
async def delete_device(device_id: str):
|
||||||
|
|
@ -149,7 +176,8 @@ async def devices_page():
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
|
|
||||||
def on_row_click(e):
|
def on_row_click(e):
|
||||||
ui.navigate.to(f"/devices/{e.args['id']}")
|
row = e.args[1] if isinstance(e.args, list) else e.args
|
||||||
|
ui.navigate.to(f"/devices/{row['id']}")
|
||||||
|
|
||||||
# --- Page content ---
|
# --- Page content ---
|
||||||
with ui.column().classes("w-full p-4"):
|
with ui.column().classes("w-full p-4"):
|
||||||
|
|
@ -158,6 +186,7 @@ async def devices_page():
|
||||||
ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary")
|
ui.button("Add Device", icon="add", on_click=lambda: create_dialog.open()).props("color=primary")
|
||||||
|
|
||||||
columns = [
|
columns = [
|
||||||
|
{"name": "status", "label": "", "field": "status_label", "align": "center"},
|
||||||
{"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True},
|
{"name": "name", "label": "Name", "field": "name", "align": "left", "sortable": True},
|
||||||
{"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"},
|
{"name": "ipv4", "label": "IPv4", "field": "ipv4", "align": "left"},
|
||||||
{"name": "ipv6", "label": "IPv6", "field": "ipv6", "align": "left"},
|
{"name": "ipv6", "label": "IPv6", "field": "ipv6", "align": "left"},
|
||||||
|
|
@ -169,6 +198,15 @@ async def devices_page():
|
||||||
]
|
]
|
||||||
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
table = ui.table(columns=columns, rows=[], row_key="id").classes("w-full")
|
||||||
table.on("rowClick", on_row_click)
|
table.on("rowClick", on_row_click)
|
||||||
|
table.add_slot(
|
||||||
|
"body-cell-status",
|
||||||
|
'''
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-badge :color="props.row.status_color" rounded class="q-mr-sm" />
|
||||||
|
<span class="text-caption">{{ props.row.status_label }}</span>
|
||||||
|
</q-td>
|
||||||
|
''',
|
||||||
|
)
|
||||||
table.add_slot(
|
table.add_slot(
|
||||||
"body-cell-actions",
|
"body-cell-actions",
|
||||||
'''
|
'''
|
||||||
|
|
@ -194,27 +232,27 @@ async def devices_page():
|
||||||
|
|
||||||
with ui.grid(columns=2).classes("w-full gap-2"):
|
with ui.grid(columns=2).classes("w-full gap-2"):
|
||||||
create_use_default_ips = ui.switch("Use default Allowed IPs", value=True)
|
create_use_default_ips = ui.switch("Use default Allowed IPs", value=True)
|
||||||
create_allowed_ips = ui.input("Allowed IPs", placeholder="0.0.0.0/0, ::/0").props(
|
create_allowed_ips = ui.input("Allowed IPs", value=_defaults["allowed_ips"]).props(
|
||||||
"outlined dense"
|
"outlined dense"
|
||||||
).classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v)
|
).classes("w-full").bind_enabled_from(create_use_default_ips, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_dns = ui.switch("Use default DNS", value=True)
|
create_use_default_dns = ui.switch("Use default DNS", value=True)
|
||||||
create_dns = ui.input("DNS Servers", placeholder="1.1.1.1, 1.0.0.1").props(
|
create_dns = ui.input("DNS Servers", value=_defaults["dns"]).props(
|
||||||
"outlined dense"
|
"outlined dense"
|
||||||
).classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v)
|
).classes("w-full").bind_enabled_from(create_use_default_dns, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_endpoint = ui.switch("Use default Endpoint", value=True)
|
create_use_default_endpoint = ui.switch("Use default Endpoint", value=True)
|
||||||
create_endpoint = ui.input("Endpoint", placeholder="vpn.example.com").props(
|
create_endpoint = ui.input("Endpoint", value=_defaults["endpoint"]).props(
|
||||||
"outlined dense"
|
"outlined dense"
|
||||||
).classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v)
|
).classes("w-full").bind_enabled_from(create_use_default_endpoint, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_mtu = ui.switch("Use default MTU", value=True)
|
create_use_default_mtu = ui.switch("Use default MTU", value=True)
|
||||||
create_mtu = ui.input("MTU", placeholder="1280").props(
|
create_mtu = ui.input("MTU", value=_defaults["mtu"]).props(
|
||||||
"outlined dense"
|
"outlined dense"
|
||||||
).classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v)
|
).classes("w-full").bind_enabled_from(create_use_default_mtu, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
create_use_default_keepalive = ui.switch("Use default Keepalive", value=True)
|
create_use_default_keepalive = ui.switch("Use default Keepalive", value=True)
|
||||||
create_keepalive = ui.input("Persistent Keepalive", placeholder="25").props(
|
create_keepalive = ui.input("Persistent Keepalive", value=_defaults["keepalive"]).props(
|
||||||
"outlined dense"
|
"outlined dense"
|
||||||
).classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v)
|
).classes("w-full").bind_enabled_from(create_use_default_keepalive, "value", backward=lambda v: not v)
|
||||||
|
|
||||||
|
|
@ -224,8 +262,7 @@ async def devices_page():
|
||||||
|
|
||||||
await refresh_table()
|
await refresh_table()
|
||||||
|
|
||||||
# Auto-refresh stats every 30 seconds
|
ui.timer(5, refresh_table)
|
||||||
ui.timer(30, refresh_table)
|
|
||||||
|
|
||||||
|
|
||||||
@ui.page("/devices/{device_id}")
|
@ui.page("/devices/{device_id}")
|
||||||
|
|
@ -236,9 +273,10 @@ async def device_detail_page(device_id: str):
|
||||||
layout()
|
layout()
|
||||||
user_id = UUID(app.storage.user["user_id"])
|
user_id = UUID(app.storage.user["user_id"])
|
||||||
|
|
||||||
|
role = app.storage.user.get("role", "")
|
||||||
async with async_session() as sess:
|
async with async_session() as sess:
|
||||||
device = await sess.get(Device, UUID(device_id))
|
device = await sess.get(Device, UUID(device_id))
|
||||||
if not device or device.user_id != user_id:
|
if not device or (device.user_id != user_id and role != "admin"):
|
||||||
ui.label("Device not found").classes("text-h5 text-negative p-4")
|
ui.label("Device not found").classes("text-h5 text-negative p-4")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -317,32 +355,133 @@ async def device_detail_page(device_id: str):
|
||||||
# Traffic stats (live-updating)
|
# Traffic stats (live-updating)
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
ui.label("Traffic Stats").classes("text-subtitle1 text-bold")
|
ui.label("Traffic Stats").classes("text-subtitle1 text-bold")
|
||||||
ui.label("Auto-refreshes every 30s").classes("text-caption text-grey")
|
|
||||||
ui.separator()
|
ui.separator()
|
||||||
with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"):
|
from wiregui.utils.time import connection_status
|
||||||
|
_color, _label = connection_status(device.latest_handshake)
|
||||||
|
with ui.row().classes("items-center gap-2 q-pa-sm"):
|
||||||
|
stat_badge = ui.badge("", color=_color).props("rounded")
|
||||||
|
stat_status = ui.label(_label).classes("text-caption")
|
||||||
|
|
||||||
|
with ui.grid(columns=3).classes("w-full gap-2 q-pa-sm"):
|
||||||
ui.label("RX:").classes("text-bold")
|
ui.label("RX:").classes("text-bold")
|
||||||
stat_rx = ui.label(_format_bytes(device.rx_bytes))
|
stat_rx = ui.label(_format_bytes(device.rx_bytes))
|
||||||
|
stat_rx_rate = ui.label("").classes("text-caption text-grey")
|
||||||
|
|
||||||
ui.label("TX:").classes("text-bold")
|
ui.label("TX:").classes("text-bold")
|
||||||
stat_tx = ui.label(_format_bytes(device.tx_bytes))
|
stat_tx = ui.label(_format_bytes(device.tx_bytes))
|
||||||
|
stat_tx_rate = ui.label("").classes("text-caption text-grey")
|
||||||
|
|
||||||
ui.label("Last Handshake:").classes("text-bold")
|
ui.label("Last Handshake:").classes("text-bold")
|
||||||
stat_handshake = ui.label(str(device.latest_handshake)[:19] if device.latest_handshake else "-")
|
stat_handshake = ui.label(str(device.latest_handshake)[:19] if device.latest_handshake else "-")
|
||||||
|
ui.label("") # spacer
|
||||||
|
|
||||||
ui.label("Remote IP:").classes("text-bold")
|
ui.label("Remote IP:").classes("text-bold")
|
||||||
stat_remote = ui.label(device.remote_ip or "-")
|
stat_remote = ui.label(device.remote_ip or "-")
|
||||||
|
ui.label("") # spacer
|
||||||
|
|
||||||
|
# Traffic chart
|
||||||
|
MAX_CHART_POINTS = 60
|
||||||
|
_chart_times: list[str] = []
|
||||||
|
_chart_rx: list[float] = []
|
||||||
|
_chart_tx: list[float] = []
|
||||||
|
|
||||||
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
|
ui.label("Traffic Rate").classes("text-subtitle1 text-bold")
|
||||||
|
ui.separator()
|
||||||
|
traffic_chart = ui.echart({
|
||||||
|
"tooltip": {
|
||||||
|
"trigger": "axis",
|
||||||
|
":valueFormatter": """(v) => {
|
||||||
|
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s';
|
||||||
|
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s';
|
||||||
|
return v.toFixed(0) + ' B/s';
|
||||||
|
}""",
|
||||||
|
},
|
||||||
|
"legend": {"data": ["RX/s", "TX/s"], "right": 20, "top": 5},
|
||||||
|
"xAxis": {"type": "category", "data": [], "boundaryGap": False},
|
||||||
|
"yAxis": {
|
||||||
|
"type": "value",
|
||||||
|
"axisLabel": {
|
||||||
|
":formatter": """(v) => {
|
||||||
|
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s';
|
||||||
|
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s';
|
||||||
|
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s';
|
||||||
|
return v.toFixed(0) + ' B/s';
|
||||||
|
}""",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"name": "RX/s",
|
||||||
|
"type": "line",
|
||||||
|
"smooth": True,
|
||||||
|
"symbol": "none",
|
||||||
|
"areaStyle": {"opacity": 0.15},
|
||||||
|
"lineStyle": {"width": 2},
|
||||||
|
"itemStyle": {"color": "#3598C3"},
|
||||||
|
"data": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TX/s",
|
||||||
|
"type": "line",
|
||||||
|
"smooth": True,
|
||||||
|
"symbol": "none",
|
||||||
|
"areaStyle": {"opacity": 0.15},
|
||||||
|
"lineStyle": {"width": 2},
|
||||||
|
"itemStyle": {"color": "#5AA6B9"},
|
||||||
|
"data": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"grid": {"left": 60, "right": 20, "top": 40, "bottom": 30},
|
||||||
|
}).classes("w-full").style("height: 250px")
|
||||||
|
|
||||||
|
_prev_rx = device.rx_bytes or 0
|
||||||
|
_prev_tx = device.tx_bytes or 0
|
||||||
|
_prev = {"rx": _prev_rx, "tx": _prev_tx}
|
||||||
|
|
||||||
async def refresh_stats():
|
async def refresh_stats():
|
||||||
|
from wiregui.utils.time import connection_status
|
||||||
|
from datetime import datetime
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
d = await session.get(Device, UUID(device_id))
|
d = await session.get(Device, UUID(device_id))
|
||||||
if not d:
|
if not d:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Compute rates
|
||||||
|
cur_rx = d.rx_bytes or 0
|
||||||
|
cur_tx = d.tx_bytes or 0
|
||||||
|
rx_rate = max(0, (cur_rx - _prev["rx"]) / 5)
|
||||||
|
tx_rate = max(0, (cur_tx - _prev["tx"]) / 5)
|
||||||
|
_prev["rx"] = cur_rx
|
||||||
|
_prev["tx"] = cur_tx
|
||||||
|
|
||||||
|
# Update labels
|
||||||
stat_rx.text = _format_bytes(d.rx_bytes)
|
stat_rx.text = _format_bytes(d.rx_bytes)
|
||||||
stat_tx.text = _format_bytes(d.tx_bytes)
|
stat_tx.text = _format_bytes(d.tx_bytes)
|
||||||
|
stat_rx_rate.text = f"({_format_bytes(int(rx_rate))}/s)"
|
||||||
|
stat_tx_rate.text = f"({_format_bytes(int(tx_rate))}/s)"
|
||||||
stat_handshake.text = str(d.latest_handshake)[:19] if d.latest_handshake else "-"
|
stat_handshake.text = str(d.latest_handshake)[:19] if d.latest_handshake else "-"
|
||||||
stat_remote.text = d.remote_ip or "-"
|
stat_remote.text = d.remote_ip or "-"
|
||||||
|
color, label = connection_status(d.latest_handshake)
|
||||||
|
stat_badge.props(f'color={color}')
|
||||||
|
stat_status.text = label
|
||||||
|
|
||||||
ui.timer(30, refresh_stats)
|
# Update chart
|
||||||
|
now = datetime.now().strftime("%H:%M:%S")
|
||||||
|
_chart_times.append(now)
|
||||||
|
_chart_rx.append(round(rx_rate, 1))
|
||||||
|
_chart_tx.append(round(tx_rate, 1))
|
||||||
|
if len(_chart_times) > MAX_CHART_POINTS:
|
||||||
|
_chart_times.pop(0)
|
||||||
|
_chart_rx.pop(0)
|
||||||
|
_chart_tx.pop(0)
|
||||||
|
|
||||||
|
traffic_chart.options["xAxis"]["data"] = _chart_times
|
||||||
|
traffic_chart.options["series"][0]["data"] = _chart_rx
|
||||||
|
traffic_chart.options["series"][1]["data"] = _chart_tx
|
||||||
|
traffic_chart.update()
|
||||||
|
|
||||||
|
ui.timer(5, refresh_stats)
|
||||||
|
|
||||||
# Active configuration
|
# Active configuration
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
|
|
@ -439,25 +578,28 @@ async def device_detail_page(device_id: str):
|
||||||
def _show_config_dialog(device_name: str, config_text: str):
|
def _show_config_dialog(device_name: str, config_text: str):
|
||||||
"""Show a dialog with the WireGuard client configuration and QR code."""
|
"""Show a dialog with the WireGuard client configuration and QR code."""
|
||||||
with ui.dialog(value=True) as dialog:
|
with ui.dialog(value=True) as dialog:
|
||||||
with ui.card().classes("w-96"):
|
with ui.card().classes("w-[700px] max-w-[90vw]"):
|
||||||
ui.label(f"Config for {device_name}").classes("text-h6")
|
ui.label(f"Config for {device_name}").classes("text-h6")
|
||||||
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative")
|
ui.label("Save this — the private key won't be shown again.").classes("text-caption text-negative q-mb-sm")
|
||||||
|
|
||||||
ui.textarea(value=config_text).props("readonly outlined").classes(
|
ui.code(config_text, language="ini").classes("w-full")
|
||||||
"w-full font-mono text-xs q-mt-sm"
|
|
||||||
).style("min-height: 200px")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
qr = qrcode.make(config_text, image_factory=qrcode.image.svg.SvgPathImage)
|
import base64
|
||||||
|
qr = qrcode.make(config_text)
|
||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
qr.save(buf)
|
qr.save(buf, format="PNG")
|
||||||
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm")
|
b64 = base64.b64encode(buf.getvalue()).decode()
|
||||||
|
with ui.row().classes("w-full justify-center q-mt-md"):
|
||||||
|
ui.image(f"data:image/png;base64,{b64}").style(
|
||||||
|
"width: 200px; height: 200px; border-radius: 8px"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
ui.label("QR code generation failed").classes("text-caption text-grey")
|
ui.label("QR code generation failed").classes("text-caption text-grey")
|
||||||
|
|
||||||
ui.button(
|
with ui.row().classes("w-full gap-2 q-mt-md"):
|
||||||
"Download .conf",
|
ui.button(
|
||||||
on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"),
|
"Download .conf",
|
||||||
).props("color=primary unelevated").classes("w-full q-mt-sm")
|
on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"),
|
||||||
|
).props("color=primary unelevated").classes("flex-grow")
|
||||||
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
|
ui.button("Close", on_click=dialog.close).props("flat")
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ def layout(title: str = "WireGUI"):
|
||||||
with ui.header().classes("items-center justify-between"):
|
with ui.header().classes("items-center justify-between"):
|
||||||
with ui.row().classes("items-center"):
|
with ui.row().classes("items-center"):
|
||||||
ui.button(icon="menu", on_click=lambda: drawer.toggle()).props("flat color=white")
|
ui.button(icon="menu", on_click=lambda: drawer.toggle()).props("flat color=white")
|
||||||
|
ui.image("/img/wiregui.svg").classes("w-8 h-8")
|
||||||
ui.label("WireGUI").classes("text-h6")
|
ui.label("WireGUI").classes("text-h6")
|
||||||
with ui.row().classes("items-center"):
|
with ui.row().classes("items-center"):
|
||||||
if role == "admin":
|
if role == "admin":
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Login page — email/password, MFA redirect, OIDC provider buttons."""
|
"""Login page — email/password, MFA redirect, OIDC/SAML provider buttons."""
|
||||||
|
|
||||||
from nicegui import app, ui
|
from nicegui import app, ui
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
|
|
@ -6,6 +6,7 @@ from sqlmodel import select
|
||||||
from wiregui.auth.oidc import load_providers
|
from wiregui.auth.oidc import load_providers
|
||||||
from wiregui.auth.session import authenticate_user
|
from wiregui.auth.session import authenticate_user
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
from wiregui.models.mfa_method import MFAMethod
|
||||||
from wiregui.pages.style import apply_style
|
from wiregui.pages.style import apply_style
|
||||||
from wiregui.utils.time import utcnow
|
from wiregui.utils.time import utcnow
|
||||||
|
|
@ -18,9 +19,13 @@ async def login_page():
|
||||||
|
|
||||||
apply_style()
|
apply_style()
|
||||||
|
|
||||||
# Load OIDC providers for SSO buttons
|
# Load SSO providers for login buttons
|
||||||
oidc_providers = await load_providers()
|
oidc_providers = await load_providers()
|
||||||
|
|
||||||
|
async with async_session() as session:
|
||||||
|
config = (await session.execute(select(Configuration).limit(1))).scalar_one_or_none()
|
||||||
|
saml_providers = config.saml_identity_providers if config else []
|
||||||
|
|
||||||
async def try_login():
|
async def try_login():
|
||||||
user = await authenticate_user(email.value, password.value)
|
user = await authenticate_user(email.value, password.value)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
@ -62,7 +67,9 @@ async def login_page():
|
||||||
ui.navigate.to("/")
|
ui.navigate.to("/")
|
||||||
|
|
||||||
with ui.column().classes("absolute-center items-center"):
|
with ui.column().classes("absolute-center items-center"):
|
||||||
ui.label("WireGUI").classes("text-h4 text-bold")
|
with ui.row().classes("items-center gap-3"):
|
||||||
|
ui.image("/img/wiregui.svg").classes("w-16 h-16")
|
||||||
|
ui.label("WireGUI").classes("text-h4 text-bold")
|
||||||
ui.label("Sign in to your account").classes("text-subtitle1 q-mb-md")
|
ui.label("Sign in to your account").classes("text-subtitle1 q-mb-md")
|
||||||
|
|
||||||
with ui.card().classes("w-80"):
|
with ui.card().classes("w-80"):
|
||||||
|
|
@ -74,8 +81,8 @@ async def login_page():
|
||||||
|
|
||||||
password.on("keydown.enter", try_login)
|
password.on("keydown.enter", try_login)
|
||||||
|
|
||||||
# OIDC provider buttons
|
# SSO provider buttons
|
||||||
if oidc_providers:
|
if oidc_providers or saml_providers:
|
||||||
ui.separator().classes("q-my-md")
|
ui.separator().classes("q-my-md")
|
||||||
ui.label("Or sign in with").classes("text-caption text-center w-full")
|
ui.label("Or sign in with").classes("text-caption text-center w-full")
|
||||||
for provider in oidc_providers:
|
for provider in oidc_providers:
|
||||||
|
|
@ -83,5 +90,12 @@ async def login_page():
|
||||||
label = provider.get("label", pid)
|
label = provider.get("label", pid)
|
||||||
ui.button(
|
ui.button(
|
||||||
label,
|
label,
|
||||||
on_click=lambda p=pid: ui.navigate.to(f"/auth/oidc/{p}"),
|
on_click=lambda p=pid: ui.run_javascript(f"window.location.href='/auth/oidc/{p}'"),
|
||||||
|
).props("color=primary unelevated").classes("w-full q-mt-xs")
|
||||||
|
for provider in saml_providers:
|
||||||
|
pid = provider.get("id", "")
|
||||||
|
label = provider.get("label", pid)
|
||||||
|
ui.button(
|
||||||
|
label,
|
||||||
|
on_click=lambda p=pid: ui.run_javascript(f"window.location.href='/auth/saml/{p}'"),
|
||||||
).props("color=primary unelevated").classes("w-full q-mt-xs")
|
).props("color=primary unelevated").classes("w-full q-mt-xs")
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,65 @@
|
||||||
|
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
|
# Logo palette
|
||||||
|
_NAVY = "#0E2747"
|
||||||
|
_BLUE = "#3598C3"
|
||||||
|
_TEAL = "#5AA6B9"
|
||||||
|
_TEAL_LIGHT = "#7AC7D6"
|
||||||
|
_MID_BLUE = "#325F7B"
|
||||||
|
|
||||||
|
|
||||||
def apply_style():
|
def apply_style():
|
||||||
"""Add Manrope font and global CSS overrides. Call once per page."""
|
"""Add Manrope font, logo-based color theme, and global CSS overrides. Call once per page."""
|
||||||
ui.add_head_html(
|
ui.add_head_html(
|
||||||
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
||||||
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
||||||
'<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet">'
|
'<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet">'
|
||||||
)
|
)
|
||||||
ui.add_css("""
|
ui.add_css(f"""
|
||||||
body, input, button, select, textarea {
|
body, input, button, select, textarea {{
|
||||||
font-family: 'Manrope', sans-serif !important;
|
font-family: 'Manrope', sans-serif !important;
|
||||||
}
|
}}
|
||||||
code, .font-mono, .q-table__container .monospace {
|
code, .font-mono, .q-table__container .monospace {{
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
|
||||||
}
|
}}
|
||||||
|
|
||||||
|
/* ---- Light theme colors ---- */
|
||||||
|
:root {{
|
||||||
|
--q-primary: {_BLUE};
|
||||||
|
--q-secondary: {_TEAL};
|
||||||
|
--q-accent: {_TEAL_LIGHT};
|
||||||
|
--q-dark: {_NAVY};
|
||||||
|
--q-positive: #21BA45;
|
||||||
|
--q-negative: #C10015;
|
||||||
|
--q-info: {_MID_BLUE};
|
||||||
|
--q-warning: #F2C037;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Header bar */
|
||||||
|
.q-header {{
|
||||||
|
background: linear-gradient(135deg, {_NAVY} 0%, {_MID_BLUE} 100%) !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Left drawer */
|
||||||
|
.q-drawer {{
|
||||||
|
border-right-color: {_TEAL}33 !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* ---- Dark theme overrides ---- */
|
||||||
|
body.body--dark {{
|
||||||
|
--q-primary: {_TEAL};
|
||||||
|
--q-secondary: {_BLUE};
|
||||||
|
--q-accent: {_TEAL_LIGHT};
|
||||||
|
--q-dark: {_NAVY};
|
||||||
|
--q-info: {_TEAL_LIGHT};
|
||||||
|
}}
|
||||||
|
|
||||||
|
body.body--dark .q-header {{
|
||||||
|
background: linear-gradient(135deg, {_NAVY} 0%, #1a3a5c 100%) !important;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body.body--dark .q-drawer {{
|
||||||
|
border-right-color: {_MID_BLUE}44 !important;
|
||||||
|
}}
|
||||||
""")
|
""")
|
||||||
|
|
@ -129,10 +129,17 @@ async def apply_rule(user_id: str, destination: str, action: str, port_type: str
|
||||||
async def rebuild_all_rules(users_devices_rules: list[dict]) -> None:
|
async def rebuild_all_rules(users_devices_rules: list[dict]) -> None:
|
||||||
"""Full reconciliation: flush and rebuild all per-user chains from DB state.
|
"""Full reconciliation: flush and rebuild all per-user chains from DB state.
|
||||||
|
|
||||||
|
Removes orphaned user chains that are no longer in the DB.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
users_devices_rules: list of dicts with keys:
|
users_devices_rules: list of dicts with keys:
|
||||||
user_id, devices (list of {ipv4, ipv6}), rules (list of {destination, action, port_type, port_range})
|
user_id, devices (list of {ipv4, ipv6}), rules (list of {destination, action, port_type, port_range})
|
||||||
"""
|
"""
|
||||||
|
# Discover existing user_ chains so we can remove orphans
|
||||||
|
existing_user_chains = await _list_user_chains()
|
||||||
|
expected_chains = {_user_chain_name(e["user_id"]) for e in users_devices_rules}
|
||||||
|
orphaned_chains = existing_user_chains - expected_chains
|
||||||
|
|
||||||
commands = []
|
commands = []
|
||||||
|
|
||||||
for entry in users_devices_rules:
|
for entry in users_devices_rules:
|
||||||
|
|
@ -162,9 +169,99 @@ async def rebuild_all_rules(users_devices_rules: list[dict]) -> None:
|
||||||
if dev.get("ipv6"):
|
if dev.get("ipv6"):
|
||||||
commands.append(f"add rule inet {TABLE_NAME} forward ip6 saddr {dev['ipv6']} jump {chain}")
|
commands.append(f"add rule inet {TABLE_NAME} forward ip6 saddr {dev['ipv6']} jump {chain}")
|
||||||
|
|
||||||
if commands:
|
# Remove orphaned user chains (must happen after forward chain is flushed
|
||||||
|
# so there are no remaining jump references to these chains)
|
||||||
|
for chain in orphaned_chains:
|
||||||
|
commands.append(f"flush chain inet {TABLE_NAME} {chain}")
|
||||||
|
commands.append(f"delete chain inet {TABLE_NAME} {chain}")
|
||||||
|
|
||||||
|
await _nft_batch(commands)
|
||||||
|
if orphaned_chains:
|
||||||
|
logger.info("Removed {} orphaned firewall chain(s): {}", len(orphaned_chains), orphaned_chains)
|
||||||
|
logger.info("Firewall rules rebuilt for {} users", len(users_devices_rules))
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_peer_to_peer_policy(enabled: bool) -> None:
|
||||||
|
"""Allow or deny traffic between WireGuard peers (peer-to-peer through the server)."""
|
||||||
|
settings = get_settings()
|
||||||
|
iface = settings.wg_interface
|
||||||
|
v4_net = settings.wg_ipv4_network
|
||||||
|
v6_net = settings.wg_ipv6_network
|
||||||
|
chain = "peer_to_peer"
|
||||||
|
|
||||||
|
commands = [
|
||||||
|
f"add chain inet {TABLE_NAME} {chain}",
|
||||||
|
f"flush chain inet {TABLE_NAME} {chain}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
# Allow traffic from WG subnet destined to WG subnet (both directions through the interface)
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr {v4_net} ip daddr {v4_net} accept')
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr {v6_net} ip6 daddr {v6_net} accept')
|
||||||
|
else:
|
||||||
|
# Drop inter-peer traffic
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr {v4_net} ip daddr {v4_net} drop')
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr {v6_net} ip6 daddr {v6_net} drop')
|
||||||
|
|
||||||
|
try:
|
||||||
await _nft_batch(commands)
|
await _nft_batch(commands)
|
||||||
logger.info("Firewall rules rebuilt for {} users", len(users_devices_rules))
|
# Ensure the forward chain jumps to peer_to_peer before user chains
|
||||||
|
# We flush and re-add to keep ordering correct
|
||||||
|
logger.info("Peer-to-peer policy: {}", "allow" if enabled else "deny")
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("Failed to apply peer-to-peer policy: {}", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_lan_to_peers_policy(enabled: bool) -> None:
|
||||||
|
"""Allow or deny traffic from the local network to WireGuard peers."""
|
||||||
|
settings = get_settings()
|
||||||
|
iface = settings.wg_interface
|
||||||
|
v4_net = settings.wg_ipv4_network
|
||||||
|
v6_net = settings.wg_ipv6_network
|
||||||
|
chain = "lan_to_peers"
|
||||||
|
|
||||||
|
commands = [
|
||||||
|
f"add chain inet {TABLE_NAME} {chain}",
|
||||||
|
f"flush chain inet {TABLE_NAME} {chain}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if enabled:
|
||||||
|
# Allow traffic from non-WG sources destined to WG subnet (LAN → peers)
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr != {v4_net} ip daddr {v4_net} accept')
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr != {v6_net} ip6 daddr {v6_net} accept')
|
||||||
|
else:
|
||||||
|
# Drop LAN → peer traffic
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip saddr != {v4_net} ip daddr {v4_net} drop')
|
||||||
|
commands.append(f'add rule inet {TABLE_NAME} {chain} ip6 saddr != {v6_net} ip6 daddr {v6_net} drop')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _nft_batch(commands)
|
||||||
|
logger.info("LAN-to-peers policy: {}", "allow" if enabled else "deny")
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.error("Failed to apply LAN-to-peers policy: {}", e)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ruleset() -> str:
|
||||||
|
"""Dump the current nftables ruleset for troubleshooting."""
|
||||||
|
try:
|
||||||
|
return await _nft("list ruleset")
|
||||||
|
except RuntimeError:
|
||||||
|
return "nftables is not available.\n\nThis requires root/NET_ADMIN privileges (production container)."
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_user_chains() -> set[str]:
|
||||||
|
"""Return the set of user_ chain names currently in the wiregui table."""
|
||||||
|
try:
|
||||||
|
output = await _nft(f"list table inet {TABLE_NAME}")
|
||||||
|
except RuntimeError:
|
||||||
|
return set()
|
||||||
|
chains = set()
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith("chain user_"):
|
||||||
|
name = line.split()[1]
|
||||||
|
chains.add(name)
|
||||||
|
return chains
|
||||||
|
|
||||||
|
|
||||||
def _user_chain_name(user_id: str) -> str:
|
def _user_chain_name(user_id: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -83,8 +83,7 @@ async def _reconcile_firewall(devices: list[Device], rules: list[Rule]) -> None:
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
if entries:
|
try:
|
||||||
try:
|
await firewall.rebuild_all_rules(entries)
|
||||||
await firewall.rebuild_all_rules(entries)
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.error("Reconcile: firewall rebuild failed: {}", e)
|
||||||
logger.error("Reconcile: firewall rebuild failed: {}", e)
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
"""WireGuard key generation and encryption utilities."""
|
"""WireGuard key generation and encryption utilities — pure Python, no wg CLI needed."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, NoEncryption, PrivateFormat, PublicFormat
|
||||||
|
|
||||||
|
|
||||||
def generate_private_key() -> str:
|
def generate_private_key() -> str:
|
||||||
"""Generate a WireGuard private key using `wg genkey`."""
|
"""Generate a WireGuard private key (Curve25519, base64-encoded)."""
|
||||||
result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True)
|
key = X25519PrivateKey.generate()
|
||||||
return result.stdout.strip()
|
raw = key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||||
|
return base64.b64encode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
def derive_public_key(private_key: str) -> str:
|
def derive_public_key(private_key: str) -> str:
|
||||||
"""Derive a WireGuard public key from a private key using `wg pubkey`."""
|
"""Derive a WireGuard public key from a private key."""
|
||||||
result = subprocess.run(
|
raw = base64.b64decode(private_key)
|
||||||
["wg", "pubkey"], input=private_key, capture_output=True, text=True, check=True
|
key = X25519PrivateKey.from_private_bytes(raw)
|
||||||
)
|
pub = key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
return result.stdout.strip()
|
return base64.b64encode(pub).decode()
|
||||||
|
|
||||||
|
|
||||||
def generate_keypair() -> tuple[str, str]:
|
def generate_keypair() -> tuple[str, str]:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""IP address allocation for WireGuard tunnel addresses."""
|
"""IP address allocation for WireGuard tunnel addresses."""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from ipaddress import IPv4Network, IPv6Network, ip_address
|
from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -11,17 +11,17 @@ from wiregui.models.device import Device
|
||||||
|
|
||||||
|
|
||||||
async def allocate_ipv4(session: AsyncSession, network_cidr: str) -> str:
|
async def allocate_ipv4(session: AsyncSession, network_cidr: str) -> str:
|
||||||
"""Find the next available IPv4 address in the given CIDR range."""
|
"""Find an available IPv4 address in the given CIDR range."""
|
||||||
network = IPv4Network(network_cidr, strict=False)
|
network = IPv4Network(network_cidr, strict=False)
|
||||||
used = await _get_used_ips(session, "ipv4")
|
used = await _get_used_ips(session, "ipv4")
|
||||||
return _find_available(network, used)
|
return _find_available_v4(network, used)
|
||||||
|
|
||||||
|
|
||||||
async def allocate_ipv6(session: AsyncSession, network_cidr: str) -> str:
|
async def allocate_ipv6(session: AsyncSession, network_cidr: str) -> str:
|
||||||
"""Find the next available IPv6 address in the given CIDR range."""
|
"""Find an available IPv6 address in the given CIDR range."""
|
||||||
network = IPv6Network(network_cidr, strict=False)
|
network = IPv6Network(network_cidr, strict=False)
|
||||||
used = await _get_used_ips(session, "ipv6")
|
used = await _get_used_ips(session, "ipv6")
|
||||||
return _find_available(network, used)
|
return _find_available_v6(network, used)
|
||||||
|
|
||||||
|
|
||||||
async def _get_used_ips(session: AsyncSession, field: str) -> set[str]:
|
async def _get_used_ips(session: AsyncSession, field: str) -> set[str]:
|
||||||
|
|
@ -31,30 +31,54 @@ async def _get_used_ips(session: AsyncSession, field: str) -> set[str]:
|
||||||
return {row[0] for row in result.all()}
|
return {row[0] for row in result.all()}
|
||||||
|
|
||||||
|
|
||||||
def _find_available(network: IPv4Network | IPv6Network, used: set[str]) -> str:
|
def _find_available_v4(network: IPv4Network, used: set[str]) -> str:
|
||||||
"""Find an available IP in the network, starting from a random offset."""
|
"""Find an available IPv4 by random sampling — O(1) per attempt, no list materialization."""
|
||||||
hosts = list(network.hosts())
|
# Usable range: network_address + 2 to broadcast - 1 (skip network, gateway, broadcast)
|
||||||
if not hosts:
|
first = int(network.network_address) + 2
|
||||||
|
last = int(network.broadcast_address) - 1
|
||||||
|
pool_size = last - first + 1
|
||||||
|
|
||||||
|
if pool_size <= 0:
|
||||||
raise ValueError(f"No usable hosts in {network}")
|
raise ValueError(f"No usable hosts in {network}")
|
||||||
|
if len(used) >= pool_size:
|
||||||
|
raise ValueError(f"No available addresses in {network}")
|
||||||
|
|
||||||
# Skip the first host (gateway/server address)
|
for _ in range(min(pool_size, 1000)):
|
||||||
hosts = hosts[1:]
|
candidate = str(IPv4Address(random.randint(first, last)))
|
||||||
if not hosts:
|
|
||||||
raise ValueError(f"No usable hosts in {network} after reserving gateway")
|
|
||||||
|
|
||||||
# Start from a random offset, then scan forward and backward
|
|
||||||
start = random.randint(0, len(hosts) - 1)
|
|
||||||
|
|
||||||
# Forward scan
|
|
||||||
for i in range(start, len(hosts)):
|
|
||||||
candidate = str(hosts[i])
|
|
||||||
if candidate not in used:
|
if candidate not in used:
|
||||||
logger.debug("Allocated {} from {}", candidate, network)
|
logger.debug("Allocated {} from {}", candidate, network)
|
||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
# Backward scan
|
# Fallback: sequential scan (only if random sampling keeps hitting used IPs)
|
||||||
for i in range(start - 1, -1, -1):
|
for offset in range(pool_size):
|
||||||
candidate = str(hosts[i])
|
candidate = str(IPv4Address(first + offset))
|
||||||
|
if candidate not in used:
|
||||||
|
logger.debug("Allocated {} from {}", candidate, network)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise ValueError(f"No available addresses in {network}")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_available_v6(network: IPv6Network, used: set[str]) -> str:
|
||||||
|
"""Find an available IPv6 by random sampling."""
|
||||||
|
first = int(network.network_address) + 2
|
||||||
|
last = int(network.broadcast_address) - 1
|
||||||
|
pool_size = last - first + 1
|
||||||
|
|
||||||
|
if pool_size <= 0:
|
||||||
|
raise ValueError(f"No usable hosts in {network}")
|
||||||
|
if len(used) >= pool_size:
|
||||||
|
raise ValueError(f"No available addresses in {network}")
|
||||||
|
|
||||||
|
for _ in range(min(pool_size, 1000)):
|
||||||
|
candidate = str(IPv6Address(random.randint(first, last)))
|
||||||
|
if candidate not in used:
|
||||||
|
logger.debug("Allocated {} from {}", candidate, network)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
# Fallback: sequential scan
|
||||||
|
for offset in range(pool_size):
|
||||||
|
candidate = str(IPv6Address(first + offset))
|
||||||
if candidate not in used:
|
if candidate not in used:
|
||||||
logger.debug("Allocated {} from {}", candidate, network)
|
logger.debug("Allocated {} from {}", candidate, network)
|
||||||
return candidate
|
return candidate
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,20 @@ from datetime import UTC, datetime
|
||||||
def utcnow() -> datetime:
|
def utcnow() -> datetime:
|
||||||
"""Return current UTC time as a naive datetime (for Postgres TIMESTAMP WITHOUT TIME ZONE)."""
|
"""Return current UTC time as a naive datetime (for Postgres TIMESTAMP WITHOUT TIME ZONE)."""
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
return datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def connection_status(latest_handshake: datetime | None) -> tuple[str, str]:
|
||||||
|
"""Return (color, label) based on handshake age.
|
||||||
|
|
||||||
|
Green: handshake < 2 min
|
||||||
|
Yellow: handshake < 5 min
|
||||||
|
Red: no recent handshake or never connected
|
||||||
|
"""
|
||||||
|
if latest_handshake is None:
|
||||||
|
return "red", "offline"
|
||||||
|
age = (utcnow() - latest_handshake).total_seconds()
|
||||||
|
if age < 120:
|
||||||
|
return "green", "online"
|
||||||
|
if age < 300:
|
||||||
|
return "yellow", "idle"
|
||||||
|
return "red", "offline"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Build WireGuard client configuration files."""
|
"""Build WireGuard client configuration files."""
|
||||||
|
|
||||||
from wiregui.config import get_settings
|
from wiregui.config import get_settings
|
||||||
|
from wiregui.models.configuration import Configuration
|
||||||
from wiregui.models.device import Device
|
from wiregui.models.device import Device
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,16 +9,40 @@ def build_client_config(
|
||||||
device: Device,
|
device: Device,
|
||||||
private_key: str,
|
private_key: str,
|
||||||
server_public_key: str,
|
server_public_key: str,
|
||||||
|
db_config: Configuration | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a WireGuard [Interface]+[Peer] config string for a device."""
|
"""Build a WireGuard [Interface]+[Peer] config string for a device.
|
||||||
|
|
||||||
|
Uses DB Configuration for client defaults when available,
|
||||||
|
falls back to env-based Settings.
|
||||||
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Resolve per-device or default values
|
# Resolve per-device overrides → DB config defaults → env var defaults
|
||||||
dns = device.dns if not device.use_default_dns else settings.wg_dns
|
if device.use_default_dns:
|
||||||
endpoint_host = device.endpoint if not device.use_default_endpoint else settings.wg_endpoint_host
|
dns = db_config.default_client_dns if db_config and db_config.default_client_dns else settings.wg_dns
|
||||||
mtu = device.mtu if not device.use_default_mtu else settings.wg_mtu
|
else:
|
||||||
keepalive = device.persistent_keepalive if not device.use_default_persistent_keepalive else settings.wg_persistent_keepalive
|
dns = device.dns
|
||||||
allowed_ips = device.allowed_ips if not device.use_default_allowed_ips else settings.wg_allowed_ips
|
|
||||||
|
if device.use_default_endpoint:
|
||||||
|
endpoint_host = db_config.default_client_endpoint if db_config and db_config.default_client_endpoint else settings.wg_endpoint_host
|
||||||
|
else:
|
||||||
|
endpoint_host = device.endpoint
|
||||||
|
|
||||||
|
if device.use_default_mtu:
|
||||||
|
mtu = db_config.default_client_mtu if db_config else settings.wg_mtu
|
||||||
|
else:
|
||||||
|
mtu = device.mtu
|
||||||
|
|
||||||
|
if device.use_default_persistent_keepalive:
|
||||||
|
keepalive = db_config.default_client_persistent_keepalive if db_config else settings.wg_persistent_keepalive
|
||||||
|
else:
|
||||||
|
keepalive = device.persistent_keepalive
|
||||||
|
|
||||||
|
if device.use_default_allowed_ips:
|
||||||
|
allowed_ips = db_config.default_client_allowed_ips if db_config and db_config.default_client_allowed_ips else settings.wg_allowed_ips
|
||||||
|
else:
|
||||||
|
allowed_ips = device.allowed_ips
|
||||||
|
|
||||||
# Build address list
|
# Build address list
|
||||||
addresses = []
|
addresses = []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue