Merge pull request #5 from Coffey-Labs/rename/ihasvpn

Rename the project to ihasvpn
This commit is contained in:
jcoffey
2026-09-12 23:54:22 -07:00
committed by GitHub
74 changed files with 529 additions and 369 deletions
+1 -1
View File
@@ -30,4 +30,4 @@ jobs:
- name: govulncheck - name: govulncheck
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
- name: Docker build - name: Docker build
run: docker build -t wgx:ci . run: docker build -t ihasvpn:ci .
+2 -2
View File
@@ -25,7 +25,7 @@ on:
default: false default: false
env: env:
IMAGE: ghcr.io/coffey-labs/wgx IMAGE: ghcr.io/coffey-labs/ihasvpn
jobs: jobs:
version: version:
@@ -74,7 +74,7 @@ jobs:
with: with:
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
build-args: WGX_VERSION=${{ needs.version.outputs.version }} build-args: IHASVPN_VERSION=${{ needs.version.outputs.version }}
provenance: false provenance: false
sbom: false sbom: false
cache-from: type=gha,scope=${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.platform }}
+1 -1
View File
@@ -1,5 +1,5 @@
# Build output # Build output
/wgx /ihasvpn
/dist/ /dist/
web/node_modules/ web/node_modules/
web/dist/ web/dist/
+6 -6
View File
@@ -1,11 +1,11 @@
# Contributing to WGX # Contributing to ihasvpn
Thanks for your interest. Bug reports, feature requests, code and Thanks for your interest. Bug reports, feature requests, code and
documentation are all welcome. documentation are all welcome.
## Before you start ## Before you start
- WGX is one container: the WireGuard server and the UI that manages it. - ihasvpn is one container: the WireGuard server and the UI that manages it.
Contributions that need a second service (a database, a message queue, a Contributions that need a second service (a database, a message queue, a
separate frontend host) are out of scope. separate frontend host) are out of scope.
- The kernel data plane is the point. Anything on the packet path has to - The kernel data plane is the point. Anything on the packet path has to
@@ -22,7 +22,7 @@ You need Go (see `go.mod` for the version), Node 26 and Docker.
cd web && npm ci && npm run dev cd web && npm ci && npm run dev
# Backend against the in-memory mock data plane -- no privileges needed # 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 IHASVPN_BACKEND=mock IHASVPN_DATA_DIR=/tmp/ihasvpn IHASVPN_HTTP_LISTEN=127.0.0.1:51821 go run ./cmd/ihasvpn
``` ```
The mock simulates peers handshaking and moving traffic so the dashboard has The mock simulates peers handshaking and moving traffic so the dashboard has
@@ -30,9 +30,9 @@ something to show. For the real thing:
```sh ```sh
cd web && npm run build && cd .. cd web && npm run build && cd ..
docker build -t wgx:dev . docker build -t ihasvpn:dev .
docker run --rm --cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \ 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 -p 51820:51820/udp -p 127.0.0.1:51821:51821 -v ihasvpn-dev:/data ihasvpn:dev
``` ```
## Before you commit ## Before you commit
@@ -42,7 +42,7 @@ CI checks are not a substitute for building locally. Run, in this order:
```sh ```sh
cd web && npm run build && cd .. # type-checks and builds the UI cd web && npm run build && cd .. # type-checks and builds the UI
go vet ./... && go test -count=1 ./... go vet ./... && go test -count=1 ./...
docker build -t wgx:dev . # when the change reaches the image docker build -t ihasvpn:dev . # when the change reaches the image
``` ```
`go test` covers the engine against the mock data plane and the whole HTTP `go test` covers the engine against the mock data plane and the whole HTTP
+6 -6
View File
@@ -10,14 +10,14 @@ RUN npm run build
FROM golang:1.27-alpine AS build FROM golang:1.27-alpine AS build
# The version string the binary reports. Worked out by whoever runs the # The version string the binary reports. Worked out by whoever runs the
# build (CI passes the tag); left empty it says "dev". # build (CI passes the tag); left empty it says "dev".
ARG WGX_VERSION=dev ARG IHASVPN_VERSION=dev
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY cmd/ cmd/ COPY cmd/ cmd/
COPY internal/ internal/ COPY internal/ internal/
COPY --from=web /src/internal/server/static/dist internal/server/static/dist 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 RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/Coffey-Labs/ihasvpn/internal/engine.Version=${IHASVPN_VERSION}" -o /ihasvpn ./cmd/ihasvpn
# ---- runtime ---- # ---- runtime ----
FROM alpine:3.22 FROM alpine:3.22
@@ -25,11 +25,11 @@ FROM alpine:3.22
# without the kernel module; wireguard-tools gives `wg show` for debugging. # without the kernel module; wireguard-tools gives `wg show` for debugging.
RUN apk add --no-cache nftables wireguard-go wireguard-tools ca-certificates tzdata \ RUN apk add --no-cache nftables wireguard-go wireguard-tools ca-certificates tzdata \
&& mkdir -p /data && mkdir -p /data
COPY --from=build /wgx /usr/local/bin/wgx COPY --from=build /ihasvpn /usr/local/bin/ihasvpn
ENV WGX_DATA_DIR=/data \ ENV IHASVPN_DATA_DIR=/data \
WGX_HTTP_LISTEN=:51821 IHASVPN_HTTP_LISTEN=:51821
VOLUME ["/data"] VOLUME ["/data"]
EXPOSE 51820/udp 51821/tcp EXPOSE 51820/udp 51821/tcp
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://127.0.0.1:51821/api/health || exit 1 CMD wget -qO- http://127.0.0.1:51821/api/health || exit 1
ENTRYPOINT ["wgx"] ENTRYPOINT ["ihasvpn"]
+3 -3
View File
@@ -1,6 +1,6 @@
# Third-party notices # Third-party notices
WGX is licensed under the AGPL-3.0; see LICENSE. This file records work by ihasvpn 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 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 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. cache and in web/node_modules and is not repeated here.
@@ -29,7 +29,7 @@ cache and in web/node_modules and is not repeated here.
The container image is built on Alpine Linux and ships nftables, The container image is built on Alpine Linux and ships nftables,
wireguard-tools and wireguard-go from its package repositories, each under wireguard-tools and wireguard-go from its package repositories, each under
its own licence (GPL-2.0 for nftables and wireguard-tools, MIT for 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-go). They are separate programs invoked by ihasvpn, not linked into it.
WireGuard is a registered trademark of Jason A. Donenfeld. WGX is not WireGuard is a registered trademark of Jason A. Donenfeld. ihasvpn is not
affiliated with or endorsed by the WireGuard project. affiliated with or endorsed by the WireGuard project.
+34 -46
View File
@@ -1,12 +1,12 @@
<p align="center"> <p align="center">
<img src="docs/brand/wgx-mark-256.png" width="128" height="128" alt="WGX: a padlock on a shield, with a W for a keyhole"> <img src="docs/brand/ihasvpn-mark-256.png" width="128" height="128" alt="ihasvpn: an orange cat peeking over the edge of a teal shield">
</p> </p>
# WGX # ihasvpn
A WireGuard server with a secure web console, in one container. A self-hosted 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 Start it, open the console, create a peer, scan the QR code. ihasvpn runs the
tunnel on the kernel's WireGuard module, keeps the NAT rules and forwarding 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 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. much they are moving, and a button to cut them off.
@@ -16,7 +16,7 @@ much they are moving, and a button to cut them off.
- **Peers.** Create, edit, disable, delete. The server generates the key - **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 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 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 device. Pin a tunnel address or let ihasvpn allocate one. Set an expiry and the
peer is disconnected on time. Rotate keys in one click. peer is disconnected on time. Rotate keys in one click.
- **Who is connected.** Live status from the interface counters every two - **Who is connected.** Live status from the interface counters every two
seconds: endpoint, last handshake, session length, current rate, total seconds: endpoint, last handshake, session length, current rate, total
@@ -68,8 +68,8 @@ apply without a restart.
## Quick start ## Quick start
```sh ```sh
curl -O https://raw.githubusercontent.com/Coffey-Labs/WGX/main/docker-compose.yml curl -O https://raw.githubusercontent.com/Coffey-Labs/ihasvpn/main/docker-compose.yml
# edit WGX_ENDPOINT (your public hostname or IP), then: # edit IHASVPN_ENDPOINT (your public hostname or IP), then:
docker compose up -d docker compose up -d
``` ```
@@ -77,9 +77,9 @@ Open <http://localhost:51821>, create the first administrator, and add a
peer. Point the WireGuard app on your phone at the QR code. 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 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 it from elsewhere, either set `IHASVPN_TLS_SELF_SIGNED: "true"` and bind to the
address you need, or put a TLS-terminating reverse proxy in front of it and address you need, or put a TLS-terminating reverse proxy in front of it and
list the proxy in `WGX_TRUSTED_PROXIES`. list the proxy in `IHASVPN_TRUSTED_PROXIES`.
For the fastest configuration, `docker-compose.host.yml` runs on the host For the fastest configuration, `docker-compose.host.yml` runs on the host
network; [docs/performance.md](docs/performance.md) says when that is worth network; [docs/performance.md](docs/performance.md) says when that is worth
@@ -104,33 +104,33 @@ routes, MTU, keepalive, peer isolation, MSS clamping, preshared keys).
| Variable | Default | Meaning | | Variable | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `WGX_ENDPOINT` | | Public hostname or IP for client configs. Also asked for at first-run setup. | | `IHASVPN_ENDPOINT` | | Public hostname or IP for client configs. Also asked for at first-run setup. |
| `WGX_PORT` | `51820` | UDP listen port. | | `IHASVPN_PORT` | `51820` | UDP listen port. |
| `WGX_SUBNET` | `10.8.0.0/24` | IPv4 tunnel network; the server takes the first address. | | `IHASVPN_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. | | `IHASVPN_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. | | `IHASVPN_DNS` | `1.1.1.1, 1.0.0.1` | Resolvers handed to clients on first run. |
| `WGX_INTERFACE` | `wg0` | Interface name. | | `IHASVPN_INTERFACE` | `wg0` | Interface name. |
| `WGX_EGRESS_INTERFACE` | auto | Interface to masquerade on. Auto uses the default route. | | `IHASVPN_EGRESS_INTERFACE` | auto | Interface to masquerade on. Auto uses the default route. |
| `WGX_HTTP_LISTEN` | `:51821` | Console listen address. | | `IHASVPN_HTTP_LISTEN` | `:51821` | Console listen address. |
| `WGX_TLS_SELF_SIGNED` | `false` | Serve HTTPS with a certificate generated into `/data`. | | `IHASVPN_TLS_SELF_SIGNED` | `false` | Serve HTTPS with a certificate generated into `/data`. |
| `WGX_TLS_CERT`, `WGX_TLS_KEY` | | Serve HTTPS with your own certificate. | | `IHASVPN_TLS_CERT`, `IHASVPN_TLS_KEY` | | Serve HTTPS with your own certificate. |
| `WGX_SECURE_COOKIES` | `false` | Mark cookies `Secure` when TLS terminates at a proxy. | | `IHASVPN_SECURE_COOKIES` | `false` | Mark cookies `Secure` when TLS terminates at a proxy. |
| `WGX_TRUSTED_PROXIES` | | CIDRs whose `X-Forwarded-For` is believed. | | `IHASVPN_TRUSTED_PROXIES` | | CIDRs whose `X-Forwarded-For` is believed. |
| `WGX_METRICS_TOKEN` | | Bearer token for `/metrics`. A signed-in session works too. | | `IHASVPN_METRICS_TOKEN` | | Bearer token for `/metrics`. A signed-in session works too. |
| `WGX_SESSION_IDLE` | `12h` | Sign out after this much inactivity. | | `IHASVPN_SESSION_IDLE` | `12h` | Sign out after this much inactivity. |
| `WGX_SESSION_MAX` | `168h` | Sign out after this long regardless. | | `IHASVPN_SESSION_MAX` | `168h` | Sign out after this long regardless. |
| `WGX_TRAFFIC_RETENTION` | `2160h` | How long usage history is kept (90 days). | | `IHASVPN_TRAFFIC_RETENTION` | `2160h` | How long usage history is kept (90 days). |
| `WGX_POLL_INTERVAL` | `2s` | How often the interface counters are read. | | `IHASVPN_POLL_INTERVAL` | `2s` | How often the interface counters are read. |
| `WGX_BACKEND` | `auto` | `kernel`, `userspace` or `mock`. Auto prefers the kernel. | | `IHASVPN_BACKEND` | `auto` | `kernel`, `userspace` or `mock`. Auto prefers the kernel. |
| `WGX_MANAGE_FIREWALL` | `true` | Set to `false` if the host owns the NAT rules. | | `IHASVPN_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. | | `IHASVPN_MANAGE_SYSCTL` | `true` | Set to `false` if the host has tuned itself. |
| `WGX_DATA_DIR` | `/data` | Where the database and TLS files live. | | `IHASVPN_DATA_DIR` | `/data` | Where the database and TLS files live. |
| `WGX_LOG_LEVEL`, `WGX_LOG_JSON` | `info`, `false` | Logging. | | `IHASVPN_LOG_LEVEL`, `IHASVPN_LOG_JSON` | `info`, `false` | Logging. |
## Locked out? ## Locked out?
```sh ```sh
docker exec -it wgx wgx reset-password admin docker exec -it ihasvpn ihasvpn reset-password admin
``` ```
sets a new password for that user, clears their second factor and ends sets a new password for that user, clears their second factor and ends
@@ -158,25 +158,13 @@ call it from the same origin or from a non-browser client.
```sh ```sh
cd web && npm ci && npm run build && cd .. cd web && npm ci && npm run build && cd ..
go build ./cmd/wgx go build ./cmd/ihasvpn
``` ```
The UI is embedded in the binary. `docker build -t wgx .` does both steps. The UI is embedded in the binary. `docker build -t ihasvpn .` does both steps.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop against the See [CONTRIBUTING.md](CONTRIBUTING.md) for the development loop against the
mock data plane, which needs no privileges. mock data plane, which needs no privileges.
## History
This is a complete, ground-up rewrite of an earlier WGX, "WireGuard
eXtended", which Coffey Labs published in October 2025 and later dropped.
That one was an installer: a collection of Bash scripts behind a text-mode
menu that set up and hardened a WireGuard stack on Debian 13 around a
third-party web UI. The
[original announcement](https://jcoffey.dev/articles/wg-easy-installer-debian-13/)
is still up. Nothing from it was carried over; this WGX is its own server and
its own console, in one container, with the name kept because the intent is
the same.
## Licence ## Licence
AGPL-3.0-or-later. See [LICENSE](LICENSE). AGPL-3.0-or-later. See [LICENSE](LICENSE).
+3 -3
View File
@@ -12,7 +12,7 @@ patched.
you think the impact is. You will get an acknowledgement within a few days 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. and a fix or a plan before anything is made public.
## What WGX does to protect itself ## What ihasvpn does to protect itself
- The admin UI requires a password (argon2id, 64 MiB, 3 passes) and offers - 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 time-based one-time codes with recovery codes. Sessions are random 256-bit
@@ -36,8 +36,8 @@ and a fix or a plan before anything is made public.
## What you must do ## What you must do
- Do not expose port 51821 to the internet without TLS. Either set - 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 `IHASVPN_TLS_SELF_SIGNED=true` (or `IHASVPN_TLS_CERT`/`IHASVPN_TLS_KEY`) or put a
TLS-terminating reverse proxy in front and list it in TLS-terminating reverse proxy in front and list it in
`WGX_TRUSTED_PROXIES` so client addresses in the audit log are right. `IHASVPN_TRUSTED_PROXIES` so client addresses in the audit log are right.
- Turn on two-factor authentication for every administrator. - Turn on two-factor authentication for every administrator.
- Keep the `/data` volume private: it holds every peer's private key. - Keep the `/data` volume private: it holds every peer's private key.
+17 -17
View File
@@ -1,9 +1,9 @@
// Command wgx runs the WireGuard server and its admin UI. // Command ihasvpn runs the WireGuard server and its admin UI.
// //
// wgx run the server (the container's default) // ihasvpn run the server (the container's default)
// wgx reset-password U set a new password for admin user U and drop // ihasvpn reset-password U set a new password for admin user U and drop
// their sessions and second factor; for lockouts // their sessions and second factor; for lockouts
// wgx version print the version // ihasvpn version print the version
package main package main
import ( import (
@@ -19,19 +19,19 @@ import (
"golang.org/x/term" "golang.org/x/term"
"github.com/Coffey-Labs/WGX/internal/auth" "github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/WGX/internal/config" "github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/WGX/internal/engine" "github.com/Coffey-Labs/ihasvpn/internal/engine"
"github.com/Coffey-Labs/WGX/internal/server" "github.com/Coffey-Labs/ihasvpn/internal/server"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg" "github.com/Coffey-Labs/ihasvpn/internal/wg"
) )
func main() { func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
switch os.Args[1] { switch os.Args[1] {
case "version", "--version", "-v": case "version", "--version", "-v":
fmt.Println("wgx", engine.Version) fmt.Println("ihasvpn", engine.Version)
return return
case "reset-password": case "reset-password":
if err := resetPassword(os.Args[2:]); err != nil { if err := resetPassword(os.Args[2:]); err != nil {
@@ -49,14 +49,14 @@ func main() {
} }
} }
if err := run(); err != nil { if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "wgx:", err) fmt.Fprintln(os.Stderr, "ihasvpn:", err)
os.Exit(1) os.Exit(1)
} }
} }
const usage = `usage: wgx [serve | reset-password <user> | version] const usage = `usage: ihasvpn [serve | reset-password <user> | version]
Configuration is read from WGX_* environment variables; see the README. Configuration is read from IHASVPN_* environment variables; see the README.
` `
func newLogger(cfg *config.Config) *slog.Logger { func newLogger(cfg *config.Config) *slog.Logger {
@@ -101,7 +101,7 @@ func run() error {
return err return err
} }
log := newLogger(cfg) log := newLogger(cfg)
log.Info("starting wgx", "version", engine.Version) log.Info("starting ihasvpn", "version", engine.Version)
st, err := store.Open(cfg.DBPath) st, err := store.Open(cfg.DBPath)
if err != nil { if err != nil {
@@ -146,7 +146,7 @@ func run() error {
// It runs inside the container against the same database. // It runs inside the container against the same database.
func resetPassword(args []string) error { func resetPassword(args []string) error {
if len(args) != 1 { if len(args) != 1 {
return errors.New("usage: wgx reset-password <username>") return errors.New("usage: ihasvpn reset-password <username>")
} }
cfg, err := config.FromEnv() cfg, err := config.FromEnv()
if err != nil { if err != nil {
@@ -163,7 +163,7 @@ func resetPassword(args []string) error {
return fmt.Errorf("no user named %q", args[0]) return fmt.Errorf("no user named %q", args[0])
} }
var pw string var pw string
if v := os.Getenv("WGX_NEW_PASSWORD"); v != "" { if v := os.Getenv("IHASVPN_NEW_PASSWORD"); v != "" {
pw = v pw = v
} else { } else {
fmt.Fprint(os.Stderr, "New password: ") fmt.Fprint(os.Stderr, "New password: ")
+16 -16
View File
@@ -1,4 +1,4 @@
# WGX on the host network: the fastest way to run it. # ihasvpn on the host network: the fastest way to run it.
# #
# With `network_mode: host` the WireGuard socket sits directly on the host's # 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 # interfaces. There is no port mapping, no conntrack entry per client packet
@@ -6,32 +6,32 @@
# little latency on a busy server. The trade-offs: wg0 is created in the # 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 # 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 # 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 # `ihasvpn`, 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 # localhost below. Put a reverse proxy in front of it or set
# WGX_TLS_SELF_SIGNED to reach it from elsewhere. # IHASVPN_TLS_SELF_SIGNED to reach it from elsewhere.
services: services:
wgx: ihasvpn:
image: ghcr.io/coffey-labs/wgx:latest image: ghcr.io/coffey-labs/ihasvpn:latest
container_name: wgx container_name: ihasvpn
restart: unless-stopped restart: unless-stopped
network_mode: host network_mode: host
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
environment: environment:
WGX_ENDPOINT: vpn.example.com IHASVPN_ENDPOINT: vpn.example.com
WGX_PORT: "51820" IHASVPN_PORT: "51820"
WGX_SUBNET: 10.8.0.0/24 IHASVPN_SUBNET: 10.8.0.0/24
WGX_DNS: 1.1.1.1, 1.0.0.1 IHASVPN_DNS: 1.1.1.1, 1.0.0.1
WGX_HTTP_LISTEN: "127.0.0.1:51821" IHASVPN_HTTP_LISTEN: "127.0.0.1:51821"
# In host mode the forwarding sysctls are the host's own; WGX sets # In host mode the forwarding sysctls are the host's own; ihasvpn sets
# them itself since it has NET_ADMIN, but if you prefer to own them # 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. # add `net.ipv4.ip_forward = 1` to /etc/sysctl.d/ and turn this off.
# WGX_MANAGE_SYSCTL: "false" # IHASVPN_MANAGE_SYSCTL: "false"
# Pick the interface to masquerade on if auto-detection picks the # Pick the interface to masquerade on if auto-detection picks the
# wrong one (it uses the default route). # wrong one (it uses the default route).
# WGX_EGRESS_INTERFACE: eth0 # IHASVPN_EGRESS_INTERFACE: eth0
volumes: volumes:
- wgx-data:/data - ihasvpn-data:/data
volumes: volumes:
wgx-data: ihasvpn-data:
+17 -17
View File
@@ -1,4 +1,4 @@
# WGX: a WireGuard server with a web admin UI, in one container. # ihasvpn: a WireGuard server with a web admin UI, in one container.
# #
# Start it, open http://<host>:51821, create the first administrator, add a # 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 # peer, scan the QR code. The container needs NET_ADMIN to create the tunnel
@@ -7,9 +7,9 @@
# For the highest throughput see docs/performance.md: it explains when to use # For the highest throughput see docs/performance.md: it explains when to use
# docker-compose.host.yml (host networking) and which host sysctls matter. # docker-compose.host.yml (host networking) and which host sysctls matter.
services: services:
wgx: ihasvpn:
image: ghcr.io/coffey-labs/wgx:latest image: ghcr.io/coffey-labs/ihasvpn:latest
container_name: wgx container_name: ihasvpn
restart: unless-stopped restart: unless-stopped
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
@@ -21,35 +21,35 @@ services:
- net.ipv4.ip_forward=1 - net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1 - net.ipv4.conf.all.src_valid_mark=1
# Loose reverse-path filtering; strict drops replies arriving on the # Loose reverse-path filtering; strict drops replies arriving on the
# tunnel. WGX would set this itself but /proc/sys is read-only in a # tunnel. ihasvpn would set this itself but /proc/sys is read-only in a
# container, so it has to come from here. # container, so it has to come from here.
- net.ipv4.conf.all.rp_filter=2 - net.ipv4.conf.all.rp_filter=2
- net.ipv4.conf.default.rp_filter=2 - net.ipv4.conf.default.rp_filter=2
# Uncomment with WGX_SUBNET6 for IPv6 inside the tunnel. # Uncomment with IHASVPN_SUBNET6 for IPv6 inside the tunnel.
# - net.ipv6.conf.all.forwarding=1 # - net.ipv6.conf.all.forwarding=1
# - net.ipv6.conf.all.disable_ipv6=0 # - net.ipv6.conf.all.disable_ipv6=0
environment: environment:
# The public hostname or IP clients connect to. Asked for at setup too. # The public hostname or IP clients connect to. Asked for at setup too.
WGX_ENDPOINT: vpn.example.com IHASVPN_ENDPOINT: vpn.example.com
# UDP port WireGuard listens on; must match the port mapping. # UDP port WireGuard listens on; must match the port mapping.
WGX_PORT: "51820" IHASVPN_PORT: "51820"
# Tunnel network. The server takes the first address. # Tunnel network. The server takes the first address.
WGX_SUBNET: 10.8.0.0/24 IHASVPN_SUBNET: 10.8.0.0/24
# WGX_SUBNET6: fd42:42:42::/64 # IHASVPN_SUBNET6: fd42:42:42::/64
# DNS handed to clients by default. # DNS handed to clients by default.
WGX_DNS: 1.1.1.1, 1.0.0.1 IHASVPN_DNS: 1.1.1.1, 1.0.0.1
# Admin UI. Put a TLS-terminating proxy in front of it, or enable the # Admin UI. Put a TLS-terminating proxy in front of it, or enable the
# built-in self-signed certificate, before exposing it anywhere but # built-in self-signed certificate, before exposing it anywhere but
# localhost or your LAN. # localhost or your LAN.
WGX_HTTP_LISTEN: ":51821" IHASVPN_HTTP_LISTEN: ":51821"
# WGX_TLS_SELF_SIGNED: "true" # IHASVPN_TLS_SELF_SIGNED: "true"
# WGX_TRUSTED_PROXIES: 172.16.0.0/12 # IHASVPN_TRUSTED_PROXIES: 172.16.0.0/12
# WGX_METRICS_TOKEN: change-me # IHASVPN_METRICS_TOKEN: change-me
ports: ports:
- "51820:51820/udp" - "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp" - "127.0.0.1:51821:51821/tcp"
volumes: volumes:
- wgx-data:/data - ihasvpn-data:/data
volumes: volumes:
wgx-data: ihasvpn-data:
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""Generate the ihasvpn brand set from one drawing.
Writes docs/brand/* and the favicons and app icons under web/public. The same
drawing is inlined in web/src/components/Mark.tsx; change both together.
Needs rsvg-convert, ImageMagick 7 (`magick`) and the Fira Sans font, the face
the ihasmail.org cards use. Run from anywhere:
python3 docs/brand/generate.py
"""
import atexit, os, shutil, subprocess, tempfile
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(REPO, "docs/brand")
PUB = os.path.join(REPO, "web/public")
TMP = tempfile.mkdtemp(prefix="ihasvpn-brand-")
atexit.register(shutil.rmtree, TMP, ignore_errors=True)
NAVY, TEAL, ORANGE, EAR = "#17404f", "#46cac3", "#f9a34b", "#ef7f2f"
SHIELD = "M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z"
HEAD = ("M31 64 C30 50 31 38 34 27 Q36 20 42 23 L55 32 Q64 29.5 73 32 L86 23 "
"Q92 20 94 27 C97 38 98 50 97 64 C96 83 82 92 64 92 C46 92 32 83 31 64 Z")
HEAD_T = "translate(64 62) scale(0.9) translate(-64 -60)"
EARS = "M38 30 L50 37.5 L40 45.5 Z M90 30 L78 37.5 L88 45.5 Z"
EYES = "M44 60 Q49.5 53 55 60 M73 60 Q78.5 53 84 60"
NOSE = "M60.8 65.5 h6.4 l-3.2 3.6 z"
MOUTH = "M56 71.5 Q60 76.5 64 71.5 Q68 76.5 72 71.5"
WHISKERS = "M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65"
LEDGE = "M6 88 Q64 81 122 88"
PAW_L = "M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z"
PAW_R = "M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z"
TOES = "M43 88.5 V93 M49 88.5 V93 M79 88.5 V93 M85 88.5 V93"
def mark_body(pfx):
"""The colour mark's drawing, ids prefixed so several can share a page."""
return f'''<defs><clipPath id="{pfx}-clip"><path d="{SHIELD}"/></clipPath></defs>
<path d="{SHIELD}" fill="{TEAL}"/>
<g clip-path="url(#{pfx}-clip)" stroke="{NAVY}" stroke-linecap="round" stroke-linejoin="round">
<path d="{WHISKERS}" stroke-width="3" fill="none"/>
<g transform="{HEAD_T}">
<path d="{HEAD}" fill="{ORANGE}" stroke-width="5"/>
<path d="{EARS}" fill="{EAR}" stroke="none"/>
<path d="{EYES}" stroke-width="4.2" fill="none"/>
<path d="{NOSE}" fill="{NAVY}" stroke-width="2"/>
<path d="{MOUTH}" stroke-width="3.5" fill="none"/>
</g>
<path d="{LEDGE} L122 130 L6 130 Z" fill="{TEAL}" stroke-width="4.5"/>
<path d="{PAW_L}" fill="{ORANGE}" stroke-width="4"/>
<path d="{PAW_R}" fill="{ORANGE}" stroke-width="4"/>
<path d="{TOES}" stroke-width="2.4" fill="none"/>
</g>
<path d="{SHIELD}" fill="none" stroke="{NAVY}" stroke-width="6" stroke-linejoin="round"/>'''
def svg(body, w=128, h=128, vb="0 0 128 128", label="ihasvpn", comment=""):
c = f"\n <!-- {comment} -->" if comment else ""
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{vb}" width="{w}" height="{h}" role="img" aria-label="{label}">{c}\n {body}\n</svg>\n'
MARK = svg(mark_body("ihasvpn"), comment="ihasvpn: the ihasmail cat peeking over the edge of a shield")
MONO = svg(f'''<defs>
<clipPath id="m-clip"><path d="{SHIELD}"/></clipPath>
<clipPath id="m-above"><path d="M0 0 H128 V88 Q64 81 0 88 Z"/></clipPath>
<mask id="m-paws"><rect width="128" height="128" fill="#fff"/><path d="{PAW_L} {PAW_R}" fill="#000" stroke="#000" stroke-width="4"/></mask>
</defs>
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="{SHIELD}" stroke-width="7"/>
<g clip-path="url(#m-clip)">
<g clip-path="url(#m-above)">
<path d="{WHISKERS}" stroke-width="3"/>
<g transform="{HEAD_T}">
<path d="{HEAD}" stroke-width="5"/>
<path d="{EYES}" stroke-width="4.2"/>
<path d="{MOUTH}" stroke-width="3.5"/>
</g>
</g>
<path d="{LEDGE}" stroke-width="4.5" mask="url(#m-paws)"/>
<path d="{PAW_L}" stroke-width="4"/>
<path d="{PAW_R}" stroke-width="4"/>
<path d="{TOES}" stroke-width="2.4"/>
</g>
</g>
<path transform="{HEAD_T}" d="{NOSE}" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>''',
comment="single-colour version: inherits currentColor, for print, status bars and anywhere one ink")
def write(path, text):
with open(path, "w") as f:
f.write(text)
def render(src, dst, w, h=None):
subprocess.run(["rsvg-convert", "-w", str(w), "-h", str(h or w), src, "-o", dst], check=True)
def magick(*args):
subprocess.run(["magick", *args], check=True)
write(f"{OUT}/ihasvpn-mark.svg", MARK)
write(f"{OUT}/ihasvpn-mark-mono.svg", MONO)
write(f"{PUB}/favicon.svg", MARK)
render(f"{OUT}/ihasvpn-mark.svg", f"{OUT}/ihasvpn-mark-256.png", 256)
render(f"{OUT}/ihasvpn-mark.svg", f"{OUT}/ihasvpn-mark-1024.png", 1024)
# Mono PNG in the brand navy, since currentColor has no value outside a page.
write(f"{TMP}/mono-navy.svg", MONO.replace("currentColor", NAVY))
render(f"{TMP}/mono-navy.svg", f"{OUT}/ihasvpn-mark-mono-256.png", 256)
# Wordmarks: light ground (navy text) and dark ground (pale text).
for name, fg in (("ihasvpn-wordmark", "#10303d"), ("ihasvpn-wordmark-dark", "#eaf6f6")):
body = f'''<g transform="translate(24 23) scale(2.36)">{mark_body("wm")}</g>
<text x="340" y="228" font-family="Fira Sans" font-weight="800" font-size="168" letter-spacing="-5" fill="{fg}">ihasvpn</text>'''
write(f"{TMP}/{name}.svg", svg(body, 936, 346, "0 0 936 346"))
render(f"{TMP}/{name}.svg", f"{OUT}/{name}.png", 936, 346)
# Social card, GitHub's 1280x640, in the ihasmail.org card's layout.
social = f'''<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#13394a"/><stop offset="1" stop-color="#0a1c26"/>
</linearGradient>
</defs>
<rect width="1280" height="640" fill="url(#bg)"/>
<g transform="translate(120 88) scale(3.05)">{mark_body("sc")}</g>
<text x="520" y="262" font-family="Fira Sans" font-weight="700" font-size="112" letter-spacing="-2" fill="#eaf6f6">ihasvpn</text>
<text x="524" y="334" font-family="Fira Sans" font-size="38" fill="#46cac3">Self-hosted WireGuard server</text>
<text x="524" y="382" font-family="Fira Sans" font-size="38" fill="#46cac3">with a secure web console</text>
<text x="524" y="446" font-family="Fira Sans" font-size="31" fill="#a3c3cb">One container · kernel data plane · 2FA</text>
<rect x="100" y="512" width="1080" height="2" fill="#21505f"/>
<text x="100" y="572" font-family="Fira Sans" font-size="29" fill="#eaf6f6">github.com/Coffey-Labs/ihasvpn</text>
<text x="1180" y="572" text-anchor="end" font-family="Fira Sans" font-size="29" fill="#f9a34b">AGPL-3.0 · Coffey Labs</text>'''
write(f"{TMP}/social.svg", svg(social, 1280, 640, "0 0 1280 640"))
render(f"{TMP}/social.svg", f"{OUT}/ihasvpn-social.png", 1280, 640)
# Favicons and app icons.
for s in (16, 32, 48, 192, 512):
render(f"{OUT}/ihasvpn-mark.svg", f"{TMP}/fav-{s}.png", s)
magick(f"{TMP}/fav-16.png", f"{TMP}/fav-32.png", f"{TMP}/fav-48.png", f"{PUB}/favicon.ico")
magick(f"{TMP}/fav-32.png", f"{PUB}/favicon-32.png")
magick(f"{TMP}/fav-192.png", f"{PUB}/icon-192.png")
magick(f"{TMP}/fav-512.png", f"{PUB}/icon-512.png")
# iOS paints transparency black, so the touch icon gets the navy ground.
render(f"{OUT}/ihasvpn-mark.svg", f"{TMP}/fav-144.png", 144)
magick("-size", "180x180", "xc:#0d2430", f"{TMP}/fav-144.png", "-gravity", "center", "-composite", f"{PUB}/apple-touch-icon.png")
print("brand set written")
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="ihasvpn">
<!-- single-colour version: inherits currentColor, for print, status bars and anywhere one ink -->
<defs>
<clipPath id="m-clip"><path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z"/></clipPath>
<clipPath id="m-above"><path d="M0 0 H128 V88 Q64 81 0 88 Z"/></clipPath>
<mask id="m-paws"><rect width="128" height="128" fill="#fff"/><path d="M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z" fill="#000" stroke="#000" stroke-width="4"/></mask>
</defs>
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" stroke-width="7"/>
<g clip-path="url(#m-clip)">
<g clip-path="url(#m-above)">
<path d="M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65" stroke-width="3"/>
<g transform="translate(64 62) scale(0.9) translate(-64 -60)">
<path d="M31 64 C30 50 31 38 34 27 Q36 20 42 23 L55 32 Q64 29.5 73 32 L86 23 Q92 20 94 27 C97 38 98 50 97 64 C96 83 82 92 64 92 C46 92 32 83 31 64 Z" stroke-width="5"/>
<path d="M44 60 Q49.5 53 55 60 M73 60 Q78.5 53 84 60" stroke-width="4.2"/>
<path d="M56 71.5 Q60 76.5 64 71.5 Q68 76.5 72 71.5" stroke-width="3.5"/>
</g>
</g>
<path d="M6 88 Q64 81 122 88" stroke-width="4.5" mask="url(#m-paws)"/>
<path d="M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z" stroke-width="4"/>
<path d="M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z" stroke-width="4"/>
<path d="M43 88.5 V93 M49 88.5 V93 M79 88.5 V93 M85 88.5 V93" stroke-width="2.4"/>
</g>
</g>
<path transform="translate(64 62) scale(0.9) translate(-64 -60)" d="M60.8 65.5 h6.4 l-3.2 3.6 z" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+20
View File
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="ihasvpn">
<!-- ihasvpn: the ihasmail cat peeking over the edge of a shield -->
<defs><clipPath id="ihasvpn-clip"><path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z"/></clipPath></defs>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="#46cac3"/>
<g clip-path="url(#ihasvpn-clip)" stroke="#17404f" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65" stroke-width="3" fill="none"/>
<g transform="translate(64 62) scale(0.9) translate(-64 -60)">
<path d="M31 64 C30 50 31 38 34 27 Q36 20 42 23 L55 32 Q64 29.5 73 32 L86 23 Q92 20 94 27 C97 38 98 50 97 64 C96 83 82 92 64 92 C46 92 32 83 31 64 Z" fill="#f9a34b" stroke-width="5"/>
<path d="M38 30 L50 37.5 L40 45.5 Z M90 30 L78 37.5 L88 45.5 Z" fill="#ef7f2f" stroke="none"/>
<path d="M44 60 Q49.5 53 55 60 M73 60 Q78.5 53 84 60" stroke-width="4.2" fill="none"/>
<path d="M60.8 65.5 h6.4 l-3.2 3.6 z" fill="#17404f" stroke-width="2"/>
<path d="M56 71.5 Q60 76.5 64 71.5 Q68 76.5 72 71.5" stroke-width="3.5" fill="none"/>
</g>
<path d="M6 88 Q64 81 122 88 L122 130 L6 130 Z" fill="#46cac3" stroke-width="4.5"/>
<path d="M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z" fill="#f9a34b" stroke-width="4"/>
<path d="M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z" fill="#f9a34b" stroke-width="4"/>
<path d="M43 88.5 V93 M49 88.5 V93 M79 88.5 V93 M85 88.5 V93" stroke-width="2.4" fill="none"/>
</g>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#17404f" stroke-width="6" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX">
<!-- single-colour version: inherits currentColor, for print, status bars and anywhere one ink -->
<g fill="none" stroke="currentColor" stroke-linejoin="round" stroke-linecap="round">
<path d="M64 8 L112 23.5 V60 C112 88.5 92 109.5 64 119.5 C36 109.5 16 88.5 16 60 V23.5 Z" stroke-width="7"/>
<path d="M48 64 V52 a16 16 0 0 1 32 0 V64" stroke-width="7.5"/>
</g>
<path fill-rule="evenodd" fill="currentColor" d="
M47 60 h34 a9 9 0 0 1 9 9 v20 a9 9 0 0 1 -9 9 h-34 a9 9 0 0 1 -9 -9 v-20 a9 9 0 0 1 9 -9 z
M52 70 l6 17.5 l6 -8.5 l6 8.5 l6 -17.5 l-4.6 -1.6 l-3.6 10.5 l-3.8 -5.4 l-3.8 5.4 l-3.6 -10.5 z"/>
</svg>

Before

Width:  |  Height:  |  Size: 749 B

-21
View File
@@ -1,21 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX">
<defs>
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#26332e"/>
<stop offset="1" stop-color="#121a17"/>
</linearGradient>
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#3fae90"/>
<stop offset="1" stop-color="#2b8f75"/>
</linearGradient>
</defs>
<!-- shield -->
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)"/>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" stroke-width="3" stroke-linejoin="round"/>
<!-- shackle -->
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" stroke-width="8.5" stroke-linecap="round"/>
<!-- lock body -->
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)"/>
<!-- the W as the keyhole -->
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

+13 -13
View File
@@ -1,6 +1,6 @@
# Performance # Performance
WGX is built so the packet path is as short as it can be. This page explains ihasvpn 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 what it does on its own, what only the host can do, and how to check the
result. result.
@@ -9,17 +9,17 @@ result.
In rough order of importance: In rough order of importance:
1. **Kernel data plane.** WireGuard in the kernel handles encryption in the 1. **Kernel data plane.** WireGuard in the kernel handles encryption in the
network stack with no copies to user space. WGX creates a native network stack with no copies to user space. ihasvpn creates a native
`wireguard` link over netlink and configures it over the same channel; `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 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 ihasvpn falls back to `wireguard-go`, which works everywhere but moves every
packet through a user-space process and is several times slower. The 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 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. 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 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 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 the server is smaller than that (PPPoE, a second tunnel, some mobile
networks) packets fragment or vanish and downloads crawl. WGX clamps the networks) packets fragment or vanish and downloads crawl. ihasvpn clamps the
TCP MSS of forwarded connections to the route MTU, which removes 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 "connected but pages hang" failure outright; lower the MTU in Settings if
UDP-heavy traffic still struggles. UDP-heavy traffic still struggles.
@@ -33,15 +33,15 @@ In rough order of importance:
peer; a single flow is bounded by one core. Machines with AVX2 or ARMv8 peer; a single flow is bounded by one core. Machines with AVX2 or ARMv8
crypto extensions do markedly better. crypto extensions do markedly better.
## What WGX sets by itself ## What ihasvpn sets by itself
At startup WGX writes these through `/proc/sys` and reports the outcome on At startup ihasvpn writes these through `/proc/sys` and reports the outcome on
the dashboard under **Show kernel tuning**: the dashboard under **Show kernel tuning**:
| sysctl | value | why | | sysctl | value | why |
| --- | --- | --- | | --- | --- | --- |
| `net.ipv4.ip_forward` | 1 | required; peers go nowhere without it | | `net.ipv4.ip_forward` | 1 | required; peers go nowhere without it |
| `net.ipv6.conf.all.forwarding` | 1 | required when `WGX_SUBNET6` is set | | `net.ipv6.conf.all.forwarding` | 1 | required when `IHASVPN_SUBNET6` is set |
| `net.ipv4.conf.all.rp_filter`, `...default.rp_filter` | 2 | strict reverse-path filtering drops legitimate tunnel replies | | `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_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.rmem_default`, `net.core.wmem_default` | 1048576 | default socket buffers |
@@ -51,13 +51,13 @@ the dashboard under **Show kernel tuning**:
The first three groups are network-namespaced and work inside the container The first three groups are network-namespaced and work inside the container
when the compose file passes them under `sysctls:` (forwarding) or the when the compose file passes them under `sysctls:` (forwarding) or the
container has NET_ADMIN (rp_filter). The `net.core.*` and `udp_*` ones are 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 **global**: the kernel refuses them from inside a container, ihasvpn logs a
warning, and they show as "not set" on the dashboard. That is expected; set warning, and they show as "not set" on the dashboard. That is expected; set
them on the host. them on the host.
## Host settings ## Host settings
Drop this into `/etc/sysctl.d/99-wgx.conf` on the Docker host and run Drop this into `/etc/sysctl.d/99-ihasvpn.conf` on the Docker host and run
`sysctl --system`: `sysctl --system`:
``` ```
@@ -76,7 +76,7 @@ net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr net.ipv4.tcp_congestion_control = bbr
# Forwarding, in case you run the host-network compose file and want to own # Forwarding, in case you run the host-network compose file and want to own
# it yourself (WGX sets it otherwise). # it yourself (ihasvpn sets it otherwise).
net.ipv4.ip_forward = 1 net.ipv4.ip_forward = 1
``` ```
@@ -92,10 +92,10 @@ Two more things on the host that are worth checking on a busy server:
## Host networking ## Host networking
`docker-compose.host.yml` runs WGX with `network_mode: host`. It removes the `docker-compose.host.yml` runs ihasvpn with `network_mode: host`. It removes the
Docker port mapping from the path and lets WGX set the host's own Docker port mapping from the path and lets ihasvpn set the host's own
forwarding sysctls. The costs are listed at the top of that file; in short, 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 `wg0` and the `ihasvpn` nftables table become visible on the host, and the admin
UI is bound to localhost so it is not exposed by accident. UI is bound to localhost so it is not exposed by accident.
## Measuring ## Measuring
Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 84 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/Coffey-Labs/WGX module github.com/Coffey-Labs/ihasvpn
go 1.27.1 go 1.27.1
+1 -1
View File
@@ -85,7 +85,7 @@ func VerifyPassword(hash, pw string) bool {
// dummyHash is verified against when the user does not exist, so a login // 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. // for an unknown name takes as long as one for a known name.
var dummyHash, _ = HashPassword("wgx-timing-equaliser-password") var dummyHash, _ = HashPassword("ihasvpn-timing-equaliser-password")
// EqualiseTiming burns the cost of one hash verification. // EqualiseTiming burns the cost of one hash verification.
func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") } func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") }
+35 -35
View File
@@ -104,52 +104,52 @@ func envDuration(key string, def time.Duration) (time.Duration, error) {
return d, nil return d, nil
} }
// FromEnv builds the configuration from WGX_* variables. // FromEnv builds the configuration from IHASVPN_* variables.
func FromEnv() (*Config, error) { func FromEnv() (*Config, error) {
var errs []error var errs []error
c := &Config{} c := &Config{}
c.DataDir = env("WGX_DATA_DIR", "/data") c.DataDir = env("IHASVPN_DATA_DIR", "/data")
c.DBPath = env("WGX_DB", c.DataDir+"/wgx.db") c.DBPath = env("IHASVPN_DB", c.DataDir+"/ihasvpn.db")
c.Backend = strings.ToLower(env("WGX_BACKEND", "auto")) c.Backend = strings.ToLower(env("IHASVPN_BACKEND", "auto"))
switch c.Backend { switch c.Backend {
case "auto", "kernel", "userspace", "mock": case "auto", "kernel", "userspace", "mock":
default: default:
errs = append(errs, fmt.Errorf("WGX_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend)) errs = append(errs, fmt.Errorf("IHASVPN_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend))
} }
c.Iface = env("WGX_INTERFACE", "wg0") c.Iface = env("IHASVPN_INTERFACE", "wg0")
if len(c.Iface) == 0 || len(c.Iface) > 15 || strings.ContainsAny(c.Iface, " /\t\n") { 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")) errs = append(errs, errors.New("IHASVPN_INTERFACE: must be 1-15 characters with no spaces or slashes"))
} }
var err error var err error
if c.ListenPort, err = envInt("WGX_PORT", 51820); err != nil { if c.ListenPort, err = envInt("IHASVPN_PORT", 51820); err != nil {
errs = append(errs, err) errs = append(errs, err)
} else if c.ListenPort < 1 || c.ListenPort > 65535 { } else if c.ListenPort < 1 || c.ListenPort > 65535 {
errs = append(errs, errors.New("WGX_PORT: must be 1-65535")) errs = append(errs, errors.New("IHASVPN_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() { if c.Subnet4, err = netip.ParsePrefix(env("IHASVPN_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")) errs = append(errs, errors.New("IHASVPN_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24"))
} else if c.Subnet4.Bits() > 30 { } else if c.Subnet4.Bits() > 30 {
errs = append(errs, errors.New("WGX_SUBNET: needs room for at least two hosts (/30 or larger)")) errs = append(errs, errors.New("IHASVPN_SUBNET: needs room for at least two hosts (/30 or larger)"))
} }
if v := env("WGX_SUBNET6", ""); v != "" { if v := env("IHASVPN_SUBNET6", ""); v != "" {
if c.Subnet6, err = netip.ParsePrefix(v); err != nil || !c.Subnet6.Addr().Is6() { 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")) errs = append(errs, errors.New("IHASVPN_SUBNET6: must be an IPv6 CIDR such as fd42:42:42::/64"))
} }
} }
c.Egress = env("WGX_EGRESS_INTERFACE", "") c.Egress = env("IHASVPN_EGRESS_INTERFACE", "")
c.HTTP = env("WGX_HTTP_LISTEN", ":51821") c.HTTP = env("IHASVPN_HTTP_LISTEN", ":51821")
c.TLSCert = env("WGX_TLS_CERT", "") c.TLSCert = env("IHASVPN_TLS_CERT", "")
c.TLSKey = env("WGX_TLS_KEY", "") c.TLSKey = env("IHASVPN_TLS_KEY", "")
if (c.TLSCert == "") != (c.TLSKey == "") { if (c.TLSCert == "") != (c.TLSKey == "") {
errs = append(errs, errors.New("WGX_TLS_CERT and WGX_TLS_KEY must be set together")) errs = append(errs, errors.New("IHASVPN_TLS_CERT and IHASVPN_TLS_KEY must be set together"))
} }
if c.TLSSelfSigned, err = envBool("WGX_TLS_SELF_SIGNED", false); err != nil { if c.TLSSelfSigned, err = envBool("IHASVPN_TLS_SELF_SIGNED", false); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if c.SecureCookies, err = envBool("WGX_SECURE_COOKIES", false); err != nil { if c.SecureCookies, err = envBool("IHASVPN_SECURE_COOKIES", false); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
for _, p := range strings.Split(env("WGX_TRUSTED_PROXIES", ""), ",") { for _, p := range strings.Split(env("IHASVPN_TRUSTED_PROXIES", ""), ",") {
p = strings.TrimSpace(p) p = strings.TrimSpace(p)
if p == "" { if p == "" {
continue continue
@@ -159,37 +159,37 @@ func FromEnv() (*Config, error) {
if a, err2 := netip.ParseAddr(p); err2 == nil { if a, err2 := netip.ParseAddr(p); err2 == nil {
pfx = netip.PrefixFrom(a, a.BitLen()) pfx = netip.PrefixFrom(a, a.BitLen())
} else { } else {
errs = append(errs, fmt.Errorf("WGX_TRUSTED_PROXIES: %q is not an address or CIDR", p)) errs = append(errs, fmt.Errorf("IHASVPN_TRUSTED_PROXIES: %q is not an address or CIDR", p))
continue continue
} }
} }
c.TrustedProxies = append(c.TrustedProxies, pfx) c.TrustedProxies = append(c.TrustedProxies, pfx)
} }
c.MetricsToken = env("WGX_METRICS_TOKEN", "") c.MetricsToken = env("IHASVPN_METRICS_TOKEN", "")
if c.SessionIdle, err = envDuration("WGX_SESSION_IDLE", 12*time.Hour); err != nil { if c.SessionIdle, err = envDuration("IHASVPN_SESSION_IDLE", 12*time.Hour); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if c.SessionMax, err = envDuration("WGX_SESSION_MAX", 7*24*time.Hour); err != nil { if c.SessionMax, err = envDuration("IHASVPN_SESSION_MAX", 7*24*time.Hour); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if c.TrafficRetention, err = envDuration("WGX_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil { if c.TrafficRetention, err = envDuration("IHASVPN_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if c.PollInterval, err = envDuration("WGX_POLL_INTERVAL", 2*time.Second); err != nil { if c.PollInterval, err = envDuration("IHASVPN_POLL_INTERVAL", 2*time.Second); err != nil {
errs = append(errs, err) errs = append(errs, err)
} else if c.PollInterval < 500*time.Millisecond { } else if c.PollInterval < 500*time.Millisecond {
errs = append(errs, errors.New("WGX_POLL_INTERVAL: must be at least 500ms")) errs = append(errs, errors.New("IHASVPN_POLL_INTERVAL: must be at least 500ms"))
} }
c.LogLevel = strings.ToLower(env("WGX_LOG_LEVEL", "info")) c.LogLevel = strings.ToLower(env("IHASVPN_LOG_LEVEL", "info"))
if c.LogJSON, err = envBool("WGX_LOG_JSON", false); err != nil { if c.LogJSON, err = envBool("IHASVPN_LOG_JSON", false); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
c.InitialEndpoint = env("WGX_ENDPOINT", "") c.InitialEndpoint = env("IHASVPN_ENDPOINT", "")
c.InitialDNS = env("WGX_DNS", "1.1.1.1, 1.0.0.1") c.InitialDNS = env("IHASVPN_DNS", "1.1.1.1, 1.0.0.1")
if c.ManageFirewall, err = envBool("WGX_MANAGE_FIREWALL", true); err != nil { if c.ManageFirewall, err = envBool("IHASVPN_MANAGE_FIREWALL", true); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if c.ManageSysctl, err = envBool("WGX_MANAGE_SYSCTL", true); err != nil { if c.ManageSysctl, err = envBool("IHASVPN_MANAGE_SYSCTL", true); err != nil {
errs = append(errs, err) errs = append(errs, err)
} }
if len(errs) > 0 { if len(errs) > 0 {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
) )
// Live is what the data plane currently says about one peer, merged with // Live is what the data plane currently says about one peer, merged with
+4 -4
View File
@@ -12,10 +12,10 @@ import (
"sync" "sync"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/config" "github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/WGX/internal/netcfg" "github.com/Coffey-Labs/ihasvpn/internal/netcfg"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg" "github.com/Coffey-Labs/ihasvpn/internal/wg"
) )
const ( const (
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"testing" "testing"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/config" "github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg" "github.com/Coffey-Labs/ihasvpn/internal/wg"
) )
func testConfig() *config.Config { func testConfig() *config.Config {
+4 -4
View File
@@ -13,9 +13,9 @@ import (
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"github.com/Coffey-Labs/WGX/internal/auth" "github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg" "github.com/Coffey-Labs/ihasvpn/internal/wg"
) )
// PeerInput is what the API accepts when creating or editing a peer. // PeerInput is what the API accepts when creating or editing a peer.
@@ -170,7 +170,7 @@ func (e *Engine) CreatePeer(ctx context.Context, in PeerInput) (*store.Peer, err
} }
p.IPv6 = a6.String() p.IPv6 = a6.String()
} else if in.IPv6 != "" { } else if in.IPv6 != "" {
return nil, invalid("IPv6 is not enabled on this server (set WGX_SUBNET6)") return nil, invalid("IPv6 is not enabled on this server (set IHASVPN_SUBNET6)")
} }
if err := applyEditable(p, in, settings); err != nil { if err := applyEditable(p, in, settings); err != nil {
return nil, err return nil, err
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/netip" "net/netip"
"strings" "strings"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
) )
// Settings are the administrator-editable server options. They persist in the // Settings are the administrator-editable server options. They persist in the
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import ( import (
"time" "time"
"github.com/Coffey-Labs/WGX/internal/netcfg" "github.com/Coffey-Labs/ihasvpn/internal/netcfg"
) )
// SysctlStatus is one sysctl as reported to the UI. // SysctlStatus is one sysctl as reported to the UI.
+5 -5
View File
@@ -12,7 +12,7 @@ import (
"strings" "strings"
) )
// Rules describes the firewall WGX wants. // Rules describes the firewall ihasvpn wants.
type Rules struct { type Rules struct {
// Iface is the WireGuard interface name, e.g. wg0. // Iface is the WireGuard interface name, e.g. wg0.
Iface string Iface string
@@ -31,7 +31,7 @@ type Rules struct {
// "the VPN connects but websites hang". // "the VPN connects but websites hang".
ClampMSS bool ClampMSS bool
// Table names the nftables table, so a host with its own rules never // Table names the nftables table, so a host with its own rules never
// collides with ours. Defaults to "wgx". // collides with ours. Defaults to "ihasvpn".
Table string Table string
} }
@@ -40,7 +40,7 @@ type Rules struct {
func Ruleset(r Rules) string { func Ruleset(r Rules) string {
table := r.Table table := r.Table
if table == "" { if table == "" {
table = "wgx" table = "ihasvpn"
} }
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "table inet %s\n", table) fmt.Fprintf(&b, "table inet %s\n", table)
@@ -88,10 +88,10 @@ func Apply(ctx context.Context, r Rules) error {
return runNFT(ctx, Ruleset(r)) return runNFT(ctx, Ruleset(r))
} }
// Remove deletes the WGX table, ignoring the case where it is already gone. // Remove deletes the ihasvpn table, ignoring the case where it is already gone.
func Remove(ctx context.Context, table string) error { func Remove(ctx context.Context, table string) error {
if table == "" { if table == "" {
table = "wgx" table = "ihasvpn"
} }
script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table) script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table)
return runNFT(ctx, script) return runNFT(ctx, script)
+1 -1
View File
@@ -17,7 +17,7 @@ func TestRuleset(t *testing.T) {
} }
out := Ruleset(r) out := Ruleset(r)
for _, want := range []string{ for _, want := range []string{
"table inet wgx {", "table inet ihasvpn {",
"udp dport 51820 accept", "udp dport 51820 accept",
`iifname "wg0" oifname "wg0" drop`, `iifname "wg0" oifname "wg0" drop`,
`tcp option maxseg size set rt mtu`, `tcp option maxseg size set rt mtu`,
+2 -2
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
) )
// Sysctl is one kernel parameter and the value WGX wants for it. // Sysctl is one kernel parameter and the value ihasvpn wants for it.
type Sysctl struct { type Sysctl struct {
Key string Key string
Value string Value string
@@ -27,7 +27,7 @@ type Result struct {
Err string Err string
} }
// Wanted returns the sysctls WGX applies at startup, in order. // Wanted returns the sysctls ihasvpn applies at startup, in order.
func Wanted(ipv6 bool) []Sysctl { func Wanted(ipv6 bool) []Sysctl {
s := []Sysctl{ s := []Sysctl{
{Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"}, {Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"},
+13 -13
View File
@@ -8,8 +8,8 @@ import (
"strings" "strings"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/engine" "github.com/Coffey-Labs/ihasvpn/internal/engine"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
) )
type healthBody struct { type healthBody struct {
@@ -192,7 +192,7 @@ func safeFilename(name string) string {
} }
out := b.String() out := b.String()
if out == "" { if out == "" {
out = "wgx" out = "ihasvpn"
} }
if len(out) > 15 { if len(out) > 15 {
// wg-quick derives the interface name from the file name and caps it // wg-quick derives the interface name from the file name and caps it
@@ -374,28 +374,28 @@ func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
} }
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
var b strings.Builder 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 ihasvpn_peers Number of configured peers.\n# TYPE ihasvpn_peers gauge\nihasvpn_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 ihasvpn_peers_connected Peers with a recent handshake.\n# TYPE ihasvpn_peers_connected gauge\nihasvpn_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") fmt.Fprintf(&b, "# HELP ihasvpn_receive_bytes_total Bytes received from peers.\n# TYPE ihasvpn_receive_bytes_total counter\n")
for id, l := range snap.Peers { 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, "ihasvpn_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") fmt.Fprintf(&b, "# HELP ihasvpn_transmit_bytes_total Bytes sent to peers.\n# TYPE ihasvpn_transmit_bytes_total counter\n")
for id, l := range snap.Peers { 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, "ihasvpn_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") fmt.Fprintf(&b, "# HELP ihasvpn_peer_connected Whether the peer has a recent handshake.\n# TYPE ihasvpn_peer_connected gauge\n")
for id, l := range snap.Peers { for id, l := range snap.Peers {
v := 0 v := 0
if l.Connected { if l.Connected {
v = 1 v = 1
} }
fmt.Fprintf(&b, "wgx_peer_connected{peer=%q,name=%q} %d\n", id, names[id], v) fmt.Fprintf(&b, "ihasvpn_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") fmt.Fprintf(&b, "# HELP ihasvpn_peer_last_handshake_seconds Unix time of the last handshake.\n# TYPE ihasvpn_peer_last_handshake_seconds gauge\n")
for id, l := range snap.Peers { for id, l := range snap.Peers {
if !l.LastHandshake.IsZero() { if !l.LastHandshake.IsZero() {
fmt.Fprintf(&b, "wgx_peer_last_handshake_seconds{peer=%q,name=%q} %d\n", id, names[id], l.LastHandshake.Unix()) fmt.Fprintf(&b, "ihasvpn_peer_last_handshake_seconds{peer=%q,name=%q} %d\n", id, names[id], l.LastHandshake.Unix())
} }
} }
_, _ = w.Write([]byte(b.String())) _, _ = w.Write([]byte(b.String()))
+5 -5
View File
@@ -10,11 +10,11 @@ import (
"github.com/skip2/go-qrcode" "github.com/skip2/go-qrcode"
"github.com/Coffey-Labs/WGX/internal/auth" "github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
) )
const cookieName = "wgx_session" const cookieName = "ihasvpn_session"
type ctxKey int type ctxKey int
@@ -450,7 +450,7 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("WGX", u.Username, secret)}) writeJSON(w, http.StatusOK, totpSetupResponse{Secret: secret, URI: auth.TOTPURI("ihasvpn", u.Username, secret)})
} }
// handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a // handleTOTPQR renders the pending secret's otpauth URI as a QR code. Only a
@@ -462,7 +462,7 @@ func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "no two-factor setup in progress") writeError(w, http.StatusNotFound, "no two-factor setup in progress")
return return
} }
png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256) png, err := qrcode.Encode(auth.TOTPURI("ihasvpn", u.Username, u.TOTPSecret), qrcode.Medium, 256)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
+4 -4
View File
@@ -17,10 +17,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/auth" "github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/WGX/internal/config" "github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/WGX/internal/engine" "github.com/Coffey-Labs/ihasvpn/internal/engine"
"github.com/Coffey-Labs/WGX/internal/server/static" "github.com/Coffey-Labs/ihasvpn/internal/server/static"
) )
// Server serves the API and UI. // Server serves the API and UI.
+6 -6
View File
@@ -14,11 +14,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/Coffey-Labs/WGX/internal/auth" "github.com/Coffey-Labs/ihasvpn/internal/auth"
"github.com/Coffey-Labs/WGX/internal/config" "github.com/Coffey-Labs/ihasvpn/internal/config"
"github.com/Coffey-Labs/WGX/internal/engine" "github.com/Coffey-Labs/ihasvpn/internal/engine"
"github.com/Coffey-Labs/WGX/internal/store" "github.com/Coffey-Labs/ihasvpn/internal/store"
"github.com/Coffey-Labs/WGX/internal/wg" "github.com/Coffey-Labs/ihasvpn/internal/wg"
) )
type client struct { type client struct {
@@ -178,7 +178,7 @@ func TestSetupLoginAndPeers(t *testing.T) {
req.Header.Set("Authorization", "Bearer metrics-secret") req.Header.Set("Authorization", "Bearer metrics-secret")
r, _ := anon.Do(req) r, _ := anon.Do(req)
b, _ := io.ReadAll(r.Body) b, _ := io.ReadAll(r.Body)
if r.StatusCode != 200 || !strings.Contains(string(b), "wgx_peers ") { if r.StatusCode != 200 || !strings.Contains(string(b), "ihasvpn_peers ") {
t.Fatalf("token metrics: %d %s", r.StatusCode, b) t.Fatalf("token metrics: %d %s", r.StatusCode, b)
} }
} }
+1 -1
View File
@@ -45,7 +45,7 @@ func (s *Server) loadCertificate() (tls.Certificate, error) {
host := s.eng.Settings().EndpointHost host := s.eng.Settings().EndpointHost
tmpl := &x509.Certificate{ tmpl := &x509.Certificate{
SerialNumber: serial, SerialNumber: serial,
Subject: pkix.Name{CommonName: "WGX", Organization: []string{"WGX"}}, Subject: pkix.Name{CommonName: "ihasvpn", Organization: []string{"ihasvpn"}},
NotBefore: time.Now().Add(-time.Hour), NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour), NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+1 -1
View File
@@ -1,4 +1,4 @@
// Package store is the SQLite persistence layer. Everything WGX remembers -- // Package store is the SQLite persistence layer. Everything ihasvpn remembers --
// peers and their keys, admin users, sessions, traffic history and the audit // peers and their keys, admin users, sessions, traffic history and the audit
// log -- lives in one file under the data directory. // log -- lives in one file under the data directory.
package store package store
+4 -4
View File
@@ -1,5 +1,5 @@
// Package wg abstracts the WireGuard data plane behind a small interface so the // 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 // rest of ihasvpn 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 // userspace wireguard-go process, or in an in-memory mock used by tests and
// UI development. // UI development.
package wg package wg
@@ -25,7 +25,7 @@ type PeerState struct {
PersistentKeepalive time.Duration PersistentKeepalive time.Duration
} }
// PeerConfig is what WGX wants a peer to look like on the interface. // PeerConfig is what ihasvpn wants a peer to look like on the interface.
type PeerConfig struct { type PeerConfig struct {
PublicKey Key PublicKey Key
PresharedKey *Key PresharedKey *Key
@@ -38,7 +38,7 @@ type DeviceConfig struct {
PrivateKey Key PrivateKey Key
ListenPort int ListenPort int
// FirewallMark is applied to every packet the interface sends; zero means // FirewallMark is applied to every packet the interface sends; zero means
// none. Left at zero by WGX, but exposed for completeness. // none. Left at zero by ihasvpn, but exposed for completeness.
FirewallMark int FirewallMark int
} }
@@ -50,7 +50,7 @@ type DeviceState struct {
Peers []PeerState Peers []PeerState
} }
// Backend is the data plane WGX drives. // Backend is the data plane ihasvpn drives.
type Backend interface { type Backend interface {
// Kind names the implementation: "kernel", "userspace" or "mock". // Kind names the implementation: "kernel", "userspace" or "mock".
Kind() string Kind() string
+2 -2
View File
@@ -23,7 +23,7 @@ import (
// linuxBackend drives a real WireGuard interface. In kernel mode the link is a // 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; // 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 // 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 // name and ihasvpn talks to it over its UAPI socket. Both are configured through
// wgctrl, which picks the transport on its own. // wgctrl, which picks the transport on its own.
type linuxBackend struct { type linuxBackend struct {
name string name string
@@ -38,7 +38,7 @@ type linuxBackend struct {
// trusting /sys/module, because a module that is loadable but not yet loaded // trusting /sys/module, because a module that is loadable but not yet loaded
// is only discovered by asking for it. // is only discovered by asking for it.
func KernelAvailable() bool { func KernelAvailable() bool {
const probe = "wgxprobe0" const probe = "ihasvpnprobe0"
link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}} link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}}
if err := netlink.LinkAdd(link); err != nil { if err := netlink.LinkAdd(link); err != nil {
return false return false
+1 -1
View File
@@ -11,7 +11,7 @@ import (
) )
// Mock is an in-memory data plane. It needs no privileges, so it is what the // 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. // tests use and what `IHASVPN_BACKEND=mock` gives a developer working on the UI.
// With Simulate on, peers randomly handshake, move traffic and go quiet so // With Simulate on, peers randomly handshake, move traffic and go quiet so
// the dashboard has something to show. // the dashboard has something to show.
type Mock struct { type Mock struct {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"log/slog" "log/slog"
) )
var errLinuxOnly = errors.New("real WireGuard interfaces are only supported on Linux; use WGX_BACKEND=mock for development") var errLinuxOnly = errors.New("real WireGuard interfaces are only supported on Linux; use IHASVPN_BACKEND=mock for development")
// KernelAvailable is always false off Linux. // KernelAvailable is always false off Linux.
func KernelAvailable() bool { return false } func KernelAvailable() bool { return false }
+2 -2
View File
@@ -9,8 +9,8 @@
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" /> <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#121a17" /> <meta name="theme-color" content="#0d2430" />
<title>WGX</title> <title>ihasvpn</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "wgx-web", "name": "ihasvpn-web",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wgx-web", "name": "ihasvpn-web",
"version": "0.0.0", "version": "0.0.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "wgx-web", "name": "ihasvpn-web",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+19 -20
View File
@@ -1,21 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="WGX"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128" role="img" aria-label="ihasvpn">
<defs> <!-- ihasvpn: the ihasmail cat peeking over the edge of a shield -->
<linearGradient id="wgx-shield" x1="0" y1="0" x2="0" y2="1"> <defs><clipPath id="ihasvpn-clip"><path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z"/></clipPath></defs>
<stop offset="0" stop-color="#26332e"/> <path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="#46cac3"/>
<stop offset="1" stop-color="#121a17"/> <g clip-path="url(#ihasvpn-clip)" stroke="#17404f" stroke-linecap="round" stroke-linejoin="round">
</linearGradient> <path d="M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65" stroke-width="3" fill="none"/>
<linearGradient id="wgx-lock" x1="0" y1="0" x2="0" y2="1"> <g transform="translate(64 62) scale(0.9) translate(-64 -60)">
<stop offset="0" stop-color="#3fae90"/> <path d="M31 64 C30 50 31 38 34 27 Q36 20 42 23 L55 32 Q64 29.5 73 32 L86 23 Q92 20 94 27 C97 38 98 50 97 64 C96 83 82 92 64 92 C46 92 32 83 31 64 Z" fill="#f9a34b" stroke-width="5"/>
<stop offset="1" stop-color="#2b8f75"/> <path d="M38 30 L50 37.5 L40 45.5 Z M90 30 L78 37.5 L88 45.5 Z" fill="#ef7f2f" stroke="none"/>
</linearGradient> <path d="M44 60 Q49.5 53 55 60 M73 60 Q78.5 53 84 60" stroke-width="4.2" fill="none"/>
</defs> <path d="M60.8 65.5 h6.4 l-3.2 3.6 z" fill="#17404f" stroke-width="2"/>
<!-- shield --> <path d="M56 71.5 Q60 76.5 64 71.5 Q68 76.5 72 71.5" stroke-width="3.5" fill="none"/>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="url(#wgx-shield)"/> </g>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" stroke-width="3" stroke-linejoin="round"/> <path d="M6 88 Q64 81 122 88 L122 130 L6 130 Z" fill="#46cac3" stroke-width="4.5"/>
<!-- shackle --> <path d="M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z" fill="#f9a34b" stroke-width="4"/>
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" stroke-width="8.5" stroke-linecap="round"/> <path d="M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z" fill="#f9a34b" stroke-width="4"/>
<!-- lock body --> <path d="M43 88.5 V93 M49 88.5 V93 M79 88.5 V93 M85 88.5 V93" stroke-width="2.4" fill="none"/>
<rect x="34" y="58" width="60" height="42" rx="10" fill="url(#wgx-lock)"/> </g>
<!-- the W as the keyhole --> <path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#17404f" stroke-width="6" stroke-linejoin="round"/>
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 32 KiB

+5 -5
View File
@@ -1,11 +1,11 @@
{ {
"name": "WGX", "name": "ihasvpn",
"short_name": "WGX", "short_name": "ihasvpn",
"description": "WireGuard server console", "description": "Self-hosted WireGuard server",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"background_color": "#111715", "background_color": "#0d2430",
"theme_color": "#121a17", "theme_color": "#0d2430",
"icons": [ "icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
+1 -1
View File
@@ -1,4 +1,4 @@
// Thin client for the WGX API. Every call goes through `request`, which // Thin client for the ihasvpn API. Every call goes through `request`, which
// turns non-2xx answers into ApiError so pages can show the server's message. // turns non-2xx answers into ApiError so pages can show the server's message.
export class ApiError extends Error { export class ApiError extends Error {
+5 -5
View File
@@ -46,9 +46,9 @@ export function Layout({ children }: { children: ReactNode }) {
<div className="shell"> <div className="shell">
{/* Phones and narrow windows: brand and account controls up top. */} {/* Phones and narrow windows: brand and account controls up top. */}
<header className="topbar"> <header className="topbar">
<Link href="/" className="brand" aria-label="WGX dashboard"> <Link href="/" className="brand" aria-label="ihasvpn dashboard">
<Mark size={30} /> <Mark size={30} />
<span className="brand-name">WGX</span> <span className="brand-name">ihasvpn</span>
</Link> </Link>
<div className="topbar-right"> <div className="topbar-right">
<LivePill /> <LivePill />
@@ -59,11 +59,11 @@ export function Layout({ children }: { children: ReactNode }) {
{/* Desktop: everything lives in the sidebar. */} {/* Desktop: everything lives in the sidebar. */}
<aside className="sidebar"> <aside className="sidebar">
<Link href="/" className="brand" aria-label="WGX dashboard"> <Link href="/" className="brand" aria-label="ihasvpn dashboard">
<Mark size={34} /> <Mark size={34} />
<span> <span>
<span className="brand-name">WGX</span> <span className="brand-name">ihasvpn</span>
<span className="brand-sub">WireGuard eXtended</span> <span className="brand-sub">Self-hosted WireGuard</span>
</span> </span>
</Link> </Link>
<nav className="nav" aria-label="Main"> <nav className="nav" aria-label="Main">
+1 -1
View File
@@ -13,7 +13,7 @@ export function Legal({ center = false }: { center?: boolean }) {
<span className="legal-sep" aria-hidden="true"> <span className="legal-sep" aria-hidden="true">
· ·
</span> </span>
<a href="https://github.com/Coffey-Labs/WGX" target="_blank" rel="noreferrer"> <a href="https://github.com/Coffey-Labs/ihasvpn" target="_blank" rel="noreferrer">
AGPL-3.0 source AGPL-3.0 source
</a> </a>
</p> </p>
+32 -23
View File
@@ -1,32 +1,41 @@
import { useId } from "react"; import { useId } from "react";
// The WGX mark: a padlock on a shield, with the W as its keyhole. The same // The ihasvpn mark: the ihasmail cat peeking over the edge of a shield. The
// drawing as docs/brand/wgx-mark.svg, inlined so it needs no request and // same drawing as docs/brand/ihasvpn-mark.svg (docs/brand/generate.py writes
// takes its size from wherever it is placed. Gradient ids are per instance: // that one), inlined so it needs no request and takes its size from wherever
// the mark appears more than once on a page (sidebar, top bar), and a shared // it is placed. The clip-path id is per instance: the mark appears more than
// id would resolve to whichever copy comes first, which may be hidden and // once on a page (sidebar, top bar), and a shared id resolves to whichever
// then paints nothing. // copy comes first, which may be hidden and then clips everything away.
const NAVY = "#17404f";
const TEAL = "#46cac3";
const ORANGE = "#f9a34b";
const SHIELD = "M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z";
export function Mark({ size = 32 }: { size?: number }) { export function Mark({ size = 32 }: { size?: number }) {
const id = useId(); const clip = `${useId()}-clip`;
const shield = `${id}-shield`;
const lock = `${id}-lock`;
return ( return (
<svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="WGX"> <svg width={size} height={size} viewBox="0 0 128 128" role="img" aria-label="ihasvpn">
<defs> <defs>
<linearGradient id={shield} x1="0" y1="0" x2="0" y2="1"> <clipPath id={clip}>
<stop offset="0" stopColor="#26332e" /> <path d={SHIELD} />
<stop offset="1" stopColor="#121a17" /> </clipPath>
</linearGradient>
<linearGradient id={lock} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#3fae90" />
<stop offset="1" stopColor="#2b8f75" />
</linearGradient>
</defs> </defs>
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill={`url(#${shield})`} /> <path d={SHIELD} fill={TEAL} />
<path d="M64 6 L114 22 V60 C114 90 93 112 64 122 C35 112 14 90 14 60 V22 Z" fill="none" stroke="#3fae90" strokeWidth="3" strokeLinejoin="round" /> <g clipPath={`url(#${clip})`} stroke={NAVY} strokeLinecap="round" strokeLinejoin="round">
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" strokeWidth="8.5" strokeLinecap="round" /> <path d="M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65" strokeWidth="3" fill="none" />
<rect x="34" y="58" width="60" height="42" rx="10" fill={`url(#${lock})`} /> <g transform="translate(64 62) scale(0.9) translate(-64 -60)">
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" /> <path d="M31 64 C30 50 31 38 34 27 Q36 20 42 23 L55 32 Q64 29.5 73 32 L86 23 Q92 20 94 27 C97 38 98 50 97 64 C96 83 82 92 64 92 C46 92 32 83 31 64 Z" fill={ORANGE} strokeWidth="5" />
<path d="M38 30 L50 37.5 L40 45.5 Z M90 30 L78 37.5 L88 45.5 Z" fill="#ef7f2f" stroke="none" />
<path d="M44 60 Q49.5 53 55 60 M73 60 Q78.5 53 84 60" strokeWidth="4.2" fill="none" />
<path d="M60.8 65.5 h6.4 l-3.2 3.6 z" fill={NAVY} strokeWidth="2" />
<path d="M56 71.5 Q60 76.5 64 71.5 Q68 76.5 72 71.5" strokeWidth="3.5" fill="none" />
</g>
<path d="M6 88 Q64 81 122 88 L122 130 L6 130 Z" fill={TEAL} strokeWidth="4.5" />
<path d="M36.5 90 C36 81 40 76.5 46 76.5 C52 76.5 56 81 55.5 90 C55.5 94 51 96 46 96 C41 96 36.5 94 36.5 90 Z" fill={ORANGE} strokeWidth="4" />
<path d="M91.5 90 C92 81 88 76.5 82 76.5 C76 76.5 72 81 72.5 90 C72.5 94 77 96 82 96 C87 96 91.5 94 91.5 90 Z" fill={ORANGE} strokeWidth="4" />
<path d="M43 88.5 V93 M49 88.5 V93 M79 88.5 V93 M85 88.5 V93" strokeWidth="2.4" fill="none" />
</g>
<path d={SHIELD} fill="none" stroke={NAVY} strokeWidth="6" strokeLinejoin="round" />
</svg> </svg>
); );
} }
+1 -1
View File
@@ -44,7 +44,7 @@ export function Login() {
<div className="brand-mark"> <div className="brand-mark">
<Mark size={44} /> <Mark size={44} />
</div> </div>
<div className="brand-name">WGX</div> <div className="brand-name">ihasvpn</div>
</div> </div>
<h1>{stage === "password" ? "Sign in" : "Second factor"}</h1> <h1>{stage === "password" ? "Sign in" : "Second factor"}</h1>
{error && <div className="error">{error}</div>} {error && <div className="error">{error}</div>}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Setup() {
<div className="brand-mark"> <div className="brand-mark">
<Mark size={44} /> <Mark size={44} />
</div> </div>
<div className="brand-name">WGX</div> <div className="brand-name">ihasvpn</div>
</div> </div>
<h1>Welcome</h1> <h1>Welcome</h1>
<p className="muted" style={{ textAlign: "center", marginBottom: 16 }}> <p className="muted" style={{ textAlign: "center", marginBottom: 16 }}>
+55 -46
View File
@@ -1,56 +1,65 @@
/* Dark is the default. theme.ts resolves the user's choice (dark, light or /* Dark is the default. theme.ts resolves the user's choice (dark, light or
follow the system) onto <html data-theme>, so the stylesheet only needs the follow the system) onto <html data-theme>, so the stylesheet only needs the
one override below. */ one override below.
The palette is ihasmail's: the teal-navy of ihasmail.org, warmed by the
orange the cat is drawn in. Dark takes the ihasmail app's "ihasmail" theme
as it is. Light takes that theme's contrast-checked tiers, with the accent
from ihasmail.org's light scheme, since the app's lighter teal is under
4.5:1 behind the white text on a primary button. */
:root { :root {
--bg: #111715; --bg: #0d2430;
--bg-elev: #19211e; --bg-elev: #12303e;
--bg-sunken: #0c100f; --bg-sunken: #0a1c26;
--fg: #e6ece9; --fg: #eaf6f6;
--fg-muted: #9aa8a2; --fg-muted: #a3c3cb;
--fg-faint: #6a7772; --fg-faint: #86aab4;
--line: #26312d; --line: #21505f;
--line-strong: #3a4742; --line-strong: #2e6a7a;
--accent: #3fae90; --accent: #46cac3;
--accent-fg: #06110d; --accent-fg: #062028;
--accent-soft: #163a31; --accent-soft: rgba(70, 202, 195, 0.16);
--ok: #3fc275; --ok: #4ade80;
--ok-soft: #12321f; --ok-soft: rgba(74, 222, 128, 0.15);
--warn: #e0a84a; --warn: #f9a34b;
--warn-soft: #3a2c10; --warn-soft: rgba(249, 163, 75, 0.14);
--bad: #ef6b62; --bad: #f87171;
--bad-soft: #3d1815; --bad-soft: rgba(248, 113, 113, 0.15);
--info: #5f9de0; --info: #6fdcd6;
--rx: #5f9de0; /* Traffic: received in the cat's orange, sent in the shield's teal. */
--tx: #3fae90; --rx: #f9a34b;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); --tx: #46cac3;
--backdrop: rgba(6, 20, 27, 0.6);
--shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.4);
--radius: 12px; --radius: 12px;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; --sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
color-scheme: dark; color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"] {
--bg: #f4f6f5; --bg: #f4f9f9;
--bg-elev: #ffffff; --bg-elev: #ffffff;
--bg-sunken: #e9edeb; --bg-sunken: #e7f1f2;
--fg: #16201c; --fg: #0d2430;
--fg-muted: #5c6b64; --fg-muted: #4a6b74;
--fg-faint: #8b9791; --fg-faint: #62757a;
--line: #d8dfdb; --line: #cfe2e4;
--line-strong: #b9c4be; --line-strong: #849295;
--accent: #1f6f5c; --accent: #0b6b67;
--accent-fg: #ffffff; --accent-fg: #ffffff;
--accent-soft: #dcefe8; --accent-soft: #daeceb;
--ok: #1d8f4e; --ok: #15803d;
--ok-soft: #dcf3e4; --ok-soft: #dcfce7;
--warn: #b7791f; --warn: #b45309;
--warn-soft: #fbeed3; --warn-soft: #fef3c7;
--bad: #c2382f; --bad: #dc2626;
--bad-soft: #f9dedb; --bad-soft: #fee2e2;
--info: #2a6fb0; --info: #0e7490;
--rx: #2a6fb0; --rx: #c5813b;
--tx: #1f6f5c; --tx: #379e98;
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06); --backdrop: rgba(13, 36, 48, 0.45);
--shadow: 0 1px 2px rgba(13, 36, 48, 0.06), 0 8px 24px rgba(13, 36, 48, 0.08);
color-scheme: light; color-scheme: light;
} }
@@ -102,7 +111,7 @@ button, input, select, textarea { font: inherit; color: inherit; }
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px; color: var(--fg); line-height: 1.15; } .brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px; color: var(--fg); line-height: 1.15; }
.brand:hover { text-decoration: none; } .brand:hover { text-decoration: none; }
.brand svg { flex: none; display: block; } .brand svg { flex: none; display: block; }
.brand-name { display: block; font-weight: 700; font-size: 16px; letter-spacing: 0.02em; } .brand-name { display: block; font-weight: 800; font-size: 18px; letter-spacing: -0.03em; }
.brand-sub { display: block; font-size: 11px; color: var(--fg-muted); margin-top: 2px; } .brand-sub { display: block; font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.nav { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; } .nav { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; }
.nav-group { display: flex; flex-direction: column; gap: 2px; } .nav-group { display: flex; flex-direction: column; gap: 2px; }
@@ -363,7 +372,7 @@ textarea.input { min-height: 72px; resize: vertical; }
.auth h1 { text-align: center; margin: 4px 0 16px; } .auth h1 { text-align: center; margin: 4px 0 16px; }
/* Modal */ /* 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-back { position: fixed; inset: 0; background: var(--backdrop); 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 { width: 100%; max-width: 640px; max-height: calc(100vh - 40px); overflow: auto; }
.modal.wide { max-width: 860px; } .modal.wide { max-width: 860px; }
.modal .card-head h2 { font-size: 16px; } .modal .card-head h2 { font-size: 16px; }
@@ -383,7 +392,7 @@ textarea.input { min-height: 72px; resize: vertical; }
/* Peer detail */ /* Peer detail */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; } .kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
.kv dt { color: var(--fg-muted); } .kv dt { color: var(--fg-muted); }
.kv dd { margin: 0; word-break: break-all; } .kv dd { margin: 0; overflow-wrap: anywhere; }
.qr { display: grid; grid-template-columns: 1fr; gap: 16px; } .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; } .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; } 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; }
+2 -2
View File
@@ -4,7 +4,7 @@
export type ThemeChoice = "dark" | "light" | "system"; export type ThemeChoice = "dark" | "light" | "system";
const KEY = "wgx.theme"; const KEY = "ihasvpn.theme";
const media = window.matchMedia("(prefers-color-scheme: light)"); const media = window.matchMedia("(prefers-color-scheme: light)");
const listeners = new Set<(c: ThemeChoice) => void>(); const listeners = new Set<(c: ThemeChoice) => void>();
@@ -28,7 +28,7 @@ export function applyTheme(choice: ThemeChoice) {
document.documentElement.dataset.theme = resolved; document.documentElement.dataset.theme = resolved;
// Keeps the browser chrome (address bar on phones) in step with the page. // Keeps the browser chrome (address bar on phones) in step with the page.
const meta = document.querySelector('meta[name="theme-color"]'); const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", resolved === "light" ? "#f4f6f5" : "#121a17"); if (meta) meta.setAttribute("content", resolved === "light" ? "#f4f9f9" : "#0d2430");
} }
export function setThemeChoice(choice: ThemeChoice) { export function setThemeChoice(choice: ThemeChoice) {