Functional logic added

This commit is contained in:
2026-02-22 22:43:09 +01:00
parent 141279ccc5
commit bda9183264
7 changed files with 1981 additions and 141 deletions

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# WireGuard GUI
A desktop application for generating WireGuard server and client configuration files through a graphical interface, built with [Electron](https://www.electronjs.org/).
Instead of hand-editing `.conf` files, you fill in a form and get ready-to-use WireGuard configs, including all the `iptables` rules, instantly.
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/) v18 or later
- npm
### Install and run
```bash
npm install
npm start
```
## Usage
1. Fill in the **Server** panel on the left -> interface, address, port, keys, and any optional settings
2. Add one or more clients with the **New client** button on the right
3. Fill in each client's keys and toggle internet access as needed
4. Click **Sync** -> both config previews update immediately
5. Use the **Copy** button on either panel to copy the config text to your clipboard
6. Use **Save JSON** in the header to persist the full setup, and **Load** to restore it later
### Field reference
**Server**
| Field | Description |
| --------------------- | ------------------------------------------------------ |
| Network interface | WireGuard interface name, e.g. `wg0` |
| Server address | Internal VPN address in CIDR, e.g. `10.0.0.1/24` |
| Listen port | UDP port WireGuard listens on, e.g. `51820` |
| LAN between clients | Allows peers to reach each other directly |
| Forwarded ports | Comma-separated list of ports to DNAT into the VPN |
| Private / Public key | WireGuard base64 key pair for the server |
| Public address | Hostname or IP clients use to reach the server |
| Running in Docker | Uses the Docker host interface in iptables rules |
| Docker host interface | The physical interface on the Docker host, e.g. `eth0` |
**Client** (per peer)
| Field | Description |
| -------------------- | --------------------------------------------------------- |
| Client name | Label used in tabs and config comments |
| Internet traffic | When on, routes all traffic through the VPN (`0.0.0.0/0`) |
| Private / Public key | WireGuard base64 key pair for this client |
### Key generation
WireGuard keys can be generated on the server with:
```bash
wg genkey | tee privatekey | wg pubkey > publickey
```
---
## Screenshots
<img src="docs/Screenshot01.png" width="960" />
<img src="docs/Screenshot02.png" width="960" />

BIN
docs/Screenshot01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
docs/Screenshot02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

View File

@@ -1,102 +1,273 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
content="default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src https://fonts.gstatic.com data:;"
/>
<meta
http-equiv="X-Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500;700&family=Syne:wght@400;600;700&family=Mulish:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
<title>Wireguard GUI</title>
<title>WireGuard GUI</title>
</head>
<body>
<h1>Wireguard GUI!</h1>
<div id="gui-wrapper">
<div id="left-side-wrapper" class="side-wrapper">
<div class="conf-server-form">
<h2>Server Side</h2>
<form>
<label>Interface:</label>
<input value="iface0" />
<br />
<label>Server address:</label>
<input value="11.11.11.1/24" />
<br />
<label>Port:</label>
<input value="port-number" />
<br />
<label>Clients view each other as in LAN:</label>
<input value="true" />
<br />
<label>List of ports to enable traffic on:</label>
<input value="port1, port2" />
<br />
<label>Server private key:</label>
<input value="veryprivatekey" />
<br />
<label>Server public key:</label>
<input value="verypublickey" />
<br />
<label>Running in docker?:</label>
<input value="true" />
<br />
<label>Docker HOST interface:</label>
<input value="iface1" />
<br />
<button>Sync</button>
</form>
<!-- Header -->
<header class="app-header">
<div class="app-header-left">
<img src="../../wireguard-icon.svg" class="logo-icon" alt="WireGuard" />
<span class="app-title">WireGuard <em>GUI</em></span>
</div>
<div class="conf-server">
<form>
<label>SERVER CONF</label>
<br />
<input
class="conf-server-input"
value="COMPLETE WORKING CONF HERE"
<div class="app-header-right">
<button id="load-btn" class="btn btn-ghost" title="Load JSON config">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Load
</button>
<button
id="download-btn"
class="btn btn-ghost"
title="Save JSON config"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Save JSON
</button>
<button id="sync-btn" class="btn btn-primary">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path
d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"
/>
</form>
</svg>
Sync
</button>
</div>
</div>
<div id="right-side-wrapper" class="side-wrapper">
<div class="conf-client-form">
<h2>Client Side</h2>
<form>
<label>Client picker</label>
<select name="Test">
<option value="1">Test A</option>
<option value="2">Test B</option>
</select>
<br />
</header>
<label>Internet traffic enabled:</label>
<input value="true" />
<br />
<label>Client private key:</label>
<input value="veryprivateclientkey" />
<br />
<label>Client private key:</label>
<input value="verypublicclientkey" />
<br />
</form>
<!-- Main two-panel layout -->
<main class="gui-layout">
<!-- LEFT: Server -->
<section class="panel panel-server">
<div class="panel-header">
<span class="panel-badge badge-server">SERVER</span>
<h2>Server Configuration</h2>
</div>
<div class="conf-server">
<form>
<label>SERVER CONF</label>
<br />
<div class="panel-body">
<!-- Form -->
<div class="form-section">
<div class="section-label">Interface</div>
<div class="field-row">
<label for="s-iface">Network interface</label>
<input
class="conf-server-input"
value="COMPLETE WORKING CONF HERE"
id="s-iface"
type="text"
placeholder="wg0"
class="mono-input"
/>
</form>
</div>
<div class="field-row">
<label for="s-address">Server address (CIDR)</label>
<input
id="s-address"
type="text"
placeholder="10.0.0.1/24"
class="mono-input"
/>
</div>
<div class="field-row">
<label for="s-port">Listen port</label>
<input
id="s-port"
type="text"
placeholder="51820"
class="mono-input"
/>
</div>
<div class="section-label" style="margin-top: 1rem">Routing</div>
<div class="field-row toggle-row">
<label for="s-lan">LAN between clients</label>
<label class="toggle-switch">
<input id="s-lan" type="checkbox" />
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="s-lan-label">Disabled</span>
</div>
<div class="field-row">
<label for="s-ports">Forwarded ports</label>
<input
id="s-ports"
type="text"
placeholder="22, 80, 443"
class="mono-input"
/>
<span class="field-sub">Comma-separated list</span>
</div>
<div class="section-label" style="margin-top: 1rem">Keys</div>
<div class="field-row">
<label for="s-privkey">Private key</label>
<input
id="s-privkey"
type="text"
placeholder="Base64 private key"
class="mono-input key-input"
/>
</div>
<div class="field-row">
<label for="s-pubkey">Public key</label>
<input
id="s-pubkey"
type="text"
placeholder="Base64 public key"
class="mono-input key-input"
/>
</div>
<div class="section-label" style="margin-top: 1rem">Connection</div>
<div class="field-row">
<label for="s-pubaddr">Public address</label>
<input
id="s-pubaddr"
type="text"
placeholder="vpn.example.com or 203.0.113.1"
class="mono-input"
/>
<span class="field-sub">Used as Endpoint in client configs</span>
</div>
<div class="section-label" style="margin-top: 1rem">Docker</div>
<div class="field-row toggle-row">
<label for="s-docker">Running in Docker</label>
<label class="toggle-switch">
<input id="s-docker" type="checkbox" />
<span class="toggle-slider"></span>
</label>
<span class="toggle-label" id="s-docker-label">No</span>
</div>
<div class="field-row" id="docker-iface-row" style="display: none">
<label for="s-docker-iface">Docker host interface</label>
<input
id="s-docker-iface"
type="text"
placeholder="eth0"
class="mono-input"
/>
</div>
</div>
<script type="module" src="gui.js"></script>
<!-- Server conf output -->
<div class="conf-output-section">
<div class="conf-output-header">
<span class="conf-output-title">wg0.conf</span>
<button id="copy-server-btn" class="btn btn-ghost btn-sm">
Copy
</button>
</div>
<pre id="server-conf-output" class="conf-output">
# Fill in server fields and click Sync</pre
>
</div>
</div>
</section>
<!-- RIGHT: Clients -->
<section class="panel panel-client">
<div class="panel-header">
<span class="panel-badge badge-client">CLIENT</span>
<h2>Client Configuration</h2>
</div>
<div class="panel-body">
<!-- Client picker + add -->
<div class="client-tabs-bar">
<div id="client-tabs" class="client-tabs"></div>
<button
id="add-client-btn"
class="btn btn-add"
title="Add new client"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New client
</button>
</div>
<!-- Client form -->
<div class="form-section" id="client-form-area">
<div class="empty-client">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8v4m0 4h.01" />
</svg>
<p>Add a client to get started</p>
</div>
</div>
<!-- Client conf output -->
<div class="conf-output-section">
<div class="conf-output-header">
<span class="conf-output-title">client.conf</span>
<span id="client-conf-name" class="conf-client-name"></span>
<button id="copy-client-btn" class="btn btn-ghost btn-sm">
Copy
</button>
</div>
<pre id="client-conf-output" class="conf-output">
# No client selected</pre
>
</div>
</div>
</section>
</main>
<script src="gui.js"></script>
</body>
</html>

