Functional logic added
This commit is contained in:
68
README.md
Normal file
68
README.md
Normal 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
BIN
docs/Screenshot01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/Screenshot02.png
Normal file
BIN
docs/Screenshot02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
343
src/gui/gui.html
343
src/gui/gui.html
@@ -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>
|
||||
</div>
|
||||
<div class="conf-server">
|
||||
<form>
|
||||
<label>SERVER CONF</label>
|
||||
<br />
|
||||
<input
|
||||
class="conf-server-input"
|
||||
value="COMPLETE WORKING CONF HERE"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<!-- 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 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 />
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
</svg>
|
||||
Sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script type="module" src="gui.js"></script>
|
||||
<!-- 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="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
|
||||
id="s-iface"
|
||||
type="text"
|
||||
placeholder="wg0"
|
||||
class="mono-input"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
932
src/gui/gui.js
932
src/gui/gui.js
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/*****************************************************************************
|
||||
* 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);
|
||||
|
||||
@@ -1,69 +1,735 @@
|
||||
#gui-wrapper {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.side-wrapper {
|
||||
/*****************************************************************************
|
||||
* 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;
|
||||
justify-content: flex-start;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid black;
|
||||
gap: 2rem;
|
||||
margin: 0.3rem;
|
||||
}
|
||||
|
||||
#left-side-wrapper {
|
||||
/*****************************************************************************
|
||||
* 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;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*****************************************************************************
|
||||
* Panels
|
||||
*****************************************************************************/
|
||||
|
||||
.panel {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.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;
|
||||
min-height: 0;
|
||||
background: var(--bg-output);
|
||||
}
|
||||
|
||||
.conf-output-header {
|
||||
display: flex;
|
||||
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);
|
||||
}
|
||||
.conf-server {
|
||||
height: 100%;
|
||||
width: 90%;
|
||||
margin: 1rem;
|
||||
> form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
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
7
wireguard-icon.svg
Normal 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 |
Reference in New Issue
Block a user