Rename the project to ihasvpn

WGX shares its name with several other WireGuard tools, so the project
becomes ihasvpn, alongside ihasmail.

- Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image
  ghcr.io/coffey-labs/ihasvpn.
- Environment variables move from WGX_* to IHASVPN_*. The default database
  is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and
  the session cookie and theme key are renamed, so existing sessions end.
- The mark is the ihasmail cat peeking over the edge of a shield, drawn as
  a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks,
  social card, favicons and app icons from that one drawing.
- The console takes ihasmail's palette: the ihasmail.org teal-navy for dark,
  its contrast-checked light tiers with the site's light accent, received
  traffic in the cat's orange and sent in teal. The wordmark weight and
  font stack follow ihasmail.org.
- Detail values wrap at spaces before breaking inside an address, so an
  IPv6 tunnel address no longer splits mid-number.
- The README history note about the earlier WGX installer is gone with the
  name it explained. Screenshots retaken.
This commit is contained in:
2026-09-12 23:48:36 -07:00
parent e807896806
commit 02e7993c87
74 changed files with 529 additions and 369 deletions
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "wgx-web",
"name": "ihasvpn-web",
"version": "0.0.0",
"private": true,
"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">
<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

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",
"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 -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.
export class ApiError extends Error {
+5 -5
View File
@@ -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">
+1 -1
View File
@@ -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>
+32 -23
View File
@@ -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>
);
}
+1 -1
View File
@@ -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>}
+1 -1
View File
@@ -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 }}>
+55 -46
View File
@@ -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; }
+2 -2
View File
@@ -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) {