View File

@@ -1,2 +1,930 @@
console.log("GUI works");
// Complete Wireguard conf yielding
// WireGuard GUI — gui.js
// State management, validation, config generation
/*****************************************************************************
* State
*****************************************************************************/
let state = {
server: {
iface: "",
address: "",
port: "",
lanBetweenClients: false,
allowedPorts: "",
privateKey: "",
publicKey: "",
publicAddress: "",
docker: false,
dockerIface: "",
},
clients: [],
};
let activeClientId = null;
let clientCounter = 0;
/*****************************************************************************
* Validation index
* Each entry: { regex, hint, optional, isBool }
* - isBool: skips regex check entirely (checkboxes)
* - optional: empty string is accepted as valid
* - extra: additional predicate run after regex passes
*****************************************************************************/
const SERVER_VALIDATORS = {
iface: {
regex: /^[a-zA-Z][a-zA-Z0-9_-]{0,14}$/,
hint: "Interface name: letters, numbers, dash, underscore (max 15 chars)",
},
address: {
regex: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
hint: "CIDR notation required, e.g. 10.0.0.1/24",
extra: (v) => {
const [ip, prefix] = v.split("/");
const parts = ip.split(".").map(Number);
return (
parts.every((p) => p >= 0 && p <= 255) && +prefix >= 0 && +prefix <= 32
);
},
},
port: {
regex: /^\d{1,5}$/,
hint: "Port number between 1 and 65535",
extra: (v) => +v >= 1 && +v <= 65535,
},
lanBetweenClients: { isBool: true },
allowedPorts: {
regex: /^(\d{1,5}(\s*,\s*\d{1,5})*)?$/,
hint: "Comma-separated port numbers, e.g. 22, 80, 443",
optional: true,
},
privateKey: {
regex: /^[A-Za-z0-9+/]{43}=$/,
hint: "WireGuard base64 private key (44 chars ending in =)",
},
publicKey: {
regex: /^[A-Za-z0-9+/]{43}=$/,
hint: "WireGuard base64 public key (44 chars ending in =)",
},
publicAddress: {
regex: /^[a-zA-Z0-9.-]+$/,
hint: "Hostname or IP address, e.g. vpn.example.com or 203.0.113.1",
},
docker: { isBool: true },
dockerIface: {
regex: /^[a-zA-Z][a-zA-Z0-9_-]{0,14}$/,
hint: "Docker host interface name, e.g. eth0 or enp1s0",
optional: true,
},
};
const CLIENT_VALIDATORS = {
name: {
regex: /^.{1,32}$/,
hint: "Client name required (max 32 chars)",
},
internetTraffic: { isBool: true },
privateKey: {
regex: /^[A-Za-z0-9+/]{43}=$/,
hint: "WireGuard base64 private key (44 chars ending in =)",
},
publicKey: {
regex: /^[A-Za-z0-9+/]{43}=$/,
hint: "WireGuard base64 public key (44 chars ending in =)",
},
};
/*****************************************************************************
* Validation helpers
*****************************************************************************/
/**
* Runs the validator spec for a single field against a given value.
*
* Looks up the field key in the provided validators map and applies, in order:
* the isBool shortcut, the optional-empty shortcut, the regex test, and any
* extra predicate. Returns a result object so the caller can decide how to
* surface the error.
*
* @param {Object} validators - One of SERVER_VALIDATORS or CLIENT_VALIDATORS.
* @param {string} fieldKey - Key matching an entry in the validators map.
* @param {string} value - Current field value to test.
* @returns {{ valid: boolean, hint?: string }}
*/
function validateValue(validators, fieldKey, value) {
const spec = validators[fieldKey];
if (!spec) return { valid: true };
if (spec.isBool) return { valid: true };
if (spec.optional && (value === "" || value === undefined || value === null))
return { valid: true };
if (!spec.regex) return { valid: true };
if (!spec.regex.test(String(value))) return { valid: false, hint: spec.hint };
if (spec.extra && !spec.extra(value))
return { valid: false, hint: spec.hint };
return { valid: true };
}
/**
* Applies or clears visual validation state on an input element.
*
* Toggles the field-invalid / field-valid CSS classes and injects or removes
* a .field-hint <span> inside the parent .field-row. Safe to call repeatedly;
* it updates the existing hint element rather than appending duplicates.
*
* @param {HTMLInputElement} inputEl - The input to decorate.
* @param {boolean} valid - Whether the current value is valid.
* @param {string} [hint] - Error message shown below the input.
*/
function markField(inputEl, valid, hint) {
const wrapper = inputEl.closest(".field-row") || inputEl.parentElement;
const existingHint = wrapper.querySelector(".field-hint");
inputEl.classList.toggle("field-invalid", !valid);
inputEl.classList.toggle("field-valid", valid);
if (!valid && hint) {
if (!existingHint) {
const hintEl = document.createElement("span");
hintEl.className = "field-hint";
hintEl.textContent = hint;
wrapper.appendChild(hintEl);
} else {
existingHint.textContent = hint;
}
} else if (existingHint) {
existingHint.remove();
}
}
/*****************************************************************************
* IP utilities
*****************************************************************************/
/** Returns the host part of a CIDR string, e.g. "10.0.0.1/24" -> "10.0.0.1". */
function parseBaseIP(cidr) {
return cidr ? cidr.split("/")[0] : "";
}
/** Returns the prefix length of a CIDR string, defaulting to "24". */
function parsePrefix(cidr) {
return cidr && cidr.includes("/") ? cidr.split("/")[1] : "24";
}
/** Converts a dotted-decimal IPv4 string to a 32-bit unsigned integer. */
function ipToInt(ip) {
return ip.split(".").reduce((acc, oct) => (acc << 8) + parseInt(oct, 10), 0);
}
/** Converts a 32-bit unsigned integer back to a dotted-decimal IPv4 string. */
function intToIp(int) {
return [
(int >>> 24) & 255,
(int >>> 16) & 255,
(int >>> 8) & 255,
int & 255,
].join(".");
}
/**
* Returns the network base address for a CIDR, e.g. "10.0.0.3/24" -> "10.0.0.0/24".
* Used to build the AllowedIPs line in client configs.
*
* @param {string} cidr - e.g. "10.0.0.1/24"
* @returns {string} - e.g. "10.0.0.0/24"
*/
function getNetworkAddress(cidr) {
const [ip, prefix] = cidr.split("/");
const mask = prefix === "0" ? 0 : (~0 << (32 - +prefix)) >>> 0;
return intToIp((ipToInt(ip) & mask) >>> 0) + "/" + prefix;
}
/**
* Derives the IP address to assign to a specific client.
*
* Clients are allocated sequentially from the server's own address.
* e.g. if the server is 10.0.0.1/24, client 1 gets 10.0.0.2/24,
* client 2 gets 10.0.0.3/24, and so on.
*
* @param {string} serverCIDR - Server address in CIDR notation.
* @param {number} clientIndex - 1-based index of the client.
* @returns {string} - Client CIDR, e.g. "10.0.0.2/24".
*/
function getClientIP(serverCIDR, clientIndex) {
const [ip] = serverCIDR.split("/");
const prefix = parsePrefix(serverCIDR);
const parts = ip.split(".").map(Number);
parts[3] = parts[3] + clientIndex;
return parts.join(".") + "/" + prefix;
}
/*****************************************************************************
* Config generators
*****************************************************************************/
/**
* Builds the complete wg0.conf text for the server side.
*
* Assembles the [Interface] block with all iptables PostUp/PostDown rules,
* then appends a [Peer] block for every client that has a public key set.
* Rules vary based on: NAT/internet forwarding (always on), LAN-between-clients
* flag, per-port DNAT forwards, and whether the server runs inside Docker
* (which changes the outbound interface used in iptables rules).
*
* @returns {string} Full .conf file content, or a placeholder comment if
* required fields are missing.
*/
function generateServerConf() {
const s = state.server;
if (!s.iface || !s.address || !s.port || !s.privateKey) {
return "# Fill in required server fields (Interface, Address, Port, Private Key) and click Sync";
}
// When running in Docker the host's physical interface is used for iptables
// rules rather than the WireGuard interface itself.
const outIface = s.docker && s.dockerIface ? s.dockerIface : s.iface;
const ports = s.allowedPorts
? s.allowedPorts
.split(",")
.map((p) => p.trim())
.filter(Boolean)
: [];
const serverIP = parseBaseIP(s.address);
let lines = [];
lines.push("[Interface]");
lines.push(`Address = ${s.address}`);
lines.push("SaveConfig = false");
lines.push("");
lines.push("# Internet access for VPN clients");
lines.push(
`PostUp = iptables -A FORWARD -i ${s.iface} -o ${outIface} -j ACCEPT`,
);
lines.push(
`PostUp = iptables -A FORWARD -i ${outIface} -o ${s.iface} -m state --state RELATED,ESTABLISHED -j ACCEPT`,
);
lines.push(
`PostUp = iptables -t nat -A POSTROUTING -o ${outIface} -j MASQUERADE`,
);
if (s.lanBetweenClients) {
lines.push("");
lines.push("# Allow VPN clients to talk to each other");
lines.push(
`PostUp = iptables -A FORWARD -i ${s.iface} -o ${s.iface} -j ACCEPT`,
);
}
ports.forEach((port) => {
lines.push("");
lines.push(`# Port ${port} forward`);
lines.push(
`PostUp = iptables -t nat -A PREROUTING -i ${outIface} -p tcp --dport ${port} -j DNAT --to-destination ${serverIP}:${port}`,
);
lines.push(
`PostUp = iptables -A FORWARD -i ${outIface} -o ${s.iface} -p tcp --dport ${port} -j ACCEPT`,
);
});
lines.push("");
lines.push("# Cleanup");
lines.push(
`PostDown = iptables -D FORWARD -i ${s.iface} -o ${outIface} -j ACCEPT`,
);
lines.push(
`PostDown = iptables -D FORWARD -i ${outIface} -o ${s.iface} -m state --state RELATED,ESTABLISHED -j ACCEPT`,
);
lines.push(
`PostDown = iptables -t nat -D POSTROUTING -o ${outIface} -j MASQUERADE`,
);
if (s.lanBetweenClients) {
lines.push(
`PostDown = iptables -D FORWARD -i ${s.iface} -o ${s.iface} -j ACCEPT`,
);
}
ports.forEach((port) => {
lines.push(
`PostDown = iptables -t nat -D PREROUTING -i ${outIface} -p tcp --dport ${port} -j DNAT --to-destination ${serverIP}:${port}`,
);
lines.push(
`PostDown = iptables -D FORWARD -i ${outIface} -o ${s.iface} -p tcp --dport ${port} -j ACCEPT`,
);
});
lines.push("");
lines.push(`ListenPort = ${s.port}`);
lines.push(`PrivateKey = ${s.privateKey}`);
state.clients.forEach((client, idx) => {
if (!client.publicKey) return;
const clientAddr = getClientIP(s.address, idx + 1);
const clientIP = parseBaseIP(clientAddr);
lines.push("");
lines.push(`# ${client.name || "Client " + (idx + 1)}`);
lines.push("[Peer]");
lines.push(`PublicKey = ${client.publicKey}`);
lines.push(`AllowedIPs = ${clientIP}/32`);
lines.push(
`#Endpoint = ${s.publicAddress || "<server-public-address>"}:${s.port}`,
);
});
return lines.join("\n");
}
/**
* Builds the client-side .conf text for a single peer.
*
* The AllowedIPs line differs based on the client's internetTraffic flag:
* when true, 0.0.0.0/0 is included so all traffic routes through the VPN;
* when false, only the VPN subnet is routed (LAN-only access).
*
* @param {Object} client - Client object from state.clients.
* @param {number} clientIndex - 0-based index in state.clients, used to
* derive this client's VPN IP address.
* @returns {string} Full .conf file content.
*/
function generateClientConf(client, clientIndex) {
const s = state.server;
if (!client) return "";
const clientAddr = getClientIP(s.address, clientIndex + 1);
const networkBase = s.address ? getNetworkAddress(s.address) : "10.0.0.0/24";
const allowedIPs = client.internetTraffic
? `${networkBase}, 0.0.0.0/0`
: networkBase;
let lines = [];
lines.push("# Client configuration");
lines.push("[Interface]");
lines.push(`Address = ${clientAddr || "<client-address>/<prefix>"}`);
lines.push("DNS = 1.1.1.1");
lines.push(`PrivateKey = ${client.privateKey || "<client-private-key>"}`);
lines.push("");
lines.push("# Server configuration");
lines.push("[Peer]");
lines.push(`PublicKey = ${s.publicKey || "<server-public-key>"}`);
lines.push(`AllowedIPs = ${allowedIPs}`);
lines.push(
`Endpoint = ${s.publicAddress || "<server-public-address>"}:${s.port || "<port>"}`,
);
lines.push("PersistentKeepalive = 25");
return lines.join("\n");
}
/*****************************************************************************
* DOM helpers
*****************************************************************************/
/** Shorthand for document.getElementById. */
function $(id) {
return document.getElementById(id);
}
/** Reads all server form inputs into a plain object matching the state shape. */
function getServerFormValues() {
return {
iface: $("s-iface").value.trim(),
address: $("s-address").value.trim(),
port: $("s-port").value.trim(),
lanBetweenClients: $("s-lan").checked,
allowedPorts: $("s-ports").value.trim(),
privateKey: $("s-privkey").value.trim(),
publicKey: $("s-pubkey").value.trim(),
publicAddress: $("s-pubaddr").value.trim(),
docker: $("s-docker").checked,
dockerIface: $("s-docker-iface").value.trim(),
};
}
/**
* Reads the currently visible client form inputs.
* Returns safe defaults when the form elements don't exist (no active client).
*/
function getActiveClientFormValues() {
return {
name: $("c-name") ? $("c-name").value.trim() : "",
internetTraffic: $("c-internet") ? $("c-internet").checked : true,
privateKey: $("c-privkey") ? $("c-privkey").value.trim() : "",
publicKey: $("c-pubkey") ? $("c-pubkey").value.trim() : "",
};
}
/**
* Writes server state back into all form inputs and updates toggle labels.
* Called after loading a JSON config file.
*/
function populateServerForm() {
const s = state.server;
$("s-iface").value = s.iface;
$("s-address").value = s.address;
$("s-port").value = s.port;
$("s-lan").checked = s.lanBetweenClients;
$("s-ports").value = s.allowedPorts;
$("s-privkey").value = s.privateKey;
$("s-pubkey").value = s.publicKey;
$("s-pubaddr").value = s.publicAddress;
$("s-docker").checked = s.docker;
$("s-docker-iface").value = s.dockerIface;
const lanLabel = $("s-lan-label");
if (lanLabel)
lanLabel.textContent = s.lanBetweenClients ? "Enabled" : "Disabled";
const dockerLabel = $("s-docker-label");
if (dockerLabel) dockerLabel.textContent = s.docker ? "Yes" : "No";
toggleDockerField();
}
/** Shows or hides the Docker host interface row based on the Docker checkbox. */
function toggleDockerField() {
const dockerRow = $("docker-iface-row");
if (dockerRow) {
dockerRow.style.display = $("s-docker").checked ? "flex" : "none";
}
}
/*****************************************************************************
* Client panel
*****************************************************************************/
/**
* Re-renders the client tab strip from state.clients.
*
* Creates one button per client. The active tab gets the .active class.
* Each tab includes an x delete button that stops click propagation so it
* doesn't also trigger the tab-switch handler.
*/
function renderClientTabs() {
const tabsEl = $("client-tabs");
tabsEl.innerHTML = "";
state.clients.forEach((client) => {
const tab = document.createElement("button");
tab.className =
"client-tab" + (client.id === activeClientId ? " active" : "");
tab.textContent = client.name || "Client";
tab.title = client.name;
tab.addEventListener("click", () => {
saveActiveClient();
activeClientId = client.id;
renderClientTabs();
renderClientForm();
updateClientConf();
});
const del = document.createElement("span");
del.className = "tab-del";
del.textContent = "x";
del.title = "Remove client";
del.addEventListener("click", (e) => {
e.stopPropagation();
removeClient(client.id);
});
tab.appendChild(del);
tabsEl.appendChild(tab);
});
}
/**
* Renders the client form area for the currently active client.
*
* Injects HTML for all client fields, then wires up live validation listeners
* and the name-change handler that keeps the tab label in sync. Shows an
* empty-state placeholder when no client is selected.
*/
function renderClientForm() {
const formEl = $("client-form-area");
if (!activeClientId || state.clients.length === 0) {
formEl.innerHTML = `<div class="empty-client">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/>
</svg>
<p>Add a client to get started</p>
</div>`;
return;
}
const client = state.clients.find((c) => c.id === activeClientId);
if (!client) return;
formEl.innerHTML = `
<div class="field-row">
<label for="c-name">Client name</label>
<input id="c-name" type="text" value="${escHtml(client.name)}" placeholder="e.g. laptop, phone" />
</div>
<div class="field-row toggle-row">
<label for="c-internet">Internet traffic</label>
<label class="toggle-switch">
<input id="c-internet" type="checkbox" ${client.internetTraffic ? "checked" : ""} />
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">${client.internetTraffic ? "Allowed" : "Blocked"}</span>
</div>
<div class="field-row">
<label for="c-privkey">Private key</label>
<input id="c-privkey" type="text" value="${escHtml(client.privateKey)}" placeholder="Base64 WireGuard key" class="mono-input" />
</div>
<div class="field-row">
<label for="c-pubkey">Public key</label>
<input id="c-pubkey" type="text" value="${escHtml(client.publicKey)}" placeholder="Base64 WireGuard key" class="mono-input" />
</div>
`;
// Live validation for text fields
const cFields = {
"c-name": "name",
"c-privkey": "privateKey",
"c-pubkey": "publicKey",
};
Object.entries(cFields).forEach(([elId, fieldKey]) => {
const el = $(elId);
if (!el) return;
el.addEventListener("input", () => {
const result = validateValue(
CLIENT_VALIDATORS,
fieldKey,
el.value.trim(),
);
markField(el, result.valid, result.hint);
});
});
// Keep the toggle label in sync with the checkbox
const internetToggle = $("c-internet");
if (internetToggle) {
internetToggle.addEventListener("change", () => {
const label = internetToggle
.closest(".toggle-row")
.querySelector(".toggle-label");
label.textContent = internetToggle.checked ? "Allowed" : "Blocked";
});
}
// Reflect name changes immediately in the tab strip
const nameInput = $("c-name");
if (nameInput) {
nameInput.addEventListener("input", () => {
const clientRef = state.clients.find((c) => c.id === activeClientId);
if (clientRef) {
clientRef.name = nameInput.value.trim() || "Client";
renderClientTabs();
}
});
}
}
/** Flushes the currently visible client form values into the matching state.clients entry. */
function saveActiveClient() {
if (!activeClientId) return;
const client = state.clients.find((c) => c.id === activeClientId);
if (!client) return;
Object.assign(client, getActiveClientFormValues());
}
/** Creates a new client entry with defaults, appends it to state, and activates it. */
function addClient() {
saveActiveClient();
clientCounter++;
const newClient = {
id: "client-" + clientCounter,
name: "Client " + clientCounter,
internetTraffic: true,
privateKey: "",
publicKey: "",
};
state.clients.push(newClient);
activeClientId = newClient.id;
renderClientTabs();
renderClientForm();
updateClientConf();
}
/** Removes a client by id, falls back the active selection, and redraws. */
function removeClient(id) {
state.clients = state.clients.filter((c) => c.id !== id);
if (activeClientId === id) {
activeClientId =
state.clients.length > 0
? state.clients[state.clients.length - 1].id
: null;
}
renderClientTabs();
renderClientForm();
syncAll();
}
/*****************************************************************************
* Validation pass
*****************************************************************************/
/**
* Validates every server form field and marks each input accordingly.
* Maps element IDs to their validator key and current value, then delegates
* to validateValue + markField for each.
*
* @returns {boolean} True if all fields are valid.
*/
function validateServerForm() {
const s = getServerFormValues();
let allValid = true;
const fieldMap = {
"s-iface": ["iface", s.iface],
"s-address": ["address", s.address],
"s-port": ["port", s.port],
"s-ports": ["allowedPorts", s.allowedPorts],
"s-privkey": ["privateKey", s.privateKey],
"s-pubkey": ["publicKey", s.publicKey],
"s-pubaddr": ["publicAddress", s.publicAddress],
"s-docker-iface": ["dockerIface", s.dockerIface],
};
Object.entries(fieldMap).forEach(([elId, [fieldKey, value]]) => {
const el = $(elId);
if (!el) return;
const result = validateValue(SERVER_VALIDATORS, fieldKey, value);
markField(el, result.valid, result.hint);
if (!result.valid) allValid = false;
});
return allValid;
}
/**
* Validates the active client's fields and marks each input accordingly.
* No-ops cleanly when no client is selected.
*
* @returns {boolean} True if all fields are valid (or no client is active).
*/
function validateActiveClientForm() {
if (!activeClientId) return true;
saveActiveClient();
const client = state.clients.find((c) => c.id === activeClientId);
if (!client) return true;
let allValid = true;
const fieldMap = {
"c-name": ["name", client.name],
"c-privkey": ["privateKey", client.privateKey],
"c-pubkey": ["publicKey", client.publicKey],
};
Object.entries(fieldMap).forEach(([elId, [fieldKey, value]]) => {
const el = $(elId);
if (!el) return;
const result = validateValue(CLIENT_VALIDATORS, fieldKey, value);
markField(el, result.valid, result.hint);
if (!result.valid) allValid = false;
});
return allValid;
}
/*****************************************************************************
* Sync and update
*****************************************************************************/
/**
* Main sync handler called by the Sync button.
*
* Commits form values to state, runs validation on both panels, regenerates
* both config outputs, and flashes the button green on success.
*/
function syncAll() {
state.server = getServerFormValues();
saveActiveClient();
const serverOk = validateServerForm();
validateActiveClientForm();
updateServerConf();
updateClientConf();
const btn = $("sync-btn");
if (serverOk) {
btn.classList.add("synced");
setTimeout(() => btn.classList.remove("synced"), 1200);
}
}
function updateServerConf() {
const el = $("server-conf-output");
if (el) el.textContent = generateServerConf();
}
function updateClientConf() {
const el = $("client-conf-output");
const titleEl = $("client-conf-name");
if (!el) return;
if (!activeClientId || state.clients.length === 0) {
el.textContent = "# No client selected";
if (titleEl) titleEl.textContent = "";
return;
}
const idx = state.clients.findIndex((c) => c.id === activeClientId);
const client = state.clients[idx];
el.textContent = generateClientConf(client, idx);
if (titleEl) titleEl.textContent = client.name || "Client";
}
/*****************************************************************************
* Copy, download, load
*****************************************************************************/
/**
* Copies the text content of a config output element to the clipboard and
* briefly updates the button label to give visual confirmation.
*
* @param {string} outputId - ID of the <pre> element containing the config.
* @param {string} btnId - ID of the button to flash "Copied!".
*/
function copyConf(outputId, btnId) {
const text = $(outputId)?.textContent || "";
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const btn = $(btnId);
const orig = btn.textContent;
btn.textContent = "Copied!";
btn.classList.add("copied");
setTimeout(() => {
btn.textContent = orig;
btn.classList.remove("copied");
}, 1500);
});
}
/**
* Serialises the current state (server + all clients) to a timestamped JSON
* file and triggers a browser download.
*/
function downloadConfig() {
state.server = getServerFormValues();
saveActiveClient();
const payload = {
exportedAt: new Date().toISOString(),
server: state.server,
clients: state.clients,
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wireguard-config-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Opens a file picker for a previously saved JSON config, parses it, merges
* it into state, and fully re-renders the UI. Shows a toast on success or
* failure.
*/
function loadConfigFromFile() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
if (data.server) state.server = { ...state.server, ...data.server };
if (Array.isArray(data.clients)) state.clients = data.clients;
if (state.clients.length > 0) {
activeClientId = state.clients[state.clients.length - 1].id;
clientCounter = state.clients.length;
}
populateServerForm();
renderClientTabs();
renderClientForm();
syncAll();
showToast("Configuration loaded");
} catch {
showToast("Failed to parse JSON file", true);
}
};
reader.readAsText(file);
});
input.click();
}
/*****************************************************************************
* Toast
*****************************************************************************/
/**
* Displays a transient notification at the bottom-right of the screen.
*
* @param {string} msg - Message to display.
* @param {boolean} [isError] - If true, renders with error styling.
*/
function showToast(msg, isError = false) {
const toast = document.createElement("div");
toast.className = "toast" + (isError ? " toast-error" : "");
toast.textContent = msg;
document.body.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("toast-show"));
setTimeout(() => {
toast.classList.remove("toast-show");
setTimeout(() => toast.remove(), 300);
}, 2500);
}
/*****************************************************************************
* Utility
*****************************************************************************/
/** Escapes a string for safe injection into HTML attribute values. */
function escHtml(str) {
return String(str || "")
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/*****************************************************************************
* Init
*****************************************************************************/
/**
* Bootstraps the application once the DOM is ready.
*
* Wires up all event listeners: live validation for server inputs, toggle
* label updates, the Sync button, client add button, copy/download/load
* buttons. Then performs an initial render so the UI is in a consistent
* state before the user interacts.
*/
function init() {
// Map element ID -> validator key for all server text inputs
const serverFieldMap = {
"s-iface": "iface",
"s-address": "address",
"s-port": "port",
"s-ports": "allowedPorts",
"s-privkey": "privateKey",
"s-pubkey": "publicKey",
"s-pubaddr": "publicAddress",
"s-docker-iface": "dockerIface",
};
Object.entries(serverFieldMap).forEach(([elId, fieldKey]) => {
const el = $(elId);
if (!el) return;
el.addEventListener("input", () => {
const result = validateValue(
SERVER_VALIDATORS,
fieldKey,
el.value.trim(),
);
markField(el, result.valid, result.hint);
});
});
$("s-lan").addEventListener("change", () => {
const label = $("s-lan-label");
if (label) label.textContent = $("s-lan").checked ? "Enabled" : "Disabled";
});
$("s-docker").addEventListener("change", () => {
const label = $("s-docker-label");
if (label) label.textContent = $("s-docker").checked ? "Yes" : "No";
toggleDockerField();
});
toggleDockerField();
$("sync-btn").addEventListener("click", syncAll);
$("add-client-btn").addEventListener("click", addClient);
$("copy-server-btn").addEventListener("click", () =>
copyConf("server-conf-output", "copy-server-btn"),
);
$("copy-client-btn").addEventListener("click", () =>
copyConf("client-conf-output", "copy-client-btn"),
);
$("download-btn").addEventListener("click", downloadConfig);
$("load-btn").addEventListener("click", loadConfigFromFile);
renderClientTabs();
renderClientForm();
updateServerConf();
updateClientConf();
}
document.addEventListener("DOMContentLoaded", init);

View File

@@ -1,69 +1,735 @@
#gui-wrapper {
width: 100%;
height: 80vh;
/* WireGuard GUI — styles.css
Soft sage-and-cream palette, Fira Mono for config output */
/*****************************************************************************
* Custom properties
*****************************************************************************/
:root {
/* Palette */
--sage-50: #f4f7f4;
--sage-100: #e8efe8;
--sage-200: #cfdecf;
--sage-300: #a8c5a8;
--sage-400: #7aa87a;
--sage-500: #5a8f5a;
--sage-600: #437043;
--cream-50: #fdfcf8;
--cream-100: #f9f6ee;
--cream-200: #f0ead8;
--slate-50: #f5f6f8;
--slate-100: #e8eaee;
--slate-200: #cdd1da;
--slate-400: #8b93a5;
--slate-600: #4a5568;
--slate-700: #374151;
--slate-800: #1f2937;
--slate-900: #111827;
--rose-100: #ffe4e6;
--rose-500: #f43f5e;
--rose-600: #e11d48;
--sky-100: #e0f2fe;
--sky-500: #0ea5e9;
/* Semantic */
--bg: var(--cream-50);
--bg-panel: #ffffff;
--bg-section: var(--sage-50);
--bg-output: var(--slate-800);
--border: var(--slate-100);
--border-mid: var(--slate-200);
--text-primary: var(--slate-800);
--text-secondary: var(--slate-600);
--text-muted: var(--slate-400);
--text-output: #d4e6c3;
--accent: var(--sage-500);
--accent-light: var(--sage-100);
--accent-hover: var(--sage-600);
--invalid-bg: var(--rose-100);
--invalid-border: var(--rose-500);
--invalid-text: var(--rose-600);
--valid-border: var(--sage-400);
/* Sizing */
--header-h: 52px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
/* Typography */
--font-ui: "Mulish", system-ui, sans-serif;
--font-display: "Syne", system-ui, sans-serif;
--font-mono: "Fira Mono", "Fira Code", monospace;
/* Transitions */
--ease: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/*****************************************************************************
* Reset
*****************************************************************************/
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
overflow: hidden;
}
body {
font-family: var(--font-ui);
background: var(--bg);
color: var(--text-primary);
font-size: 13px;
line-height: 1.5;
display: flex;
flex-direction: column;
}
/*****************************************************************************
* Header
*****************************************************************************/
.app-header {
height: var(--header-h);
min-height: var(--header-h);
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
gap: 1rem;
flex-shrink: 0;
-webkit-app-region: drag;
}
/* Buttons inside the draggable header must opt back out */
.app-header > * {
-webkit-app-region: no-drag;
}
.app-header-left {
display: flex;
align-items: center;
gap: 0.6rem;
}
.logo-icon {
width: 22px;
height: 22px;
color: var(--accent);
flex-shrink: 0;
}
.app-title {
font-family: var(--font-display);
font-size: 15px;
font-weight: 700;
color: var(--slate-800);
letter-spacing: -0.02em;
}
.app-title em {
font-style: normal;
color: var(--accent);
}
.app-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
/*****************************************************************************
* Main layout
*****************************************************************************/
.gui-layout {
flex: 1 1 0;
display: flex;
flex-direction: row;
justify-content: space-between;
overflow: hidden;
min-height: 0;
}
.side-wrapper {
height: 100%;
/*****************************************************************************
* Panels
*****************************************************************************/
.panel {
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: flex-start;
border-radius: 0.5rem;
border: 1px solid black;
gap: 2rem;
margin: 0.3rem;
min-width: 0;
overflow: hidden;
border-right: 1px solid var(--border);
}
#left-side-wrapper {
.panel:last-child {
border-right: none;
}
.panel-header {
padding: 0.9rem 1.25rem 0.7rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.6rem;
background: var(--bg-panel);
flex-shrink: 0;
}
.panel-header h2 {
font-family: var(--font-display);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text-secondary);
}
.panel-badge {
font-family: var(--font-mono);
font-size: 9px;
font-weight: 500;
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 4px;
}
.badge-server {
background: var(--sage-100);
color: var(--sage-600);
border: 1px solid var(--sage-200);
}
.badge-client {
background: var(--sky-100);
color: var(--sky-500);
border: 1px solid #bae6fd;
}
.panel-body {
flex: 1 1 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/*****************************************************************************
* Form section
*****************************************************************************/
.form-section {
flex: 0 0 auto;
padding: 1rem 1.25rem 0.5rem;
background: var(--bg-panel);
overflow-y: auto;
max-height: 46%;
border-bottom: 1px solid var(--border);
}
.section-label {
font-family: var(--font-display);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
/*****************************************************************************
* Fields
*****************************************************************************/
.field-row {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 0.55rem;
position: relative;
}
.field-row label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: 0.01em;
}
.field-row input[type="text"],
.field-row input[type="password"] {
width: 100%;
height: 100%;
height: 30px;
padding: 0 0.6rem;
border: 1.5px solid var(--border-mid);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: 12.5px;
transition:
border-color 0.15s var(--ease),
box-shadow 0.15s var(--ease),
background 0.15s;
outline: none;
}
.conf-server-form {
width: 90%;
margin: 1rem;
> form {
.field-row input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(90, 143, 90, 0.12);
background: #fff;
}
.mono-input {
font-family: var(--font-mono) !important;
font-size: 11.5px !important;
}
.key-input {
font-size: 10.5px !important;
letter-spacing: 0.02em;
}
/* Validation states */
.field-invalid {
border-color: var(--invalid-border) !important;
background: var(--invalid-bg) !important;
}
.field-invalid:focus {
box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.12) !important;
}
.field-valid {
border-color: var(--valid-border) !important;
}
.field-hint {
font-size: 10.5px;
color: var(--invalid-text);
font-weight: 500;
display: block;
margin-top: 1px;
}
.field-sub {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
/*****************************************************************************
* Toggle switch
*****************************************************************************/
.toggle-row {
flex-direction: row !important;
align-items: center;
gap: 8px !important;
}
.toggle-row label:first-child {
flex: 1;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 34px;
height: 18px;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-slider {
position: absolute;
inset: 0;
background: var(--slate-200);
border-radius: 999px;
cursor: pointer;
transition: background 0.2s var(--ease);
}
.toggle-slider::before {
content: "";
position: absolute;
height: 12px;
width: 12px;
left: 3px;
top: 3px;
border-radius: 50%;
background: white;
transition: transform 0.2s var(--ease);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--accent);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(16px);
}
.toggle-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 500;
min-width: 45px;
}
/*****************************************************************************
* Config output
*****************************************************************************/
.conf-output-section {
flex: 1 1 0;
display: flex;
flex-direction: column;
}
}
.conf-server {
height: 100%;
width: 90%;
margin: 1rem;
> form {
min-height: 0;
background: var(--bg-output);
}
.conf-output-header {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.9rem;
background: rgba(255, 255, 255, 0.05);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
}
.conf-output-title {
font-family: var(--font-mono);
font-size: 10.5px;
color: rgba(255, 255, 255, 0.45);
letter-spacing: 0.05em;
}
.conf-client-name {
font-family: var(--font-mono);
font-size: 10.5px;
color: rgba(255, 255, 255, 0.7);
letter-spacing: 0.04em;
margin-left: auto;
margin-right: 0.6rem;
}
.conf-output {
flex: 1 1 0;
overflow-y: auto;
overflow-x: auto;
padding: 0.9rem 1rem;
margin: 0;
font-family: var(--font-mono);
font-size: 11.5px;
line-height: 1.7;
color: var(--text-output);
white-space: pre;
background: transparent;
border: none;
outline: none;
}
.conf-output::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.conf-output::-webkit-scrollbar-track {
background: transparent;
}
.conf-output::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12);
border-radius: 999px;
}
.form-section::-webkit-scrollbar {
width: 4px;
}
.form-section::-webkit-scrollbar-track {
background: transparent;
}
.form-section::-webkit-scrollbar-thumb {
background: var(--slate-200);
border-radius: 999px;
}
/*****************************************************************************
* Client tabs
*****************************************************************************/
.client-tabs-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.25rem 0;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.client-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
flex: 1;
}
.client-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
border: 1.5px solid var(--border-mid);
border-bottom: none;
background: var(--bg);
color: var(--text-secondary);
font-family: var(--font-ui);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s var(--ease);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: -1px;
}
.client-tab:hover {
background: var(--sage-50);
color: var(--accent);
}
.client-tab.active {
background: var(--bg-panel);
color: var(--accent);
border-color: var(--sage-300);
border-bottom-color: var(--bg-panel);
z-index: 1;
}
.tab-del {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
font-size: 13px;
line-height: 1;
color: var(--text-muted);
flex-shrink: 0;
margin-left: 2px;
transition:
background 0.1s,
color 0.1s;
}
.tab-del:hover {
background: var(--rose-100);
color: var(--rose-600);
}
/*****************************************************************************
* Buttons
*****************************************************************************/
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: var(--radius-sm);
border: 1.5px solid transparent;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s var(--ease);
letter-spacing: 0.01em;
white-space: nowrap;
}
.btn svg {
width: 13px;
height: 13px;
flex-shrink: 0;
}
.btn-primary {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
transform: translateY(-1px);
box-shadow: 0 3px 8px rgba(90, 143, 90, 0.3);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary.synced {
background: var(--sage-400);
animation: pulse-green 0.4s var(--ease);
}
@keyframes pulse-green {
0% {
box-shadow: 0 0 0 0 rgba(90, 143, 90, 0.5);
}
100% {
box-shadow: 0 0 0 8px rgba(90, 143, 90, 0);
}
}
#right-side-wrapper {
width: 60%;
.conf-client-form {
height: 100%;
width: 90%;
margin: 1rem;
> form {
display: flex;
flex-direction: column;
}
}
.conf-server {
height: 100%;
width: 90%;
margin: 1rem;
> form {
display: flex;
flex-direction: column;
}
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
border-color: var(--border-mid);
}
.conf-server-input {
min-height: 100%;
.btn-ghost:hover {
background: var(--sage-50);
color: var(--accent);
border-color: var(--sage-300);
}
.btn-ghost.copied {
color: var(--accent);
border-color: var(--sage-300);
background: var(--sage-50);
}
.btn-sm {
padding: 3px 9px;
font-size: 11px;
}
.btn-add {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-sm);
border: 1.5px dashed var(--sage-300);
background: transparent;
color: var(--accent);
font-family: var(--font-ui);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s var(--ease);
white-space: nowrap;
}
.btn-add svg {
width: 13px;
height: 13px;
}
.btn-add:hover {
background: var(--sage-50);
border-color: var(--accent);
transform: translateY(-1px);
}
/*****************************************************************************
* Empty state
*****************************************************************************/
.empty-client {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--text-muted);
text-align: center;
flex: 1;
}
.empty-client svg {
opacity: 0.4;
color: var(--sage-400);
}
.empty-client p {
font-size: 12.5px;
color: var(--text-muted);
}
/*****************************************************************************
* Toast
*****************************************************************************/
.toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
background: var(--slate-800);
color: white;
padding: 0.65rem 1.1rem;
border-radius: var(--radius-md);
font-size: 12.5px;
font-weight: 500;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
opacity: 0;
transform: translateY(8px);
transition:
opacity 0.25s var(--ease),
transform 0.25s var(--ease);
z-index: 1000;
}
.toast-show {
opacity: 1;
transform: translateY(0);
}
.toast-error {
background: var(--rose-600);
}
/*****************************************************************************
* Scrollbars (global Firefox fallback)
*****************************************************************************/
* {
scrollbar-width: thin;
scrollbar-color: var(--slate-200) transparent;
}

