Merge pull request #5 from Coffey-Labs/rename/ihasvpn
Rename the project to ihasvpn
@@ -30,4 +30,4 @@ jobs:
|
||||
- name: govulncheck
|
||||
run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
|
||||
- name: Docker build
|
||||
run: docker build -t wgx:ci .
|
||||
run: docker build -t ihasvpn:ci .
|
||||
|
||||
@@ -25,7 +25,7 @@ on:
|
||||
default: false
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/coffey-labs/wgx
|
||||
IMAGE: ghcr.io/coffey-labs/ihasvpn
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: WGX_VERSION=${{ needs.version.outputs.version }}
|
||||
build-args: IHASVPN_VERSION=${{ needs.version.outputs.version }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build output
|
||||
/wgx
|
||||
/ihasvpn
|
||||
/dist/
|
||||
web/node_modules/
|
||||
web/dist/
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# Contributing to WGX
|
||||
# Contributing to ihasvpn
|
||||
|
||||
Thanks for your interest. Bug reports, feature requests, code and
|
||||
documentation are all welcome.
|
||||
|
||||
## Before you start
|
||||
|
||||
- WGX is one container: the WireGuard server and the UI that manages it.
|
||||
- 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
|
||||
separate frontend host) are out of scope.
|
||||
- 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
|
||||
|
||||
# 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
|
||||
@@ -30,9 +30,9 @@ something to show. For the real thing:
|
||||
|
||||
```sh
|
||||
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 \
|
||||
-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
|
||||
@@ -42,7 +42,7 @@ CI checks are not a substitute for building locally. Run, in this order:
|
||||
```sh
|
||||
cd web && npm run build && cd .. # type-checks and builds the UI
|
||||
go vet ./... && go test -count=1 ./...
|
||||
docker build -t wgx:dev . # when the change reaches the image
|
||||
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
|
||||
|
||||
@@ -10,14 +10,14 @@ RUN npm run build
|
||||
FROM golang:1.27-alpine AS build
|
||||
# The version string the binary reports. Worked out by whoever runs the
|
||||
# build (CI passes the tag); left empty it says "dev".
|
||||
ARG WGX_VERSION=dev
|
||||
ARG IHASVPN_VERSION=dev
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY --from=web /src/internal/server/static/dist internal/server/static/dist
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X github.com/Coffey-Labs/WGX/internal/engine.Version=${WGX_VERSION}" -o /wgx ./cmd/wgx
|
||||
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 ----
|
||||
FROM alpine:3.22
|
||||
@@ -25,11 +25,11 @@ FROM alpine:3.22
|
||||
# without the kernel module; wireguard-tools gives `wg show` for debugging.
|
||||
RUN apk add --no-cache nftables wireguard-go wireguard-tools ca-certificates tzdata \
|
||||
&& mkdir -p /data
|
||||
COPY --from=build /wgx /usr/local/bin/wgx
|
||||
ENV WGX_DATA_DIR=/data \
|
||||
WGX_HTTP_LISTEN=:51821
|
||||
COPY --from=build /ihasvpn /usr/local/bin/ihasvpn
|
||||
ENV IHASVPN_DATA_DIR=/data \
|
||||
IHASVPN_HTTP_LISTEN=:51821
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 51820/udp 51821/tcp
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD wget -qO- http://127.0.0.1:51821/api/health || exit 1
|
||||
ENTRYPOINT ["wgx"]
|
||||
ENTRYPOINT ["ihasvpn"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
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.
|
||||
@@ -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,
|
||||
wireguard-tools and wireguard-go from its package repositories, each under
|
||||
its own licence (GPL-2.0 for nftables and wireguard-tools, MIT for
|
||||
wireguard-go). They are separate programs invoked by WGX, not linked into it.
|
||||
wireguard-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.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<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>
|
||||
|
||||
# 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
|
||||
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.
|
||||
@@ -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
|
||||
pair (and a preshared key) and shows a QR code and a `.conf` download; or
|
||||
the client brings its own public key and the private key never leaves the
|
||||
device. Pin a tunnel address or let WGX allocate one. Set an expiry and the
|
||||
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.
|
||||
- **Who is connected.** Live status from the interface counters every two
|
||||
seconds: endpoint, last handshake, session length, current rate, total
|
||||
@@ -68,8 +68,8 @@ apply without a restart.
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
curl -O https://raw.githubusercontent.com/Coffey-Labs/WGX/main/docker-compose.yml
|
||||
# edit WGX_ENDPOINT (your public hostname or IP), then:
|
||||
curl -O https://raw.githubusercontent.com/Coffey-Labs/ihasvpn/main/docker-compose.yml
|
||||
# edit IHASVPN_ENDPOINT (your public hostname or IP), then:
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `WGX_ENDPOINT` | | Public hostname or IP for client configs. Also asked for at first-run setup. |
|
||||
| `WGX_PORT` | `51820` | UDP listen port. |
|
||||
| `WGX_SUBNET` | `10.8.0.0/24` | IPv4 tunnel network; the server takes the first address. |
|
||||
| `WGX_SUBNET6` | | IPv6 tunnel network, e.g. `fd42:42:42::/64`. Off when empty. |
|
||||
| `WGX_DNS` | `1.1.1.1, 1.0.0.1` | Resolvers handed to clients on first run. |
|
||||
| `WGX_INTERFACE` | `wg0` | Interface name. |
|
||||
| `WGX_EGRESS_INTERFACE` | auto | Interface to masquerade on. Auto uses the default route. |
|
||||
| `WGX_HTTP_LISTEN` | `:51821` | Console listen address. |
|
||||
| `WGX_TLS_SELF_SIGNED` | `false` | Serve HTTPS with a certificate generated into `/data`. |
|
||||
| `WGX_TLS_CERT`, `WGX_TLS_KEY` | | Serve HTTPS with your own certificate. |
|
||||
| `WGX_SECURE_COOKIES` | `false` | Mark cookies `Secure` when TLS terminates at a proxy. |
|
||||
| `WGX_TRUSTED_PROXIES` | | CIDRs whose `X-Forwarded-For` is believed. |
|
||||
| `WGX_METRICS_TOKEN` | | Bearer token for `/metrics`. A signed-in session works too. |
|
||||
| `WGX_SESSION_IDLE` | `12h` | Sign out after this much inactivity. |
|
||||
| `WGX_SESSION_MAX` | `168h` | Sign out after this long regardless. |
|
||||
| `WGX_TRAFFIC_RETENTION` | `2160h` | How long usage history is kept (90 days). |
|
||||
| `WGX_POLL_INTERVAL` | `2s` | How often the interface counters are read. |
|
||||
| `WGX_BACKEND` | `auto` | `kernel`, `userspace` or `mock`. Auto prefers the kernel. |
|
||||
| `WGX_MANAGE_FIREWALL` | `true` | Set to `false` if the host owns the NAT rules. |
|
||||
| `WGX_MANAGE_SYSCTL` | `true` | Set to `false` if the host has tuned itself. |
|
||||
| `WGX_DATA_DIR` | `/data` | Where the database and TLS files live. |
|
||||
| `WGX_LOG_LEVEL`, `WGX_LOG_JSON` | `info`, `false` | Logging. |
|
||||
| `IHASVPN_ENDPOINT` | | Public hostname or IP for client configs. Also asked for at first-run setup. |
|
||||
| `IHASVPN_PORT` | `51820` | UDP listen port. |
|
||||
| `IHASVPN_SUBNET` | `10.8.0.0/24` | IPv4 tunnel network; the server takes the first address. |
|
||||
| `IHASVPN_SUBNET6` | | IPv6 tunnel network, e.g. `fd42:42:42::/64`. Off when empty. |
|
||||
| `IHASVPN_DNS` | `1.1.1.1, 1.0.0.1` | Resolvers handed to clients on first run. |
|
||||
| `IHASVPN_INTERFACE` | `wg0` | Interface name. |
|
||||
| `IHASVPN_EGRESS_INTERFACE` | auto | Interface to masquerade on. Auto uses the default route. |
|
||||
| `IHASVPN_HTTP_LISTEN` | `:51821` | Console listen address. |
|
||||
| `IHASVPN_TLS_SELF_SIGNED` | `false` | Serve HTTPS with a certificate generated into `/data`. |
|
||||
| `IHASVPN_TLS_CERT`, `IHASVPN_TLS_KEY` | | Serve HTTPS with your own certificate. |
|
||||
| `IHASVPN_SECURE_COOKIES` | `false` | Mark cookies `Secure` when TLS terminates at a proxy. |
|
||||
| `IHASVPN_TRUSTED_PROXIES` | | CIDRs whose `X-Forwarded-For` is believed. |
|
||||
| `IHASVPN_METRICS_TOKEN` | | Bearer token for `/metrics`. A signed-in session works too. |
|
||||
| `IHASVPN_SESSION_IDLE` | `12h` | Sign out after this much inactivity. |
|
||||
| `IHASVPN_SESSION_MAX` | `168h` | Sign out after this long regardless. |
|
||||
| `IHASVPN_TRAFFIC_RETENTION` | `2160h` | How long usage history is kept (90 days). |
|
||||
| `IHASVPN_POLL_INTERVAL` | `2s` | How often the interface counters are read. |
|
||||
| `IHASVPN_BACKEND` | `auto` | `kernel`, `userspace` or `mock`. Auto prefers the kernel. |
|
||||
| `IHASVPN_MANAGE_FIREWALL` | `true` | Set to `false` if the host owns the NAT rules. |
|
||||
| `IHASVPN_MANAGE_SYSCTL` | `true` | Set to `false` if the host has tuned itself. |
|
||||
| `IHASVPN_DATA_DIR` | `/data` | Where the database and TLS files live. |
|
||||
| `IHASVPN_LOG_LEVEL`, `IHASVPN_LOG_JSON` | `info`, `false` | Logging. |
|
||||
|
||||
## Locked out?
|
||||
|
||||
```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
|
||||
@@ -158,25 +158,13 @@ call it from the same origin or from a non-browser client.
|
||||
|
||||
```sh
|
||||
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
|
||||
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
|
||||
|
||||
AGPL-3.0-or-later. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -12,7 +12,7 @@ patched.
|
||||
you think the impact is. You will get an acknowledgement within a few days
|
||||
and a fix or a plan before anything is made public.
|
||||
|
||||
## What WGX does to protect itself
|
||||
## What ihasvpn does to protect itself
|
||||
|
||||
- The admin UI requires a password (argon2id, 64 MiB, 3 passes) and offers
|
||||
time-based one-time codes with recovery codes. Sessions are random 256-bit
|
||||
@@ -36,8 +36,8 @@ and a fix or a plan before anything is made public.
|
||||
## What you must do
|
||||
|
||||
- Do not expose port 51821 to the internet without TLS. Either set
|
||||
`WGX_TLS_SELF_SIGNED=true` (or `WGX_TLS_CERT`/`WGX_TLS_KEY`) or put a
|
||||
`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
|
||||
`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.
|
||||
- Keep the `/data` volume private: it holds every peer's private key.
|
||||
|
||||
@@ -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)
|
||||
// wgx reset-password U set a new password for admin user U and drop
|
||||
// ihasvpn run the server (the container's default)
|
||||
// ihasvpn reset-password U set a new password for admin user U and drop
|
||||
// their sessions and second factor; for lockouts
|
||||
// wgx version print the version
|
||||
// ihasvpn version print the version
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -19,19 +19,19 @@ import (
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/server"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/engine"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/server"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("wgx", engine.Version)
|
||||
fmt.Println("ihasvpn", engine.Version)
|
||||
return
|
||||
case "reset-password":
|
||||
if err := resetPassword(os.Args[2:]); err != nil {
|
||||
@@ -49,14 +49,14 @@ func main() {
|
||||
}
|
||||
}
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "wgx:", err)
|
||||
fmt.Fprintln(os.Stderr, "ihasvpn:", err)
|
||||
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 {
|
||||
@@ -101,7 +101,7 @@ func run() error {
|
||||
return err
|
||||
}
|
||||
log := newLogger(cfg)
|
||||
log.Info("starting wgx", "version", engine.Version)
|
||||
log.Info("starting ihasvpn", "version", engine.Version)
|
||||
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
@@ -146,7 +146,7 @@ func run() error {
|
||||
// It runs inside the container against the same database.
|
||||
func resetPassword(args []string) error {
|
||||
if len(args) != 1 {
|
||||
return errors.New("usage: wgx reset-password <username>")
|
||||
return errors.New("usage: ihasvpn reset-password <username>")
|
||||
}
|
||||
cfg, err := config.FromEnv()
|
||||
if err != nil {
|
||||
@@ -163,7 +163,7 @@ func resetPassword(args []string) error {
|
||||
return fmt.Errorf("no user named %q", args[0])
|
||||
}
|
||||
var pw string
|
||||
if v := os.Getenv("WGX_NEW_PASSWORD"); v != "" {
|
||||
if v := os.Getenv("IHASVPN_NEW_PASSWORD"); v != "" {
|
||||
pw = v
|
||||
} else {
|
||||
fmt.Fprint(os.Stderr, "New password: ")
|
||||
@@ -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
|
||||
# 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
|
||||
# host's namespace (you will see it in `ip link` and it is removed on
|
||||
# shutdown), the NAT rules land in the host's nftables as a table named
|
||||
# `wgx`, and the admin UI listens on the host directly -- so it is bound to
|
||||
# `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
|
||||
# WGX_TLS_SELF_SIGNED to reach it from elsewhere.
|
||||
# IHASVPN_TLS_SELF_SIGNED to reach it from elsewhere.
|
||||
services:
|
||||
wgx:
|
||||
image: ghcr.io/coffey-labs/wgx:latest
|
||||
container_name: wgx
|
||||
ihasvpn:
|
||||
image: ghcr.io/coffey-labs/ihasvpn:latest
|
||||
container_name: ihasvpn
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
WGX_ENDPOINT: vpn.example.com
|
||||
WGX_PORT: "51820"
|
||||
WGX_SUBNET: 10.8.0.0/24
|
||||
WGX_DNS: 1.1.1.1, 1.0.0.1
|
||||
WGX_HTTP_LISTEN: "127.0.0.1:51821"
|
||||
# In host mode the forwarding sysctls are the host's own; WGX sets
|
||||
IHASVPN_ENDPOINT: vpn.example.com
|
||||
IHASVPN_PORT: "51820"
|
||||
IHASVPN_SUBNET: 10.8.0.0/24
|
||||
IHASVPN_DNS: 1.1.1.1, 1.0.0.1
|
||||
IHASVPN_HTTP_LISTEN: "127.0.0.1:51821"
|
||||
# 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
|
||||
# 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
|
||||
# wrong one (it uses the default route).
|
||||
# WGX_EGRESS_INTERFACE: eth0
|
||||
# IHASVPN_EGRESS_INTERFACE: eth0
|
||||
volumes:
|
||||
- wgx-data:/data
|
||||
- ihasvpn-data:/data
|
||||
|
||||
volumes:
|
||||
wgx-data:
|
||||
ihasvpn-data:
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
# docker-compose.host.yml (host networking) and which host sysctls matter.
|
||||
services:
|
||||
wgx:
|
||||
image: ghcr.io/coffey-labs/wgx:latest
|
||||
container_name: wgx
|
||||
ihasvpn:
|
||||
image: ghcr.io/coffey-labs/ihasvpn:latest
|
||||
container_name: ihasvpn
|
||||
restart: unless-stopped
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
@@ -21,35 +21,35 @@ services:
|
||||
- net.ipv4.ip_forward=1
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
# Loose reverse-path filtering; strict drops replies arriving on the
|
||||
# tunnel. WGX would set this itself but /proc/sys is read-only in a
|
||||
# tunnel. ihasvpn would set this itself but /proc/sys is read-only in a
|
||||
# container, so it has to come from here.
|
||||
- net.ipv4.conf.all.rp_filter=2
|
||||
- net.ipv4.conf.default.rp_filter=2
|
||||
# Uncomment with WGX_SUBNET6 for IPv6 inside the tunnel.
|
||||
# Uncomment with IHASVPN_SUBNET6 for IPv6 inside the tunnel.
|
||||
# - net.ipv6.conf.all.forwarding=1
|
||||
# - net.ipv6.conf.all.disable_ipv6=0
|
||||
environment:
|
||||
# The public hostname or IP clients connect to. Asked for at setup too.
|
||||
WGX_ENDPOINT: vpn.example.com
|
||||
IHASVPN_ENDPOINT: vpn.example.com
|
||||
# UDP port WireGuard listens on; must match the port mapping.
|
||||
WGX_PORT: "51820"
|
||||
IHASVPN_PORT: "51820"
|
||||
# Tunnel network. The server takes the first address.
|
||||
WGX_SUBNET: 10.8.0.0/24
|
||||
# WGX_SUBNET6: fd42:42:42::/64
|
||||
IHASVPN_SUBNET: 10.8.0.0/24
|
||||
# IHASVPN_SUBNET6: fd42:42:42::/64
|
||||
# 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
|
||||
# built-in self-signed certificate, before exposing it anywhere but
|
||||
# localhost or your LAN.
|
||||
WGX_HTTP_LISTEN: ":51821"
|
||||
# WGX_TLS_SELF_SIGNED: "true"
|
||||
# WGX_TRUSTED_PROXIES: 172.16.0.0/12
|
||||
# WGX_METRICS_TOKEN: change-me
|
||||
IHASVPN_HTTP_LISTEN: ":51821"
|
||||
# IHASVPN_TLS_SELF_SIGNED: "true"
|
||||
# IHASVPN_TRUSTED_PROXIES: 172.16.0.0/12
|
||||
# IHASVPN_METRICS_TOKEN: change-me
|
||||
ports:
|
||||
- "51820:51820/udp"
|
||||
- "127.0.0.1:51821:51821/tcp"
|
||||
volumes:
|
||||
- wgx-data:/data
|
||||
- ihasvpn-data:/data
|
||||
|
||||
volumes:
|
||||
wgx-data:
|
||||
ihasvpn-data:
|
||||
|
||||
@@ -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")
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.9 KiB |
@@ -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 |
@@ -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 |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB |
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
result.
|
||||
|
||||
@@ -9,17 +9,17 @@ result.
|
||||
In rough order of importance:
|
||||
|
||||
1. **Kernel data plane.** WireGuard in the kernel handles encryption in the
|
||||
network stack with no copies to user space. WGX creates a native
|
||||
network stack with no copies to user space. ihasvpn creates a native
|
||||
`wireguard` link over netlink and configures it over the same channel;
|
||||
nothing sits between the NIC and the module. On a host without the module
|
||||
WGX falls back to `wireguard-go`, which works everywhere but moves every
|
||||
ihasvpn falls back to `wireguard-go`, which works everywhere but moves every
|
||||
packet through a user-space process and is several times slower. The
|
||||
dashboard says which one is in use. Any Linux kernel from 5.6 has the
|
||||
module; on older kernels install `wireguard-dkms` on the host.
|
||||
2. **MTU and MSS.** A tunnel packet has 60 bytes of overhead on IPv4 (80 on
|
||||
IPv6). The default MTU of 1420 fits a 1500-byte underlay. If the path to
|
||||
the server is smaller than that (PPPoE, a second tunnel, some mobile
|
||||
networks) packets fragment or vanish and downloads crawl. WGX clamps the
|
||||
networks) packets fragment or vanish and downloads crawl. ihasvpn clamps the
|
||||
TCP MSS of forwarded connections to the route MTU, which removes the
|
||||
"connected but pages hang" failure outright; lower the MTU in Settings if
|
||||
UDP-heavy traffic still struggles.
|
||||
@@ -33,15 +33,15 @@ In rough order of importance:
|
||||
peer; a single flow is bounded by one core. Machines with AVX2 or ARMv8
|
||||
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**:
|
||||
|
||||
| sysctl | value | why |
|
||||
| --- | --- | --- |
|
||||
| `net.ipv4.ip_forward` | 1 | required; peers go nowhere without it |
|
||||
| `net.ipv6.conf.all.forwarding` | 1 | required when `WGX_SUBNET6` is set |
|
||||
| `net.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.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 |
|
||||
@@ -51,13 +51,13 @@ the dashboard under **Show kernel tuning**:
|
||||
The first three groups are network-namespaced and work inside the container
|
||||
when the compose file passes them under `sysctls:` (forwarding) or the
|
||||
container has NET_ADMIN (rp_filter). The `net.core.*` and `udp_*` ones are
|
||||
**global**: the kernel refuses them from inside a container, WGX logs a
|
||||
**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
|
||||
them on the host.
|
||||
|
||||
## 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`:
|
||||
|
||||
```
|
||||
@@ -76,7 +76,7 @@ net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_congestion_control = bbr
|
||||
|
||||
# Forwarding, in case you run the host-network compose file and want to own
|
||||
# it yourself (WGX sets it otherwise).
|
||||
# it yourself (ihasvpn sets it otherwise).
|
||||
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
|
||||
|
||||
`docker-compose.host.yml` runs WGX with `network_mode: host`. It removes the
|
||||
Docker port mapping from the path and lets WGX set the host's own
|
||||
`docker-compose.host.yml` runs ihasvpn with `network_mode: host`. It removes the
|
||||
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,
|
||||
`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.
|
||||
|
||||
## Measuring
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 84 KiB |
@@ -1,4 +1,4 @@
|
||||
module github.com/Coffey-Labs/WGX
|
||||
module github.com/Coffey-Labs/ihasvpn
|
||||
|
||||
go 1.27.1
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ func VerifyPassword(hash, pw string) bool {
|
||||
|
||||
// dummyHash is verified against when the user does not exist, so a login
|
||||
// for an unknown name takes as long as one for a known name.
|
||||
var dummyHash, _ = HashPassword("wgx-timing-equaliser-password")
|
||||
var dummyHash, _ = HashPassword("ihasvpn-timing-equaliser-password")
|
||||
|
||||
// EqualiseTiming burns the cost of one hash verification.
|
||||
func EqualiseTiming() { VerifyPassword(dummyHash, "not-the-password") }
|
||||
|
||||
@@ -104,52 +104,52 @@ func envDuration(key string, def time.Duration) (time.Duration, error) {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// FromEnv builds the configuration from WGX_* variables.
|
||||
// FromEnv builds the configuration from IHASVPN_* variables.
|
||||
func FromEnv() (*Config, error) {
|
||||
var errs []error
|
||||
c := &Config{}
|
||||
c.DataDir = env("WGX_DATA_DIR", "/data")
|
||||
c.DBPath = env("WGX_DB", c.DataDir+"/wgx.db")
|
||||
c.Backend = strings.ToLower(env("WGX_BACKEND", "auto"))
|
||||
c.DataDir = env("IHASVPN_DATA_DIR", "/data")
|
||||
c.DBPath = env("IHASVPN_DB", c.DataDir+"/ihasvpn.db")
|
||||
c.Backend = strings.ToLower(env("IHASVPN_BACKEND", "auto"))
|
||||
switch c.Backend {
|
||||
case "auto", "kernel", "userspace", "mock":
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("WGX_BACKEND: %q is not auto, kernel, userspace or mock", c.Backend))
|
||||
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") {
|
||||
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
|
||||
if c.ListenPort, err = envInt("WGX_PORT", 51820); err != nil {
|
||||
if c.ListenPort, err = envInt("IHASVPN_PORT", 51820); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else if c.ListenPort < 1 || c.ListenPort > 65535 {
|
||||
errs = append(errs, errors.New("WGX_PORT: must be 1-65535"))
|
||||
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() {
|
||||
errs = append(errs, errors.New("WGX_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24"))
|
||||
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("IHASVPN_SUBNET: must be an IPv4 CIDR such as 10.8.0.0/24"))
|
||||
} else if c.Subnet4.Bits() > 30 {
|
||||
errs = append(errs, errors.New("WGX_SUBNET: needs room for at least two hosts (/30 or larger)"))
|
||||
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() {
|
||||
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.HTTP = env("WGX_HTTP_LISTEN", ":51821")
|
||||
c.TLSCert = env("WGX_TLS_CERT", "")
|
||||
c.TLSKey = env("WGX_TLS_KEY", "")
|
||||
c.Egress = env("IHASVPN_EGRESS_INTERFACE", "")
|
||||
c.HTTP = env("IHASVPN_HTTP_LISTEN", ":51821")
|
||||
c.TLSCert = env("IHASVPN_TLS_CERT", "")
|
||||
c.TLSKey = env("IHASVPN_TLS_KEY", "")
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, p := range strings.Split(env("WGX_TRUSTED_PROXIES", ""), ",") {
|
||||
for _, p := range strings.Split(env("IHASVPN_TRUSTED_PROXIES", ""), ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
@@ -159,37 +159,37 @@ func FromEnv() (*Config, error) {
|
||||
if a, err2 := netip.ParseAddr(p); err2 == nil {
|
||||
pfx = netip.PrefixFrom(a, a.BitLen())
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("WGX_TRUSTED_PROXIES: %q is not an address or CIDR", p))
|
||||
errs = append(errs, fmt.Errorf("IHASVPN_TRUSTED_PROXIES: %q is not an address or CIDR", p))
|
||||
continue
|
||||
}
|
||||
}
|
||||
c.TrustedProxies = append(c.TrustedProxies, pfx)
|
||||
}
|
||||
c.MetricsToken = env("WGX_METRICS_TOKEN", "")
|
||||
if c.SessionIdle, err = envDuration("WGX_SESSION_IDLE", 12*time.Hour); err != nil {
|
||||
c.MetricsToken = env("IHASVPN_METRICS_TOKEN", "")
|
||||
if c.SessionIdle, err = envDuration("IHASVPN_SESSION_IDLE", 12*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.SessionMax, err = envDuration("WGX_SESSION_MAX", 7*24*time.Hour); err != nil {
|
||||
if c.SessionMax, err = envDuration("IHASVPN_SESSION_MAX", 7*24*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.TrafficRetention, err = envDuration("WGX_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil {
|
||||
if c.TrafficRetention, err = envDuration("IHASVPN_TRAFFIC_RETENTION", 90*24*time.Hour); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
if c.PollInterval, err = envDuration("WGX_POLL_INTERVAL", 2*time.Second); err != nil {
|
||||
if c.PollInterval, err = envDuration("IHASVPN_POLL_INTERVAL", 2*time.Second); err != nil {
|
||||
errs = append(errs, err)
|
||||
} else if c.PollInterval < 500*time.Millisecond {
|
||||
errs = append(errs, errors.New("WGX_POLL_INTERVAL: must be at least 500ms"))
|
||||
errs = append(errs, errors.New("IHASVPN_POLL_INTERVAL: must be at least 500ms"))
|
||||
}
|
||||
c.LogLevel = strings.ToLower(env("WGX_LOG_LEVEL", "info"))
|
||||
if c.LogJSON, err = envBool("WGX_LOG_JSON", false); err != nil {
|
||||
c.LogLevel = strings.ToLower(env("IHASVPN_LOG_LEVEL", "info"))
|
||||
if c.LogJSON, err = envBool("IHASVPN_LOG_JSON", false); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
c.InitialEndpoint = env("WGX_ENDPOINT", "")
|
||||
c.InitialDNS = env("WGX_DNS", "1.1.1.1, 1.0.0.1")
|
||||
if c.ManageFirewall, err = envBool("WGX_MANAGE_FIREWALL", true); err != nil {
|
||||
c.InitialEndpoint = env("IHASVPN_ENDPOINT", "")
|
||||
c.InitialDNS = env("IHASVPN_DNS", "1.1.1.1, 1.0.0.1")
|
||||
if c.ManageFirewall, err = envBool("IHASVPN_MANAGE_FIREWALL", true); err != nil {
|
||||
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)
|
||||
}
|
||||
if len(errs) > 0 {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"sync"
|
||||
"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
|
||||
|
||||
@@ -12,10 +12,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/netcfg"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/netcfg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
||||
)
|
||||
|
||||
func testConfig() *config.Config {
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
||||
)
|
||||
|
||||
// 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()
|
||||
} 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 {
|
||||
return nil, err
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"net/netip"
|
||||
"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
|
||||
|
||||
@@ -3,7 +3,7 @@ package engine
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/netcfg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/netcfg"
|
||||
)
|
||||
|
||||
// SysctlStatus is one sysctl as reported to the UI.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Rules describes the firewall WGX wants.
|
||||
// Rules describes the firewall ihasvpn wants.
|
||||
type Rules struct {
|
||||
// Iface is the WireGuard interface name, e.g. wg0.
|
||||
Iface string
|
||||
@@ -31,7 +31,7 @@ type Rules struct {
|
||||
// "the VPN connects but websites hang".
|
||||
ClampMSS bool
|
||||
// Table names the nftables table, so a host with its own rules never
|
||||
// collides with ours. Defaults to "wgx".
|
||||
// collides with ours. Defaults to "ihasvpn".
|
||||
Table string
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ type Rules struct {
|
||||
func Ruleset(r Rules) string {
|
||||
table := r.Table
|
||||
if table == "" {
|
||||
table = "wgx"
|
||||
table = "ihasvpn"
|
||||
}
|
||||
var b strings.Builder
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if table == "" {
|
||||
table = "wgx"
|
||||
table = "ihasvpn"
|
||||
}
|
||||
script := fmt.Sprintf("table inet %s\ndelete table inet %s\n", table, table)
|
||||
return runNFT(ctx, script)
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestRuleset(t *testing.T) {
|
||||
}
|
||||
out := Ruleset(r)
|
||||
for _, want := range []string{
|
||||
"table inet wgx {",
|
||||
"table inet ihasvpn {",
|
||||
"udp dport 51820 accept",
|
||||
`iifname "wg0" oifname "wg0" drop`,
|
||||
`tcp option maxseg size set rt mtu`,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"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 {
|
||||
Key string
|
||||
Value string
|
||||
@@ -27,7 +27,7 @@ type Result struct {
|
||||
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 {
|
||||
s := []Sysctl{
|
||||
{Key: "net.ipv4.ip_forward", Value: "1", Required: true, Why: "peers cannot reach anything beyond the server without forwarding"},
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/engine"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
)
|
||||
|
||||
type healthBody struct {
|
||||
@@ -192,7 +192,7 @@ func safeFilename(name string) string {
|
||||
}
|
||||
out := b.String()
|
||||
if out == "" {
|
||||
out = "wgx"
|
||||
out = "ihasvpn"
|
||||
}
|
||||
if len(out) > 15 {
|
||||
// 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")
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# HELP wgx_peers Number of configured peers.\n# TYPE wgx_peers gauge\nwgx_peers %d\n", snap.Totals.Peers)
|
||||
fmt.Fprintf(&b, "# HELP wgx_peers_connected Peers with a recent handshake.\n# TYPE wgx_peers_connected gauge\nwgx_peers_connected %d\n", snap.Totals.Connected)
|
||||
fmt.Fprintf(&b, "# HELP wgx_receive_bytes_total Bytes received from peers.\n# TYPE wgx_receive_bytes_total counter\n")
|
||||
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 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 ihasvpn_receive_bytes_total Bytes received from peers.\n# TYPE ihasvpn_receive_bytes_total counter\n")
|
||||
for id, l := range snap.Peers {
|
||||
fmt.Fprintf(&b, "wgx_receive_bytes_total{peer=%q,name=%q} %d\n", id, names[id], l.Rx)
|
||||
fmt.Fprintf(&b, "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 {
|
||||
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 {
|
||||
v := 0
|
||||
if l.Connected {
|
||||
v = 1
|
||||
}
|
||||
fmt.Fprintf(&b, "wgx_peer_connected{peer=%q,name=%q} %d\n", id, names[id], v)
|
||||
fmt.Fprintf(&b, "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 {
|
||||
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()))
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
|
||||
"github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
)
|
||||
|
||||
const cookieName = "wgx_session"
|
||||
const cookieName = "ihasvpn_session"
|
||||
|
||||
type ctxKey int
|
||||
|
||||
@@ -450,7 +450,7 @@ func (s *Server) handleTOTPSetup(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
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
|
||||
@@ -462,7 +462,7 @@ func (s *Server) handleTOTPQR(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "no two-factor setup in progress")
|
||||
return
|
||||
}
|
||||
png, err := qrcode.Encode(auth.TOTPURI("WGX", u.Username, u.TOTPSecret), qrcode.Medium, 256)
|
||||
png, err := qrcode.Encode(auth.TOTPURI("ihasvpn", u.Username, u.TOTPSecret), qrcode.Medium, 256)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/server/static"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/engine"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/server/static"
|
||||
)
|
||||
|
||||
// Server serves the API and UI.
|
||||
|
||||
@@ -14,11 +14,11 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Coffey-Labs/WGX/internal/auth"
|
||||
"github.com/Coffey-Labs/WGX/internal/config"
|
||||
"github.com/Coffey-Labs/WGX/internal/engine"
|
||||
"github.com/Coffey-Labs/WGX/internal/store"
|
||||
"github.com/Coffey-Labs/WGX/internal/wg"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/auth"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/engine"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
||||
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
||||
)
|
||||
|
||||
type client struct {
|
||||
@@ -178,7 +178,7 @@ func TestSetupLoginAndPeers(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer metrics-secret")
|
||||
r, _ := anon.Do(req)
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
if r.StatusCode != 200 || !strings.Contains(string(b), "wgx_peers ") {
|
||||
if r.StatusCode != 200 || !strings.Contains(string(b), "ihasvpn_peers ") {
|
||||
t.Fatalf("token metrics: %d %s", r.StatusCode, b)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *Server) loadCertificate() (tls.Certificate, error) {
|
||||
host := s.eng.Settings().EndpointHost
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "WGX", Organization: []string{"WGX"}},
|
||||
Subject: pkix.Name{CommonName: "ihasvpn", Organization: []string{"ihasvpn"}},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(3 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
|
||||
@@ -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
|
||||
// log -- lives in one file under the data directory.
|
||||
package store
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// UI development.
|
||||
package wg
|
||||
@@ -25,7 +25,7 @@ type PeerState struct {
|
||||
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 {
|
||||
PublicKey Key
|
||||
PresharedKey *Key
|
||||
@@ -38,7 +38,7 @@ type DeviceConfig struct {
|
||||
PrivateKey Key
|
||||
ListenPort int
|
||||
// FirewallMark is applied to every packet the interface sends; zero means
|
||||
// none. Left at zero by WGX, but exposed for completeness.
|
||||
// none. Left at zero by ihasvpn, but exposed for completeness.
|
||||
FirewallMark int
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ type DeviceState struct {
|
||||
Peers []PeerState
|
||||
}
|
||||
|
||||
// Backend is the data plane WGX drives.
|
||||
// Backend is the data plane ihasvpn drives.
|
||||
type Backend interface {
|
||||
// Kind names the implementation: "kernel", "userspace" or "mock".
|
||||
Kind() string
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// linuxBackend drives a real WireGuard interface. In kernel mode the link is a
|
||||
// native `wireguard` netlink link and every packet is handled by the module;
|
||||
// in userspace mode a wireguard-go process owns a TUN device with the same
|
||||
// name and WGX talks to it over its UAPI socket. Both are configured through
|
||||
// name and ihasvpn talks to it over its UAPI socket. Both are configured through
|
||||
// wgctrl, which picks the transport on its own.
|
||||
type linuxBackend struct {
|
||||
name string
|
||||
@@ -38,7 +38,7 @@ type linuxBackend struct {
|
||||
// trusting /sys/module, because a module that is loadable but not yet loaded
|
||||
// is only discovered by asking for it.
|
||||
func KernelAvailable() bool {
|
||||
const probe = "wgxprobe0"
|
||||
const probe = "ihasvpnprobe0"
|
||||
link := &netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: probe}}
|
||||
if err := netlink.LinkAdd(link); err != nil {
|
||||
return false
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// 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
|
||||
// the dashboard has something to show.
|
||||
type Mock struct {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"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.
|
||||
func KernelAvailable() bool { return false }
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#121a17" />
|
||||
<title>WGX</title>
|
||||
<meta name="theme-color" content="#0d2430" />
|
||||
<title>ihasvpn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wgx-web",
|
||||
"name": "ihasvpn-web",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wgx-web",
|
||||
"name": "ihasvpn-web",
|
||||
"version": "0.0.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wgx-web",
|
||||
"name": "ihasvpn-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
@@ -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">
|
||||
<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 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 32 KiB |
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "WGX",
|
||||
"short_name": "WGX",
|
||||
"description": "WireGuard server console",
|
||||
"name": "ihasvpn",
|
||||
"short_name": "ihasvpn",
|
||||
"description": "Self-hosted WireGuard server",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#111715",
|
||||
"theme_color": "#121a17",
|
||||
"background_color": "#0d2430",
|
||||
"theme_color": "#0d2430",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
@@ -46,9 +46,9 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
<div className="shell">
|
||||
{/* Phones and narrow windows: brand and account controls up top. */}
|
||||
<header className="topbar">
|
||||
<Link href="/" className="brand" aria-label="WGX dashboard">
|
||||
<Link href="/" className="brand" aria-label="ihasvpn dashboard">
|
||||
<Mark size={30} />
|
||||
<span className="brand-name">WGX</span>
|
||||
<span className="brand-name">ihasvpn</span>
|
||||
</Link>
|
||||
<div className="topbar-right">
|
||||
<LivePill />
|
||||
@@ -59,11 +59,11 @@ export function Layout({ children }: { children: ReactNode }) {
|
||||
|
||||
{/* Desktop: everything lives in the sidebar. */}
|
||||
<aside className="sidebar">
|
||||
<Link href="/" className="brand" aria-label="WGX dashboard">
|
||||
<Link href="/" className="brand" aria-label="ihasvpn dashboard">
|
||||
<Mark size={34} />
|
||||
<span>
|
||||
<span className="brand-name">WGX</span>
|
||||
<span className="brand-sub">WireGuard eXtended</span>
|
||||
<span className="brand-name">ihasvpn</span>
|
||||
<span className="brand-sub">Self-hosted WireGuard</span>
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="nav" aria-label="Main">
|
||||
|
||||
@@ -13,7 +13,7 @@ export function Legal({ center = false }: { center?: boolean }) {
|
||||
<span className="legal-sep" aria-hidden="true">
|
||||
·
|
||||
</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
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { useId } from "react";
|
||||
|
||||
// The WGX mark: a padlock on a shield, with the W as its keyhole. The same
|
||||
// drawing as docs/brand/wgx-mark.svg, inlined so it needs no request and
|
||||
// takes its size from wherever it is placed. Gradient ids are per instance:
|
||||
// the mark appears more than once on a page (sidebar, top bar), and a shared
|
||||
// id would resolve to whichever copy comes first, which may be hidden and
|
||||
// then paints nothing.
|
||||
// The ihasvpn mark: the ihasmail cat peeking over the edge of a shield. The
|
||||
// same drawing as docs/brand/ihasvpn-mark.svg (docs/brand/generate.py writes
|
||||
// that one), inlined so it needs no request and takes its size from wherever
|
||||
// it is placed. The clip-path id is per instance: the mark appears more than
|
||||
// once on a page (sidebar, top bar), and a shared id resolves to whichever
|
||||
// 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 }) {
|
||||
const id = useId();
|
||||
const shield = `${id}-shield`;
|
||||
const lock = `${id}-lock`;
|
||||
const clip = `${useId()}-clip`;
|
||||
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>
|
||||
<linearGradient id={shield} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#26332e" />
|
||||
<stop offset="1" stopColor="#121a17" />
|
||||
</linearGradient>
|
||||
<linearGradient id={lock} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#3fae90" />
|
||||
<stop offset="1" stopColor="#2b8f75" />
|
||||
</linearGradient>
|
||||
<clipPath id={clip}>
|
||||
<path d={SHIELD} />
|
||||
</clipPath>
|
||||
</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="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" />
|
||||
<path d="M46 62 V50 a18 18 0 0 1 36 0 V62" fill="none" stroke="#3fae90" strokeWidth="8.5" strokeLinecap="round" />
|
||||
<rect x="34" y="58" width="60" height="42" rx="10" fill={`url(#${lock})`} />
|
||||
<path d="M46 70 L53 89 L64 76 L75 89 L82 70" fill="none" stroke="#ffffff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d={SHIELD} fill={TEAL} />
|
||||
<g clipPath={`url(#${clip})`} stroke={NAVY} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 58 L30 60 M15 67 L30 65 M114 58 L98 60 M113 67 L98 65" strokeWidth="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={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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export function Login() {
|
||||
<div className="brand-mark">
|
||||
<Mark size={44} />
|
||||
</div>
|
||||
<div className="brand-name">WGX</div>
|
||||
<div className="brand-name">ihasvpn</div>
|
||||
</div>
|
||||
<h1>{stage === "password" ? "Sign in" : "Second factor"}</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function Setup() {
|
||||
<div className="brand-mark">
|
||||
<Mark size={44} />
|
||||
</div>
|
||||
<div className="brand-name">WGX</div>
|
||||
<div className="brand-name">ihasvpn</div>
|
||||
</div>
|
||||
<h1>Welcome</h1>
|
||||
<p className="muted" style={{ textAlign: "center", marginBottom: 16 }}>
|
||||
|
||||
@@ -1,56 +1,65 @@
|
||||
/* 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
|
||||
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 {
|
||||
--bg: #111715;
|
||||
--bg-elev: #19211e;
|
||||
--bg-sunken: #0c100f;
|
||||
--fg: #e6ece9;
|
||||
--fg-muted: #9aa8a2;
|
||||
--fg-faint: #6a7772;
|
||||
--line: #26312d;
|
||||
--line-strong: #3a4742;
|
||||
--accent: #3fae90;
|
||||
--accent-fg: #06110d;
|
||||
--accent-soft: #163a31;
|
||||
--ok: #3fc275;
|
||||
--ok-soft: #12321f;
|
||||
--warn: #e0a84a;
|
||||
--warn-soft: #3a2c10;
|
||||
--bad: #ef6b62;
|
||||
--bad-soft: #3d1815;
|
||||
--info: #5f9de0;
|
||||
--rx: #5f9de0;
|
||||
--tx: #3fae90;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
--bg: #0d2430;
|
||||
--bg-elev: #12303e;
|
||||
--bg-sunken: #0a1c26;
|
||||
--fg: #eaf6f6;
|
||||
--fg-muted: #a3c3cb;
|
||||
--fg-faint: #86aab4;
|
||||
--line: #21505f;
|
||||
--line-strong: #2e6a7a;
|
||||
--accent: #46cac3;
|
||||
--accent-fg: #062028;
|
||||
--accent-soft: rgba(70, 202, 195, 0.16);
|
||||
--ok: #4ade80;
|
||||
--ok-soft: rgba(74, 222, 128, 0.15);
|
||||
--warn: #f9a34b;
|
||||
--warn-soft: rgba(249, 163, 75, 0.14);
|
||||
--bad: #f87171;
|
||||
--bad-soft: rgba(248, 113, 113, 0.15);
|
||||
--info: #6fdcd6;
|
||||
/* Traffic: received in the cat's orange, sent in the shield's teal. */
|
||||
--rx: #f9a34b;
|
||||
--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;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f6f5;
|
||||
--bg: #f4f9f9;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-sunken: #e9edeb;
|
||||
--fg: #16201c;
|
||||
--fg-muted: #5c6b64;
|
||||
--fg-faint: #8b9791;
|
||||
--line: #d8dfdb;
|
||||
--line-strong: #b9c4be;
|
||||
--accent: #1f6f5c;
|
||||
--bg-sunken: #e7f1f2;
|
||||
--fg: #0d2430;
|
||||
--fg-muted: #4a6b74;
|
||||
--fg-faint: #62757a;
|
||||
--line: #cfe2e4;
|
||||
--line-strong: #849295;
|
||||
--accent: #0b6b67;
|
||||
--accent-fg: #ffffff;
|
||||
--accent-soft: #dcefe8;
|
||||
--ok: #1d8f4e;
|
||||
--ok-soft: #dcf3e4;
|
||||
--warn: #b7791f;
|
||||
--warn-soft: #fbeed3;
|
||||
--bad: #c2382f;
|
||||
--bad-soft: #f9dedb;
|
||||
--info: #2a6fb0;
|
||||
--rx: #2a6fb0;
|
||||
--tx: #1f6f5c;
|
||||
--shadow: 0 1px 2px rgba(20, 30, 26, 0.06), 0 8px 24px rgba(20, 30, 26, 0.06);
|
||||
--accent-soft: #daeceb;
|
||||
--ok: #15803d;
|
||||
--ok-soft: #dcfce7;
|
||||
--warn: #b45309;
|
||||
--warn-soft: #fef3c7;
|
||||
--bad: #dc2626;
|
||||
--bad-soft: #fee2e2;
|
||||
--info: #0e7490;
|
||||
--rx: #c5813b;
|
||||
--tx: #379e98;
|
||||
--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;
|
||||
}
|
||||
|
||||
@@ -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:hover { text-decoration: none; }
|
||||
.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; }
|
||||
.nav { display: flex; flex-direction: column; gap: 14px; margin-top: 10px; }
|
||||
.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; }
|
||||
|
||||
/* 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.wide { max-width: 860px; }
|
||||
.modal .card-head h2 { font-size: 16px; }
|
||||
@@ -383,7 +392,7 @@ textarea.input { min-height: 72px; resize: vertical; }
|
||||
/* Peer detail */
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 13px; }
|
||||
.kv dt { color: var(--fg-muted); }
|
||||
.kv dd { margin: 0; word-break: break-all; }
|
||||
.kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.qr { display: grid; grid-template-columns: 1fr; gap: 16px; }
|
||||
.qr img { width: 100%; max-width: 320px; image-rendering: pixelated; border-radius: 8px; border: 1px solid var(--line); background: #fff; justify-self: center; }
|
||||
pre.config { background: var(--bg-sunken); border: 1px solid var(--line); border-radius: 8px; padding: 12px; font-family: var(--mono); font-size: 12.5px; overflow: auto; margin: 0; white-space: pre; }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export type ThemeChoice = "dark" | "light" | "system";
|
||||
|
||||
const KEY = "wgx.theme";
|
||||
const KEY = "ihasvpn.theme";
|
||||
const media = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const listeners = new Set<(c: ThemeChoice) => void>();
|
||||
|
||||
@@ -28,7 +28,7 @@ export function applyTheme(choice: ThemeChoice) {
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
// Keeps the browser chrome (address bar on phones) in step with the page.
|
||||
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) {
|
||||
|
||||