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.
This commit is contained in:
jcoffey
2026-09-12 19:56:08 -07:00
commit 6c006e1d4d
72 changed files with 11675 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
.git
.github
docs
web/node_modules
web/dist
internal/server/static/dist
*.md
!web/**/*.md
docker-compose*.yml
.gitignore
+33
View File
@@ -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 .
+127
View File
@@ -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 }}"
+15
View File
@@ -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
+59
View File
@@ -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`.
+35
View File
@@ -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"]
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+35
View File
@@ -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.
+140
View File
@@ -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 <http://localhost:51821>, 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).
+43
View File
@@ -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.
+195
View File
@@ -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 <user> | 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 <username>")
}
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
}
+37
View File
@@ -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:
+55
View File
@@ -0,0 +1,55 @@
# WGX: a WireGuard server with a web admin UI, in one container.
#
# Start it, open http://<host>: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:
+110
View File
@@ -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 <tunnel address> -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.
+33
View File
@@ -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
)
+80
View File
@@ -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=
+277
View File
@@ -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)
}
}
}
+106
View File
@@ -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)
}
}
+202
View File
@@ -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 }
+56
View File
@@ -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
}
+277
View File
@@ -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
}
+421
View File
@@ -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)
}
}
}
+308
View File
@@ -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), "<your private key>") {
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")
}
}
+60
View File
@@ -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)
}
+486
View File
@@ -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 = <your private key>\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
}
+168
View File
@@ -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))
}
+75
View File
@@ -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}
}
+42
View File
@@ -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
}
+8
View File
@@ -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") }
+113
View File
@@ -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
}
+53
View File
@@ -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")
}
}
+94
View File
@@ -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 }
+402
View File
@@ -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()))
}
+745
View File
@@ -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})
}
+336
View File
@@ -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())
}
}
+265
View File
@@ -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)
}
}
View File
+20
View File
@@ -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
}
+80
View File
@@ -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)
}
+300
View File
@@ -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
}
+157
View File
@@ -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
}
+239
View File
@@ -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
}
+72
View File
@@ -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
}
+60
View File
@@ -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
}
+287
View File
@@ -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
}
+54
View File
@@ -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")
}
}
+174
View File
@@ -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()
}
}
+19
View File
@@ -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 }
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="referrer" content="no-referrer" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231f6f5c'/%3E%3Cpath d='M7 10l4 12 5-9 5 9 4-12' fill='none' stroke='%23fff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
<title>WGX</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1338
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+46
View File
@@ -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 <div className="auth">Loading</div>;
if (needsSetup) return <Setup />;
if (!me) return <Login />;
return (
<LiveProvider>
<Layout>
<Switch>
<Route path="/" component={Dashboard} />
<Route path="/peers" component={Peers} />
<Route path="/peers/:id" component={Peers} />
<Route path="/settings" component={SettingsPage} />
<Route path="/users" component={UsersPage} />
<Route path="/audit" component={Audit} />
<Route path="/account" component={Account} />
<Route>
<div className="empty">Nothing here.</div>
</Route>
</Switch>
</Layout>
</LiveProvider>
);
}
export function App() {
return (
<ToastProvider>
<AuthProvider>
<Gate />
</AuthProvider>
</ToastProvider>
);
}
+240
View File
@@ -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<T>(method: string, url: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 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 = <T>(url: string) => request<T>("GET", url);
export const post = <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {});
export const put = <T>(url: string, body: unknown) => request<T>("PUT", url, body);
export const del = <T>(url: string) => request<T>("DELETE", url);
export async function getText(url: string): Promise<string> {
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<string, Live>;
}
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<Me>("/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<Me>("/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<SessionInfo[]>("/api/auth/sessions"),
revokeSessions: () => post<{ ok: boolean }>("/api/auth/sessions/revoke"),
status: () => get<Status>("/api/status"),
peers: () => get<Peer[]>("/api/peers"),
peer: (id: string) => get<Peer>(`/api/peers/${id}`),
createPeer: (body: PeerInput) => post<Peer & { config: string }>("/api/peers", body),
updatePeer: (id: string, body: PeerInput) => put<Peer>(`/api/peers/${id}`, body),
deletePeer: (id: string) => del<{ ok: boolean }>(`/api/peers/${id}`),
enablePeer: (id: string) => post<Peer>(`/api/peers/${id}/enable`),
disablePeer: (id: string) => post<Peer>(`/api/peers/${id}/disable`),
resetPeer: (id: string) => post<{ ok: boolean }>(`/api/peers/${id}/reset`),
rotatePeer: (id: string) => post<Peer & { config: string }>(`/api/peers/${id}/rotate`),
peerConfig: (id: string) => getText(`/api/peers/${id}/config`),
peerUsage: (id: string, range: Range) => get<TrafficPoint[]>(`/api/peers/${id}/usage?range=${range}`),
usage: (range: Range) => get<TrafficPoint[]>(`/api/usage?range=${range}`),
usageByPeer: (range: Range) => get<PeerUsage[]>(`/api/usage/peers?range=${range}`),
settings: () => get<Settings>("/api/settings"),
saveSettings: (body: Settings) => put<Settings>("/api/settings", body),
audit: (limit = 200) => get<AuditEntry[]>(`/api/audit?limit=${limit}`),
users: () => get<User[]>("/api/users"),
createUser: (body: { username: string; password: string; role: string }) => post<User>("/api/users", body),
updateUser: (id: number, body: { role?: string; password?: string; resetTotp?: boolean }) => put<User>(`/api/users/${id}`, body),
deleteUser: (id: number) => del<{ ok: boolean }>(`/api/users/${id}`),
};
+63
View File
@@ -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 (
<div className="shell">
<aside className="sidebar">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 32 32">
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div>
<div className="brand-name">WGX</div>
<div className="brand-sub">WireGuard server</div>
</div>
</div>
<nav className="nav">
{items.map((it) => {
const active = it.href === "/" ? location === "/" : location.startsWith(it.href);
const Icon = it.icon;
return (
<Link key={it.href} href={it.href} className={active ? "active" : ""}>
<Icon />
<span>{it.label}</span>
</Link>
);
})}
</nav>
<div className="sidebar-foot">
<div title={connected ? "Live updates connected" : "Live updates reconnecting"} className="nowrap">
{connected ? <Wifi size={13} style={{ verticalAlign: -2, color: "var(--ok)" }} /> : <WifiOff size={13} style={{ verticalAlign: -2, color: "var(--warn)" }} />} {connected ? "live" : "reconnecting"}
</div>
<div className="nowrap">
{me?.username} <span className="faint">({me?.role})</span>
</div>
<button className="btn sm ghost" onClick={() => void signOut()} style={{ alignSelf: "flex-start", marginLeft: -6 }}>
<LogOut /> Sign out
</button>
<a className="nowrap" href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
AGPL-3.0 source
</a>
</div>
</aside>
<main className="main">{children}</main>
</div>
);
}
+213
View File
@@ -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<Range, number> = { "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<string>(initialConfig ?? "");
const [range, setRange] = useState<Range>("24h");
const [series, setSeries] = useState<TrafficPoint[]>([]);
const [editing, setEditing] = useState(false);
const [confirm, setConfirm] = useState<null | "delete" | "rotate" | "disable">(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<unknown>, 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 (
<>
<Modal
title={peer.name}
onClose={onClose}
wide
footer={
isAdmin ? (
<div className="btn-row" style={{ justifyContent: "flex-end", width: "100%" }}>
<button className="btn sm" onClick={() => setEditing(true)} disabled={busy}>
<Pencil /> Edit
</button>
{peer.enabled ? (
<button className="btn sm" onClick={() => setConfirm("disable")} disabled={busy} title="Remove from the interface; drops the session">
<Power /> Disconnect
</button>
) : (
<button className="btn sm" onClick={() => act(() => api.enablePeer(peer.id), "Peer enabled")} disabled={busy}>
<Power /> Enable
</button>
)}
<button className="btn sm" onClick={() => act(() => api.resetPeer(peer.id), "Session reset")} disabled={busy || !peer.enabled} title="Drop the current session; a client that is sending traffic handshakes again within about 15 seconds">
<RefreshCw /> Reset session
</button>
{peer.serverKeys && (
<button className="btn sm" onClick={() => setConfirm("rotate")} disabled={busy} title="New key pair; the old config stops working">
<KeyRound /> Rotate keys
</button>
)}
<button className="btn sm danger" onClick={() => setConfirm("delete")} disabled={busy}>
<Trash2 /> Delete
</button>
</div>
) : undefined
}
>
<div className="tabs">
<button className={tab === "overview" ? "active" : ""} onClick={() => setTab("overview")}>
Overview
</button>
<button className={tab === "config" ? "active" : ""} onClick={() => setTab("config")}>
Configuration
</button>
</div>
{tab === "overview" && (
<>
<div className="grid grid-2">
<dl className="kv">
<dt>Status</dt>
<dd>
<span className={`dot ${state}`} />
{stateLabel}
{live.connected && live.connectedSince && <span className="faint"> for {duration(live.connectedSince, now)}</span>}
</dd>
<dt>Tunnel address</dt>
<dd className="mono">
{peer.ipv4}
{peer.ipv6 ? `, ${peer.ipv6}` : ""}
</dd>
<dt>Endpoint</dt>
<dd className="mono">{live.endpoint || "—"}</dd>
<dt>Last handshake</dt>
<dd>
{ago(live.lastHandshake, now)} <span className="faint">{dateTime(live.lastHandshake)}</span>
</dd>
<dt>Rate</dt>
<dd className="num">
{rate(live.rxRate)} · {rate(live.txRate)}
</dd>
<dt>Transfer</dt>
<dd className="num">
{bytes(live.rx)} · {bytes(live.tx)}
</dd>
</dl>
<dl className="kv">
<dt>Public key</dt>
<dd className="mono" title={peer.publicKey}>
{shortKey(peer.publicKey)}{" "}
<button className="btn icon ghost sm" title="Copy" onClick={() => copyText(peer.publicKey).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
<Copy />
</button>
</dd>
<dt>Keys</dt>
<dd>
{peer.serverKeys ? "generated by the server" : "held by the client"}
{peer.presharedKey ? " · preshared key" : ""}
</dd>
<dt>Client routes</dt>
<dd className="mono">{peer.clientRoutes}</dd>
<dt>DNS</dt>
<dd>{peer.dns || <span className="faint">server default ({settings.dns || "none"})</span>}</dd>
<dt>Keepalive / MTU</dt>
<dd>
{peer.keepalive || settings.keepalive}s / {peer.mtu || settings.mtu}
</dd>
<dt>Expires</dt>
<dd>{peer.expiresAt ? dateTime(peer.expiresAt) : <span className="faint">never</span>}</dd>
<dt>Created</dt>
<dd>{dateTime(peer.createdAt)}</dd>
</dl>
</div>
{peer.notes && <p className="mt muted" style={{ whiteSpace: "pre-wrap" }}>{peer.notes}</p>}
<div className="mt" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Legend />
<Segmented value={range} onChange={setRange} options={[{ value: "1h", label: "1h" }, { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, { value: "30d", label: "30d" }]} />
</div>
<TrafficChart points={series} from={now - rangeMs[range]} to={now} bucketSeconds={range === "30d" ? 3600 : 300} />
</>
)}
{tab === "config" && (
<div className="qr">
{peer.serverKeys ? (
<img key={qrKey} src={`/api/peers/${peer.id}/qr.png?size=384&v=${peer.updatedAt}`} alt="QR code of the client configuration" width={320} height={320} onError={() => setQrKey((k) => k + 1)} />
) : (
<div className="notice">This peer holds its own private key, so there is no QR code. Fill in the PrivateKey line on the client.</div>
)}
<pre className="config">{config || "…"}</pre>
<div className="btn-row">
<button className="btn" onClick={() => copyText(config).then((ok) => toast(ok ? "Configuration copied" : "Could not copy", ok ? "ok" : "bad"))} disabled={!config}>
<Copy /> Copy
</button>
<a className="btn" href={`/api/peers/${peer.id}/config?download=1`}>
<Download /> Download .conf
</a>
<span className="small faint">Anyone with this file can connect as this peer. Viewing it is recorded in the audit log.</span>
</div>
</div>
)}
</Modal>
{editing && (
<PeerForm
peer={peer}
settings={settings}
onClose={() => setEditing(false)}
onSaved={(p) => {
setEditing(false);
setConfig("");
toast("Peer saved");
onChanged(p);
}}
/>
)}
{confirm === "delete" && <Confirm title="Delete peer" danger confirmLabel="Delete" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.deletePeer(peer.id).then(() => onClose()), "Peer deleted")} text={<>Delete <b>{peer.name}</b>? Its keys, address and traffic history are gone for good.</>} />}
{confirm === "disable" && <Confirm title="Disconnect peer" confirmLabel="Disconnect" busy={busy} onClose={() => setConfirm(null)} onConfirm={() => act(() => api.disablePeer(peer.id), "Peer disconnected")} text={<>Remove <b>{peer.name}</b> from the interface? Its session drops now and it cannot reconnect until you enable it again.</>} />}
{confirm === "rotate" && (
<Confirm
title="Rotate keys"
confirmLabel="Rotate"
busy={busy}
onClose={() => setConfirm(null)}
onConfirm={() =>
act(
() =>
api.rotatePeer(peer.id).then((p) => {
setConfig(p.config);
setTab("config");
}),
"Keys rotated; hand out the new configuration",
)
}
text={<>Give <b>{peer.name}</b> a new key pair? The configuration it has now stops working the moment you confirm.</>}
/>
)}
</>
);
}
+138
View File
@@ -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 (
<Modal
title={editing ? `Edit ${peer.name}` : "New peer"}
onClose={onClose}
footer={
<>
<button className="btn" type="button" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="btn primary" type="submit" form="peer-form" disabled={busy}>
{busy ? "…" : editing ? "Save" : "Create peer"}
</button>
</>
}
>
<form id="peer-form" onSubmit={submit}>
{error && <div className="error">{error}</div>}
<Field label="Name" hint="A device or a person: “Laptop”, “Phone”, “Office router”.">
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} required maxLength={64} />
</Field>
{!editing && (
<div className="field">
<label>Keys</label>
<div className="btn-row">
<label className="btn sm" style={{ cursor: "pointer" }}>
<input type="radio" name="keys" checked={keyMode === "server"} onChange={() => setKeyMode("server")} /> Generate here (QR code)
</label>
<label className="btn sm" style={{ cursor: "pointer" }}>
<input type="radio" name="keys" checked={keyMode === "client"} onChange={() => setKeyMode("client")} /> Client brings its own public key
</label>
</div>
<span className="hint">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.</span>
</div>
)}
{!editing && keyMode === "client" && (
<Field label="Client public key">
<input className="input mono" value={publicKey} onChange={(e) => setPublicKey(e.target.value)} placeholder="base64, 44 characters" required />
</Field>
)}
{!advanced && (
<button type="button" className="btn sm ghost" onClick={() => setAdvanced(true)} style={{ marginLeft: -8 }}>
More options
</button>
)}
{advanced && (
<>
<Field label="Client routes (AllowedIPs)" hint="What the client sends through the tunnel. 0.0.0.0/0, ::/0 is everything; the tunnel subnet alone is split tunnelling.">
<input className="input mono" value={routes} onChange={(e) => setRoutes(e.target.value)} />
</Field>
<div className="form-cols">
<Field label="DNS" hint={`Blank uses the server default (${settings.dns || "none"}).`}>
<input className="input" value={dns} onChange={(e) => setDns(e.target.value)} placeholder={settings.dns} />
</Field>
<Field label="Keepalive (s)" hint={`0 uses the server default (${settings.keepalive}).`}>
<input className="input" type="number" min={0} max={65535} value={keepalive} onChange={(e) => setKeepalive(e.target.value)} />
</Field>
<Field label="MTU" hint={`0 uses the server default (${settings.mtu}).`}>
<input className="input" type="number" min={0} max={9000} value={mtu} onChange={(e) => setMtu(e.target.value)} />
</Field>
<Field label="Expires" hint="The peer is disconnected at this time. Blank never expires.">
<input className="input" type="datetime-local" value={expires} onChange={(e) => setExpires(e.target.value)} />
</Field>
{!editing && (
<>
<Field label="IPv4 address" hint="Blank picks the next free one.">
<input className="input mono" value={ipv4} onChange={(e) => setIpv4(e.target.value)} placeholder="auto" />
</Field>
<Field label="IPv6 address" hint="Only when the server has an IPv6 subnet.">
<input className="input mono" value={ipv6} onChange={(e) => setIpv6(e.target.value)} placeholder="auto" />
</Field>
</>
)}
</div>
<Field label="Notes">
<textarea className="input" value={notes} onChange={(e) => setNotes(e.target.value)} maxLength={2000} />
</Field>
<Check label="Enabled" hint="A disabled peer is removed from the interface and cannot connect." checked={enabled} onChange={setEnabled} />
</>
)}
</form>
</Modal>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useMemo } from "react";
import type { TrafficPoint } from "../api";
import { bytes } from "../format";
// Both charts are plain SVG: no library, no runtime dependency, and they
// pick their colours up from the CSS variables so light and dark just work.
export function TrafficChart({ points, from, to, bucketSeconds = 300 }: { points: TrafficPoint[]; from: number; to: number; bucketSeconds?: number }) {
const W = 800;
const H = 180;
const padL = 48;
const padB = 22;
const padT = 8;
const padR = 8;
const { rxPath, txPath, max, ticks, xTicks } = useMemo(() => {
const span = Math.max(1, to - from);
const byBucket = new Map<number, TrafficPoint>();
for (const p of points) byBucket.set(Math.floor(new Date(p.t).getTime() / 1000), p);
// One bar per bucket across the whole window, zeros where nothing was
// recorded, so quiet periods read as quiet rather than missing.
const start = Math.floor(from / 1000 / bucketSeconds) * bucketSeconds;
const end = Math.floor(to / 1000);
const rows: { t: number; rx: number; tx: number }[] = [];
for (let t = start; t <= end; t += bucketSeconds) {
const p = byBucket.get(t);
rows.push({ t, rx: p?.rx ?? 0, tx: p?.tx ?? 0 });
}
const max = Math.max(1, ...rows.map((r) => Math.max(r.rx, r.tx)));
const x = (t: number) => padL + ((t * 1000 - from) / span) * (W - padL - padR);
const y = (v: number) => padT + (1 - v / max) * (H - padT - padB);
const path = (key: "rx" | "tx") => {
if (rows.length === 0) return "";
let d = `M${x(rows[0].t).toFixed(1)},${y(0).toFixed(1)}`;
for (const r of rows) d += ` L${x(r.t).toFixed(1)},${y(r[key]).toFixed(1)}`;
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(rows[rows.length - 1][key]).toFixed(1)}`;
d += ` L${x(rows[rows.length - 1].t + bucketSeconds).toFixed(1)},${y(0).toFixed(1)} Z`;
return d;
};
const ticks = [0, 0.5, 1].map((f) => ({ v: max * f, y: y(max * f) }));
const xTicks: { x: number; label: string }[] = [];
const n = 6;
for (let i = 0; i <= n; i++) {
const t = from + (span * i) / n;
const d = new Date(t);
const label = span > 2 * 86400 * 1000 ? d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) : d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
xTicks.push({ x: padL + (i / n) * (W - padL - padR), label });
}
return { rxPath: path("rx"), txPath: path("tx"), max, ticks, xTicks };
}, [points, from, to, bucketSeconds]);
return (
<svg className="chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label="Traffic over time">
{ticks.map((t) => (
<g key={t.v}>
<line x1={padL} x2={W - padR} y1={t.y} y2={t.y} stroke="var(--line)" strokeWidth="1" />
<text x={padL - 6} y={t.y + 4} fontSize="10" textAnchor="end" fill="var(--fg-faint)">
{bytes(t.v)}
</text>
</g>
))}
<path d={txPath} fill="var(--tx)" fillOpacity="0.35" stroke="var(--tx)" strokeWidth="1.2" />
<path d={rxPath} fill="var(--rx)" fillOpacity="0.35" stroke="var(--rx)" strokeWidth="1.2" />
{xTicks.map((t, i) => (
<text key={i} x={t.x} y={H - 6} fontSize="10" textAnchor={i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"} fill="var(--fg-faint)">
{t.label}
</text>
))}
<title>peak {bytes(max)} per bucket</title>
</svg>
);
}
export function Sparkline({ values, color = "var(--accent)" }: { values: number[]; color?: string }) {
const W = 110;
const H = 26;
const d = useMemo(() => {
if (values.length < 2) return "";
const max = Math.max(1, ...values);
const step = W / (values.length - 1);
return values.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(H - 2 - (v / max) * (H - 4)).toFixed(1)}`).join(" ");
}, [values]);
return (
<svg className="sparkline" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
<path d={d} fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
}
export function Legend() {
return (
<div className="legend">
<span>
<i style={{ background: "var(--rx)" }} /> received from peers
</span>
<span>
<i style={{ background: "var(--tx)" }} /> sent to peers
</span>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, type ReactNode } from "react";
import { X } from "lucide-react";
export function Modal({ title, onClose, children, wide, footer }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean; footer?: ReactNode }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div
className="modal-back"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className={`card modal${wide ? " wide" : ""}`} role="dialog" aria-modal="true" aria-label={title}>
<div className="card-head">
<h2>{title}</h2>
<button className="btn icon ghost" onClick={onClose} aria-label="Close">
<X />
</button>
</div>
<div className="card-body">{children}</div>
{footer && (
<div className="card-head" style={{ borderTop: "1px solid var(--line)", borderBottom: 0, justifyContent: "flex-end" }}>
{footer}
</div>
)}
</div>
</div>
);
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<div className="field">
<label>{label}</label>
{children}
{hint && <span className="hint">{hint}</span>}
</div>
);
}
export function Check({ label, hint, checked, onChange, disabled }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
const id = `chk-${label.replace(/\W+/g, "-").toLowerCase()}`;
return (
<div className="check">
<input id={id} type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} />
<label htmlFor={id}>
{label}
{hint && <span className="hint">{hint}</span>}
</label>
</div>
);
}
export function Confirm({ title, text, confirmLabel = "Confirm", danger, onConfirm, onClose, busy }: { title: string; text: ReactNode; confirmLabel?: string; danger?: boolean; onConfirm: () => void; onClose: () => void; busy?: boolean }) {
return (
<Modal
title={title}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className={`btn ${danger ? "danger" : "primary"}`} onClick={onConfirm} disabled={busy}>
{confirmLabel}
</button>
</>
}
>
<p>{text}</p>
</Modal>
);
}
export function Segmented<T extends string>({ value, options, onChange }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void }) {
return (
<div className="segmented" role="tablist">
{options.map((o) => (
<button key={o.value} role="tab" aria-selected={o.value === value} className={o.value === value ? "active" : ""} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+70
View File
@@ -0,0 +1,70 @@
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
export function bytes(n: number): string {
if (!Number.isFinite(n) || n < 0) return "0 B";
let i = 0;
let v = n;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
const digits = i === 0 ? 0 : v < 10 ? 2 : v < 100 ? 1 : 0;
return `${v.toFixed(digits)} ${units[i]}`;
}
export function rate(bytesPerSecond: number): string {
const bits = bytesPerSecond * 8;
if (bits < 1000) return `${Math.round(bits)} bit/s`;
if (bits < 1e6) return `${(bits / 1e3).toFixed(bits < 1e4 ? 1 : 0)} kbit/s`;
if (bits < 1e9) return `${(bits / 1e6).toFixed(bits < 1e7 ? 2 : 1)} Mbit/s`;
return `${(bits / 1e9).toFixed(2)} Gbit/s`;
}
export function isZeroTime(s?: string): boolean {
return !s || s.startsWith("0001-01-01");
}
export function ago(s?: string, now = Date.now()): string {
if (isZeroTime(s)) return "never";
const t = new Date(s!).getTime();
const d = Math.max(0, Math.round((now - t) / 1000));
if (d < 5) return "just now";
if (d < 60) return `${d}s ago`;
if (d < 3600) return `${Math.floor(d / 60)}m ago`;
if (d < 86400) return `${Math.floor(d / 3600)}h ${Math.floor((d % 3600) / 60)}m ago`;
return `${Math.floor(d / 86400)}d ago`;
}
export function duration(from?: string, now = Date.now()): string {
if (isZeroTime(from)) return "";
const d = Math.max(0, Math.round((now - new Date(from!).getTime()) / 1000));
const h = Math.floor(d / 3600);
const m = Math.floor((d % 3600) / 60);
const s = d % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
export function dateTime(s?: string): string {
if (isZeroTime(s)) return "";
return new Date(s!).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
export function shortKey(k: string): string {
return k.length > 12 ? `${k.slice(0, 8)}${k.slice(-4)}` : k;
}
// Renders a Date as the value an <input type="datetime-local"> wants.
export function toLocalInput(s?: string): string {
if (isZeroTime(s)) return "";
const d = new Date(s!);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function fromLocalInput(v: string): string | null {
if (!v) return null;
const d = new Date(v);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
+251
View File
@@ -0,0 +1,251 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type SessionInfo } from "../api";
import { ago, dateTime } from "../format";
import { errorMessage, useAuth, useNow, useToast } from "../state";
import { Field, Modal, copyText } from "../components/ui";
export function Account() {
const { me, refresh } = useAuth();
const toast = useToast();
const now = useNow();
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const [pwError, setPwError] = useState("");
const [busy, setBusy] = useState(false);
const [totp, setTotp] = useState<{ secret: string; uri: string } | null>(null);
const [code, setCode] = useState("");
const [totpError, setTotpError] = useState("");
const [recovery, setRecovery] = useState<string[] | null>(null);
const [disablePw, setDisablePw] = useState("");
const [disabling, setDisabling] = useState(false);
const loadSessions = () => api.sessions().then(setSessions).catch(() => {});
useEffect(() => {
void loadSessions();
}, []);
async function changePassword(e: FormEvent) {
e.preventDefault();
setPwError("");
if (next !== confirm) {
setPwError("The new passwords do not match.");
return;
}
setBusy(true);
try {
await api.changePassword(current, next);
setCurrent("");
setNext("");
setConfirm("");
toast("Password changed; other sessions were signed out");
void loadSessions();
} catch (err) {
setPwError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function startTotp() {
try {
setTotp(await api.totpSetup());
setCode("");
setTotpError("");
} catch (err) {
toast(errorMessage(err), "bad");
}
}
async function confirmTotp(e: FormEvent) {
e.preventDefault();
setTotpError("");
try {
const r = await api.totpConfirm(code);
setTotp(null);
setRecovery(r.recoveryCodes);
await refresh();
} catch (err) {
setTotpError(errorMessage(err));
}
}
async function disableTotp(e: FormEvent) {
e.preventDefault();
try {
await api.totpDisable(disablePw);
setDisabling(false);
setDisablePw("");
toast("Two-factor authentication turned off");
await refresh();
} catch (err) {
toast(errorMessage(err), "bad");
}
}
return (
<>
<div className="page-head">
<div>
<h1>Account</h1>
<p>
Signed in as <b>{me?.username}</b> ({me?.role}).
</p>
</div>
</div>
<div className="grid grid-2">
<form className="card" onSubmit={changePassword}>
<div className="card-head">
<h2>Password</h2>
</div>
<div className="card-body">
{pwError && <div className="error">{pwError}</div>}
<Field label="Current password">
<input className="input" type="password" value={current} onChange={(e) => setCurrent(e.target.value)} required autoComplete="current-password" />
</Field>
<Field label="New password" hint="At least 12 characters.">
<input className="input" type="password" value={next} onChange={(e) => setNext(e.target.value)} required minLength={12} autoComplete="new-password" />
</Field>
<Field label="Confirm new password">
<input className="input" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required autoComplete="new-password" />
</Field>
<button className="btn primary" type="submit" disabled={busy}>
Change password
</button>
</div>
</form>
<div className="card">
<div className="card-head">
<h2>Two-factor authentication</h2>
{me?.totpEnabled ? <span className="badge ok">on</span> : <span className="badge">off</span>}
</div>
<div className="card-body">
{me?.totpEnabled ? (
<>
<p>A code from your authenticator app is required at every sign-in.</p>
<p className="muted small">
{me.recoveryCodesLeft} recovery code{me.recoveryCodesLeft === 1 ? "" : "s"} left.
</p>
{!disabling ? (
<button className="btn" onClick={() => setDisabling(true)}>
Turn off
</button>
) : (
<form onSubmit={disableTotp}>
<Field label="Confirm with your password">
<input className="input" type="password" value={disablePw} onChange={(e) => setDisablePw(e.target.value)} required autoComplete="current-password" autoFocus />
</Field>
<div className="btn-row">
<button className="btn danger" type="submit">
Turn off two-factor
</button>
<button className="btn" type="button" onClick={() => setDisabling(false)}>
Cancel
</button>
</div>
</form>
)}
</>
) : (
<>
<p>Add a time-based one-time code from an authenticator app (Aegis, Google Authenticator, 1Password, and so on).</p>
<button className="btn primary" onClick={startTotp}>
Set up
</button>
</>
)}
</div>
</div>
</div>
<div className="card mt">
<div className="card-head">
<h2>Sessions</h2>
{sessions.length > 1 && (
<button
className="btn sm"
onClick={() =>
api
.revokeSessions()
.then(() => {
toast("Other sessions signed out");
void loadSessions();
})
.catch((e) => toast(errorMessage(e), "bad"))
}
>
Sign out everywhere else
</button>
)}
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>Signed in</th>
<th>Last seen</th>
<th>From</th>
<th>Browser</th>
</tr>
</thead>
<tbody>
{sessions.map((s, i) => (
<tr key={i}>
<td>{s.current && <span className="badge accent">this one</span>}</td>
<td className="nowrap">{dateTime(s.createdAt)}</td>
<td className="nowrap">{ago(s.lastSeenAt, now)}</td>
<td className="mono">{s.ip}</td>
<td className="muted small" style={{ maxWidth: 360, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{s.userAgent}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{totp && (
<Modal title="Set up two-factor authentication" onClose={() => setTotp(null)}>
<form onSubmit={confirmTotp}>
<div className="qr">
<img src="/api/auth/totp/qr.png" alt="QR code for your authenticator app" width={256} height={256} style={{ maxWidth: 256 }} />
<p className="small muted">
Cannot scan? Enter this key by hand: <code>{totp.secret}</code>{" "}
<button type="button" className="btn sm ghost" onClick={() => copyText(totp.secret).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
copy
</button>
</p>
</div>
{totpError && <div className="error">{totpError}</div>}
<Field label="Enter the six-digit code the app shows">
<input className="input" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<button className="btn primary" type="submit">
Turn on
</button>
</form>
</Modal>
)}
{recovery && (
<Modal title="Recovery codes" onClose={() => setRecovery(null)}>
<p>Each of these signs you in once if you lose your authenticator. Keep them somewhere safe; they are not shown again.</p>
<ul className="recovery">
{recovery.map((c) => (
<li key={c}>{c}</li>
))}
</ul>
<div className="btn-row mt">
<button className="btn" onClick={() => copyText(recovery.join("\n")).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
Copy all
</button>
<button className="btn primary" onClick={() => setRecovery(null)}>
I have saved them
</button>
</div>
</Modal>
)}
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import { api, type AuditEntry } from "../api";
import { dateTime } from "../format";
import { errorMessage, useToast } from "../state";
export function Audit() {
const toast = useToast();
const [entries, setEntries] = useState<AuditEntry[]>([]);
const [query, setQuery] = useState("");
useEffect(() => {
api.audit(500).then(setEntries).catch((e) => toast(errorMessage(e), "bad"));
}, [toast]);
const q = query.trim().toLowerCase();
const rows = q ? entries.filter((e) => [e.actor, e.action, e.target, e.detail, e.ip].some((v) => v.toLowerCase().includes(q))) : entries;
return (
<>
<div className="page-head">
<div>
<h1>Audit log</h1>
<p>Every administrative action, newest first. The last 5,000 entries are kept.</p>
</div>
<input className="input search" placeholder="Filter…" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="card">
{rows.length === 0 ? (
<div className="empty">Nothing recorded yet.</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>When</th>
<th>Who</th>
<th>Action</th>
<th>Target</th>
<th>Detail</th>
<th>From</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr key={e.id}>
<td className="nowrap">{dateTime(e.at)}</td>
<td>{e.actor}</td>
<td>
<span className={`badge ${e.action.includes("failed") ? "bad" : e.action.includes("deleted") || e.action.includes("disabled") ? "warn" : ""}`}>{e.action}</span>
</td>
<td>{e.target}</td>
<td className="muted">{e.detail}</td>
<td className="mono">{e.ip}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { Link } from "wouter";
import { AlertTriangle, Copy } from "lucide-react";
import { api, type Peer, type Range, type Status, type TrafficPoint } from "../api";
import { ago, bytes, duration, rate } from "../format";
import { errorMessage, useLive, useNow, useToast } from "../state";
import { Legend, TrafficChart } from "../components/charts";
import { Segmented, copyText } from "../components/ui";
const rangeMs: Record<Range, number> = { "1h": 3600e3, "24h": 86400e3, "7d": 7 * 86400e3, "30d": 30 * 86400e3 };
export function Dashboard() {
const { snapshot, peersVersion } = useLive();
const toast = useToast();
const now = useNow();
const [status, setStatus] = useState<Status | null>(null);
const [peers, setPeers] = useState<Peer[]>([]);
const [range, setRange] = useState<Range>("24h");
const [series, setSeries] = useState<TrafficPoint[]>([]);
const [showSysctls, setShowSysctls] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
api.status().then(setStatus).catch((e) => setError(errorMessage(e)));
}, []);
useEffect(() => {
api.peers().then(setPeers).catch((e) => setError(errorMessage(e)));
}, [peersVersion]);
useEffect(() => {
let live = true;
const load = () => api.usage(range).then((s) => live && setSeries(s)).catch(() => {});
void load();
const t = setInterval(load, 60_000);
return () => {
live = false;
clearInterval(t);
};
}, [range]);
const totals = snapshot?.totals ?? status?.totals;
const connected = peers
.map((p) => ({ p, l: snapshot?.peers[p.id] ?? p.live }))
.filter((x) => x.l.connected)
.sort((a, b) => b.l.rxRate + b.l.txRate - (a.l.rxRate + a.l.txRate));
const unapplied = status?.sysctls.filter((s) => !s.applied) ?? [];
return (
<>
<div className="page-head">
<div>
<h1>Dashboard</h1>
<p>{status ? `${status.interface} on UDP ${status.listenPort} · ${status.backend} data plane` : " "}</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{status?.backend === "userspace" && (
<div className="notice">
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> Running on the userspace data plane (wireguard-go). Load the <code>wireguard</code> kernel module on the host for several times the throughput.
</div>
)}
{status?.backend === "mock" && <div className="notice">Mock data plane: no real tunnel exists. Traffic and handshakes are simulated.</div>}
{status?.firewallError && (
<div className="error">
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> Firewall rules were not applied: {status.firewallError}. Peers will connect but cannot reach beyond the server.
</div>
)}
{unapplied.some((s) => s.required) && (
<div className="error">
<AlertTriangle size={14} style={{ verticalAlign: -2 }} /> IP forwarding is off and could not be enabled. Pass <code>net.ipv4.ip_forward=1</code> in the container's sysctls.
</div>
)}
<div className="grid grid-4">
<Stat label="Connected" value={`${totals?.connected ?? 0}`} sub={`of ${totals?.active ?? 0} enabled · ${totals?.peers ?? 0} total`} />
<Stat label="Throughput" value={rate((totals?.rxRate ?? 0) + (totals?.txRate ?? 0))} sub={`${rate(totals?.rxRate ?? 0)} · ↑ ${rate(totals?.txRate ?? 0)}`} />
<Stat label="Received" value={bytes(totals?.rx ?? 0)} sub="from peers, all time" />
<Stat label="Sent" value={bytes(totals?.tx ?? 0)} sub="to peers, all time" />
</div>
<div className="grid grid-2 mt" style={{ gridTemplateColumns: "2fr 1fr" }}>
<div className="card">
<div className="card-head">
<h2>Traffic</h2>
<div className="toolbar">
<Legend />
<Segmented value={range} onChange={setRange} options={[{ value: "1h", label: "1h" }, { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, { value: "30d", label: "30d" }]} />
</div>
</div>
<div className="card-body">
<TrafficChart points={series} from={now - rangeMs[range]} to={now} bucketSeconds={range === "30d" ? 3600 : 300} />
<div className="small faint">Five-minute buckets{range === "30d" ? ", shown per hour" : ""}. Live rates above update every couple of seconds.</div>
</div>
</div>
<div className="card">
<div className="card-head">
<h2>Server</h2>
</div>
<div className="card-body">
{status && (
<dl className="kv">
<dt>Public key</dt>
<dd className="mono">
{status.publicKey}{" "}
<button className="btn icon ghost sm" title="Copy" onClick={() => copyText(status.publicKey).then((ok) => toast(ok ? "Copied" : "Could not copy", ok ? "ok" : "bad"))}>
<Copy />
</button>
</dd>
<dt>Endpoint</dt>
<dd className="mono">
{status.settings.endpointHost}:{status.settings.endpointPort}
</dd>
<dt>Tunnel</dt>
<dd className="mono">{status.addresses.join(", ")}</dd>
<dt>MTU</dt>
<dd>{status.settings.mtu}</dd>
<dt>Egress</dt>
<dd>{status.egress || (status.firewallManaged ? "any" : "not managed")}</dd>
<dt>Firewall</dt>
<dd>{status.firewallManaged ? (status.firewallError ? <span className="badge bad">failed</span> : <span className="badge ok">nftables</span>) : <span className="badge">host-managed</span>}</dd>
<dt>Up since</dt>
<dd>{duration(status.startedAt, now) || "—"}</dd>
<dt>Version</dt>
<dd>{status.version}</dd>
</dl>
)}
{status && status.sysctls.length > 0 && (
<div className="mt small">
<button className="btn sm ghost" onClick={() => setShowSysctls((v) => !v)} style={{ marginLeft: -8 }}>
{showSysctls ? "Hide" : "Show"} kernel tuning ({status.sysctls.length - unapplied.length}/{status.sysctls.length} applied)
</button>
{showSysctls && (
<div className="table-wrap mt">
<table>
<thead>
<tr>
<th>sysctl</th>
<th>wanted</th>
<th>current</th>
</tr>
</thead>
<tbody>
{status.sysctls.map((s) => (
<tr key={s.key} title={s.error ? `${s.why}. ${s.error}` : s.why}>
<td className="mono">{s.key}</td>
<td className="mono">{s.wanted}</td>
<td className="mono">
{s.current || "?"} {s.applied ? <span className="badge ok">ok</span> : <span className={`badge ${s.required ? "bad" : "warn"}`}>not set</span>}
</td>
</tr>
))}
</tbody>
</table>
{unapplied.length > 0 && <p className="faint mt">Values marked "not set" are global sysctls the container may not change. Apply them on the host; see docs/performance.md.</p>}
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="card mt">
<div className="card-head">
<h2>Connected now</h2>
<Link href="/peers" className="small">
All peers
</Link>
</div>
{connected.length === 0 ? (
<div className="empty">No peer has handshaken in the last {status?.settings.connectedWindow ?? 180} seconds.</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Peer</th>
<th>Address</th>
<th>Endpoint</th>
<th>Session</th>
<th>Handshake</th>
<th className="right">Rate</th>
<th className="right">Transfer</th>
</tr>
</thead>
<tbody>
{connected.map(({ p, l }) => (
<tr key={p.id} className="clickable" onClick={() => (window.location.hash = "")}>
<td>
<Link href={`/peers/${p.id}`}>
<span className="dot on" />
{p.name}
</Link>
</td>
<td className="mono">{p.ipv4}</td>
<td className="mono">{l.endpoint ?? "—"}</td>
<td>{duration(l.connectedSince, now)}</td>
<td>{ago(l.lastHandshake, now)}</td>
<td className="num right">
{rate(l.rxRate)} · {rate(l.txRate)}
</td>
<td className="num right">
{bytes(l.rx)} / {bytes(l.tx)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
<div className="card stat">
<div className="stat-label">{label}</div>
<div className="stat-value">{value}</div>
{sub && <div className="stat-sub">{sub}</div>}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, type FormEvent } from "react";
import { api } from "../api";
import { errorMessage, useAuth } from "../state";
import { Field } from "../components/ui";
export function Login() {
const { refresh } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [stage, setStage] = useState<"password" | "totp">("password");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setError("");
setBusy(true);
try {
if (stage === "password") {
const r = await api.login(username, password);
if (r.totpRequired) {
setStage("totp");
return;
}
} else {
await api.loginTotp(code);
}
await refresh();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="auth">
<form className="card" onSubmit={submit}>
<div className="card-body">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 32 32">
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="brand-name">WGX</div>
</div>
<h1>{stage === "password" ? "Sign in" : "Second factor"}</h1>
{error && <div className="error">{error}</div>}
{stage === "password" ? (
<>
<Field label="Username">
<input className="input" autoFocus autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
</Field>
<Field label="Password">
<input className="input" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</Field>
</>
) : (
<Field label="Authenticator code" hint="Or one of your recovery codes.">
<input className="input" autoFocus autoComplete="one-time-code" inputMode="numeric" value={code} onChange={(e) => setCode(e.target.value)} required />
</Field>
)}
<button className="btn primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "…" : stage === "password" ? "Sign in" : "Verify"}
</button>
<p className="small faint" style={{ textAlign: "center", margin: "14px 0 0" }}>
<a href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
AGPL-3.0 source
</a>
</p>
</div>
</form>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useRoute } from "wouter";
import { Plus, Search } from "lucide-react";
import { api, type Peer, type Settings } from "../api";
import { ago, bytes, rate } from "../format";
import { errorMessage, useAuth, useLive, useNow, useToast } from "../state";
import { Sparkline } from "../components/charts";
import { Segmented } from "../components/ui";
import { PeerForm } from "../components/PeerForm";
import { PeerDetail } from "../components/PeerDetail";
type Filter = "all" | "connected" | "offline" | "disabled";
export function Peers() {
const { me } = useAuth();
const { snapshot, peersVersion } = useLive();
const toast = useToast();
const now = useNow();
const [, navigate] = useLocation();
const [, params] = useRoute("/peers/:id");
const [peers, setPeers] = useState<Peer[]>([]);
const [settings, setSettings] = useState<Settings | null>(null);
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<(Peer & { config: string }) | null>(null);
const isAdmin = me?.role === "admin";
// Recent throughput per peer for the sparklines: one sample per snapshot.
const history = useRef<Map<string, number[]>>(new Map());
useEffect(() => {
if (!snapshot) return;
for (const [id, l] of Object.entries(snapshot.peers)) {
const h = history.current.get(id) ?? [];
h.push(l.rxRate + l.txRate);
if (h.length > 40) h.shift();
history.current.set(id, h);
}
}, [snapshot]);
const load = () =>
Promise.all([api.peers(), api.settings()])
.then(([p, s]) => {
setPeers(p);
setSettings(s);
})
.catch((e) => toast(errorMessage(e), "bad"));
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peersVersion]);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
return peers
.map((p) => ({ p, l: snapshot?.peers[p.id] ?? p.live }))
.filter(({ p, l }) => {
if (q && !(p.name.toLowerCase().includes(q) || p.ipv4.includes(q) || (p.ipv6 ?? "").includes(q) || p.publicKey.toLowerCase().startsWith(q) || (l.endpoint ?? "").includes(q) || p.notes.toLowerCase().includes(q))) return false;
switch (filter) {
case "connected":
return l.connected;
case "offline":
return !l.connected && p.enabled && !p.expired;
case "disabled":
return !p.enabled || p.expired;
default:
return true;
}
})
.sort((a, b) => {
// Connected first, then by name.
if (a.l.connected !== b.l.connected) return a.l.connected ? -1 : 1;
return a.p.name.localeCompare(b.p.name);
});
}, [peers, snapshot, query, filter]);
const selected = params?.id ? peers.find((p) => p.id === params.id) : undefined;
const counts = useMemo(() => {
let connected = 0;
let disabled = 0;
for (const p of peers) {
const l = snapshot?.peers[p.id] ?? p.live;
if (l.connected) connected++;
if (!p.enabled || p.expired) disabled++;
}
return { connected, disabled, offline: peers.length - connected - disabled };
}, [peers, snapshot]);
return (
<>
<div className="page-head">
<div>
<h1>Peers</h1>
<p>
{peers.length} peer{peers.length === 1 ? "" : "s"} · {counts.connected} connected
</p>
</div>
<div className="toolbar">
<div style={{ position: "relative" }}>
<Search size={14} style={{ position: "absolute", left: 9, top: 10, color: "var(--fg-faint)" }} />
<input className="input search" style={{ paddingLeft: 28 }} placeholder="Search name, address, key…" value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<Segmented
value={filter}
onChange={setFilter}
options={[
{ value: "all", label: "All" },
{ value: "connected", label: `Connected ${counts.connected}` },
{ value: "offline", label: `Offline ${counts.offline}` },
{ value: "disabled", label: `Disabled ${counts.disabled}` },
]}
/>
{isAdmin && (
<button className="btn primary" onClick={() => setCreating(true)}>
<Plus /> New peer
</button>
)}
</div>
</div>
<div className="card">
{rows.length === 0 ? (
<div className="empty">{peers.length === 0 ? "No peers yet. Create one and scan the QR code with the WireGuard app." : "Nothing matches."}</div>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Peer</th>
<th>Address</th>
<th>Endpoint</th>
<th>Handshake</th>
<th className="right">Rate</th>
<th></th>
<th className="right">Transfer</th>
</tr>
</thead>
<tbody>
{rows.map(({ p, l }) => {
const state = !p.enabled ? "disabled" : p.expired ? "expired" : l.connected ? "on" : "off";
return (
<tr key={p.id} className="clickable" onClick={() => navigate(`/peers/${p.id}`)}>
<td>
<span className={`dot ${state}`} title={state} />
{p.name}
{!p.enabled && <span className="badge bad" style={{ marginLeft: 8 }}>disabled</span>}
{p.enabled && p.expired && <span className="badge warn" style={{ marginLeft: 8 }}>expired</span>}
{!p.serverKeys && <span className="badge" style={{ marginLeft: 8 }} title="The client holds its own private key">client key</span>}
</td>
<td className="mono nowrap">{p.ipv4}</td>
<td className="mono nowrap">{l.endpoint || <span className="faint"></span>}</td>
<td className="nowrap">{ago(l.lastHandshake, now)}</td>
<td className="num right">{l.connected ? `${rate(l.rxRate)}${rate(l.txRate)}` : <span className="faint"></span>}</td>
<td>{l.connected && <Sparkline values={history.current.get(p.id) ?? []} />}</td>
<td className="num right">
{bytes(l.rx)} <span className="faint">/</span> {bytes(l.tx)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{creating && settings && (
<PeerForm
settings={settings}
onClose={() => setCreating(false)}
onSaved={(p) => {
setCreating(false);
toast(`Peer ${p.name} created`);
void load().then(() => {
setCreated(p as Peer & { config: string });
navigate(`/peers/${p.id}`);
});
}}
/>
)}
{selected && settings && (
<PeerDetail
key={selected.id + selected.updatedAt}
peer={selected}
live={snapshot?.peers[selected.id] ?? selected.live}
settings={settings}
isAdmin={!!isAdmin}
initialTab={created?.id === selected.id ? "config" : "overview"}
initialConfig={created?.id === selected.id ? created.config : undefined}
onClose={() => {
setCreated(null);
navigate("/peers");
}}
onChanged={() => void load()}
/>
)}
</>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, type Settings } from "../api";
import { errorMessage, useAuth, useLive, useToast } from "../state";
import { Check, Field } from "../components/ui";
export function SettingsPage() {
const { me } = useAuth();
const { settingsVersion } = useLive();
const toast = useToast();
const [s, setS] = useState<Settings | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const readOnly = me?.role !== "admin";
useEffect(() => {
api.settings().then(setS).catch((e) => setError(errorMessage(e)));
}, [settingsVersion]);
async function submit(e: FormEvent) {
e.preventDefault();
if (!s) return;
setError("");
setBusy(true);
try {
setS(await api.saveSettings(s));
toast("Settings saved");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
if (!s) return <div className="empty">{error || "Loading…"}</div>;
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setS({ ...s, [k]: v });
return (
<>
<div className="page-head">
<div>
<h1>Settings</h1>
<p>Changes apply at once. New client configurations use the new values; existing clients keep what they have.</p>
</div>
</div>
<form onSubmit={submit} className="stack">
{error && <div className="error">{error}</div>}
{readOnly && <div className="notice">You have the viewer role; settings are read-only.</div>}
<div className="card">
<div className="card-head">
<h2>Endpoint</h2>
</div>
<div className="card-body">
<div className="form-cols">
<Field label="Public host" hint="Hostname or IP address clients connect to.">
<input className="input" value={s.endpointHost} onChange={(e) => set("endpointHost", e.target.value)} disabled={readOnly} required />
</Field>
<Field label="Public port" hint="What clients dial. Usually the listen port; change it if the container's UDP port is remapped.">
<input className="input" type="number" min={1} max={65535} value={s.endpointPort} onChange={(e) => set("endpointPort", Number(e.target.value))} disabled={readOnly} required />
</Field>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<h2>Client defaults</h2>
</div>
<div className="card-body">
<div className="form-cols">
<Field label="DNS" hint="Comma separated. Clients use these while connected. Blank hands out none.">
<input className="input" value={s.dns} onChange={(e) => set("dns", e.target.value)} disabled={readOnly} />
</Field>
<Field label="Client routes (AllowedIPs)" hint="0.0.0.0/0, ::/0 sends everything through the tunnel.">
<input className="input mono" value={s.clientRoutes} onChange={(e) => set("clientRoutes", e.target.value)} disabled={readOnly} required />
</Field>
<Field label="MTU" hint="1420 fits an IPv4 underlay at 1500; use 1412 for PPPoE, 1400 or less for IPv6-over-IPv6 or when downloads stall.">
<input className="input" type="number" min={1280} max={9000} value={s.mtu} onChange={(e) => set("mtu", Number(e.target.value))} disabled={readOnly} required />
</Field>
<Field label="Persistent keepalive (s)" hint="25 keeps NAT mappings open on home routers. 0 disables it.">
<input className="input" type="number" min={0} max={65535} value={s.keepalive} onChange={(e) => set("keepalive", Number(e.target.value))} disabled={readOnly} required />
</Field>
</div>
<Check label="Preshared keys" hint="Add a per-peer preshared key to new peers: a symmetric layer on top of the key exchange." checked={s.presharedKeys} onChange={(v) => set("presharedKeys", v)} disabled={readOnly} />
</div>
</div>
<div className="card">
<div className="card-head">
<h2>Network</h2>
</div>
<div className="card-body">
<Check label="Peer isolation" hint="Drop traffic between peers. Each device can reach the server and the internet, but not the other devices." checked={s.peerIsolation} onChange={(v) => set("peerIsolation", v)} disabled={readOnly} />
<Check label="Clamp TCP MSS" hint="Rewrite the MSS of forwarded connections to fit the tunnel MTU. Leave on unless you know why not: it is the fix for “connected but pages hang”." checked={s.clampMSS} onChange={(v) => set("clampMSS", v)} disabled={readOnly} />
<Field label="Connected window (s)" hint="A peer counts as connected this long after its last handshake. WireGuard rejects a session after 180 s.">
<input className="input" type="number" min={30} max={3600} value={s.connectedWindow} onChange={(e) => set("connectedWindow", Number(e.target.value))} disabled={readOnly} required style={{ maxWidth: 160 }} />
</Field>
</div>
</div>
{!readOnly && (
<div>
<button className="btn primary" type="submit" disabled={busy}>
{busy ? "…" : "Save settings"}
</button>
</div>
)}
</form>
</>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useState, type FormEvent } from "react";
import { api } from "../api";
import { errorMessage, useAuth } from "../state";
import { Field } from "../components/ui";
export function Setup() {
const { refresh } = useAuth();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [endpointHost, setEndpointHost] = useState(window.location.hostname === "localhost" ? "" : window.location.hostname);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("The passwords do not match.");
return;
}
setBusy(true);
try {
await api.setup({ username, password, endpointHost });
await refresh();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="auth">
<form className="card" onSubmit={submit}>
<div className="card-body">
<div className="brand">
<div className="brand-mark" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 32 32">
<path d="M7 10l4 12 5-9 5 9 4-12" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<div className="brand-name">WGX</div>
</div>
<h1>Welcome</h1>
<p className="muted" style={{ textAlign: "center", marginBottom: 16 }}>
Create the first administrator. This form only works once.
</p>
{error && <div className="error">{error}</div>}
<Field label="Username">
<input className="input" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} required />
</Field>
<Field label="Password" hint="At least 12 characters. Length beats complexity.">
<input className="input" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={12} />
</Field>
<Field label="Confirm password">
<input className="input" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
</Field>
<Field label="Public endpoint" hint="The hostname or IP address clients will connect to. You can change it later in Settings.">
<input className="input" value={endpointHost} onChange={(e) => setEndpointHost(e.target.value)} placeholder="vpn.example.com" required />
</Field>
<button className="btn primary" type="submit" disabled={busy} style={{ width: "100%", justifyContent: "center" }}>
{busy ? "…" : "Create administrator"}
</button>
<p className="small faint" style={{ textAlign: "center", margin: "14px 0 0" }}>
<a href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer">
AGPL-3.0 source
</a>
</p>
</div>
</form>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState, type FormEvent } from "react";
import { Plus } from "lucide-react";
import { api, type User } from "../api";
import { ago, dateTime } from "../format";
import { errorMessage, useAuth, useNow, useToast } from "../state";
import { Confirm, Field, Modal } from "../components/ui";
export function UsersPage() {
const { me } = useAuth();
const toast = useToast();
const now = useNow();
const [users, setUsers] = useState<User[]>([]);
const [creating, setCreating] = useState(false);
const [editing, setEditing] = useState<User | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [busy, setBusy] = useState(false);
const isAdmin = me?.role === "admin";
const load = () => api.users().then(setUsers).catch((e) => toast(errorMessage(e), "bad"));
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<>
<div className="page-head">
<div>
<h1>Users</h1>
<p>Administrators manage everything; viewers can look but not touch.</p>
</div>
{isAdmin && (
<button className="btn primary" onClick={() => setCreating(true)}>
<Plus /> New user
</button>
)}
</div>
<div className="card">
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Two-factor</th>
<th>Last sign-in</th>
<th>Created</th>
{isAdmin && <th></th>}
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
{u.username} {u.id === me?.id && <span className="badge accent">you</span>}
</td>
<td>
<span className={`badge ${u.role === "admin" ? "accent" : ""}`}>{u.role}</span>
</td>
<td>{u.totpEnabled ? <span className="badge ok">on</span> : <span className="badge">off</span>}</td>
<td>{ago(u.lastLoginAt, now)}</td>
<td>{dateTime(u.createdAt)}</td>
{isAdmin && (
<td className="actions">
<button className="btn sm" onClick={() => setEditing(u)}>
Edit
</button>{" "}
{u.id !== me?.id && (
<button className="btn sm danger" onClick={() => setDeleting(u)}>
Delete
</button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
{creating && (
<UserForm
onClose={() => setCreating(false)}
onSaved={() => {
setCreating(false);
toast("User created");
void load();
}}
/>
)}
{editing && (
<UserEdit
user={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
toast("User updated");
void load();
}}
/>
)}
{deleting && (
<Confirm
title="Delete user"
danger
confirmLabel="Delete"
busy={busy}
onClose={() => setDeleting(null)}
onConfirm={async () => {
setBusy(true);
try {
await api.deleteUser(deleting.id);
toast("User deleted");
setDeleting(null);
void load();
} catch (e) {
toast(errorMessage(e), "bad");
} finally {
setBusy(false);
}
}}
text={
<>
Delete <b>{deleting.username}</b>? Their sessions end immediately.
</>
}
/>
)}
</>
);
}
function UserForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("admin");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await api.createUser({ username, password, role });
onSaved();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<Modal
title="New user"
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="btn primary" type="submit" form="user-form" disabled={busy}>
Create
</button>
</>
}
>
<form id="user-form" onSubmit={submit}>
{error && <div className="error">{error}</div>}
<Field label="Username">
<input className="input" autoFocus value={username} onChange={(e) => setUsername(e.target.value)} required autoComplete="off" />
</Field>
<Field label="Password" hint="At least 12 characters. Tell them to change it after signing in.">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={12} autoComplete="new-password" />
</Field>
<Field label="Role">
<select className="input" value={role} onChange={(e) => setRole(e.target.value)}>
<option value="admin">Administrator</option>
<option value="viewer">Viewer (read-only)</option>
</select>
</Field>
</form>
</Modal>
);
}
function UserEdit({ user, onClose, onSaved }: { user: User; onClose: () => void; onSaved: () => void }) {
const { me } = useAuth();
const [role, setRole] = useState(user.role);
const [password, setPassword] = useState("");
const [resetTotp, setResetTotp] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError("");
try {
await api.updateUser(user.id, { role: role !== user.role ? role : undefined, password: password || undefined, resetTotp });
onSaved();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<Modal
title={`Edit ${user.username}`}
onClose={onClose}
footer={
<>
<button className="btn" onClick={onClose} disabled={busy}>
Cancel
</button>
<button className="btn primary" type="submit" form="user-edit" disabled={busy}>
Save
</button>
</>
}
>
<form id="user-edit" onSubmit={submit}>
{error && <div className="error">{error}</div>}
<Field label="Role" hint={user.id === me?.id ? "You cannot change your own role." : undefined}>
<select className="input" value={role} onChange={(e) => setRole(e.target.value as User["role"])} disabled={user.id === me?.id}>
<option value="admin">Administrator</option>
<option value="viewer">Viewer (read-only)</option>
</select>
</Field>
<Field label="New password" hint="Leave blank to keep it. Setting one signs them out everywhere.">
<input className="input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} minLength={12} autoComplete="new-password" />
</Field>
{user.totpEnabled && (
<div className="check">
<input id="reset-totp" type="checkbox" checked={resetTotp} onChange={(e) => setResetTotp(e.target.checked)} />
<label htmlFor="reset-totp">
Reset two-factor authentication
<span className="hint">For a lost authenticator. They can set it up again from their account page.</span>
</label>
</div>
)}
</form>
</Modal>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { api, ApiError, type Me, type Snapshot } from "./api";
// --- auth -------------------------------------------------------------------
interface AuthState {
me: Me | null;
loading: boolean;
needsSetup: boolean;
refresh: () => Promise<void>;
signOut: () => Promise<void>;
}
const AuthCtx = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [me, setMe] = useState<Me | null>(null);
const [loading, setLoading] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
const refresh = useCallback(async () => {
try {
const setup = await api.setupStatus();
setNeedsSetup(setup.needsSetup);
if (setup.needsSetup) {
setMe(null);
return;
}
setMe(await api.me());
} catch (e) {
if (e instanceof ApiError && e.status === 401) setMe(null);
else setMe(null);
} finally {
setLoading(false);
}
}, []);
const signOut = useCallback(async () => {
try {
await api.logout();
} finally {
setMe(null);
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const value = useMemo(() => ({ me, loading, needsSetup, refresh, signOut }), [me, loading, needsSetup, refresh, signOut]);
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}
export function useAuth(): AuthState {
const v = useContext(AuthCtx);
if (!v) throw new Error("useAuth outside AuthProvider");
return v;
}
// --- live updates ----------------------------------------------------------
interface LiveState {
snapshot: Snapshot | null;
connected: boolean;
// Bumps whenever the peer list changed on the server.
peersVersion: number;
settingsVersion: number;
}
const LiveCtx = createContext<LiveState>({ snapshot: null, connected: false, peersVersion: 0, settingsVersion: 0 });
export function LiveProvider({ children }: { children: ReactNode }) {
const { me } = useAuth();
const [snapshot, setSnapshot] = useState<Snapshot | null>(null);
const [connected, setConnected] = useState(false);
const [peersVersion, setPeersVersion] = useState(0);
const [settingsVersion, setSettingsVersion] = useState(0);
useEffect(() => {
if (!me) {
setSnapshot(null);
setConnected(false);
return;
}
const es = new EventSource("/api/events");
es.onopen = () => setConnected(true);
es.onerror = () => setConnected(false);
es.addEventListener("status", (ev) => {
try {
setSnapshot(JSON.parse((ev as MessageEvent).data) as Snapshot);
} catch {
/* ignore malformed frames */
}
});
es.addEventListener("peers", () => setPeersVersion((v) => v + 1));
es.addEventListener("settings", () => setSettingsVersion((v) => v + 1));
return () => es.close();
}, [me]);
const value = useMemo(() => ({ snapshot, connected, peersVersion, settingsVersion }), [snapshot, connected, peersVersion, settingsVersion]);
return <LiveCtx.Provider value={value}>{children}</LiveCtx.Provider>;
}
export function useLive(): LiveState {
return useContext(LiveCtx);
}
// A ticking clock so relative times ("2m ago") stay honest.
export function useNow(intervalMs = 5000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const t = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(t);
}, [intervalMs]);
return now;
}
// --- toasts -----------------------------------------------------------------
interface Toast {
id: number;
text: string;
kind: "ok" | "bad";
}
const ToastCtx = createContext<(text: string, kind?: "ok" | "bad") => void>(() => {});
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const counter = useRef(0);
const push = useCallback((text: string, kind: "ok" | "bad" = "ok") => {
const id = ++counter.current;
setToasts((t) => [...t, { id, text, kind }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), kind === "bad" ? 6000 : 3500);
}, []);
return (
<ToastCtx.Provider value={push}>
{children}
<div className="toasts" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast ${t.kind}`}>
{t.text}
</div>
))}
</div>
</ToastCtx.Provider>
);
}
export function useToast() {
return useContext(ToastCtx);
}
export function errorMessage(e: unknown): string {
if (e instanceof ApiError) return e.message;
if (e instanceof Error) return e.message;
return String(e);
}
+257
View File
@@ -0,0 +1,257 @@
:root {
--bg: #f4f6f5;
--bg-elev: #ffffff;
--bg-sunken: #e9edeb;
--fg: #16201c;
--fg-muted: #5c6b64;
--fg-faint: #8b9791;
--line: #d8dfdb;
--line-strong: #b9c4be;
--accent: #1f6f5c;
--accent-fg: #ffffff;
--accent-soft: #dcefe8;
--ok: #1d8f4e;
--ok-soft: #dcf3e4;
--warn: #b7791f;
--warn-soft: #fbeed3;
--bad: #c2382f;
--bad-soft: #f9dedb;
--info: #2a6fb0;
--rx: #2a6fb0;
--tx: #1f6f5c;
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06);
--radius: 10px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111715;
--bg-elev: #19211e;
--bg-sunken: #0c100f;
--fg: #e6ece9;
--fg-muted: #9aa8a2;
--fg-faint: #6a7772;
--line: #26312d;
--line-strong: #3a4742;
--accent: #3fae90;
--accent-fg: #06110d;
--accent-soft: #163a31;
--ok: #3fc275;
--ok-soft: #12321f;
--warn: #e0a84a;
--warn-soft: #3a2c10;
--bad: #ef6b62;
--bad-soft: #3d1815;
--info: #5f9de0;
--rx: #5f9de0;
--tx: #3fae90;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
color-scheme: dark;
}
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
font-size: 14px;
line-height: 1.45;
color: var(--fg);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
code, .mono { font-family: var(--mono); font-size: 12.5px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
h1 { font-size: 20px; }
h2 { font-size: 16px; }
h3 { font-size: 14px; }
p { margin: 0 0 8px; }
button, input, select, textarea { font: inherit; color: inherit; }
::selection { background: var(--accent-soft); }
/* Layout */
.shell { display: grid; grid-template-columns: 220px 1fr; min-height: 100%; }
.sidebar {
background: var(--bg-elev);
border-right: 1px solid var(--line);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
position: sticky;
top: 0;
height: 100vh;
}
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 16px; }
.brand-mark {
width: 30px; height: 30px; border-radius: 8px; background: var(--accent);
display: grid; place-items: center; color: var(--accent-fg); flex: none;
}
.brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.02em; }
.brand-sub { font-size: 11px; color: var(--fg-muted); }
.nav a {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 8px; color: var(--fg-muted); font-weight: 500;
}
.nav a:hover { background: var(--bg-sunken); text-decoration: none; color: var(--fg); }
.nav a.active { background: var(--accent-soft); color: var(--accent); }
.nav a svg { width: 17px; height: 17px; }
.sidebar-foot { margin-top: auto; padding: 8px; font-size: 12px; color: var(--fg-muted); display: flex; flex-direction: column; gap: 6px; }
.main { padding: 24px 28px 48px; min-width: 0; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
.page-head p { color: var(--fg-muted); margin: 2px 0 0; }
.toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
@media (max-width: 860px) {
.shell { grid-template-columns: 1fr; }
.sidebar { position: static; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid var(--line); }
.brand { padding: 4px 8px; }
.nav { display: flex; flex-wrap: wrap; gap: 2px; }
.nav a span { display: none; }
.sidebar-foot { margin-top: 0; margin-left: auto; }
.main { padding: 16px; }
}
/* Cards and grids */
.card {
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-body { padding: 16px 18px; }
.card-head { padding: 12px 18px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.grid { display: grid; gap: 14px; }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 1100px) { .grid-4 { grid-template-columns: repeat(2, minmax(0, 1fr)); } .grid-3 { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) { .grid-4, .grid-3, .grid-2 { grid-template-columns: 1fr; } }
.stack { display: flex; flex-direction: column; gap: 14px; }
.stat { padding: 14px 16px; }
.stat-label { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; }
.stat-value { font-size: 24px; font-weight: 600; margin-top: 2px; font-variant-numeric: tabular-nums; }
.stat-sub { font-size: 12px; color: var(--fg-muted); margin-top: 2px; }
/* Tables */
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); vertical-align: middle; }
th { font-size: 12px; color: var(--fg-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
tbody tr:hover { background: color-mix(in srgb, var(--bg-sunken) 50%, transparent); }
tbody tr.clickable { cursor: pointer; }
td.num { font-variant-numeric: tabular-nums; white-space: nowrap; }
td.actions { white-space: nowrap; text-align: right; }
.empty { padding: 40px 16px; text-align: center; color: var(--fg-muted); }
/* Status dot */
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--fg-faint); vertical-align: middle; margin-right: 7px; }
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-soft); }
.dot.off { background: var(--fg-faint); }
.dot.disabled { background: var(--bad); }
.dot.expired { background: var(--warn); }
.status-text { white-space: nowrap; }
/* Badges */
.badge { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 11.5px; font-weight: 600; background: var(--bg-sunken); color: var(--fg-muted); white-space: nowrap; }
.badge.ok { background: var(--ok-soft); color: var(--ok); }
.badge.warn { background: var(--warn-soft); color: var(--warn); }
.badge.bad { background: var(--bad-soft); color: var(--bad); }
.badge.accent { background: var(--accent-soft); color: var(--accent); }
/* Buttons */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 12px; border-radius: 8px; border: 1px solid var(--line-strong);
background: var(--bg-elev); color: var(--fg); cursor: pointer; font-weight: 500; line-height: 1.2;
}
.btn svg { width: 15px; height: 15px; }
.btn:hover { background: var(--bg-sunken); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, var(--line-strong)); }
.btn.danger:hover { background: var(--bad-soft); }
.btn.sm { padding: 4px 8px; font-size: 12.5px; }
.btn.icon { padding: 5px; }
.btn.ghost { border-color: transparent; background: transparent; }
.btn.ghost:hover { background: var(--bg-sunken); }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* Forms */
.field { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
.field label { font-size: 12.5px; font-weight: 600; color: var(--fg-muted); }
.field .hint { font-size: 12px; color: var(--fg-faint); }
.input, textarea.input, select.input {
width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--line-strong);
background: var(--bg-elev); color: var(--fg); outline: none;
}
.input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
textarea.input { min-height: 72px; resize: vertical; }
.check { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
.check input { margin-top: 3px; accent-color: var(--accent); }
.check label { font-weight: 500; }
.check .hint { display: block; font-size: 12px; color: var(--fg-faint); font-weight: 400; }
.form-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 16px; }
@media (max-width: 640px) { .form-cols { grid-template-columns: 1fr; } }
.error { color: var(--bad); background: var(--bad-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
.notice { color: var(--warn); background: var(--warn-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
.success { color: var(--ok); background: var(--ok-soft); padding: 8px 10px; border-radius: 8px; margin-bottom: 12px; font-size: 13px; }
/* Auth pages */
.auth { min-height: 100%; display: grid; place-items: center; padding: 24px; }
.auth .card { width: 100%; max-width: 420px; }
.auth .brand { justify-content: center; padding-bottom: 4px; }
.auth h1 { text-align: center; margin: 4px 0 16px; }
/* Modal */
.modal-back { position: fixed; inset: 0; background: rgba(8, 12, 10, 0.5); display: grid; place-items: center; padding: 20px; z-index: 50; backdrop-filter: blur(2px); }
.modal { width: 100%; max-width: 640px; max-height: calc(100vh - 40px); overflow: auto; }
.modal.wide { max-width: 860px; }
.modal .card-head h2 { font-size: 16px; }
/* Toasts */
.toasts { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 60; }
.toast { background: var(--fg); color: var(--bg); padding: 10px 14px; border-radius: 8px; box-shadow: var(--shadow); font-size: 13px; max-width: 360px; }
.toast.bad { background: var(--bad); color: #fff; }
/* Peer detail */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
.kv dt { color: var(--fg-muted); }
.kv dd { margin: 0; word-break: break-all; }
.qr { display: grid; grid-template-columns: 1fr; gap: 16px; }
.qr img { width: 100%; max-width: 320px; image-rendering: pixelated; border-radius: 8px; border: 1px solid var(--line); background: #fff; justify-self: center; }
pre.config { background: var(--bg-sunken); border: 1px solid var(--line); border-radius: 8px; padding: 12px; font-family: var(--mono); font-size: 12.5px; overflow: auto; margin: 0; white-space: pre; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin-bottom: 14px; }
.tabs button { background: none; border: 0; padding: 8px 12px; cursor: pointer; color: var(--fg-muted); font-weight: 500; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); }
/* Charts */
.chart { width: 100%; height: 180px; display: block; }
.legend { display: flex; gap: 14px; font-size: 12px; color: var(--fg-muted); }
.legend i { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
.bar { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
.bar > i { display: block; height: 100%; background: var(--accent); }
.sparkline { width: 110px; height: 26px; display: block; }
.muted { color: var(--fg-muted); }
.faint { color: var(--fg-faint); }
.small { font-size: 12px; }
.right { text-align: right; }
.nowrap { white-space: nowrap; }
.mt { margin-top: 12px; }
.recovery { columns: 2; font-family: var(--mono); font-size: 14px; }
.recovery li { margin: 4px 0; }
.search { max-width: 280px; }
.segmented { display: inline-flex; border: 1px solid var(--line-strong); border-radius: 8px; overflow: hidden; }
.segmented button { border: 0; background: var(--bg-elev); padding: 5px 10px; cursor: pointer; color: var(--fg-muted); font-size: 12.5px; }
.segmented button + button { border-left: 1px solid var(--line); }
.segmented button.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// The build lands inside the Go module so `go build` embeds it.
export default defineConfig({
plugins: [react()],
build: {
outDir: "../internal/server/static/dist",
emptyOutDir: true,
sourcemap: false,
},
server: {
port: 5173,
proxy: {
"/api": { target: "http://127.0.0.1:51821", changeOrigin: false },
"/metrics": { target: "http://127.0.0.1:51821", changeOrigin: false },
},
},
});