From 61240dab725ef90a48eb8adfd4ebc4b61b98e216 Mon Sep 17 00:00:00 2001 From: GoliathLabs Date: Mon, 8 Aug 2022 16:54:29 +0200 Subject: [PATCH 001/107] Added: workflows to publish our own images --- .github/buildkitd.toml | 2 ++ .github/dependabot.yml | 22 ++++++++++++++ .github/workflows/build.yml | 48 +++++++++++++++++++++++++++++++ .github/workflows/docker.yml | 56 ++++++++++++++++++++++++++++++++++++ Dockerfile | 4 +-- docker-compose.yml | 6 ++-- 6 files changed, 132 insertions(+), 6 deletions(-) create mode 100644 .github/buildkitd.toml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/docker.yml diff --git a/.github/buildkitd.toml b/.github/buildkitd.toml new file mode 100644 index 0000000..ff6039f --- /dev/null +++ b/.github/buildkitd.toml @@ -0,0 +1,2 @@ +[worker.oci] + max-parallelism = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..547ae9f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +# Docs: + +version: 2 + +updates: + - package-ecosystem: python + directory: / + schedule: {interval: monthly} + reviewers: [freifunkMUC/salt-stack] + assignees: [freifunkMUC/salt-stack] + + - package-ecosystem: github-actions + directory: / + schedule: {interval: monthly} + reviewers: [freifunkMUC/salt-stack] + assignees: [freifunkMUC/salt-stack] + + - package-ecosystem: docker + directory: / + schedule: {interval: monthly} + reviewers: [freifunkMUC/salt-stack] + assignees: [freifunkMUC/salt-stack] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..6330862 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,48 @@ +name: Build Docker image + +on: + push: + branches: + - master + pull_request: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup QEMU + uses: docker/setup-qemu-action@v2 + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v2 + with: + config: .github/buildkitd.toml + - name: Retrieve author data + run: | + echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.authors=${{ env.AUTHOR }} + - name: Build Docker image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64 + push: false + load: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - name: Inspect Docker image + run: docker image inspect ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..41e0a8b --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,56 @@ +name: Publish Docker image +on: + push: + branches: + - master + tags: + - 'v*.*.*' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + push_to_registry: + name: Push Docker image to GitHub Packages + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + with: + platforms: all + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + with: + config: .github/buildkitd.toml + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Retrieve author data + run: | + echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.authors=${{ env.AUTHOR }} + - name: Build container image + uses: docker/build-push-action@v3 + with: + context: . + platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/ppc64le,linux/s390x + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/Dockerfile b/Dockerfile index 065c52c..e2bbc8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM l.gcr.io/google/bazel:latest AS builder +FROM gcr.io/cloud-marketplace-containers/google/bazel:2.5.0 AS builder WORKDIR /wgkex @@ -8,7 +8,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3 +FROM python:3.10.6-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ diff --git a/docker-compose.yml b/docker-compose.yml index 7330800..0f96056 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,7 @@ services: - "9001:9001" broker: - build: . - image: wgkex + image: ghcr.io/freifunkmuc/wgkex:latest command: broker restart: unless-stopped ports: @@ -32,8 +31,7 @@ services: MQTT_TLS: ${MQTT_TLS-False} worker: - build: . - image: wgkex + image: ghcr.io/freifunkmuc/wgkex:latest command: worker restart: unless-stopped #volumes: From 40581ef1cad103ff719c96663cecda3fe747f0ff Mon Sep 17 00:00:00 2001 From: Felix <8057646+GoliathLabs@users.noreply.github.com> Date: Mon, 8 Aug 2022 17:11:37 +0200 Subject: [PATCH 002/107] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index e2bbc8a..03ac518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM gcr.io/cloud-marketplace-containers/google/bazel:2.5.0 AS builder +FROM gcr.io/cloud-marketplace-containers/google/bazel:3.5.0 AS builder WORKDIR /wgkex From c7e69e5cf8b6b2a5b7d93bb31ebfe8570de75466 Mon Sep 17 00:00:00 2001 From: Felix <8057646+GoliathLabs@users.noreply.github.com> Date: Mon, 8 Aug 2022 17:20:33 +0200 Subject: [PATCH 003/107] Update docker.yml --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 41e0a8b..b1709e7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -48,7 +48,7 @@ jobs: uses: docker/build-push-action@v3 with: context: . - platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/ppc64le,linux/s390x + platforms: linux/amd64 push: true cache-from: type=gha cache-to: type=gha,mode=max From 2512a7d93b2045263d18a7ab2a2e79b4d041e6b6 Mon Sep 17 00:00:00 2001 From: Annika Wickert Date: Tue, 9 Aug 2022 11:22:41 +0200 Subject: [PATCH 004/107] Update dependabot.yml --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 547ae9f..92bfa4c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,7 @@ version: 2 updates: - - package-ecosystem: python + - package-ecosystem: pip directory: / schedule: {interval: monthly} reviewers: [freifunkMUC/salt-stack] From e6ac98d682d730cf71f43285d9fbaa120d659034 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 09:23:11 +0000 Subject: [PATCH 005/107] Bump actions/cache from 2 to 3 Bumps [actions/cache](https://github.com/actions/cache) from 2 to 3. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/bazel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 31992d7..eebe11d 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: "/home/runner/.cache/bazel" key: bazel From 81dc75274df388f080448ff6b685e10d1b9fb70e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 09:23:18 +0000 Subject: [PATCH 006/107] Bump actions/upload-artifact from 1 to 3 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 1 to 3. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v1...v3) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a9ec5f..c6bb5af 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,7 @@ jobs: run: | cp -v CHANGELOG.md dist/ - name: Upload artifact - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v3 with: name: dist path: dist From 9d11a4ffc90c33408804bc377f1bd76befec58d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 09:23:21 +0000 Subject: [PATCH 007/107] Bump actions/setup-python from 2 to 4 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 2 to 4. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v2...v4) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/black.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index f58e4c6..0003738 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -7,5 +7,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 - uses: psf/black@stable diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0a9ec5f..a1f126e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 0 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 # https://github.com/marketplace/actions/python-poetry-action - uses: abatilo/actions-poetry@v2.1.0 - name: Install dependencies From 39c5ec7a7ae6979daf95f42cfc7ae12e428c371b Mon Sep 17 00:00:00 2001 From: GoliathLabs Date: Tue, 9 Aug 2022 15:28:09 +0200 Subject: [PATCH 008/107] Changed: push branch to main --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6330862..1307308 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ name: Build Docker image on: push: branches: - - master + - main pull_request: env: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b1709e7..1e216f2 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -2,7 +2,7 @@ name: Publish Docker image on: push: branches: - - master + - main tags: - 'v*.*.*' From 455551106a7e4bfaba51168ddbee09e3a01d75c6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 14:15:26 +0000 Subject: [PATCH 009/107] Bump abatilo/actions-poetry from 2.1.0 to 2.1.5 Bumps [abatilo/actions-poetry](https://github.com/abatilo/actions-poetry) from 2.1.0 to 2.1.5. - [Release notes](https://github.com/abatilo/actions-poetry/releases) - [Changelog](https://github.com/abatilo/actions-poetry/blob/master/.releaserc) - [Commits](https://github.com/abatilo/actions-poetry/compare/v2.1.0...v2.1.5) --- updated-dependencies: - dependency-name: abatilo/actions-poetry dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3427006..8661107 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 # https://github.com/marketplace/actions/python-poetry-action - - uses: abatilo/actions-poetry@v2.1.0 + - uses: abatilo/actions-poetry@v2.1.5 - name: Install dependencies run: | poetry install --no-dev From 87df8e66af7045b29d60559fe1d2c5725eefa4c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Aug 2022 14:15:31 +0000 Subject: [PATCH 010/107] Bump actions/checkout from 2 to 3 Bumps [actions/checkout](https://github.com/actions/checkout) from 2 to 3. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/bazel.yml | 2 +- .github/workflows/black.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/pylint.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index eebe11d..8ccc140 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -11,7 +11,7 @@ jobs: with: path: "/home/runner/.cache/bazel" key: bazel - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Run Bazel tests run: bazel test ...:all --test_output=all --action_env=WGKEX_CONFIG_FILE=`pwd`/wgkex.yaml.example - name: Python coverage diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 0003738..048e600 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -6,6 +6,6 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: actions/setup-python@v4 - uses: psf/black@stable diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3427006..be93bf9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -10,7 +10,7 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-python@v4 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index c50aa7f..8091195 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -5,7 +5,7 @@ jobs: name: GitHub Action for pylint runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v3 - name: GitHub Action for pylint uses: cclauss/GitHub-Action-for-pylint@master with: From 6c9242408ced522314e9cbb789c8b691f8039fb6 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Mon, 25 Jul 2022 15:35:05 +0200 Subject: [PATCH 011/107] README & build fixes --- README.md | 43 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c3439ff..f8b6c69 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ wgkex is a WireGuard key exchange and management tool designed and run by FFMUC. ## Overview -WireGuard Key Exchange is a tool consisting of two parts: a frontend (broker) and a backend (worker). These components +WireGuard Key Exchange is a tool consisting of two parts: a frontend (broker) and a backend (worker). These components communicate to each other via MQTT - a messaging bus. ![](Docs/architecture.png) @@ -43,7 +43,7 @@ JSON POST'd to this endpoint should be in this format: ```json { - "domain": "CONFIGURED_DOMAIN", + "domain": "CONFIGURED_DOMAIN", "public_key": "PUBLIC_KEY" } ``` @@ -53,9 +53,12 @@ The broker will validate the domain and public key, and if valid, will push the ### Backend worker The backend (worker) waits for new keys to appear on the MQTT message bus. Once a new key appears, the worker performs -validation task on the key, then injects those keys into a WireGuard instance(While also updating the VxLAN FDB). +validation task on the key, then injects those keys into a WireGuard instance(While also updating the VxLAN FDB). +It reports metrics like number of connected peers and instance data like local address, WG listening port and +external domain name (configured in config.yml) back to the broker. +Each worker must run on a machine with a unique hostname, as it is used for separation of metrics. -This tool is intended to facilitate running BATMAN over VXLAN over WireGuard as a means to create encrypted +This tool is intended to facilitate running BATMAN over VXLAN over WireGuard as a means to create encrypted high-performance mesh links. For further information, please see this [presentation on the architecture](https://www.slideshare.net/AnnikaWickert/ffmuc-goes-wild-infrastructure-recap-2020-rc3) @@ -73,9 +76,9 @@ can also be overwritten by setting the environment variable `WGKEX_CONFIG_FILE`. ## Running the broker -* The broker web frontend can be started directly from a Git checkout: +* The worker can be started directly from a Git checkout: -``` +```sh # defaults to /etc/wgkex.yaml if not set export WGKEX_CONFIG_FILE=/opt/wgkex/wgkex.yaml bazel build //wgkex/worker:app @@ -85,7 +88,7 @@ bazel build //wgkex/worker:app * The broker can also be built and run via [bazel](https://bazel.build): -```shell +```sh # defaults to /etc/wgkex.yaml if not set export WGKEX_CONFIG_FILE=/opt/wgkex/wgkex.yaml bazel build //wgkex/broker:app @@ -112,6 +115,30 @@ push_key = requests.get(f'{broker_url}/api/v1/wg/key/exchange', json=key_data) print(f'Key push was: {push_key.json().get("Message")]}') ``` +### Worker + +You can set up dummy interfaces for the worker using this script: + +```sh +interface_linklocal() { + # We generate a predictable v6 address + local macaddr="$(echo $1 | wg pubkey |md5sum|sed 's/^\(..\)\(..\)\(..\)\(..\)\(..\).*$/02:\1:\2:\3:\4:\5/')" + local oldIFS="$IFS"; IFS=':'; set -- $macaddr; IFS="$oldIFS" + echo "fe80::$1$2:$3ff:fe$4:$5$6" +} + +sudo ip link add wg-welt type wireguard +wg genkey | sudo wg set wg-welt private-key /dev/stdin +sudo wg set wg-welt listen-port 51820 +addr=$(interface_linklocal $(sudo wg show wg-welt private-key)) +sudo ip addr add $addr dev wg-welt +sudo ip link add vx-welt type vxlan id 99 dstport 0 local $addr dev wg-welt +sudo ip addr add fe80::1/64 dev vx-welt +sudo ip link set wg-welt up +sudo ip link set vx-welt up +``` + + ## Contact -[wgkex - IRCNet](ircs://irc.ircnet.net:6697/wgkex) +[Freifunk Munich Mattermost](https://chat.ffmuc.net) From 310bcc663ead21d8acd535957fd47781906f7b39 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Mon, 25 Jul 2022 14:41:50 +0000 Subject: [PATCH 012/107] Update rules_python to fix bazel build --- README.md | 23 ++++++++++++++++++++--- WORKSPACE | 7 ++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f8b6c69..9f9a63a 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,11 @@ For further information, please see this [presentation on the architecture](http The `wgkex` configuration file defaults to `/etc/wgkex.yaml` ([Sample configuration file](wgkex.yaml.example)), however can also be overwritten by setting the environment variable `WGKEX_CONFIG_FILE`. -## Running the broker +## Running the broker and worker -* The worker can be started directly from a Git checkout: +### Build using [Bazel](https://bazel.build) + +Worker: ```sh # defaults to /etc/wgkex.yaml if not set @@ -86,7 +88,7 @@ bazel build //wgkex/worker:app ./bazel-bin/wgkex/worker/app ``` -* The broker can also be built and run via [bazel](https://bazel.build): +Broker: ```sh # defaults to /etc/wgkex.yaml if not set @@ -96,6 +98,21 @@ bazel build //wgkex/broker:app ./bazel-bin/wgkex/broker/app ``` +### Run using Python + +Broker: +(Using Flask development server) + +```sh +FLASK_ENV=development FLASK_DEBUG=1 FLASK_APP=wgkex/broker/app.py python3 -m flask run +``` + +Worker: + +```sh +python3 -c 'from wgkex.worker.app import main; main()' +``` + ## Client usage The client can be used via CLI: diff --git a/WORKSPACE b/WORKSPACE index 4aafce8..2756b7d 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -2,12 +2,13 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") http_archive( name = "rules_python", - url = "https://github.com/bazelbuild/rules_python/releases/download/0.1.0/rules_python-0.1.0.tar.gz", - sha256 = "b6d46438523a3ec0f3cead544190ee13223a52f6a6765a29eae7b7cc24cc83a0", + sha256 = "a3a6e99f497be089f81ec082882e40246bfd435f52f4e82f37e89449b04573f6", + strip_prefix = "rules_python-0.10.2", + url = "https://github.com/bazelbuild/rules_python/archive/refs/tags/0.10.2.tar.gz", ) # PIP support. load("@rules_python//python:pip.bzl", "pip_install") pip_install( requirements = "//:requirements.txt", -) \ No newline at end of file +) From d20dcfe7fc323c75e504fb0cfbd00629750ebbdd Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sat, 27 Aug 2022 18:43:04 +0000 Subject: [PATCH 013/107] Fix Docker build, use python base image and install Bazel ourselves --- Dockerfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 03ac518..e91d526 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,15 @@ -FROM gcr.io/cloud-marketplace-containers/google/bazel:3.5.0 AS builder +FROM python:3.10-bullseye AS builder + +RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ + && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ + && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/bazel-archive-keyring.gpg] https://storage.googleapis.com/bazel-apt stable jdk1.8" | tee /etc/apt/sources.list.d/bazel.list \ + && apt-get update && apt-get install -y bazel \ + && rm -rf /var/lib/apt/lists/* WORKDIR /wgkex -COPY . ./ +COPY BUILD WORKSPACE requirements.txt ./ +COPY wgkex ./wgkex RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] From 3720b45a9cdf5b51bce57775f49e33de649c92e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Sep 2022 09:08:20 +0000 Subject: [PATCH 014/107] Bump abatilo/actions-poetry from 2.1.5 to 2.1.6 Bumps [abatilo/actions-poetry](https://github.com/abatilo/actions-poetry) from 2.1.5 to 2.1.6. - [Release notes](https://github.com/abatilo/actions-poetry/releases) - [Changelog](https://github.com/abatilo/actions-poetry/blob/master/.releaserc) - [Commits](https://github.com/abatilo/actions-poetry/compare/v2.1.5...v2.1.6) --- updated-dependencies: - dependency-name: abatilo/actions-poetry dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 604791b..8741764 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 # https://github.com/marketplace/actions/python-poetry-action - - uses: abatilo/actions-poetry@v2.1.5 + - uses: abatilo/actions-poetry@v2.1.6 - name: Install dependencies run: | poetry install --no-dev From d00200353b623a7deb833c4a83c320a3b6464875 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Oct 2022 19:29:05 +0000 Subject: [PATCH 015/107] Bump python from 3.10.6-bullseye to 3.10.7-bullseye Bumps python from 3.10.6-bullseye to 3.10.7-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e91d526..9e948d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-bullseye AS builder +FROM python:3.10.7-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.10.6-bullseye +FROM python:3.10.7-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 9766052fdf044b1973fa44b044f3947758bc0144 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 23 Oct 2022 20:34:34 +0200 Subject: [PATCH 016/107] Add broker listen config to wgkex.yaml --- README.md | 10 ++++++++++ wgkex.yaml.example | 5 ++++- wgkex/broker/app.py | 10 +++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9f9a63a..e92bfd6 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,16 @@ The frontend broker exposes the following API endpoints for use: /api/v1/wg/key/exchange ``` +The listen address and port for the Flask server can be configured in `wgkex.yaml` under the `broker_listen` key: + +```yaml +broker_listen: + # host defaults to 127.0.0.1 if unspecified + host: 0.0.0.0 + # port defaults to 5000 if unspecified + port: 5000 +``` + #### POST /api/v1/wg/key/exchange JSON POST'd to this endpoint should be in this format: diff --git a/wgkex.yaml.example b/wgkex.yaml.example index 5bceb6e..f318b0c 100644 --- a/wgkex.yaml.example +++ b/wgkex.yaml.example @@ -18,6 +18,9 @@ mqtt: password: SECRET keepalive: 5 tls: False +broker_listen: + host: 0.0.0.0 + port: 5000 domain_prefix: myprefix- logging_config: formatters: @@ -31,4 +34,4 @@ logging_config: handlers: - console level: DEBUG - version: 1 \ No newline at end of file + version: 1 diff --git a/wgkex/broker/app.py b/wgkex/broker/app.py index 484f39b..e82497b 100644 --- a/wgkex/broker/app.py +++ b/wgkex/broker/app.py @@ -160,4 +160,12 @@ def is_valid_domain(domain: str) -> str: if __name__ == "__main__": - app.run() + listen_host = None + listen_port = None + + listen_config = config.fetch_from_config("broker_listen") + if listen_config is not None: + listen_host = listen_config.get("host") + listen_port = listen_config.get("port") + + app.run(host=listen_host, port=listen_port) From 2c38bb4e2a8a25f17f37bcb22c95ab26d8ed3aa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Nov 2022 09:38:02 +0000 Subject: [PATCH 017/107] Bump python from 3.10.7-bullseye to 3.11.0-bullseye Bumps python from 3.10.7-bullseye to 3.11.0-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e948d3..e91a8a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.7-bullseye AS builder +FROM python:3.11.0-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.10.7-bullseye +FROM python:3.11.0-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 66401c5548faacec9749f3c1aaeee9862058812b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Dec 2022 09:02:29 +0000 Subject: [PATCH 018/107] Bump abatilo/actions-poetry from 2.1.6 to 2.2.0 Bumps [abatilo/actions-poetry](https://github.com/abatilo/actions-poetry) from 2.1.6 to 2.2.0. - [Release notes](https://github.com/abatilo/actions-poetry/releases) - [Changelog](https://github.com/abatilo/actions-poetry/blob/master/.releaserc) - [Commits](https://github.com/abatilo/actions-poetry/compare/v2.1.6...v2.2.0) --- updated-dependencies: - dependency-name: abatilo/actions-poetry dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8741764..0707e2f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,7 @@ jobs: fetch-depth: 0 - uses: actions/setup-python@v4 # https://github.com/marketplace/actions/python-poetry-action - - uses: abatilo/actions-poetry@v2.1.6 + - uses: abatilo/actions-poetry@v2.2.0 - name: Install dependencies run: | poetry install --no-dev From adcacdc891aa342f8e9ecace6812c74aaed4ed9d Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 11:20:46 +0100 Subject: [PATCH 019/107] This fixes bazel coverage There was a change in bazel 6.0 which caused this issue. --- .github/workflows/bazel.yml | 4 ---- requirements.txt | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 8ccc140..1a2b66d 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,10 +17,6 @@ jobs: - name: Python coverage run: | sudo apt-get install -y lcov - mkdir "${GITHUB_WORKSPACE}/src" - cd "${GITHUB_WORKSPACE}/src" - curl -L https://github.com/ulfjack/coveragepy/archive/lcov-support.tar.gz | tar xvz - cd "${GITHUB_WORKSPACE}" bazel coverage --javabase=@bazel_tools//tools/jdk:remote_jdk11 -t- --instrument_test_targets \ --experimental_cc_coverage --test_output=errors --linkopt=--coverage --linkopt=-lc \ --test_env=PYTHON_COVERAGE=${GITHUB_WORKSPACE}/src/coveragepy-lcov-support/__main__.py \ diff --git a/requirements.txt b/requirements.txt index 6eb6ea0..2652652 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,5 @@ Flask # Common ipaddress -mock \ No newline at end of file +mock +coverage \ No newline at end of file From 3ba23e4a1979c4934083c5483c71806b6dc10037 Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 11:22:50 +0100 Subject: [PATCH 020/107] Cleanup even more stuff --- .github/workflows/bazel.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 1a2b66d..f7e3bc2 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -19,7 +19,6 @@ jobs: sudo apt-get install -y lcov bazel coverage --javabase=@bazel_tools//tools/jdk:remote_jdk11 -t- --instrument_test_targets \ --experimental_cc_coverage --test_output=errors --linkopt=--coverage --linkopt=-lc \ - --test_env=PYTHON_COVERAGE=${GITHUB_WORKSPACE}/src/coveragepy-lcov-support/__main__.py \ --define=config_file=test ...:all # Combine all generated reports into a single merged coverage.dat lcov $(find ./bazel-wgkex/ -size +1 -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat From 964da1fbc486b762222ce5b33d399345216830bd Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 11:38:41 +0100 Subject: [PATCH 021/107] Wtf --- .github/workflows/bazel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index f7e3bc2..53c4477 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -21,7 +21,7 @@ jobs: --experimental_cc_coverage --test_output=errors --linkopt=--coverage --linkopt=-lc \ --define=config_file=test ...:all # Combine all generated reports into a single merged coverage.dat - lcov $(find ./bazel-wgkex/ -size +1 -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat + lcov $(find ./bazel-wgkex/ -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat lcov --extract combined.dat '*/__main__/wgkex/*' | sed 's/^SF\:.*__main__\//SF\:/g' > coverage.dat - name: Coveralls uses: coverallsapp/github-action@master From 7cfb9b9b04f4de923508fbbf2280c5eb784c0c4e Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 11:41:42 +0100 Subject: [PATCH 022/107] This is all flawed --- .github/workflows/bazel.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 53c4477..a11b986 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,11 +17,16 @@ jobs: - name: Python coverage run: | sudo apt-get install -y lcov + mkdir "${GITHUB_WORKSPACE}/src" + cd "${GITHUB_WORKSPACE}/src" + curl -L https://files.pythonhosted.org/packages/18/a0/bfa6c6ab7a5f0aeb69dd169d956ead54133f5bca68a5945c4569ea2c40b3/coverage-7.1.0.tar.gz | tar xvz + cd "${GITHUB_WORKSPACE}" bazel coverage --javabase=@bazel_tools//tools/jdk:remote_jdk11 -t- --instrument_test_targets \ --experimental_cc_coverage --test_output=errors --linkopt=--coverage --linkopt=-lc \ + --test_env=PYTHON_COVERAGE=${GITHUB_WORKSPACE}/src/coverage-7.1.0/__main__.py \ --define=config_file=test ...:all # Combine all generated reports into a single merged coverage.dat - lcov $(find ./bazel-wgkex/ -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat + lcov $(find ./bazel-wgkex/ -size +1 -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat lcov --extract combined.dat '*/__main__/wgkex/*' | sed 's/^SF\:.*__main__\//SF\:/g' > coverage.dat - name: Coveralls uses: coverallsapp/github-action@master From ebb27bb0a4f99b40f990f17b7faa83cc77e8ab5b Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 11:52:35 +0100 Subject: [PATCH 023/107] once more --- .github/workflows/bazel.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index a11b986..d2febf6 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -21,15 +21,12 @@ jobs: cd "${GITHUB_WORKSPACE}/src" curl -L https://files.pythonhosted.org/packages/18/a0/bfa6c6ab7a5f0aeb69dd169d956ead54133f5bca68a5945c4569ea2c40b3/coverage-7.1.0.tar.gz | tar xvz cd "${GITHUB_WORKSPACE}" - bazel coverage --javabase=@bazel_tools//tools/jdk:remote_jdk11 -t- --instrument_test_targets \ + bazel coverage --combined_report=lcov --javabase=@bazel_tools//tools/jdk:remote_jdk11 -t- --instrument_test_targets \ --experimental_cc_coverage --test_output=errors --linkopt=--coverage --linkopt=-lc \ --test_env=PYTHON_COVERAGE=${GITHUB_WORKSPACE}/src/coverage-7.1.0/__main__.py \ --define=config_file=test ...:all - # Combine all generated reports into a single merged coverage.dat - lcov $(find ./bazel-wgkex/ -size +1 -name "*coverage.dat" | sed 's/^/\-a\ /g') -o combined.dat - lcov --extract combined.dat '*/__main__/wgkex/*' | sed 's/^SF\:.*__main__\//SF\:/g' > coverage.dat - name: Coveralls uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: coverage.dat + path-to-lcov: bazel-out/_coverage/_coverage_report.dat From bbd9d7ba6c61d9f7a601da2e59ccf3a8365d0119 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 10:55:06 +0000 Subject: [PATCH 024/107] Bump docker/build-push-action from 3 to 4 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v3...v4) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1307308..7fff248 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: labels: | org.opencontainers.image.authors=${{ env.AUTHOR }} - name: Build Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v4 with: context: . platforms: linux/amd64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1e216f2..1e7b32e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: labels: | org.opencontainers.image.authors=${{ env.AUTHOR }} - name: Build container image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v4 with: context: . platforms: linux/amd64 From 87ef39418eb3b25194a67acf59cb0fb6c51c59b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 10:58:52 +0000 Subject: [PATCH 025/107] Bump python from 3.11.0-bullseye to 3.11.2-bullseye Bumps python from 3.11.0-bullseye to 3.11.2-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index e91a8a0..d3866ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.0-bullseye AS builder +FROM python:3.11.2-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.11.0-bullseye +FROM python:3.11.2-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 75ac4837856e70ba28c70f7d6f78fda83718063e Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 12:08:22 +0100 Subject: [PATCH 026/107] Fix publish step --- .github/workflows/publish.yml | 34 +++++----------------------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0707e2f..ed8b3ec 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,32 +7,8 @@ on: - '*.*.*' jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - # https://github.com/marketplace/actions/python-poetry-action - - uses: abatilo/actions-poetry@v2.2.0 - - name: Install dependencies - run: | - poetry install --no-dev - - name: Build project - run: | - poetry run poetry build - - name: Collect artifacts - run: | - cp -v CHANGELOG.md dist/ - - name: Upload artifact - uses: actions/upload-artifact@v3 - with: - name: dist - path: dist - - name: Publish package - env: - # https://python-poetry.org/docs/repositories/#configuring-credentials - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.pypi_password }} - run: | - poetry publish --build \ No newline at end of file + - name: Publish package + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file From 8be1136cff2b8037b556a4eb25a761fe270e02c5 Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 12:13:55 +0100 Subject: [PATCH 027/107] Fix this shit --- .github/workflows/publish.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ed8b3ec..3f459d3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,8 +7,12 @@ on: - '*.*.*' jobs: - - name: Publish package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file + push_to_pypi: + name: Push stuff to pypi + runs-on: ubuntu-latest + steps: + - name: Publish package + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file From 0f3d758f6aa2c28dd0544090c59182c44a6baf36 Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 12:14:45 +0100 Subject: [PATCH 028/107] Fix this shit --- .github/workflows/publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3f459d3..54c58f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Publish package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file From c1fd3d3334e26de6e43593672d1ede64db32eada Mon Sep 17 00:00:00 2001 From: awlx Date: Sun, 12 Feb 2023 12:17:34 +0100 Subject: [PATCH 029/107] Get rid of pypi step --- .github/workflows/publish.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/publish.yml diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 54c58f0..0000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: Build and release - -on: - push: - tags: - - '*.*.*' - -jobs: - push_to_pypi: - name: Push stuff to pypi - runs-on: ubuntu-latest - steps: - - name: Publish package - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_PASSWORD }} \ No newline at end of file From b0d90712dda6b2eb3628533116a35497aa3fa8dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 May 2023 09:58:44 +0000 Subject: [PATCH 030/107] Bump python from 3.11.2-bullseye to 3.11.3-bullseye Bumps python from 3.11.2-bullseye to 3.11.3-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d3866ab..4629d2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.2-bullseye AS builder +FROM python:3.11.3-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.11.2-bullseye +FROM python:3.11.3-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 44781a06e65615f098d6c9884505646166f4f3fc Mon Sep 17 00:00:00 2001 From: Felix <8057646+GoliathLabs@users.noreply.github.com> Date: Mon, 19 Jun 2023 00:53:32 +0200 Subject: [PATCH 031/107] Update and rename changelog.txt to CHANGELOG.md --- CHANGELOG.md | 1 + changelog.txt | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 CHANGELOG.md delete mode 100644 changelog.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bc6facc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +### latest diff --git a/changelog.txt b/changelog.txt deleted file mode 100644 index 68905cd..0000000 --- a/changelog.txt +++ /dev/null @@ -1 +0,0 @@ -changelog.txt From cd29a4232bf1a6afa3f79d8c83af51651e21d6b4 Mon Sep 17 00:00:00 2001 From: Felix <8057646+GoliathLabs@users.noreply.github.com> Date: Mon, 19 Jun 2023 00:54:26 +0200 Subject: [PATCH 032/107] Update Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 4629d2a..f1f16b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.3-bullseye AS builder +FROM python:3.11.4-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ From 48ffbfb4329cc57d2da355001bed6a1db8e15ff6 Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Thu, 20 Apr 2023 11:01:39 +0200 Subject: [PATCH 033/107] Add architecture PNG for dark mode The dot file generates something else than the existing PNG shows, so I took the PNG and inverted it. A better solution could be obtained by matching colors for dark mode. However, this is still more readable than the current state when in dark mode. --- Docs/architecture-dark.png | Bin 0 -> 251839 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Docs/architecture-dark.png diff --git a/Docs/architecture-dark.png b/Docs/architecture-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..19aa42498b626ede5984978d717120e82d9dd9c3 GIT binary patch literal 251839 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�^ss!Uzv7f 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