7
wireguard-icon.svg Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg xmlns="http://www.w3.org/2000/svg"
aria-label="WireGuard" role="img"
viewBox="0 0 512 512"><rect
width="512" height="512"
rx="15%"
fill="#88171a"/><path d="m238 53l35 8 0 2c-15 2-30-4-45-5 11 7 23 11 35 15-19 16-35-5-56 9 20 10 19 8 21 27-9 1-24 10-27 16 13 3 28 0 41 8-4 3-14 7-18 10 9 2 20-2 25 1 19 16 54 38 64 60 17 37-22 77-60 83-53 11-83 66-64 117 19 50 78 72 125 46 66-40 56-108 16-145-2-2-4-2-6 0-14 9-29 17-45 24 36 8 41 35 37 54-13 48-78 37-85-4-3-19 7-38 24-46 59-26 87-30 104-97 6-38-3-58-31-80-11-11-33-18-40-35-1-2 1-6 3-6 10-2 49-3 49-1 7 7 13-4 16-9-10-2-21-1-29-1-1 0-3-2-4-3 1 -1 3-2 4-2h41c0-7-9-17-18-19v3c-8 1-16-1-24-4-4-3-7-9-11-11-16-9-33-16-54-16-10 0-17 1-23 1zm74 30l3 3-4 2c-2 1-3 0-4-1-2-3 4-5 5-4zm-120 96c-54 33-51 109-3 139 4 2 6 2 8-1 12-15 23-22 36-30-25-4-38-16-37-33-4-60 83-54 74 2-2 10-8 19-16 25 27-6 47-21 55-48 2-8 2-19-2-26-30-44-75-53-115-28zm-62 195c16-7 33-10 49-13 1-13 5-26 13-36-30 0-55 20-62 49z" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB