commit 6c006e1d4dcfd773561d23acbea3e85116d31d8e Author: jcoffey <51408202+jcoffey-dev@users.noreply.github.com> Date: Sat Sep 12 18:58:16 2026 -0700 WireGuard server with an embedded admin console Go backend that drives kernel WireGuard over netlink (wireguard-go as the fallback), nftables NAT with MSS clamping, forwarding and buffer sysctls, SQLite for peers, users, sessions, traffic history and the audit log. React console: dashboard with live rates and usage history, peer management with QR codes and .conf downloads, disconnect, session reset, key rotation, expiry, client-supplied keys, settings, users with admin and viewer roles, two-factor authentication with recovery codes, audit log. Docker image on Alpine with compose files for bridged and host networking, CI and GHCR publish workflows, performance notes. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c5b43f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.github +docs +web/node_modules +web/dist +internal/server/static/dist +*.md +!web/**/*.md +docker-compose*.yml +.gitignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..288445b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI +on: + push: + branches: [main] + pull_request: + # Lets a run be started by hand against any ref, including one GitHub + # queued and then orphaned. + workflow_dispatch: +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 26 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci --ignore-scripts --no-audit --no-fund + working-directory: web + - run: npm run build + working-directory: web + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - run: go vet ./... + - run: go test -count=1 ./... + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... + - name: Docker build + run: docker build -t wgx:ci . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a0cfb1c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,127 @@ +# Publish the container image to GHCR. +# +# FIRST RUN: a package GHCR creates for the first time is private, even in a +# public repository. Set it to public by hand under the package's settings +# and check with a logged-out `docker pull`. +# +# Two architectures, each built on its own native runner rather than under +# QEMU (`ubuntu-24.04-arm` is free for public repositories). Each runner +# pushes an untagged image by digest and a final job joins the two into one +# multi-arch tag. +name: Publish image + +on: + release: + types: [published] + workflow_dispatch: + inputs: + ref: + description: "Tag, branch or SHA to build" + required: true + default: main + tag_latest: + description: "Also move :latest to this build" + type: boolean + default: false + +env: + IMAGE: ghcr.io/coffey-labs/wgx + +jobs: + version: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.v.outputs.version }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + fetch-depth: 0 + - id: v + run: | + V="$(git describe --tags --always --dirty)" + V="${V#v}" + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "version $V" + + build: + needs: version + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref || github.ref }} + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push by digest + id: push + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + build-args: WGX_VERSION=${{ needs.version.outputs.version }} + provenance: false + sbom: false + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true + - name: Save the digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + - uses: actions/upload-artifact@v7 + with: + name: digest-${{ strategy.job-index }} + path: /tmp/digests/* + retention-days: 1 + if-no-files-found: error + + publish: + needs: [version, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digest-* + merge-multiple: true + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Create the manifest + run: | + tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}") + if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then + tags+=(-t "${IMAGE}:latest") + elif [ "${{ inputs.tag_latest }}" = "true" ]; then + tags+=(-t "${IMAGE}:latest") + fi + refs=() + for f in /tmp/digests/*; do + refs+=("${IMAGE}@sha256:$(basename "$f")") + done + docker buildx imagetools create "${tags[@]}" "${refs[@]}" + - name: Show what landed + run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78e9dbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Build output +/wgx +/dist/ +web/node_modules/ +web/dist/ +internal/server/static/dist/* +!internal/server/static/dist/.gitkeep + +# Local state +/data/ +*.db +*.db-wal +*.db-shm +*.log +.env diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..87f73db --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing to WGX + +Thanks for your interest. Bug reports, feature requests, code and +documentation are all welcome. + +## Before you start + +- WGX is one container: the WireGuard server and the UI that manages it. + Contributions that need a second service (a database, a message queue, a + separate frontend host) are out of scope. +- The kernel data plane is the point. Anything on the packet path has to + justify its cost. +- This project is licensed under **AGPL-3.0**. Code you contribute is + distributed under that licence, including for hosted deployments. + +## Development + +You need Go (see `go.mod` for the version), Node 26 and Docker. + +```sh +# Frontend, with hot reload, proxying /api to a local server on :51821 +cd web && npm ci && npm run dev + +# Backend against the in-memory mock data plane -- no privileges needed +WGX_BACKEND=mock WGX_DATA_DIR=/tmp/wgx WGX_HTTP_LISTEN=127.0.0.1:51821 go run ./cmd/wgx +``` + +The mock simulates peers handshaking and moving traffic so the dashboard has +something to show. For the real thing: + +```sh +cd web && npm run build && cd .. +docker build -t wgx:dev . +docker run --rm --cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \ + -p 51820:51820/udp -p 127.0.0.1:51821:51821 -v wgx-dev:/data wgx:dev +``` + +## Before you commit + +CI checks are not a substitute for building locally. Run, in this order: + +```sh +cd web && npm run build && cd .. # type-checks and builds the UI +go vet ./... && go test -count=1 ./... +docker build -t wgx:dev . # when the change reaches the image +``` + +`go test` covers the engine against the mock data plane and the whole HTTP +API through `httptest`. A change to the data plane itself (`internal/wg`, +`internal/netcfg`) needs a run in a container with NET_ADMIN and a real +client handshake; say in the pull request that you did that. + +## Pull requests + +- One change per pull request, with a description of what and why. +- Keep the commit message about the change. No tooling attributions or + generated-by footers. +- New settings need a line in the README's configuration table; anything on + the packet path needs a note in `docs/performance.md`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d8535c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ---- web build ---- +FROM node:26-alpine AS web +WORKDIR /src/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY web/ ./ +RUN npm run build + +# ---- go build ---- +FROM golang:1.27-alpine AS build +# The version string the binary reports. Worked out by whoever runs the +# build (CI passes the tag); left empty it says "dev". +ARG WGX_VERSION=dev +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ cmd/ +COPY internal/ internal/ +COPY --from=web /src/internal/server/static/dist internal/server/static/dist +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/Coffey-Labs/WGX/internal/engine.Version=${WGX_VERSION}" -o /wgx ./cmd/wgx + +# ---- runtime ---- +FROM alpine:3.22 +# nftables does the NAT; wireguard-go is the fallback data plane for hosts +# without the kernel module; wireguard-tools gives `wg show` for debugging. +RUN apk add --no-cache nftables wireguard-go wireguard-tools ca-certificates tzdata \ + && mkdir -p /data +COPY --from=build /wgx /usr/local/bin/wgx +ENV WGX_DATA_DIR=/data \ + WGX_HTTP_LISTEN=:51821 +VOLUME ["/data"] +EXPOSE 51820/udp 51821/tcp +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ + CMD wget -qO- http://127.0.0.1:51821/api/health || exit 1 +ENTRYPOINT ["wgx"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..9d1fdcb --- /dev/null +++ b/NOTICE @@ -0,0 +1,35 @@ +# Third-party notices + +WGX is licensed under the AGPL-3.0; see LICENSE. This file records work by +other people that ships inside the binary and the image, and the terms it +comes under. Each project's own licence text travels with it in the Go module +cache and in web/node_modules and is not repeated here. + +## Go + +| Module | Licence | +| --- | --- | +| golang.zx2c4.com/wireguard/wgctrl (and golang.zx2c4.com/wireguard) | MIT, Matt Layher / Jason A. Donenfeld | +| github.com/mdlayher/netlink, genetlink, socket | MIT, Matt Layher | +| github.com/vishvananda/netlink, netns | Apache-2.0 | +| modernc.org/sqlite, libc, mathutil, memory | BSD-3-Clause | +| github.com/skip2/go-qrcode | MIT, Tom Harwood | +| golang.org/x/crypto, x/sys, x/net, x/sync, x/term | BSD-3-Clause, The Go Authors | + +## JavaScript (built into the console) + +| Package | Licence | +| --- | --- | +| react, react-dom | MIT, Meta Platforms | +| wouter | Unlicense | +| lucide-react | ISC, Lucide Contributors | + +## Runtime image + +The container image is built on Alpine Linux and ships nftables, +wireguard-tools and wireguard-go from its package repositories, each under +its own licence (GPL-2.0 for nftables and wireguard-tools, MIT for +wireguard-go). They are separate programs invoked by WGX, not linked into it. + +WireGuard is a registered trademark of Jason A. Donenfeld. WGX is not +affiliated with or endorsed by the WireGuard project. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1164c43 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# WGX + +A WireGuard server with a secure web console, in one container. + +Start it, open the console, create a peer, scan the QR code. WGX runs the +tunnel on the kernel's WireGuard module, keeps the NAT rules and forwarding +sysctls in order, and gives you a dashboard that shows who is connected, how +much they are moving, and a button to cut them off. + +## What it does + +- **Peers.** Create, edit, disable, delete. The server generates the key + pair (and a preshared key) and shows a QR code and a `.conf` download; or + the client brings its own public key and the private key never leaves the + device. Pin a tunnel address or let WGX allocate one. Set an expiry and the + peer is disconnected on time. Rotate keys in one click. +- **Who is connected.** Live status from the interface counters every two + seconds: endpoint, last handshake, session length, current rate, total + transfer. Usage history in five-minute buckets, per peer and overall, kept + for 90 days. +- **Disconnect them.** *Disconnect* removes the peer from the interface and + keeps it off until you enable it again. *Reset session* drops the current + session and lets the client handshake afresh. +- **Fast.** Kernel data plane over netlink, no user-space hop. Tuned + sysctls, TCP MSS clamping, optional host networking. Falls back to + `wireguard-go` on hosts without the module and tells you so. See + [docs/performance.md](docs/performance.md). +- **Locked down.** argon2id passwords, two-factor authentication with + recovery codes, viewer and administrator roles, rate-limited login, + same-origin enforcement, strict CSP, built-in TLS if you want it, and an + audit log of every change (including every time a peer's configuration is + viewed). See [SECURITY.md](SECURITY.md). +- **Observable.** `/api/health` for a liveness probe and `/metrics` in + Prometheus format, guarded by a bearer token. +- **Self-contained.** One static Go binary, one SQLite file under `/data`, + no other services. Multi-arch image for amd64 and arm64. + +## Quick start + +```sh +curl -O https://raw.githubusercontent.com/Coffey-Labs/WGX/main/docker-compose.yml +# edit WGX_ENDPOINT (your public hostname or IP), then: +docker compose up -d +``` + +Open , create the first administrator, and add a +peer. Point the WireGuard app on your phone at the QR code. + +The console is bound to localhost in the compose file on purpose. To reach +it from elsewhere, either set `WGX_TLS_SELF_SIGNED: "true"` and bind to the +address you need, or put a TLS-terminating reverse proxy in front of it and +list the proxy in `WGX_TRUSTED_PROXIES`. + +For the fastest configuration, `docker-compose.host.yml` runs on the host +network; [docs/performance.md](docs/performance.md) says when that is worth +it and which host sysctls to set. + +### Requirements + +- Docker (or Podman) on a Linux host with a kernel from 5.6 on. Older kernels + work with `wireguard-dkms` installed on the host, or fall back to the + slower user-space data plane automatically. +- The container needs `NET_ADMIN` and the forwarding sysctls in the compose + file. `SYS_MODULE` is not needed unless the host has never loaded the + module and cannot autoload it. +- UDP port 51820 (or whatever you choose) reachable from the internet. + +## Configuration + +Infrastructure is configured through the environment; everything an +administrator might change while the server runs lives in the database and +is edited in the console under **Settings** (endpoint, DNS, default client +routes, MTU, keepalive, peer isolation, MSS clamping, preshared keys). + +| Variable | Default | Meaning | +| --- | --- | --- | +| `WGX_ENDPOINT` | | Public hostname or IP for client configs. Also asked for at first-run setup. | +| `WGX_PORT` | `51820` | UDP listen port. | +| `WGX_SUBNET` | `10.8.0.0/24` | IPv4 tunnel network; the server takes the first address. | +| `WGX_SUBNET6` | | IPv6 tunnel network, e.g. `fd42:42:42::/64`. Off when empty. | +| `WGX_DNS` | `1.1.1.1, 1.0.0.1` | Resolvers handed to clients on first run. | +| `WGX_INTERFACE` | `wg0` | Interface name. | +| `WGX_EGRESS_INTERFACE` | auto | Interface to masquerade on. Auto uses the default route. | +| `WGX_HTTP_LISTEN` | `:51821` | Console listen address. | +| `WGX_TLS_SELF_SIGNED` | `false` | Serve HTTPS with a certificate generated into `/data`. | +| `WGX_TLS_CERT`, `WGX_TLS_KEY` | | Serve HTTPS with your own certificate. | +| `WGX_SECURE_COOKIES` | `false` | Mark cookies `Secure` when TLS terminates at a proxy. | +| `WGX_TRUSTED_PROXIES` | | CIDRs whose `X-Forwarded-For` is believed. | +| `WGX_METRICS_TOKEN` | | Bearer token for `/metrics`. A signed-in session works too. | +| `WGX_SESSION_IDLE` | `12h` | Sign out after this much inactivity. | +| `WGX_SESSION_MAX` | `168h` | Sign out after this long regardless. | +| `WGX_TRAFFIC_RETENTION` | `2160h` | How long usage history is kept (90 days). | +| `WGX_POLL_INTERVAL` | `2s` | How often the interface counters are read. | +| `WGX_BACKEND` | `auto` | `kernel`, `userspace` or `mock`. Auto prefers the kernel. | +| `WGX_MANAGE_FIREWALL` | `true` | Set to `false` if the host owns the NAT rules. | +| `WGX_MANAGE_SYSCTL` | `true` | Set to `false` if the host has tuned itself. | +| `WGX_DATA_DIR` | `/data` | Where the database and TLS files live. | +| `WGX_LOG_LEVEL`, `WGX_LOG_JSON` | `info`, `false` | Logging. | + +## Locked out? + +```sh +docker exec -it wgx wgx reset-password admin +``` + +sets a new password for that user, clears their second factor and ends +their sessions. It runs against the same database, so no restart is needed. + +## Client setup + +Any WireGuard client works: the official apps on iOS, Android, macOS and +Windows, `wg-quick` on Linux, and routers that speak WireGuard. Scan the QR +code from the peer's **Configuration** tab, or download the `.conf`. The +default configuration routes everything through the tunnel; change **Client +routes** on the peer (or the default under Settings) to the tunnel subnet +alone for split tunnelling. + +## API + +Everything the console does goes through `/api/…` with the session cookie. +`GET /api/peers`, `POST /api/peers`, `GET /api/peers/{id}/config`, +`POST /api/peers/{id}/disable` and friends are stable enough to script +against; the shapes are in `internal/server/api.go`. A cross-site request +without a same-origin `Sec-Fetch-Site` or `Origin` header is refused, so +call it from the same origin or from a non-browser client. + +## Building from source + +```sh +cd web && npm ci && npm run build && cd .. +go build ./cmd/wgx +``` + +The UI is embedded in the binary. `docker build -t wgx .` does both steps. +See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop against the +mock data plane, which needs no privileges. + +## Licence + +AGPL-3.0-or-later. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..41a86a4 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,43 @@ +# Security Policy + +## Supported versions + +Security fixes go to `main` and the next release. Older releases are not +patched. + +## Reporting a vulnerability + +**Please do not open a public issue for a security problem.** Email +**johnellisATlinuxDOTcom** with what you found, how to reproduce it and what +you think the impact is. You will get an acknowledgement within a few days +and a fix or a plan before anything is made public. + +## What WGX does to protect itself + +- The admin UI requires a password (argon2id, 64 MiB, 3 passes) and offers + time-based one-time codes with recovery codes. Sessions are random 256-bit + tokens stored hashed, `HttpOnly`, `SameSite=Strict`, with idle and absolute + expiry. +- Every state-changing request must come from the same origin + (`Sec-Fetch-Site` / `Origin` are checked in addition to the cookie policy) + and carry a JSON body; the first-run setup endpoint stops working the + moment a user exists. +- Login is rate-limited per address and per username, and a failed login for + an unknown user takes as long as one for a known user. +- Responses carry a strict Content-Security-Policy, `X-Frame-Options: DENY`, + `Referrer-Policy: no-referrer` and, under TLS, HSTS. +- Peer private keys never appear in list or detail responses; they are only + returned through the configuration and QR endpoints, and each view is + written to the audit log. The server's own private key never leaves the + process. +- The database file is created mode 0600 and the container image contains + no shell tooling beyond what nftables and WireGuard need. + +## What you must do + +- Do not expose port 51821 to the internet without TLS. Either set + `WGX_TLS_SELF_SIGNED=true` (or `WGX_TLS_CERT`/`WGX_TLS_KEY`) or put a + TLS-terminating reverse proxy in front and list it in + `WGX_TRUSTED_PROXIES` so client addresses in the audit log are right. +- Turn on two-factor authentication for every administrator. +- Keep the `/data` volume private: it holds every peer's private key. diff --git a/cmd/wgx/main.go b/cmd/wgx/main.go new file mode 100644 index 0000000..0dcbe85 --- /dev/null +++ b/cmd/wgx/main.go @@ -0,0 +1,195 @@ +// Command wgx runs the WireGuard server and its admin UI. +// +// wgx run the server (the container's default) +// wgx reset-password U set a new password for admin user U and drop +// their sessions and second factor; for lockouts +// wgx version print the version +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "golang.org/x/term" + + "github.com/Coffey-Labs/WGX/internal/auth" + "github.com/Coffey-Labs/WGX/internal/config" + "github.com/Coffey-Labs/WGX/internal/engine" + "github.com/Coffey-Labs/WGX/internal/server" + "github.com/Coffey-Labs/WGX/internal/store" + "github.com/Coffey-Labs/WGX/internal/wg" +) + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "version", "--version", "-v": + fmt.Println("wgx", engine.Version) + return + case "reset-password": + if err := resetPassword(os.Args[2:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + return + case "serve", "run": + case "help", "--help", "-h": + fmt.Print(usage) + return + default: + fmt.Fprintf(os.Stderr, "unknown command %q\n%s", os.Args[1], usage) + os.Exit(2) + } + } + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "wgx:", err) + os.Exit(1) + } +} + +const usage = `usage: wgx [serve | reset-password | version] + +Configuration is read from WGX_* environment variables; see the README. +` + +func newLogger(cfg *config.Config) *slog.Logger { + var level slog.Level + switch cfg.LogLevel { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + opts := &slog.HandlerOptions{Level: level} + if cfg.LogJSON { + return slog.New(slog.NewJSONHandler(os.Stderr, opts)) + } + return slog.New(slog.NewTextHandler(os.Stderr, opts)) +} + +func openBackend(cfg *config.Config, log *slog.Logger) (wg.Backend, error) { + switch cfg.Backend { + case "mock": + log.Warn("using the mock data plane: no real tunnel will be created") + return wg.NewMock(cfg.Iface, true), nil + case "kernel": + return wg.NewKernel(cfg.Iface, log) + case "userspace": + return wg.NewUserspace(cfg.Iface, log) + } + if wg.KernelAvailable() { + return wg.NewKernel(cfg.Iface, log) + } + log.Warn("the kernel has no WireGuard support; falling back to wireguard-go, which is slower. Load the wireguard module on the host for full speed.") + return wg.NewUserspace(cfg.Iface, log) +} + +func run() error { + cfg, err := config.FromEnv() + if err != nil { + return err + } + log := newLogger(cfg) + log.Info("starting wgx", "version", engine.Version) + + st, err := store.Open(cfg.DBPath) + if err != nil { + return err + } + defer st.Close() + + be, err := openBackend(cfg, log) + if err != nil { + return err + } + eng := engine.New(cfg, st, be, log) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if err := eng.Start(ctx); err != nil { + _ = eng.Stop(context.Background()) + return err + } + srv := server.New(cfg, eng, log) + serveErr := make(chan error, 1) + go func() { serveErr <- srv.ListenAndServe(ctx) }() + + select { + case <-ctx.Done(): + log.Info("shutting down") + case err := <-serveErr: + if err != nil { + log.Error("admin server failed", "error", err) + } + } + stop() + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := eng.Stop(shutdown); err != nil { + log.Warn("shutdown", "error", err) + } + return nil +} + +// resetPassword is the way back in when every administrator is locked out. +// It runs inside the container against the same database. +func resetPassword(args []string) error { + if len(args) != 1 { + return errors.New("usage: wgx reset-password ") + } + cfg, err := config.FromEnv() + if err != nil { + return err + } + st, err := store.Open(cfg.DBPath) + if err != nil { + return err + } + defer st.Close() + ctx := context.Background() + u, err := st.UserByName(ctx, args[0]) + if err != nil { + return fmt.Errorf("no user named %q", args[0]) + } + var pw string + if v := os.Getenv("WGX_NEW_PASSWORD"); v != "" { + pw = v + } else { + fmt.Fprint(os.Stderr, "New password: ") + b, err := term.ReadPassword(int(syscall.Stdin)) + fmt.Fprintln(os.Stderr) + if err != nil { + return err + } + pw = strings.TrimSpace(string(b)) + } + if err := auth.ValidatePassword(pw); err != nil { + return err + } + hash, err := auth.HashPassword(pw) + if err != nil { + return err + } + if err := st.SetPassword(ctx, u.ID, hash); err != nil { + return err + } + if err := st.SetTOTP(ctx, u.ID, "", false); err != nil { + return err + } + _ = st.ReplaceRecoveryCodes(ctx, u.ID, nil) + _ = st.DeleteUserSessions(ctx, u.ID) + _ = st.Audit(ctx, store.AuditEntry{Actor: "cli", Action: "password.reset", Target: u.Username, Detail: "two-factor cleared, sessions dropped"}) + fmt.Fprintf(os.Stderr, "password for %s reset; two-factor cleared and sessions dropped\n", u.Username) + return nil +} diff --git a/docker-compose.host.yml b/docker-compose.host.yml new file mode 100644 index 0000000..a3912e2 --- /dev/null +++ b/docker-compose.host.yml @@ -0,0 +1,37 @@ +# WGX on the host network: the fastest way to run it. +# +# With `network_mode: host` the WireGuard socket sits directly on the host's +# interfaces. There is no port mapping, no conntrack entry per client packet +# and no second NAT hop, which is worth a few percent of throughput and a +# little latency on a busy server. The trade-offs: wg0 is created in the +# host's namespace (you will see it in `ip link` and it is removed on +# shutdown), the NAT rules land in the host's nftables as a table named +# `wgx`, and the admin UI listens on the host directly -- so it is bound to +# localhost below. Put a reverse proxy in front of it or set +# WGX_TLS_SELF_SIGNED to reach it from elsewhere. +services: + wgx: + image: ghcr.io/coffey-labs/wgx:latest + container_name: wgx + restart: unless-stopped + network_mode: host + cap_add: + - NET_ADMIN + environment: + WGX_ENDPOINT: vpn.example.com + WGX_PORT: "51820" + WGX_SUBNET: 10.8.0.0/24 + WGX_DNS: 1.1.1.1, 1.0.0.1 + WGX_HTTP_LISTEN: "127.0.0.1:51821" + # In host mode the forwarding sysctls are the host's own; WGX sets + # them itself since it has NET_ADMIN, but if you prefer to own them + # add `net.ipv4.ip_forward = 1` to /etc/sysctl.d/ and turn this off. + # WGX_MANAGE_SYSCTL: "false" + # Pick the interface to masquerade on if auto-detection picks the + # wrong one (it uses the default route). + # WGX_EGRESS_INTERFACE: eth0 + volumes: + - wgx-data:/data + +volumes: + wgx-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f4a93c1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,55 @@ +# WGX: a WireGuard server with a web admin UI, in one container. +# +# Start it, open http://:51821, create the first administrator, add a +# peer, scan the QR code. The container needs NET_ADMIN to create the tunnel +# interface and its NAT rules, and the sysctls below to forward packets. +# +# For the highest throughput see docs/performance.md: it explains when to use +# docker-compose.host.yml (host networking) and which host sysctls matter. +services: + wgx: + image: ghcr.io/coffey-labs/wgx:latest + container_name: wgx + restart: unless-stopped + cap_add: + - NET_ADMIN + # Only needed if the host has not loaded the wireguard module yet and + # you want the container to load it. Usually unnecessary on any kernel + # from 5.6 on: the module loads itself when the interface is created. + # - SYS_MODULE + sysctls: + - net.ipv4.ip_forward=1 + - net.ipv4.conf.all.src_valid_mark=1 + # Loose reverse-path filtering; strict drops replies arriving on the + # tunnel. WGX would set this itself but /proc/sys is read-only in a + # container, so it has to come from here. + - net.ipv4.conf.all.rp_filter=2 + - net.ipv4.conf.default.rp_filter=2 + # Uncomment with WGX_SUBNET6 for IPv6 inside the tunnel. + # - net.ipv6.conf.all.forwarding=1 + # - net.ipv6.conf.all.disable_ipv6=0 + environment: + # The public hostname or IP clients connect to. Asked for at setup too. + WGX_ENDPOINT: vpn.example.com + # UDP port WireGuard listens on; must match the port mapping. + WGX_PORT: "51820" + # Tunnel network. The server takes the first address. + WGX_SUBNET: 10.8.0.0/24 + # WGX_SUBNET6: fd42:42:42::/64 + # DNS handed to clients by default. + WGX_DNS: 1.1.1.1, 1.0.0.1 + # Admin UI. Put a TLS-terminating proxy in front of it, or enable the + # built-in self-signed certificate, before exposing it anywhere but + # localhost or your LAN. + WGX_HTTP_LISTEN: ":51821" + # WGX_TLS_SELF_SIGNED: "true" + # WGX_TRUSTED_PROXIES: 172.16.0.0/12 + # WGX_METRICS_TOKEN: change-me + ports: + - "51820:51820/udp" + - "127.0.0.1:51821:51821/tcp" + volumes: + - wgx-data:/data + +volumes: + wgx-data: diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..c31b959 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,110 @@ +# Performance + +WGX is built so the packet path is as short as it can be. This page explains +what it does on its own, what only the host can do, and how to check the +result. + +## What decides throughput + +In rough order of importance: + +1. **Kernel data plane.** WireGuard in the kernel handles encryption in the + network stack with no copies to user space. WGX creates a native + `wireguard` link over netlink and configures it over the same channel; + nothing sits between the NIC and the module. On a host without the module + WGX falls back to `wireguard-go`, which works everywhere but moves every + packet through a user-space process and is several times slower. The + dashboard says which one is in use. Any Linux kernel from 5.6 has the + module; on older kernels install `wireguard-dkms` on the host. +2. **MTU and MSS.** A tunnel packet has 60 bytes of overhead on IPv4 (80 on + IPv6). The default MTU of 1420 fits a 1500-byte underlay. If the path to + the server is smaller than that (PPPoE, a second tunnel, some mobile + networks) packets fragment or vanish and downloads crawl. WGX clamps the + TCP MSS of forwarded connections to the route MTU, which removes the + "connected but pages hang" failure outright; lower the MTU in Settings if + UDP-heavy traffic still struggles. +3. **Socket buffers.** Bursts on a fast link fill the default UDP receive + buffer before WireGuard drains it, and the kernel drops the excess. These + are host-wide sysctls, so see below. +4. **Port mapping.** In the default bridged mode Docker DNATs the UDP port, + which costs a conntrack lookup per packet. `docker-compose.host.yml` puts + the socket on the host network and skips it. +5. **The CPU.** WireGuard is ChaCha20-Poly1305 and scales across cores per + peer; a single flow is bounded by one core. Machines with AVX2 or ARMv8 + crypto extensions do markedly better. + +## What WGX sets by itself + +At startup WGX writes these through `/proc/sys` and reports the outcome on +the dashboard under **Show kernel tuning**: + +| sysctl | value | why | +| --- | --- | --- | +| `net.ipv4.ip_forward` | 1 | required; peers go nowhere without it | +| `net.ipv6.conf.all.forwarding` | 1 | required when `WGX_SUBNET6` is set | +| `net.ipv4.conf.all.rp_filter`, `...default.rp_filter` | 2 | strict reverse-path filtering drops legitimate tunnel replies | +| `net.core.rmem_max`, `net.core.wmem_max` | 26214400 | room for bursts on the UDP socket | +| `net.core.rmem_default`, `net.core.wmem_default` | 1048576 | default socket buffers | +| `net.core.netdev_max_backlog` | 16384 | deeper per-CPU input queue | +| `net.ipv4.udp_rmem_min`, `net.ipv4.udp_wmem_min` | 16384 | UDP buffers under memory pressure | + +The first three groups are network-namespaced and work inside the container +when the compose file passes them under `sysctls:` (forwarding) or the +container has NET_ADMIN (rp_filter). The `net.core.*` and `udp_*` ones are +**global**: the kernel refuses them from inside a container, WGX logs a +warning, and they show as "not set" on the dashboard. That is expected; set +them on the host. + +## Host settings + +Drop this into `/etc/sysctl.d/99-wgx.conf` on the Docker host and run +`sysctl --system`: + +``` +# Socket buffers: let a 1-10 GbE burst queue rather than drop. +net.core.rmem_max = 26214400 +net.core.wmem_max = 26214400 +net.core.rmem_default = 1048576 +net.core.wmem_default = 1048576 +net.core.netdev_max_backlog = 16384 +net.ipv4.udp_rmem_min = 16384 +net.ipv4.udp_wmem_min = 16384 + +# Fair queueing and BBR help the *host's own* TCP flows; forwarded peer +# traffic keeps the peers' congestion control. Harmless, often helpful. +net.core.default_qdisc = fq +net.ipv4.tcp_congestion_control = bbr + +# Forwarding, in case you run the host-network compose file and want to own +# it yourself (WGX sets it otherwise). +net.ipv4.ip_forward = 1 +``` + +Two more things on the host that are worth checking on a busy server: + +- **UDP GRO/GSO offload** on the physical NIC lets the kernel batch + WireGuard's UDP segments. It is on by default on modern drivers; `ethtool + -k eth0 | grep -i udp` shows the state. +- **IRQ affinity / multi-queue.** WireGuard spreads decryption across all + CPUs, but the NIC's receive queues need to be spread too. `irqbalance` on + most distributions does it; on single-queue virtual NICs there is nothing + to gain. + +## Host networking + +`docker-compose.host.yml` runs WGX with `network_mode: host`. It removes the +Docker port mapping from the path and lets WGX set the host's own +forwarding sysctls. The costs are listed at the top of that file; in short, +`wg0` and the `wgx` nftables table become visible on the host, and the admin +UI is bound to localhost so it is not exposed by accident. + +## Measuring + +Run `iperf3 -s` on a machine behind the server (or on the server itself) +and `iperf3 -c -P 4` from a peer, then compare it with the +same test outside the tunnel. On a wired gigabit link a modern x86 host with +the kernel module should get within a few percent of line rate; with +`wireguard-go` expect a few hundred Mbit/s and a busy core. + +The dashboard's live rates come from the interface counters every two +seconds, so they show what the tunnel actually carries during the test. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..36c9a98 --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/Coffey-Labs/WGX + +go 1.27.1 + +require ( + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + github.com/vishvananda/netlink v1.3.1 + golang.org/x/crypto v0.57.0 + golang.org/x/term v0.46.0 + golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 + modernc.org/sqlite v1.58.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/mdlayher/genetlink v1.3.2 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/vishvananda/netns v0.0.5 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect + modernc.org/libc v1.75.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.12.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2409392 --- /dev/null +++ b/go.sum @@ -0,0 +1,80 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= +github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= +github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= +golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU= +golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ= +modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8= +modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w= +modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc= +modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= +modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g= +modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0= +modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..db5dedb --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,277 @@ +// Package auth holds the primitives behind the admin login: argon2id password +// hashing, opaque session tokens, RFC 6238 one-time passwords, recovery codes +// and a login rate limiter. None of it knows about HTTP. +package auth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "golang.org/x/crypto/argon2" +) + +// Argon2id parameters: 64 MiB, 3 passes, 4 lanes. Roughly 100 ms on a +// modest server, which is the point. +const ( + argonTime = 3 + argonMemory = 64 * 1024 + argonThreads = 4 + argonKeyLen = 32 +) + +// MinPasswordLength is the shortest password accepted. +const MinPasswordLength = 12 + +// ValidatePassword enforces the password policy: length only. Composition +// rules produce worse passwords, not better ones. +func ValidatePassword(pw string) error { + if utf8.RuneCountInString(pw) < MinPasswordLength { + return fmt.Errorf("password must be at least %d characters", MinPasswordLength) + } + if len(pw) > 1024 { + return errors.New("password is too long") + } + return nil +} + +// HashPassword returns a PHC-format argon2id string. +func HashPassword(pw string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifyPassword checks a password against a hash from HashPassword. +func VerifyPassword(hash, pw string) bool { + parts := strings.Split(hash, "$") + if len(parts) != 6 || parts[1] != "argon2id" { + return false + } + var m, t uint32 + var p uint8 + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { + return false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return false + } + want, err := base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil { + return false + } + got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want))) + return subtle.ConstantTimeCompare(got, want) == 1 +} + +// dummyHash is verified against when the user does not exist, so a login +// for an unknown name takes as long as one for a known name. +var dummyHash, _ = HashPassword("wgx-timing-equaliser-password") + +// EqualiseTiming burns the cost of one hash verification. +func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") } + +// NewToken returns a random URL-safe token and its storage hash. +func NewToken() (token, hash string, err error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", "", err + } + token = base64.RawURLEncoding.EncodeToString(b) + return token, HashToken(token), nil +} + +// HashToken is how tokens are stored: a leaked database is not a leaked login. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// NewID returns a short random identifier for peers. +func NewID() (string, error) { + b := make([]byte, 10) + if _, err := rand.Read(b); err != nil { + return "", err + } + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil +} + +// --- TOTP ----------------------------------------------------------------- + +// NewTOTPSecret returns a base32 secret for an authenticator app. +func NewTOTPSecret() (string, error) { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil +} + +// TOTPURI builds the otpauth:// URI an authenticator app scans. +func TOTPURI(issuer, account, secret string) string { + v := url.Values{} + v.Set("secret", secret) + v.Set("issuer", issuer) + v.Set("algorithm", "SHA1") + v.Set("digits", "6") + v.Set("period", "30") + return "otpauth://totp/" + url.PathEscape(issuer+":"+account) + "?" + v.Encode() +} + +func totpCode(secret string, counter uint64) (string, error) { + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(strings.ReplaceAll(secret, " ", ""))) + if err != nil { + return "", errors.New("bad secret") + } + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], counter) + mac := hmac.New(sha1.New, key) + mac.Write(msg[:]) + sum := mac.Sum(nil) + off := sum[len(sum)-1] & 0x0f + code := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1_000_000 + return fmt.Sprintf("%06d", code), nil +} + +// TOTPNow returns the current code, for the tests and the setup flow. +func TOTPNow(secret string, at time.Time) (string, error) { + return totpCode(secret, uint64(at.Unix()/30)) +} + +// VerifyTOTP accepts the current code and one step either side. +func VerifyTOTP(secret, code string, at time.Time) bool { + code = strings.TrimSpace(code) + if len(code) != 6 { + return false + } + if _, err := strconv.Atoi(code); err != nil { + return false + } + counter := uint64(at.Unix() / 30) + ok := false + for _, c := range []uint64{counter - 1, counter, counter + 1} { + want, err := totpCode(secret, c) + if err != nil { + return false + } + if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 { + ok = true + } + } + return ok +} + +// NewRecoveryCodes returns n codes in the form xxxx-xxxx-xxxx and their hashes. +func NewRecoveryCodes(n int) (codes, hashes []string, err error) { + const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" + for i := 0; i < n; i++ { + b := make([]byte, 12) + if _, err := rand.Read(b); err != nil { + return nil, nil, err + } + var sb strings.Builder + for j, x := range b { + if j > 0 && j%4 == 0 { + sb.WriteByte('-') + } + sb.WriteByte(alphabet[int(x)%len(alphabet)]) + } + codes = append(codes, sb.String()) + hashes = append(hashes, HashToken(NormaliseRecoveryCode(sb.String()))) + } + return codes, hashes, nil +} + +// NormaliseRecoveryCode strips separators and case so typed codes match. +func NormaliseRecoveryCode(c string) string { + return strings.ToLower(strings.NewReplacer("-", "", " ", "").Replace(c)) +} + +// --- Rate limiting -------------------------------------------------------- + +// Limiter is a fixed-window failure counter keyed by string (an IP, a +// username). After max failures in the window the key is locked out until +// the window passes. +type Limiter struct { + mu sync.Mutex + max int + window time.Duration + hits map[string]*bucket +} + +type bucket struct { + count int + start time.Time +} + +// NewLimiter allows max failures per window. +func NewLimiter(max int, window time.Duration) *Limiter { + return &Limiter{max: max, window: window, hits: map[string]*bucket{}} +} + +// Allowed reports whether the key may attempt again and how long to wait. +func (l *Limiter) Allowed(key string) (bool, time.Duration) { + l.mu.Lock() + defer l.mu.Unlock() + b, ok := l.hits[key] + if !ok { + return true, 0 + } + if time.Since(b.start) > l.window { + delete(l.hits, key) + return true, 0 + } + if b.count >= l.max { + return false, l.window - time.Since(b.start) + } + return true, 0 +} + +// Fail records a failure. +func (l *Limiter) Fail(key string) { + l.mu.Lock() + defer l.mu.Unlock() + b, ok := l.hits[key] + if !ok || time.Since(b.start) > l.window { + l.hits[key] = &bucket{count: 1, start: time.Now()} + return + } + b.count++ +} + +// Reset clears a key after success. +func (l *Limiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.hits, key) +} + +// Sweep drops stale keys; call it now and then. +func (l *Limiter) Sweep() { + l.mu.Lock() + defer l.mu.Unlock() + for k, b := range l.hits { + if time.Since(b.start) > l.window { + delete(l.hits, k) + } + } +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..916f54f --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,106 @@ +package auth + +import ( + "testing" + "time" +) + +func TestPasswordRoundTrip(t *testing.T) { + h, err := HashPassword("correct horse battery staple") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(h, "correct horse battery staple") { + t.Fatal("right password rejected") + } + if VerifyPassword(h, "correct horse battery stapl") { + t.Fatal("wrong password accepted") + } + if VerifyPassword("garbage", "x") { + t.Fatal("garbage hash accepted") + } +} + +func TestValidatePassword(t *testing.T) { + if err := ValidatePassword("short"); err == nil { + t.Fatal("short password accepted") + } + if err := ValidatePassword("twelve chars"); err != nil { + t.Fatalf("12-character password rejected: %v", err) + } +} + +func TestTOTP(t *testing.T) { + // RFC 6238 test vector: secret "12345678901234567890" (base32 + // GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ), time 59 -> 287082 with SHA1. + secret := "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + code, err := TOTPNow(secret, time.Unix(59, 0)) + if err != nil { + t.Fatal(err) + } + if code != "287082" { + t.Fatalf("got %s, want 287082", code) + } + if !VerifyTOTP(secret, "287082", time.Unix(59, 0)) { + t.Fatal("valid code rejected") + } + // One step later still accepted (window of one either side). + if !VerifyTOTP(secret, "287082", time.Unix(59+30, 0)) { + t.Fatal("previous-step code rejected") + } + if VerifyTOTP(secret, "287082", time.Unix(59+120, 0)) { + t.Fatal("stale code accepted") + } + if VerifyTOTP(secret, "28708", time.Unix(59, 0)) { + t.Fatal("short code accepted") + } +} + +func TestRecoveryCodes(t *testing.T) { + codes, hashes, err := NewRecoveryCodes(8) + if err != nil { + t.Fatal(err) + } + if len(codes) != 8 || len(hashes) != 8 { + t.Fatal("wrong count") + } + if HashToken(NormaliseRecoveryCode(" "+codes[0]+" ")) != hashes[0] { + t.Fatal("normalised code does not hash to stored value") + } + if len(codes[0]) != 14 { + t.Fatalf("unexpected format %q", codes[0]) + } +} + +func TestLimiter(t *testing.T) { + l := NewLimiter(2, time.Minute) + if ok, _ := l.Allowed("a"); !ok { + t.Fatal("fresh key blocked") + } + l.Fail("a") + l.Fail("a") + if ok, wait := l.Allowed("a"); ok || wait <= 0 { + t.Fatal("key not blocked after max failures") + } + if ok, _ := l.Allowed("b"); !ok { + t.Fatal("unrelated key blocked") + } + l.Reset("a") + if ok, _ := l.Allowed("a"); !ok { + t.Fatal("reset key still blocked") + } +} + +func TestTokens(t *testing.T) { + tok, hash, err := NewToken() + if err != nil { + t.Fatal(err) + } + if HashToken(tok) != hash { + t.Fatal("hash mismatch") + } + id, err := NewID() + if err != nil || len(id) != 16 { + t.Fatalf("bad id %q %v", id, err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..de1313e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,202 @@ +// Package config reads the environment. Everything here is infrastructure +// that has to be known before the database opens; anything an administrator +// might change while the server runs lives in the database instead (see +// engine.Settings). +package config + +import ( + "errors" + "fmt" + "net/netip" + "os" + "strconv" + "strings" + "time" +) + +// Config is the process configuration. +type Config struct { + DataDir string + DBPath string + Backend string // auto | kernel | userspace | mock + Iface string + // Listen is the UDP port WireGuard listens on. + ListenPort int + // Subnet4 / Subnet6 are the tunnel networks. The server takes the first + // usable address of each. + Subnet4 netip.Prefix + Subnet6 netip.Prefix // may be invalid (unset) + // Egress is the interface to masquerade on; empty means auto-detect. + Egress string + // HTTP is the admin listener address. + HTTP string + // TLSCert/TLSKey enable HTTPS from files; TLSSelfSigned generates and + // persists a certificate in the data directory. + TLSCert, TLSKey string + TLSSelfSigned bool + // SecureCookies forces the Secure flag on when TLS terminates elsewhere. + SecureCookies bool + // TrustedProxies are CIDRs whose X-Forwarded-For / X-Real-IP is believed. + TrustedProxies []netip.Prefix + // MetricsToken protects /metrics; empty disables the endpoint. + MetricsToken string + // SessionIdle / SessionMax bound admin sessions. + SessionIdle time.Duration + SessionMax time.Duration + // TrafficRetention bounds the usage history. + TrafficRetention time.Duration + // PollInterval is how often the data plane is read. + PollInterval time.Duration + // LogLevel is debug, info, warn or error. + LogLevel string + // LogJSON switches the log format. + LogJSON bool + // Initial* seed the settings on first run only. + InitialEndpoint string + InitialDNS string + // ManageFirewall may be turned off when the host owns the NAT rules. + ManageFirewall bool + // ManageSysctl may be turned off when the host has already tuned itself. + ManageSysctl bool +} + +func env(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return strings.TrimSpace(v) + } + return def +} + +func envInt(key string, def int) (int, error) { + v := env(key, "") + if v == "" { + return def, nil + } + n, err := strconv.Atoi(v) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a number", key, v) + } + return n, nil +} + +func envBool(key string, def bool) (bool, error) { + v := strings.ToLower(env(key, "")) + switch v { + case "": + return def, nil + case "1", "true", "yes", "on": + return true, nil + case "0", "false", "no", "off": + return false, nil + } + return false, fmt.Errorf("%s: %q is not a boolean", key, v) +} + +func envDuration(key string, def time.Duration) (time.Duration, error) { + v := env(key, "") + if v == "" { + return def, nil + } + d, err := time.ParseDuration(v) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a duration (try 12h, 30m)", key, v) + } + return d, nil +} + +// FromEnv builds the configuration from WGX_* variables. +func FromEnv() (*Config, error) { + var errs []error + c := &Config{} + c.DataDir = env("WGX_DATA_DIR", "/data") + c.DBPath = env("WGX_DB", c.DataDir+"/wgx.db") + c.Backend = strings.ToLower(env("WGX_BACKEND", "auto")) + switch c.Backend { + case "auto", "kernel", "userspace", "mock": + default: + errs = append(errs, fmt.Errorf("WGX_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend)) + } + c.Iface = env("WGX_INTERFACE", "wg0") + if len(c.Iface) == 0 || len(c.Iface) > 15 || strings.ContainsAny(c.Iface, " /\t\n") { + errs = append(errs, errors.New("WGX_INTERFACE: must be 1-15 characters with no spaces or slashes")) + } + var err error + if c.ListenPort, err = envInt("WGX_PORT", 51820); err != nil { + errs = append(errs, err) + } else if c.ListenPort < 1 || c.ListenPort > 65535 { + errs = append(errs, errors.New("WGX_PORT: must be 1-65535")) + } + if c.Subnet4, err = netip.ParsePrefix(env("WGX_SUBNET", "10.8.0.0/24")); err != nil || !c.Subnet4.Addr().Is4() { + errs = append(errs, errors.New("WGX_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24")) + } else if c.Subnet4.Bits() > 30 { + errs = append(errs, errors.New("WGX_SUBNET: needs room for at least two hosts (/30 or larger)")) + } + if v := env("WGX_SUBNET6", ""); v != "" { + if c.Subnet6, err = netip.ParsePrefix(v); err != nil || !c.Subnet6.Addr().Is6() { + errs = append(errs, errors.New("WGX_SUBNET6: must be an IPv6 CIDR such as fd42:42:42::/64")) + } + } + c.Egress = env("WGX_EGRESS_INTERFACE", "") + c.HTTP = env("WGX_HTTP_LISTEN", ":51821") + c.TLSCert = env("WGX_TLS_CERT", "") + c.TLSKey = env("WGX_TLS_KEY", "") + if (c.TLSCert == "") != (c.TLSKey == "") { + errs = append(errs, errors.New("WGX_TLS_CERT and WGX_TLS_KEY must be set together")) + } + if c.TLSSelfSigned, err = envBool("WGX_TLS_SELF_SIGNED", false); err != nil { + errs = append(errs, err) + } + if c.SecureCookies, err = envBool("WGX_SECURE_COOKIES", false); err != nil { + errs = append(errs, err) + } + for _, p := range strings.Split(env("WGX_TRUSTED_PROXIES", ""), ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + pfx, err := netip.ParsePrefix(p) + if err != nil { + if a, err2 := netip.ParseAddr(p); err2 == nil { + pfx = netip.PrefixFrom(a, a.BitLen()) + } else { + errs = append(errs, fmt.Errorf("WGX_TRUSTED_PROXIES: %q is not an address or CIDR", p)) + continue + } + } + c.TrustedProxies = append(c.TrustedProxies, pfx) + } + c.MetricsToken = env("WGX_METRICS_TOKEN", "") + if c.SessionIdle, err = envDuration("WGX_SESSION_IDLE", 12*time.Hour); err != nil { + errs = append(errs, err) + } + if c.SessionMax, err = envDuration("WGX_SESSION_MAX", 7*24*time.Hour); err != nil { + errs = append(errs, err) + } + if c.TrafficRetention, err = envDuration("WGX_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil { + errs = append(errs, err) + } + if c.PollInterval, err = envDuration("WGX_POLL_INTERVAL", 2*time.Second); err != nil { + errs = append(errs, err) + } else if c.PollInterval < 500*time.Millisecond { + errs = append(errs, errors.New("WGX_POLL_INTERVAL: must be at least 500ms")) + } + c.LogLevel = strings.ToLower(env("WGX_LOG_LEVEL", "info")) + if c.LogJSON, err = envBool("WGX_LOG_JSON", false); err != nil { + errs = append(errs, err) + } + c.InitialEndpoint = env("WGX_ENDPOINT", "") + c.InitialDNS = env("WGX_DNS", "1.1.1.1, 1.0.0.1") + if c.ManageFirewall, err = envBool("WGX_MANAGE_FIREWALL", true); err != nil { + errs = append(errs, err) + } + if c.ManageSysctl, err = envBool("WGX_MANAGE_SYSCTL", true); err != nil { + errs = append(errs, err) + } + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + return c, nil +} + +// TLSEnabled reports whether the admin listener speaks HTTPS itself. +func (c *Config) TLSEnabled() bool { return c.TLSCert != "" || c.TLSSelfSigned } diff --git a/internal/engine/alloc.go b/internal/engine/alloc.go new file mode 100644 index 0000000..70e2f36 --- /dev/null +++ b/internal/engine/alloc.go @@ -0,0 +1,56 @@ +package engine + +import ( + "errors" + "fmt" + "net/netip" +) + +// allocate returns the lowest free host address in the subnet, skipping the +// network address, the server's own (first host) and, for IPv4, the +// broadcast address. `used` holds addresses already handed out. +func allocate(subnet netip.Prefix, used map[string]bool) (netip.Addr, error) { + subnet = subnet.Masked() + base := subnet.Addr() + server := base.Next() + var last netip.Addr + if base.Is4() { + // Broadcast: all host bits set. + a := base.As4() + hostBits := 32 - subnet.Bits() + var n uint32 = uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3]) + n |= (1 << hostBits) - 1 + last = netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}) + } + // Cap the scan: a /64 is not walked to the end, and nobody has 65k peers. + const maxScan = 65536 + addr := server.Next() + for i := 0; i < maxScan && subnet.Contains(addr); i++ { + if base.Is4() && addr == last { + break + } + if !used[addr.String()] { + return addr, nil + } + addr = addr.Next() + } + return netip.Addr{}, errors.New("no free addresses left in " + subnet.String()) +} + +// checkAddress validates an operator-chosen address for a peer. +func checkAddress(subnet netip.Prefix, s string, used map[string]bool) (netip.Addr, error) { + a, err := netip.ParseAddr(s) + if err != nil { + return netip.Addr{}, fmt.Errorf("%q is not an IP address", s) + } + if !subnet.Contains(a) { + return netip.Addr{}, fmt.Errorf("%s is outside %s", a, subnet.Masked()) + } + if a == subnet.Masked().Addr() || a == subnet.Masked().Addr().Next() { + return netip.Addr{}, fmt.Errorf("%s is reserved for the server", a) + } + if used[a.String()] { + return netip.Addr{}, fmt.Errorf("%s is already assigned", a) + } + return a, nil +} diff --git a/internal/engine/collector.go b/internal/engine/collector.go new file mode 100644 index 0000000..b6c395c --- /dev/null +++ b/internal/engine/collector.go @@ -0,0 +1,277 @@ +package engine + +import ( + "context" + "sync" + "time" + + "github.com/Coffey-Labs/WGX/internal/store" +) + +// Live is what the data plane currently says about one peer, merged with +// the totals persisted across restarts. +type Live struct { + PeerID string `json:"id"` + Connected bool `json:"connected"` + Endpoint string `json:"endpoint,omitempty"` + LastHandshake time.Time `json:"lastHandshake,omitempty"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` + RxRate float64 `json:"rxRate"` + TxRate float64 `json:"txRate"` + ConnectedSince time.Time `json:"connectedSince,omitempty"` +} + +// Totals summarises the whole interface. +type Totals struct { + Peers int `json:"peers"` + Active int `json:"active"` + Connected int `json:"connected"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` + RxRate float64 `json:"rxRate"` + TxRate float64 `json:"txRate"` +} + +// Snapshot is the payload pushed to dashboards on every poll. +type Snapshot struct { + At time.Time `json:"at"` + Totals Totals `json:"totals"` + Peers map[string]Live `json:"peers"` +} + +type counterMemo struct { + rx, tx int64 // raw device counters at the last poll + at time.Time +} + +// collector polls the backend, turns raw counters into deltas and rates, +// and batches what needs writing. +type collector struct { + e *Engine + mu sync.Mutex + live map[string]*Live // by peer id + memo map[string]counterMemo // by public key + // base is the persisted total per peer at the moment it was loaded, so + // that live totals = base + everything seen since. + pendingBuckets map[string]map[int64]*[2]int64 // peer id -> bucket -> [rx, tx] + dirty map[string]bool + last Snapshot +} + +func newCollector(e *Engine) *collector { + return &collector{e: e, live: map[string]*Live{}, memo: map[string]counterMemo{}, pendingBuckets: map[string]map[int64]*[2]int64{}, dirty: map[string]bool{}} +} + +// Snapshot returns the last computed snapshot. +func (c *collector) Snapshot() Snapshot { + c.mu.Lock() + defer c.mu.Unlock() + return c.last +} + +// LiveFor returns a copy of a peer's live state, if any. +func (c *collector) LiveFor(id string) (Live, bool) { + c.mu.Lock() + defer c.mu.Unlock() + l, ok := c.live[id] + if !ok { + return Live{}, false + } + return *l, true +} + +func (c *collector) run(ctx context.Context) { + t := time.NewTicker(c.e.cfg.PollInterval) + defer t.Stop() + c.poll(ctx) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.poll(ctx) + } + } +} + +// forget drops a peer's live state after it is deleted. +func (c *collector) forget(id, pubKey string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.live, id) + delete(c.memo, pubKey) + delete(c.pendingBuckets, id) + delete(c.dirty, id) +} + +// rekey moves the memo when a peer's key changes so the next poll does not +// count the new key's zeroed counters as a reset. +func (c *collector) rekey(oldKey string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.memo, oldKey) +} + +func (c *collector) poll(ctx context.Context) { + dev, err := c.e.be.Device(ctx) + if err != nil { + c.e.log.Warn("poll", "error", err) + return + } + now := time.Now() + window := time.Duration(c.e.Settings().ConnectedWindow) * time.Second + + c.e.mu.RLock() + peers := make([]*store.Peer, 0, len(c.e.peers)) + for _, p := range c.e.peers { + peers = append(peers, p) + } + c.e.mu.RUnlock() + + seen := map[string]struct{}{} + for _, ps := range dev.Peers { + seen[ps.PublicKey.String()] = struct{}{} + } + + c.mu.Lock() + defer c.mu.Unlock() + snap := Snapshot{At: now, Peers: make(map[string]Live, len(peers))} + snap.Totals.Peers = len(peers) + byKey := map[string]int{} + for i, ps := range dev.Peers { + byKey[ps.PublicKey.String()] = i + } + for _, p := range peers { + l, ok := c.live[p.ID] + if !ok { + l = &Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint} + c.live[p.ID] = l + } + l.RxRate, l.TxRate = 0, 0 + if active(p, now) { + snap.Totals.Active++ + } + if i, ok := byKey[p.PublicKey]; ok { + ps := dev.Peers[i] + m, had := c.memo[p.PublicKey] + var drx, dtx int64 + if had { + drx, dtx = ps.ReceiveBytes-m.rx, ps.TransmitBytes-m.tx + // A counter smaller than last time means the peer was + // removed and re-added: the new value is all new traffic. + if drx < 0 { + drx = ps.ReceiveBytes + } + if dtx < 0 { + dtx = ps.TransmitBytes + } + dt := now.Sub(m.at).Seconds() + if dt > 0 { + l.RxRate = float64(drx) / dt + l.TxRate = float64(dtx) / dt + } + } else { + // First sight of this key since start: whatever the device + // already counted happened before we were watching, unless + // the peer was just created, in which case it is zero anyway. + // Either way it must not be added to the persisted total, so + // only the memo is set. + drx, dtx = 0, 0 + } + c.memo[p.PublicKey] = counterMemo{rx: ps.ReceiveBytes, tx: ps.TransmitBytes, at: now} + if drx > 0 || dtx > 0 { + l.Rx += drx + l.Tx += dtx + c.dirty[p.ID] = true + bucket := now.Truncate(bucketSize).Unix() + pb, ok := c.pendingBuckets[p.ID] + if !ok { + pb = map[int64]*[2]int64{} + c.pendingBuckets[p.ID] = pb + } + b, ok := pb[bucket] + if !ok { + b = &[2]int64{} + pb[bucket] = b + } + b[0] += drx + b[1] += dtx + } + if !ps.LastHandshake.IsZero() && ps.LastHandshake.After(l.LastHandshake) { + l.LastHandshake = ps.LastHandshake + c.dirty[p.ID] = true + } + if ps.Endpoint != nil { + ep := ps.Endpoint.String() + if ep != l.Endpoint { + l.Endpoint = ep + c.dirty[p.ID] = true + } + } + // Connected means the *interface* has a recent handshake, not + // the remembered one: after a restart, or after a peer is + // disabled and re-enabled, everyone is disconnected until the + // client handshakes again, which is the truth. + connected := !ps.LastHandshake.IsZero() && now.Sub(ps.LastHandshake) < window + if connected && !l.Connected { + l.ConnectedSince = l.LastHandshake + } + if !connected { + l.ConnectedSince = time.Time{} + } + l.Connected = connected + } else { + // Not on the interface: disabled, expired or removed by hand. + l.Connected = false + l.ConnectedSince = time.Time{} + delete(c.memo, p.PublicKey) + } + if l.Connected { + snap.Totals.Connected++ + } + snap.Totals.Rx += l.Rx + snap.Totals.Tx += l.Tx + snap.Totals.RxRate += l.RxRate + snap.Totals.TxRate += l.TxRate + snap.Peers[p.ID] = *l + } + c.last = snap + c.e.hub.Publish("status", snap) +} + +// flush writes dirty totals and pending buckets to the database. +func (c *collector) flush(ctx context.Context) error { + c.mu.Lock() + var counters []store.PeerCounters + var samples []store.TrafficSample + for id := range c.dirty { + l := c.live[id] + if l == nil { + continue + } + counters = append(counters, store.PeerCounters{ID: id, RxTotal: l.Rx, TxTotal: l.Tx, LastHandshake: l.LastHandshake, LastEndpoint: l.Endpoint}) + } + for id, pb := range c.pendingBuckets { + for bucket, v := range pb { + samples = append(samples, store.TrafficSample{PeerID: id, Bucket: time.Unix(bucket, 0), Rx: v[0], Tx: v[1]}) + } + } + c.dirty = map[string]bool{} + c.pendingBuckets = map[string]map[int64]*[2]int64{} + c.mu.Unlock() + if err := c.e.st.FlushCounters(ctx, counters, samples); err != nil { + return err + } + // Keep the in-memory peer records' totals current so a later UpdatePeer + // does not carry stale numbers around (they are not written by it, but + // the API reads them). + c.e.mu.Lock() + for _, k := range counters { + if p, ok := c.e.peers[k.ID]; ok { + p.RxTotal, p.TxTotal, p.LastHandshake, p.LastEndpoint = k.RxTotal, k.TxTotal, k.LastHandshake, k.LastEndpoint + } + } + c.e.mu.Unlock() + return nil +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..09fbe2d --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,421 @@ +// Package engine ties the pieces together: it owns the server key, brings the +// interface up, keeps the data plane in step with the database, reads +// counters, and answers the questions the API asks. +package engine + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/netip" + "sync" + "time" + + "github.com/Coffey-Labs/WGX/internal/config" + "github.com/Coffey-Labs/WGX/internal/netcfg" + "github.com/Coffey-Labs/WGX/internal/store" + "github.com/Coffey-Labs/WGX/internal/wg" +) + +const ( + serverKeySetting = "server_private_key" + bucketSize = 5 * time.Minute + flushEvery = 30 * time.Second + houseEvery = 30 * time.Second +) + +// Engine is the long-running core. +type Engine struct { + cfg *config.Config + st *store.Store + be wg.Backend + log *slog.Logger + + mu sync.RWMutex + settings Settings + serverKey wg.Key + startedAt time.Time + sysctls []netcfg.Result + egress string + fwErr string + peers map[string]*store.Peer // by id + byKey map[string]*store.Peer // by public key + + col *collector + hub *Hub + + cancel context.CancelFunc + wg sync.WaitGroup +} + +// New wires an engine up without starting anything. +func New(cfg *config.Config, st *store.Store, be wg.Backend, log *slog.Logger) *Engine { + e := &Engine{cfg: cfg, st: st, be: be, log: log, peers: map[string]*store.Peer{}, byKey: map[string]*store.Peer{}, hub: NewHub()} + e.col = newCollector(e) + return e +} + +// Store exposes the database to the HTTP layer for users, sessions and audit. +func (e *Engine) Store() *store.Store { return e.st } + +// Config exposes the process configuration. +func (e *Engine) Config() *config.Config { return e.cfg } + +// Hub is the live-update fan-out. +func (e *Engine) Hub() *Hub { return e.hub } + +// Backend names the data plane in use. +func (e *Engine) Backend() string { return e.be.Kind() } + +// ServerPublicKey is what clients put in their [Peer] section. +func (e *Engine) ServerPublicKey() string { + e.mu.RLock() + defer e.mu.RUnlock() + return e.serverKey.PublicKey().String() +} + +// Settings returns a copy of the current settings. +func (e *Engine) Settings() Settings { + e.mu.RLock() + defer e.mu.RUnlock() + return e.settings +} + +// ServerAddresses are the interface's own tunnel addresses. +func (e *Engine) ServerAddresses() []netip.Prefix { + var out []netip.Prefix + out = append(out, netip.PrefixFrom(e.cfg.Subnet4.Masked().Addr().Next(), e.cfg.Subnet4.Bits())) + if e.cfg.Subnet6.IsValid() { + out = append(out, netip.PrefixFrom(e.cfg.Subnet6.Masked().Addr().Next(), e.cfg.Subnet6.Bits())) + } + return out +} + +func (e *Engine) tunnelSubnets() []netip.Prefix { + out := []netip.Prefix{e.cfg.Subnet4.Masked()} + if e.cfg.Subnet6.IsValid() { + out = append(out, e.cfg.Subnet6.Masked()) + } + return out +} + +// Start loads state, brings the interface up and starts the background +// loops. It is safe to call Stop after a failed Start. +func (e *Engine) Start(ctx context.Context) error { + if err := e.loadSettings(ctx); err != nil { + return err + } + if err := e.loadServerKey(ctx); err != nil { + return err + } + if err := e.loadPeers(ctx); err != nil { + return err + } + if e.cfg.ManageSysctl && e.be.Kind() != "mock" { + results, err := netcfg.ApplyAll(netcfg.Wanted(e.cfg.Subnet6.IsValid())) + e.mu.Lock() + e.sysctls = results + e.mu.Unlock() + for _, r := range results { + if !r.Applied { + e.log.Warn("sysctl not applied", "key", r.Key, "wanted", r.Value, "current", r.Current, "error", r.Err, "why", r.Why) + } + } + if err != nil { + return err + } + } + settings := e.Settings() + dev := wg.DeviceConfig{PrivateKey: e.serverKey, ListenPort: e.cfg.ListenPort} + if err := e.be.Up(ctx, dev, e.ServerAddresses(), settings.MTU); err != nil { + return err + } + e.log.Info("interface up", "iface", e.cfg.Iface, "backend", e.be.Kind(), "port", e.cfg.ListenPort, "addresses", e.ServerAddresses(), "mtu", settings.MTU) + if err := e.applyFirewall(ctx); err != nil { + // Not fatal: the operator may run their own NAT. It is reported in + // the UI and the log so nobody wonders why peers cannot reach out. + e.log.Error("firewall rules not applied", "error", err) + e.mu.Lock() + e.fwErr = err.Error() + e.mu.Unlock() + } + if err := e.reconcile(ctx); err != nil { + return err + } + e.mu.Lock() + e.startedAt = time.Now() + e.mu.Unlock() + + loopCtx, cancel := context.WithCancel(context.Background()) + e.cancel = cancel + e.wg.Add(2) + go func() { defer e.wg.Done(); e.col.run(loopCtx) }() + go func() { defer e.wg.Done(); e.housekeeping(loopCtx) }() + return nil +} + +// Stop halts the loops, flushes counters and tears the interface down. +func (e *Engine) Stop(ctx context.Context) error { + if e.cancel != nil { + e.cancel() + e.wg.Wait() + } + var errs []error + if err := e.col.flush(ctx); err != nil { + errs = append(errs, err) + } + if e.cfg.ManageFirewall && e.be.Kind() != "mock" { + if err := netcfg.Remove(ctx, ""); err != nil { + errs = append(errs, err) + } + } + if err := e.be.Down(ctx); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func (e *Engine) loadSettings(ctx context.Context) error { + s, ok, err := loadSettings(ctx, e.st) + if err != nil { + return err + } + if !ok { + def := DefaultSettings(e.cfg.InitialEndpoint, e.cfg.InitialDNS, e.cfg.ListenPort) + s = &def + if err := saveSettings(ctx, e.st, s); err != nil { + return err + } + } + e.mu.Lock() + e.settings = *s + e.mu.Unlock() + return nil +} + +func (e *Engine) loadServerKey(ctx context.Context) error { + raw, err := e.st.GetSetting(ctx, serverKeySetting) + if err != nil { + return err + } + var key wg.Key + if raw == "" { + key, err = wg.GeneratePrivateKey() + if err != nil { + return err + } + if err := e.st.SetSetting(ctx, serverKeySetting, key.String()); err != nil { + return err + } + e.log.Info("generated server key", "publicKey", key.PublicKey().String()) + } else { + key, err = wg.ParseKey(raw) + if err != nil { + return fmt.Errorf("stored server key is invalid: %w", err) + } + } + e.mu.Lock() + e.serverKey = key + e.mu.Unlock() + return nil +} + +func (e *Engine) loadPeers(ctx context.Context) error { + peers, err := e.st.ListPeers(ctx) + if err != nil { + return err + } + e.mu.Lock() + defer e.mu.Unlock() + e.peers = make(map[string]*store.Peer, len(peers)) + e.byKey = make(map[string]*store.Peer, len(peers)) + for _, p := range peers { + e.peers[p.ID] = p + e.byKey[p.PublicKey] = p + } + return nil +} + +func (e *Engine) applyFirewall(ctx context.Context) error { + if !e.cfg.ManageFirewall || e.be.Kind() == "mock" { + return nil + } + egress := e.cfg.Egress + if egress == "" { + if d, err := netcfg.DefaultEgress(); err == nil { + egress = d + } else { + e.log.Warn("could not detect the egress interface; masquerading on every non-tunnel interface", "error", err) + } + } + s := e.Settings() + rules := netcfg.Rules{ + Iface: e.cfg.Iface, + Egress: egress, + ListenPort: e.cfg.ListenPort, + Subnets: e.tunnelSubnets(), + PeerIsolation: s.PeerIsolation, + ClampMSS: s.ClampMSS, + } + if err := netcfg.Apply(ctx, rules); err != nil { + return err + } + e.mu.Lock() + e.egress = egress + e.fwErr = "" + e.mu.Unlock() + e.log.Info("firewall rules applied", "egress", egress, "peerIsolation", s.PeerIsolation, "clampMSS", s.ClampMSS) + return nil +} + +// UpdateSettings validates, persists and applies new settings. +func (e *Engine) UpdateSettings(ctx context.Context, s Settings) error { + if err := s.Validate(); err != nil { + return err + } + old := e.Settings() + if err := saveSettings(ctx, e.st, &s); err != nil { + return err + } + e.mu.Lock() + e.settings = s + e.mu.Unlock() + if s.MTU != old.MTU { + if err := e.be.SetMTU(ctx, s.MTU); err != nil { + e.log.Warn("could not change interface MTU", "error", err) + } + } + if s.PeerIsolation != old.PeerIsolation || s.ClampMSS != old.ClampMSS { + if err := e.applyFirewall(ctx); err != nil { + e.mu.Lock() + e.fwErr = err.Error() + e.mu.Unlock() + return fmt.Errorf("settings saved but firewall rules failed: %w", err) + } + } + e.hub.Publish("settings", s) + return nil +} + +// active reports whether a peer should currently be on the interface. +func active(p *store.Peer, now time.Time) bool { + if !p.Enabled { + return false + } + if !p.ExpiresAt.IsZero() && now.After(p.ExpiresAt) { + return false + } + return true +} + +func (e *Engine) peerConfig(p *store.Peer) (wg.PeerConfig, error) { + pub, err := wg.ParseKey(p.PublicKey) + if err != nil { + return wg.PeerConfig{}, err + } + pc := wg.PeerConfig{PublicKey: pub} + if p.PresharedKey != "" { + psk, err := wg.ParseKey(p.PresharedKey) + if err != nil { + return wg.PeerConfig{}, err + } + pc.PresharedKey = &psk + } + if a, err := netip.ParseAddr(p.IPv4); err == nil { + pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 32)) + } + if p.IPv6 != "" { + if a, err := netip.ParseAddr(p.IPv6); err == nil { + pc.AllowedIPs = append(pc.AllowedIPs, netip.PrefixFrom(a, 128)) + } + } + return pc, nil +} + +// reconcile makes the interface's peer set match the database, one peer at +// a time. It never uses ReplacePeers on a running interface: that would +// reset every counter and drop every session for the sake of one change. +func (e *Engine) reconcile(ctx context.Context) error { + dev, err := e.be.Device(ctx) + if err != nil { + return err + } + now := time.Now() + e.mu.RLock() + want := make(map[string]wg.PeerConfig, len(e.peers)) + for _, p := range e.peers { + if active(p, now) { + pc, err := e.peerConfig(p) + if err != nil { + e.log.Warn("skipping peer with bad key", "peer", p.ID, "error", err) + continue + } + want[p.PublicKey] = pc + } + } + e.mu.RUnlock() + + have := make(map[string]wg.PeerState, len(dev.Peers)) + for _, p := range dev.Peers { + have[p.PublicKey.String()] = p + } + var errs []error + for key, pc := range want { + cur, ok := have[key] + if ok && samePeer(cur, pc) { + continue + } + if err := e.be.SetPeer(ctx, pc); err != nil { + errs = append(errs, fmt.Errorf("add peer %s: %w", key, err)) + } + } + for key, cur := range have { + if _, ok := want[key]; !ok { + if err := e.be.RemovePeer(ctx, cur.PublicKey); err != nil { + errs = append(errs, fmt.Errorf("remove peer %s: %w", key, err)) + } + } + } + return errors.Join(errs...) +} + +func samePeer(cur wg.PeerState, want wg.PeerConfig) bool { + if len(cur.AllowedIPs) != len(want.AllowedIPs) { + return false + } + set := map[netip.Prefix]bool{} + for _, a := range cur.AllowedIPs { + set[a] = true + } + for _, a := range want.AllowedIPs { + if !set[a] { + return false + } + } + return cur.PersistentKeepalive == want.PersistentKeepalive +} + +func (e *Engine) housekeeping(ctx context.Context) { + t := time.NewTicker(houseEvery) + defer t.Stop() + prune := time.NewTicker(time.Hour) + defer prune.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := e.reconcile(ctx); err != nil { + e.log.Warn("reconcile", "error", err) + } + if err := e.col.flush(ctx); err != nil { + e.log.Warn("flush counters", "error", err) + } + _ = e.st.PruneSessions(ctx) + case <-prune.C: + _ = e.st.PruneTraffic(ctx, time.Now().Add(-e.cfg.TrafficRetention)) + _ = e.st.PruneAudit(ctx, 5000) + } + } +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..c5fcb99 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,308 @@ +package engine + +import ( + "context" + "io" + "log/slog" + "net/netip" + "strings" + "testing" + "time" + + "github.com/Coffey-Labs/WGX/internal/config" + "github.com/Coffey-Labs/WGX/internal/store" + "github.com/Coffey-Labs/WGX/internal/wg" +) + +func testConfig() *config.Config { + return &config.Config{ + DBPath: ":memory:", + Backend: "mock", + Iface: "wg0", + ListenPort: 51820, + Subnet4: netip.MustParsePrefix("10.8.0.0/29"), + Subnet6: netip.MustParsePrefix("fd42::/64"), + HTTP: "127.0.0.1:0", + SessionIdle: time.Hour, + SessionMax: 24 * time.Hour, + TrafficRetention: 24 * time.Hour, + PollInterval: time.Hour, // tests drive polls by hand + InitialEndpoint: "vpn.example.com", + InitialDNS: "1.1.1.1", + ManageFirewall: true, + ManageSysctl: true, + } +} + +func newTestEngine(t *testing.T) (*Engine, *wg.Mock) { + t.Helper() + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + mock := wg.NewMock("wg0", false) + e := New(testConfig(), st, mock, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err := e.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = e.Stop(context.Background()) }) + return e, mock +} + +func TestAllocate(t *testing.T) { + subnet := netip.MustParsePrefix("10.8.0.0/29") // .0 net, .1 server, .2-.6 usable, .7 broadcast + used := map[string]bool{} + var got []string + for { + a, err := allocate(subnet, used) + if err != nil { + break + } + used[a.String()] = true + got = append(got, a.String()) + } + want := "10.8.0.2 10.8.0.3 10.8.0.4 10.8.0.5 10.8.0.6" + if strings.Join(got, " ") != want { + t.Fatalf("got %v, want %s", got, want) + } + a6, err := allocate(netip.MustParsePrefix("fd42::/64"), map[string]bool{"fd42::2": true}) + if err != nil || a6.String() != "fd42::3" { + t.Fatalf("v6 allocation %v %v", a6, err) + } + if _, err := checkAddress(subnet, "10.8.0.1", used); err == nil { + t.Fatal("server address accepted") + } + if _, err := checkAddress(subnet, "10.9.0.1", used); err == nil { + t.Fatal("outside address accepted") + } +} + +func TestCreatePeerAndConfig(t *testing.T) { + e, mock := newTestEngine(t) + ctx := context.Background() + p, err := e.CreatePeer(ctx, PeerInput{Name: "Laptop"}) + if err != nil { + t.Fatal(err) + } + if p.IPv4 != "10.8.0.2" || p.IPv6 != "fd42::2" { + t.Fatalf("addresses %s %s", p.IPv4, p.IPv6) + } + if p.PrivateKey == "" || p.PresharedKey == "" { + t.Fatal("server-managed peer should have private and preshared keys") + } + cfg := e.ClientConfig(p) + for _, want := range []string{ + "PrivateKey = " + p.PrivateKey, + "Address = 10.8.0.2/29, fd42::2/64", + "DNS = 1.1.1.1", + "MTU = 1420", + "PublicKey = " + e.ServerPublicKey(), + "PresharedKey = " + p.PresharedKey, + "AllowedIPs = 0.0.0.0/0, ::/0", + "Endpoint = vpn.example.com:51820", + "PersistentKeepalive = 25", + } { + if !strings.Contains(cfg, want) { + t.Errorf("config missing %q:\n%s", want, cfg) + } + } + dev, _ := mock.Device(ctx) + if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != p.PublicKey { + t.Fatal("peer not applied to the interface") + } + if len(dev.Peers[0].AllowedIPs) != 2 { + t.Fatalf("allowed ips %v", dev.Peers[0].AllowedIPs) + } + png, err := e.QRCode(p, 256) + if err != nil || len(png) < 100 { + t.Fatalf("qr: %v", err) + } + + // A client-keyed peer: no private key, no QR code. + priv, _ := wg.GeneratePrivateKey() + c, err := e.CreatePeer(ctx, PeerInput{Name: "Router", PublicKey: priv.PublicKey().String(), ClientRoutes: "10.8.0.0/29"}) + if err != nil { + t.Fatal(err) + } + if c.PrivateKey != "" || !strings.Contains(e.ClientConfig(c), "") { + t.Fatal("client-keyed peer leaked or lacked placeholder") + } + if _, err := e.QRCode(c, 256); err == nil { + t.Fatal("QR for client-keyed peer should fail") + } + if _, err := e.CreatePeer(ctx, PeerInput{Name: "Dup", PublicKey: priv.PublicKey().String()}); err == nil { + t.Fatal("duplicate public key accepted") + } + if _, err := e.CreatePeer(ctx, PeerInput{Name: ""}); err == nil { + t.Fatal("empty name accepted") + } +} + +func TestDisableResetRotateDelete(t *testing.T) { + e, mock := newTestEngine(t) + ctx := context.Background() + p, _ := e.CreatePeer(ctx, PeerInput{Name: "Phone"}) + pub, _ := wg.ParseKey(p.PublicKey) + mock.Touch(pub, 1000, 2000, "203.0.113.5:1234") + e.col.poll(ctx) + l, ok := e.Live(p.ID) + if !ok || !l.Connected || l.Endpoint != "203.0.113.5:1234" { + t.Fatalf("live state %+v", l) + } + // First sight sets the memo only; the next delta counts. + mock.Touch(pub, 500, 700, "") + e.col.poll(ctx) + l, _ = e.Live(p.ID) + if l.Rx != 500 || l.Tx != 700 { + t.Fatalf("totals %d/%d, want 500/700", l.Rx, l.Tx) + } + + if _, err := e.SetEnabled(ctx, p.ID, false); err != nil { + t.Fatal(err) + } + dev, _ := mock.Device(ctx) + if len(dev.Peers) != 0 { + t.Fatal("disabled peer still on interface") + } + e.col.poll(ctx) + if l, _ := e.Live(p.ID); l.Connected { + t.Fatal("disabled peer reported connected") + } + if _, err := e.SetEnabled(ctx, p.ID, true); err != nil { + t.Fatal(err) + } + if dev, _ = mock.Device(ctx); len(dev.Peers) != 1 { + t.Fatal("enabled peer not back on interface") + } + // Counters restart at zero after re-add; the total must not go backwards. + mock.Touch(pub, 100, 100, "") + e.col.poll(ctx) + mock.Touch(pub, 100, 100, "") + e.col.poll(ctx) + l, _ = e.Live(p.ID) + if l.Rx != 600 || l.Tx != 800 { + t.Fatalf("totals after re-add %d/%d, want 600/800", l.Rx, l.Tx) + } + + if err := e.ResetSession(ctx, p.ID); err != nil { + t.Fatal(err) + } + r, err := e.RotateKeys(ctx, p.ID) + if err != nil { + t.Fatal(err) + } + if r.PublicKey == p.PublicKey { + t.Fatal("key did not change") + } + dev, _ = mock.Device(ctx) + if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != r.PublicKey { + t.Fatal("rotated key not applied") + } + if err := e.col.flush(ctx); err != nil { + t.Fatal(err) + } + if err := e.DeletePeer(ctx, p.ID); err != nil { + t.Fatal(err) + } + if _, err := e.Peer(p.ID); err != ErrNotFound { + t.Fatal("deleted peer still present") + } + if dev, _ = mock.Device(ctx); len(dev.Peers) != 0 { + t.Fatal("deleted peer still on interface") + } +} + +func TestExpiryAndReconcile(t *testing.T) { + e, mock := newTestEngine(t) + ctx := context.Background() + past := time.Now().Add(-time.Minute) + p, err := e.CreatePeer(ctx, PeerInput{Name: "Temp", ExpiresAt: &past}) + if err != nil { + t.Fatal(err) + } + if dev, _ := mock.Device(ctx); len(dev.Peers) != 0 { + t.Fatal("expired peer applied") + } + future := time.Now().Add(time.Hour) + if _, err := e.UpdatePeer(ctx, p.ID, PeerInput{Name: "Temp", ExpiresAt: &future}); err != nil { + t.Fatal(err) + } + if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 { + t.Fatal("un-expired peer not applied") + } + // Someone removes the peer by hand; reconcile puts it back. + pub, _ := wg.ParseKey(p.PublicKey) + _ = mock.RemovePeer(ctx, pub) + if err := e.reconcile(ctx); err != nil { + t.Fatal(err) + } + if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 { + t.Fatal("reconcile did not restore peer") + } + // A stranger appears on the interface; reconcile removes it. + stray, _ := wg.GeneratePrivateKey() + _ = mock.SetPeer(ctx, wg.PeerConfig{PublicKey: stray.PublicKey()}) + if err := e.reconcile(ctx); err != nil { + t.Fatal(err) + } + if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 { + t.Fatal("reconcile did not remove stray peer") + } +} + +func TestSettingsValidation(t *testing.T) { + e, _ := newTestEngine(t) + s := e.Settings() + s.EndpointHost = "" + if err := e.UpdateSettings(context.Background(), s); err == nil { + t.Fatal("empty endpoint accepted") + } + s = e.Settings() + s.MTU = 100 + if err := e.UpdateSettings(context.Background(), s); err == nil { + t.Fatal("tiny MTU accepted") + } + s = e.Settings() + s.DNS = "1.1.1.1, example.com" + s.MTU = 1380 + if err := e.UpdateSettings(context.Background(), s); err != nil { + t.Fatal(err) + } + if e.Settings().MTU != 1380 { + t.Fatal("settings not persisted") + } +} + +func TestPersistenceAcrossRestart(t *testing.T) { + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer st.Close() + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + ctx := context.Background() + e1 := New(testConfig(), st, wg.NewMock("wg0", false), log) + if err := e1.Start(ctx); err != nil { + t.Fatal(err) + } + p, _ := e1.CreatePeer(ctx, PeerInput{Name: "Keep"}) + key := e1.ServerPublicKey() + _ = e1.Stop(ctx) + + e2 := New(testConfig(), st, wg.NewMock("wg0", false), log) + if err := e2.Start(ctx); err != nil { + t.Fatal(err) + } + defer e2.Stop(ctx) + if e2.ServerPublicKey() != key { + t.Fatal("server key changed across restart") + } + got, err := e2.Peer(p.ID) + if err != nil || got.PublicKey != p.PublicKey { + t.Fatal("peer lost across restart") + } + if dev, _ := e2.be.Device(ctx); len(dev.Peers) != 1 { + t.Fatal("peer not re-applied after restart") + } +} diff --git a/internal/engine/hub.go b/internal/engine/hub.go new file mode 100644 index 0000000..34b8e3e --- /dev/null +++ b/internal/engine/hub.go @@ -0,0 +1,60 @@ +package engine + +import ( + "encoding/json" + "sync" +) + +// Event is one server-sent event. +type Event struct { + Name string + Data []byte +} + +// Hub fans events out to every open dashboard. A subscriber that cannot keep +// up loses events rather than stalling the collector; the next status +// snapshot carries the full picture anyway. +type Hub struct { + mu sync.Mutex + subs map[chan Event]struct{} +} + +// NewHub returns an empty hub. +func NewHub() *Hub { return &Hub{subs: map[chan Event]struct{}{}} } + +// Subscribe returns a channel of events and a function to leave. +func (h *Hub) Subscribe() (<-chan Event, func()) { + ch := make(chan Event, 16) + h.mu.Lock() + h.subs[ch] = struct{}{} + h.mu.Unlock() + return ch, func() { + h.mu.Lock() + delete(h.subs, ch) + h.mu.Unlock() + } +} + +// Publish encodes v as JSON and sends it to every subscriber. +func (h *Hub) Publish(name string, v any) { + data, err := json.Marshal(v) + if err != nil { + return + } + ev := Event{Name: name, Data: data} + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.subs { + select { + case ch <- ev: + default: + } + } +} + +// Subscribers is how many dashboards are listening. +func (h *Hub) Subscribers() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.subs) +} diff --git a/internal/engine/peers.go b/internal/engine/peers.go new file mode 100644 index 0000000..6c01ec1 --- /dev/null +++ b/internal/engine/peers.go @@ -0,0 +1,486 @@ +package engine + +import ( + "context" + "errors" + "fmt" + "net" + "net/netip" + "sort" + "strconv" + "strings" + "time" + + "github.com/skip2/go-qrcode" + + "github.com/Coffey-Labs/WGX/internal/auth" + "github.com/Coffey-Labs/WGX/internal/store" + "github.com/Coffey-Labs/WGX/internal/wg" +) + +// PeerInput is what the API accepts when creating or editing a peer. +type PeerInput struct { + Name string `json:"name"` + // PublicKey, when set on create, means the client generated its own key + // pair and the server never sees the private key. + PublicKey string `json:"publicKey,omitempty"` + // IPv4 / IPv6 may pin addresses; empty means allocate. + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + ClientRoutes string `json:"clientRoutes"` + DNS string `json:"dns"` + Keepalive *int `json:"keepalive"` + MTU *int `json:"mtu"` + Enabled *bool `json:"enabled"` + ExpiresAt *time.Time `json:"expiresAt"` + Notes string `json:"notes"` +} + +// ErrValidation marks user errors so the API can answer 400. +type ErrValidation struct{ Msg string } + +func (e ErrValidation) Error() string { return e.Msg } + +func invalid(format string, a ...any) error { return ErrValidation{Msg: fmt.Sprintf(format, a...)} } + +// ErrNotFound is returned for unknown peer ids. +var ErrNotFound = store.ErrNotFound + +// Peer returns a copy of one peer. +func (e *Engine) Peer(id string) (*store.Peer, error) { + e.mu.RLock() + defer e.mu.RUnlock() + p, ok := e.peers[id] + if !ok { + return nil, ErrNotFound + } + cp := *p + return &cp, nil +} + +// Peers returns copies of every peer, newest first. +func (e *Engine) Peers() []*store.Peer { + e.mu.RLock() + defer e.mu.RUnlock() + out := make([]*store.Peer, 0, len(e.peers)) + for _, p := range e.peers { + cp := *p + out = append(out, &cp) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].CreatedAt.Equal(out[j].CreatedAt) { + return out[i].CreatedAt.After(out[j].CreatedAt) + } + return out[i].ID < out[j].ID + }) + return out +} + +// Live returns the live state for a peer. +func (e *Engine) Live(id string) (Live, bool) { return e.col.LiveFor(id) } + +// Snapshot returns the last poll. +func (e *Engine) Snapshot() Snapshot { return e.col.Snapshot() } + +func (e *Engine) usedAddresses() (v4, v6 map[string]bool) { + v4, v6 = map[string]bool{}, map[string]bool{} + e.mu.RLock() + defer e.mu.RUnlock() + for _, p := range e.peers { + v4[p.IPv4] = true + if p.IPv6 != "" { + v6[p.IPv6] = true + } + } + return +} + +func validateName(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", invalid("name is required") + } + if len(name) > 64 { + return "", invalid("name must be 64 characters or fewer") + } + return name, nil +} + +// CreatePeer allocates addresses, generates keys and adds the peer to the +// interface. The returned peer includes the private key when the server +// generated it. +func (e *Engine) CreatePeer(ctx context.Context, in PeerInput) (*store.Peer, error) { + name, err := validateName(in.Name) + if err != nil { + return nil, err + } + settings := e.Settings() + p := &store.Peer{Name: name, Enabled: true, Notes: strings.TrimSpace(in.Notes)} + if p.ID, err = auth.NewID(); err != nil { + return nil, err + } + if in.PublicKey != "" { + pub, err := wg.ParseKey(strings.TrimSpace(in.PublicKey)) + if err != nil { + return nil, invalid("public key: %v", err) + } + p.PublicKey = pub.String() + } else { + priv, err := wg.GeneratePrivateKey() + if err != nil { + return nil, err + } + p.PrivateKey = priv.String() + p.PublicKey = priv.PublicKey().String() + } + if p.PublicKey == e.ServerPublicKey() { + return nil, invalid("that is the server's own public key") + } + e.mu.RLock() + _, dup := e.byKey[p.PublicKey] + e.mu.RUnlock() + if dup { + return nil, invalid("a peer with that public key already exists") + } + if settings.PresharedKeys { + psk, err := wg.GeneratePresharedKey() + if err != nil { + return nil, err + } + p.PresharedKey = psk.String() + } + used4, used6 := e.usedAddresses() + var a4 netip.Addr + if in.IPv4 != "" { + if a4, err = checkAddress(e.cfg.Subnet4, in.IPv4, used4); err != nil { + return nil, invalid("IPv4: %v", err) + } + } else if a4, err = allocate(e.cfg.Subnet4, used4); err != nil { + return nil, invalid("%v", err) + } + p.IPv4 = a4.String() + if e.cfg.Subnet6.IsValid() { + var a6 netip.Addr + if in.IPv6 != "" { + if a6, err = checkAddress(e.cfg.Subnet6, in.IPv6, used6); err != nil { + return nil, invalid("IPv6: %v", err) + } + } else if a6, err = allocate(e.cfg.Subnet6, used6); err != nil { + return nil, invalid("%v", err) + } + p.IPv6 = a6.String() + } else if in.IPv6 != "" { + return nil, invalid("IPv6 is not enabled on this server (set WGX_SUBNET6)") + } + if err := applyEditable(p, in, settings); err != nil { + return nil, err + } + if err := e.st.CreatePeer(ctx, p); err != nil { + return nil, err + } + e.mu.Lock() + e.peers[p.ID] = p + e.byKey[p.PublicKey] = p + e.mu.Unlock() + if err := e.applyPeer(ctx, p); err != nil { + e.log.Error("apply new peer", "peer", p.ID, "error", err) + } + e.hub.Publish("peers", "changed") + cp := *p + return &cp, nil +} + +// applyEditable copies the fields that may change after creation. +func applyEditable(p *store.Peer, in PeerInput, settings Settings) error { + routes := strings.TrimSpace(in.ClientRoutes) + if routes == "" { + routes = settings.ClientRoutes + } + ps, err := ParsePrefixes(routes) + if err != nil { + return invalid("client routes: %v", err) + } + p.ClientRoutes = JoinPrefixes(ps) + if _, err := ParseDNS(in.DNS); err != nil { + return invalid("%v", err) + } + p.DNS = strings.TrimSpace(in.DNS) + if in.Keepalive != nil { + if *in.Keepalive < 0 || *in.Keepalive > 65535 { + return invalid("keepalive must be 0-65535 seconds") + } + p.Keepalive = *in.Keepalive + } + if in.MTU != nil { + if *in.MTU != 0 && (*in.MTU < 1280 || *in.MTU > 9000) { + return invalid("MTU must be 0 (server default) or 1280-9000") + } + p.MTU = *in.MTU + } + if in.Enabled != nil { + p.Enabled = *in.Enabled + } + if in.ExpiresAt != nil { + p.ExpiresAt = in.ExpiresAt.UTC() + if p.ExpiresAt.Unix() <= 0 { + p.ExpiresAt = time.Time{} + } + } + p.Notes = strings.TrimSpace(in.Notes) + if len(p.Notes) > 2000 { + return invalid("notes must be 2000 characters or fewer") + } + return nil +} + +// UpdatePeer edits a peer. Keys and addresses do not change here. +func (e *Engine) UpdatePeer(ctx context.Context, id string, in PeerInput) (*store.Peer, error) { + e.mu.RLock() + cur, ok := e.peers[id] + e.mu.RUnlock() + if !ok { + return nil, ErrNotFound + } + p := *cur + name, err := validateName(in.Name) + if err != nil { + return nil, err + } + p.Name = name + if err := applyEditable(&p, in, e.Settings()); err != nil { + return nil, err + } + if err := e.st.UpdatePeer(ctx, &p); err != nil { + return nil, err + } + e.mu.Lock() + *cur = p + e.mu.Unlock() + if err := e.applyPeer(ctx, cur); err != nil { + e.log.Error("apply peer", "peer", id, "error", err) + } + e.hub.Publish("peers", "changed") + return &p, nil +} + +// SetEnabled turns a peer on or off. Off removes it from the interface at +// once, which drops any session it has: this is "disconnect". +func (e *Engine) SetEnabled(ctx context.Context, id string, enabled bool) (*store.Peer, error) { + e.mu.RLock() + cur, ok := e.peers[id] + e.mu.RUnlock() + if !ok { + return nil, ErrNotFound + } + p := *cur + p.Enabled = enabled + if err := e.st.UpdatePeer(ctx, &p); err != nil { + return nil, err + } + e.mu.Lock() + *cur = p + e.mu.Unlock() + if err := e.applyPeer(ctx, cur); err != nil { + return nil, err + } + e.hub.Publish("peers", "changed") + return &p, nil +} + +// ResetSession drops a peer's current session without disabling it. The +// client will handshake again on its next packet. +func (e *Engine) ResetSession(ctx context.Context, id string) error { + e.mu.RLock() + cur, ok := e.peers[id] + e.mu.RUnlock() + if !ok { + return ErrNotFound + } + pub, err := wg.ParseKey(cur.PublicKey) + if err != nil { + return err + } + if err := e.be.RemovePeer(ctx, pub); err != nil { + return err + } + e.col.rekey(cur.PublicKey) + return e.applyPeer(ctx, cur) +} + +// RotateKeys gives a server-managed peer a new key pair (and preshared key). +// The old configuration stops working immediately. +func (e *Engine) RotateKeys(ctx context.Context, id string) (*store.Peer, error) { + e.mu.RLock() + cur, ok := e.peers[id] + e.mu.RUnlock() + if !ok { + return nil, ErrNotFound + } + if cur.PrivateKey == "" { + return nil, invalid("this peer's keys are managed by the client; create a new peer instead") + } + priv, err := wg.GeneratePrivateKey() + if err != nil { + return nil, err + } + p := *cur + oldKey := p.PublicKey + p.PrivateKey = priv.String() + p.PublicKey = priv.PublicKey().String() + if e.Settings().PresharedKeys { + psk, err := wg.GeneratePresharedKey() + if err != nil { + return nil, err + } + p.PresharedKey = psk.String() + } else { + p.PresharedKey = "" + } + if err := e.st.UpdatePeer(ctx, &p); err != nil { + return nil, err + } + if old, err := wg.ParseKey(oldKey); err == nil { + _ = e.be.RemovePeer(ctx, old) + } + e.col.rekey(oldKey) + e.mu.Lock() + delete(e.byKey, oldKey) + *cur = p + e.byKey[p.PublicKey] = cur + e.mu.Unlock() + if err := e.applyPeer(ctx, cur); err != nil { + e.log.Error("apply rotated peer", "peer", id, "error", err) + } + e.hub.Publish("peers", "changed") + return &p, nil +} + +// DeletePeer removes a peer for good. +func (e *Engine) DeletePeer(ctx context.Context, id string) error { + e.mu.RLock() + cur, ok := e.peers[id] + e.mu.RUnlock() + if !ok { + return ErrNotFound + } + if pub, err := wg.ParseKey(cur.PublicKey); err == nil { + if err := e.be.RemovePeer(ctx, pub); err != nil { + e.log.Warn("remove peer from interface", "peer", id, "error", err) + } + } + if err := e.st.DeletePeer(ctx, id); err != nil { + return err + } + e.mu.Lock() + delete(e.peers, id) + delete(e.byKey, cur.PublicKey) + e.mu.Unlock() + e.col.forget(id, cur.PublicKey) + e.hub.Publish("peers", "changed") + return nil +} + +// applyPeer adds or removes one peer on the interface according to whether +// it should be active. +func (e *Engine) applyPeer(ctx context.Context, p *store.Peer) error { + pc, err := e.peerConfig(p) + if err != nil { + return err + } + if active(p, time.Now()) { + return e.be.SetPeer(ctx, pc) + } + return e.be.RemovePeer(ctx, pc.PublicKey) +} + +// ClientConfig renders the WireGuard configuration file for a peer. When the +// client holds its own private key the placeholder is left for them. +func (e *Engine) ClientConfig(p *store.Peer) string { + s := e.Settings() + var b strings.Builder + b.WriteString("[Interface]\n") + if p.PrivateKey != "" { + fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey) + } else { + b.WriteString("PrivateKey = \n") + } + addrs := []string{fmt.Sprintf("%s/%d", p.IPv4, e.cfg.Subnet4.Bits())} + if p.IPv6 != "" && e.cfg.Subnet6.IsValid() { + addrs = append(addrs, fmt.Sprintf("%s/%d", p.IPv6, e.cfg.Subnet6.Bits())) + } + fmt.Fprintf(&b, "Address = %s\n", strings.Join(addrs, ", ")) + dns := p.DNS + if dns == "" { + dns = s.DNS + } + if d, _ := ParseDNS(dns); len(d) > 0 { + fmt.Fprintf(&b, "DNS = %s\n", strings.Join(d, ", ")) + } + mtu := p.MTU + if mtu == 0 { + mtu = s.MTU + } + fmt.Fprintf(&b, "MTU = %d\n", mtu) + b.WriteString("\n[Peer]\n") + fmt.Fprintf(&b, "PublicKey = %s\n", e.ServerPublicKey()) + if p.PresharedKey != "" { + fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey) + } + fmt.Fprintf(&b, "AllowedIPs = %s\n", p.ClientRoutes) + fmt.Fprintf(&b, "Endpoint = %s\n", net.JoinHostPort(s.EndpointHost, strconv.Itoa(s.EndpointPort))) + ka := p.Keepalive + if ka == 0 { + ka = s.Keepalive + } + if ka > 0 { + fmt.Fprintf(&b, "PersistentKeepalive = %d\n", ka) + } + return b.String() +} + +// QRCode renders the client configuration as a PNG. +func (e *Engine) QRCode(p *store.Peer, size int) ([]byte, error) { + if p.PrivateKey == "" { + return nil, errors.New("no QR code: the private key is held by the client") + } + if size < 128 || size > 1024 { + size = 384 + } + return qrcode.Encode(e.ClientConfig(p), qrcode.Medium, size) +} + +// Usage returns a peer's traffic series since a time. +func (e *Engine) Usage(ctx context.Context, peerID string, since time.Time) ([]store.TrafficPoint, error) { + if peerID != "" { + if _, err := e.Peer(peerID); err != nil { + return nil, err + } + } + // Pending buckets are flushed first so the last few minutes show. + if err := e.col.flush(ctx); err != nil { + return nil, err + } + pts, err := e.st.TrafficSeries(ctx, peerID, since) + if err != nil { + return nil, err + } + if pts == nil { + pts = []store.TrafficPoint{} + } + return pts, nil +} + +// UsageByPeer sums traffic per peer since a time. +func (e *Engine) UsageByPeer(ctx context.Context, since time.Time) ([]store.PeerUsage, error) { + if err := e.col.flush(ctx); err != nil { + return nil, err + } + u, err := e.st.UsageSince(ctx, since) + if err != nil { + return nil, err + } + if u == nil { + u = []store.PeerUsage{} + } + return u, nil +} diff --git a/internal/engine/settings.go b/internal/engine/settings.go new file mode 100644 index 0000000..bbdf544 --- /dev/null +++ b/internal/engine/settings.go @@ -0,0 +1,168 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/netip" + "strings" + + "github.com/Coffey-Labs/WGX/internal/store" +) + +// Settings are the administrator-editable server options. They persist in the +// database and can be changed from the UI without restarting the container. +type Settings struct { + // EndpointHost is the public name or address clients connect to. + EndpointHost string `json:"endpointHost"` + // EndpointPort is what clients dial; usually the listen port, but + // different when the container's UDP port is remapped. + EndpointPort int `json:"endpointPort"` + // DNS handed to clients, comma separated. Empty means none. + DNS string `json:"dns"` + // ClientRoutes is the default AllowedIPs written into client configs. + ClientRoutes string `json:"clientRoutes"` + // MTU for the server interface and, by default, client configs. + MTU int `json:"mtu"` + // Keepalive is the default PersistentKeepalive for clients, in seconds. + Keepalive int `json:"keepalive"` + // PeerIsolation stops peers reaching one another. + PeerIsolation bool `json:"peerIsolation"` + // ClampMSS rewrites TCP MSS on forwarded SYNs to fit the tunnel MTU. + ClampMSS bool `json:"clampMSS"` + // PresharedKeys adds a per-peer preshared key to every new peer. + PresharedKeys bool `json:"presharedKeys"` + // ConnectedWindow is how many seconds since the last handshake still + // counts as connected. WireGuard rejects sessions after 180 s. + ConnectedWindow int `json:"connectedWindow"` +} + +// DefaultSettings returns what a fresh install starts with. +func DefaultSettings(endpointHost, dns string, port int) Settings { + return Settings{ + EndpointHost: endpointHost, + EndpointPort: port, + DNS: dns, + ClientRoutes: "0.0.0.0/0, ::/0", + MTU: 1420, + Keepalive: 25, + PeerIsolation: false, + ClampMSS: true, + PresharedKeys: true, + ConnectedWindow: 180, + } +} + +// Validate checks settings coming in from the API. +func (s *Settings) Validate() error { + var errs []error + s.EndpointHost = strings.TrimSpace(s.EndpointHost) + if s.EndpointHost == "" { + errs = append(errs, errors.New("endpoint host is required")) + } else if strings.ContainsAny(s.EndpointHost, " /\\:") && !strings.HasPrefix(s.EndpointHost, "[") { + if _, err := netip.ParseAddr(s.EndpointHost); err != nil { + errs = append(errs, errors.New("endpoint host must be a hostname or IP address without a port")) + } + } + if s.EndpointPort < 1 || s.EndpointPort > 65535 { + errs = append(errs, errors.New("endpoint port must be 1-65535")) + } + if _, err := ParseDNS(s.DNS); err != nil { + errs = append(errs, err) + } + if _, err := ParsePrefixes(s.ClientRoutes); err != nil { + errs = append(errs, fmt.Errorf("client routes: %w", err)) + } + if s.MTU < 1280 || s.MTU > 9000 { + errs = append(errs, errors.New("MTU must be between 1280 and 9000")) + } + if s.Keepalive < 0 || s.Keepalive > 65535 { + errs = append(errs, errors.New("keepalive must be 0-65535 seconds")) + } + if s.ConnectedWindow < 30 || s.ConnectedWindow > 3600 { + errs = append(errs, errors.New("connected window must be 30-3600 seconds")) + } + return errors.Join(errs...) +} + +// ParseDNS validates a comma-separated list of resolvers (addresses, or a +// search domain which WireGuard clients also accept in the DNS field). +func ParseDNS(s string) ([]string, error) { + var out []string + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, err := netip.ParseAddr(part); err != nil { + // Allow search domains: letters, digits, dots and dashes only. + for _, r := range part { + if !(r == '.' || r == '-' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return nil, fmt.Errorf("DNS entry %q is neither an address nor a domain", part) + } + } + } + out = append(out, part) + } + return out, nil +} + +// ParsePrefixes parses a comma-separated CIDR list; bare addresses become +// host prefixes. +func ParsePrefixes(s string) ([]netip.Prefix, error) { + var out []netip.Prefix + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + p, err := netip.ParsePrefix(part) + if err != nil { + a, err2 := netip.ParseAddr(part) + if err2 != nil { + return nil, fmt.Errorf("%q is not a CIDR", part) + } + p = netip.PrefixFrom(a, a.BitLen()) + } + out = append(out, p.Masked()) + } + if len(out) == 0 { + return nil, errors.New("at least one route is required") + } + return out, nil +} + +// JoinPrefixes renders prefixes the way a WireGuard config expects. +func JoinPrefixes(ps []netip.Prefix) string { + parts := make([]string, len(ps)) + for i, p := range ps { + parts[i] = p.String() + } + return strings.Join(parts, ", ") +} + +const settingsKey = "server" + +func loadSettings(ctx context.Context, st *store.Store) (*Settings, bool, error) { + raw, err := st.GetSetting(ctx, settingsKey) + if err != nil { + return nil, false, err + } + if raw == "" { + return nil, false, nil + } + var s Settings + if err := json.Unmarshal([]byte(raw), &s); err != nil { + return nil, false, fmt.Errorf("settings are corrupt: %w", err) + } + return &s, true, nil +} + +func saveSettings(ctx context.Context, st *store.Store, s *Settings) error { + raw, err := json.Marshal(s) + if err != nil { + return err + } + return st.SetSetting(ctx, settingsKey, string(raw)) +} diff --git a/internal/engine/status.go b/internal/engine/status.go new file mode 100644 index 0000000..cdf4be7 --- /dev/null +++ b/internal/engine/status.go @@ -0,0 +1,75 @@ +package engine + +import ( + "time" + + "github.com/Coffey-Labs/WGX/internal/netcfg" +) + +// SysctlStatus is one sysctl as reported to the UI. +type SysctlStatus struct { + Key string `json:"key"` + Wanted string `json:"wanted"` + Current string `json:"current"` + Applied bool `json:"applied"` + Required bool `json:"required"` + Why string `json:"why"` + Error string `json:"error,omitempty"` +} + +// Status is the server overview. +type Status struct { + Version string `json:"version"` + Backend string `json:"backend"` + Interface string `json:"interface"` + PublicKey string `json:"publicKey"` + ListenPort int `json:"listenPort"` + Addresses []string `json:"addresses"` + Subnet4 string `json:"subnet4"` + Subnet6 string `json:"subnet6,omitempty"` + Egress string `json:"egress,omitempty"` + FirewallError string `json:"firewallError,omitempty"` + FirewallManaged bool `json:"firewallManaged"` + StartedAt time.Time `json:"startedAt"` + Sysctls []SysctlStatus `json:"sysctls"` + Totals Totals `json:"totals"` + Settings Settings `json:"settings"` +} + +// Version is stamped at build time. +var Version = "dev" + +// Status assembles the overview. +func (e *Engine) Status() Status { + e.mu.RLock() + defer e.mu.RUnlock() + st := Status{ + Version: Version, + Backend: e.be.Kind(), + Interface: e.cfg.Iface, + PublicKey: e.serverKey.PublicKey().String(), + ListenPort: e.cfg.ListenPort, + Subnet4: e.cfg.Subnet4.Masked().String(), + Egress: e.egress, + FirewallError: e.fwErr, + FirewallManaged: e.cfg.ManageFirewall && e.be.Kind() != "mock", + StartedAt: e.startedAt, + Settings: e.settings, + Sysctls: []SysctlStatus{}, + } + if e.cfg.Subnet6.IsValid() { + st.Subnet6 = e.cfg.Subnet6.Masked().String() + } + for _, a := range e.ServerAddresses() { + st.Addresses = append(st.Addresses, a.String()) + } + for _, r := range e.sysctls { + st.Sysctls = append(st.Sysctls, sysctlStatus(r)) + } + st.Totals = e.col.Snapshot().Totals + return st +} + +func sysctlStatus(r netcfg.Result) SysctlStatus { + return SysctlStatus{Key: r.Key, Wanted: r.Value, Current: r.Current, Applied: r.Applied, Required: r.Required, Why: r.Why, Error: r.Err} +} diff --git a/internal/netcfg/egress_linux.go b/internal/netcfg/egress_linux.go new file mode 100644 index 0000000..905da20 --- /dev/null +++ b/internal/netcfg/egress_linux.go @@ -0,0 +1,42 @@ +//go:build linux + +package netcfg + +import ( + "errors" + "net" + + "github.com/vishvananda/netlink" +) + +// DefaultEgress returns the interface the default IPv4 route leaves through. +// Masquerading on exactly that interface (rather than "anything that is not +// wg0") keeps the NAT rule from touching docker-internal traffic. +func DefaultEgress() (string, error) { + routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) + if err != nil { + return "", err + } + for _, r := range routes { + if r.Dst == nil || (r.Dst.IP.Equal(net.IPv4zero) && isZeroMask(r.Dst.Mask)) { + if r.LinkIndex == 0 { + continue + } + link, err := netlink.LinkByIndex(r.LinkIndex) + if err != nil { + return "", err + } + return link.Attrs().Name, nil + } + } + return "", errors.New("no default route") +} + +func isZeroMask(m net.IPMask) bool { + for _, b := range m { + if b != 0 { + return false + } + } + return true +} diff --git a/internal/netcfg/egress_other.go b/internal/netcfg/egress_other.go new file mode 100644 index 0000000..dc88a88 --- /dev/null +++ b/internal/netcfg/egress_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package netcfg + +import "errors" + +// DefaultEgress is only implemented on Linux. +func DefaultEgress() (string, error) { return "", errors.New("not supported on this platform") } diff --git a/internal/netcfg/nft.go b/internal/netcfg/nft.go new file mode 100644 index 0000000..abe0ddb --- /dev/null +++ b/internal/netcfg/nft.go @@ -0,0 +1,113 @@ +// Package netcfg owns everything around the WireGuard interface that is not +// WireGuard itself: IP forwarding, the nftables ruleset that NATs peers to the +// outside world, and the sysctls that keep throughput up. +package netcfg + +import ( + "context" + "bytes" + "fmt" + "net/netip" + "os/exec" + "strings" +) + +// Rules describes the firewall WGX wants. +type Rules struct { + // Iface is the WireGuard interface name, e.g. wg0. + Iface string + // Egress is the interface peers reach the outside world through. Empty + // means "any interface that is not Iface", which is what most single-NIC + // containers want. + Egress string + // ListenPort is the UDP port to accept WireGuard traffic on. + ListenPort int + // Subnets are the tunnel networks to masquerade (v4 and/or v6). + Subnets []netip.Prefix + // PeerIsolation drops traffic between peers when true. + PeerIsolation bool + // ClampMSS rewrites the MSS of forwarded SYNs to fit the path MTU. It + // costs almost nothing and removes the single most common cause of + // "the VPN connects but websites hang". + ClampMSS bool + // Table names the nftables table, so a host with its own rules never + // collides with ours. Defaults to "wgx". + Table string +} + +// Ruleset renders the nftables script for the given rules. It is a pure +// function so tests can check the output without a kernel. +func Ruleset(r Rules) string { + table := r.Table + if table == "" { + table = "wgx" + } + var b strings.Builder + fmt.Fprintf(&b, "table inet %s\n", table) + fmt.Fprintf(&b, "delete table inet %s\n", table) + fmt.Fprintf(&b, "table inet %s {\n", table) + + fmt.Fprintf(&b, " chain input {\n") + fmt.Fprintf(&b, " type filter hook input priority filter; policy accept;\n") + fmt.Fprintf(&b, " udp dport %d accept comment \"wireguard\"\n", r.ListenPort) + fmt.Fprintf(&b, " }\n") + + fmt.Fprintf(&b, " chain forward {\n") + fmt.Fprintf(&b, " type filter hook forward priority filter; policy accept;\n") + if r.PeerIsolation { + fmt.Fprintf(&b, " iifname %q oifname %q drop comment \"peer isolation\"\n", r.Iface, r.Iface) + } + if r.ClampMSS { + fmt.Fprintf(&b, " iifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface) + fmt.Fprintf(&b, " oifname %q tcp flags syn tcp option maxseg size set rt mtu comment \"clamp mss\"\n", r.Iface) + } + fmt.Fprintf(&b, " iifname %q accept\n", r.Iface) + fmt.Fprintf(&b, " oifname %q ct state related,established accept\n", r.Iface) + fmt.Fprintf(&b, " }\n") + + fmt.Fprintf(&b, " chain postrouting {\n") + fmt.Fprintf(&b, " type nat hook postrouting priority srcnat; policy accept;\n") + for _, s := range r.Subnets { + fam := "ip" + if s.Addr().Is6() { + fam = "ip6" + } + if r.Egress != "" { + fmt.Fprintf(&b, " %s saddr %s oifname %q masquerade\n", fam, s.Masked(), r.Egress) + } else { + fmt.Fprintf(&b, " %s saddr %s oifname != %q masquerade\n", fam, s.Masked(), r.Iface) + } + } + fmt.Fprintf(&b, " }\n") + fmt.Fprintf(&b, "}\n") + return b.String() +} + +// Apply loads the ruleset with nft(8). +func Apply(ctx context.Context, r Rules) error { + return runNFT(ctx, Ruleset(r)) +} + +// Remove deletes the WGX table, ignoring the case where it is already gone. +func Remove(ctx context.Context, table string) error { + if table == "" { + table = "wgx" + } + script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table) + return runNFT(ctx, script) +} + +func runNFT(ctx context.Context, script string) error { + nft, err := exec.LookPath("nft") + if err != nil { + return fmt.Errorf("nft is not installed: %w", err) + } + cmd := exec.CommandContext(ctx, nft, "-f", "-") + cmd.Stdin = strings.NewReader(script) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("nft: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil +} diff --git a/internal/netcfg/nft_test.go b/internal/netcfg/nft_test.go new file mode 100644 index 0000000..2451ae3 --- /dev/null +++ b/internal/netcfg/nft_test.go @@ -0,0 +1,53 @@ +package netcfg + +import ( + "net/netip" + "strings" + "testing" +) + +func TestRuleset(t *testing.T) { + r := Rules{ + Iface: "wg0", + Egress: "eth0", + ListenPort: 51820, + Subnets: []netip.Prefix{netip.MustParsePrefix("10.8.0.0/24"), netip.MustParsePrefix("fd42::/64")}, + PeerIsolation: true, + ClampMSS: true, + } + out := Ruleset(r) + for _, want := range []string{ + "table inet wgx {", + "udp dport 51820 accept", + `iifname "wg0" oifname "wg0" drop`, + `tcp option maxseg size set rt mtu`, + `ip saddr 10.8.0.0/24 oifname "eth0" masquerade`, + `ip6 saddr fd42::/64 oifname "eth0" masquerade`, + `oifname "wg0" ct state related,established accept`, + } { + if !strings.Contains(out, want) { + t.Errorf("ruleset missing %q:\n%s", want, out) + } + } + // Without an egress, masquerade on anything that is not the tunnel. + r.Egress = "" + r.PeerIsolation = false + out = Ruleset(r) + if !strings.Contains(out, `oifname != "wg0" masquerade`) { + t.Errorf("expected wildcard masquerade:\n%s", out) + } + if strings.Contains(out, "peer isolation") { + t.Error("isolation rule present when off") + } +} + +func TestWanted(t *testing.T) { + v4 := Wanted(false) + v6 := Wanted(true) + if len(v6) != len(v4)+1 { + t.Fatal("ipv6 forwarding not added") + } + if v4[0].Key != "net.ipv4.ip_forward" || !v4[0].Required { + t.Fatal("ip_forward must be first and required") + } +} diff --git a/internal/netcfg/sysctl.go b/internal/netcfg/sysctl.go new file mode 100644 index 0000000..cd3979c --- /dev/null +++ b/internal/netcfg/sysctl.go @@ -0,0 +1,94 @@ +package netcfg + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// Sysctl is one kernel parameter and the value WGX wants for it. +type Sysctl struct { + Key string + Value string + // Required marks the ones the VPN cannot work without (forwarding). The + // others are throughput tuning: nice to have, and often refused inside a + // container because they are not network-namespaced. + Required bool + // Why is shown in the log and the UI when a value could not be set. + Why string +} + +// Result records what happened to one sysctl. +type Result struct { + Sysctl + Applied bool + Current string + Err string +} + +// Wanted returns the sysctls WGX applies at startup, in order. +func Wanted(ipv6 bool) []Sysctl { + s := []Sysctl{ + {Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"}, + // Strict reverse-path filtering drops replies that arrive on the + // tunnel for a source the kernel would route elsewhere. Loose is + // what every VPN gateway runs. + {Key: "net.ipv4.conf.all.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"}, + {Key: "net.ipv4.conf.default.rp_filter", Value: "2", Why: "strict rp_filter drops legitimate tunnel replies"}, + // The remaining ones are throughput. They are global (not + // namespaced), so inside a container they usually fail and must be + // set on the host instead -- see docs/performance.md. + {Key: "net.core.rmem_max", Value: "26214400", Why: "larger UDP receive buffers stop bursts being dropped before WireGuard reads them"}, + {Key: "net.core.wmem_max", Value: "26214400", Why: "larger UDP send buffers keep the encrypt path from stalling"}, + {Key: "net.core.rmem_default", Value: "1048576", Why: "default socket receive buffer"}, + {Key: "net.core.wmem_default", Value: "1048576", Why: "default socket send buffer"}, + {Key: "net.core.netdev_max_backlog", Value: "16384", Why: "deeper per-CPU input queue for 10GbE bursts"}, + {Key: "net.ipv4.udp_rmem_min", Value: "16384", Why: "minimum UDP receive buffer under memory pressure"}, + {Key: "net.ipv4.udp_wmem_min", Value: "16384", Why: "minimum UDP send buffer under memory pressure"}, + } + if ipv6 { + s = append(s, Sysctl{Key: "net.ipv6.conf.all.forwarding", Value: "1", Required: true, Why: "IPv6 peers cannot reach anything beyond the server without forwarding"}) + } + return s +} + +// ApplyAll writes each sysctl through /proc/sys and reports what happened. +// A value that is already right counts as applied. A required value that +// cannot be set is returned as an error along with the full report so the +// caller can decide whether to keep going. +func ApplyAll(want []Sysctl) ([]Result, error) { + var results []Result + var fatal []string + for _, s := range want { + r := Result{Sysctl: s} + path := filepath.Join("/proc/sys", strings.ReplaceAll(s.Key, ".", "/")) + cur, err := os.ReadFile(path) + if err == nil { + r.Current = strings.TrimSpace(string(cur)) + } + if r.Current == s.Value { + r.Applied = true + results = append(results, r) + continue + } + if err := os.WriteFile(path, []byte(s.Value), 0o644); err != nil { + r.Err = err.Error() + if s.Required && !forwardingSatisfied(r.Current, s.Value) { + fatal = append(fatal, fmt.Sprintf("%s=%s (%s)", s.Key, s.Value, r.Err)) + } + } else { + r.Applied = true + r.Current = s.Value + } + results = append(results, r) + } + if len(fatal) > 0 { + return results, fmt.Errorf("required sysctls could not be set: %s -- pass them with `--sysctl` or the compose `sysctls:` list", strings.Join(fatal, ", ")) + } + return results, nil +} + +// forwardingSatisfied treats "1" as satisfied for forwarding keys even when +// the file was read-only, which is what a compose `sysctls:` entry produces. +func forwardingSatisfied(current, want string) bool { return current == want } diff --git a/internal/server/api.go b/internal/server/api.go new file mode 100644 index 0000000..c78880d --- /dev/null +++ b/internal/server/api.go @@ -0,0 +1,402 @@ +package server + +import ( + "crypto/subtle" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Coffey-Labs/WGX/internal/engine" + "github.com/Coffey-Labs/WGX/internal/store" +) + +type healthBody struct { + OK bool `json:"ok"` + Version string `json:"version"` +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, healthBody{OK: true, Version: engine.Version}) +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.eng.Status()) +} + +// peerBody is a peer as the API presents it: never the private or preshared +// key (those only leave through the config and QR endpoints). +type peerBody struct { + ID string `json:"id"` + Name string `json:"name"` + PublicKey string `json:"publicKey"` + ServerKeys bool `json:"serverKeys"` + PresharedKey bool `json:"presharedKey"` + IPv4 string `json:"ipv4"` + IPv6 string `json:"ipv6,omitempty"` + ClientRoutes string `json:"clientRoutes"` + DNS string `json:"dns"` + Keepalive int `json:"keepalive"` + MTU int `json:"mtu"` + Enabled bool `json:"enabled"` + Expired bool `json:"expired"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Live engine.Live `json:"live"` +} + +func (s *Server) toPeerBody(p *store.Peer) peerBody { + b := peerBody{ + ID: p.ID, Name: p.Name, PublicKey: p.PublicKey, ServerKeys: p.PrivateKey != "", PresharedKey: p.PresharedKey != "", + IPv4: p.IPv4, IPv6: p.IPv6, ClientRoutes: p.ClientRoutes, DNS: p.DNS, Keepalive: p.Keepalive, MTU: p.MTU, + Enabled: p.Enabled, Notes: p.Notes, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + } + if !p.ExpiresAt.IsZero() { + t := p.ExpiresAt + b.ExpiresAt = &t + b.Expired = time.Now().After(t) + } + if l, ok := s.eng.Live(p.ID); ok { + b.Live = l + } else { + b.Live = engine.Live{PeerID: p.ID, Rx: p.RxTotal, Tx: p.TxTotal, LastHandshake: p.LastHandshake, Endpoint: p.LastEndpoint} + } + return b +} + +func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) { + peers := s.eng.Peers() + out := make([]peerBody, 0, len(peers)) + for _, p := range peers { + out = append(out, s.toPeerBody(p)) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handlePeer(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.Peer(r.PathValue("id")) + if err != nil { + engineError(w, err) + return + } + writeJSON(w, http.StatusOK, s.toPeerBody(p)) +} + +type createPeerResponse struct { + peerBody + Config string `json:"config"` +} + +func (s *Server) handleCreatePeer(w http.ResponseWriter, r *http.Request) { + var in engine.PeerInput + if !readJSON(w, r, &in) { + return + } + p, err := s.eng.CreatePeer(r.Context(), in) + if err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.created", p.Name, fmt.Sprintf("%s %s", p.ID, p.IPv4)) + writeJSON(w, http.StatusCreated, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)}) +} + +func (s *Server) handleUpdatePeer(w http.ResponseWriter, r *http.Request) { + var in engine.PeerInput + if !readJSON(w, r, &in) { + return + } + p, err := s.eng.UpdatePeer(r.Context(), r.PathValue("id"), in) + if err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.updated", p.Name, p.ID) + writeJSON(w, http.StatusOK, s.toPeerBody(p)) +} + +func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, err := s.eng.Peer(id) + if err != nil { + engineError(w, err) + return + } + if err := s.eng.DeletePeer(r.Context(), id); err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.deleted", p.Name, id) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleEnablePeer(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), true) + if err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.enabled", p.Name, p.ID) + writeJSON(w, http.StatusOK, s.toPeerBody(p)) +} + +// handleDisablePeer is "disconnect": the peer leaves the interface and its +// session dies with it. +func (s *Server) handleDisablePeer(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.SetEnabled(r.Context(), r.PathValue("id"), false) + if err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.disabled", p.Name, p.ID) + writeJSON(w, http.StatusOK, s.toPeerBody(p)) +} + +func (s *Server) handleResetPeer(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + p, err := s.eng.Peer(id) + if err != nil { + engineError(w, err) + return + } + if err := s.eng.ResetSession(r.Context(), id); err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.session_reset", p.Name, id) + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +func (s *Server) handleRotatePeer(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.RotateKeys(r.Context(), r.PathValue("id")) + if err != nil { + engineError(w, err) + return + } + s.audit(r, "peer.keys_rotated", p.Name, p.ID) + writeJSON(w, http.StatusOK, createPeerResponse{peerBody: s.toPeerBody(p), Config: s.eng.ClientConfig(p)}) +} + +func safeFilename(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + case r == ' ' || r == '.': + b.WriteByte('-') + } + } + out := b.String() + if out == "" { + out = "wgx" + } + if len(out) > 15 { + // wg-quick derives the interface name from the file name and caps it + // at 15 characters. + out = out[:15] + } + return out +} + +func (s *Server) handlePeerConfig(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.Peer(r.PathValue("id")) + if err != nil { + engineError(w, err) + return + } + cfg := s.eng.ClientConfig(p) + if r.URL.Query().Get("download") != "" { + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.conf"`, safeFilename(p.Name))) + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + s.audit(r, "peer.config_viewed", p.Name, p.ID) + _, _ = w.Write([]byte(cfg)) +} + +func (s *Server) handlePeerQR(w http.ResponseWriter, r *http.Request) { + p, err := s.eng.Peer(r.PathValue("id")) + if err != nil { + engineError(w, err) + return + } + size, _ := strconv.Atoi(r.URL.Query().Get("size")) + png, err := s.eng.QRCode(p, size) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(png) +} + +func sinceParam(r *http.Request) time.Time { + switch r.URL.Query().Get("range") { + case "1h": + return time.Now().Add(-time.Hour) + case "7d": + return time.Now().Add(-7 * 24 * time.Hour) + case "30d": + return time.Now().Add(-30 * 24 * time.Hour) + case "90d": + return time.Now().Add(-90 * 24 * time.Hour) + default: + return time.Now().Add(-24 * time.Hour) + } +} + +func (s *Server) handlePeerUsage(w http.ResponseWriter, r *http.Request) { + pts, err := s.eng.Usage(r.Context(), r.PathValue("id"), sinceParam(r)) + if err != nil { + engineError(w, err) + return + } + writeJSON(w, http.StatusOK, pts) +} + +func (s *Server) handleUsage(w http.ResponseWriter, r *http.Request) { + pts, err := s.eng.Usage(r.Context(), "", sinceParam(r)) + if err != nil { + engineError(w, err) + return + } + writeJSON(w, http.StatusOK, pts) +} + +func (s *Server) handleUsageByPeer(w http.ResponseWriter, r *http.Request) { + u, err := s.eng.UsageByPeer(r.Context(), sinceParam(r)) + if err != nil { + engineError(w, err) + return + } + writeJSON(w, http.StatusOK, u) +} + +func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.eng.Settings()) +} + +func (s *Server) handlePutSettings(w http.ResponseWriter, r *http.Request) { + var in engine.Settings + if !readJSON(w, r, &in) { + return + } + if err := s.eng.UpdateSettings(r.Context(), in); err != nil { + if strings.Contains(err.Error(), "firewall") { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.audit(r, "settings.updated", "", "") + writeJSON(w, http.StatusOK, s.eng.Settings()) +} + +func (s *Server) handleAudit(w http.ResponseWriter, r *http.Request) { + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 || limit > 1000 { + limit = 200 + } + entries, err := s.eng.Store().ListAudit(r.Context(), limit) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, entries) +} + +// handleEvents streams status snapshots and change notices as SSE. +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, "streaming unsupported") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + // Send the current picture at once rather than waiting for the next poll. + snap := s.eng.Snapshot() + if !snap.At.IsZero() { + s.eng.Hub().Publish("status", snap) + } + ch, leave := s.eng.Hub().Subscribe() + defer leave() + fmt.Fprintf(w, "retry: 3000\n\n") + flusher.Flush() + keep := time.NewTicker(25 * time.Second) + defer keep.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-keep.C: + fmt.Fprintf(w, ": keepalive\n\n") + flusher.Flush() + case ev := <-ch: + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.Data) + flusher.Flush() + } + } +} + +// handleMetrics exposes Prometheus metrics, protected by a bearer token or +// an admin session. +func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { + allowed := false + if s.cfg.MetricsToken != "" { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if subtle.ConstantTimeCompare([]byte(tok), []byte(s.cfg.MetricsToken)) == 1 { + allowed = true + } + } + if !allowed { + if u, sess := s.loadSession(r); u != nil && !sess.TOTPPending { + allowed = true + } + } + if !allowed { + writeError(w, http.StatusUnauthorized, "metrics require a bearer token or a session") + return + } + snap := s.eng.Snapshot() + peers := s.eng.Peers() + names := map[string]string{} + for _, p := range peers { + names[p.ID] = p.Name + } + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + var b strings.Builder + fmt.Fprintf(&b, "# HELP wgx_peers Number of configured peers.\n# TYPE wgx_peers gauge\nwgx_peers %d\n", snap.Totals.Peers) + fmt.Fprintf(&b, "# HELP wgx_peers_connected Peers with a recent handshake.\n# TYPE wgx_peers_connected gauge\nwgx_peers_connected %d\n", snap.Totals.Connected) + fmt.Fprintf(&b, "# HELP wgx_receive_bytes_total Bytes received from peers.\n# TYPE wgx_receive_bytes_total counter\n") + for id, l := range snap.Peers { + fmt.Fprintf(&b, "wgx_receive_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Rx) + } + fmt.Fprintf(&b, "# HELP wgx_transmit_bytes_total Bytes sent to peers.\n# TYPE wgx_transmit_bytes_total counter\n") + for id, l := range snap.Peers { + fmt.Fprintf(&b, "wgx_transmit_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Tx) + } + fmt.Fprintf(&b, "# HELP wgx_peer_connected Whether the peer has a recent handshake.\n# TYPE wgx_peer_connected gauge\n") + for id, l := range snap.Peers { + v := 0 + if l.Connected { + v = 1 + } + fmt.Fprintf(&b, "wgx_peer_connected{peer=%q,name=%q} %d\n", id, names[id], v) + } + fmt.Fprintf(&b, "# HELP wgx_peer_last_handshake_seconds Unix time of the last handshake.\n# TYPE wgx_peer_last_handshake_seconds gauge\n") + for id, l := range snap.Peers { + if !l.LastHandshake.IsZero() { + fmt.Fprintf(&b, "wgx_peer_last_handshake_seconds{peer=%q,name=%q} %d\n", id, names[id], l.LastHandshake.Unix()) + } + } + _, _ = w.Write([]byte(b.String())) +} diff --git a/internal/server/auth_http.go b/internal/server/auth_http.go new file mode 100644 index 0000000..55dd898 --- /dev/null +++ b/internal/server/auth_http.go @@ -0,0 +1,745 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/skip2/go-qrcode" + + "github.com/Coffey-Labs/WGX/internal/auth" + "github.com/Coffey-Labs/WGX/internal/store" +) + +const cookieName = "wgx_session" + +type ctxKey int + +const ( + ctxUser ctxKey = iota + ctxSession +) + +// userOf returns the authenticated user for a request. +func userOf(r *http.Request) *store.User { + u, _ := r.Context().Value(ctxUser).(*store.User) + return u +} + +func sessionOf(r *http.Request) *store.Session { + s, _ := r.Context().Value(ctxSession).(*store.Session) + return s +} + +func (s *Server) setCookie(w http.ResponseWriter, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies, + SameSite: http.SameSiteStrictMode, + Expires: expires, + }) +} + +func (s *Server) clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.TLSEnabled() || s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1}) +} + +// loadSession resolves the cookie to a user, if the session is valid. +func (s *Server) loadSession(r *http.Request) (*store.User, *store.Session) { + c, err := r.Cookie(cookieName) + if err != nil || c.Value == "" { + return nil, nil + } + ctx := r.Context() + sess, err := s.eng.Store().SessionByHash(ctx, auth.HashToken(c.Value)) + if err != nil { + return nil, nil + } + now := time.Now() + if now.After(sess.ExpiresAt) || now.Sub(sess.CreatedAt) > s.cfg.SessionMax { + _ = s.eng.Store().DeleteSession(ctx, sess.TokenHash) + return nil, nil + } + u, err := s.eng.Store().UserByID(ctx, sess.UserID) + if err != nil { + return nil, nil + } + // Slide the idle expiry, but not on every request: once a minute is + // plenty and keeps the write load off SQLite. + if now.Sub(sess.LastSeenAt) > time.Minute { + _ = s.eng.Store().TouchSession(ctx, sess.TokenHash, now.Add(s.cfg.SessionIdle)) + } + return u, sess +} + +// authed requires a fully authenticated session. +func (s *Server) authed(next http.HandlerFunc) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-site request refused") + return + } + u, sess := s.loadSession(r) + if u == nil || sess.TOTPPending { + writeError(w, http.StatusUnauthorized, "not signed in") + return + } + ctx := context.WithValue(r.Context(), ctxUser, u) + ctx = context.WithValue(ctx, ctxSession, sess) + next(w, r.WithContext(ctx)) + }) +} + +// admin additionally requires the admin role. +func (s *Server) admin(next http.HandlerFunc) http.Handler { + return s.authed(func(w http.ResponseWriter, r *http.Request) { + if userOf(r).Role != "admin" { + writeError(w, http.StatusForbidden, "administrator role required") + return + } + next(w, r) + }) +} + +func (s *Server) audit(r *http.Request, action, target, detail string) { + actor := "-" + if u := userOf(r); u != nil { + actor = u.Username + } + if err := s.eng.Store().Audit(r.Context(), store.AuditEntry{Actor: actor, Action: action, Target: target, Detail: detail, IP: s.clientIP(r)}); err != nil { + s.log.Warn("audit write failed", "error", err) + } + s.log.Info("audit", "actor", actor, "action", action, "target", target, "detail", detail, "ip", s.clientIP(r)) +} + +// --- setup ----------------------------------------------------------------- + +type setupStatus struct { + NeedsSetup bool `json:"needsSetup"` +} + +func (s *Server) handleSetupStatus(w http.ResponseWriter, r *http.Request) { + n, err := s.eng.Store().CountUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, setupStatus{NeedsSetup: n == 0}) +} + +type setupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + EndpointHost string `json:"endpointHost"` +} + +func validUsername(u string) bool { + if len(u) < 2 || len(u) > 32 { + return false + } + for _, r := range u { + if !(r == '.' || r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { + return false + } + } + return true +} + +// handleSetup creates the first administrator. It only works while there +// are no users at all, so a running server cannot be taken over by it. +func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-site request refused") + return + } + ctx := r.Context() + n, err := s.eng.Store().CountUsers(ctx) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if n > 0 { + writeError(w, http.StatusConflict, "setup has already been completed") + return + } + var req setupRequest + if !readJSON(w, r, &req) { + return + } + req.Username = strings.TrimSpace(req.Username) + if !validUsername(req.Username) { + writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore") + return + } + if err := auth.ValidatePassword(req.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + settings := s.eng.Settings() + if host := strings.TrimSpace(req.EndpointHost); host != "" { + settings.EndpointHost = host + } + if err := settings.Validate(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + u, err := s.eng.Store().CreateUser(ctx, req.Username, hash, "admin") + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.UpdateSettings(ctx, settings); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.startSession(w, r, u, false); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + r = r.WithContext(context.WithValue(ctx, ctxUser, u)) + s.audit(r, "setup", u.Username, "first administrator created") + writeJSON(w, http.StatusCreated, s.meBody(ctx, u)) +} + +// --- login ----------------------------------------------------------------- + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type loginResponse struct { + TOTPRequired bool `json:"totpRequired"` +} + +func (s *Server) startSession(w http.ResponseWriter, r *http.Request, u *store.User, totpPending bool) error { + token, hash, err := auth.NewToken() + if err != nil { + return err + } + now := time.Now() + expires := now.Add(s.cfg.SessionIdle) + if totpPending { + expires = now.Add(10 * time.Minute) + } + ua := r.UserAgent() + if len(ua) > 200 { + ua = ua[:200] + } + if err := s.eng.Store().CreateSession(r.Context(), store.Session{ + TokenHash: hash, UserID: u.ID, CreatedAt: now, LastSeenAt: now, ExpiresAt: expires, + IP: s.clientIP(r), UserAgent: ua, TOTPPending: totpPending, + }); err != nil { + return err + } + s.setCookie(w, token, now.Add(s.cfg.SessionMax)) + return nil +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-site request refused") + return + } + ip := s.clientIP(r) + if ok, wait := s.ipLimit.Allowed(ip); !ok { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + writeError(w, http.StatusTooManyRequests, "too many attempts; try again later") + return + } + var req loginRequest + if !readJSON(w, r, &req) { + return + } + req.Username = strings.TrimSpace(req.Username) + if ok, wait := s.usrLimit.Allowed(strings.ToLower(req.Username)); !ok { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + writeError(w, http.StatusTooManyRequests, "too many attempts; try again later") + return + } + ctx := r.Context() + u, err := s.eng.Store().UserByName(ctx, req.Username) + if err != nil { + auth.EqualiseTiming() + s.ipLimit.Fail(ip) + s.usrLimit.Fail(strings.ToLower(req.Username)) + s.log.Warn("login failed", "user", req.Username, "ip", ip) + writeError(w, http.StatusUnauthorized, "wrong username or password") + return + } + if !auth.VerifyPassword(u.PasswordHash, req.Password) { + s.ipLimit.Fail(ip) + s.usrLimit.Fail(strings.ToLower(req.Username)) + s.log.Warn("login failed", "user", req.Username, "ip", ip) + _ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.failed", IP: ip}) + writeError(w, http.StatusUnauthorized, "wrong username or password") + return + } + s.ipLimit.Reset(ip) + s.usrLimit.Reset(strings.ToLower(req.Username)) + if err := s.startSession(w, r, u, u.TOTPEnabled); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if u.TOTPEnabled { + writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true}) + return + } + _ = s.eng.Store().TouchLogin(ctx, u.ID) + r = r.WithContext(context.WithValue(ctx, ctxUser, u)) + s.audit(r, "login", u.Username, "") + writeJSON(w, http.StatusOK, loginResponse{}) +} + +type totpRequest struct { + Code string `json:"code"` +} + +// handleLoginTOTP completes a login that is waiting on a second factor. +// A recovery code is accepted in place of a TOTP code. +func (s *Server) handleLoginTOTP(w http.ResponseWriter, r *http.Request) { + if !s.sameOrigin(r) { + writeError(w, http.StatusForbidden, "cross-site request refused") + return + } + u, sess := s.loadSession(r) + if u == nil || !sess.TOTPPending { + writeError(w, http.StatusUnauthorized, "no login in progress") + return + } + ip := s.clientIP(r) + if ok, wait := s.ipLimit.Allowed(ip); !ok { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + writeError(w, http.StatusTooManyRequests, "too many attempts; try again later") + return + } + var req totpRequest + if !readJSON(w, r, &req) { + return + } + ctx := r.Context() + ok := auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now()) + usedRecovery := false + if !ok { + used, err := s.eng.Store().UseRecoveryCode(ctx, u.ID, auth.HashToken(auth.NormaliseRecoveryCode(req.Code))) + if err == nil && used { + ok, usedRecovery = true, true + } + } + if !ok { + s.ipLimit.Fail(ip) + _ = s.eng.Store().Audit(ctx, store.AuditEntry{Actor: u.Username, Action: "login.totp_failed", IP: ip}) + writeError(w, http.StatusUnauthorized, "wrong code") + return + } + s.ipLimit.Reset(ip) + if err := s.eng.Store().ClearTOTPPending(ctx, sess.TokenHash); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = s.eng.Store().TouchSession(ctx, sess.TokenHash, time.Now().Add(s.cfg.SessionIdle)) + _ = s.eng.Store().TouchLogin(ctx, u.ID) + r = r.WithContext(context.WithValue(ctx, ctxUser, u)) + detail := "" + if usedRecovery { + detail = "recovery code used" + } + s.audit(r, "login", u.Username, detail) + writeJSON(w, http.StatusOK, loginResponse{}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + if sess := sessionOf(r); sess != nil { + _ = s.eng.Store().DeleteSession(r.Context(), sess.TokenHash) + } + s.clearCookie(w) + s.audit(r, "logout", userOf(r).Username, "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// --- account --------------------------------------------------------------- + +type meBody struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + TOTPEnabled bool `json:"totpEnabled"` + RecoveryCodes int `json:"recoveryCodesLeft"` + CreatedAt time.Time `json:"createdAt"` + LastLoginAt time.Time `json:"lastLoginAt,omitempty"` +} + +func (s *Server) meBody(ctx context.Context, u *store.User) meBody { + left, _ := s.eng.Store().RecoveryCodesLeft(ctx, u.ID) + return meBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, RecoveryCodes: left, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt} +} + +func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, s.meBody(r.Context(), userOf(r))) +} + +type changePasswordRequest struct { + Current string `json:"current"` + New string `json:"new"` +} + +func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { + var req changePasswordRequest + if !readJSON(w, r, &req) { + return + } + u := userOf(r) + if !auth.VerifyPassword(u.PasswordHash, req.Current) { + writeError(w, http.StatusForbidden, "current password is wrong") + return + } + if err := auth.ValidatePassword(req.New); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.New) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.Store().SetPassword(r.Context(), u.ID, hash); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + // Every other browser is signed out; this one keeps its session. + sess := sessionOf(r) + _ = s.eng.Store().DeleteUserSessions(r.Context(), u.ID) + if err := s.startSession(w, r, u, false); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = sess + s.audit(r, "password.changed", u.Username, "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +type totpSetupResponse struct { + Secret string `json:"secret"` + URI string `json:"uri"` +} + +// handleTOTPSetup issues a pending secret; it becomes active on confirm. +func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) { + u := userOf(r) + if u.TOTPEnabled { + writeError(w, http.StatusConflict, "two-factor authentication is already on") + return + } + secret, err := auth.NewTOTPSecret() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.Store().SetTOTP(r.Context(), u.ID, secret, false); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("WGX", u.Username, secret)}) +} + +// handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a +// secret that is not yet active is shown: an enabled one must never leave +// the server again. +func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) { + u := userOf(r) + if u.TOTPEnabled || u.TOTPSecret == "" { + writeError(w, http.StatusNotFound, "no two-factor setup in progress") + return + } + png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(png) +} + +type totpConfirmResponse struct { + RecoveryCodes []string `json:"recoveryCodes"` +} + +func (s *Server) handleTOTPConfirm(w http.ResponseWriter, r *http.Request) { + var req totpRequest + if !readJSON(w, r, &req) { + return + } + u := userOf(r) + if u.TOTPEnabled || u.TOTPSecret == "" { + writeError(w, http.StatusConflict, "start two-factor setup first") + return + } + if !auth.VerifyTOTP(u.TOTPSecret, req.Code, time.Now()) { + writeError(w, http.StatusBadRequest, "wrong code; check the time on your device") + return + } + codes, hashes, err := auth.NewRecoveryCodes(8) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + ctx := r.Context() + if err := s.eng.Store().SetTOTP(ctx, u.ID, u.TOTPSecret, true); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, hashes); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.audit(r, "totp.enabled", u.Username, "") + writeJSON(w, http.StatusOK, totpConfirmResponse{RecoveryCodes: codes}) +} + +type totpDisableRequest struct { + Password string `json:"password"` +} + +func (s *Server) handleTOTPDisable(w http.ResponseWriter, r *http.Request) { + var req totpDisableRequest + if !readJSON(w, r, &req) { + return + } + u := userOf(r) + if !auth.VerifyPassword(u.PasswordHash, req.Password) { + writeError(w, http.StatusForbidden, "password is wrong") + return + } + ctx := r.Context() + if err := s.eng.Store().SetTOTP(ctx, u.ID, "", false); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = s.eng.Store().ReplaceRecoveryCodes(ctx, u.ID, nil) + s.audit(r, "totp.disabled", u.Username, "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +type sessionBody struct { + Current bool `json:"current"` + CreatedAt time.Time `json:"createdAt"` + LastSeenAt time.Time `json:"lastSeenAt"` + IP string `json:"ip"` + UserAgent string `json:"userAgent"` +} + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + u := userOf(r) + cur := sessionOf(r) + rows, err := s.eng.Store().DB().QueryContext(r.Context(), `SELECT token_hash, created_at, last_seen_at, ip, user_agent FROM sessions WHERE user_id = ? AND totp_pending = 0 ORDER BY last_seen_at DESC`, u.ID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer rows.Close() + out := []sessionBody{} + for rows.Next() { + var hash, ip, ua string + var created, seen int64 + if err := rows.Scan(&hash, &created, &seen, &ip, &ua); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out = append(out, sessionBody{Current: hash == cur.TokenHash, CreatedAt: time.Unix(created, 0), LastSeenAt: time.Unix(seen, 0), IP: ip, UserAgent: ua}) + } + writeJSON(w, http.StatusOK, out) +} + +// handleRevokeSessions signs the user out everywhere but here. +func (s *Server) handleRevokeSessions(w http.ResponseWriter, r *http.Request) { + u := userOf(r) + cur := sessionOf(r) + if _, err := s.eng.Store().DB().ExecContext(r.Context(), `DELETE FROM sessions WHERE user_id = ? AND token_hash != ?`, u.ID, cur.TokenHash); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.audit(r, "sessions.revoked", u.Username, "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + +// --- users ----------------------------------------------------------------- + +type userBody struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + TOTPEnabled bool `json:"totpEnabled"` + CreatedAt time.Time `json:"createdAt"` + LastLoginAt time.Time `json:"lastLoginAt,omitempty"` +} + +func toUserBody(u *store.User) userBody { + return userBody{ID: u.ID, Username: u.Username, Role: u.Role, TOTPEnabled: u.TOTPEnabled, CreatedAt: u.CreatedAt, LastLoginAt: u.LastLoginAt} +} + +func (s *Server) handleUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.eng.Store().ListUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + out := make([]userBody, 0, len(users)) + for _, u := range users { + out = append(out, toUserBody(u)) + } + writeJSON(w, http.StatusOK, out) +} + +type userRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Role string `json:"role"` +} + +func validRole(role string) bool { return role == "admin" || role == "viewer" } + +func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var req userRequest + if !readJSON(w, r, &req) { + return + } + req.Username = strings.TrimSpace(req.Username) + if !validUsername(req.Username) { + writeError(w, http.StatusBadRequest, "username must be 2-32 characters: letters, digits, dot, dash or underscore") + return + } + if !validRole(req.Role) { + writeError(w, http.StatusBadRequest, "role must be admin or viewer") + return + } + if err := auth.ValidatePassword(req.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + u, err := s.eng.Store().CreateUser(r.Context(), req.Username, hash, req.Role) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + writeError(w, http.StatusConflict, "that username is taken") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.audit(r, "user.created", u.Username, "role "+u.Role) + writeJSON(w, http.StatusCreated, toUserBody(u)) +} + +type userUpdateRequest struct { + Role string `json:"role,omitempty"` + Password string `json:"password,omitempty"` + ResetTOTP bool `json:"resetTotp,omitempty"` +} + +func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad user id") + return + } + var req userUpdateRequest + if !readJSON(w, r, &req) { + return + } + ctx := r.Context() + target, err := s.eng.Store().UserByID(ctx, id) + if err != nil { + writeError(w, http.StatusNotFound, "no such user") + return + } + var changes []string + if req.Role != "" && req.Role != target.Role { + if !validRole(req.Role) { + writeError(w, http.StatusBadRequest, "role must be admin or viewer") + return + } + if target.ID == userOf(r).ID { + writeError(w, http.StatusBadRequest, "you cannot change your own role") + return + } + if err := s.eng.Store().SetRole(ctx, id, req.Role); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + changes = append(changes, "role "+req.Role) + } + if req.Password != "" { + if err := auth.ValidatePassword(req.Password); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + hash, err := auth.HashPassword(req.Password) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.Store().SetPassword(ctx, id, hash); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = s.eng.Store().DeleteUserSessions(ctx, id) + changes = append(changes, "password reset") + } + if req.ResetTOTP && target.TOTPEnabled { + if err := s.eng.Store().SetTOTP(ctx, id, "", false); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + _ = s.eng.Store().ReplaceRecoveryCodes(ctx, id, nil) + changes = append(changes, "two-factor reset") + } + if len(changes) > 0 { + s.audit(r, "user.updated", target.Username, strings.Join(changes, ", ")) + } + u, _ := s.eng.Store().UserByID(ctx, id) + writeJSON(w, http.StatusOK, toUserBody(u)) +} + +func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "bad user id") + return + } + if id == userOf(r).ID { + writeError(w, http.StatusBadRequest, "you cannot delete yourself") + return + } + ctx := r.Context() + target, err := s.eng.Store().UserByID(ctx, id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "no such user") + return + } + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if err := s.eng.Store().DeleteUser(ctx, id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + s.audit(r, "user.deleted", target.Username, "") + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1a20f5a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,336 @@ +// Package server is the admin HTTP API and the host for the embedded UI. +package server + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "net" + "net/http" + "net/netip" + "path" + "strings" + "time" + + "github.com/Coffey-Labs/WGX/internal/auth" + "github.com/Coffey-Labs/WGX/internal/config" + "github.com/Coffey-Labs/WGX/internal/engine" + "github.com/Coffey-Labs/WGX/internal/server/static" +) + +// Server serves the API and UI. +type Server struct { + cfg *config.Config + eng *engine.Engine + log *slog.Logger + mux *http.ServeMux + ipLimit *auth.Limiter + usrLimit *auth.Limiter + http *http.Server +} + +// New builds the router. +func New(cfg *config.Config, eng *engine.Engine, log *slog.Logger) *Server { + s := &Server{ + cfg: cfg, + eng: eng, + log: log, + mux: http.NewServeMux(), + ipLimit: auth.NewLimiter(20, 15*time.Minute), + usrLimit: auth.NewLimiter(8, 15*time.Minute), + } + s.routes() + return s +} + +// Handler returns the full middleware chain, for tests and for ListenAndServe. +func (s *Server) Handler() http.Handler { + return s.recoverer(s.securityHeaders(s.mux)) +} + +// ListenAndServe runs until ctx is cancelled. +func (s *Server) ListenAndServe(ctx context.Context) error { + s.http = &http.Server{ + Addr: s.cfg.HTTP, + Handler: s.Handler(), + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 30 * time.Second, + // No WriteTimeout: the SSE stream is long-lived. Handlers that + // matter bound themselves. + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 64 << 10, + ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn), + } + var tlsCfg *tls.Config + if s.cfg.TLSEnabled() { + cert, err := s.loadCertificate() + if err != nil { + return err + } + tlsCfg = &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, + } + s.http.TLSConfig = tlsCfg + } + ln, err := net.Listen("tcp", s.cfg.HTTP) + if err != nil { + return fmt.Errorf("listen %s: %w", s.cfg.HTTP, err) + } + errCh := make(chan error, 1) + go func() { + if tlsCfg != nil { + errCh <- s.http.ServeTLS(ln, "", "") + } else { + errCh <- s.http.Serve(ln) + } + }() + s.log.Info("admin UI listening", "addr", ln.Addr().String(), "tls", tlsCfg != nil) + select { + case <-ctx.Done(): + shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return s.http.Shutdown(shutdown) + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} + +func (s *Server) routes() { + m := s.mux + // Unauthenticated. + m.HandleFunc("GET /api/health", s.handleHealth) + m.HandleFunc("GET /api/setup", s.handleSetupStatus) + m.HandleFunc("POST /api/setup", s.handleSetup) + m.HandleFunc("POST /api/auth/login", s.handleLogin) + m.HandleFunc("POST /api/auth/totp", s.handleLoginTOTP) + m.HandleFunc("GET /metrics", s.handleMetrics) + + // Session required. + m.Handle("GET /api/auth/me", s.authed(s.handleMe)) + m.Handle("POST /api/auth/logout", s.authed(s.handleLogout)) + m.Handle("POST /api/auth/password", s.authed(s.handleChangePassword)) + m.Handle("POST /api/auth/totp/setup", s.authed(s.handleTOTPSetup)) + m.Handle("GET /api/auth/totp/qr.png", s.authed(s.handleTOTPQR)) + m.Handle("POST /api/auth/totp/confirm", s.authed(s.handleTOTPConfirm)) + m.Handle("POST /api/auth/totp/disable", s.authed(s.handleTOTPDisable)) + m.Handle("GET /api/auth/sessions", s.authed(s.handleSessions)) + m.Handle("POST /api/auth/sessions/revoke", s.authed(s.handleRevokeSessions)) + + m.Handle("GET /api/status", s.authed(s.handleStatus)) + m.Handle("GET /api/events", s.authed(s.handleEvents)) + m.Handle("GET /api/peers", s.authed(s.handlePeers)) + m.Handle("GET /api/peers/{id}", s.authed(s.handlePeer)) + m.Handle("GET /api/peers/{id}/config", s.authed(s.handlePeerConfig)) + m.Handle("GET /api/peers/{id}/qr.png", s.authed(s.handlePeerQR)) + m.Handle("GET /api/peers/{id}/usage", s.authed(s.handlePeerUsage)) + m.Handle("GET /api/usage", s.authed(s.handleUsage)) + m.Handle("GET /api/usage/peers", s.authed(s.handleUsageByPeer)) + m.Handle("GET /api/settings", s.authed(s.handleGetSettings)) + m.Handle("GET /api/audit", s.authed(s.handleAudit)) + m.Handle("GET /api/users", s.authed(s.handleUsers)) + + // Admin role required. + m.Handle("POST /api/peers", s.admin(s.handleCreatePeer)) + m.Handle("PUT /api/peers/{id}", s.admin(s.handleUpdatePeer)) + m.Handle("DELETE /api/peers/{id}", s.admin(s.handleDeletePeer)) + m.Handle("POST /api/peers/{id}/enable", s.admin(s.handleEnablePeer)) + m.Handle("POST /api/peers/{id}/disable", s.admin(s.handleDisablePeer)) + m.Handle("POST /api/peers/{id}/reset", s.admin(s.handleResetPeer)) + m.Handle("POST /api/peers/{id}/rotate", s.admin(s.handleRotatePeer)) + m.Handle("PUT /api/settings", s.admin(s.handlePutSettings)) + m.Handle("POST /api/users", s.admin(s.handleCreateUser)) + m.Handle("PUT /api/users/{id}", s.admin(s.handleUpdateUser)) + m.Handle("DELETE /api/users/{id}", s.admin(s.handleDeleteUser)) + + m.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "no such endpoint") + }) + m.Handle("/", s.spa()) +} + +// spa serves the embedded UI, falling back to index.html for client routes. +func (s *Server) spa() http.Handler { + files := static.FS() + fileServer := http.FileServerFS(files) + index, _ := fs.ReadFile(files, "index.html") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + p := path.Clean(r.URL.Path) + if p != "/" { + if f, err := files.Open(strings.TrimPrefix(p, "/")); err == nil { + f.Close() + if strings.HasPrefix(p, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + fileServer.ServeHTTP(w, r) + return + } + } + if index == nil { + http.Error(w, "the admin UI has not been built; run `npm run build` in web/", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Write(index) + }) +} + +// --- middleware ------------------------------------------------------------ + +func (s *Server) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if rec := recover(); rec != nil { + if rec == http.ErrAbortHandler { + panic(rec) + } + s.log.Error("panic", "path", r.URL.Path, "error", rec) + writeError(w, http.StatusInternalServerError, "internal error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'") + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + h.Set("Cross-Origin-Opener-Policy", "same-origin") + if s.cfg.TLSEnabled() || s.cfg.SecureCookies { + h.Set("Strict-Transport-Security", "max-age=31536000") + } + if strings.HasPrefix(r.URL.Path, "/api/") { + h.Set("Cache-Control", "no-store") + } + next.ServeHTTP(w, r) + }) +} + +// sameOrigin rejects cross-site state changes. Cookies are SameSite=Strict +// already; this is the belt to that brace, for browsers that send +// Sec-Fetch-Site or Origin. +func (s *Server) sameOrigin(r *http.Request) bool { + switch r.Method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return true + } + if site := r.Header.Get("Sec-Fetch-Site"); site != "" { + return site == "same-origin" || site == "none" + } + if origin := r.Header.Get("Origin"); origin != "" { + host := r.Host + return strings.EqualFold(strings.TrimPrefix(strings.TrimPrefix(origin, "https://"), "http://"), host) + } + // Neither header: not a modern browser. A non-browser client cannot be + // tricked by a third-party page, so allow it. + return true +} + +// clientIP returns the caller's address, honouring proxy headers only from +// trusted proxies. +func (s *Server) clientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + addr, err := netip.ParseAddr(host) + if err != nil { + return host + } + if !s.trusted(addr) { + return addr.String() + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + // Walk from the right, skipping trusted hops, to the first address + // that is not one of our proxies. + for i := len(parts) - 1; i >= 0; i-- { + a, err := netip.ParseAddr(strings.TrimSpace(parts[i])) + if err != nil { + break + } + if !s.trusted(a) { + return a.String() + } + } + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + if a, err := netip.ParseAddr(real); err == nil { + return a.String() + } + } + return addr.String() +} + +func (s *Server) trusted(a netip.Addr) bool { + for _, p := range s.cfg.TrustedProxies { + if p.Contains(a) { + return true + } + } + return false +} + +// --- helpers --------------------------------------------------------------- + +type errorBody struct { + Error string `json:"error"` +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, errorBody{Error: msg}) +} + +// readJSON decodes a small JSON body strictly. +func readJSON(w http.ResponseWriter, r *http.Request, v any) bool { + ct := r.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + writeError(w, http.StatusUnsupportedMediaType, "expected application/json") + return false + } + dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + dec.DisallowUnknownFields() + if err := dec.Decode(v); err != nil { + writeError(w, http.StatusBadRequest, "bad JSON: "+err.Error()) + return false + } + return true +} + +// engineError maps engine errors to status codes. +func engineError(w http.ResponseWriter, err error) { + var ve engine.ErrValidation + switch { + case errors.As(err, &ve): + writeError(w, http.StatusBadRequest, ve.Msg) + case errors.Is(err, engine.ErrNotFound): + writeError(w, http.StatusNotFound, "not found") + default: + writeError(w, http.StatusInternalServerError, err.Error()) + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..06fec26 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,265 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "github.com/Coffey-Labs/WGX/internal/auth" + "github.com/Coffey-Labs/WGX/internal/config" + "github.com/Coffey-Labs/WGX/internal/engine" + "github.com/Coffey-Labs/WGX/internal/store" + "github.com/Coffey-Labs/WGX/internal/wg" +) + +type client struct { + t *testing.T + srv *httptest.Server + c *http.Client +} + +func newClient(t *testing.T) (*client, *engine.Engine) { + t.Helper() + cfg := &config.Config{ + DBPath: ":memory:", Backend: "mock", Iface: "wg0", ListenPort: 51820, + Subnet4: netip.MustParsePrefix("10.8.0.0/24"), HTTP: "127.0.0.1:0", + SessionIdle: time.Hour, SessionMax: 24 * time.Hour, TrafficRetention: time.Hour, PollInterval: time.Hour, + InitialEndpoint: "vpn.example.com", InitialDNS: "1.1.1.1", MetricsToken: "metrics-secret", + } + st, err := store.Open(":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + log := slog.New(slog.NewTextHandler(io.Discard, nil)) + eng := engine.New(cfg, st, wg.NewMock("wg0", false), log) + if err := eng.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = eng.Stop(context.Background()) }) + s := New(cfg, eng, log) + srv := httptest.NewServer(s.Handler()) + t.Cleanup(srv.Close) + jar, _ := cookiejar.New(nil) + return &client{t: t, srv: srv, c: &http.Client{Jar: jar}}, eng +} + +func (c *client) do(method, path string, body any, headers ...string) (*http.Response, []byte) { + c.t.Helper() + var rd io.Reader + if body != nil { + b, _ := json.Marshal(body) + rd = bytes.NewReader(b) + } + req, _ := http.NewRequest(method, c.srv.URL+path, rd) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + res, err := c.c.Do(req) + if err != nil { + c.t.Fatal(err) + } + defer res.Body.Close() + out, _ := io.ReadAll(res.Body) + return res, out +} + +func (c *client) expect(method, path string, body any, status int) []byte { + c.t.Helper() + res, out := c.do(method, path, body) + if res.StatusCode != status { + c.t.Fatalf("%s %s: got %d, want %d: %s", method, path, res.StatusCode, status, out) + } + return out +} + +func TestSetupLoginAndPeers(t *testing.T) { + c, _ := newClient(t) + + // Before setup: nothing works, setup is announced. + out := c.expect("GET", "/api/setup", nil, 200) + if !strings.Contains(string(out), `"needsSetup":true`) { + t.Fatal(out) + } + c.expect("GET", "/api/peers", nil, 401) + c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "short"}, 400) + c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201) + c.expect("POST", "/api/setup", map[string]string{"username": "x", "password": "a-long-enough-password"}, 409) + + // Setup signed us in. + out = c.expect("GET", "/api/auth/me", nil, 200) + if !strings.Contains(string(out), `"username":"admin"`) || !strings.Contains(string(out), `"role":"admin"`) { + t.Fatal(string(out)) + } + c.expect("POST", "/api/auth/logout", nil, 200) + c.expect("GET", "/api/auth/me", nil, 401) + + c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"}, 401) + c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200) + + // Cross-site state change refused. + res, _ := c.do("POST", "/api/peers", map[string]string{"name": "x"}, "Sec-Fetch-Site", "cross-site") + if res.StatusCode != 403 { + t.Fatalf("cross-site request got %d", res.StatusCode) + } + + out = c.expect("POST", "/api/peers", map[string]any{"name": "Laptop", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 201) + var created struct { + ID string `json:"id"` + IPv4 string `json:"ipv4"` + Config string `json:"config"` + } + _ = json.Unmarshal(out, &created) + if created.IPv4 != "10.8.0.2" || !strings.Contains(created.Config, "Endpoint = vpn.test:51820") { + t.Fatalf("%+v", created) + } + if strings.Contains(string(out), `"privateKey"`) { + t.Fatal("private key leaked in peer body") + } + out = c.expect("GET", "/api/peers", nil, 200) + if !strings.Contains(string(out), `"name":"Laptop"`) { + t.Fatal(string(out)) + } + res, out = c.do("GET", "/api/peers/"+created.ID+"/config?download=1", nil) + if res.StatusCode != 200 || !strings.Contains(res.Header.Get("Content-Disposition"), `Laptop.conf`) || !strings.Contains(string(out), "[Interface]") { + t.Fatalf("config download: %d %s", res.StatusCode, res.Header) + } + res, out = c.do("GET", "/api/peers/"+created.ID+"/qr.png", nil) + if res.StatusCode != 200 || res.Header.Get("Content-Type") != "image/png" || !bytes.HasPrefix(out, []byte("\x89PNG")) { + t.Fatalf("qr: %d %s", res.StatusCode, res.Header.Get("Content-Type")) + } + c.expect("POST", "/api/peers/"+created.ID+"/disable", nil, 200) + out = c.expect("GET", "/api/peers/"+created.ID, nil, 200) + if !strings.Contains(string(out), `"enabled":false`) { + t.Fatal(string(out)) + } + c.expect("POST", "/api/peers/"+created.ID+"/enable", nil, 200) + c.expect("POST", "/api/peers/"+created.ID+"/reset", nil, 200) + c.expect("POST", "/api/peers/"+created.ID+"/rotate", nil, 200) + c.expect("PUT", "/api/peers/"+created.ID, map[string]any{"name": "Laptop 2", "clientRoutes": "10.8.0.0/24", "dns": "9.9.9.9", "keepalive": 15, "mtu": 1380, "expiresAt": nil, "notes": "n"}, 200) + c.expect("GET", "/api/peers/"+created.ID+"/usage?range=24h", nil, 200) + c.expect("GET", "/api/usage?range=7d", nil, 200) + c.expect("GET", "/api/status", nil, 200) + c.expect("GET", "/api/audit", nil, 200) + c.expect("DELETE", "/api/peers/"+created.ID, nil, 200) + c.expect("GET", "/api/peers/"+created.ID, nil, 404) + + // Settings round trip. + out = c.expect("GET", "/api/settings", nil, 200) + var s engine.Settings + _ = json.Unmarshal(out, &s) + s.MTU = 1400 + c.expect("PUT", "/api/settings", s, 200) + s.MTU = 10 + c.expect("PUT", "/api/settings", s, 400) + + // Metrics: token or session. + res, _ = c.do("GET", "/metrics", nil) + if res.StatusCode != 200 { + t.Fatalf("metrics with session: %d", res.StatusCode) + } + anon := &http.Client{} + req, _ := http.NewRequest("GET", c.srv.URL+"/metrics", nil) + if r, _ := anon.Do(req); r.StatusCode != 401 { + t.Fatalf("anonymous metrics: %d", r.StatusCode) + } + req.Header.Set("Authorization", "Bearer metrics-secret") + r, _ := anon.Do(req) + b, _ := io.ReadAll(r.Body) + if r.StatusCode != 200 || !strings.Contains(string(b), "wgx_peers ") { + t.Fatalf("token metrics: %d %s", r.StatusCode, b) + } +} + +func TestViewerRoleAndUsers(t *testing.T) { + c, _ := newClient(t) + c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201) + c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 201) + c.expect("POST", "/api/users", map[string]string{"username": "eve", "password": "another-long-password", "role": "viewer"}, 409) + c.expect("POST", "/api/auth/logout", nil, 200) + c.expect("POST", "/api/auth/login", map[string]string{"username": "eve", "password": "another-long-password"}, 200) + c.expect("GET", "/api/peers", nil, 200) + c.expect("POST", "/api/peers", map[string]any{"name": "x", "clientRoutes": "", "dns": "", "keepalive": nil, "mtu": nil, "expiresAt": nil, "notes": ""}, 403) + c.expect("GET", "/api/users", nil, 200) + c.expect("DELETE", "/api/users/1", nil, 403) +} + +func TestTOTPFlow(t *testing.T) { + c, _ := newClient(t) + c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201) + out := c.expect("POST", "/api/auth/totp/setup", nil, 200) + var setup struct{ Secret string } + _ = json.Unmarshal(out, &setup) + c.expect("GET", "/api/auth/totp/qr.png", nil, 200) + c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": "000000"}, 400) + code, _ := auth.TOTPNow(setup.Secret, time.Now()) + out = c.expect("POST", "/api/auth/totp/confirm", map[string]string{"code": code}, 200) + var conf struct{ RecoveryCodes []string } + _ = json.Unmarshal(out, &conf) + if len(conf.RecoveryCodes) != 8 { + t.Fatal("no recovery codes") + } + c.expect("POST", "/api/auth/logout", nil, 200) + + // Login now stops half way. + out = c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200) + if !strings.Contains(string(out), `"totpRequired":true`) { + t.Fatal(string(out)) + } + c.expect("GET", "/api/peers", nil, 401) + c.expect("POST", "/api/auth/totp", map[string]string{"code": "123456"}, 401) + code, _ = auth.TOTPNow(setup.Secret, time.Now()) + c.expect("POST", "/api/auth/totp", map[string]string{"code": code}, 200) + c.expect("GET", "/api/peers", nil, 200) + + // A recovery code works once. + c.expect("POST", "/api/auth/logout", nil, 200) + c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200) + c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 200) + c.expect("POST", "/api/auth/logout", nil, 200) + c.expect("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "a-long-enough-password"}, 200) + c.expect("POST", "/api/auth/totp", map[string]string{"code": conf.RecoveryCodes[0]}, 401) +} + +func TestLoginRateLimit(t *testing.T) { + c, _ := newClient(t) + c.expect("POST", "/api/setup", map[string]string{"username": "admin", "password": "a-long-enough-password", "endpointHost": "vpn.test"}, 201) + c.expect("POST", "/api/auth/logout", nil, 200) + var last int + for i := 0; i < 10; i++ { + res, _ := c.do("POST", "/api/auth/login", map[string]string{"username": "admin", "password": "wrong-password-here"}) + last = res.StatusCode + } + if last != 429 { + t.Fatalf("expected 429 after repeated failures, got %d", last) + } +} + +func TestSecurityHeadersAndSPA(t *testing.T) { + c, _ := newClient(t) + res, _ := c.do("GET", "/api/health", nil) + for _, h := range []string{"Content-Security-Policy", "X-Frame-Options", "X-Content-Type-Options", "Referrer-Policy"} { + if res.Header.Get(h) == "" { + t.Errorf("missing %s", h) + } + } + if res.Header.Get("Cache-Control") != "no-store" { + t.Error("api responses must be no-store") + } + res, _ = c.do("GET", "/api/nope", nil) + if res.StatusCode != 404 { + t.Errorf("unknown api path: %d", res.StatusCode) + } +} diff --git a/internal/server/static/dist/.gitkeep b/internal/server/static/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/server/static/embed.go b/internal/server/static/embed.go new file mode 100644 index 0000000..88ed81e --- /dev/null +++ b/internal/server/static/embed.go @@ -0,0 +1,20 @@ +// Package static carries the built admin UI inside the binary. `npm run +// build` in web/ writes into dist/; the Go build embeds whatever is there. +package static + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +// FS returns the built UI rooted at dist/. +func FS() fs.FS { + sub, err := fs.Sub(dist, "dist") + if err != nil { + panic(err) + } + return sub +} diff --git a/internal/server/tls.go b/internal/server/tls.go new file mode 100644 index 0000000..45f1e41 --- /dev/null +++ b/internal/server/tls.go @@ -0,0 +1,80 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" +) + +// loadCertificate returns the configured certificate, generating and +// persisting a self-signed one when asked to. +func (s *Server) loadCertificate() (tls.Certificate, error) { + if s.cfg.TLSCert != "" { + cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + if err != nil { + return tls.Certificate{}, fmt.Errorf("load TLS certificate: %w", err) + } + return cert, nil + } + certPath := filepath.Join(s.cfg.DataDir, "tls.crt") + keyPath := filepath.Join(s.cfg.DataDir, "tls.key") + if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil { + if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil && time.Now().Before(leaf.NotAfter.Add(-30*24*time.Hour)) { + return cert, nil + } + } + s.log.Info("generating a self-signed TLS certificate", "path", certPath) + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + host := s.eng.Settings().EndpointHost + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "WGX", Organization: []string{"WGX"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + } + if host != "" { + if ip := net.ParseIP(host); ip != nil { + tmpl.IPAddresses = append(tmpl.IPAddresses, ip) + } else { + tmpl.DNSNames = append(tmpl.DNSNames, host) + } + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, err + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return tls.Certificate{}, err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return tls.Certificate{}, err + } + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return tls.Certificate{}, err + } + return tls.X509KeyPair(certPEM, keyPEM) +} diff --git a/internal/store/peers.go b/internal/store/peers.go new file mode 100644 index 0000000..b5d2280 --- /dev/null +++ b/internal/store/peers.go @@ -0,0 +1,300 @@ +package store + +import ( + "context" + "database/sql" + "time" +) + +// Peer is a client device. +type Peer struct { + ID string + Name string + PublicKey string + PrivateKey string // empty when the client generated its own key pair + PresharedKey string + IPv4 string // tunnel address without prefix, e.g. 10.8.0.2 + IPv6 string // may be empty + ClientRoutes string // AllowedIPs the *client* routes into the tunnel + DNS string // override; empty means the server default + Keepalive int // seconds; 0 means the server default + MTU int // 0 means the server default + Enabled bool + ExpiresAt time.Time + Notes string + CreatedAt time.Time + UpdatedAt time.Time + RxTotal int64 + TxTotal int64 + LastHandshake time.Time + LastEndpoint string +} + +const peerCols = `id, name, public_key, private_key, preshared_key, ipv4, ipv6, client_routes, dns, keepalive, mtu, enabled, expires_at, notes, created_at, updated_at, rx_total, tx_total, last_handshake, last_endpoint` + +func scanPeer(row interface{ Scan(...any) error }) (*Peer, error) { + var p Peer + var priv, psk, v6 sql.NullString + var enabled int + var exp, created, updated int64 + var expN, hsN sql.NullInt64 + if err := row.Scan(&p.ID, &p.Name, &p.PublicKey, &priv, &psk, &p.IPv4, &v6, &p.ClientRoutes, &p.DNS, &p.Keepalive, &p.MTU, &enabled, &expN, &p.Notes, &created, &updated, &p.RxTotal, &p.TxTotal, &hsN, &p.LastEndpoint); err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, err + } + _ = exp + p.PrivateKey = priv.String + p.PresharedKey = psk.String + p.IPv6 = v6.String + p.Enabled = enabled == 1 + if expN.Valid { + p.ExpiresAt = time.Unix(expN.Int64, 0) + } + p.CreatedAt = time.Unix(created, 0) + p.UpdatedAt = time.Unix(updated, 0) + if hsN.Valid && hsN.Int64 > 0 { + p.LastHandshake = time.Unix(hsN.Int64, 0) + } + return &p, nil +} + +func nullStr(s string) any { + if s == "" { + return nil + } + return s +} + +func nullTime(t time.Time) any { + if t.IsZero() { + return nil + } + return t.Unix() +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} + +// CreatePeer inserts a peer. +func (s *Store) CreatePeer(ctx context.Context, p *Peer) error { + now := time.Now() + p.CreatedAt, p.UpdatedAt = now, now + _, err := s.db.ExecContext(ctx, `INSERT INTO peers(`+peerCols+`) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + p.ID, p.Name, p.PublicKey, nullStr(p.PrivateKey), nullStr(p.PresharedKey), p.IPv4, nullStr(p.IPv6), p.ClientRoutes, p.DNS, p.Keepalive, p.MTU, boolInt(p.Enabled), nullTime(p.ExpiresAt), p.Notes, now.Unix(), now.Unix(), p.RxTotal, p.TxTotal, nullTime(p.LastHandshake), p.LastEndpoint) + return err +} + +// UpdatePeer writes every editable column of a peer. +func (s *Store) UpdatePeer(ctx context.Context, p *Peer) error { + p.UpdatedAt = time.Now() + _, err := s.db.ExecContext(ctx, `UPDATE peers SET name=?, public_key=?, private_key=?, preshared_key=?, ipv4=?, ipv6=?, client_routes=?, dns=?, keepalive=?, mtu=?, enabled=?, expires_at=?, notes=?, updated_at=? WHERE id=?`, + p.Name, p.PublicKey, nullStr(p.PrivateKey), nullStr(p.PresharedKey), p.IPv4, nullStr(p.IPv6), p.ClientRoutes, p.DNS, p.Keepalive, p.MTU, boolInt(p.Enabled), nullTime(p.ExpiresAt), p.Notes, p.UpdatedAt.Unix(), p.ID) + return err +} + +// PeerByID loads one peer. +func (s *Store) PeerByID(ctx context.Context, id string) (*Peer, error) { + return scanPeer(s.db.QueryRowContext(ctx, `SELECT `+peerCols+` FROM peers WHERE id = ?`, id)) +} + +// ListPeers returns every peer, newest first. +func (s *Store) ListPeers(ctx context.Context) ([]*Peer, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+peerCols+` FROM peers ORDER BY created_at DESC, id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*Peer + for rows.Next() { + p, err := scanPeer(rows) + if err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// DeletePeer removes a peer and its traffic history. +func (s *Store) DeletePeer(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM peers WHERE id = ?`, id) + return err +} + +// UsedAddresses returns every tunnel address in use, for allocation. +func (s *Store) UsedAddresses(ctx context.Context) (v4, v6 []string, err error) { + rows, err := s.db.QueryContext(ctx, `SELECT ipv4, ipv6 FROM peers`) + if err != nil { + return nil, nil, err + } + defer rows.Close() + for rows.Next() { + var a string + var b sql.NullString + if err := rows.Scan(&a, &b); err != nil { + return nil, nil, err + } + v4 = append(v4, a) + if b.Valid { + v6 = append(v6, b.String) + } + } + return v4, v6, rows.Err() +} + +// PeerCounters is the running total the collector flushes. +type PeerCounters struct { + ID string + RxTotal int64 + TxTotal int64 + LastHandshake time.Time + LastEndpoint string +} + +// TrafficSample is one bucket increment. +type TrafficSample struct { + PeerID string + Bucket time.Time + Rx, Tx int64 +} + +// FlushCounters writes peer totals and traffic buckets in one transaction. +func (s *Store) FlushCounters(ctx context.Context, counters []PeerCounters, samples []TrafficSample) error { + if len(counters) == 0 && len(samples) == 0 { + return nil + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for _, c := range counters { + if _, err := tx.ExecContext(ctx, `UPDATE peers SET rx_total=?, tx_total=?, last_handshake=?, last_endpoint=? WHERE id=?`, c.RxTotal, c.TxTotal, nullTime(c.LastHandshake), c.LastEndpoint, c.ID); err != nil { + return err + } + } + for _, t := range samples { + if t.Rx == 0 && t.Tx == 0 { + continue + } + if _, err := tx.ExecContext(ctx, `INSERT INTO traffic(peer_id, bucket_start, rx, tx) VALUES(?,?,?,?) ON CONFLICT(peer_id, bucket_start) DO UPDATE SET rx = rx + excluded.rx, tx = tx + excluded.tx`, t.PeerID, t.Bucket.Unix(), t.Rx, t.Tx); err != nil { + return err + } + } + return tx.Commit() +} + +// TrafficPoint is one row of a usage series. +type TrafficPoint struct { + Bucket time.Time `json:"t"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` +} + +// TrafficSeries returns a peer's buckets since a time; peerID "" means all +// peers summed. +func (s *Store) TrafficSeries(ctx context.Context, peerID string, since time.Time) ([]TrafficPoint, error) { + var rows *sql.Rows + var err error + if peerID == "" { + rows, err = s.db.QueryContext(ctx, `SELECT bucket_start, SUM(rx), SUM(tx) FROM traffic WHERE bucket_start >= ? GROUP BY bucket_start ORDER BY bucket_start`, since.Unix()) + } else { + rows, err = s.db.QueryContext(ctx, `SELECT bucket_start, rx, tx FROM traffic WHERE peer_id = ? AND bucket_start >= ? ORDER BY bucket_start`, peerID, since.Unix()) + } + if err != nil { + return nil, err + } + defer rows.Close() + var out []TrafficPoint + for rows.Next() { + var b, rx, tx int64 + if err := rows.Scan(&b, &rx, &tx); err != nil { + return nil, err + } + out = append(out, TrafficPoint{Bucket: time.Unix(b, 0), Rx: rx, Tx: tx}) + } + return out, rows.Err() +} + +// PeerUsage is a per-peer total over a window. +type PeerUsage struct { + PeerID string `json:"peerId"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` +} + +// UsageSince sums traffic per peer since a time. +func (s *Store) UsageSince(ctx context.Context, since time.Time) ([]PeerUsage, error) { + rows, err := s.db.QueryContext(ctx, `SELECT peer_id, SUM(rx), SUM(tx) FROM traffic WHERE bucket_start >= ? GROUP BY peer_id`, since.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + var out []PeerUsage + for rows.Next() { + var u PeerUsage + if err := rows.Scan(&u.PeerID, &u.Rx, &u.Tx); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// PruneTraffic deletes buckets older than the cutoff. +func (s *Store) PruneTraffic(ctx context.Context, before time.Time) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM traffic WHERE bucket_start < ?`, before.Unix()) + return err +} + +// AuditEntry is one administrative action. +type AuditEntry struct { + ID int64 `json:"id"` + At time.Time `json:"at"` + Actor string `json:"actor"` + Action string `json:"action"` + Target string `json:"target"` + Detail string `json:"detail"` + IP string `json:"ip"` +} + +// Audit appends an entry. +func (s *Store) Audit(ctx context.Context, e AuditEntry) error { + if e.At.IsZero() { + e.At = time.Now() + } + _, err := s.db.ExecContext(ctx, `INSERT INTO audit(at, actor, action, target, detail, ip) VALUES(?,?,?,?,?,?)`, e.At.Unix(), e.Actor, e.Action, e.Target, e.Detail, e.IP) + return err +} + +// ListAudit returns the newest entries. +func (s *Store) ListAudit(ctx context.Context, limit int) ([]AuditEntry, error) { + rows, err := s.db.QueryContext(ctx, `SELECT id, at, actor, action, target, detail, ip FROM audit ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []AuditEntry{} + for rows.Next() { + var e AuditEntry + var at int64 + if err := rows.Scan(&e.ID, &at, &e.Actor, &e.Action, &e.Target, &e.Detail, &e.IP); err != nil { + return nil, err + } + e.At = time.Unix(at, 0) + out = append(out, e) + } + return out, rows.Err() +} + +// PruneAudit keeps the newest n entries. +func (s *Store) PruneAudit(ctx context.Context, keep int) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT ?)`, keep) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..974d3f8 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,157 @@ +// Package store is the SQLite persistence layer. Everything WGX remembers -- +// peers and their keys, admin users, sessions, traffic history and the audit +// log -- lives in one file under the data directory. +package store + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +// Store wraps the database handle. +type Store struct { + db *sql.DB +} + +// Open opens (creating if needed) the database at path and applies the schema. +func Open(path string) (*Store, error) { + if path != ":memory:" { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("create data directory: %w", err) + } + } + dsn := path + if path != ":memory:" { + // The file holds private keys, so it is created unreadable to anyone + // but the owner. `_pragma` options ride along in the DSN. + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + f.Close() + dsn = "file:" + path + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)&_pragma=synchronous(NORMAL)" + } else { + dsn = "file::memory:?cache=shared&_pragma=foreign_keys(ON)" + } + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // One connection: SQLite serialises writers anyway and a single handle + // avoids "database is locked" surprises under WAL with the pure-Go driver. + db.SetMaxOpenConns(1) + s := &Store{db: db} + if err := s.migrate(context.Background()); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close closes the database. +func (s *Store) Close() error { return s.db.Close() } + +// DB exposes the handle for the rare caller that needs raw SQL (tests). +func (s *Store) DB() *sql.DB { return s.db } + +const schema = ` +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + totp_secret TEXT, + totp_enabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_login_at INTEGER +); +CREATE TABLE IF NOT EXISTS recovery_codes ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code_hash TEXT NOT NULL, + used_at INTEGER +); +CREATE INDEX IF NOT EXISTS recovery_codes_user ON recovery_codes(user_id); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + ip TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + totp_pending INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id); +CREATE TABLE IF NOT EXISTS peers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + public_key TEXT NOT NULL UNIQUE, + private_key TEXT, + preshared_key TEXT, + ipv4 TEXT NOT NULL UNIQUE, + ipv6 TEXT UNIQUE, + client_routes TEXT NOT NULL, + dns TEXT NOT NULL DEFAULT '', + keepalive INTEGER NOT NULL DEFAULT 0, + mtu INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + expires_at INTEGER, + notes TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + rx_total INTEGER NOT NULL DEFAULT 0, + tx_total INTEGER NOT NULL DEFAULT 0, + last_handshake INTEGER, + last_endpoint TEXT NOT NULL DEFAULT '' +); +CREATE TABLE IF NOT EXISTS traffic ( + peer_id TEXT NOT NULL REFERENCES peers(id) ON DELETE CASCADE, + bucket_start INTEGER NOT NULL, + rx INTEGER NOT NULL DEFAULT 0, + tx INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (peer_id, bucket_start) +); +CREATE INDEX IF NOT EXISTS traffic_bucket ON traffic(bucket_start); +CREATE TABLE IF NOT EXISTS audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at INTEGER NOT NULL, + actor TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL DEFAULT '', + detail TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS audit_at ON audit(at); +` + +func (s *Store) migrate(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, schema); err != nil { + return fmt.Errorf("apply schema: %w", err) + } + return nil +} + +// GetSetting returns the raw value of a key, or "" when unset. +func (s *Store) GetSetting(ctx context.Context, key string) (string, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v) + if err == sql.ErrNoRows { + return "", nil + } + return v, err +} + +// SetSetting writes a key. +func (s *Store) SetSetting(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, `INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) + return err +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..3902c8b --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,239 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// ErrNotFound is returned when a row does not exist. +var ErrNotFound = errors.New("not found") + +// User is an administrator account. +type User struct { + ID int64 + Username string + PasswordHash string + Role string + TOTPSecret string + TOTPEnabled bool + CreatedAt time.Time + LastLoginAt time.Time +} + +func scanUser(row interface{ Scan(...any) error }) (*User, error) { + var u User + var secret sql.NullString + var totp int + var created int64 + var last sql.NullInt64 + if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &secret, &totp, &created, &last); err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, err + } + u.TOTPSecret = secret.String + u.TOTPEnabled = totp == 1 + u.CreatedAt = time.Unix(created, 0) + if last.Valid { + u.LastLoginAt = time.Unix(last.Int64, 0) + } + return &u, nil +} + +const userCols = `id, username, password_hash, role, totp_secret, totp_enabled, created_at, last_login_at` + +// CountUsers returns how many users exist; zero means first-run setup is due. +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) + return n, err +} + +// CreateUser inserts a user and returns it. +func (s *Store) CreateUser(ctx context.Context, username, passwordHash, role string) (*User, error) { + now := time.Now().Unix() + res, err := s.db.ExecContext(ctx, `INSERT INTO users(username, password_hash, role, created_at) VALUES(?, ?, ?, ?)`, username, passwordHash, role, now) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return s.UserByID(ctx, id) +} + +// UserByID looks a user up by id. +func (s *Store) UserByID(ctx context.Context, id int64) (*User, error) { + return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id)) +} + +// UserByName looks a user up by username (case-insensitive). +func (s *Store) UserByName(ctx context.Context, name string) (*User, error) { + return scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name)) +} + +// ListUsers returns every user ordered by username. +func (s *Store) ListUsers(ctx context.Context) ([]*User, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// SetPassword replaces a user's password hash. +func (s *Store) SetPassword(ctx context.Context, id int64, hash string) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) + return err +} + +// SetRole changes a user's role. +func (s *Store) SetRole(ctx context.Context, id int64, role string) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET role = ? WHERE id = ?`, role, id) + return err +} + +// SetTOTP stores a secret and whether it is active. An empty secret clears it. +func (s *Store) SetTOTP(ctx context.Context, id int64, secret string, enabled bool) error { + var sec any + if secret != "" { + sec = secret + } + en := 0 + if enabled { + en = 1 + } + _, err := s.db.ExecContext(ctx, `UPDATE users SET totp_secret = ?, totp_enabled = ? WHERE id = ?`, sec, en, id) + return err +} + +// TouchLogin records a successful login. +func (s *Store) TouchLogin(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, time.Now().Unix(), id) + return err +} + +// DeleteUser removes a user and, through cascades, their sessions and codes. +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) + return err +} + +// ReplaceRecoveryCodes replaces a user's recovery codes with the given hashes. +func (s *Store) ReplaceRecoveryCodes(ctx context.Context, id int64, hashes []string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `DELETE FROM recovery_codes WHERE user_id = ?`, id); err != nil { + return err + } + for _, h := range hashes { + if _, err := tx.ExecContext(ctx, `INSERT INTO recovery_codes(user_id, code_hash) VALUES(?, ?)`, id, h); err != nil { + return err + } + } + return tx.Commit() +} + +// UseRecoveryCode marks a code used if it exists and is unused; it reports +// whether it did. +func (s *Store) UseRecoveryCode(ctx context.Context, id int64, hash string) (bool, error) { + res, err := s.db.ExecContext(ctx, `UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL`, time.Now().Unix(), id, hash) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n == 1, nil +} + +// RecoveryCodesLeft counts a user's unused recovery codes. +func (s *Store) RecoveryCodesLeft(ctx context.Context, id int64) (int, error) { + var n int + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM recovery_codes WHERE user_id = ? AND used_at IS NULL`, id).Scan(&n) + return n, err +} + +// Session is one logged-in browser. +type Session struct { + TokenHash string + UserID int64 + CreatedAt time.Time + LastSeenAt time.Time + ExpiresAt time.Time + IP string + UserAgent string + TOTPPending bool +} + +// CreateSession stores a session. +func (s *Store) CreateSession(ctx context.Context, sess Session) error { + pending := 0 + if sess.TOTPPending { + pending = 1 + } + _, err := s.db.ExecContext(ctx, `INSERT INTO sessions(token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending) VALUES(?, ?, ?, ?, ?, ?, ?, ?)`, + sess.TokenHash, sess.UserID, sess.CreatedAt.Unix(), sess.LastSeenAt.Unix(), sess.ExpiresAt.Unix(), sess.IP, sess.UserAgent, pending) + return err +} + +// SessionByHash loads a session. +func (s *Store) SessionByHash(ctx context.Context, hash string) (*Session, error) { + var sess Session + var created, seen, exp int64 + var pending int + err := s.db.QueryRowContext(ctx, `SELECT token_hash, user_id, created_at, last_seen_at, expires_at, ip, user_agent, totp_pending FROM sessions WHERE token_hash = ?`, hash). + Scan(&sess.TokenHash, &sess.UserID, &created, &seen, &exp, &sess.IP, &sess.UserAgent, &pending) + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + sess.CreatedAt = time.Unix(created, 0) + sess.LastSeenAt = time.Unix(seen, 0) + sess.ExpiresAt = time.Unix(exp, 0) + sess.TOTPPending = pending == 1 + return &sess, nil +} + +// TouchSession bumps last_seen and the sliding expiry. +func (s *Store) TouchSession(ctx context.Context, hash string, expires time.Time) error { + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE token_hash = ?`, time.Now().Unix(), expires.Unix(), hash) + return err +} + +// ClearTOTPPending marks a session fully authenticated. +func (s *Store) ClearTOTPPending(ctx context.Context, hash string) error { + _, err := s.db.ExecContext(ctx, `UPDATE sessions SET totp_pending = 0 WHERE token_hash = ?`, hash) + return err +} + +// DeleteSession logs one browser out. +func (s *Store) DeleteSession(ctx context.Context, hash string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, hash) + return err +} + +// DeleteUserSessions logs a user out everywhere. +func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID) + return err +} + +// PruneSessions drops expired sessions. +func (s *Store) PruneSessions(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, time.Now().Unix()) + return err +} diff --git a/internal/wg/backend.go b/internal/wg/backend.go new file mode 100644 index 0000000..a2efe1c --- /dev/null +++ b/internal/wg/backend.go @@ -0,0 +1,72 @@ +// Package wg abstracts the WireGuard data plane behind a small interface so the +// rest of WGX does not care whether peers live in the kernel module, in a +// userspace wireguard-go process, or in an in-memory mock used by tests and +// UI development. +package wg + +import ( + "context" + "net" + "net/netip" + "time" +) + +// Key is a 32-byte WireGuard key (public, private or preshared). +type Key [32]byte + +// PeerState is one peer as the data plane currently sees it. +type PeerState struct { + PublicKey Key + Endpoint *net.UDPAddr + LastHandshake time.Time // zero when the peer has never completed a handshake + ReceiveBytes int64 + TransmitBytes int64 + AllowedIPs []netip.Prefix + PersistentKeepalive time.Duration +} + +// PeerConfig is what WGX wants a peer to look like on the interface. +type PeerConfig struct { + PublicKey Key + PresharedKey *Key + AllowedIPs []netip.Prefix + PersistentKeepalive time.Duration +} + +// DeviceConfig is the interface-level configuration. +type DeviceConfig struct { + PrivateKey Key + ListenPort int + // FirewallMark is applied to every packet the interface sends; zero means + // none. Left at zero by WGX, but exposed for completeness. + FirewallMark int +} + +// DeviceState is a snapshot of the interface. +type DeviceState struct { + Name string + PublicKey Key + ListenPort int + Peers []PeerState +} + +// Backend is the data plane WGX drives. +type Backend interface { + // Kind names the implementation: "kernel", "userspace" or "mock". + Kind() string + // Up creates the interface (if needed), applies the device configuration + // and brings the link up with the given addresses and MTU. + Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error + // Down tears the interface down and releases every resource Up acquired. + Down(ctx context.Context) error + // Device returns the current state of the interface and all of its peers. + Device(ctx context.Context) (*DeviceState, error) + // SetPeer adds or replaces a peer; AllowedIPs replace what was there. + SetPeer(ctx context.Context, p PeerConfig) error + // RemovePeer removes a peer. Removing a peer that is absent is not an error. + RemovePeer(ctx context.Context, pub Key) error + // ReplacePeers makes the interface's peer set exactly the given list. + ReplacePeers(ctx context.Context, peers []PeerConfig) error + // SetMTU changes the interface MTU without disturbing peers. + SetMTU(ctx context.Context, mtu int) error +} diff --git a/internal/wg/keys.go b/internal/wg/keys.go new file mode 100644 index 0000000..82c47a7 --- /dev/null +++ b/internal/wg/keys.go @@ -0,0 +1,60 @@ +package wg + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + + "golang.org/x/crypto/curve25519" +) + +// GeneratePrivateKey returns a fresh Curve25519 private key, clamped the way +// WireGuard expects. +func GeneratePrivateKey() (Key, error) { + var k Key + if _, err := rand.Read(k[:]); err != nil { + return Key{}, fmt.Errorf("generate private key: %w", err) + } + k[0] &= 248 + k[31] &= 127 + k[31] |= 64 + return k, nil +} + +// GeneratePresharedKey returns 32 random bytes for use as a preshared key. +func GeneratePresharedKey() (Key, error) { + var k Key + if _, err := rand.Read(k[:]); err != nil { + return Key{}, fmt.Errorf("generate preshared key: %w", err) + } + return k, nil +} + +// PublicKey derives the public key of a private key. +func (k Key) PublicKey() Key { + var pub Key + priv := k + curve25519.ScalarBaseMult((*[32]byte)(&pub), (*[32]byte)(&priv)) + return pub +} + +// String renders the key the way wg(8) does: standard base64. +func (k Key) String() string { return base64.StdEncoding.EncodeToString(k[:]) } + +// IsZero reports whether the key is all zeros. +func (k Key) IsZero() bool { return k == Key{} } + +// ParseKey parses a base64 key as produced by wg genkey / wg pubkey. +func ParseKey(s string) (Key, error) { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return Key{}, errors.New("key is not valid base64") + } + if len(b) != 32 { + return Key{}, errors.New("key must decode to 32 bytes") + } + var k Key + copy(k[:], b) + return k, nil +} diff --git a/internal/wg/linux.go b/internal/wg/linux.go new file mode 100644 index 0000000..29cfa1f --- /dev/null +++ b/internal/wg/linux.go @@ -0,0 +1,287 @@ +//go:build linux + +package wg + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/netip" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/vishvananda/netlink" + "golang.zx2c4.com/wireguard/wgctrl" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" +) + +// linuxBackend drives a real WireGuard interface. In kernel mode the link is a +// native `wireguard` netlink link and every packet is handled by the module; +// in userspace mode a wireguard-go process owns a TUN device with the same +// name and WGX talks to it over its UAPI socket. Both are configured through +// wgctrl, which picks the transport on its own. +type linuxBackend struct { + name string + userspace bool + client *wgctrl.Client + proc *exec.Cmd + log *slog.Logger +} + +// KernelAvailable reports whether the running kernel can create a WireGuard +// link. It tries to add and immediately delete a probe interface rather than +// trusting /sys/module, because a module that is loadable but not yet loaded +// is only discovered by asking for it. +func KernelAvailable() bool { + const probe = "wgxprobe0" + link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}} + if err := netlink.LinkAdd(link); err != nil { + return false + } + _ = netlink.LinkDel(link) + return true +} + +// NewKernel returns a backend that uses the kernel module. +func NewKernel(name string, log *slog.Logger) (Backend, error) { + return newLinux(name, false, log) +} + +// NewUserspace returns a backend that runs wireguard-go for the data plane. +func NewUserspace(name string, log *slog.Logger) (Backend, error) { + if _, err := exec.LookPath("wireguard-go"); err != nil { + return nil, errors.New("wireguard-go is not installed and the kernel has no WireGuard support") + } + return newLinux(name, true, log) +} + +func newLinux(name string, userspace bool, log *slog.Logger) (Backend, error) { + c, err := wgctrl.New() + if err != nil { + return nil, fmt.Errorf("open wgctrl: %w", err) + } + return &linuxBackend{name: name, userspace: userspace, client: c, log: log}, nil +} + +func (b *linuxBackend) Kind() string { + if b.userspace { + return "userspace" + } + return "kernel" +} + +func (b *linuxBackend) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error { + // A previous run that died without Down leaves the link behind. Start + // clean rather than inheriting peers and addresses nobody remembers. + if err := b.deleteLink(); err != nil { + return err + } + if b.userspace { + if err := b.startUserspace(ctx); err != nil { + return err + } + } else { + if err := netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: b.name}}); err != nil { + return fmt.Errorf("create %s: %w (is the container running with NET_ADMIN?)", b.name, err) + } + } + link, err := b.link() + if err != nil { + return err + } + priv := wgtypes.Key(cfg.PrivateKey) + port := cfg.ListenPort + wcfg := wgtypes.Config{PrivateKey: &priv, ListenPort: &port, ReplacePeers: true} + if cfg.FirewallMark != 0 { + fw := cfg.FirewallMark + wcfg.FirewallMark = &fw + } + if err := b.client.ConfigureDevice(b.name, wcfg); err != nil { + return fmt.Errorf("configure %s: %w", b.name, err) + } + for _, p := range addrs { + // Not prefixToIPNet: that masks the host bits, and an interface + // address must keep them (10.8.0.1/24, not 10.8.0.0/24). + ipn := addrToIPNet(p) + if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &ipn}); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("add address %s: %w", p, err) + } + } + if mtu > 0 { + if err := netlink.LinkSetMTU(link, mtu); err != nil { + return fmt.Errorf("set mtu %d: %w", mtu, err) + } + } + if err := netlink.LinkSetUp(link); err != nil { + return fmt.Errorf("bring up %s: %w", b.name, err) + } + return nil +} + +func (b *linuxBackend) startUserspace(ctx context.Context) error { + _ = os.MkdirAll("/var/run/wireguard", 0o700) + cmd := exec.Command("wireguard-go", "-f", b.name) + cmd.Env = append(os.Environ(), "WG_PROCESS_FOREGROUND=1", "LOG_LEVEL=error") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return fmt.Errorf("start wireguard-go: %w", err) + } + b.proc = cmd + sock := filepath.Join("/var/run/wireguard", b.name+".sock") + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sock); err == nil { + if _, err := netlink.LinkByName(b.name); err == nil { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + return errors.New("wireguard-go did not create its UAPI socket in time") +} + +func (b *linuxBackend) Down(ctx context.Context) error { + var errs []error + if err := b.deleteLink(); err != nil { + errs = append(errs, err) + } + if b.proc != nil && b.proc.Process != nil { + _ = b.proc.Process.Kill() + _ = b.proc.Wait() + b.proc = nil + } + if err := b.client.Close(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func (b *linuxBackend) deleteLink() error { + link, err := netlink.LinkByName(b.name) + if err != nil { + var nf netlink.LinkNotFoundError + if errors.As(err, &nf) { + return nil + } + return fmt.Errorf("look up %s: %w", b.name, err) + } + if err := netlink.LinkDel(link); err != nil { + return fmt.Errorf("delete stale %s: %w", b.name, err) + } + return nil +} + +func (b *linuxBackend) link() (netlink.Link, error) { + link, err := netlink.LinkByName(b.name) + if err != nil { + return nil, fmt.Errorf("look up %s: %w", b.name, err) + } + return link, nil +} + +func (b *linuxBackend) Device(ctx context.Context) (*DeviceState, error) { + d, err := b.client.Device(b.name) + if err != nil { + return nil, fmt.Errorf("read %s: %w", b.name, err) + } + st := &DeviceState{Name: d.Name, PublicKey: Key(d.PublicKey), ListenPort: d.ListenPort} + st.Peers = make([]PeerState, 0, len(d.Peers)) + for _, p := range d.Peers { + ps := PeerState{ + PublicKey: Key(p.PublicKey), + Endpoint: p.Endpoint, + LastHandshake: p.LastHandshakeTime, + ReceiveBytes: p.ReceiveBytes, + TransmitBytes: p.TransmitBytes, + PersistentKeepalive: p.PersistentKeepaliveInterval, + } + for _, a := range p.AllowedIPs { + if pfx, ok := ipNetToPrefix(a); ok { + ps.AllowedIPs = append(ps.AllowedIPs, pfx) + } + } + st.Peers = append(st.Peers, ps) + } + return st, nil +} + +func (b *linuxBackend) SetPeer(ctx context.Context, p PeerConfig) error { + return b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{toPeerConfig(p)}}) +} + +func (b *linuxBackend) RemovePeer(ctx context.Context, pub Key) error { + err := b.client.ConfigureDevice(b.name, wgtypes.Config{Peers: []wgtypes.PeerConfig{{PublicKey: wgtypes.Key(pub), Remove: true}}}) + if err != nil && strings.Contains(err.Error(), "no such") { + return nil + } + return err +} + +func (b *linuxBackend) ReplacePeers(ctx context.Context, peers []PeerConfig) error { + cfg := wgtypes.Config{ReplacePeers: true} + for _, p := range peers { + cfg.Peers = append(cfg.Peers, toPeerConfig(p)) + } + return b.client.ConfigureDevice(b.name, cfg) +} + +func (b *linuxBackend) SetMTU(ctx context.Context, mtu int) error { + link, err := b.link() + if err != nil { + return err + } + return netlink.LinkSetMTU(link, mtu) +} + +func toPeerConfig(p PeerConfig) wgtypes.PeerConfig { + pc := wgtypes.PeerConfig{PublicKey: wgtypes.Key(p.PublicKey), ReplaceAllowedIPs: true} + if p.PresharedKey != nil { + psk := wgtypes.Key(*p.PresharedKey) + pc.PresharedKey = &psk + } + if p.PersistentKeepalive > 0 { + ka := p.PersistentKeepalive + pc.PersistentKeepaliveInterval = &ka + } + for _, a := range p.AllowedIPs { + pc.AllowedIPs = append(pc.AllowedIPs, prefixToIPNet(a)) + } + return pc +} + +// prefixToIPNet converts a route prefix; host bits are cleared. +func prefixToIPNet(p netip.Prefix) net.IPNet { + return addrToIPNet(p.Masked()) +} + +// addrToIPNet converts an interface address with its prefix length, keeping +// the host bits. +func addrToIPNet(p netip.Prefix) net.IPNet { + ip := p.Addr() + if ip.Is4() { + a := ip.As4() + return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 32)} + } + a := ip.As16() + return net.IPNet{IP: net.IP(a[:]), Mask: net.CIDRMask(p.Bits(), 128)} +} + +func ipNetToPrefix(n net.IPNet) (netip.Prefix, bool) { + addr, ok := netip.AddrFromSlice(n.IP) + if !ok { + return netip.Prefix{}, false + } + addr = addr.Unmap() + ones, _ := n.Mask.Size() + return netip.PrefixFrom(addr, ones), true +} diff --git a/internal/wg/linux_test.go b/internal/wg/linux_test.go new file mode 100644 index 0000000..95de0c8 --- /dev/null +++ b/internal/wg/linux_test.go @@ -0,0 +1,54 @@ +//go:build linux + +package wg + +import ( + "net/netip" + "testing" +) + +func TestIPNetConversions(t *testing.T) { + // An interface address keeps its host bits; a route prefix loses them. + // Getting this wrong once put 10.8.0.0/24 on the interface, and the + // server answered nothing on 10.8.0.1. + addr := addrToIPNet(netip.MustParsePrefix("10.8.0.1/24")) + if addr.IP.String() != "10.8.0.1" { + t.Fatalf("address lost host bits: %s", addr.IP) + } + if ones, _ := addr.Mask.Size(); ones != 24 { + t.Fatalf("mask %d", ones) + } + route := prefixToIPNet(netip.MustParsePrefix("10.8.0.7/24")) + if route.IP.String() != "10.8.0.0" { + t.Fatalf("route kept host bits: %s", route.IP) + } + v6 := addrToIPNet(netip.MustParsePrefix("fd42::1/64")) + if v6.IP.String() != "fd42::1" || len(v6.IP) != 16 { + t.Fatalf("v6 %s", v6.IP) + } + back, ok := ipNetToPrefix(route) + if !ok || back.String() != "10.8.0.0/24" { + t.Fatalf("round trip %v %v", back, ok) + } +} + +func TestKeys(t *testing.T) { + priv, err := GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + if priv[0]&7 != 0 || priv[31]&128 != 0 || priv[31]&64 == 0 { + t.Fatal("private key not clamped") + } + pub := priv.PublicKey() + parsed, err := ParseKey(pub.String()) + if err != nil || parsed != pub { + t.Fatal("public key does not round-trip through base64") + } + if _, err := ParseKey("not base64!"); err == nil { + t.Fatal("bad key accepted") + } + if _, err := ParseKey("AAAA"); err == nil { + t.Fatal("short key accepted") + } +} diff --git a/internal/wg/mock.go b/internal/wg/mock.go new file mode 100644 index 0000000..e27310a --- /dev/null +++ b/internal/wg/mock.go @@ -0,0 +1,174 @@ +package wg + +import ( + "context" + "math/rand/v2" + "net" + "net/netip" + "sort" + "sync" + "time" +) + +// Mock is an in-memory data plane. It needs no privileges, so it is what the +// tests use and what `WGX_BACKEND=mock` gives a developer working on the UI. +// With Simulate on, peers randomly handshake, move traffic and go quiet so +// the dashboard has something to show. +type Mock struct { + mu sync.Mutex + name string + cfg DeviceConfig + peers map[Key]*mockPeer + up bool + Simulate bool + stop chan struct{} +} + +type mockPeer struct { + cfg PeerConfig + endpoint *net.UDPAddr + handshake time.Time + rx, tx int64 + active bool +} + +// NewMock returns an empty mock backend for the named interface. +func NewMock(name string, simulate bool) *Mock { + return &Mock{name: name, peers: map[Key]*mockPeer{}, Simulate: simulate} +} + +func (m *Mock) Kind() string { return "mock" } + +func (m *Mock) Up(ctx context.Context, cfg DeviceConfig, addrs []netip.Prefix, mtu int) error { + m.mu.Lock() + defer m.mu.Unlock() + m.cfg = cfg + m.up = true + if m.Simulate && m.stop == nil { + m.stop = make(chan struct{}) + go m.simulate(m.stop) + } + return nil +} + +func (m *Mock) Down(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.up = false + if m.stop != nil { + close(m.stop) + m.stop = nil + } + return nil +} + +func (m *Mock) Device(ctx context.Context) (*DeviceState, error) { + m.mu.Lock() + defer m.mu.Unlock() + st := &DeviceState{Name: m.name, PublicKey: m.cfg.PrivateKey.PublicKey(), ListenPort: m.cfg.ListenPort} + keys := make([]Key, 0, len(m.peers)) + for k := range m.peers { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + for _, k := range keys { + p := m.peers[k] + st.Peers = append(st.Peers, PeerState{ + PublicKey: k, + Endpoint: p.endpoint, + LastHandshake: p.handshake, + ReceiveBytes: p.rx, + TransmitBytes: p.tx, + AllowedIPs: append([]netip.Prefix(nil), p.cfg.AllowedIPs...), + PersistentKeepalive: p.cfg.PersistentKeepalive, + }) + } + return st, nil +} + +func (m *Mock) SetPeer(ctx context.Context, p PeerConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + if existing, ok := m.peers[p.PublicKey]; ok { + existing.cfg = p + return nil + } + // Half of new peers start out busy so a fresh mock install has + // something moving on the dashboard straight away. + m.peers[p.PublicKey] = &mockPeer{cfg: p, active: m.Simulate && rand.IntN(2) == 0} + return nil +} + +func (m *Mock) RemovePeer(ctx context.Context, pub Key) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.peers, pub) + return nil +} + +func (m *Mock) ReplacePeers(ctx context.Context, peers []PeerConfig) error { + m.mu.Lock() + defer m.mu.Unlock() + next := map[Key]*mockPeer{} + for _, p := range peers { + if existing, ok := m.peers[p.PublicKey]; ok { + existing.cfg = p + next[p.PublicKey] = existing + } else { + next[p.PublicKey] = &mockPeer{cfg: p} + } + } + m.peers = next + return nil +} + +func (m *Mock) SetMTU(ctx context.Context, mtu int) error { return nil } + +// Touch fakes a handshake and some traffic for a peer. Tests use it to make +// a peer look connected without waiting on the simulator. +func (m *Mock) Touch(pub Key, rx, tx int64, endpoint string) { + m.mu.Lock() + defer m.mu.Unlock() + p, ok := m.peers[pub] + if !ok { + return + } + p.handshake = time.Now() + p.rx += rx + p.tx += tx + if endpoint != "" { + if ap, err := netip.ParseAddrPort(endpoint); err == nil { + p.endpoint = net.UDPAddrFromAddrPort(ap) + } + } +} + +func (m *Mock) simulate(stop chan struct{}) { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + } + m.mu.Lock() + for _, p := range m.peers { + // Peers flip between active and idle a few times an hour. + if rand.IntN(60) == 0 { + p.active = !p.active + } + if p.active { + if p.endpoint == nil { + p.endpoint = &net.UDPAddr{IP: net.IPv4(203, 0, 113, byte(1+rand.IntN(250))), Port: 30000 + rand.IntN(30000)} + } + if time.Since(p.handshake) > time.Duration(90+rand.IntN(40))*time.Second { + p.handshake = time.Now() + } + p.rx += int64(rand.IntN(400_000)) + p.tx += int64(rand.IntN(3_000_000)) + } + } + m.mu.Unlock() + } +} diff --git a/internal/wg/other.go b/internal/wg/other.go new file mode 100644 index 0000000..acaabe0 --- /dev/null +++ b/internal/wg/other.go @@ -0,0 +1,19 @@ +//go:build !linux + +package wg + +import ( + "errors" + "log/slog" +) + +var errLinuxOnly = errors.New("real WireGuard interfaces are only supported on Linux; use WGX_BACKEND=mock for development") + +// KernelAvailable is always false off Linux. +func KernelAvailable() bool { return false } + +// NewKernel is unavailable off Linux. +func NewKernel(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly } + +// NewUserspace is unavailable off Linux. +func NewUserspace(name string, log *slog.Logger) (Backend, error) { return nil, errLinuxOnly } diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b86ec57 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + + WGX + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..fc95358 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1338 @@ +{ + "name": "wgx-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wgx-web", + "version": "0.0.0", + "license": "AGPL-3.0-or-later", + "dependencies": { + "lucide-react": "^1.45.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "wouter": "^3.11.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz", + "integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/regexparam": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/wouter": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/wouter/-/wouter-3.11.0.tgz", + "integrity": "sha512-xyMLHhytdGhIjXVRcSjeYbXd7TR1lrxVTPLGBWmmyW52ZuWVQ1jXmsgoJ4G5s4tjloJCq7h4i2eREljWUQ4+/A==", + "license": "Unlicense", + "dependencies": { + "regexparam": "^3.0.0", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..5b13882 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "wgx-web", + "version": "0.0.0", + "private": true, + "license": "AGPL-3.0-or-later", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.json --noEmit && vite build && touch ../internal/server/static/dist/.gitkeep", + "typecheck": "tsc -p tsconfig.json --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.45.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "wouter": "^3.11.0" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.1", + "typescript": "^7.0.2", + "vite": "^8.3.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..2e30e7d --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,46 @@ +import { Route, Switch } from "wouter"; +import { AuthProvider, LiveProvider, ToastProvider, useAuth } from "./state"; +import { Layout } from "./components/Layout"; +import { Login } from "./pages/Login"; +import { Setup } from "./pages/Setup"; +import { Dashboard } from "./pages/Dashboard"; +import { Peers } from "./pages/Peers"; +import { SettingsPage } from "./pages/Settings"; +import { UsersPage } from "./pages/Users"; +import { Audit } from "./pages/Audit"; +import { Account } from "./pages/Account"; + +function Gate() { + const { me, loading, needsSetup } = useAuth(); + if (loading) return
Loading…
; + if (needsSetup) return ; + if (!me) return ; + return ( + + + + + + + + + + + +
Nothing here.
+
+
+
+
+ ); +} + +export function App() { + return ( + + + + + + ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..5aae80c --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,240 @@ +// Thin client for the WGX API. Every call goes through `request`, which +// turns non-2xx answers into ApiError so pages can show the server's message. + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const headers: Record = { Accept: "application/json" }; + const init: RequestInit = { method, headers, credentials: "same-origin" }; + if (body !== undefined) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const res = await fetch(url, init); + if (res.status === 204) return undefined as T; + const text = await res.text(); + let data: unknown = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + if (!res.ok) { + const msg = (data as { error?: string } | null)?.error ?? res.statusText ?? "request failed"; + throw new ApiError(res.status, msg); + } + return data as T; +} + +export const get = (url: string) => request("GET", url); +export const post = (url: string, body?: unknown) => request("POST", url, body ?? {}); +export const put = (url: string, body: unknown) => request("PUT", url, body); +export const del = (url: string) => request("DELETE", url); + +export async function getText(url: string): Promise { + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) throw new ApiError(res.status, await res.text()); + return res.text(); +} + +// --- types ------------------------------------------------------------------ + +export interface Me { + id: number; + username: string; + role: "admin" | "viewer"; + totpEnabled: boolean; + recoveryCodesLeft: number; + createdAt: string; + lastLoginAt?: string; +} + +export interface Live { + id: string; + connected: boolean; + endpoint?: string; + lastHandshake?: string; + rx: number; + tx: number; + rxRate: number; + txRate: number; + connectedSince?: string; +} + +export interface Peer { + id: string; + name: string; + publicKey: string; + serverKeys: boolean; + presharedKey: boolean; + ipv4: string; + ipv6?: string; + clientRoutes: string; + dns: string; + keepalive: number; + mtu: number; + enabled: boolean; + expired: boolean; + expiresAt?: string; + notes: string; + createdAt: string; + updatedAt: string; + live: Live; +} + +export interface PeerInput { + name: string; + publicKey?: string; + ipv4?: string; + ipv6?: string; + clientRoutes: string; + dns: string; + keepalive: number | null; + mtu: number | null; + enabled?: boolean; + expiresAt: string | null; + notes: string; +} + +export interface Settings { + endpointHost: string; + endpointPort: number; + dns: string; + clientRoutes: string; + mtu: number; + keepalive: number; + peerIsolation: boolean; + clampMSS: boolean; + presharedKeys: boolean; + connectedWindow: number; +} + +export interface Totals { + peers: number; + active: number; + connected: number; + rx: number; + tx: number; + rxRate: number; + txRate: number; +} + +export interface Snapshot { + at: string; + totals: Totals; + peers: Record; +} + +export interface SysctlStatus { + key: string; + wanted: string; + current: string; + applied: boolean; + required: boolean; + why: string; + error?: string; +} + +export interface Status { + version: string; + backend: "kernel" | "userspace" | "mock"; + interface: string; + publicKey: string; + listenPort: number; + addresses: string[]; + subnet4: string; + subnet6?: string; + egress?: string; + firewallError?: string; + firewallManaged: boolean; + startedAt: string; + sysctls: SysctlStatus[]; + totals: Totals; + settings: Settings; +} + +export interface TrafficPoint { + t: string; + rx: number; + tx: number; +} + +export interface PeerUsage { + peerId: string; + rx: number; + tx: number; +} + +export interface AuditEntry { + id: number; + at: string; + actor: string; + action: string; + target: string; + detail: string; + ip: string; +} + +export interface User { + id: number; + username: string; + role: "admin" | "viewer"; + totpEnabled: boolean; + createdAt: string; + lastLoginAt?: string; +} + +export interface SessionInfo { + current: boolean; + createdAt: string; + lastSeenAt: string; + ip: string; + userAgent: string; +} + +export type Range = "1h" | "24h" | "7d" | "30d"; + +// --- endpoints -------------------------------------------------------------- + +export const api = { + setupStatus: () => get<{ needsSetup: boolean }>("/api/setup"), + setup: (body: { username: string; password: string; endpointHost: string }) => post("/api/setup", body), + login: (username: string, password: string) => post<{ totpRequired: boolean }>("/api/auth/login", { username, password }), + loginTotp: (code: string) => post<{ totpRequired: boolean }>("/api/auth/totp", { code }), + logout: () => post<{ ok: boolean }>("/api/auth/logout"), + me: () => get("/api/auth/me"), + changePassword: (current: string, next: string) => post<{ ok: boolean }>("/api/auth/password", { current, new: next }), + totpSetup: () => post<{ secret: string; uri: string }>("/api/auth/totp/setup"), + totpConfirm: (code: string) => post<{ recoveryCodes: string[] }>("/api/auth/totp/confirm", { code }), + totpDisable: (password: string) => post<{ ok: boolean }>("/api/auth/totp/disable", { password }), + sessions: () => get("/api/auth/sessions"), + revokeSessions: () => post<{ ok: boolean }>("/api/auth/sessions/revoke"), + + status: () => get("/api/status"), + peers: () => get("/api/peers"), + peer: (id: string) => get(`/api/peers/${id}`), + createPeer: (body: PeerInput) => post("/api/peers", body), + updatePeer: (id: string, body: PeerInput) => put(`/api/peers/${id}`, body), + deletePeer: (id: string) => del<{ ok: boolean }>(`/api/peers/${id}`), + enablePeer: (id: string) => post(`/api/peers/${id}/enable`), + disablePeer: (id: string) => post(`/api/peers/${id}/disable`), + resetPeer: (id: string) => post<{ ok: boolean }>(`/api/peers/${id}/reset`), + rotatePeer: (id: string) => post(`/api/peers/${id}/rotate`), + peerConfig: (id: string) => getText(`/api/peers/${id}/config`), + peerUsage: (id: string, range: Range) => get(`/api/peers/${id}/usage?range=${range}`), + usage: (range: Range) => get(`/api/usage?range=${range}`), + usageByPeer: (range: Range) => get(`/api/usage/peers?range=${range}`), + settings: () => get("/api/settings"), + saveSettings: (body: Settings) => put("/api/settings", body), + audit: (limit = 200) => get(`/api/audit?limit=${limit}`), + users: () => get("/api/users"), + createUser: (body: { username: string; password: string; role: string }) => post("/api/users", body), + updateUser: (id: number, body: { role?: string; password?: string; resetTotp?: boolean }) => put(`/api/users/${id}`, body), + deleteUser: (id: number) => del<{ ok: boolean }>(`/api/users/${id}`), +}; diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx new file mode 100644 index 0000000..8bf18cb --- /dev/null +++ b/web/src/components/Layout.tsx @@ -0,0 +1,63 @@ +import type { ReactNode } from "react"; +import { Link, useLocation } from "wouter"; +import { Activity, ClipboardList, LayoutDashboard, LogOut, Settings, Shield, Users, Wifi, WifiOff } from "lucide-react"; +import { useAuth, useLive } from "../state"; + +const items = [ + { href: "/", label: "Dashboard", icon: LayoutDashboard }, + { href: "/peers", label: "Peers", icon: Activity }, + { href: "/settings", label: "Settings", icon: Settings }, + { href: "/users", label: "Users", icon: Users }, + { href: "/audit", label: "Audit log", icon: ClipboardList }, + { href: "/account", label: "Account", icon: Shield }, +]; + +export function Layout({ children }: { children: ReactNode }) { + const [location] = useLocation(); + const { me, signOut } = useAuth(); + const { connected } = useLive(); + return ( +
+ +
{children}
+
+ ); +} diff --git a/web/src/components/PeerDetail.tsx b/web/src/components/PeerDetail.tsx new file mode 100644 index 0000000..987aaed --- /dev/null +++ b/web/src/components/PeerDetail.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from "react"; +import { Copy, Download, KeyRound, Pencil, Power, RefreshCw, Trash2 } from "lucide-react"; +import { api, type Live, type Peer, type Range, type Settings, type TrafficPoint } from "../api"; +import { ago, bytes, dateTime, duration, rate, shortKey } from "../format"; +import { errorMessage, useNow, useToast } from "../state"; +import { Legend, TrafficChart } from "./charts"; +import { Confirm, Modal, Segmented, copyText } from "./ui"; +import { PeerForm } from "./PeerForm"; + +const rangeMs: Record = { "1h": 3600e3, "24h": 86400e3, "7d": 7 * 86400e3, "30d": 30 * 86400e3 }; + +export function PeerDetail({ peer, live, settings, isAdmin, onClose, onChanged, initialTab = "overview", initialConfig }: { peer: Peer; live: Live; settings: Settings; isAdmin: boolean; onClose: () => void; onChanged: (p?: Peer) => void; initialTab?: "overview" | "config"; initialConfig?: string }) { + const toast = useToast(); + const now = useNow(1000); + const [tab, setTab] = useState<"overview" | "config">(initialTab); + const [config, setConfig] = useState(initialConfig ?? ""); + const [range, setRange] = useState("24h"); + const [series, setSeries] = useState([]); + const [editing, setEditing] = useState(false); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [qrKey, setQrKey] = useState(0); + + useEffect(() => { + if (tab === "config" && !config) api.peerConfig(peer.id).then(setConfig).catch((e) => toast(errorMessage(e), "bad")); + }, [tab, config, peer.id, toast]); + useEffect(() => { + api.peerUsage(peer.id, range).then(setSeries).catch(() => {}); + }, [peer.id, range, peer.updatedAt]); + + async function act(fn: () => Promise, done: string) { + setBusy(true); + try { + await fn(); + toast(done); + onChanged(); + } catch (e) { + toast(errorMessage(e), "bad"); + } finally { + setBusy(false); + setConfirm(null); + } + } + + const state = !peer.enabled ? "disabled" : peer.expired ? "expired" : live.connected ? "on" : "off"; + const stateLabel = { disabled: "Disabled", expired: "Expired", on: "Connected", off: "Not connected" }[state]; + + return ( + <> + + + {peer.enabled ? ( + + ) : ( + + )} + + {peer.serverKeys && ( + + )} + + + ) : undefined + } + > +
+ + +
+ {tab === "overview" && ( + <> +
+
+
Status
+
+ + {stateLabel} + {live.connected && live.connectedSince && for {duration(live.connectedSince, now)}} +
+
Tunnel address
+
+ {peer.ipv4} + {peer.ipv6 ? `, ${peer.ipv6}` : ""} +
+
Endpoint
+
{live.endpoint || "—"}
+
Last handshake
+
+ {ago(live.lastHandshake, now)} {dateTime(live.lastHandshake)} +
+
Rate
+
+ ↓ {rate(live.rxRate)} · ↑ {rate(live.txRate)} +
+
Transfer
+
+ ↓ {bytes(live.rx)} · ↑ {bytes(live.tx)} +
+
+
+
Public key
+
+ {shortKey(peer.publicKey)}{" "} + +
+
Keys
+
+ {peer.serverKeys ? "generated by the server" : "held by the client"} + {peer.presharedKey ? " · preshared key" : ""} +
+
Client routes
+
{peer.clientRoutes}
+
DNS
+
{peer.dns || server default ({settings.dns || "none"})}
+
Keepalive / MTU
+
+ {peer.keepalive || settings.keepalive}s / {peer.mtu || settings.mtu} +
+
Expires
+
{peer.expiresAt ? dateTime(peer.expiresAt) : never}
+
Created
+
{dateTime(peer.createdAt)}
+
+
+ {peer.notes &&

{peer.notes}

} +
+ + +
+ + + )} + {tab === "config" && ( +
+ {peer.serverKeys ? ( + QR code of the client configuration setQrKey((k) => k + 1)} /> + ) : ( +
This peer holds its own private key, so there is no QR code. Fill in the PrivateKey line on the client.
+ )} +
{config || "…"}
+
+ + + Download .conf + + Anyone with this file can connect as this peer. Viewing it is recorded in the audit log. +
+
+ )} +
+ {editing && ( + setEditing(false)} + onSaved={(p) => { + setEditing(false); + setConfig(""); + toast("Peer saved"); + onChanged(p); + }} + /> + )} + {confirm === "delete" && setConfirm(null)} onConfirm={() => act(() => api.deletePeer(peer.id).then(() => onClose()), "Peer deleted")} text={<>Delete {peer.name}? Its keys, address and traffic history are gone for good.} />} + {confirm === "disable" && setConfirm(null)} onConfirm={() => act(() => api.disablePeer(peer.id), "Peer disconnected")} text={<>Remove {peer.name} from the interface? Its session drops now and it cannot reconnect until you enable it again.} />} + {confirm === "rotate" && ( + setConfirm(null)} + onConfirm={() => + act( + () => + api.rotatePeer(peer.id).then((p) => { + setConfig(p.config); + setTab("config"); + }), + "Keys rotated; hand out the new configuration", + ) + } + text={<>Give {peer.name} a new key pair? The configuration it has now stops working the moment you confirm.} + /> + )} + + ); +} diff --git a/web/src/components/PeerForm.tsx b/web/src/components/PeerForm.tsx new file mode 100644 index 0000000..5c21f5c --- /dev/null +++ b/web/src/components/PeerForm.tsx @@ -0,0 +1,138 @@ +import { useState, type FormEvent } from "react"; +import { api, type Peer, type PeerInput, type Settings } from "../api"; +import { fromLocalInput, toLocalInput } from "../format"; +import { errorMessage } from "../state"; +import { Check, Field, Modal } from "./ui"; + +// One form for create and edit. On create the key mode is chosen here; on +// edit keys and addresses are fixed (rotate keys from the detail view). +export function PeerForm({ peer, settings, onClose, onSaved }: { peer?: Peer; settings: Settings; onClose: () => void; onSaved: (p: Peer & { config?: string }) => void }) { + const editing = !!peer; + const [name, setName] = useState(peer?.name ?? ""); + const [keyMode, setKeyMode] = useState<"server" | "client">("server"); + const [publicKey, setPublicKey] = useState(""); + const [ipv4, setIpv4] = useState(""); + const [ipv6, setIpv6] = useState(""); + const [routes, setRoutes] = useState(peer?.clientRoutes ?? settings.clientRoutes); + const [dns, setDns] = useState(peer?.dns ?? ""); + const [keepalive, setKeepalive] = useState(peer ? String(peer.keepalive) : "0"); + const [mtu, setMtu] = useState(peer ? String(peer.mtu) : "0"); + const [expires, setExpires] = useState(toLocalInput(peer?.expiresAt)); + const [notes, setNotes] = useState(peer?.notes ?? ""); + const [enabled, setEnabled] = useState(peer?.enabled ?? true); + const [advanced, setAdvanced] = useState(editing); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(e: FormEvent) { + e.preventDefault(); + setError(""); + setBusy(true); + const body: PeerInput = { + name, + clientRoutes: routes, + dns, + keepalive: keepalive === "" ? null : Number(keepalive), + mtu: mtu === "" ? null : Number(mtu), + enabled, + expiresAt: expires ? fromLocalInput(expires) : "1970-01-01T00:00:00Z", + notes, + }; + if (!editing) { + if (keyMode === "client") body.publicKey = publicKey.trim(); + if (ipv4.trim()) body.ipv4 = ipv4.trim(); + if (ipv6.trim()) body.ipv6 = ipv6.trim(); + } + try { + const saved = editing ? await api.updatePeer(peer.id, body) : await api.createPeer(body); + onSaved(saved); + } catch (err) { + setError(errorMessage(err)); + } finally { + setBusy(false); + } + } + + return ( + + + + + } + > +
+ {error &&
{error}
} + + setName(e.target.value)} required maxLength={64} /> + + {!editing && ( +
+ +
+ + +
+ Generating here lets you scan a QR code. Bringing a key means the private key never leaves the client, but there is no QR code. +
+ )} + {!editing && keyMode === "client" && ( + + setPublicKey(e.target.value)} placeholder="base64, 44 characters" required /> + + )} + {!advanced && ( + + )} + {advanced && ( + <> + + setRoutes(e.target.value)} /> + +
+ + setDns(e.target.value)} placeholder={settings.dns} /> + + + setKeepalive(e.target.value)} /> + + + setMtu(e.target.value)} /> + + + setExpires(e.target.value)} /> + + {!editing && ( + <> + + setIpv4(e.target.value)} placeholder="auto" /> + + + setIpv6(e.target.value)} placeholder="auto" /> + + + )} +
+ +