oE zp&iTX}Yp*vy<^g-$|VxbQ#a9nWKz(KDt{GuFKv_nhBLW2yepG$Kn+Mmbv_y)n2 zEj=E09&6PGr5~3MDZbC}rcGHfR3?TzwW%{aekM1|7sEA7aQN%L>_$uO_2mtJ+W6f3 zR1oino$r!kpx*k)ugP9_g_`J~vwDGyV0t|zLtIbjTtPYD{j1mj($9;I2ez4p&|1;s z-s=9bB>uMSh{?%-mB3iNTC2%JPmx6{>%n_EFW$c;2g21((=D}?}XehL_aUO)!4y#ut#{M{JG8QOaKBTu9tD)AxXtwyhvF%( zNnsq%=g;4{8_K&NJvKcv=NHR6K>a_3H?-;PHH@_{ zqI@ic9NL?C;6@yCxLSL0Lm18VFV5Ft%EVr13(Gn2%r;!^L*lee%%I-8T?hlM0?AA| zqPrvh&$=h1Nfhzp5(JtsjW-$)iu=rKcXdmpJL}o9i5*>d3yo0+Dher5NdpFhhGHKL zG3uW{wy3oT!3Ylr!N`UMc>A5@k4W(UrHDGbHUUY569q&8!hc(*S6z3c@4w5`l!Y}O zQ|z-$ujtN_lsne wbr{Ocm3JH#*7dzp!>IGWH~atprbcIeucykNnErG`q5(@qK|{XunMuI^0c;4GkpKVy literal 0 HcmV?d00001 From 6f0cc13028d8cbb22e994ebb334531344f887997 Mon Sep 17 00:00:00 2001 From: Stefan Haun Date: Thu, 20 Apr 2023 11:04:12 +0200 Subject: [PATCH 034/107] README: Select architecture picture based on color scheme --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e92bfd6..88a466c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,11 @@ wgkex is a WireGuard key exchange and management tool designed and run by FFMUC. WireGuard Key Exchange is a tool consisting of two parts: a frontend (broker) and a backend (worker). These components communicate to each other via MQTT - a messaging bus. -![](Docs/architecture.png) + + + + Architectural Diagram + ### Frontend broker From 8b7b5775f505012bc48e622a1a5359153f4528bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Sep 2023 09:52:13 +0000 Subject: [PATCH 035/107] Bump python from 3.11.3-bullseye to 3.11.5-bullseye Bumps python from 3.11.3-bullseye to 3.11.5-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index f1f16b3..2d2283e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.4-bullseye AS builder +FROM python:3.11.5-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.11.3-bullseye +FROM python:3.11.5-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 8ab2b81b420f580e01a62f45b2cbf6ed28e3f4d8 Mon Sep 17 00:00:00 2001 From: Annika Wickert Date: Mon, 18 Sep 2023 12:29:08 +0200 Subject: [PATCH 036/107] Use waitress instead of flask for prod --- wgkex/broker/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wgkex/broker/app.py b/wgkex/broker/app.py index e82497b..5001dc3 100644 --- a/wgkex/broker/app.py +++ b/wgkex/broker/app.py @@ -13,6 +13,7 @@ from flask_mqtt import Mqtt import paho.mqtt.client as mqtt_client +from waitress import serve from wgkex.config import config from wgkex.common import logger @@ -168,4 +169,4 @@ def is_valid_domain(domain: str) -> str: listen_host = listen_config.get("host") listen_port = listen_config.get("port") - app.run(host=listen_host, port=listen_port) + serve(app, host=listen_host, port=listen_port) From ac4d2a9f8ef6d1d331173adf28f92009c4e7a816 Mon Sep 17 00:00:00 2001 From: Annika Wickert Date: Mon, 18 Sep 2023 10:32:13 +0000 Subject: [PATCH 037/107] Add this stuff --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 2652652..b16dec7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ flask-mqtt pyroute2 PyYAML Flask +waitress # Common ipaddress From 4aabc325fa38d74bf7ef9f4d88dabe64696ea5a1 Mon Sep 17 00:00:00 2001 From: Annika Wickert Date: Mon, 18 Sep 2023 11:25:16 +0000 Subject: [PATCH 038/107] Also require waitress in bazel --- wgkex/broker/BUILD | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/broker/BUILD b/wgkex/broker/BUILD index 316c91a..260fe45 100644 --- a/wgkex/broker/BUILD +++ b/wgkex/broker/BUILD @@ -9,6 +9,7 @@ py_binary( deps=[ requirement("flask"), requirement("flask-mqtt"), + requirement("waitress"), "//wgkex/config:config", ], ) From 250fb3fe3d91de42f226a9d965a9d2d68ab519a0 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:27:30 +0200 Subject: [PATCH 039/107] Add queues for netlink messages This resolves #103 --- wgkex/worker/BUILD | 11 +++++++++++ wgkex/worker/app.py | 3 ++- wgkex/worker/mqtt.py | 16 ++++++---------- wgkex/worker/msg_queue.py | 26 ++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 wgkex/worker/msg_queue.py diff --git a/wgkex/worker/BUILD b/wgkex/worker/BUILD index 22e2424..ec4ffaa 100644 --- a/wgkex/worker/BUILD +++ b/wgkex/worker/BUILD @@ -16,6 +16,7 @@ py_library( ], ) + py_test( name = "netlink_test", srcs = ["netlink_test.py"], @@ -54,6 +55,7 @@ py_binary( srcs = ["app.py"], deps = [ ":mqtt", + ":msg_queue", "//wgkex/config:config", "//wgkex/common:logger", ], @@ -67,3 +69,12 @@ py_test( requirement("mock"), ], ) + +py_library( + name = "msg_queue", + srcs = ["msg_queue.py"], + visibility = ["//visibility:public"], + deps = [ + "//wgkex/common:logger", + ], +) \ No newline at end of file diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index e99575e..a917ed5 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -2,8 +2,8 @@ import wgkex.config.config as config from wgkex.worker import mqtt +from wgkex.worker.msg_queue import watch_queue from wgkex.worker.netlink import wg_flush_stale_peers -import threading import time from wgkex.common import logger from typing import List, Text @@ -59,6 +59,7 @@ def main(): domains = config.load_config().get("domains") if not domains: raise DomainsNotInConfig("Could not locate domains in configuration.") + watch_queue() clean_up_worker(domains) mqtt.connect() diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index dfa742a..21e749e 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -7,12 +7,13 @@ from wgkex.config.config import load_config import socket import re -from wgkex.worker.netlink import link_handler -from wgkex.worker.netlink import WireGuardClient -from typing import Optional, Dict, List, Any, Union +from typing import Optional, Dict, Any, Union from wgkex.common import logger +import queue +q = queue.Queue() + def fetch_from_config(var: str) -> Optional[Union[Dict[str, str], str]]: """Fetches values from configuration file. @@ -93,13 +94,8 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> ) domain = domain.group(1) logger.debug("Found domain %s", domain) - client = WireGuardClient( - public_key=str(message.payload.decode("utf-8")), - domain=domain, - remove=False, - ) + logger.info( f"Received create message for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" ) - # TODO(ruairi): Verify return type here. - logger.debug(link_handler(client)) + q.put(domain, message.payload.decode("utf-8")) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py new file mode 100644 index 0000000..b164b7a --- /dev/null +++ b/wgkex/worker/msg_queue.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import queue +import threading +from wgkex.common import logger +from wgkex.worker.netlink import link_handler +from wgkex.worker.netlink import WireGuardClient + +q = queue.Queue() + +def watch_queue() -> None: + """Watches the queue for new messages.""" + threading.Thread(target=worker, daemon=True).start() + while q.empty() != True: + pick_from_queue() + +def pick_from_queue() -> None: + """Picks a message from the queue and processes it.""" + domain, message = q.get() + logger.debug("Processing queue item %s for domain %s", message, domain) + client = WireGuardClient( + public_key=str(message.payload.decode("utf-8")), + domain=domain, + remove=False, + ) + logger.debug(link_handler(client)) + q.task_done() \ No newline at end of file From 82d8e1b9f4852d02d2359eee6e1df6676378b659 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:30:56 +0200 Subject: [PATCH 040/107] Fix --- wgkex/worker/msg_queue.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index b164b7a..2f0b600 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -9,9 +9,7 @@ def watch_queue() -> None: """Watches the queue for new messages.""" - threading.Thread(target=worker, daemon=True).start() - while q.empty() != True: - pick_from_queue() + threading.Thread(target=pick_from_queue, daemon=True).start() def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" From f2951bf77932accb08e9693f4fdd5205a27c1790 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:32:15 +0200 Subject: [PATCH 041/107] Fix --- wgkex/worker/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index a917ed5..36e741c 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -5,6 +5,7 @@ from wgkex.worker.msg_queue import watch_queue from wgkex.worker.netlink import wg_flush_stale_peers import time +import threading from wgkex.common import logger from typing import List, Text From b8e15846aa8f782ed0a6e7f96a32e0e012bd15b3 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:34:09 +0200 Subject: [PATCH 042/107] More fixes --- wgkex/worker/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 21e749e..8e5611e 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -96,6 +96,6 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> logger.debug("Found domain %s", domain) logger.info( - f"Received create message for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" + f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain} with lladdr {client.lladdr}" ) q.put(domain, message.payload.decode("utf-8")) From ec3cc0940c5f956845e0fecdead208678805cf6a Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:34:19 +0200 Subject: [PATCH 043/107] More fixes --- wgkex/worker/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 8e5611e..e7af08a 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -96,6 +96,6 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> logger.debug("Found domain %s", domain) logger.info( - f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain} with lladdr {client.lladdr}" + f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain}" ) q.put(domain, message.payload.decode("utf-8")) From 7019076c58515f010d02470e015363e3e022cb65 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:36:07 +0200 Subject: [PATCH 044/107] More logs --- wgkex/worker/msg_queue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 2f0b600..10b18d6 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -9,6 +9,7 @@ def watch_queue() -> None: """Watches the queue for new messages.""" + logger.debug("Starting queue watcher") threading.Thread(target=pick_from_queue, daemon=True).start() def pick_from_queue() -> None: From fff2e338fbd29d00ab1bb2d52cc3b619934c434d Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:40:42 +0200 Subject: [PATCH 045/107] More --- wgkex/worker/msg_queue.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 10b18d6..feb10cb 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -21,5 +21,8 @@ def pick_from_queue() -> None: domain=domain, remove=False, ) + logger.info( + f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" + ) logger.debug(link_handler(client)) q.task_done() \ No newline at end of file From 4fc10f29373eeadbfed12d7b6a72c488bc6e503d Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:42:11 +0200 Subject: [PATCH 046/107] More debugging --- wgkex/worker/msg_queue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index feb10cb..4528c69 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -14,6 +14,7 @@ def watch_queue() -> None: def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" + logger.debug("Starting queue processor") domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) client = WireGuardClient( From 1e91cfb7def89c884c70dfbe26106ef5f0c7c48c Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:43:42 +0200 Subject: [PATCH 047/107] More debugging --- wgkex/worker/msg_queue.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 4528c69..d272fa0 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -15,15 +15,16 @@ def watch_queue() -> None: def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") - domain, message = q.get() - logger.debug("Processing queue item %s for domain %s", message, domain) - client = WireGuardClient( - public_key=str(message.payload.decode("utf-8")), - domain=domain, - remove=False, - ) - logger.info( - f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" - ) - logger.debug(link_handler(client)) - q.task_done() \ No newline at end of file + while True: + domain, message = q.get() + logger.debug("Processing queue item %s for domain %s", message, domain) + client = WireGuardClient( + public_key=str(message.payload.decode("utf-8")), + domain=domain, + remove=False, + ) + logger.info( + f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" + ) + logger.debug(link_handler(client)) + q.task_done() \ No newline at end of file From 714aa36571e6bbd116ac97685ca3c7fad1648df4 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:45:16 +0200 Subject: [PATCH 048/107] More debugging --- wgkex/worker/mqtt.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index e7af08a..d63dd3c 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -9,10 +9,7 @@ import re from typing import Optional, Dict, Any, Union from wgkex.common import logger -import queue - - -q = queue.Queue() +from wgkex.worker.msg_queue import q def fetch_from_config(var: str) -> Optional[Union[Dict[str, str], str]]: """Fetches values from configuration file. From be258559cec8dcc3baa934ee2916a9522780e52a Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:46:17 +0200 Subject: [PATCH 049/107] Make sure we only pull when queue is empty --- wgkex/worker/msg_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index d272fa0..5d6a6f3 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -15,7 +15,7 @@ def watch_queue() -> None: def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") - while True: + while q.empty() is False: domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) client = WireGuardClient( From 32217a703a3987826ebb6ceeb71ba790f63a6e60 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:46:50 +0200 Subject: [PATCH 050/107] Make sure we only pull when queue is not empty --- wgkex/worker/msg_queue.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 5d6a6f3..8df94ae 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -15,16 +15,17 @@ def watch_queue() -> None: def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") - while q.empty() is False: - domain, message = q.get() - logger.debug("Processing queue item %s for domain %s", message, domain) - client = WireGuardClient( - public_key=str(message.payload.decode("utf-8")), - domain=domain, - remove=False, - ) - logger.info( - f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" - ) - logger.debug(link_handler(client)) - q.task_done() \ No newline at end of file + while True: + if not q.empty(): + domain, message = q.get() + logger.debug("Processing queue item %s for domain %s", message, domain) + client = WireGuardClient( + public_key=str(message.payload.decode("utf-8")), + domain=domain, + remove=False, + ) + logger.info( + f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" + ) + logger.debug(link_handler(client)) + q.task_done() \ No newline at end of file From 0490f0ece48ef2269a653cf06b8aa9474773ccb1 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:48:45 +0200 Subject: [PATCH 051/107] Use a tuple which auto unpacks --- wgkex/worker/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index d63dd3c..98a42d4 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -95,4 +95,4 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> logger.info( f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain}" ) - q.put(domain, message.payload.decode("utf-8")) + q.put((domain, message.payload.decode("utf-8"))) From f66a225dab6cdd1d07286501ac2935322edb43b9 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:50:50 +0200 Subject: [PATCH 052/107] We already converted the message --- wgkex/worker/mqtt.py | 2 +- wgkex/worker/msg_queue.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 98a42d4..0586310 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -95,4 +95,4 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> logger.info( f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain}" ) - q.put((domain, message.payload.decode("utf-8"))) + q.put((domain, str(message.payload.decode("utf-8")))) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 8df94ae..116d05f 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -20,7 +20,7 @@ def pick_from_queue() -> None: domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) client = WireGuardClient( - public_key=str(message.payload.decode("utf-8")), + public_key=message, domain=domain, remove=False, ) From 665a926f73672c3f961f7b27872f460f5b16db5b Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:53:40 +0200 Subject: [PATCH 053/107] Debug queuesize --- wgkex/worker/msg_queue.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 116d05f..8dee9af 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -17,6 +17,7 @@ def pick_from_queue() -> None: logger.debug("Starting queue processor") while True: if not q.empty(): + logger.debug("Queue is not empty current size is %i", q.qsize()) domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) client = WireGuardClient( From 005b390d896a21e8835c10c1847ccddef21bf29b Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 14:55:56 +0200 Subject: [PATCH 054/107] Run black --- wgkex/worker/mqtt.py | 1 + wgkex/worker/msg_queue.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 0586310..64a62dd 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -11,6 +11,7 @@ from wgkex.common import logger from wgkex.worker.msg_queue import q + def fetch_from_config(var: str) -> Optional[Union[Dict[str, str], str]]: """Fetches values from configuration file. diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 8dee9af..9bc99a8 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -7,11 +7,13 @@ q = queue.Queue() + def watch_queue() -> None: """Watches the queue for new messages.""" logger.debug("Starting queue watcher") threading.Thread(target=pick_from_queue, daemon=True).start() + def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") @@ -29,4 +31,4 @@ def pick_from_queue() -> None: f"Processing queue for key {client.public_key} on domain {domain} with lladdr {client.lladdr}" ) logger.debug(link_handler(client)) - q.task_done() \ No newline at end of file + q.task_done() From 3b7cb27d9cfdcdda91265aaf81d8bf2ae69d1b96 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:11:25 +0200 Subject: [PATCH 055/107] Extend the normal Queue to not allow dups --- wgkex/worker/msg_queue.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 9bc99a8..76e6b7d 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -5,8 +5,15 @@ from wgkex.worker.netlink import link_handler from wgkex.worker.netlink import WireGuardClient -q = queue.Queue() +class UniqueQueue(queue.Queue): + def _init(self, maxsize): + self.queue = set() + def _put(self, item): + self.queue.add(item) + def _get(self): + return self.queue.pop() +q = UniqueQueue() def watch_queue() -> None: """Watches the queue for new messages.""" From 88b9ec4863bdaec05d476b01a547b2921c1103b6 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:14:23 +0200 Subject: [PATCH 056/107] Fix tests --- wgkex/worker/BUILD | 3 +++ 1 file changed, 3 insertions(+) diff --git a/wgkex/worker/BUILD b/wgkex/worker/BUILD index ec4ffaa..80a82eb 100644 --- a/wgkex/worker/BUILD +++ b/wgkex/worker/BUILD @@ -37,6 +37,7 @@ py_library( "//wgkex/common:utils", "//wgkex/common:logger", "//wgkex/config:config", + ":msg_queue", ":netlink", ], ) @@ -46,6 +47,7 @@ py_test( srcs = ["mqtt_test.py"], deps = [ ":mqtt", + ":msg_queue", requirement("mock"), ], ) @@ -66,6 +68,7 @@ py_test( srcs = ["app_test.py"], deps = [ ":app", + ":msg_queue", requirement("mock"), ], ) From 90fe2d7b8ef2ac97959b9dcc608a155295218179 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:15:40 +0200 Subject: [PATCH 057/107] Run black --- wgkex/worker/msg_queue.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 76e6b7d..ed9e52f 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -5,16 +5,21 @@ from wgkex.worker.netlink import link_handler from wgkex.worker.netlink import WireGuardClient + class UniqueQueue(queue.Queue): def _init(self, maxsize): self.queue = set() + def _put(self, item): self.queue.add(item) + def _get(self): return self.queue.pop() + q = UniqueQueue() + def watch_queue() -> None: """Watches the queue for new messages.""" logger.debug("Starting queue watcher") From 3add6250162afe62f5dd7d75be5aaf57609b6139 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:18:55 +0200 Subject: [PATCH 058/107] Fix tests --- wgkex/worker/mqtt_test.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index d779408..a577157 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -2,6 +2,7 @@ import unittest import mock import mqtt +import msg_queue class MQTTTest(unittest.TestCase): @@ -40,7 +41,7 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): with self.assertRaises(ValueError): mqtt.connect() - @mock.patch.object(mqtt, "link_handler") + @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_success(self, config_mock, link_mock): """Tests on_message for success.""" @@ -61,7 +62,7 @@ def test_on_message_success(self, config_mock, link_mock): any_order=True, ) - @mock.patch.object(mqtt, "link_handler") + @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_fails_no_domain(self, config_mock, link_mock): """Tests on_message for failure to parse domain.""" From 1b3079fbf3203c87518f66e9292c97a7b2c70992 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:20:52 +0200 Subject: [PATCH 059/107] This other stuff --- wgkex/worker/mqtt_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index a577157..c1afc4c 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -54,7 +54,7 @@ def test_on_message_success(self, config_mock, link_mock): link_mock.assert_has_calls( [ mock.call( - mqtt.WireGuardClient( + msg_queue.WireGuardClient( public_key="PUB_KEY", domain="domain1", remove=False ) ) From a6dcbfdf14d8881aa561fe3995a871b116502872 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:25:40 +0200 Subject: [PATCH 060/107] Commment out until I figure out how to test queus --- wgkex/worker/mqtt_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index c1afc4c..5108348 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -41,7 +41,7 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): with self.assertRaises(ValueError): mqtt.connect() - @mock.patch.object(msg_queue, "link_handler") +""" @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_success(self, config_mock, link_mock): """Tests on_message for success.""" @@ -84,7 +84,7 @@ def test_on_message_fails_no_domain(self, config_mock, link_mock): mqtt_msg.topic = "bad_domain_match" with self.assertRaises(ValueError): mqtt.on_message(None, None, mqtt_msg) - + """ if __name__ == "__main__": unittest.main() From 6b426f5fd8103f915e6021ba8ffe64c14bbdc5d9 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:26:58 +0200 Subject: [PATCH 061/107] More log info --- wgkex/worker/mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 64a62dd..995d49c 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -94,6 +94,6 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> logger.debug("Found domain %s", domain) logger.info( - f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain}" + f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain} adding to queue" ) q.put((domain, str(message.payload.decode("utf-8")))) From ffceda2a11fac1343d7bbc60030ec6da0d6511e4 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:27:51 +0200 Subject: [PATCH 062/107] More log info --- wgkex/worker/mqtt_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 5108348..c6e8b1e 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -44,7 +44,6 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): """ @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_success(self, config_mock, link_mock): - """Tests on_message for success.""" config_mock.return_value = {"domain_prefix": "_ffmuc_"} link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") @@ -65,7 +64,6 @@ def test_on_message_success(self, config_mock, link_mock): @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_fails_no_domain(self, config_mock, link_mock): - """Tests on_message for failure to parse domain.""" config_mock.return_value = { "domain_prefix": "ffmuc_", "log_level": "DEBUG", From c330a1ee29e644bf9da24cf5eedc7491678cf378 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:30:46 +0200 Subject: [PATCH 063/107] Shut the fuck up --- wgkex/worker/mqtt_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index c6e8b1e..8e2fcbf 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -41,6 +41,7 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): with self.assertRaises(ValueError): mqtt.connect() + """ @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "load_config") def test_on_message_success(self, config_mock, link_mock): From 6e35300fe898b74ee58ab2263a40e810abc445e0 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:56:29 +0200 Subject: [PATCH 064/107] Fix a queue empty bug --- wgkex/worker/app.py | 2 +- wgkex/worker/msg_queue.py | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 36e741c..18fd8f5 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -60,9 +60,9 @@ def main(): domains = config.load_config().get("domains") if not domains: raise DomainsNotInConfig("Could not locate domains in configuration.") - watch_queue() clean_up_worker(domains) mqtt.connect() + watch_queue() if __name__ == "__main__": diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index ed9e52f..214628b 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -1,20 +1,20 @@ #!/usr/bin/env python3 -import queue import threading +from queue import Queue from wgkex.common import logger from wgkex.worker.netlink import link_handler -from wgkex.worker.netlink import WireGuardClient - - -class UniqueQueue(queue.Queue): - def _init(self, maxsize): - self.queue = set() - - def _put(self, item): - self.queue.add(item) - - def _get(self): - return self.queue.pop() +from wgkex.worker.netlink import WireGuardClient + +class UniqueQueue(Queue): + def put(self, item, block=True, timeout=None): + if item not in self.queue: # fix join bug + Queue.put(self, item, block, timeout) + def _init(self, maxsize): + self.queue = set() + def _put(self, item): + self.queue.add(item) + def _get(self): + return self.queue.pop() q = UniqueQueue() From bceb4b7ba9ac40e56b251de86281acd8c281984e Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 15:57:51 +0200 Subject: [PATCH 065/107] Add black --- wgkex/worker/msg_queue.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 214628b..bf811ef 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -3,18 +3,22 @@ from queue import Queue from wgkex.common import logger from wgkex.worker.netlink import link_handler -from wgkex.worker.netlink import WireGuardClient - -class UniqueQueue(Queue): - def put(self, item, block=True, timeout=None): - if item not in self.queue: # fix join bug - Queue.put(self, item, block, timeout) - def _init(self, maxsize): - self.queue = set() - def _put(self, item): - self.queue.add(item) - def _get(self): - return self.queue.pop() +from wgkex.worker.netlink import WireGuardClient + + +class UniqueQueue(Queue): + def put(self, item, block=True, timeout=None): + if item not in self.queue: # fix join bug + Queue.put(self, item, block, timeout) + + def _init(self, maxsize): + self.queue = set() + + def _put(self, item): + self.queue.add(item) + + def _get(self): + return self.queue.pop() q = UniqueQueue() From 3c0c6a216234bda8e2fef32f5e96edb93db4b0f3 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:02:30 +0200 Subject: [PATCH 066/107] Add more debugging --- wgkex/worker/msg_queue.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index bf811ef..9ada6cb 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -48,3 +48,5 @@ def pick_from_queue() -> None: ) logger.debug(link_handler(client)) q.task_done() + else: + logger.debug("Queue is empty") From cbaaf00ecb4215f8c5b0eb9e7b43a80e26a7142b Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:03:29 +0200 Subject: [PATCH 067/107] We can sleep for a second --- wgkex/worker/msg_queue.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 9ada6cb..ec1b4c5 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import threading from queue import Queue +from time import sleep from wgkex.common import logger from wgkex.worker.netlink import link_handler from wgkex.worker.netlink import WireGuardClient @@ -50,3 +51,4 @@ def pick_from_queue() -> None: q.task_done() else: logger.debug("Queue is empty") + sleep(1) \ No newline at end of file From de9a34b139e1dc269d1b7c073194475896e62947 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:16:28 +0200 Subject: [PATCH 068/107] Run black --- wgkex/worker/msg_queue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index ec1b4c5..74a9fab 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -9,7 +9,7 @@ class UniqueQueue(Queue): def put(self, item, block=True, timeout=None): - if item not in self.queue: # fix join bug + if item not in self.queue: Queue.put(self, item, block, timeout) def _init(self, maxsize): @@ -51,4 +51,4 @@ def pick_from_queue() -> None: q.task_done() else: logger.debug("Queue is empty") - sleep(1) \ No newline at end of file + sleep(1) From 675e6421eac27e1e8773ddfd03fa8477c6043d4c Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:23:28 +0200 Subject: [PATCH 069/107] Does this compare not work? --- wgkex/worker/msg_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 74a9fab..578a72b 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -35,7 +35,7 @@ def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") while True: - if not q.empty(): + if q.size == 0: logger.debug("Queue is not empty current size is %i", q.qsize()) domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) From e9083b803c0f9cedbf721f17bd3f16fed5dba6b6 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:26:53 +0200 Subject: [PATCH 070/107] Fix typo --- wgkex/worker/msg_queue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/msg_queue.py b/wgkex/worker/msg_queue.py index 578a72b..74a9fab 100644 --- a/wgkex/worker/msg_queue.py +++ b/wgkex/worker/msg_queue.py @@ -35,7 +35,7 @@ def pick_from_queue() -> None: """Picks a message from the queue and processes it.""" logger.debug("Starting queue processor") while True: - if q.size == 0: + if not q.empty(): logger.debug("Queue is not empty current size is %i", q.qsize()) domain, message = q.get() logger.debug("Processing queue item %s for domain %s", message, domain) From 7a4cbcdaa6d0d1715be3ea823a5af9903d44e060 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 16:32:41 +0200 Subject: [PATCH 071/107] This calls blocks --- wgkex/worker/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 18fd8f5..911fd8b 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -61,8 +61,8 @@ def main(): if not domains: raise DomainsNotInConfig("Could not locate domains in configuration.") clean_up_worker(domains) - mqtt.connect() watch_queue() + mqtt.connect() if __name__ == "__main__": From 9d6692df713bf7afab978939f02ea7446f26e2d3 Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 17:23:48 +0200 Subject: [PATCH 072/107] Disable keepalive on Gateway site This will safe us some CPU cycles and the Clients can keep on doing it. --- wgkex/worker/netlink.py | 2 -- wgkex/worker/netlink_test.py | 1 - 2 files changed, 3 deletions(-) diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index 03af944..d4f0656 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -11,7 +11,6 @@ from wgkex.common.utils import mac2eui64 from wgkex.common import logger -_PERSISTENT_KEEPALIVE_SECONDS = 15 _PEER_TIMEOUT_HOURS = 3 @@ -149,7 +148,6 @@ def update_wireguard_peer(client: WireGuardClient) -> Dict: with pyroute2.WireGuard() as wg: wg_peer = { "public_key": client.public_key, - "persistent_keepalive": _PERSISTENT_KEEPALIVE_SECONDS, "allowed_ips": [client.lladdr], "remove": client.remove, } diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index d528b56..3b1df45 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -89,7 +89,6 @@ def test_update_wireguard_peer_success(self): "wg-add", peer={ "public_key": "public_key", - "persistent_keepalive": 15, "allowed_ips": ["fe80::282:6eff:fe9d:ecd3/128"], "remove": False, }, From dbabb5589c2e271e3699969a1bc0573756d2b7fd Mon Sep 17 00:00:00 2001 From: awlx Date: Mon, 18 Sep 2023 17:26:09 +0200 Subject: [PATCH 073/107] Remove it from all tests as well --- wgkex/worker/netlink_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index 3b1df45..aeb4ff3 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -147,7 +147,6 @@ def test_link_handler_addition_success(self): "wg-add", peer={ "public_key": "public_key", - "persistent_keepalive": 15, "allowed_ips": ["fe80::282:6eff:fe9d:ecd3/128"], "remove": False, }, From 65726fd3b2fc4e3c5befad89dd6e95681cb7888f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 1 Oct 2023 09:27:11 +0000 Subject: [PATCH 074/107] Bump docker/login-action from 2 to 3 Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/login-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 1e7b32e..918e3a9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -29,7 +29,7 @@ jobs: with: config: .github/buildkitd.toml - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From 78601191af36e66d616090c3b5a72d9ea80f80dd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:52:37 +0000 Subject: [PATCH 075/107] Bump actions/checkout from 3 to 4 Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/bazel.yml | 2 +- .github/workflows/black.yml | 2 +- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- .github/workflows/pylint.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index d2febf6..1513680 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -11,7 +11,7 @@ jobs: with: path: "/home/runner/.cache/bazel" key: bazel - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run Bazel tests run: bazel test ...:all --test_output=all --action_env=WGKEX_CONFIG_FILE=`pwd`/wgkex.yaml.example - name: Python coverage diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index 048e600..e91693a 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -6,6 +6,6 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 - uses: psf/black@stable diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7fff248..44a809b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Setup QEMU uses: docker/setup-qemu-action@v2 - name: Setup Docker buildx diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 918e3a9..b7ef1aa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,7 +19,7 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v2 with: diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 8091195..642de39 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -5,7 +5,7 @@ jobs: name: GitHub Action for pylint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: GitHub Action for pylint uses: cclauss/GitHub-Action-for-pylint@master with: From 93eb00f5ddfe81c913ed9bcba4f2139809e93200 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:55:22 +0000 Subject: [PATCH 076/107] Bump docker/setup-buildx-action from 2 to 3 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 44a809b..ccfec9e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - name: Setup QEMU uses: docker/setup-qemu-action@v2 - name: Setup Docker buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: config: .github/buildkitd.toml - name: Retrieve author data diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b7ef1aa..7828e0a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -25,7 +25,7 @@ jobs: with: platforms: all - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 with: config: .github/buildkitd.toml - name: Login to DockerHub From f6771d73b92dbddd3c2f9a786fa8730d581be3b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:56:57 +0000 Subject: [PATCH 077/107] Bump docker/metadata-action from 4 to 5 Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5. - [Release notes](https://github.com/docker/metadata-action/releases) - [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md) - [Commits](https://github.com/docker/metadata-action/compare/v4...v5) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccfec9e..98258e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} labels: | diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7828e0a..380964b 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -39,7 +39,7 @@ jobs: echo AUTHOR=$(curl -sSL ${{ github.event.repository.owner.url }} | jq -r '.name') >> $GITHUB_ENV - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} labels: | From b4b026d3f57e00d4c692c8ff087db75c9e95cc96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Oct 2023 14:58:56 +0000 Subject: [PATCH 078/107] Bump docker/build-push-action from 4 to 5 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v4...v5) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 98258e4..f25aa3e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,7 +33,7 @@ jobs: labels: | org.opencontainers.image.authors=${{ env.AUTHOR }} - name: Build Docker image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 380964b..a8cd206 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -45,7 +45,7 @@ jobs: labels: | org.opencontainers.image.authors=${{ env.AUTHOR }} - name: Build container image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v5 with: context: . platforms: linux/amd64 From 989777b254962d5f675e26cc5efc5c69d5f6fcbb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Nov 2023 09:27:23 +0000 Subject: [PATCH 079/107] Bump docker/setup-qemu-action from 2 to 3 Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3. - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/build.yml | 2 +- .github/workflows/docker.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f25aa3e..d6a92cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,7 +17,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Setup QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Setup Docker buildx uses: docker/setup-buildx-action@v3 with: diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a8cd206..5133b04 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,7 +21,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: all - name: Set up Docker Buildx From 67cf35791a9084781136c140b91899694f51b4d8 Mon Sep 17 00:00:00 2001 From: Grische <2787581+grische@users.noreply.github.com> Date: Wed, 22 Nov 2023 23:42:27 +0100 Subject: [PATCH 080/107] readme: update TOC --- README.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 88a466c..94b1135 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,18 @@ [![Bazel tests](https://github.com/freifunkMUC/wgkex/actions/workflows/bazel.yml/badge.svg)](https://github.com/freifunkMUC/wgkex/actions/workflows/bazel.yml) - [WireGuard Key Exchange](#wireguard-key-exchange) - * [Overview](#overview) - + [Frontend broker](#frontend-broker) - - [POST /api/v1/wg/key/exchange](#post--api-v1-wg-key-exchange) - + [Backend worker](#backend-worker) - * [Installation](#installation) - * [Configuration](#configuration) - * [Running the broker](#running-the-broker) - * [Client usage](#client-usage) - * [Contact](#contact) + - [Overview](#overview) + - [Frontend broker](#frontend-broker) + - [POST /api/v1/wg/key/exchange](#post-apiv1wgkeyexchange) + - [Backend worker](#backend-worker) + - [Installation](#installation) + - [Configuration](#configuration) + - [Running the broker and worker](#running-the-broker-and-worker) + - [Build using Bazel](#build-using-bazel) + - [Run using Python](#run-using-python) + - [Client usage](#client-usage) + - [Worker](#worker) + - [Contact](#contact) # WireGuard Key Exchange From cf65b32482b4459b425c0d23c105ad125ec5aa81 Mon Sep 17 00:00:00 2001 From: Grische <2787581+grische@users.noreply.github.com> Date: Wed, 22 Nov 2023 23:43:11 +0100 Subject: [PATCH 081/107] readme: fix markdown violations --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 94b1135..f2327cf 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,6 @@ - [Worker](#worker) - [Contact](#contact) - # WireGuard Key Exchange wgkex is a WireGuard key exchange and management tool designed and run by FFMUC. @@ -82,11 +81,11 @@ For further information, please see this [presentation on the architecture](http ## Installation -* TBA +- TBA ## Configuration -* Configuration file +- Configuration file The `wgkex` configuration file defaults to `/etc/wgkex.yaml` ([Sample configuration file](wgkex.yaml.example)), however can also be overwritten by setting the environment variable `WGKEX_CONFIG_FILE`. @@ -133,6 +132,7 @@ python3 -c 'from wgkex.worker.app import main; main()' ## Client usage The client can be used via CLI: + ``` $ wget -q -O- --post-data='{"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="}' --header='Content-Type:application/json' 'http://127.0.0.1:5000/api/v1/wg/key/exchange' { @@ -141,6 +141,7 @@ $ wget -q -O- --post-data='{"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUS ``` Or via python: + ```python import requests key_data = {"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="} @@ -172,7 +173,6 @@ sudo ip link set wg-welt up sudo ip link set vx-welt up ``` - ## Contact [Freifunk Munich Mattermost](https://chat.ffmuc.net) From 5b84aa16e5bbc918978697241d6a175e2176eed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?istrator=C2=B2?= Date: Sun, 26 Nov 2023 14:51:49 +0100 Subject: [PATCH 082/107] readme.md: fix wrong bracket ] in command --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f2327cf..18bf361 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ import requests key_data = {"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="} broker_url = "http://127.0.0.1:5000" push_key = requests.get(f'{broker_url}/api/v1/wg/key/exchange', json=key_data) -print(f'Key push was: {push_key.json().get("Message")]}') +print(f'Key push was: {push_key.json().get("Message")}') ``` ### Worker From eab4be0ec6855f100b4007a5a93f4e3baa21e650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?istrator=C2=B2?= <82608337+istrator2@users.noreply.github.com> Date: Mon, 27 Nov 2023 22:32:48 +0100 Subject: [PATCH 083/107] support for multiple prefixes and related fixes (#116) * make code multiprefix-able * fix some autoformatting * change quoting * add unittests --- .gitignore | 4 +- docker-compose.yml | 14 +++--- entrypoint | 23 ++++++---- env.example | 6 +-- requirements.txt | 17 +++---- wgkex.yaml.example | 32 ++++++------- wgkex/broker/app.py | 2 +- wgkex/broker/templates/index.html | 2 +- wgkex/config/config.py | 17 +++++-- wgkex/config/config_test.py | 13 ++++-- wgkex/worker/app.py | 76 +++++++++++++++++++++++++------ wgkex/worker/app_test.py | 48 +++++++++++++++++-- wgkex/worker/mqtt.py | 12 +++-- 13 files changed, 189 insertions(+), 77 deletions(-) diff --git a/.gitignore b/.gitignore index c2847fb..4ffb7f2 100644 --- a/.gitignore +++ b/.gitignore @@ -132,7 +132,6 @@ dmypy.json bazel-* # docker-compose -.env docker-compose.override.yaml # docker-compose volumes /volumes @@ -141,3 +140,6 @@ docker-compose.override.yaml # config file wgkex.yaml + +# pycharm project metadata +.idea/ diff --git a/docker-compose.yml b/docker-compose.yml index 0f96056..39ba4f5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: - ./volumes/mosquitto/data:/mosquitto/data - ./volumes/mosquitto/log:/mosquitto/log ports: - - "9001:9001" + - "9001:9001" broker: image: ghcr.io/freifunkmuc/wgkex:latest @@ -17,11 +17,11 @@ services: restart: unless-stopped ports: - "5000:5000" - #volumes: + #volumes: #- ./config/broker/wgkex.yaml:/etc/wgkex.yaml environment: - WGKEX_DOMAINS: ${WGKEX_DOMAINS-ffmuc_freising, ffmuc_gauting, ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_uml_nord, ffmuc_uml_ost, ffmuc_uml_sued, ffmuc_uml_west, ffmuc_welt} - WGKEX_DOMAIN_PREFIX: ${WGKEX_DOMAIN_PREFIX-ffmuc_} + WGKEX_DOMAINS: ${WGKEX_DOMAINS-ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_welt, ffwert_city} + WGKEX_DOMAIN_PREFIXES: ${WGKEX_DOMAIN_PREFIXES-ffmuc_, ffdon_, ffwert_} WGKEX_DEBUG: ${WGKEX_DEBUG-DEBUG} MQTT_BROKER_URL: ${MQTT_BROKER_URL-mqtt} MQTT_BROKER_PORT: ${MQTT_BROKER_PORT-1883} @@ -35,10 +35,10 @@ services: command: worker restart: unless-stopped #volumes: - #- ./config/worker/wgkex.yaml:/etc/wgkex.yaml + #- ./config/worker/wgkex.yaml:/etc/wgkex.yaml environment: - WGKEX_DOMAINS: ${WGKEX_DOMAINS-ffmuc_freising, ffmuc_gauting, ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_uml_nord, ffmuc_uml_ost, ffmuc_uml_sued, ffmuc_uml_west, ffmuc_welt} - WGKEX_DOMAIN_PREFIX: ${WGKEX_DOMAIN_PREFIX-ffmuc_} + WGKEX_DOMAINS: ${WGKEX_DOMAINS-ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_welt, ffwert_city} + WGKEX_DOMAIN_PREFIXES: ${WGKEX_DOMAIN_PREFIXES-ffmuc_, ffdon_, ffwert_} WGKEX_DEBUG: ${WGKEX_DEBUG-DEBUG} MQTT_BROKER_URL: ${MQTT_BROKER_URL-mqtt} MQTT_BROKER_PORT: ${MQTT_BROKER_PORT-1883} diff --git a/entrypoint b/entrypoint index 6741032..4975f35 100755 --- a/entrypoint +++ b/entrypoint @@ -1,15 +1,15 @@ #!/bin/bash set -e -: ${WGKEX_DOMAINS:="ffmuc_freising, ffmuc_gauting, ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_uml_nord, ffmuc_uml_ost, ffmuc_uml_sued, ffmuc_uml_west, ffmuc_welt"} -: ${WGKEX_DOMAIN_PREFIX:="ffmuc_"} -: ${WGKEX_DEBUG:="DEBUG"} -: ${MQTT_BROKER_URL:="mqtt"} -: ${MQTT_BROKER_PORT:="1883"} -: ${MQTT_USERNAME:=""} -: ${MQTT_PASSWORD:=""} -: ${MQTT_KEEPALIVE:="5"} -: ${MQTT_TLS:="False"} +: "${WGKEX_DOMAINS:=ffmuc_muc_cty, ffmuc_muc_nord, ffmuc_muc_ost, ffmuc_muc_sued, ffmuc_muc_west, ffmuc_welt, ffwert_city}" +: "${WGKEX_DOMAIN_PREFIXES:=ffmuc_, ffdon_, ffwert_}" +: "${WGKEX_DEBUG:=DEBUG}" +: "${MQTT_BROKER_URL:=mqtt}" +: "${MQTT_BROKER_PORT:=1883}" +: "${MQTT_USERNAME:=}" +: "${MQTT_PASSWORD:=}" +: "${MQTT_KEEPALIVE:=5}" +: "${MQTT_TLS:=False}" mk_config() { if [ ! -e /etc/wgkex.yaml ] ; then @@ -19,9 +19,12 @@ IFS=", " for i in $WGKEX_DOMAINS; do echo " - $i" done +echo "domain_prefixes:" +for i in $WGKEX_DOMAIN_PREFIXES; do + echo " - $i" +done cat < -wgkex + wgkex

WGKEX

diff --git a/wgkex/config/config.py b/wgkex/config/config.py index efe4d0f..0659d69 100644 --- a/wgkex/config/config.py +++ b/wgkex/config/config.py @@ -1,4 +1,5 @@ """Configuration handling class.""" +import logging import os import sys import yaml @@ -41,6 +42,14 @@ class MQTT: @classmethod def from_dict(cls, mqtt_cfg: Dict[str, str]) -> "MQTT": + """seems to generate a mqtt config object from dictionary + + Args: + mqtt_cfg (): + + Returns: + mqtt config object + """ return cls( broker_url=mqtt_cfg["broker_url"], username=mqtt_cfg["username"], @@ -60,12 +69,11 @@ class Config: Attributes: domains: The list of domains to listen for. mqtt: The MQTT configuration. - domain_prefix: The prefix to pre-pend to a given domain. - """ + domain_prefixes: The list of prefixes to pre-pend to a given domain.""" domains: List[str] mqtt: MQTT - domain_prefix: str + domain_prefixes: List[str] @classmethod def from_dict(cls, cfg: Dict[str, str]) -> "Config": @@ -79,7 +87,7 @@ def from_dict(cls, cfg: Dict[str, str]) -> "Config": return cls( domains=cfg["domains"], mqtt=mqtt_cfg, - domain_prefix=cfg["domain_prefix"], + domain_prefixes=cfg["domain_prefixes"], ) @@ -124,6 +132,7 @@ def fetch_config_from_disk() -> str: The file contents as string. """ config_file = os.environ.get(WG_CONFIG_OS_ENV, WG_CONFIG_DEFAULT_LOCATION) + logging.debug("getting config_file: %s", repr(config_file)) try: with open(config_file, "r") as stream: return stream.read() diff --git a/wgkex/config/config_test.py b/wgkex/config/config_test.py index 58427c8..3c33148 100644 --- a/wgkex/config/config_test.py +++ b/wgkex/config/config_test.py @@ -1,10 +1,17 @@ +"""Tests for configuration handling class.""" import unittest import mock import config import yaml -_VALID_CFG = "domain_prefix: ffmuc_\nlog_level: DEBUG\ndomains:\n- a\n- b\nmqtt:\n broker_port: 1883\n broker_url: mqtt://broker\n keepalive: 5\n password: pass\n tls: true\n username: user\n" -_INVALID_LINT = "domain_prefix: ffmuc_\nBAD_KEY_FOR_DOMAIN:\n- a\n- b\nmqtt:\n broker_port: 1883\n broker_url: mqtt://broker\n keepalive: 5\n password: pass\n tls: true\n username: user\n" +_VALID_CFG = ( + "domain_prefixes:\n- ffmuc_\n- ffdon_\n- ffwert_\nlog_level: DEBUG\ndomains:\n- a\n- b\nmqtt:\n broker_port: 1883" + "\n broker_url: mqtt://broker\n keepalive: 5\n password: pass\n tls: true\n username: user\n" +) +_INVALID_LINT = ( + "domain_prefixes: ffmuc_\nBAD_KEY_FOR_DOMAIN:\n- a\n- b\nmqtt:\n broker_port: 1883\n broker_url: " + "mqtt://broker\n keepalive: 5\n password: pass\n tls: true\n username: user\n" +) _INVALID_CFG = "asdasdasdasd" @@ -52,7 +59,7 @@ def test_fetch_from_config_success(self): self.assertListEqual(["a", "b"], config.fetch_from_config("domains")) def test_fetch_from_config_no_key_in_config(self): - """Test fetch non existent key from configuration.""" + """Test fetch non-existent key from configuration.""" mock_open = mock.mock_open(read_data=_VALID_CFG) with mock.patch("builtins.open", mock_open): self.assertIsNone(config.fetch_from_config("key_does_not_exist")) diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 911fd8b..70aa8fc 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -20,6 +20,14 @@ class DomainsNotInConfig(Error): """If no domains exist in configuration file.""" +class PrefixesNotInConfig(Error): + """If no prefixes exist in configuration file.""" + + +class DomainsAreNotUnique(Error): + """If non-unique domains exist in configuration file.""" + + def flush_workers(domain: Text) -> None: """Calls peer flush every _CLEANUP_TIME interval.""" while True: @@ -35,20 +43,58 @@ def clean_up_worker(domains: List[Text]) -> None: domains: list of domains. """ logger.debug("Cleaning up the following domains: %s", domains) - prefix = config.load_config().get("domain_prefix") + prefixes = config.load_config().get("domain_prefixes") + cleanup_counter = 0 + # ToDo: do we need a check if every domain got gleaned? + for prefix in prefixes: + for domain in domains: + if prefix in domain: + logger.info("Scheduling cleanup task for %s, ", domain) + try: + cleaned_domain = domain.split(prefix)[1] + cleanup_counter += 1 + except IndexError: + logger.error( + "Cannot strip domain with prefix %s from passed value %s. Skipping cleanup operation", + prefix, + domain, + ) + continue + thread = threading.Thread(target=flush_workers, args=(cleaned_domain,)) + thread.start() + if cleanup_counter < len(domains): + logger.error( + "Not every domain got cleaned. Check domains for missing prefixes", + repr(domains), + repr(prefixes), + ) + + +def check_all_domains_unique(domains, prefixes): + """strips off prefixes and checks if domains are unique + + Args: + domains: [str] + Returns: + boolean + """ + if not prefixes: + raise PrefixesNotInConfig("Could not locate prefixes in configuration.") + if not isinstance(prefixes, list): + raise TypeError("prefixes is not a list") + unique_domains = [] for domain in domains: - logger.info("Scheduling cleanup task for %s, ", domain) - try: - cleaned_domain = domain.split(prefix)[1] - except IndexError: - logger.error( - "Cannot strip domain with prefix %s from passed value %s. Skipping cleanup operation", - prefix, - domain, - ) - continue - thread = threading.Thread(target=flush_workers, args=(cleaned_domain,)) - thread.start() + for prefix in prefixes: + if prefix in domain: + stripped_domain = domain.split(prefix)[1] + if stripped_domain in unique_domains: + logger.error( + "We have a non-unique domain here", + domain, + ) + return False + unique_domains.append(stripped_domain) + return True def main(): @@ -56,10 +102,14 @@ def main(): Raises: DomainsNotInConfig: If no domains were found in configuration file. + DomainsAreNotUnique: If there were non-unique domains after stripping prefix """ domains = config.load_config().get("domains") + prefixes = config.load_config().get("domain_prefixes") if not domains: raise DomainsNotInConfig("Could not locate domains in configuration.") + if not check_all_domains_unique(domains, prefixes): + raise DomainsAreNotUnique("There are non-unique domains! Check config.") clean_up_worker(domains) watch_queue() mqtt.connect() diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index 1fbea9d..111590b 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -5,17 +5,57 @@ class AppTest(unittest.TestCase): + """unittest.TestCase class""" + def setUp(self) -> None: + """set up unittests""" app._CLEANUP_TIME = 0 + def test_unique_domains_success(self): + """Ensure domain suffixes are unique.""" + test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] + test_domains = [ + "TEST_PREFIX_DOMAINSUFFIX1", + "TEST_PREFIX_DOMAINSUFFIX2", + "TEST_PREFIX2_DOMAINSUFFIX3", + ] + self.assertTrue( + app.check_all_domains_unique(test_domains, test_prefixes), + "unique domains are not detected unique", + ) + + def test_unique_domains_fail(self): + """Ensure domain suffixes are not unique.""" + test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] + test_domains = [ + "TEST_PREFIX_DOMAINSUFFIX1", + "TEST_PREFIX_DOMAINSUFFIX2", + "TEST_PREFIX2_DOMAINSUFFIX1", + ] + self.assertFalse( + app.check_all_domains_unique(test_domains, test_prefixes), + "non-unique domains are detected as unique", + ) + + def test_unique_domains_not_list(self): + """Ensure domain prefixes are a list.""" + test_prefixes = "TEST_PREFIX_, TEST_PREFIX2_" + test_domains = [ + "TEST_PREFIX_DOMAINSUFFIX1", + "TEST_PREFIX_DOMAINSUFFIX2", + "TEST_PREFIX2_DOMAINSUFFIX1", + ] + with self.assertRaises(TypeError): + app.check_all_domains_unique(test_domains, test_prefixes) + @mock.patch.object(app.config, "load_config") @mock.patch.object(app.mqtt, "connect", autospec=True) def test_main_success(self, connect_mock, config_mock): """Ensure we can execute main.""" connect_mock.return_value = None - test_prefix = "TEST_PREFIX_" + test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] config_mock.return_value = dict( - domains=[f"{test_prefix}domain.one"], domain_prefix=test_prefix + domains=[f"{test_prefixes[1]}domain.one"], domain_prefixes=test_prefixes ) with mock.patch("app.flush_workers", return_value=None): app.main() @@ -34,9 +74,9 @@ def test_main_fails_no_domain(self, connect_mock, config_mock): @mock.patch.object(app.mqtt, "connect", autospec=True) def test_main_fails_bad_domain(self, connect_mock, config_mock): """Ensure we fail when domains are badly formatted.""" - test_prefix = "TEST_PREFIX_" + test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] config_mock.return_value = dict( - domains=[f"cant_split_domain"], domain_prefix=test_prefix + domains=[f"cant_split_domain"], domain_prefixes=test_prefixes ) connect_mock.return_value = None with mock.patch("app.flush_workers", return_value=None): diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 995d49c..1c1cf31 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -84,15 +84,19 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> """ # TODO(ruairi): Check bounds and raise exception here. logger.debug("Got message %s from MTQQ", message) - domain_prefix = load_config().get("domain_prefix") - domain = re.search(r"/.*" + domain_prefix + "(\w+)/", message.topic) + domain_prefixes = load_config().get("domain_prefixes") + domain = None + for domain_prefix in domain_prefixes: + domain = re.search(r"/.*" + domain_prefix + "(\w+)/", message.topic) + if domain: + break if not domain: raise ValueError( - "Could not find a match for %s on %s", domain_prefix, message.topic + "Could not find a match for %s on %s", repr(domain_prefixes), message.topic ) + # this will not work, if we have non-unique prefix stripped domains domain = domain.group(1) logger.debug("Found domain %s", domain) - logger.info( f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain} adding to queue" ) From 2e3f68c78a306fbad306f014322d557e055e1350 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 09:09:55 +0000 Subject: [PATCH 084/107] Update pyroute2 requirement from ~=0.7.9 to ~=0.7.10 Updates the requirements on [pyroute2](https://github.com/svinota/pyroute2) to permit the latest version. - [Release notes](https://github.com/svinota/pyroute2/releases) - [Changelog](https://github.com/svinota/pyroute2/blob/master/CHANGELOG.rst) - [Commits](https://github.com/svinota/pyroute2/commits) --- updated-dependencies: - dependency-name: pyroute2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9e7853f..97a41ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ NetLink~=0.1 flask-mqtt -pyroute2~=0.7.9 +pyroute2~=0.7.10 PyYAML~=6.0.1 Flask~=3.0.0 waitress~=2.1.2 From f8a8b59ee24a8b796c50d7114d2797bd590fd32a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jan 2024 09:35:55 +0000 Subject: [PATCH 085/107] Bump actions/setup-python from 4 to 5 Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/black.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml index e91693a..cc2368f 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -7,5 +7,5 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 - uses: psf/black@stable From 804107d6f692cac86f4b55fb05a56fef6c82956f Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 086/107] Refactor tests to allow running trough cmdline unittest --- README.md | 9 ++++++++- requirements.txt | 2 +- wgkex/common/BUILD | 3 ++- wgkex/common/utils.py | 2 ++ wgkex/common/utils_test.py | 2 +- wgkex/config/BUILD | 2 +- wgkex/config/config_test.py | 3 ++- wgkex/worker/BUILD | 13 +++++++------ wgkex/worker/app_test.py | 24 ++++++++++++++++-------- wgkex/worker/mqtt.py | 2 +- wgkex/worker/mqtt_test.py | 8 ++++---- wgkex/worker/netlink_test.py | 3 ++- 12 files changed, 47 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 18bf361..2c32b25 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,14 @@ Worker: python3 -c 'from wgkex.worker.app import main; main()' ``` -## Client usage + +## Development + +### Unit tests + +The test can be run using `bazel test ... --test_output=all` or `python3 -m unittest discover -p '*_test.py'`. + +### Client The client can be used via CLI: diff --git a/requirements.txt b/requirements.txt index 97a41ba..1821412 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,4 @@ waitress~=2.1.2 ipaddress~=1.0.23 mock~=5.1.0 coverage -paho-mqtt~=1.6.1 \ No newline at end of file +paho-mqtt~=1.6.1 diff --git a/wgkex/common/BUILD b/wgkex/common/BUILD index 4a12559..93b284b 100644 --- a/wgkex/common/BUILD +++ b/wgkex/common/BUILD @@ -15,7 +15,8 @@ py_test( name = "utils_test", srcs = ["utils_test.py"], deps = [ - ":utils", + "//wgkex/common:utils", + "//wgkex/config:config", requirement("mock"), ], ) diff --git a/wgkex/common/utils.py b/wgkex/common/utils.py index fecebef..276c2de 100644 --- a/wgkex/common/utils.py +++ b/wgkex/common/utils.py @@ -2,6 +2,8 @@ import ipaddress import re +from wgkex.config import config + def mac2eui64(mac: str, prefix=None) -> str: """Converts a MAC address to an EUI64 identifier. diff --git a/wgkex/common/utils_test.py b/wgkex/common/utils_test.py index e14b174..a0aa187 100644 --- a/wgkex/common/utils_test.py +++ b/wgkex/common/utils_test.py @@ -1,5 +1,5 @@ import unittest -import utils +from wgkex.common import utils class UtilsTest(unittest.TestCase): diff --git a/wgkex/config/BUILD b/wgkex/config/BUILD index 1ca5fb3..8167c58 100644 --- a/wgkex/config/BUILD +++ b/wgkex/config/BUILD @@ -16,7 +16,7 @@ py_test( name="config_test", srcs=["config_test.py"], deps=[ - ":config", + "//wgkex/config:config", requirement("mock"), ], ) diff --git a/wgkex/config/config_test.py b/wgkex/config/config_test.py index 3c33148..d8d6a15 100644 --- a/wgkex/config/config_test.py +++ b/wgkex/config/config_test.py @@ -1,9 +1,10 @@ """Tests for configuration handling class.""" import unittest import mock -import config import yaml +from wgkex.config import config + _VALID_CFG = ( "domain_prefixes:\n- ffmuc_\n- ffdon_\n- ffwert_\nlog_level: DEBUG\ndomains:\n- a\n- b\nmqtt:\n broker_port: 1883" "\n broker_url: mqtt://broker\n keepalive: 5\n password: pass\n tls: true\n username: user\n" diff --git a/wgkex/worker/BUILD b/wgkex/worker/BUILD index 80a82eb..7f1c2c3 100644 --- a/wgkex/worker/BUILD +++ b/wgkex/worker/BUILD @@ -21,8 +21,9 @@ py_test( name = "netlink_test", srcs = ["netlink_test.py"], deps = [ - ":netlink", + "//wgkex/worker:netlink", requirement("mock"), + requirement("pyroute2"), ], ) @@ -46,8 +47,8 @@ py_test( name = "mqtt_test", srcs = ["mqtt_test.py"], deps = [ - ":mqtt", - ":msg_queue", + "//wgkex/worker:mqtt", + "//wgkex/worker:msg_queue", requirement("mock"), ], ) @@ -67,8 +68,8 @@ py_test( name = "app_test", srcs = ["app_test.py"], deps = [ - ":app", - ":msg_queue", + "//wgkex/worker:app", + "//wgkex/worker:msg_queue", requirement("mock"), ], ) @@ -80,4 +81,4 @@ py_library( deps = [ "//wgkex/common:logger", ], -) \ No newline at end of file +) diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index 111590b..717fcfe 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -1,7 +1,9 @@ """Unit tests for app.py""" import unittest import mock -import app + +import wgkex.config.config +from wgkex.worker import app class AppTest(unittest.TestCase): @@ -48,43 +50,49 @@ def test_unique_domains_not_list(self): with self.assertRaises(TypeError): app.check_all_domains_unique(test_domains, test_prefixes) - @mock.patch.object(app.config, "load_config") + @mock.patch.object(wgkex.config.config, "fetch_from_config") + @mock.patch.object(wgkex.config.config, "load_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_success(self, connect_mock, config_mock): + def test_main_success(self, connect_mock, config_mock, config_fetch_mock): """Ensure we can execute main.""" connect_mock.return_value = None test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] config_mock.return_value = dict( domains=[f"{test_prefixes[1]}domain.one"], domain_prefixes=test_prefixes ) - with mock.patch("app.flush_workers", return_value=None): + config_fetch_mock.side_effect = config_mock().get + with mock.patch.object(app, "flush_workers", return_value=None): app.main() - connect_mock.assert_called_with() + connect_mock.assert_called() + @mock.patch.object(wgkex.config.config, "fetch_from_config") @mock.patch.object(app.config, "load_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_fails_no_domain(self, connect_mock, config_mock): + def test_main_fails_no_domain(self, connect_mock, config_mock, config_fetch_mock): """Ensure we fail when domains are not configured.""" config_mock.return_value = dict(domains=None) + config_fetch_mock.side_effect = config_mock().get connect_mock.return_value = None with self.assertRaises(app.DomainsNotInConfig): app.main() + @mock.patch.object(wgkex.config.config, "fetch_from_config") @mock.patch.object(app.config, "load_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_fails_bad_domain(self, connect_mock, config_mock): + def test_main_fails_bad_domain(self, connect_mock, config_mock, config_fetch_mock): """Ensure we fail when domains are badly formatted.""" test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] config_mock.return_value = dict( domains=[f"cant_split_domain"], domain_prefixes=test_prefixes ) + config_fetch_mock.side_effect = config_mock().get connect_mock.return_value = None with mock.patch("app.flush_workers", return_value=None): app.main() connect_mock.assert_called_with() @mock.patch("time.sleep", side_effect=InterruptedError) - @mock.patch("app.wg_flush_stale_peers") + @mock.patch.object(app, "wg_flush_stale_peers") def test_flush_workers(self, flush_mock, sleep_mock): """Ensure we fail when domains are badly formatted.""" flush_mock.return_value = "" diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 1c1cf31..2216d69 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -92,7 +92,7 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> break if not domain: raise ValueError( - "Could not find a match for %s on %s", repr(domain_prefixes), message.topic + f"Could not find a match for {domain_prefixes} on {message.topic}" ) # this will not work, if we have non-unique prefix stripped domains domain = domain.group(1) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 8e2fcbf..8aece41 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -1,8 +1,8 @@ """Unit tests for mqtt.py""" import unittest import mock -import mqtt -import msg_queue + +from wgkex.worker import mqtt class MQTTTest(unittest.TestCase): @@ -48,7 +48,7 @@ def test_on_message_success(self, config_mock, link_mock): config_mock.return_value = {"domain_prefix": "_ffmuc_"} link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") - mqtt_msg.topic = "/_ffmuc_domain1/" + mqtt_msg.topic = "wireguard/_ffmuc_domain1/gateway" mqtt_msg.payload = b"PUB_KEY" mqtt.on_message(None, None, mqtt_msg) link_mock.assert_has_calls( @@ -80,7 +80,7 @@ def test_on_message_fails_no_domain(self, config_mock, link_mock): } link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") - mqtt_msg.topic = "bad_domain_match" + mqtt_msg.topic = "wireguard/bad_domain_match" with self.assertRaises(ValueError): mqtt.on_message(None, None, mqtt_msg) """ diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index aeb4ff3..c209731 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -13,7 +13,8 @@ sys.modules["pyroute2.IPRoute"] = mock.MagicMock() from pyroute2 import WireGuard from pyroute2 import IPRoute -import netlink + +from wgkex.worker import netlink _WG_CLIENT_ADD = netlink.WireGuardClient( public_key="public_key", domain="add", remove=False From 2f1a9586fb1e9fae404d0a3bb2d01a79b0bdf294 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 087/107] Use Config class over raw dict everywhere --- wgkex/broker/app.py | 35 +++---------- wgkex/common/BUILD | 2 +- wgkex/common/utils.py | 14 ++++++ wgkex/config/__init__.py | 4 +- wgkex/config/config.py | 98 +++++++++++++++++++++++-------------- wgkex/config/config_test.py | 18 ++++--- wgkex/worker/app.py | 30 ++++++++---- wgkex/worker/app_test.py | 40 +++++++-------- wgkex/worker/mqtt.py | 47 +++++------------- wgkex/worker/mqtt_test.py | 74 +++++++++++++++------------- 10 files changed, 188 insertions(+), 174 deletions(-) diff --git a/wgkex/broker/app.py b/wgkex/broker/app.py index f01ec3f..f5d23d7 100644 --- a/wgkex/broker/app.py +++ b/wgkex/broker/app.py @@ -2,7 +2,6 @@ """wgkex broker""" import re import dataclasses -import logging from typing import Tuple, Any from flask import Flask @@ -17,6 +16,7 @@ from waitress import serve from wgkex.config import config from wgkex.common import logger +from wgkex.common.utils import is_valid_domain WG_PUBKEY_PATTERN = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") @@ -43,7 +43,9 @@ def from_dict(cls, msg: dict) -> "KeyExchange": A KeyExchange object. """ public_key = is_valid_wg_pubkey(msg.get("public_key")) - domain = is_valid_domain(msg.get("domain")) + domain = str(msg.get("domain")) + if not is_valid_domain(domain): + raise ValueError(f"Domain {domain} not in configured domains.") return cls(public_key=public_key, domain=domain) @@ -54,8 +56,7 @@ def _fetch_app_config() -> Flask_app: A created Flask app. """ app = Flask(__name__) - # TODO(ruairi): Refactor load_config to return Dataclass. - mqtt_cfg = config.Config.from_dict(config.load_config()).mqtt + mqtt_cfg = config.get_config().mqtt app.config["MQTT_BROKER_URL"] = mqtt_cfg.broker_url app.config["MQTT_BROKER_PORT"] = mqtt_cfg.broker_port app.config["MQTT_USERNAME"] = mqtt_cfg.username @@ -140,33 +141,13 @@ def is_valid_wg_pubkey(pubkey: str) -> str: return pubkey -def is_valid_domain(domain: str) -> str: - """Verifies if the domain is configured. - - Arguments: - domain: The domain to verify. - - Raises: - ValueError: If the domain is not configured. - - Returns: - The domain. - """ - # TODO(ruairi): Refactor to return bool. - if domain not in config.fetch_from_config("domains"): - raise ValueError( - f'Domains {domain} not in configured domains({config.fetch_from_config("domains")}) a valid domain' - ) - return domain - - if __name__ == "__main__": listen_host = None listen_port = None - listen_config = config.fetch_from_config("broker_listen") + listen_config = config.get_config().broker_listen if listen_config is not None: - listen_host = listen_config.get("host") - listen_port = listen_config.get("port") + listen_host = listen_config.host + listen_port = listen_config.port serve(app, host=listen_host, port=listen_port) diff --git a/wgkex/common/BUILD b/wgkex/common/BUILD index 93b284b..7a79f93 100644 --- a/wgkex/common/BUILD +++ b/wgkex/common/BUILD @@ -25,4 +25,4 @@ py_library( name = "logger", srcs = ["logger.py"], visibility = ["//visibility:public"] -) \ No newline at end of file +) diff --git a/wgkex/common/utils.py b/wgkex/common/utils.py index 276c2de..45c7b7b 100644 --- a/wgkex/common/utils.py +++ b/wgkex/common/utils.py @@ -37,3 +37,17 @@ def mac2eui64(mac: str, prefix=None) -> str: net = ipaddress.ip_network(prefix, strict=False) euil = int(f"0x{eui64:16}", 16) return f"{net[euil]}/{net.prefixlen}" + + +def is_valid_domain(domain: str) -> bool: + """Verifies if the domain is configured. + + Arguments: + domain: The domain to verify. + + Returns: + True if the domain is valid, False otherwise. + """ + return domain in config.get_config().domains and domain.startswith( + config.get_config().domain_prefix + ) diff --git a/wgkex/config/__init__.py b/wgkex/config/__init__.py index 1b48be8..9c9cace 100644 --- a/wgkex/config/__init__.py +++ b/wgkex/config/__init__.py @@ -1,3 +1,3 @@ -from wgkex.config.config import load_config +from wgkex.config.config import get_config -__all__ = ["load_config"] +__all__ = ["get_config"] diff --git a/wgkex/config/config.py b/wgkex/config/config.py index 0659d69..29ba0a5 100644 --- a/wgkex/config/config.py +++ b/wgkex/config/config.py @@ -1,11 +1,11 @@ """Configuration handling class.""" +import dataclasses import logging import os import sys +from typing import Dict, Any, List, Optional + import yaml -from functools import lru_cache -from typing import Dict, Union, Any, List, Optional -import dataclasses class Error(Exception): @@ -20,9 +20,29 @@ class ConfigFileNotFoundError(Error): WG_CONFIG_DEFAULT_LOCATION = "/etc/wgkex.yaml" +@dataclasses.dataclass +class BrokerListen: + """A representation of the 'broker_listen' key in Configuration file. + + Attributes: + host: The listen address the broker should listen to for the HTTP API. + port: The port the broker should listen to for the HTTP API. + """ + + host: Optional[str] + port: Optional[int] + + @classmethod + def from_dict(cls, broker_listen: Dict[str, Any]) -> "BrokerListen": + return cls( + host=broker_listen.get("host"), + port=broker_listen.get("port"), + ) + + @dataclasses.dataclass class MQTT: - """A representation of MQTT key in Configuration file. + """A representation of the 'mqtt' key in Configuration file. Attributes: broker_url: The broker URL for MQTT to connect to. @@ -54,11 +74,9 @@ def from_dict(cls, mqtt_cfg: Dict[str, str]) -> "MQTT": broker_url=mqtt_cfg["broker_url"], username=mqtt_cfg["username"], password=mqtt_cfg["password"], - tls=mqtt_cfg["tls"] if mqtt_cfg["tls"] else False, - broker_port=int(mqtt_cfg["broker_port"]) - if mqtt_cfg["broker_port"] - else None, - keepalive=int(mqtt_cfg["keepalive"]) if mqtt_cfg["keepalive"] else None, + tls=bool(mqtt_cfg.get("tls", cls.tls)), + broker_port=int(mqtt_cfg.get("broker_port", cls.broker_port)), + keepalive=int(mqtt_cfg.get("keepalive", cls.keepalive)), ) @@ -68,59 +86,65 @@ class Config: Attributes: domains: The list of domains to listen for. + domain_prefixes: The list of prefixes to pre-pend to a given domain. mqtt: The MQTT configuration. - domain_prefixes: The list of prefixes to pre-pend to a given domain.""" + """ + raw: Dict[str, Any] domains: List[str] - mqtt: MQTT domain_prefixes: List[str] + broker_listen: BrokerListen + mqtt: MQTT @classmethod - def from_dict(cls, cfg: Dict[str, str]) -> "Config": + def from_dict(cls, cfg: Dict[str, Any]) -> "Config": """Creates a Config object from a configuration file. Arguments: cfg: The configuration file as a dict. Returns: A Config object. """ + broker_listen = BrokerListen.from_dict(cfg.get("broker_listen", {})) mqtt_cfg = MQTT.from_dict(cfg["mqtt"]) return cls( + raw=cfg, domains=cfg["domains"], - mqtt=mqtt_cfg, domain_prefixes=cfg["domain_prefixes"], + broker_listen=broker_listen, + mqtt=mqtt_cfg, ) + def get(self, key: str) -> Any: + """Get the value of key from the raw dict representation of the config file""" + return self.raw.get(key) -@lru_cache(maxsize=10) -def fetch_from_config(key: str) -> Optional[Union[Dict[str, Any], List[str]]]: - """Fetches a specific key from configuration. - Arguments: - key: The named key to fetch. - Returns: - The config value associated with the key - """ - return load_config().get(key) +_parsed_config: Optional[Config] = None -def load_config() -> Dict[str, str]: - """Fetches and validates configuration file from disk. +def get_config() -> Config: + """Returns a parsed Config object. + Raises: + ConfigFileNotFoundError: If we could not find the configuration file on disk. Returns: - Linted configuration file. + The Config representation of the config file """ - cfg_contents = fetch_config_from_disk() - try: - config = yaml.safe_load(cfg_contents) - except yaml.YAMLError as e: - print("Failed to load YAML file: %s", e) - sys.exit(1) - try: - _ = Config.from_dict(config) - return config - except (KeyError, TypeError) as e: - print("Failed to lint file: %s", e) - sys.exit(2) + global _parsed_config + if _parsed_config is None: + cfg_contents = fetch_config_from_disk() + try: + config = yaml.safe_load(cfg_contents) + except yaml.YAMLError as e: + print("Failed to load YAML file: %s" % e) + sys.exit(1) + try: + config = Config.from_dict(config) + except (KeyError, TypeError, AttributeError) as e: + print("Failed to lint file: %s" % e) + sys.exit(2) + _parsed_config = config + return _parsed_config def fetch_config_from_disk() -> str: diff --git a/wgkex/config/config_test.py b/wgkex/config/config_test.py index d8d6a15..6e30eb3 100644 --- a/wgkex/config/config_test.py +++ b/wgkex/config/config_test.py @@ -17,18 +17,22 @@ class TestConfig(unittest.TestCase): + def tearDown(self) -> None: + config._parsed_config = None + return super().tearDown() + def test_load_config_success(self): """Test loads and lint config successfully.""" mock_open = mock.mock_open(read_data=_VALID_CFG) with mock.patch("builtins.open", mock_open): - self.assertDictEqual(yaml.safe_load(_VALID_CFG), config.load_config()) + self.assertDictEqual(yaml.safe_load(_VALID_CFG), config.get_config().raw) @mock.patch.object(config.sys, "exit", autospec=True) def test_load_config_fails_good_yaml_bad_format(self, exit_mock): """Test loads yaml successfully and fails lint.""" mock_open = mock.mock_open(read_data=_INVALID_LINT) with mock.patch("builtins.open", mock_open): - config.load_config() + config.get_config() exit_mock.assert_called_with(2) @mock.patch.object(config.sys, "exit", autospec=True) @@ -36,7 +40,7 @@ def test_load_config_fails_bad_yaml(self, exit_mock): """Test loads bad YAML.""" mock_open = mock.mock_open(read_data=_INVALID_CFG) with mock.patch("builtins.open", mock_open): - config.load_config() + config.get_config() exit_mock.assert_called_with(2) def test_fetch_config_from_disk_success(self): @@ -53,17 +57,17 @@ def test_fetch_config_from_disk_fails_file_not_found(self): with self.assertRaises(config.ConfigFileNotFoundError): config.fetch_config_from_disk() - def test_fetch_from_config_success(self): + def test_raw_get_success(self): """Test fetch key from configuration.""" mock_open = mock.mock_open(read_data=_VALID_CFG) with mock.patch("builtins.open", mock_open): - self.assertListEqual(["a", "b"], config.fetch_from_config("domains")) + self.assertListEqual(["a", "b"], config.get_config().raw.get("domains")) - def test_fetch_from_config_no_key_in_config(self): + def test_raw_get_no_key_in_config(self): """Test fetch non-existent key from configuration.""" mock_open = mock.mock_open(read_data=_VALID_CFG) with mock.patch("builtins.open", mock_open): - self.assertIsNone(config.fetch_from_config("key_does_not_exist")) + self.assertIsNone(config.get_config().raw.get("key_does_not_exist")) if __name__ == "__main__": diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 70aa8fc..7fd8b7f 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -1,13 +1,15 @@ """Initialises the MQTT worker.""" -import wgkex.config.config as config +import threading +import time +from typing import Text + +from wgkex.common import logger +from wgkex.common.utils import is_valid_domain +from wgkex.config import config from wgkex.worker import mqtt from wgkex.worker.msg_queue import watch_queue from wgkex.worker.netlink import wg_flush_stale_peers -import time -import threading -from wgkex.common import logger -from typing import List, Text _CLEANUP_TIME = 3600 @@ -28,6 +30,10 @@ class DomainsAreNotUnique(Error): """If non-unique domains exist in configuration file.""" +class InvalidDomain(Error): + """If the domains is invalid and is not listed in the configuration file.""" + + def flush_workers(domain: Text) -> None: """Calls peer flush every _CLEANUP_TIME interval.""" while True: @@ -36,14 +42,15 @@ def flush_workers(domain: Text) -> None: logger.info("Cleaned up domains: %s", wg_flush_stale_peers(domain)) -def clean_up_worker(domains: List[Text]) -> None: +def clean_up_worker() -> None: """Wraps flush_workers in a thread for all given domains. Arguments: domains: list of domains. """ + domains = config.get_config().domains + prefixes = config.get_config().domain_prefixes logger.debug("Cleaning up the following domains: %s", domains) - prefixes = config.load_config().get("domain_prefixes") cleanup_counter = 0 # ToDo: do we need a check if every domain got gleaned? for prefix in prefixes: @@ -104,13 +111,16 @@ def main(): DomainsNotInConfig: If no domains were found in configuration file. DomainsAreNotUnique: If there were non-unique domains after stripping prefix """ - domains = config.load_config().get("domains") - prefixes = config.load_config().get("domain_prefixes") + domains = config.get_config().domains + prefixes = config.get_config().domain_prefixes if not domains: raise DomainsNotInConfig("Could not locate domains in configuration.") if not check_all_domains_unique(domains, prefixes): raise DomainsAreNotUnique("There are non-unique domains! Check config.") - clean_up_worker(domains) + for domain in domains: + if not is_valid_domain(domain): + raise InvalidDomain(f"Domain {domain} has invalid prefix.") + clean_up_worker() watch_queue() mqtt.connect() diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index 717fcfe..cfb2f37 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -6,6 +6,16 @@ from wgkex.worker import app +def _get_config_mock(domains=None): + test_prefixes = ["_TEST_PREFIX_", "_TEST_PREFIX2_"] + config_mock = mock.MagicMock() + config_mock.domains = ( + domains if domains is not None else [f"{test_prefixes[1]}domain.one"] + ) + config_mock.domain_prefixes = test_prefixes + return config_mock + + class AppTest(unittest.TestCase): """unittest.TestCase class""" @@ -50,46 +60,34 @@ def test_unique_domains_not_list(self): with self.assertRaises(TypeError): app.check_all_domains_unique(test_domains, test_prefixes) - @mock.patch.object(wgkex.config.config, "fetch_from_config") - @mock.patch.object(wgkex.config.config, "load_config") + @mock.patch.object(app.config, "get_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_success(self, connect_mock, config_mock, config_fetch_mock): + def test_main_success(self, connect_mock, config_mock): """Ensure we can execute main.""" connect_mock.return_value = None - test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] - config_mock.return_value = dict( - domains=[f"{test_prefixes[1]}domain.one"], domain_prefixes=test_prefixes - ) - config_fetch_mock.side_effect = config_mock().get + config_mock.return_value = _get_config_mock() with mock.patch.object(app, "flush_workers", return_value=None): app.main() connect_mock.assert_called() - @mock.patch.object(wgkex.config.config, "fetch_from_config") - @mock.patch.object(app.config, "load_config") + @mock.patch.object(app.config, "get_config") @mock.patch.object(app.mqtt, "connect", autospec=True) def test_main_fails_no_domain(self, connect_mock, config_mock, config_fetch_mock): """Ensure we fail when domains are not configured.""" - config_mock.return_value = dict(domains=None) - config_fetch_mock.side_effect = config_mock().get + config_mock.return_value = _get_config_mock(domains=[]) connect_mock.return_value = None with self.assertRaises(app.DomainsNotInConfig): app.main() - @mock.patch.object(wgkex.config.config, "fetch_from_config") - @mock.patch.object(app.config, "load_config") + @mock.patch.object(app.config, "get_config") @mock.patch.object(app.mqtt, "connect", autospec=True) def test_main_fails_bad_domain(self, connect_mock, config_mock, config_fetch_mock): """Ensure we fail when domains are badly formatted.""" - test_prefixes = ["TEST_PREFIX_", "TEST_PREFIX2_"] - config_mock.return_value = dict( - domains=[f"cant_split_domain"], domain_prefixes=test_prefixes - ) - config_fetch_mock.side_effect = config_mock().get + config_mock.return_value = _get_config_mock(domains=["cant_split_domain"]) connect_mock.return_value = None - with mock.patch("app.flush_workers", return_value=None): + with self.assertRaises(app.InvalidDomain): app.main() - connect_mock.assert_called_with() + connect_mock.assert_not_called() @mock.patch("time.sleep", side_effect=InterruptedError) @mock.patch.object(app, "wg_flush_stale_peers") diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index 2216d69..c58be0d 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -1,46 +1,25 @@ #!/usr/bin/env python3 """Process messages from MQTT.""" -import paho.mqtt.client as mqtt - # TODO(ruairi): Deprecate __init__.py from config, as it masks namespace. -from wgkex.config.config import load_config import socket import re -from typing import Optional, Dict, Any, Union -from wgkex.common import logger -from wgkex.worker.msg_queue import q - - -def fetch_from_config(var: str) -> Optional[Union[Dict[str, str], str]]: - """Fetches values from configuration file. - - Arguments: - var: The variable to fetch from config. +from typing import Any - Raises: - ValueError: If given key cannot be found in configuration. +import paho.mqtt.client as mqtt - Returns: - The given variable from configuration. - """ - config = load_config() - ret = config.get(var) - if not ret: - raise ValueError("Failed to get %s from configuration, failing", var) - return config.get(var) +from wgkex.common import logger +from wgkex.config.config import get_config +from wgkex.worker.msg_queue import q +from wgkex.worker.netlink import link_handler, WireGuardClient def connect() -> None: - """Connect to MQTT for the given domains. - - Argument: - domains: The domains to connect to. - """ - base_config = fetch_from_config("mqtt") - broker_address = base_config.get("broker_url") - broker_port = base_config.get("broker_port") - broker_keepalive = base_config.get("keepalive") + """Connect to MQTT.""" + base_config = get_config().mqtt + broker_address = base_config.broker_url + broker_port = base_config.broker_port + broker_keepalive = base_config.keepalive # TODO(ruairi): Move the hostname to a global variable. client = mqtt.Client(socket.gethostname()) @@ -64,7 +43,7 @@ def on_connect(client: mqtt.Client, userdata: Any, flags, rc) -> None: rc: The MQTT rc. """ logger.debug("Connected with result code " + str(rc)) - domains = load_config().get("domains") + domains = get_config().domains # Subscribing in on_connect() means that if we lose the connection and # reconnect then subscriptions will be renewed. @@ -84,7 +63,7 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> """ # TODO(ruairi): Check bounds and raise exception here. logger.debug("Got message %s from MTQQ", message) - domain_prefixes = load_config().get("domain_prefixes") + domain_prefixes = get_config().domain_prefixes domain = None for domain_prefix in domain_prefixes: domain = re.search(r"/.*" + domain_prefix + "(\w+)/", message.topic) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 8aece41..cd19aa0 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -5,47 +5,53 @@ from wgkex.worker import mqtt -class MQTTTest(unittest.TestCase): - @mock.patch.object(mqtt, "load_config") - def test_fetch_from_config_success(self, config_mock): - """Ensure we can fetch a value from config.""" - config_mock.return_value = dict(key="value") - self.assertEqual("value", mqtt.fetch_from_config("key")) +def _get_config_mock(domains=None, mqtt=None): + test_prefix = "_ffmuc_" + config_mock = mock.MagicMock() + config_mock.domains = ( + domains if domains is not None else [f"{test_prefix}domain.one"] + ) + config_mock.domain_prefix = test_prefix + if mqtt: + config_mock.mqtt = mqtt + return config_mock - @mock.patch.object(mqtt, "load_config") - def test_fetch_from_config_fails_no_key(self, config_mock): - """Tests we fail with ValueError for missing key in config.""" - config_mock.return_value = dict(key="value") - with self.assertRaises(ValueError): - mqtt.fetch_from_config("does_not_exist") +class MQTTTest(unittest.TestCase): @mock.patch.object(mqtt.mqtt, "Client") @mock.patch.object(mqtt.socket, "gethostname") - @mock.patch.object(mqtt, "load_config") + @mock.patch.object(mqtt, "get_config") def test_connect_success(self, config_mock, hostname_mock, mqtt_mock): """Tests successful connection to MQTT server.""" hostname_mock.return_value = "hostname" - config_mock.return_value = dict(mqtt={"broker_url": "some_url"}) + config_mqtt_mock = mock.MagicMock() + config_mqtt_mock.broker_url = "some_url" + config_mqtt_mock.broker_port = 1833 + config_mqtt_mock.keepalive = False + config_mock.return_value = _get_config_mock(mqtt=config_mqtt_mock) mqtt.connect() mqtt_mock.assert_has_calls( - [mock.call().connect("some_url", port=None, keepalive=None)], + [mock.call().connect("some_url", port=1833, keepalive=False)], any_order=True, ) @mock.patch.object(mqtt.mqtt, "Client") - @mock.patch.object(mqtt, "load_config") + @mock.patch.object(mqtt, "get_config") def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): """Tests failure for connect - ValueError.""" mqtt_mock.side_effect = ValueError("barf") - config_mock.return_value = dict(mqtt={"broker_url": "some_url"}) + config_mqtt_mock = mock.MagicMock() + config_mqtt_mock.broker_url = "some_url" + config_mock.return_value = _get_config_mock(mqtt=config_mqtt_mock) with self.assertRaises(ValueError): mqtt.connect() -""" @mock.patch.object(msg_queue, "link_handler") - @mock.patch.object(mqtt, "load_config") +""" @mock.patch.object(msg_queue, "link_handler") + @mock.patch.object(mqtt, "get_config") def test_on_message_success(self, config_mock, link_mock): - config_mock.return_value = {"domain_prefix": "_ffmuc_"} + # Tests on_message for success. + config_mock.return_value = _get_config_mock() link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") mqtt_msg.topic = "wireguard/_ffmuc_domain1/gateway" @@ -63,27 +69,25 @@ def test_on_message_success(self, config_mock, link_mock): ) @mock.patch.object(msg_queue, "link_handler") - @mock.patch.object(mqtt, "load_config") + @mock.patch.object(mqtt, "get_config") def test_on_message_fails_no_domain(self, config_mock, link_mock): - config_mock.return_value = { - "domain_prefix": "ffmuc_", - "log_level": "DEBUG", - "domains": ["a", "b"], - "mqtt": { - "broker_port": 1883, - "broker_url": "mqtt://broker", - "keepalive": 5, - "password": "pass", - "tls": True, - "username": "user", - }, - } + # Tests on_message for failure to parse domain. + config_mqtt_mock = mock.MagicMock() + config_mqtt_mock.broker_url = "mqtt://broker" + config_mqtt_mock.broker_port = 1883 + config_mqtt_mock.keepalive = 5 + config_mqtt_mock.password = "pass" + config_mqtt_mock.tls = True + config_mqtt_mock.username = "user" + config_mock.return_value = _get_config_mock( + domains=["a", "b"], mqtt=config_mqtt_mock + ) link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") mqtt_msg.topic = "wireguard/bad_domain_match" with self.assertRaises(ValueError): mqtt.on_message(None, None, mqtt_msg) - """ +""" if __name__ == "__main__": unittest.main() From 216008e70f7fff2c89ecdcbdd58d4a49f28b0579 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 088/107] Publish worker metrics and data, assign gateways to clients * Workers publish their number of connected peers per domain * Workers publish their status, i.e. up or down * The new /api/v2/exchange endpoint returns a predetermined gateway endpoint for clients * This gateway is chosen based on weighted loadbalancing between online workers/gateways * Fetch worker data through netlink and publish with MQTT: * Read worker pubkey, port and link address from interface. * Publish it together with the external domain / address (read from the config file) via MQTT to the broker. --- README.md | 47 ++++++++- wgkex.yaml.example | 26 ++++- wgkex/broker/BUILD | 13 +++ wgkex/broker/app.py | 163 ++++++++++++++++++++++++++---- wgkex/broker/metrics.py | 122 ++++++++++++++++++++++ wgkex/common/BUILD | 6 ++ wgkex/common/mqtt.py | 6 ++ wgkex/config/config.py | 56 +++++++++++ wgkex/worker/BUILD | 3 +- wgkex/worker/app.py | 18 +++- wgkex/worker/app_test.py | 5 +- wgkex/worker/mqtt.py | 190 +++++++++++++++++++++++++++++++++-- wgkex/worker/mqtt_test.py | 15 +-- wgkex/worker/netlink.py | 91 ++++++++++++++--- wgkex/worker/netlink_test.py | 2 + 15 files changed, 705 insertions(+), 58 deletions(-) create mode 100644 wgkex/broker/metrics.py create mode 100644 wgkex/common/mqtt.py diff --git a/README.md b/README.md index 2c32b25..9f673f8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - [Overview](#overview) - [Frontend broker](#frontend-broker) - [POST /api/v1/wg/key/exchange](#post-apiv1wgkeyexchange) + - [POST /api/v2/wg/key/exchange](#post-apiv2wgkeyexchange) - [Backend worker](#backend-worker) - [Installation](#installation) - [Configuration](#configuration) @@ -41,6 +42,7 @@ The frontend broker exposes the following API endpoints for use: ``` /api/v1/wg/key/exchange +/api/v2/wg/key/exchange ``` The listen address and port for the Flask server can be configured in `wgkex.yaml` under the `broker_listen` key: @@ -66,6 +68,35 @@ JSON POST'd to this endpoint should be in this format: The broker will validate the domain and public key, and if valid, will push the key onto the MQTT bus. + +#### POST /api/v2/wg/key/exchange + +JSON POST'd to this endpoint should be in this format: + +```json +{ + "domain": "CONFIGURED_DOMAIN", + "public_key": "PUBLIC_KEY" +} +``` + +The broker will validate the domain and public key, and if valid, will push the key onto the MQTT bus. +Additionally it chooses a worker (aka gateway, endpoint) that the client should connect to. +The response is JSON data containing the connection details for the chosen gateway: + +```json +{ + "Endpoint": { + "Address": "GATEWAY_ADDRESS", + "Port": "GATEWAY_WIREGUARD_PORT", + "AllowedIPs": [ + "GATEWAY_WIREGUARD_INTERFACE_ADDRESS" + ], + "PublicKey": "GATEWAY_PUBLIC_KEY" + } +} +``` + ### Backend worker The backend (worker) waits for new keys to appear on the MQTT message bus. Once a new key appears, the worker performs @@ -141,8 +172,13 @@ The test can be run using `bazel test ... --test_output=all` or `python3 -m unit The client can be used via CLI: ``` -$ wget -q -O- --post-data='{"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="}' --header='Content-Type:application/json' 'http://127.0.0.1:5000/api/v1/wg/key/exchange' +$ wget -q -O- --post-data='{"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="}' --header='Content-Type:application/json' 'http://127.0.0.1:5000/api/v2/wg/key/exchange' { + "Endpoint": { + "Address": "gw04.ext.ffmuc.net:40011", + "LinkAddress": "fe80::27c:16ff:fec0:6c74", + "PublicKey": "TszFS3oFRdhsJP3K0VOlklGMGYZy+oFCtlaghXJqW2g=" + }, "Message": "OK" } ``` @@ -153,7 +189,7 @@ Or via python: import requests key_data = {"domain": "ffmuc_welt","public_key": "o52Ge+Rpj4CUSitVag9mS7pSXUesNM0ESnvj/wwehkg="} broker_url = "http://127.0.0.1:5000" -push_key = requests.get(f'{broker_url}/api/v1/wg/key/exchange', json=key_data) +push_key = requests.get(f'{broker_url}/api/v2/wg/key/exchange', json=key_data) print(f'Key push was: {push_key.json().get("Message")}') ``` @@ -180,6 +216,13 @@ sudo ip link set wg-welt up sudo ip link set vx-welt up ``` +### MQTT topics + +Publishing keys broker->worker: `wireguard/{domain}/{worker}` +Publishing metrics worker->broker: `wireguard-metrics/{domain}/{worker}/connected_peers` +Publishing worker status: `wireguard-worker/{worker}/status` +Publishing worker data: `wireguard-worker/{worker}/{domain}/data` + ## Contact [Freifunk Munich Mattermost](https://chat.ffmuc.net) diff --git a/wgkex.yaml.example b/wgkex.yaml.example index 7b82c71..30340fe 100644 --- a/wgkex.yaml.example +++ b/wgkex.yaml.example @@ -1,3 +1,4 @@ +# [broker] The domains that should be accepted by clients and for which matching WireGuard interfaces exist domains: - ffmuc_muc_cty - ffmuc_muc_nord @@ -6,6 +7,25 @@ domains: - ffmuc_muc_west - ffmuc_welt - ffwert_city +# [broker, worker] The prefix is trimmed from the domain name and replaced with 'wg-' and 'vx-' +# to calculate the WireGuard and VXLAN interface names +domain_prefixes: + - ffmuc_ + - ffdon_ + - ffwert_ +# [broker] The dict of workers mapping their hostname to their respective weight for weighted peer distribution +workers: + gw04.in.ffmuc.net: + weight: 30 + gw05.in.ffmuc.net: + weight: 30 + gw06.in.ffmuc.net: + weight: 20 + gw07.in.ffmuc.net: + weight: 20 +# [worker] The external hostname of this worker +externalName: gw04.ext.ffmuc.net +# [broker, worker] MQTT connection informations mqtt: broker_url: broker.hivemq.com broker_port: 1883 @@ -13,13 +33,11 @@ mqtt: password: SECRET keepalive: 5 tls: False +# [broker] broker_listen: host: 0.0.0.0 port: 5000 -domain_prefixes: - - ffmuc_ - - ffdon_ - - ffwert_ +# [broker, worker] logging_config: formatters: standard: diff --git a/wgkex/broker/BUILD b/wgkex/broker/BUILD index 260fe45..414da32 100644 --- a/wgkex/broker/BUILD +++ b/wgkex/broker/BUILD @@ -1,6 +1,17 @@ load("@rules_python//python:defs.bzl", "py_binary", "py_test") load("@pip//:requirements.bzl", "requirement") +py_library( + name = "metrics", + srcs = ["metrics.py"], + visibility = ["//visibility:public"], + deps = [ + "//wgkex/common:mqtt", + "//wgkex/common:logger", + "//wgkex/config:config", + ], +) + py_binary( name="app", srcs=["app.py"], @@ -11,5 +22,7 @@ py_binary( requirement("flask-mqtt"), requirement("waitress"), "//wgkex/config:config", + "//wgkex/common:mqtt", + ":metrics" ], ) diff --git a/wgkex/broker/app.py b/wgkex/broker/app.py index f5d23d7..1d753ff 100644 --- a/wgkex/broker/app.py +++ b/wgkex/broker/app.py @@ -1,22 +1,25 @@ #!/usr/bin/env python3 """wgkex broker""" -import re import dataclasses -from typing import Tuple, Any +import json +import re +from typing import Dict, Tuple, Any -from flask import Flask -from flask import abort -from flask import jsonify -from flask import render_template -from flask import request +import paho.mqtt.client as mqtt_client +from flask import Flask, render_template, request, Response from flask.app import Flask as Flask_app from flask_mqtt import Mqtt -import paho.mqtt.client as mqtt_client from waitress import serve from wgkex.config import config from wgkex.common import logger from wgkex.common.utils import is_valid_domain +from wgkex.broker.metrics import WorkerMetricsCollection +from wgkex.common.mqtt import ( + CONNECTED_PEERS_METRIC, + TOPIC_WORKER_STATUS, + TOPIC_WORKER_WG_DATA, +) WG_PUBKEY_PATTERN = re.compile(r"^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=$") @@ -68,34 +71,86 @@ def _fetch_app_config() -> Flask_app: app = _fetch_app_config() mqtt = Mqtt(app) +worker_metrics = WorkerMetricsCollection() +worker_data: Dict[Tuple[str, str], Dict] = {} @app.route("/", methods=["GET"]) -def index() -> None: +def index() -> str: """Returns main page""" return render_template("index.html") @app.route("/api/v1/wg/key/exchange", methods=["POST"]) -def wg_key_exchange() -> Tuple[str, int]: +def wg_api_v1_key_exchange() -> Tuple[Response | Dict, int]: """Retrieves a new key and validates. - Returns: Status message. """ try: data = KeyExchange.from_dict(request.get_json(force=True)) - except TypeError as ex: - return abort(400, jsonify({"error": {"message": str(ex)}})) + except Exception as ex: + return {"error": {"message": str(ex)}}, 400 key = data.public_key domain = data.domain # in case we want to decide here later we want to publish it only to dedicated gateways gateway = "all" - logger.info(f"wg_key_exchange: Domain: {domain}, Key:{key}") + logger.info(f"wg_api_v1_key_exchange: Domain: {domain}, Key:{key}") mqtt.publish(f"wireguard/{domain}/{gateway}", key) - return jsonify({"Message": "OK"}), 200 + return {"Message": "OK"}, 200 + + +@app.route("/api/v2/wg/key/exchange", methods=["POST"]) +def wg_api_v2_key_exchange() -> Tuple[Response | Dict, int]: + """Retrieves a new key, validates it and responds with a worker/gateway the client should connect to. + + Returns: + Status message, Endpoint with address/domain, port pubic key and link address. + """ + try: + data = KeyExchange.from_dict(request.get_json(force=True)) + except Exception as ex: + return {"error": {"message": str(ex)}}, 400 + + key = data.public_key + domain = data.domain + # in case we want to decide here later we want to publish it only to dedicated gateways + gateway = "all" + logger.info(f"wg_api_v2_key_exchange: Domain: {domain}, Key:{key}") + + mqtt.publish(f"wireguard/{domain}/{gateway}", key) + + best_worker, diff, current_peers = worker_metrics.get_best_worker(domain) + if best_worker is None: + logger.warning(f"No worker online for domain {domain}") + return { + "error": { + "message": "no gateway online for this domain, please check the domain value and try again later" + } + }, 400 + + worker_metrics.update( + best_worker, domain, CONNECTED_PEERS_METRIC, current_peers + 1 + ) + logger.debug( + f"Chose worker {best_worker} with {current_peers} connected clients ({diff})" + ) + + w_data = worker_data.get((best_worker, domain), None) + if w_data is None: + logger.error(f"Couldn't get worker endpoint data for {best_worker}/{domain}") + return {"error": {"message": "could not get gateway data"}}, 500 + + endpoint = { + "Address": w_data.get("ExternalAddress"), + "Port": str(w_data.get("Port")), + "AllowedIPs": [w_data.get("LinkAddress")], + "PublicKey": w_data.get("PublicKey"), + } + + return {"Endpoint": endpoint}, 200 @mqtt.on_connect() @@ -109,7 +164,69 @@ def handle_mqtt_connect( app.config["MQTT_BROKER_URL"], app.config["MQTT_BROKER_PORT"] ) ) - # mqtt.subscribe("wireguard/#") + mqtt.subscribe("wireguard-metrics/#") + mqtt.subscribe(TOPIC_WORKER_STATUS.format(worker="+")) + mqtt.subscribe(TOPIC_WORKER_WG_DATA.format(worker="+", domain="+")) + + +@mqtt.on_topic("wireguard-metrics/#") +def handle_mqtt_message_metrics( + client: mqtt_client.Client, userdata: bytes, message: mqtt_client.MQTTMessage +) -> None: + """Processes published metrics from workers.""" + logger.debug( + f"MQTT message received on {message.topic}: {message.payload.decode()}" + ) + _, domain, worker, metric = message.topic.split("/", 3) + if not is_valid_domain(domain): + logger.error(f"Domain {domain} not in configured domains") + return + + if not worker or not metric: + logger.error("Ignored MQTT message with empty worker or metrics label") + return + + data = int(message.payload) + + logger.info(f"Update worker metrics: {metric} on {worker}/{domain} = {data}") + worker_metrics.update(worker, domain, metric, data) + + +@mqtt.on_topic(TOPIC_WORKER_STATUS.format(worker="+")) +def handle_mqtt_message_status( + client: mqtt_client.Client, userdata: bytes, message: mqtt_client.MQTTMessage +) -> None: + """Processes status messages from workers.""" + _, worker, _ = message.topic.split("/", 2) + + status = int(message.payload) + if status < 1: + logger.warning(f"Marking worker as offline: {worker}") + worker_metrics.set_offline(worker) + else: + logger.warning(f"Marking worker as online: {worker}") + worker_metrics.set_online(worker) + + +@mqtt.on_topic(TOPIC_WORKER_WG_DATA.format(worker="+", domain="+")) +def handle_mqtt_message_data( + client: mqtt_client.Client, userdata: bytes, message: mqtt_client.MQTTMessage +) -> None: + """Processes data messages from workers. + + Stores them in a local dict""" + _, worker, domain, _ = message.topic.split("/", 3) + if not is_valid_domain(domain): + logger.error(f"Domain {domain} not in configured domains.") + return + + data = json.loads(message.payload) + if not isinstance(data, dict): + logger.error("Invalid worker data received for %s/%s: %s", worker, domain, data) + return + + logger.info("Worker data received for %s/%s: %s", worker, domain, data) + worker_data[(worker, domain)] = data @mqtt.on_message() @@ -117,7 +234,6 @@ def handle_mqtt_message( client: mqtt_client.Client, userdata: bytes, message: mqtt_client.MQTTMessage ) -> None: """Prints message contents.""" - # TODO(ruairi): Clarify current usage of this function. logger.debug( f"MQTT message received on {message.topic}: {message.payload.decode()}" ) @@ -141,6 +257,19 @@ def is_valid_wg_pubkey(pubkey: str) -> str: return pubkey +def join_host_port(host: str, port: str) -> str: + """Concatenate a port string with a host string using a colon. + The host may be either a hostname, IPv4 or IPv6 address. + An IPv6 address as host will be automatically encapsulated in square brackets. + + Returns: + The joined host:port string + """ + if host.find(":") >= 0: + return "[" + host + "]:" + port + return host + ":" + port + + if __name__ == "__main__": listen_host = None listen_port = None diff --git a/wgkex/broker/metrics.py b/wgkex/broker/metrics.py new file mode 100644 index 0000000..9e5fdc4 --- /dev/null +++ b/wgkex/broker/metrics.py @@ -0,0 +1,122 @@ +import dataclasses +from operator import itemgetter +from typing import Any, Dict, Optional, Tuple + +from wgkex.config import config +from wgkex.common import logger +from wgkex.common.mqtt import CONNECTED_PEERS_METRIC + + +@dataclasses.dataclass +class WorkerMetrics: + """Metrics of a single worker""" + + worker: str + # domain -> [metric name -> metric data] + domain_data: Dict[str, Dict[str, Any]] = dataclasses.field(default_factory=dict) + online: bool = False + + def is_online(self, domain: str = "") -> bool: + if domain: + return ( + self.online + and self.get_domain_metrics(domain).get(CONNECTED_PEERS_METRIC, -1) >= 0 + ) + else: + return self.online + + def get_domain_metrics(self, domain: str) -> Dict[str, Any]: + return self.domain_data.get(domain, {}) + + def set_metric(self, domain: str, metric: str, value: Any) -> None: + if domain in self.domain_data: + self.domain_data[domain][metric] = value + else: + self.domain_data[domain] = {metric: value} + + +@dataclasses.dataclass +class WorkerMetricsCollection: + """A container for all worker metrics""" + + # worker -> WorkerMetrics + data: Dict[str, WorkerMetrics] = dataclasses.field(default_factory=dict) + + def get(self, worker: str) -> WorkerMetrics: + return self.data.get(worker, WorkerMetrics(worker=worker)) + + def set(self, worker: str, metrics: WorkerMetrics) -> None: + self.data[worker] = metrics + + def update(self, worker: str, domain: str, metric: str, value: Any) -> None: + if worker in self.data: + self.data[worker].set_metric(domain, metric, value) + else: + metrics = WorkerMetrics(worker) + metrics.set_metric(domain, metric, value) + self.data[worker] = metrics + + def set_online(self, worker: str) -> None: + if worker in self.data: + self.data[worker].online = True + else: + metrics = WorkerMetrics(worker) + metrics.online = True + self.data[worker] = metrics + + def set_offline(self, worker: str) -> None: + if worker in self.data: + self.data[worker].online = False + + def get_total_peers(self) -> int: + total = 0 + for worker in self.data: + worker_data = self.data.get(worker) + if not worker_data: + continue + for domain in worker_data.domain_data: + total += max( + worker_data.get_domain_metrics(domain).get( + CONNECTED_PEERS_METRIC, 0 + ), + 0, + ) + + return total + + def get_best_worker(self, domain: str) -> Tuple[Optional[str], int, int]: + """Analyzes the metrics and determines the best worker that a new client should connect to. + The best worker is defined as the one with the most number of clients missing + to its should-be target value according to its weight. + + Returns: + A 3-tuple containing the worker name, difference to target peers, number of connected peers. + The worker name can be None if none is online. + """ + # Map metrics to a list of (target diff, peer count, worker) tuples for online workers + + peers_worker_tuples = [] + total_peers = self.get_total_peers() + workerCfg = config.get_config().workers + + for wm in self.data.values(): + if not wm.online: + continue + peers = wm.get_domain_metrics(domain).get(CONNECTED_PEERS_METRIC, -1) + if peers < 0: + continue + + rel_weight = workerCfg.relative_worker_weight(wm.worker) + target = rel_weight * total_peers + diff = peers - target + logger.debug( + f"Worker {wm.worker}: rel weight {rel_weight}, target {target} (total {total_peers}), diff {diff}" + ) + peers_worker_tuples.append((diff, peers, wm.worker)) + + peers_worker_tuples = sorted(peers_worker_tuples, key=itemgetter(0)) + + if len(peers_worker_tuples) > 0: + best = peers_worker_tuples[0] + return best[2], best[0], best[1] + return None, 0, 0 diff --git a/wgkex/common/BUILD b/wgkex/common/BUILD index 7a79f93..b203348 100644 --- a/wgkex/common/BUILD +++ b/wgkex/common/BUILD @@ -26,3 +26,9 @@ py_library( srcs = ["logger.py"], visibility = ["//visibility:public"] ) + +py_library( + name = "mqtt", + srcs = ["mqtt.py"], + visibility = ["//visibility:public"] +) diff --git a/wgkex/common/mqtt.py b/wgkex/common/mqtt.py new file mode 100644 index 0000000..69bf15b --- /dev/null +++ b/wgkex/common/mqtt.py @@ -0,0 +1,6 @@ +"""Common MQTT constants like topic string templates""" + +TOPIC_WORKER_WG_DATA = "wireguard-worker/{worker}/{domain}/data" +TOPIC_WORKER_STATUS = "wireguard-worker/{worker}/status" +CONNECTED_PEERS_METRIC = "connected_peers" +TOPIC_CONNECTED_PEERS = "wireguard-metrics/{domain}/{worker}/" + CONNECTED_PEERS_METRIC diff --git a/wgkex/config/config.py b/wgkex/config/config.py index 29ba0a5..b0239e2 100644 --- a/wgkex/config/config.py +++ b/wgkex/config/config.py @@ -20,6 +20,55 @@ class ConfigFileNotFoundError(Error): WG_CONFIG_DEFAULT_LOCATION = "/etc/wgkex.yaml" +@dataclasses.dataclass +class Worker: + """A representation of the values of the 'workers' dict in the configuration file. + + Attributes: + weight: The relative weight of a worker, defaults to 1. + """ + + weight: int + + @classmethod + def from_dict(cls, worker_cfg: Dict[str, Any]) -> "Worker": + return cls( + weight=int(worker_cfg["weight"]) if worker_cfg["weight"] else 1, + ) + + +@dataclasses.dataclass +class Workers: + """A representation of the 'workers' key in the configuration file. + + Attributes: + total_weight: Calculated on init, the total weight of all configured workers. + """ + + total_weight: int + _workers: Dict[str, Worker] + + @classmethod + def from_dict(cls, workers_cfg: Dict[str, Dict[str, Any]]) -> "Workers": + d = {key: Worker.from_dict(value) for (key, value) in workers_cfg.items()} + + total = 0 + for worker in d.values(): + total += worker.weight + total = max(total, 1) + + return cls(total_weight=total, _workers=d) + + def get(self, worker: str) -> Optional[Worker]: + return self._workers.get(worker) + + def relative_worker_weight(self, worker_name: str) -> float: + worker = self.get(worker_name) + if worker is None: + return 1 / self.total_weight + return worker.weight / self.total_weight + + @dataclasses.dataclass class BrokerListen: """A representation of the 'broker_listen' key in Configuration file. @@ -88,6 +137,8 @@ class Config: domains: The list of domains to listen for. domain_prefixes: The list of prefixes to pre-pend to a given domain. mqtt: The MQTT configuration. + workers: The worker weights configuration (broker-only). + externalName: The publicly resolvable domain name or public IP address of this worker (worker-only). """ raw: Dict[str, Any] @@ -95,6 +146,8 @@ class Config: domain_prefixes: List[str] broker_listen: BrokerListen mqtt: MQTT + workers: Workers + external_name: Optional[str] @classmethod def from_dict(cls, cfg: Dict[str, Any]) -> "Config": @@ -106,12 +159,15 @@ def from_dict(cls, cfg: Dict[str, Any]) -> "Config": """ broker_listen = BrokerListen.from_dict(cfg.get("broker_listen", {})) mqtt_cfg = MQTT.from_dict(cfg["mqtt"]) + workers_cfg = Workers.from_dict(cfg.get("workers", {})) return cls( raw=cfg, domains=cfg["domains"], domain_prefixes=cfg["domain_prefixes"], broker_listen=broker_listen, mqtt=mqtt_cfg, + workers=workers_cfg, + external_name=cfg.get("externalName"), ) def get(self, key: str) -> Any: diff --git a/wgkex/worker/BUILD b/wgkex/worker/BUILD index 7f1c2c3..b1d9b6d 100644 --- a/wgkex/worker/BUILD +++ b/wgkex/worker/BUILD @@ -35,8 +35,9 @@ py_library( requirement("NetLink"), requirement("paho-mqtt"), requirement("pyroute2"), - "//wgkex/common:utils", "//wgkex/common:logger", + "//wgkex/common:mqtt", + "//wgkex/common:utils", "//wgkex/config:config", ":msg_queue", ":netlink", diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 7fd8b7f..9a07d97 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -1,5 +1,7 @@ """Initialises the MQTT worker.""" +import signal +import sys import threading import time from typing import Text @@ -67,7 +69,9 @@ def clean_up_worker() -> None: domain, ) continue - thread = threading.Thread(target=flush_workers, args=(cleaned_domain,)) + thread = threading.Thread( + target=flush_workers, args=(cleaned_domain,), daemon=True + ) thread.start() if cleanup_counter < len(domains): logger.error( @@ -111,6 +115,16 @@ def main(): DomainsNotInConfig: If no domains were found in configuration file. DomainsAreNotUnique: If there were non-unique domains after stripping prefix """ + exit_event = threading.Event() + + def on_exit(sig_number, stack_frame) -> None: + logger.info("Shutting down...") + exit_event.set() + time.sleep(2) + sys.exit() + + signal.signal(signal.SIGINT, on_exit) + domains = config.get_config().domains prefixes = config.get_config().domain_prefixes if not domains: @@ -122,7 +136,7 @@ def main(): raise InvalidDomain(f"Domain {domain} has invalid prefix.") clean_up_worker() watch_queue() - mqtt.connect() + mqtt.connect(exit_event) if __name__ == "__main__": diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index cfb2f37..0cf525f 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -2,7 +2,6 @@ import unittest import mock -import wgkex.config.config from wgkex.worker import app @@ -72,7 +71,7 @@ def test_main_success(self, connect_mock, config_mock): @mock.patch.object(app.config, "get_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_fails_no_domain(self, connect_mock, config_mock, config_fetch_mock): + def test_main_fails_no_domain(self, connect_mock, config_mock): """Ensure we fail when domains are not configured.""" config_mock.return_value = _get_config_mock(domains=[]) connect_mock.return_value = None @@ -81,7 +80,7 @@ def test_main_fails_no_domain(self, connect_mock, config_mock, config_fetch_mock @mock.patch.object(app.config, "get_config") @mock.patch.object(app.mqtt, "connect", autospec=True) - def test_main_fails_bad_domain(self, connect_mock, config_mock, config_fetch_mock): + def test_main_fails_bad_domain(self, connect_mock, config_mock): """Ensure we fail when domains are badly formatted.""" config_mock.return_value = _get_config_mock(domains=["cant_split_domain"]) connect_mock.return_value = None diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index c58be0d..ef45345 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -2,36 +2,105 @@ """Process messages from MQTT.""" # TODO(ruairi): Deprecate __init__.py from config, as it masks namespace. -import socket +import json import re -from typing import Any +import socket +import threading +from typing import Any, Optional import paho.mqtt.client as mqtt from wgkex.common import logger +from wgkex.common.mqtt import ( + TOPIC_CONNECTED_PEERS, + TOPIC_WORKER_STATUS, + TOPIC_WORKER_WG_DATA, +) from wgkex.config.config import get_config from wgkex.worker.msg_queue import q -from wgkex.worker.netlink import link_handler, WireGuardClient +from wgkex.worker.netlink import ( + get_device_data, + link_handler, + get_connected_peers_count, + WireGuardClient, +) + +_HOSTNAME = socket.gethostname() +_METRICS_SEND_INTERVAL = 60 + +def connect(exit_event: threading.Event) -> None: + """Connect to MQTT. -def connect() -> None: - """Connect to MQTT.""" + Argument: + exit_event: A threading.Event that signals application shutdown. + """ base_config = get_config().mqtt broker_address = base_config.broker_url broker_port = base_config.broker_port broker_keepalive = base_config.keepalive - # TODO(ruairi): Move the hostname to a global variable. - client = mqtt.Client(socket.gethostname()) + client = mqtt.Client(_HOSTNAME) + domains = get_config().domains + + # Register LWT to set worker status down when lossing connection + client.will_set(TOPIC_WORKER_STATUS.format(worker=_HOSTNAME), 0, qos=1, retain=True) # Register handlers client.on_connect = on_connect + client.on_disconnect = on_disconnect client.on_message = on_message + client.message_callback_add("wireguard/#", on_message_wireguard) logger.info("connecting to broker %s", broker_address) client.connect(broker_address, port=broker_port, keepalive=broker_keepalive) + + # Start background threads that should not be restarted on reconnect + + # Mark worker as offline on graceful shutdown, after exit_event is set + def mark_offline_on_exit(exit_event: threading.Event): + exit_event.wait() + if client.is_connected(): + logger.info("Marking worker as down") + client.publish( + TOPIC_WORKER_STATUS.format(worker=_HOSTNAME), 0, qos=1, retain=True + ) + + mark_offline_on_exit_thread = threading.Thread( + target=mark_offline_on_exit, args=(exit_event,) + ) + mark_offline_on_exit_thread.start() + + for domain in domains: + # Schedule repeated metrics publishing + metrics_thread = threading.Thread( + target=publish_metrics_loop, args=(exit_event, client, domain) + ) + metrics_thread.start() + client.loop_forever() +def on_disconnect(client: mqtt.Client, userdata: Any, rc): + """Handles MQTT disconnect and logs the event + + Expected signature for MQTT v3.1.1 and v3.1 is: + disconnect_callback(client, userdata, rc) + + and for MQTT v5.0: + disconnect_callback(client, userdata, reasonCode, properties) + + Arguments: + client: the client instance for this callback + userdata: the private user data as set in Client() or userdata_set() + rc: the disconnection result + The rc parameter indicates the disconnection state. If + MQTT_ERR_SUCCESS (0), the callback was called in response to + a disconnect() call. If any other value the disconnection + was unexpected, such as might be caused by a network error. + """ + logger.debug("Disconnected with result code " + str(rc)) + + # The callback for when the client receives a CONNACK response from the server. def on_connect(client: mqtt.Client, userdata: Any, flags, rc) -> None: """Handles MQTT connect and subscribes to topics on connect @@ -45,15 +114,59 @@ def on_connect(client: mqtt.Client, userdata: Any, flags, rc) -> None: logger.debug("Connected with result code " + str(rc)) domains = get_config().domains - # Subscribing in on_connect() means that if we lose the connection and - # reconnect then subscriptions will be renewed. + own_external_name = ( + get_config().external_name + if get_config().external_name is not None + else _HOSTNAME + ) + for domain in domains: + # Subscribing in on_connect() means that if we lose the connection and + # reconnect then subscriptions will be renewed. topic = f"wireguard/{domain}/+" logger.info(f"Subscribing to topic {topic}") client.subscribe(topic) + # Publish worker data (WG pubkeys, ports, local addresses) + iface = wg_interface_name(domain) + if iface: + (port, public_key, link_address) = get_device_data(iface) + data = { + "ExternalAddress": own_external_name, + "Port": port, + "PublicKey": public_key, + "LinkAddress": link_address, + } + client.publish( + TOPIC_WORKER_WG_DATA.format(worker=_HOSTNAME, domain=domain), + json.dumps(data), + qos=1, + retain=True, + ) + else: + logger.error( + f"Could not get interface name for domain {domain}. Skipping worker data publication" + ) + + # Mark worker as online + client.publish(TOPIC_WORKER_STATUS.format(worker=_HOSTNAME), 1, qos=1, retain=True) + def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> None: + """Fallback handler for MQTT messages that do not match any other registered handler. + + Arguments: + client: the client instance for this callback. + userdata: the private user data. + message: The MQTT message. + """ + logger.info("Got unhandled message on %s from MQTT", message.topic) + return + + +def on_message_wireguard( + client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage +) -> None: """Processes messages from MQTT and forwards them to netlink. Arguments: @@ -62,11 +175,12 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> message: The MQTT message. """ # TODO(ruairi): Check bounds and raise exception here. - logger.debug("Got message %s from MTQQ", message) + logger.debug("Got message on %s from MQTT", message.topic) + domain_prefixes = get_config().domain_prefixes domain = None for domain_prefix in domain_prefixes: - domain = re.search(r"/.*" + domain_prefix + "(\w+)/", message.topic) + domain = re.search(r".*/" + domain_prefix + r"(\w+)/", message.topic) if domain: break if not domain: @@ -80,3 +194,57 @@ def on_message(client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage) -> f"Received create message for key {str(message.payload.decode('utf-8'))} on domain {domain} adding to queue" ) q.put((domain, str(message.payload.decode("utf-8")))) + + +def publish_metrics_loop( + exit_event: threading.Event, client: mqtt.Client, domain: str +) -> None: + """Continuously send metrics every METRICS_SEND_INTERVAL seconds for this gateway and the given domain.""" + logger.info("Scheduling metrics task for %s, ", domain) + + topic = TOPIC_CONNECTED_PEERS.format(domain=domain, worker=_HOSTNAME) + + while not exit_event.is_set(): + publish_metrics(client, topic, domain) + # This drifts slightly over time, doesn't matter for us + exit_event.wait(_METRICS_SEND_INTERVAL) + + # Set peers metric to -1 to mark worker as offline + # Use QoS 1 (at least once) to make sure the broker notices + client.publish(topic, -1, qos=1, retain=True) + + +def publish_metrics(client: mqtt.Client, topic: str, domain: str) -> None: + """Publish metrics for this gateway and the given domain. + + The metrics currently only consist of the number of connected peers. + """ + logger.debug(f"Publishing metrics for domain {domain}") + iface = wg_interface_name(domain) + if not iface: + logger.error( + f"Could not get interface name for domain {domain}. Skipping metrics publication" + ) + return + peer_count = get_connected_peers_count(iface) + + # Publish metrics, retain it at MQTT broker so restarted wgkex broker has metrics right away + client.publish(topic, peer_count, retain=True) + + +def wg_interface_name(domain: str) -> Optional[str]: + """Calculates the WireGuard interface name for a domain""" + domain_prefixes = get_config().domain_prefixes + cleaned_domain = None + for prefix in domain_prefixes: + try: + cleaned_domain = domain.split(prefix[1]) + except IndexError: + continue + break + if not cleaned_domain: + raise ValueError( + f"Could not find a match for {domain_prefixes} on {domain}" + ) + # this will not work, if we have non-unique prefix stripped domains + return f"wg-{cleaned_domain}" diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index cd19aa0..803125a 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -1,4 +1,5 @@ """Unit tests for mqtt.py""" +import threading import unittest import mock @@ -29,7 +30,9 @@ def test_connect_success(self, config_mock, hostname_mock, mqtt_mock): config_mqtt_mock.broker_port = 1833 config_mqtt_mock.keepalive = False config_mock.return_value = _get_config_mock(mqtt=config_mqtt_mock) - mqtt.connect() + ee = threading.Event() + mqtt.connect(ee) + ee.set() mqtt_mock.assert_has_calls( [mock.call().connect("some_url", port=1833, keepalive=False)], any_order=True, @@ -44,19 +47,19 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): config_mqtt_mock.broker_url = "some_url" config_mock.return_value = _get_config_mock(mqtt=config_mqtt_mock) with self.assertRaises(ValueError): - mqtt.connect() + mqtt.connect(threading.Event()) """ @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "get_config") - def test_on_message_success(self, config_mock, link_mock): + def test_on_message_wireguard_success(self, config_mock, link_mock): # Tests on_message for success. config_mock.return_value = _get_config_mock() link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") mqtt_msg.topic = "wireguard/_ffmuc_domain1/gateway" mqtt_msg.payload = b"PUB_KEY" - mqtt.on_message(None, None, mqtt_msg) + mqtt.on_message_wireguard(None, None, mqtt_msg) link_mock.assert_has_calls( [ mock.call( @@ -70,7 +73,7 @@ def test_on_message_success(self, config_mock, link_mock): @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "get_config") - def test_on_message_fails_no_domain(self, config_mock, link_mock): + def test_on_message_wireguard_fails_no_domain(self, config_mock, link_mock): # Tests on_message for failure to parse domain. config_mqtt_mock = mock.MagicMock() config_mqtt_mock.broker_url = "mqtt://broker" @@ -86,7 +89,7 @@ def test_on_message_fails_no_domain(self, config_mock, link_mock): mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") mqtt_msg.topic = "wireguard/bad_domain_match" with self.assertRaises(ValueError): - mqtt.on_message(None, None, mqtt_msg) + mqtt.on_message_wireguard(None, None, mqtt_msg) """ if __name__ == "__main__": diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index d4f0656..a880d64 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -1,12 +1,15 @@ """Functions related to netlink manipulation for Wireguard, IPRoute and FDB on Linux.""" +# See https://docs.pyroute2.org/iproute.html for a documentation of the layout of netlink responses import hashlib import re from dataclasses import dataclass from datetime import datetime from datetime import timedelta from textwrap import wrap -from typing import Dict, List +from typing import Any, Dict, List, Tuple + import pyroute2 +import pyroute2.netlink from wgkex.common.utils import mac2eui64 from wgkex.common import logger @@ -191,18 +194,82 @@ def find_stale_wireguard_clients(wg_interface: str) -> List: "Starting search for stale wireguard peers for interface %s.", wg_interface ) with pyroute2.WireGuard() as wg: - all_clients = [] - peers_on_interface = wg.info(wg_interface) - logger.info("Got infos: %s.", peers_on_interface) - for peer in peers_on_interface: - clients = peer.get_attr("WGDEVICE_A_PEERS") - logger.info("Got clients: %s.", clients) - if clients: - all_clients.extend(clients) + all_peers = [] + msgs = wg.info(wg_interface) + logger.debug("Got infos for stale peers: %s.", msgs) + for msg in msgs: + peers = msg.get_attr("WGDEVICE_A_PEERS") + logger.debug("Got clients: %s.", peers) + if peers: + all_peers.extend(peers) ret = [ - client.get_attr("WGPEER_A_PUBLIC_KEY").decode("utf-8") - for client in all_clients - if client.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME").get("tv_sec", int()) + peer.get_attr("WGPEER_A_PUBLIC_KEY").decode("utf-8") + for peer in all_peers + if peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME").get("tv_sec", int()) < three_hrs_in_secs ] return ret + + +def get_connected_peers_count(wg_interface: str) -> int: + """Fetches and returns the number of connected peers, i.e. which had recent handshakes. + + Arguments: + wg_interface: The WireGuard interface to query. + + Returns: + # The number of peers which have recently seen a handshake. + """ + three_mins_ago_in_secs = int((datetime.now() - timedelta(minutes=3)).timestamp()) + logger.info("Counting connected wireguard peers for interface %s.", wg_interface) + with pyroute2.WireGuard() as wg: + msgs = wg.info(wg_interface) + logger.debug("Got infos for connected peers: %s.", msgs) + count = 0 + for msg in msgs: + peers = msg.get_attr("WGDEVICE_A_PEERS") + logger.debug("Got clients: %s.", peers) + if peers: + for peer in peers: + if ( + peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME").get( + "tv_sec", int() + ) + > three_mins_ago_in_secs + ): + count += 1 + + return count + + +def get_device_data(wg_interface: str) -> Tuple[Any, Any, Any]: + """Returns the listening port, public key and local IP address. + + Arguments: + wg_interface: The WireGuard interface to query. + + Returns: + # The listening port, public key, and local IP address of the WireGuard interface. + """ + logger.info("Reading data from interface %s.", wg_interface) + with pyroute2.WireGuard() as wg, pyroute2.NDB() as ndb: + msgs = wg.info(wg_interface) + logger.debug("Got infos for interface data: %s.", msgs) + if len(msgs) > 1: + logger.warning( + "Got multiple messages from netlink, expected one. Using only first one." + ) + info: pyroute2.netlink.nla = msgs[0] + + port = int(info.get_attr("WGDEVICE_A_LISTEN_PORT")) + public_key = info.get_attr("WGDEVICE_A_PUBLIC_KEY").decode("ascii") + link_address = ndb.interfaces[wg_interface].ipaddr[0].get("address") + + logger.debug( + "Interface data: port '%s', public key '%s', link address '%s", + port, + public_key, + link_address, + ) + + return (port, public_key, link_address) diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index c209731..68cd573 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -11,6 +11,8 @@ sys.modules["pyroute2"] = mock.MagicMock() sys.modules["pyroute2.WireGuard"] = mock.MagicMock() sys.modules["pyroute2.IPRoute"] = mock.MagicMock() +sys.modules["pyroute2.NDB"] = mock.MagicMock() +sys.modules["pyroute2.netlink"] = mock.MagicMock() from pyroute2 import WireGuard from pyroute2 import IPRoute From 21a125bca15b7ab3133751fa3764877b4eeb6642 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 089/107] Add more tests for broker/metrics.py --- wgkex/broker/BUILD | 9 +++ wgkex/broker/metrics.py | 10 ++- wgkex/broker/metrics_test.py | 125 +++++++++++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 wgkex/broker/metrics_test.py diff --git a/wgkex/broker/BUILD b/wgkex/broker/BUILD index 414da32..780f340 100644 --- a/wgkex/broker/BUILD +++ b/wgkex/broker/BUILD @@ -12,6 +12,15 @@ py_library( ], ) +py_test( + name="metrics_test", + srcs=["metrics_test.py"], + deps = [ + "//wgkex/broker:metrics", + requirement("mock"), + ], +) + py_binary( name="app", srcs=["app.py"], diff --git a/wgkex/broker/metrics.py b/wgkex/broker/metrics.py index 9e5fdc4..a2e2893 100644 --- a/wgkex/broker/metrics.py +++ b/wgkex/broker/metrics.py @@ -97,16 +97,14 @@ def get_best_worker(self, domain: str) -> Tuple[Optional[str], int, int]: peers_worker_tuples = [] total_peers = self.get_total_peers() - workerCfg = config.get_config().workers + worker_cfg = config.get_config().workers for wm in self.data.values(): - if not wm.online: - continue - peers = wm.get_domain_metrics(domain).get(CONNECTED_PEERS_METRIC, -1) - if peers < 0: + if not wm.is_online(domain): continue - rel_weight = workerCfg.relative_worker_weight(wm.worker) + peers = wm.get_domain_metrics(domain).get(CONNECTED_PEERS_METRIC) + rel_weight = worker_cfg.relative_worker_weight(wm.worker) target = rel_weight * total_peers diff = peers - target logger.debug( diff --git a/wgkex/broker/metrics_test.py b/wgkex/broker/metrics_test.py new file mode 100644 index 0000000..63b25bf --- /dev/null +++ b/wgkex/broker/metrics_test.py @@ -0,0 +1,125 @@ +import unittest + +import mock +from wgkex.config import config +from wgkex.broker.metrics import WorkerMetricsCollection + + +class TestMetrics(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + # Give each test a placeholder config + test_config = config.Config.from_dict( + { + "domains": [], + "domain_prefix": "", + "workers": {}, + "mqtt": {"broker_url": "", "username": "", "password": ""}, + } + ) + mocked_config = mock.create_autospec(spec=test_config, spec_set=True) + config._parsed_config = mocked_config + + @classmethod + def tearDownClass(cls) -> None: + config._parsed_config = None + + def test_set_online_matches_is_online(self): + """Verify set_online sets worker online and matches result of is_online.""" + worker_metrics = WorkerMetricsCollection() + worker_metrics.set_online("worker1") + + ret = worker_metrics.get("worker1").is_online() + self.assertTrue(ret) + + def test_set_offline_matches_is_online(self): + """Verify set_offline sets worker offline and matches negated result of is_online.""" + worker_metrics = WorkerMetricsCollection() + worker_metrics.set_offline("worker1") + + ret = worker_metrics.get("worker1").is_online() + self.assertFalse(ret) + + def test_unkown_is_offline(self): + """Verify an unkown worker is considered offline.""" + worker_metrics = WorkerMetricsCollection() + + ret = worker_metrics.get("worker1").is_online() + self.assertFalse(ret) + + def test_set_online_matches_is_online_domain(self): + """Verify set_online sets worker online and matches result of is_online with domain.""" + worker_metrics = WorkerMetricsCollection() + worker_metrics.set_online("worker1") + worker_metrics.update("worker1", "d", "connected_peers", 5) + + ret = worker_metrics.get("worker1").is_online("d") + self.assertTrue(ret) + + def test_set_online_matches_is_online_offline_domain(self): + """Verify worker is considered offline if connected_peers for domain is <0.""" + worker_metrics = WorkerMetricsCollection() + worker_metrics.set_online("worker1") + worker_metrics.update("worker1", "d", "connected_peers", -1) + + ret = worker_metrics.get("worker1").is_online("d") + self.assertFalse(ret) + + @mock.patch("wgkex.broker.metrics.config.get_config", autospec=True) + def test_get_best_worker_returns_best(self, config_mock): + """Verify get_best_worker returns the worker with least connected clients for equally weighted workers.""" + test_config = mock.MagicMock(spec=config.Config) + test_config.workers = config.Workers.from_dict({}) + config_mock.return_value = test_config + + worker_metrics = WorkerMetricsCollection() + worker_metrics.update("1", "d", "connected_peers", 20) + worker_metrics.update("2", "d", "connected_peers", 19) + worker_metrics.set_online("1") + worker_metrics.set_online("2") + + (worker, diff, connected) = worker_metrics.get_best_worker("d") + self.assertEqual(worker, "2") + self.assertEqual(diff, -20) # 19-(1*(20+19)) + self.assertEqual(connected, 19) + + @mock.patch("wgkex.broker.metrics.config.get_config", autospec=True) + def test_get_best_worker_weighted_returns_best(self, config_mock): + """Verify get_best_worker returns the worker with least client differential for weighted workers.""" + test_config = mock.MagicMock(spec=config.Config) + test_config.workers = config.Workers.from_dict( + {"1": {"weight": 84}, "2": {"weight": 42}} + ) + config_mock.return_value = test_config + + worker_metrics = WorkerMetricsCollection() + worker_metrics.update("1", "d", "connected_peers", 21) + worker_metrics.update("2", "d", "connected_peers", 19) + worker_metrics.set_online("1") + worker_metrics.set_online("2") + + (worker, _, _) = worker_metrics.get_best_worker("d") + config_mock.assert_called() + self.assertEqual(worker, "1") + + def test_get_best_worker_no_worker_online_returns_none(self): + """Verify get_best_worker returns None if there is no online worker.""" + worker_metrics = WorkerMetricsCollection() + worker_metrics.update("1", "d", "connected_peers", 20) + worker_metrics.update("2", "d", "connected_peers", 19) + worker_metrics.set_offline("1") + worker_metrics.set_offline("2") + + (worker, _, _) = worker_metrics.get_best_worker("d") + self.assertIsNone(worker) + + def test_get_best_worker_no_worker_registered_returns_none(self): + """Verify get_best_worker returns None if there is no online worker.""" + worker_metrics = WorkerMetricsCollection() + + (worker, _, _) = worker_metrics.get_best_worker("d") + self.assertIsNone(worker) + + +if __name__ == "__main__": + unittest.main() From 50a7ca686472a1f638f07900c761815ce0552139 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 090/107] Add more tests for worker/netlink.py --- wgkex/worker/netlink.py | 2 +- wgkex/worker/netlink_test.py | 67 +++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index a880d64..a1b5411 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -242,7 +242,7 @@ def get_connected_peers_count(wg_interface: str) -> int: return count -def get_device_data(wg_interface: str) -> Tuple[Any, Any, Any]: +def get_device_data(wg_interface: str) -> Tuple[int, str, str]: """Returns the listening port, public key and local IP address. Arguments: diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index 68cd573..0874005 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -26,15 +26,31 @@ ) -def _get_wg_mock(key_name, stale_time): - pm = mock.Mock() - pm.get_attr.side_effect = [{"tv_sec": stale_time}, key_name.encode()] +def _get_peer_mock(public_key, last_handshake_time): + def peer_get_attr(attr: str): + if attr == "WGPEER_A_LAST_HANDSHAKE_TIME": + return {"tv_sec": last_handshake_time} + if attr == "WGPEER_A_PUBLIC_KEY": + return public_key.encode() + peer_mock = mock.Mock() - peer_mock.get_attr.side_effect = [[pm]] + peer_mock.get_attr.side_effect = peer_get_attr + return peer_mock + + +def _get_wg_mock(public_key, last_handshake_time): + peer_mock = _get_peer_mock(public_key, last_handshake_time) + + def msg_get_attr(attr: str): + if attr == "WGDEVICE_A_PEERS": + return [peer_mock] + + msg_mock = mock.Mock() + msg_mock.get_attr.side_effect = msg_get_attr wg_instance = WireGuard() wg_info_mock = wg_instance.__enter__.return_value wg_info_mock.set.return_value = {"WireGuard": "set"} - wg_info_mock.info.return_value = [peer_mock] + wg_info_mock.info.return_value = [msg_mock] return wg_info_mock @@ -188,6 +204,47 @@ def test_wg_flush_stale_peers_stale_success(self): "del", dst="fe80::281:16ff:fe49:395e/128", oif=mock.ANY ) + def test_get_connected_peers_count_success(self): + """Tests getting the correct number of connected peers for an interface.""" + peers = [] + for i in range(10): + peer_mock = _get_peer_mock( + "TEST_KEY", + int((datetime.now() - timedelta(minutes=i, seconds=5)).timestamp()), + ) + peers.append(peer_mock) + + def msg_get_attr(attr: str): + if attr == "WGDEVICE_A_PEERS": + return peers + + msg_mock = mock.Mock() + msg_mock.get_attr.side_effect = msg_get_attr + + wg_instance = WireGuard() + wg_info_mock = wg_instance.__enter__.return_value + wg_info_mock.info.return_value = [msg_mock] + + ret = netlink.get_connected_peers_count("wg-welt") + self.assertEqual(ret, 3) + + def test_get_device_data_success(self): + def msg_get_attr(attr: str): + if attr == "WGDEVICE_A_LISTEN_PORT": + return 51820 + if attr == "WGDEVICE_A_PUBLIC_KEY": + return "TEST_PUBLIC_KEY".encode("ascii") + + msg_mock = mock.Mock() + msg_mock.get_attr.side_effect = msg_get_attr + + wg_instance = WireGuard() + wg_info_mock = wg_instance.__enter__.return_value + wg_info_mock.info.return_value = [msg_mock] + + ret = netlink.get_device_data("wg-welt") + self.assertTupleEqual(ret, (51820, "TEST_PUBLIC_KEY", mock.ANY)) + if __name__ == "__main__": unittest.main() From be4206325364c907d276676230b3f5cdc58d8bac Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 091/107] Add more tests for worker/mqtt.py --- wgkex/worker/mqtt_test.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 803125a..1c6bf81 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -1,8 +1,13 @@ """Unit tests for mqtt.py""" +import socket import threading import unittest +from time import sleep + import mock +import paho.mqtt.client +from wgkex.common.mqtt import TOPIC_CONNECTED_PEERS from wgkex.worker import mqtt @@ -50,6 +55,44 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): mqtt.connect(threading.Event()) + @mock.patch.object(mqtt, "get_config") + @mock.patch.object(mqtt, "get_connected_peers_count") + def test_publish_metrics_loop_success(self, conn_peers_mock, config_mock): + config_mock.return_value = _get_config_mock() + conn_peers_mock.return_value = 20 + mqtt_client = mock.MagicMock(spec=paho.mqtt.client.Client) + + ee = threading.Event() + thread = threading.Thread( + target=mqtt.publish_metrics_loop, + args=(ee, mqtt_client, "_ffmuc_domain.one"), + ) + thread.start() + + i = 0 + while i < 20 and not mqtt_client.publish.called: + i += 1 + sleep(0.1) + + conn_peers_mock.assert_called_with("wg-domain.one") + mqtt_client.publish.assert_called_with( + TOPIC_CONNECTED_PEERS.format( + domain="_ffmuc_domain.one", worker=socket.gethostname() + ), + 20, + retain=True, + ) + + ee.set() + + i = 0 + while i < 20 and thread.is_alive(): + i += 1 + sleep(0.1) + + self.assertFalse(thread.is_alive()) + + """ @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "get_config") def test_on_message_wireguard_success(self, config_mock, link_mock): @@ -92,5 +135,6 @@ def test_on_message_wireguard_fails_no_domain(self, config_mock, link_mock): mqtt.on_message_wireguard(None, None, mqtt_msg) """ + if __name__ == "__main__": unittest.main() From 7aa996797204ad7933f1e0fb3e1b00d6367b71f7 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sun, 17 Dec 2023 21:27:15 +0000 Subject: [PATCH 092/107] Fix logical merge conflicts --- wgkex/broker/metrics_test.py | 2 +- wgkex/common/utils.py | 9 ++++++--- wgkex/worker/mqtt.py | 6 ++---- wgkex/worker/mqtt_test.py | 7 +++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/wgkex/broker/metrics_test.py b/wgkex/broker/metrics_test.py index 63b25bf..520e6a9 100644 --- a/wgkex/broker/metrics_test.py +++ b/wgkex/broker/metrics_test.py @@ -12,7 +12,7 @@ def setUpClass(cls) -> None: test_config = config.Config.from_dict( { "domains": [], - "domain_prefix": "", + "domain_prefixes": "", "workers": {}, "mqtt": {"broker_url": "", "username": "", "password": ""}, } diff --git a/wgkex/common/utils.py b/wgkex/common/utils.py index 45c7b7b..8fa201c 100644 --- a/wgkex/common/utils.py +++ b/wgkex/common/utils.py @@ -48,6 +48,9 @@ def is_valid_domain(domain: str) -> bool: Returns: True if the domain is valid, False otherwise. """ - return domain in config.get_config().domains and domain.startswith( - config.get_config().domain_prefix - ) + if not domain in config.get_config().domains: + return False + for prefix in config.get_config().domain_prefixes: + if domain.startswith(prefix): + return True + return False diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index ef45345..caf7011 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -238,13 +238,11 @@ def wg_interface_name(domain: str) -> Optional[str]: cleaned_domain = None for prefix in domain_prefixes: try: - cleaned_domain = domain.split(prefix[1]) + cleaned_domain = domain.split(prefix)[1] except IndexError: continue break if not cleaned_domain: - raise ValueError( - f"Could not find a match for {domain_prefixes} on {domain}" - ) + raise ValueError(f"Could not find a match for {domain_prefixes} on {domain}") # this will not work, if we have non-unique prefix stripped domains return f"wg-{cleaned_domain}" diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 1c6bf81..b17d1d6 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -12,12 +12,12 @@ def _get_config_mock(domains=None, mqtt=None): - test_prefix = "_ffmuc_" + test_prefixes = ["_ffmuc_", "_TEST_PREFIX2_"] config_mock = mock.MagicMock() config_mock.domains = ( - domains if domains is not None else [f"{test_prefix}domain.one"] + domains if domains is not None else [f"{test_prefixes[0]}domain.one"] ) - config_mock.domain_prefix = test_prefix + config_mock.domain_prefixes = test_prefixes if mqtt: config_mock.mqtt = mqtt return config_mock @@ -54,7 +54,6 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): with self.assertRaises(ValueError): mqtt.connect(threading.Event()) - @mock.patch.object(mqtt, "get_config") @mock.patch.object(mqtt, "get_connected_peers_count") def test_publish_metrics_loop_success(self, conn_peers_mock, config_mock): From bab86f7c11ed68a18848cecd1f00297f052a0af8 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Sat, 6 Jan 2024 19:02:54 +0000 Subject: [PATCH 093/107] Make worker cleanup threads more robust, handle peers without handshake time --- README.md | 8 ++++---- wgkex/worker/app.py | 14 +++++++++----- wgkex/worker/app_test.py | 30 +++++++++++++++++++++++------- wgkex/worker/mqtt_test.py | 21 ++++++--------------- wgkex/worker/netlink.py | 9 ++++----- 5 files changed, 46 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 9f673f8..911aeb8 100644 --- a/README.md +++ b/README.md @@ -218,10 +218,10 @@ sudo ip link set vx-welt up ### MQTT topics -Publishing keys broker->worker: `wireguard/{domain}/{worker}` -Publishing metrics worker->broker: `wireguard-metrics/{domain}/{worker}/connected_peers` -Publishing worker status: `wireguard-worker/{worker}/status` -Publishing worker data: `wireguard-worker/{worker}/{domain}/data` +- Publishing keys broker->worker: `wireguard/{domain}/{worker}` +- Publishing metrics worker->broker: `wireguard-metrics/{domain}/{worker}/connected_peers` +- Publishing worker status: `wireguard-worker/{worker}/status` +- Publishing worker data: `wireguard-worker/{worker}/{domain}/data` ## Contact diff --git a/wgkex/worker/app.py b/wgkex/worker/app.py index 9a07d97..432955c 100644 --- a/wgkex/worker/app.py +++ b/wgkex/worker/app.py @@ -39,9 +39,14 @@ class InvalidDomain(Error): def flush_workers(domain: Text) -> None: """Calls peer flush every _CLEANUP_TIME interval.""" while True: - time.sleep(_CLEANUP_TIME) - logger.info(f"Running cleanup task for {domain}") - logger.info("Cleaned up domains: %s", wg_flush_stale_peers(domain)) + try: + time.sleep(_CLEANUP_TIME) + logger.info(f"Running cleanup task for {domain}") + logger.info("Cleaned up domains: %s", wg_flush_stale_peers(domain)) + except Exception as e: + # Don't crash the thread when an exception is encountered + logger.error(f"Exception during cleanup task for {domain}:") + logger.error(e) def clean_up_worker() -> None: @@ -100,8 +105,7 @@ def check_all_domains_unique(domains, prefixes): stripped_domain = domain.split(prefix)[1] if stripped_domain in unique_domains: logger.error( - "We have a non-unique domain here", - domain, + f"Domain {domain} is not unique after stripping the prefix" ) return False unique_domains.append(stripped_domain) diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index 0cf525f..04cc6fb 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -1,4 +1,6 @@ """Unit tests for app.py""" +import threading +from time import sleep import unittest import mock @@ -88,14 +90,28 @@ def test_main_fails_bad_domain(self, connect_mock, config_mock): app.main() connect_mock.assert_not_called() - @mock.patch("time.sleep", side_effect=InterruptedError) + @mock.patch.object(app, "_CLEANUP_TIME", 0) @mock.patch.object(app, "wg_flush_stale_peers") - def test_flush_workers(self, flush_mock, sleep_mock): - """Ensure we fail when domains are badly formatted.""" - flush_mock.return_value = "" - # Infinite loop in flush_workers has no exit value, so test will generate one, and test for that. - with self.assertRaises(InterruptedError): - app.flush_workers("test_domain") + def test_flush_workers_doesnt_throw(self, wg_flush_mock): + """Ensure the flush_workers thread doesn't throw and exit if it encounters an exception.""" + wg_flush_mock.side_effect = AttributeError( + "'NoneType' object has no attribute 'get'" + ) + + thread = threading.Thread( + target=app.flush_workers, args=("dummy_domain",), daemon=True + ) + thread.start() + + i = 0 + while i < 20 and not wg_flush_mock.called: + i += 1 + sleep(0.1) + + wg_flush_mock.assert_called() + # Assert that the thread hasn't crashed and is still running + self.assertTrue(thread.is_alive()) + # If Python would allow it without writing custom signalling, this would be the place to stop the thread again if __name__ == "__main__": diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index b17d1d6..8bd6672 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -91,29 +91,20 @@ def test_publish_metrics_loop_success(self, conn_peers_mock, config_mock): self.assertFalse(thread.is_alive()) - -""" @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "get_config") - def test_on_message_wireguard_success(self, config_mock, link_mock): + def test_on_message_wireguard_success(self, config_mock): # Tests on_message for success. config_mock.return_value = _get_config_mock() - link_mock.return_value = dict(WireGuard="result") mqtt_msg = mock.patch.object(mqtt.mqtt, "MQTTMessage") mqtt_msg.topic = "wireguard/_ffmuc_domain1/gateway" mqtt_msg.payload = b"PUB_KEY" mqtt.on_message_wireguard(None, None, mqtt_msg) - link_mock.assert_has_calls( - [ - mock.call( - msg_queue.WireGuardClient( - public_key="PUB_KEY", domain="domain1", remove=False - ) - ) - ], - any_order=True, - ) + self.assertTrue(mqtt.q.qsize() > 0) + item = mqtt.q.get_nowait() + self.assertEqual(item, ("domain1", "PUB_KEY")) + - @mock.patch.object(msg_queue, "link_handler") +""" @mock.patch.object(msg_queue, "link_handler") @mock.patch.object(mqtt, "get_config") def test_on_message_wireguard_fails_no_domain(self, config_mock, link_mock): # Tests on_message for failure to parse domain. diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index a1b5411..366d430 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -72,13 +72,12 @@ def wg_flush_stale_peers(domain: str) -> List[Dict]: stale_clients = [ stale_client for stale_client in find_stale_wireguard_clients("wg-" + domain) ] - logger.debug("Found stale clients: %s", stale_clients) - logger.info("Searching for stale WireGuard clients.") + logger.debug("Found %s stale clients: %s", len(stale_clients), stale_clients) stale_wireguard_clients = [ WireGuardClient(public_key=stale_client, domain=domain, remove=True) for stale_client in stale_clients ] - logger.debug("Found stable WireGuard clients: %s", stale_wireguard_clients) + logger.debug("Found stale WireGuard clients: %s", stale_wireguard_clients) logger.info("Processing clients.") link_handled = [ link_handler(stale_client) for stale_client in stale_wireguard_clients @@ -205,8 +204,8 @@ def find_stale_wireguard_clients(wg_interface: str) -> List: ret = [ peer.get_attr("WGPEER_A_PUBLIC_KEY").decode("utf-8") for peer in all_peers - if peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME").get("tv_sec", int()) - < three_hrs_in_secs + if (hshk_time := peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME")) is not None + and hshk_time.get("tv_sec", int()) < three_hrs_in_secs ] return ret From 908efa43fb2650aa601e127b04b9b0af197783e7 Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Tue, 9 Jan 2024 22:17:08 +0000 Subject: [PATCH 094/107] Some fixes for the loadbalancing changes - [worker] Use pyroute2.IPRoute instead of .NDB to get wg link address, as NDB() takes 20 seconds to instantiate - [worker] Fix get_connected_peers_count() for peers without handshake time - [broker] Use peer count sum from all domains to correctly calculate diff to expected peers in get_best_worker() - [broker] Don't update worker status on MQTT messages if it hasn't actually changed --- wgkex.yaml.example | 2 +- wgkex/broker/app.py | 13 ++++++++++--- wgkex/broker/metrics.py | 25 ++++++++++++++++++++----- wgkex/broker/metrics_test.py | 20 ++++++++++++++++++++ wgkex/worker/app_test.py | 2 +- wgkex/worker/mqtt.py | 1 + wgkex/worker/mqtt_test.py | 36 ++++++++++++++++++++++++++++++++++++ wgkex/worker/netlink.py | 17 ++++++++++------- 8 files changed, 99 insertions(+), 17 deletions(-) diff --git a/wgkex.yaml.example b/wgkex.yaml.example index 30340fe..afef1e6 100644 --- a/wgkex.yaml.example +++ b/wgkex.yaml.example @@ -1,4 +1,4 @@ -# [broker] The domains that should be accepted by clients and for which matching WireGuard interfaces exist +# [broker, worker] The domains that should be accepted by clients and for which matching WireGuard interfaces exist domains: - ffmuc_muc_cty - ffmuc_muc_nord diff --git a/wgkex/broker/app.py b/wgkex/broker/app.py index 1d753ff..e8122cc 100644 --- a/wgkex/broker/app.py +++ b/wgkex/broker/app.py @@ -131,8 +131,15 @@ def wg_api_v2_key_exchange() -> Tuple[Response | Dict, int]: } }, 400 + # Update number of peers locally to interpolate data between MQTT updates from the worker + # TODO fix data race + current_peers_domain = ( + worker_metrics.get(best_worker) + .get_domain_metrics(domain) + .get(CONNECTED_PEERS_METRIC, 0) + ) worker_metrics.update( - best_worker, domain, CONNECTED_PEERS_METRIC, current_peers + 1 + best_worker, domain, CONNECTED_PEERS_METRIC, current_peers_domain + 1 ) logger.debug( f"Chose worker {best_worker} with {current_peers} connected clients ({diff})" @@ -200,10 +207,10 @@ def handle_mqtt_message_status( _, worker, _ = message.topic.split("/", 2) status = int(message.payload) - if status < 1: + if status < 1 and worker_metrics.get(worker).is_online(): logger.warning(f"Marking worker as offline: {worker}") worker_metrics.set_offline(worker) - else: + elif status >= 1 and not worker_metrics.get(worker).is_online(): logger.warning(f"Marking worker as online: {worker}") worker_metrics.set_online(worker) diff --git a/wgkex/broker/metrics.py b/wgkex/broker/metrics.py index a2e2893..73a27c0 100644 --- a/wgkex/broker/metrics.py +++ b/wgkex/broker/metrics.py @@ -34,10 +34,23 @@ def set_metric(self, domain: str, metric: str, value: Any) -> None: else: self.domain_data[domain] = {metric: value} + def get_peer_count(self) -> int: + """Returns the sum of connected peers on this worker over all domains""" + total = 0 + for data in self.domain_data.values(): + total += max( + data.get(CONNECTED_PEERS_METRIC, 0), + 0, + ) + + return total + @dataclasses.dataclass class WorkerMetricsCollection: - """A container for all worker metrics""" + """A container for all worker metrics + # TODO make threadsafe / fix data races + """ # worker -> WorkerMetrics data: Dict[str, WorkerMetrics] = dataclasses.field(default_factory=dict) @@ -68,7 +81,8 @@ def set_offline(self, worker: str) -> None: if worker in self.data: self.data[worker].online = False - def get_total_peers(self) -> int: + def get_total_peer_count(self) -> int: + """Returns the sum of connected peers over all workers and domains""" total = 0 for worker in self.data: worker_data = self.data.get(worker) @@ -96,22 +110,23 @@ def get_best_worker(self, domain: str) -> Tuple[Optional[str], int, int]: # Map metrics to a list of (target diff, peer count, worker) tuples for online workers peers_worker_tuples = [] - total_peers = self.get_total_peers() + total_peers = self.get_total_peer_count() worker_cfg = config.get_config().workers for wm in self.data.values(): if not wm.is_online(domain): continue - peers = wm.get_domain_metrics(domain).get(CONNECTED_PEERS_METRIC) + peers = wm.get_peer_count() rel_weight = worker_cfg.relative_worker_weight(wm.worker) target = rel_weight * total_peers diff = peers - target logger.debug( - f"Worker {wm.worker}: rel weight {rel_weight}, target {target} (total {total_peers}), diff {diff}" + f"Worker candidate {wm.worker}: current {peers}, target {target} (total {total_peers}, rel weight {rel_weight}), diff {diff}" ) peers_worker_tuples.append((diff, peers, wm.worker)) + # Sort by diff (ascending), workers with most peers missing to target are sorted first peers_worker_tuples = sorted(peers_worker_tuples, key=itemgetter(0)) if len(peers_worker_tuples) > 0: diff --git a/wgkex/broker/metrics_test.py b/wgkex/broker/metrics_test.py index 520e6a9..97fc138 100644 --- a/wgkex/broker/metrics_test.py +++ b/wgkex/broker/metrics_test.py @@ -83,6 +83,26 @@ def test_get_best_worker_returns_best(self, config_mock): self.assertEqual(diff, -20) # 19-(1*(20+19)) self.assertEqual(connected, 19) + @mock.patch("wgkex.broker.metrics.config.get_config", autospec=True) + def test_get_best_worker_returns_best_imbalanced_domains(self, config_mock): + """Verify get_best_worker returns the worker with overall least connected clients even if it has more clients on this domain.""" + test_config = mock.MagicMock(spec=config.Config) + test_config.workers = config.Workers.from_dict({}) + config_mock.return_value = test_config + + worker_metrics = WorkerMetricsCollection() + worker_metrics.update("1", "domain1", "connected_peers", 25) + worker_metrics.update("1", "domain2", "connected_peers", 5) + worker_metrics.update("2", "domain1", "connected_peers", 20) + worker_metrics.update("2", "domain2", "connected_peers", 20) + worker_metrics.set_online("1") + worker_metrics.set_online("2") + + (worker, diff, connected) = worker_metrics.get_best_worker("domain1") + self.assertEqual(worker, "1") + self.assertEqual(diff, -40) # 30-(1*(25+5+20+20)) + self.assertEqual(connected, 30) + @mock.patch("wgkex.broker.metrics.config.get_config", autospec=True) def test_get_best_worker_weighted_returns_best(self, config_mock): """Verify get_best_worker returns the worker with least client differential for weighted workers.""" diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index 04cc6fb..efe774d 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -90,7 +90,7 @@ def test_main_fails_bad_domain(self, connect_mock, config_mock): app.main() connect_mock.assert_not_called() - @mock.patch.object(app, "_CLEANUP_TIME", 0) + @mock.patch.object(app, "_CLEANUP_TIME", 1) @mock.patch.object(app, "wg_flush_stale_peers") def test_flush_workers_doesnt_throw(self, wg_flush_mock): """Ensure the flush_workers thread doesn't throw and exit if it encounters an exception.""" diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index caf7011..d5941cd 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -127,6 +127,7 @@ def on_connect(client: mqtt.Client, userdata: Any, flags, rc) -> None: logger.info(f"Subscribing to topic {topic}") client.subscribe(topic) + for domain in domains: # Publish worker data (WG pubkeys, ports, local addresses) iface = wg_interface_name(domain) if iface: diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 8bd6672..3ea186e 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -54,6 +54,42 @@ def test_connect_fails_mqtt_error(self, config_mock, mqtt_mock): with self.assertRaises(ValueError): mqtt.connect(threading.Event()) + @mock.patch.object(mqtt.mqtt, "Client") + @mock.patch.object(mqtt, "get_config") + @mock.patch.object(mqtt, "get_device_data") + def test_on_connect_subscribes( + self, get_device_data_mock, config_mock, mqtt_client_mock + ): + """Test that the on_connect callback correctly subscribes to all domains and pushes device data""" + config_mqtt_mock = mock.MagicMock() + config_mqtt_mock.broker_url = "some_url" + config_mqtt_mock.broker_port = 1833 + config_mqtt_mock.keepalive = False + config = _get_config_mock(mqtt=config_mqtt_mock) + config.external_name = None + config_mock.return_value = config + get_device_data_mock.return_value = (51820, "456asdf=", "fe80::1") + + hostname = socket.gethostname() + + mqtt.on_connect(mqtt.mqtt.Client(), None, None, 0) + + mqtt_client_mock.assert_has_calls( + [ + mock.call().subscribe("wireguard/_ffmuc_domain.one/+"), + mock.call().publish( + f"wireguard-worker/{hostname}/_ffmuc_domain.one/data", + '{"ExternalAddress": "%s", "Port": 51820, "PublicKey": "456asdf=", "LinkAddress": "fe80::1"}' + % hostname, + qos=1, + retain=True, + ), + mock.call().publish( + f"wireguard-worker/{hostname}/status", 1, qos=1, retain=True + ), + ] + ) + @mock.patch.object(mqtt, "get_config") @mock.patch.object(mqtt, "get_connected_peers_count") def test_publish_metrics_loop_success(self, conn_peers_mock, config_mock): diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index 366d430..bb413f1 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -231,11 +231,10 @@ def get_connected_peers_count(wg_interface: str) -> int: if peers: for peer in peers: if ( - peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME").get( - "tv_sec", int() - ) - > three_mins_ago_in_secs - ): + hshk_time := peer.get_attr("WGPEER_A_LAST_HANDSHAKE_TIME") + ) is not None and hshk_time.get( + "tv_sec", int() + ) > three_mins_ago_in_secs: count += 1 return count @@ -251,7 +250,7 @@ def get_device_data(wg_interface: str) -> Tuple[int, str, str]: # The listening port, public key, and local IP address of the WireGuard interface. """ logger.info("Reading data from interface %s.", wg_interface) - with pyroute2.WireGuard() as wg, pyroute2.NDB() as ndb: + with pyroute2.WireGuard() as wg, pyroute2.IPRoute() as ipr: msgs = wg.info(wg_interface) logger.debug("Got infos for interface data: %s.", msgs) if len(msgs) > 1: @@ -262,7 +261,11 @@ def get_device_data(wg_interface: str) -> Tuple[int, str, str]: port = int(info.get_attr("WGDEVICE_A_LISTEN_PORT")) public_key = info.get_attr("WGDEVICE_A_PUBLIC_KEY").decode("ascii") - link_address = ndb.interfaces[wg_interface].ipaddr[0].get("address") + + # Get link address using IPRoute + ipr_link = ipr.link_lookup(ifname=wg_interface)[0] + msgs = ipr.get_addr(index=ipr_link) + link_address = msgs[0].get_attr("IFA_ADDRESS") logger.debug( "Interface data: port '%s', public key '%s', link address '%s", From dc2f5fb03fe80ff44fdabd5fcb815a8823f2ea2a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:23:29 +0000 Subject: [PATCH 095/107] Bump python from 3.11.5-bullseye to 3.11.7-bullseye Bumps python from 3.11.5-bullseye to 3.11.7-bullseye. --- updated-dependencies: - dependency-name: python dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2d2283e..d7474cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.5-bullseye AS builder +FROM python:3.11.7-bullseye AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,7 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.11.5-bullseye +FROM python:3.11.7-bullseye WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From 249ec8eaef832dc0211e54098f45f31541cb8fb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:32:47 +0000 Subject: [PATCH 096/107] Bump actions/cache from 3 to 4 Bumps [actions/cache](https://github.com/actions/cache) from 3 to 4. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/bazel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 1513680..22604f1 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Setup cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: "/home/runner/.cache/bazel" key: bazel From 198dfc67a0132559ed8748ec77976da331e58e9f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 17:55:19 +0100 Subject: [PATCH 097/107] Update flask requirement from ~=3.0.0 to ~=3.0.1 (#124) Updates the requirements on [flask](https://github.com/pallets/flask) to permit the latest version. - [Release notes](https://github.com/pallets/flask/releases) - [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/flask/compare/3.0.0...3.0.1) --- updated-dependencies: - dependency-name: flask dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1821412..beac852 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ NetLink~=0.1 flask-mqtt pyroute2~=0.7.10 PyYAML~=6.0.1 -Flask~=3.0.0 +Flask~=3.0.1 waitress~=2.1.2 # Common From 58d034965896b54ebd149cd9ec9b753dc8149509 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 17:55:46 +0100 Subject: [PATCH 098/107] Update pyroute2 requirement from ~=0.7.10 to ~=0.7.11 (#123) Updates the requirements on [pyroute2](https://github.com/svinota/pyroute2) to permit the latest version. - [Release notes](https://github.com/svinota/pyroute2/releases) - [Changelog](https://github.com/svinota/pyroute2/blob/master/CHANGELOG.rst) - [Commits](https://github.com/svinota/pyroute2/compare/0.7.10...0.7.11) --- updated-dependencies: - dependency-name: pyroute2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index beac852..65fb79c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ NetLink~=0.1 flask-mqtt -pyroute2~=0.7.10 +pyroute2~=0.7.11 PyYAML~=6.0.1 Flask~=3.0.1 waitress~=2.1.2 From b2d0c3d03dc8ecd68c9a194e7ee9e601209c9e3f Mon Sep 17 00:00:00 2001 From: GoliathLabs Date: Tue, 6 Feb 2024 17:44:53 +0100 Subject: [PATCH 099/107] fix: black linting --- wgkex/common/utils.py | 1 + wgkex/config/config.py | 1 + wgkex/config/config_test.py | 1 + wgkex/worker/app_test.py | 1 + wgkex/worker/mqtt_test.py | 1 + wgkex/worker/netlink.py | 1 + wgkex/worker/netlink_test.py | 1 + 7 files changed, 7 insertions(+) diff --git a/wgkex/common/utils.py b/wgkex/common/utils.py index 8fa201c..e90ee43 100644 --- a/wgkex/common/utils.py +++ b/wgkex/common/utils.py @@ -1,4 +1,5 @@ """A collection of general utilities.""" + import ipaddress import re diff --git a/wgkex/config/config.py b/wgkex/config/config.py index b0239e2..b597f5a 100644 --- a/wgkex/config/config.py +++ b/wgkex/config/config.py @@ -1,4 +1,5 @@ """Configuration handling class.""" + import dataclasses import logging import os diff --git a/wgkex/config/config_test.py b/wgkex/config/config_test.py index 6e30eb3..8c3be35 100644 --- a/wgkex/config/config_test.py +++ b/wgkex/config/config_test.py @@ -1,4 +1,5 @@ """Tests for configuration handling class.""" + import unittest import mock import yaml diff --git a/wgkex/worker/app_test.py b/wgkex/worker/app_test.py index efe774d..8cb418f 100644 --- a/wgkex/worker/app_test.py +++ b/wgkex/worker/app_test.py @@ -1,4 +1,5 @@ """Unit tests for app.py""" + import threading from time import sleep import unittest diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index 3ea186e..efdd4eb 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -1,4 +1,5 @@ """Unit tests for mqtt.py""" + import socket import threading import unittest diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index bb413f1..057e110 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -1,4 +1,5 @@ """Functions related to netlink manipulation for Wireguard, IPRoute and FDB on Linux.""" + # See https://docs.pyroute2.org/iproute.html for a documentation of the layout of netlink responses import hashlib import re diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index 0874005..86673a7 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -1,4 +1,5 @@ """Unit tests for netlink.py""" + import unittest import mock from datetime import timedelta From 69c38d1ddc21ed1d37a49c7e4e23d44a09c1abff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 Mar 2024 09:04:40 +0000 Subject: [PATCH 100/107] Update pyroute2 requirement from ~=0.7.11 to ~=0.7.12 Updates the requirements on [pyroute2](https://github.com/svinota/pyroute2) to permit the latest version. - [Release notes](https://github.com/svinota/pyroute2/releases) - [Changelog](https://github.com/svinota/pyroute2/blob/master/CHANGELOG.rst) - [Commits](https://github.com/svinota/pyroute2/compare/0.7.11...0.7.12) --- updated-dependencies: - dependency-name: pyroute2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 65fb79c..d66e6d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ NetLink~=0.1 flask-mqtt -pyroute2~=0.7.11 +pyroute2~=0.7.12 PyYAML~=6.0.1 Flask~=3.0.1 waitress~=2.1.2 From fcc275fd13d63bff8078ca9c3a0c72acda0815be Mon Sep 17 00:00:00 2001 From: Grische <2787581+grische@users.noreply.github.com> Date: Sun, 17 Mar 2024 19:58:39 +0100 Subject: [PATCH 101/107] update to Debian bookworm and bump Python 3.11 --- Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d7474cb..962a3f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11.7-bullseye AS builder +FROM python:3.11.8-bookworm AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ @@ -15,7 +15,8 @@ RUN ["bazel", "build", "//wgkex/broker:app"] RUN ["bazel", "build", "//wgkex/worker:app"] RUN ["cp", "-rL", "bazel-bin", "bazel"] -FROM python:3.11.7-bullseye + +FROM python:3.11.8-slim-bookworm WORKDIR /wgkex COPY --from=builder /wgkex/bazel /wgkex/ From ecfcfbc6fb3f2b42d1362593730393ea7f97c165 Mon Sep 17 00:00:00 2001 From: Grische <2787581+grische@users.noreply.github.com> Date: Sun, 17 Mar 2024 19:59:02 +0100 Subject: [PATCH 102/107] Downgrade and pin Bazel to 7.0.2 Version 7.1.0 runs into issues with its sandbox: 2.108 ERROR: Failed to initialize sandbox: /root/.cache/bazel/_bazel_root/20f0ced2adfccd482c7c7a0094d96fba/sandbox/_moved_trash_dir -> /root/.cache/bazel/_bazel_root/20f0ced2adfccd482c7c7a0094d96fba/sandbox/stale-trash-0 (Invalid cross-device link) --- Dockerfile | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index 962a3f3..dff770c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.11.8-bookworm AS builder RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/bazel-archive-keyring.gpg] https://storage.googleapis.com/bazel-apt stable jdk1.8" | tee /etc/apt/sources.list.d/bazel.list \ - && apt-get update && apt-get install -y bazel \ + && apt-get update && apt-get install -y bazel-7.0.2 \ && rm -rf /var/lib/apt/lists/* WORKDIR /wgkex @@ -11,15 +11,15 @@ WORKDIR /wgkex COPY BUILD WORKSPACE requirements.txt ./ COPY wgkex ./wgkex -RUN ["bazel", "build", "//wgkex/broker:app"] -RUN ["bazel", "build", "//wgkex/worker:app"] -RUN ["cp", "-rL", "bazel-bin", "bazel"] +RUN ["bazel-7.0.2", "build", "//wgkex/broker:app"] +RUN ["bazel-7.0.2", "build", "//wgkex/worker:app"] +RUN ["cp", "-rL", "bazel-bin", "bazel-7.0.2"] FROM python:3.11.8-slim-bookworm WORKDIR /wgkex -COPY --from=builder /wgkex/bazel /wgkex/ +COPY --from=builder /wgkex/bazel-7.0.2 /wgkex/ COPY entrypoint /entrypoint From 435daa07e3287b5c1a1342593edcfa6c584e36a9 Mon Sep 17 00:00:00 2001 From: Tobias Date: Sun, 21 Apr 2024 20:52:34 +0200 Subject: [PATCH 103/107] Update Dockerfile to bazel 7.1.1 (#136) --- Dockerfile | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index dff770c..a761638 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,19 @@ -FROM python:3.11.8-bookworm AS builder - -RUN apt-get update && apt-get install -y apt-transport-https curl gnupg \ - && curl -fsSL https://bazel.build/bazel-release.pub.gpg | gpg --dearmor >/usr/share/keyrings/bazel-archive-keyring.gpg \ - && echo "deb [arch=amd64 signed-by=/usr/share/keyrings/bazel-archive-keyring.gpg] https://storage.googleapis.com/bazel-apt stable jdk1.8" | tee /etc/apt/sources.list.d/bazel.list \ - && apt-get update && apt-get install -y bazel-7.0.2 \ - && rm -rf /var/lib/apt/lists/* +FROM gcr.io/bazel-public/bazel:7.1.1 AS builder WORKDIR /wgkex COPY BUILD WORKSPACE requirements.txt ./ COPY wgkex ./wgkex -RUN ["bazel-7.0.2", "build", "//wgkex/broker:app"] -RUN ["bazel-7.0.2", "build", "//wgkex/worker:app"] -RUN ["cp", "-rL", "bazel-bin", "bazel-7.0.2"] +RUN ["bazel", "build", "//wgkex/broker:app"] +RUN ["bazel", "build", "//wgkex/worker:app"] +RUN ["cp", "-rL", "bazel-bin", "bazel"] FROM python:3.11.8-slim-bookworm WORKDIR /wgkex -COPY --from=builder /wgkex/bazel-7.0.2 /wgkex/ +COPY --from=builder /wgkex/bazel /wgkex/ COPY entrypoint /entrypoint From acf08a932caf258b03681e72b6feadbf6622e7e6 Mon Sep 17 00:00:00 2001 From: Tobias Date: Sun, 21 Apr 2024 20:55:08 +0200 Subject: [PATCH 104/107] Update README.md (#134) --- README.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 911aeb8..de3d878 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,15 @@ wgkex is a WireGuard key exchange and management tool designed and run by FFMUC. WireGuard Key Exchange is a tool consisting of two parts: a frontend (broker) and a backend (worker). These components communicate to each other via MQTT - a messaging bus. - - - - Architectural Diagram - +```mermaid +graph TD; + A{"client"} -->|"RESTful API"| G("WGKex Broker") + G -->|"publish"| B("Mosquitto") + C("WGKex Worker") -->|"Subscribe"| B + C -->|"Route Injection"| D["netlink (pyroute2)"] + C -->|"Peer Creation"| E["wireguard (pyroute2)"] + C -->|"VxLAN FDB Entry"| F["VXLAN FDB (pyroute2)"] +``` ### Frontend broker From e8857770b33acee2326f514ad42326b4c02cfd69 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Apr 2024 20:59:19 +0200 Subject: [PATCH 105/107] Update flask requirement from ~=3.0.1 to ~=3.0.3 (#139) Updates the requirements on [flask](https://github.com/pallets/flask) to permit the latest version. - [Release notes](https://github.com/pallets/flask/releases) - [Changelog](https://github.com/pallets/flask/blob/main/CHANGES.rst) - [Commits](https://github.com/pallets/flask/compare/3.0.1...3.0.3) --- updated-dependencies: - dependency-name: flask dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index d66e6d8..5c9c1bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ NetLink~=0.1 flask-mqtt pyroute2~=0.7.12 PyYAML~=6.0.1 -Flask~=3.0.1 +Flask~=3.0.3 waitress~=2.1.2 # Common From ade9f0c620a8c53e418f13a8cba1426b027cad52 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 21 Apr 2024 21:02:52 +0200 Subject: [PATCH 106/107] Update waitress requirement from ~=2.1.2 to ~=3.0.0 (#128) Updates the requirements on [waitress](https://github.com/Pylons/waitress) to permit the latest version. - [Release notes](https://github.com/Pylons/waitress/releases) - [Changelog](https://github.com/Pylons/waitress/blob/main/CHANGES.txt) - [Commits](https://github.com/Pylons/waitress/compare/v2.1.2...v3.0.0) --- updated-dependencies: - dependency-name: waitress dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 5c9c1bf..887c7c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ flask-mqtt pyroute2~=0.7.12 PyYAML~=6.0.1 Flask~=3.0.3 -waitress~=2.1.2 +waitress~=3.0.0 # Common ipaddress~=1.0.23 From 18578c007dd3fd89fa4c56b349c2971541fbb53e Mon Sep 17 00:00:00 2001 From: DasSkelett Date: Thu, 28 Mar 2024 22:18:48 +0000 Subject: [PATCH 107/107] Handle NetlinkDumpInterrupted, fix worker metrics going stale after exceptions --- wgkex/worker/mqtt.py | 25 ++++++++++++---- wgkex/worker/mqtt_test.py | 56 ++++++++++++++++++++++++++++++++++++ wgkex/worker/netlink.py | 17 ++++++++--- wgkex/worker/netlink_test.py | 29 ++++++++++++++++--- 4 files changed, 113 insertions(+), 14 deletions(-) diff --git a/wgkex/worker/mqtt.py b/wgkex/worker/mqtt.py index d5941cd..6c30c73 100644 --- a/wgkex/worker/mqtt.py +++ b/wgkex/worker/mqtt.py @@ -9,6 +9,7 @@ from typing import Any, Optional import paho.mqtt.client as mqtt +import pyroute2.netlink.exceptions from wgkex.common import logger from wgkex.common.mqtt import ( @@ -20,9 +21,7 @@ from wgkex.worker.msg_queue import q from wgkex.worker.netlink import ( get_device_data, - link_handler, get_connected_peers_count, - WireGuardClient, ) _HOSTNAME = socket.gethostname() @@ -206,9 +205,15 @@ def publish_metrics_loop( topic = TOPIC_CONNECTED_PEERS.format(domain=domain, worker=_HOSTNAME) while not exit_event.is_set(): - publish_metrics(client, topic, domain) - # This drifts slightly over time, doesn't matter for us - exit_event.wait(_METRICS_SEND_INTERVAL) + try: + publish_metrics(client, topic, domain) + except Exception as e: + # Don't crash the thread when an exception is encountered + logger.error(f"Exception during publish metrics task for {domain}:") + logger.error(e) + finally: + # This drifts slightly over time, doesn't matter for us + exit_event.wait(_METRICS_SEND_INTERVAL) # Set peers metric to -1 to mark worker as offline # Use QoS 1 (at least once) to make sure the broker notices @@ -227,7 +232,15 @@ def publish_metrics(client: mqtt.Client, topic: str, domain: str) -> None: f"Could not get interface name for domain {domain}. Skipping metrics publication" ) return - peer_count = get_connected_peers_count(iface) + + try: + peer_count = get_connected_peers_count(iface) + except pyroute2.netlink.exceptions.NetlinkDumpInterrupted: + # Handle gracefully, don't update metrics + logger.info( + "Caught NetlinkDumpInterrupted exception while collecting metrics for domain {domain}" + ) + return # Publish metrics, retain it at MQTT broker so restarted wgkex broker has metrics right away client.publish(topic, peer_count, retain=True) diff --git a/wgkex/worker/mqtt_test.py b/wgkex/worker/mqtt_test.py index efdd4eb..127ec48 100644 --- a/wgkex/worker/mqtt_test.py +++ b/wgkex/worker/mqtt_test.py @@ -7,6 +7,7 @@ import mock import paho.mqtt.client +import pyroute2.netlink.exceptions from wgkex.common.mqtt import TOPIC_CONNECTED_PEERS from wgkex.worker import mqtt @@ -128,6 +129,61 @@ def test_publish_metrics_loop_success(self, conn_peers_mock, config_mock): self.assertFalse(thread.is_alive()) + @mock.patch.object(mqtt, "_METRICS_SEND_INTERVAL", 0.02) + @mock.patch.object(mqtt, "get_config") + @mock.patch.object(mqtt, "get_connected_peers_count") + def test_publish_metrics_loop_no_exception(self, conn_peers_mock, config_mock): + """Tests that an exception doesn't interrupt the loop""" + config_mock.return_value = _get_config_mock() + conn_peers_mock.side_effect = Exception("Mocked exception") + mqtt_client = mock.MagicMock(spec=paho.mqtt.client.Client) + + ee = threading.Event() + thread = threading.Thread( + target=mqtt.publish_metrics_loop, + args=(ee, mqtt_client, "_ffmuc_domain.one"), + ) + thread.start() + + i = 0 + while i < 20 and not len(conn_peers_mock.mock_calls) >= 2: + i += 1 + sleep(0.1) + + self.assertTrue( + len(conn_peers_mock.mock_calls) >= 2, + "get_connected_peers_count must be called at least twice", + ) + + mqtt_client.publish.assert_not_called() + + ee.set() + + i = 0 + while i < 20 and thread.is_alive(): + i += 1 + sleep(0.1) + + self.assertFalse(thread.is_alive()) + + @mock.patch.object(mqtt, "get_config") + @mock.patch.object(mqtt, "get_connected_peers_count") + def test_publish_metrics_NetlinkDumpInterrupted(self, conn_peers_mock, config_mock): + config_mock.return_value = _get_config_mock() + conn_peers_mock.side_effect = ( + pyroute2.netlink.exceptions.NetlinkDumpInterrupted() + ) + mqtt_client = mock.MagicMock(spec=paho.mqtt.client.Client) + + domain = mqtt.get_config().domains[0] + hostname = socket.gethostname() + topic = TOPIC_CONNECTED_PEERS.format(domain=domain, worker=hostname) + + # Must not raise NetlinkDumpInterrupted, but handle gracefully by doing nothing + mqtt.publish_metrics(mqtt_client, topic, domain) + + mqtt_client.publish.assert_not_called() + @mock.patch.object(mqtt, "get_config") def test_on_message_wireguard_success(self, config_mock): # Tests on_message for success. diff --git a/wgkex/worker/netlink.py b/wgkex/worker/netlink.py index 057e110..1e681aa 100644 --- a/wgkex/worker/netlink.py +++ b/wgkex/worker/netlink.py @@ -9,8 +9,7 @@ from textwrap import wrap from typing import Any, Dict, List, Tuple -import pyroute2 -import pyroute2.netlink +import pyroute2, pyroute2.netlink, pyroute2.netlink.exceptions from wgkex.common.utils import mac2eui64 from wgkex.common import logger @@ -218,12 +217,22 @@ def get_connected_peers_count(wg_interface: str) -> int: wg_interface: The WireGuard interface to query. Returns: - # The number of peers which have recently seen a handshake. + The number of peers which have recently seen a handshake. + + Raises: + NetlinkDumpInterrupted if the interface data has changed while it was being returned by netlink """ three_mins_ago_in_secs = int((datetime.now() - timedelta(minutes=3)).timestamp()) logger.info("Counting connected wireguard peers for interface %s.", wg_interface) with pyroute2.WireGuard() as wg: - msgs = wg.info(wg_interface) + try: + msgs = wg.info(wg_interface) + except pyroute2.netlink.exceptions.NetlinkDumpInterrupted: + # Normal behaviour, data has changed while it was being returned by netlink. + # Retry once, don't catch the exception this time, and let the caller handle it. + # See https://github.com/svinota/pyroute2/issues/874 + msgs = wg.info(wg_interface) + logger.debug("Got infos for connected peers: %s.", msgs) count = 0 for msg in msgs: diff --git a/wgkex/worker/netlink_test.py b/wgkex/worker/netlink_test.py index 86673a7..6716a90 100644 --- a/wgkex/worker/netlink_test.py +++ b/wgkex/worker/netlink_test.py @@ -9,10 +9,11 @@ # any testing platform can execute tests. import sys -sys.modules["pyroute2"] = mock.MagicMock() -sys.modules["pyroute2.WireGuard"] = mock.MagicMock() -sys.modules["pyroute2.IPRoute"] = mock.MagicMock() -sys.modules["pyroute2.NDB"] = mock.MagicMock() +import pyroute2.netlink.exceptions as pyroute2_netlink_exceptions + +pyroute2_module_mock = mock.MagicMock() +pyroute2_module_mock.netlink.exceptions = pyroute2_netlink_exceptions +sys.modules["pyroute2"] = pyroute2_module_mock sys.modules["pyroute2.netlink"] = mock.MagicMock() from pyroute2 import WireGuard from pyroute2 import IPRoute @@ -229,6 +230,26 @@ def msg_get_attr(attr: str): ret = netlink.get_connected_peers_count("wg-welt") self.assertEqual(ret, 3) + @mock.patch("pyroute2.WireGuard") + def test_get_connected_peers_count_NetlinkDumpInterrupted(self, pyroute2_wg_mock): + """Tests getting the correct number of connected peers for an interface.""" + + nl_wg_mock_ctx = mock.MagicMock() + wg_info_mock = mock.MagicMock( + side_effect=(pyroute2_netlink_exceptions.NetlinkDumpInterrupted), + ) + nl_wg_mock_ctx.info = wg_info_mock + + nl_wg_mock_inst = pyroute2_wg_mock.return_value + nl_wg_mock_inst.__enter__ = mock.MagicMock(return_value=nl_wg_mock_ctx) + + self.assertRaises( + pyroute2_netlink_exceptions.NetlinkDumpInterrupted, + netlink.get_connected_peers_count, + "wg-welt", + ) + self.assertTrue(len(wg_info_mock.mock_calls) == 2) + def test_get_device_data_success(self): def msg_get_attr(attr: str): if attr == "WGDEVICE_A_LISTEN_PORT":