Replace from-scratch rewrite with real ngoduykhanh/wireguard-ui fork

The from-scratch Go rewrite had unresolved bugs (missing go.sum, UI
404s, path issues) from being built without a working local Go
toolchain to verify against. Switching strategy: use the actual
upstream wireguard-ui codebase (proven, battle-tested single-server
manager) as the base, and extend it for multi-server support instead
of re-deriving everything from zero.

Kept our own installers (bootstrap.sh, update.sh, scripts/install.sh,
scripts/proxmox-install.sh) - these still apply, just need updating
to build/install the upstream module layout instead of the old
cmd/wireguard-ui-multi structure.

Module path intentionally left as upstream's own
(github.com/ngoduykhanh/wireguard-ui) for now to avoid touching every
internal import; revisit if this needs to be fully rebranded.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-07-10 18:34:44 +02:00
co-authored by Claude Sonnet 5
parent 6eeea65ede
commit 867dc7740a
79 changed files with 11951 additions and 2548 deletions
@@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Read(//usr/local/go/**)",
"Bash(apt list *)",
"Bash(chmod +x *)",
"Bash(git add *)"
]
}
}
+67
View File
@@ -0,0 +1,67 @@
# Contributing Guidelines
Thank you for your interest in contributing to my project. Whether it's a bug report, new feature, correction, or additional
documentation, I greatly value feedback and contributions from my community.
Please read through this document before submitting any issues or pull requests to ensure I have all the necessary
information to effectively respond to your bug report or contribution.
## Reporting Bugs/Feature Requests
I welcome you to use the GitHub issue tracker to report bugs or suggest features.
When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
- A reproducible test case or series of steps
- The version of my code being used
- Any modifications you've made relevant to the bug
- Anything unusual about your environment or deployment
## Contributing via Pull Requests
### Discussion of New Features
Before initiating the implementation of a new feature, I encourage contributors to open a discussion by creating a new GitHub issue. This allows me to provide feedback, share insights, and ensure alignment with the project's direction and save your time.
#### Process for Discussing New Features:
1. **Create an Issue:**
- Go to the "Issues" tab in the repository.
- Click on "New Issue."
- Clearly describe the proposed feature, its purpose, and potential benefits.
2. **Engage in Discussion:**
- Respond promptly to comments and feedback from the community.
- Be open to adjusting the feature based on collaborative input.
3. **Consensus Building:**
- Strive to reach a consensus on the proposed feature.
- Ensure alignment with the overall project vision.
### Bug Fixes and Improvements
For bug fixes, documentation improvements, and general enhancements, feel free to submit a pull request directly.
#### Pull Request Guidelines:
1. **Fork the Repository:**
- Fork the repository to your GitHub account.
2. **Create a Branch:**
- Create a new branch for your changes.
3. **Make Changes:**
- Make your changes and ensure they adhere to coding standards.
4. **Submit a Pull Request:**
- Submit a pull request to the main repository.
5. **Engage in Review:**
- Be responsive to feedback and address any requested changes.
6. **Merge Process:**
- Once approved, your changes will be merged into the main branch.
## Licensing
See the [LICENSE](LICENSE) file for my project's licensing.
+382
View File
@@ -448,3 +448,385 @@ Keine Commits in dieser Session.
- systemd/wireguard-ui-multi.service | 22 ++
---
## 2026-07-10 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 9a1d811 Add one-shot bootstrap installer script
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:32 17:32 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:32 17:32 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:34 17:34 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:35 17:35 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:38 17:38 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:40 17:40 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++
- scripts/bootstrap.sh | 71 ++++++++++
---
## 2026-07-10 17:41 17:42 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 18188f4 Move bootstrap installer to project root, document one-liner install
### Geänderte Dateien
- README.md | 25 +++++++++++++++++++++++--
- scripts/bootstrap.sh => bootstrap.sh | 0
---
## 2026-07-10 17:47 17:47 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 25 +++++++++++++++++++++++--
- scripts/bootstrap.sh => bootstrap.sh | 0
---
## 2026-07-10 17:48 17:48 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 6e0e681 Fix bootstrap.sh: go not found on PATH after install
### Geänderte Dateien
- bootstrap.sh | 8 ++++++++
---
## 2026-07-10 17:50 17:51 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- bootstrap.sh | 8 ++++++++
---
## 2026-07-10 17:52 17:52 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- bootstrap.sh | 8 ++++++++
---
## 2026-07-10 17:53 17:53 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- f333623 Run go mod tidy before build in all installers
### Geänderte Dateien
- bootstrap.sh | 1 +
- scripts/install.sh | 1 +
- scripts/proxmox-install.sh | 1 +
---
## 2026-07-10 17:54 17:54 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- bootstrap.sh | 1 +
- scripts/install.sh | 1 +
- scripts/proxmox-install.sh | 1 +
---
## 2026-07-10 17:55 17:55 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- bootstrap.sh | 1 +
- scripts/install.sh | 1 +
- scripts/proxmox-install.sh | 1 +
---
## 2026-07-10 17:57 17:57 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- bootstrap.sh | 1 +
- scripts/install.sh | 1 +
- scripts/proxmox-install.sh | 1 +
---
## 2026-07-10 17:58 17:59 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- b2b6b82 Add update.sh, persist Go on PATH via profile.d
### Geänderte Dateien
- README.md | 10 +++++++
- bootstrap.sh | 5 ++++
- scripts/install.sh | 8 ++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:05 18:05 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 +++++++
- bootstrap.sh | 5 ++++
- scripts/install.sh | 8 ++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:07 18:07 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:07 18:08 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:09 18:09 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:11 18:11 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:14 18:14 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:14 18:14 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:16 18:16 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- README.md | 10 ++++++++++
- bootstrap.sh | 5 +++++
- scripts/install.sh | 8 ++++++++
- update.sh | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-07-10 18:16 18:18 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 41894e6 Fix 404s: serve UI templates/static from configurable ui-root, not CWD
### Geänderte Dateien
- README.md | 15 +++++++++++++++
- bootstrap.sh | 71 ++++++++++++++++++++++++++++++++++++++++++++++-------------------------
- cmd/wireguard-ui-multi/main.go | 7 ++++---
- internal/api/router.go | 15 +++++++++++++--
- internal/api/ui_handlers.go | 8 +++-----
- scripts/install.sh | 9 ++++++++-
- scripts/proxmox-install.sh | 2 +-
- update.sh | 2 +-
---
## 2026-07-10 18:22 18:23 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
- 6eeea65 Fix "Text file busy" on reinstall while service is running
### Geänderte Dateien
- scripts/install.sh | 5 +++--
---
## 2026-07-10 18:27 18:27 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- scripts/install.sh | 5 +++--
---
## 2026-07-10 18:30 18:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** wireguard-ui-multi
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- scripts/install.sh | 5 +++--
---
+77
View File
@@ -0,0 +1,77 @@
# Build stage
FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.21-alpine3.19 AS builder
LABEL maintainer="Khanh Ngo <k@ndk.name>"
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG APP_VERSION=dev
ARG BUILD_TIME
ARG GIT_COMMIT
ARG BUILD_DEPENDENCIES="npm \
yarn"
# Get dependencies
RUN apk add --update --no-cache ${BUILD_DEPENDENCIES}
WORKDIR /build
# Add dependencies
COPY go.mod /build
COPY go.sum /build
COPY package.json /build
COPY yarn.lock /build
# Prepare assets
RUN yarn install --pure-lockfile --production && \
yarn cache clean
# Move admin-lte dist
RUN mkdir -p assets/dist/js assets/dist/css && \
cp /build/node_modules/admin-lte/dist/js/adminlte.min.js \
assets/dist/js/adminlte.min.js && \
cp /build/node_modules/admin-lte/dist/css/adminlte.min.css \
assets/dist/css/adminlte.min.css
# Move plugin assets
RUN mkdir -p assets/plugins && \
cp -r /build/node_modules/admin-lte/plugins/jquery/ \
/build/node_modules/admin-lte/plugins/fontawesome-free/ \
/build/node_modules/admin-lte/plugins/bootstrap/ \
/build/node_modules/admin-lte/plugins/icheck-bootstrap/ \
/build/node_modules/admin-lte/plugins/toastr/ \
/build/node_modules/admin-lte/plugins/jquery-validation/ \
/build/node_modules/admin-lte/plugins/select2/ \
/build/node_modules/jquery-tags-input/ \
assets/plugins/
# Add sources
COPY . /build
# Move custom assets
RUN cp -r /build/custom/ assets/
# Build
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-X 'main.appVersion=${APP_VERSION}' -X 'main.buildTime=${BUILD_TIME}' -X 'main.gitCommit=${GIT_COMMIT}'" -a -o wg-ui .
# Release stage
FROM alpine:3.19
RUN addgroup -S wgui && \
adduser -S -D -G wgui wgui
RUN apk --no-cache add ca-certificates wireguard-tools jq iptables
WORKDIR /app
RUN mkdir -p db
# Copy binary files
COPY --from=builder --chown=wgui:wgui /build/wg-ui .
RUN chmod +x wg-ui
COPY init.sh .
RUN chmod +x init.sh
EXPOSE 5000/tcp
ENTRYPOINT ["./init.sh"]
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2020 Khanh Ngo - k[at]ndk.name
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+198 -232
View File
@@ -1,280 +1,246 @@
# wireguard-ui-multi
![](https://github.com/ngoduykhanh/wireguard-ui/workflows/wireguard-ui%20build%20release/badge.svg)
Native Multi-Server-Verwaltungsoberfläche für WireGuard**ohne Docker**.
Im Gegensatz zum ursprünglichen `wireguard-ui`, das genau eine WireGuard-Instanz
verwaltet, kann `wireguard-ui-multi` mehrere unabhängige WireGuard-Interfaces
gleichzeitig verwalten (z. B. `wg-home`, `wg-rz`, `wg-winter`), jedes mit
eigenem Port, eigenem Adressbereich, eigenen Peers und eigenem Status.
# wireguard-ui
Zielumgebungen: Debian/Ubuntu, Proxmox LXC Container, generisches Linux mit
systemd. Betrieb als natives Go-Binary.
A web user interface to manage your WireGuard setup.
## Features
- **Multi-Server-Verwaltung**: beliebig viele WireGuard-Server, jeder mit
eigenem Interface-Namen, Port, Private/Public Key, Adressbereich, DNS, MTU
und Enabled/Disabled-Status (Tabelle `servers` in SQLite).
- **Peer-Verwaltung pro Server**: Peers gehören zu genau einem Server
(Fremdschlüssel `server_id`), inklusive Name, E-Mail, Public/Private/
Preshared Key, Allowed IPs, Endpoint, Persistent Keepalive, Enabled-Status
und optionalem Ablaufdatum (`expires_at`).
- **Automatische Config-Erzeugung**: Server-Configs werden nach
`/etc/wireguard/<interface>.conf` im Standard-`wg-quick`-Format geschrieben.
- **Service-Steuerung**: Start/Stop/Reload je Interface über `wg-quick up`,
`wg-quick down` und `wg syncconf` (Hot-Reload ohne Verbindungsabbruch),
Status-Abfrage über `wg show`.
- **QR-Code & Config-Download**: Peer-Konfiguration kann als `.conf`-Datei
heruntergeladen oder als QR-Code (PNG) angezeigt werden — Private Keys
verlassen den Server nur in dieser generierten Peer-Config, nie über die
UI/JSON-API.
- **REST-API** für Server- und Peer-Verwaltung (siehe unten) plus
Web-Dashboard.
- **Firewall-Vorbereitung**: optionale Lifecycle-Hook-Skripte
(`server-start`, `server-stop`, `peer-add`, `peer-remove`) in
`/etc/wireguard-manager/hooks/` sowie ein Generator für einen
Vorschlags-nftables-Ruleset pro Server (Port freigeben, Forwarding
Tunnel ↔ LAN-Interface).
- **Audit Log**: Tabelle `audit_log` protokolliert Aktionen mit Akteur,
Aktion, Ziel und Detail.
- **Sitzungsbasierte Authentifizierung** mit CSRF-Schutz: jede mutierende
Anfrage (POST/PUT/DELETE) benötigt einen gültigen Session-Cookie plus
den Header `X-CSRF-Token`.
- Optional HTTPS über `--tls-cert` / `--tls-key`.
- Friendly UI
- Authentication
- Manage extra client information (name, email, etc.)
- Retrieve client config using QR code / file / email / Telegram
## Installation
![wireguard-ui 0.3.7](https://user-images.githubusercontent.com/37958026/177041280-e3e7ca16-d4cf-4e95-9920-68af15e780dd.png)
### Hardware-Anforderungen
## Run WireGuard-UI
Betrieb selbst ist sehr genügsam (kleines Go-Binary + SQLite, kein Docker/JVM):
> ⚠️The default username and password are `admin`. Please change it to secure your setup.
- **Betrieb:** 1 vCPU, 128-256 MB RAM reichen locker
- **Build aus Quellcode:** mind. **1 GB RAM** während `go build` — das
`modernc.org/sqlite`-Package (reines Go, kein cgo, aber sehr großzügiger
generierter Code) sprengt den `go`-Compiler bei 512 MB LXC-RAM
(`signal: killed`, OOM-Killer). Bei 1 GB lief der Build durch.
- Nach dem Build kann der Container/Server wieder auf 256-512 MB reduziert
werden, falls Ressourcen knapp sind.
- Alternative ohne Build-RAM-Bedarf: fertiges Release-Binary nutzen, sobald
eine Release-Pipeline existiert (`bootstrap.sh` versucht das automatisch
zuerst und fällt nur bei Fehlschlag auf den Source-Build zurück).
### Using binary file
### Schnellinstallation (Einzeiler)
Download the binary file from the release page and run it directly on the host machine
Auf einem frischen Debian/Ubuntu-Host (als root), lädt und installiert alles
in einem Schritt (Abhängigkeiten, Go-Toolchain falls nötig, Quellcode, Build,
`install.sh`):
```
./wireguard-ui
```
### Using docker compose
The [examples/docker-compose](examples/docker-compose) folder contains example docker-compose files.
Choose the example which fits you the most, adjust the configuration for your needs, then run it like below:
```
docker-compose up
```
## Environment Variables
| Variable | Description | Default |
|-------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------|
| `BASE_PATH` | Set this variable if you run wireguard-ui under a subpath of your reverse proxy virtual host (e.g. /wireguard) | N/A |
| `BIND_ADDRESS` | The addresses that can access to the web interface and the port, use unix:///abspath/to/file.socket for unix domain socket. | 0.0.0.0:80 |
| `SESSION_SECRET` | The secret key used to encrypt the session cookies. Set this to a random value | N/A |
| `SESSION_SECRET_FILE` | Optional filepath for the secret key used to encrypt the session cookies. Leave `SESSION_SECRET` blank to take effect | N/A |
| `SESSION_MAX_DURATION` | Max time in days a remembered session is refreshed and valid. Non-refreshed session is valid for 7 days max, regardless of this setting. | 90 |
| `SUBNET_RANGES` | The list of address subdivision ranges. Format: `SR Name:10.0.1.0/24; SR2:10.0.2.0/24,10.0.3.0/24` Each CIDR must be inside one of the server interfaces. | N/A |
| `WGUI_USERNAME` | The username for the login page. Used for db initialization only | `admin` |
| `WGUI_PASSWORD` | The password for the user on the login page. Will be hashed automatically. Used for db initialization only | `admin` |
| `WGUI_PASSWORD_FILE` | Optional filepath for the user login password. Will be hashed automatically. Used for db initialization only. Leave `WGUI_PASSWORD` blank to take effect | N/A |
| `WGUI_PASSWORD_HASH` | The password hash for the user on the login page. (alternative to `WGUI_PASSWORD`). Used for db initialization only | N/A |
| `WGUI_PASSWORD_HASH_FILE` | Optional filepath for the user login password hash. (alternative to `WGUI_PASSWORD_FILE`). Used for db initialization only. Leave `WGUI_PASSWORD_HASH` blank to take effect | N/A |
| `WGUI_ENDPOINT_ADDRESS` | The default endpoint address used in global settings where clients should connect to. The endpoint can contain a port as well, useful when you are listening internally on the `WGUI_SERVER_LISTEN_PORT` port, but you forward on another port (ex 9000). Ex: myvpn.dyndns.com:9000 | Resolved to your public ip address |
| `WGUI_FAVICON_FILE_PATH` | The file path used as website favicon | Embedded WireGuard logo |
| `WGUI_DNS` | The default DNS servers (comma-separated-list) used in the global settings | `1.1.1.1` |
| `WGUI_MTU` | The default MTU used in global settings | `1450` |
| `WGUI_PERSISTENT_KEEPALIVE` | The default persistent keepalive for WireGuard in global settings | `15` |
| `WGUI_FIREWALL_MARK` | The default WireGuard firewall mark | `0xca6c` (51820) |
| `WGUI_TABLE` | The default WireGuard table value settings | `auto` |
| `WGUI_CONFIG_FILE_PATH` | The default WireGuard config file path used in global settings | `/etc/wireguard/wg0.conf` |
| `WGUI_LOG_LEVEL` | The default log level. Possible values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF` | `INFO` |
| `WG_CONF_TEMPLATE` | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/ngoduykhanh/wireguard-ui/blob/master/templates/wg.conf) | N/A |
| `EMAIL_FROM_ADDRESS` | The sender email address | N/A |
| `EMAIL_FROM_NAME` | The sender name | `WireGuard UI` |
| `SENDGRID_API_KEY` | The SendGrid api key | N/A |
| `SENDGRID_API_KEY_FILE` | Optional filepath for the SendGrid api key. Leave `SENDGRID_API_KEY` blank to take effect | N/A |
| `SMTP_HOSTNAME` | The SMTP IP address or hostname | `127.0.0.1` |
| `SMTP_PORT` | The SMTP port | `25` |
| `SMTP_USERNAME` | The SMTP username | N/A |
| `SMTP_PASSWORD` | The SMTP user password | N/A |
| `SMTP_PASSWORD_FILE` | Optional filepath for the SMTP user password. Leave `SMTP_PASSWORD` blank to take effect | N/A |
| `SMTP_AUTH_TYPE` | The SMTP authentication type. Possible values: `PLAIN`, `LOGIN`, `NONE` | `NONE` |
| `SMTP_ENCRYPTION` | The encryption method. Possible values: `NONE`, `SSL`, `SSLTLS`, `TLS`, `STARTTLS` | `STARTTLS` |
| `SMTP_HELO` | Hostname to use for the HELO message. smtp-relay.gmail.com needs this set to anything but `localhost` | `localhost` |
| `TELEGRAM_TOKEN` | Telegram bot token for distributing configs to clients | N/A |
| `TELEGRAM_ALLOW_CONF_REQUEST` | Allow users to get configs from the bot by sending a message | `false` |
| `TELEGRAM_FLOOD_WAIT` | Time in minutes before the next conf request is processed | `60` |
### Defaults for server configuration
These environment variables are used to control the default server settings used when initializing the database.
| Variable | Description | Default |
|-----------------------------------|-----------------------------------------------------------------------------------------------|-----------------|
| `WGUI_SERVER_INTERFACE_ADDRESSES` | The default interface addresses (comma-separated-list) for the WireGuard server configuration | `10.252.1.0/24` |
| `WGUI_SERVER_LISTEN_PORT` | The default server listen port | `51820` |
| `WGUI_SERVER_POST_UP_SCRIPT` | The default server post-up script | N/A |
| `WGUI_SERVER_POST_DOWN_SCRIPT` | The default server post-down script | N/A |
### Defaults for new clients
These environment variables are used to set the defaults used in `New Client` dialog.
| Variable | Description | Default |
|---------------------------------------------|-------------------------------------------------------------------------------------------------|-------------|
| `WGUI_DEFAULT_CLIENT_ALLOWED_IPS` | Comma-separated-list of CIDRs for the `Allowed IPs` field. (default ) | `0.0.0.0/0` |
| `WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS` | Comma-separated-list of CIDRs for the `Extra Allowed IPs` field. (default empty) | N/A |
| `WGUI_DEFAULT_CLIENT_USE_SERVER_DNS` | Boolean value [`0`, `f`, `F`, `false`, `False`, `FALSE`, `1`, `t`, `T`, `true`, `True`, `TRUE`] | `true` |
| `WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION` | Boolean value [`0`, `f`, `F`, `false`, `False`, `FALSE`, `1`, `t`, `T`, `true`, `True`, `TRUE`] | `true` |
### Docker only
These environment variables only apply to the docker container.
| Variable | Description | Default |
|-----------------------|---------------------------------------------------------------|---------|
| `WGUI_MANAGE_START` | Start/stop WireGuard when the container is started/stopped | `false` |
| `WGUI_MANAGE_RESTART` | Auto restart WireGuard when we Apply Config changes in the UI | `false` |
## Auto restart WireGuard daemon
WireGuard-UI only takes care of configuration generation. You can use systemd to watch for the changes and restart the
service. Following is an example:
### Using systemd
Create `/etc/systemd/system/wgui.service`
```bash
curl -fsSL https://gitea.perlbach24.de/scripte/wireguard-ui-multi/raw/branch/main/bootstrap.sh | bash
cd /etc/systemd/system/
cat << EOF > wgui.service
[Unit]
Description=Restart WireGuard
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/bin/systemctl restart wg-quick@wg0.service
[Install]
RequiredBy=wgui.path
EOF
```
Danach nur noch:
Create `/etc/systemd/system/wgui.path`
```bash
sudo systemctl enable --now wireguard-ui-multi.service
cd /etc/systemd/system/
cat << EOF > wgui.path
[Unit]
Description=Watch /etc/wireguard/wg0.conf for changes
[Path]
PathModified=/etc/wireguard/wg0.conf
[Install]
WantedBy=multi-user.target
EOF
```
Der Quellcode bleibt unter `/opt/wireguard-ui-multi-src` liegen; erneutes
Ausführen des Einzeilers aktualisiert die Installation.
Apply it
### Update
Für ein gezieltes Update (holt neuesten Code, baut neu, installiert neu und
startet den Dienst neu):
```bash
cd /opt/wireguard-ui-multi-src
sudo ./update.sh
```sh
systemctl enable wgui.{path,service}
systemctl start wgui.{path,service}
```
### Manuelle Installation
### Using openrc
#### 1. Aus dem Quellcode bauen
Create `/usr/local/bin/wgui` file and make it executable
```bash
go build -o wireguard-ui-multi ./cmd/wireguard-ui-multi
```sh
cd /usr/local/bin/
cat << EOF > wgui
#!/bin/sh
wg-quick down wg0
wg-quick up wg0
EOF
chmod +x wgui
```
#### 2. Installationsskript ausführen (als root)
Create `/etc/init.d/wgui` file and make it executable
```bash
sudo ./scripts/install.sh
```sh
cd /etc/init.d/
cat << EOF > wgui
#!/sbin/openrc-run
command=/sbin/inotifyd
command_args="/usr/local/bin/wgui /etc/wireguard/wg0.conf:w"
pidfile=/run/${RC_SVCNAME}.pid
command_background=yes
EOF
chmod +x wgui
```
Das Skript ist idempotent und:
Apply it
- kopiert die Binary nach `/usr/local/bin/wireguard-ui-multi`
- legt `/etc/wireguard-ui-multi`, `/var/lib/wireguard-ui-multi` und
`/etc/wireguard-manager/hooks` an
- installiert die systemd-Unit nach
`/etc/systemd/system/wireguard-ui-multi.service`
- setzt `chmod 0700` auf das Datenverzeichnis (dort liegt die SQLite-DB mit
Passwort-Hashes)
**Wichtig:** Das Skript startet den Dienst nicht automatisch. Danach manuell
aktivieren:
```bash
sudo systemctl enable --now wireguard-ui-multi.service
sudo systemctl status wireguard-ui-multi.service
sudo journalctl -u wireguard-ui-multi.service -f
```sh
rc-service wgui start
rc-update add wgui default
```
## Konfiguration
### Using Docker
Die Anwendung wird über Kommandozeilen-Flags konfiguriert (siehe
`cmd/wireguard-ui-multi/main.go`):
Set `WGUI_MANAGE_RESTART=true` to manage Wireguard interface restarts.
Using `WGUI_MANAGE_START=true` can also replace the function of `wg-quick@wg0` service, to start Wireguard at boot, by
running the container with `restart: unless-stopped`. These settings can also pick up changes to Wireguard Config File
Path, after restarting the container. Please make sure you have `--cap-add=NET_ADMIN` in your container config to make
this feature work.
| Flag | Default | Bedeutung |
|----------------|-------------------------------------------------------|-------------------------------------------------------|
| `--listen` | `:8443` | Listen-Adresse des Webservers |
| `--db` | `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db` | Pfad zur SQLite-Datenbankdatei |
| `--config-dir` | `/etc/wireguard` | Zielverzeichnis für generierte `wg-quick`-Configs |
| `--hooks-dir` | `/etc/wireguard-manager/hooks` | Verzeichnis mit optionalen Hook-Skripten |
| `--lan-iface` | `eth0` | LAN-Interface für die vorgeschlagenen nftables-Forward-Regeln |
| `--tls-cert` | (leer) | Pfad zum TLS-Zertifikat (aktiviert HTTPS zusammen mit `--tls-key`) |
| `--tls-key` | (leer) | Pfad zum TLS-Private-Key |
## Build
Die in `systemd/wireguard-ui-multi.service` hinterlegte `ExecStart`-Zeile
setzt `--db`, `--config-dir` und `--hooks-dir` bereits passend zur
Installationsstruktur.
### Build docker image
### Erststart / Admin-Passwort
Go to the project root directory and run the following command:
Beim allerersten Start (leere `users`-Tabelle) wird automatisch ein
`admin`-Benutzer mit einem zufällig erzeugten 32-stelligen Hex-Passwort
angelegt. Das Klartext-Passwort wird **genau einmal** auf `stderr`
ausgegeben (z. B. sichtbar via `journalctl -u wireguard-ui-multi.service`)
und danach nur noch als bcrypt-Hash in der Datenbank gespeichert. Nach dem
ersten Login sollte das Passwort umgehend geändert werden.
## LXC / Proxmox Hinweise
WireGuard benötigt Zugriff auf das `wireguard`-Kernelmodul des Hosts sowie
`CAP_NET_ADMIN` und Zugriff auf `/dev/net/tun` im Container:
- Auf dem **Proxmox-Host** muss das `wireguard`-Kernelmodul geladen sein
(`modprobe wireguard`; bei Bedarf `/etc/modules` ergänzen).
- Der LXC-Container sollte entweder **privilegiert** betrieben werden, oder
als unprivilegierter Container mit gezielten Lockerungen
(`lxc.cap.drop` ohne `net_admin`, `lxc.cgroup2.devices.allow: c 10:200 rwm`
für `/dev/net/tun`) konfiguriert werden. In der Praxis ist ein
privilegierter Container für WireGuard-Hosting deutlich unkomplizierter.
- `/dev/net/tun` muss im Container vorhanden und beschreibbar sein
(`ls -l /dev/net/tun`); ggf. per Bind-Mount/`lxc.mount.entry` durchreichen.
- Die systemd-Unit läuft als `root` mit `AmbientCapabilities=CAP_NET_ADMIN`,
weil sie `wg-quick`, `systemctl` und `nft` aufruft — diese Tools benötigen
in der Praxis root-Rechte im Container.
- Läuft `nftables` bereits als eigener Dienst im Container/Host, sollte der
von `wireguard-ui-multi` vorgeschlagene Ruleset (siehe unten) manuell in
die bestehende Regelbasis integriert statt blind angewendet werden, um
Konflikte mit vorhandenen Tabellen/Chains zu vermeiden.
## Server- & Peer-Verwaltung
**Server anlegen** (UI oder `POST /api/servers`): Name, Interface-Name
(z. B. `wg-home`), Listen-Port, Adressbereich (z. B. `10.20.22.0/24`), DNS,
MTU angeben. Private/Public Key werden serverseitig automatisch erzeugt.
**Server starten/stoppen/neuladen**: über die Dashboard-Buttons oder
`POST /api/servers/{id}/start|stop|reload`. Start schreibt zunächst die
`wg-quick`-Config nach `/etc/wireguard/<interface>.conf` und ruft dann
`wg-quick up <interface>` auf; Reload nutzt `wg syncconf` für einen
Hot-Reload ohne Tunnelabbruch.
**Peer hinzufügen** (UI oder `POST /api/server/{id}/peer`): Name, optional
E-Mail/Beschreibung und Ablaufdatum angeben — Schlüsselpaar und Preshared
Key werden automatisch generiert.
**Config/QR-Code abrufen**: `GET /api/server/{id}/peer/{peerid}/config`
liefert die fertige `.conf`-Datei zum Download, `GET
/api/server/{id}/peer/{peerid}/qrcode` liefert denselben Inhalt als
PNG-QR-Code zum Scannen mit der WireGuard-App.
### REST-API-Übersicht
```
POST /api/login
POST /api/logout
GET /api/servers
POST /api/servers
GET /api/servers/{id}
PUT /api/servers/{id}
DELETE /api/servers/{id}
POST /api/servers/{id}/start
POST /api/servers/{id}/stop
POST /api/servers/{id}/reload
GET /api/servers/{id}/config
GET /api/server/{id}/peers
POST /api/server/{id}/peer
DELETE /api/server/{id}/peer/{peerid}
GET /api/server/{id}/peer/{peerid}/config
GET /api/server/{id}/peer/{peerid}/qrcode
```sh
docker build --build-arg=GIT_COMMIT=$(git rev-parse --short HEAD) -t wireguard-ui .
```
Alle Endpunkte außer `/api/login` erfordern einen gültigen Session-Cookie;
mutierende Methoden (POST/PUT/DELETE) benötigen zusätzlich den Header
`X-CSRF-Token` mit dem beim Login ausgegebenen Token.
or
## Backup / Restore
Ein automatisiertes Backup-/Restore-Werkzeug ist aktuell **nicht**
implementiert. Für ein manuelles Backup genügt es, folgende Pfade zu
sichern:
- die SQLite-Datenbank: `/var/lib/wireguard-ui-multi/wireguard-ui-multi.db`
(enthält Server, Peers, Keys, Audit Log, Benutzer)
- die generierten Interface-Configs: `/etc/wireguard/*.conf`
- ggf. eigene Hook-Skripte: `/etc/wireguard-manager/hooks/`
Beispiel:
```bash
sudo tar czf wireguard-ui-multi-backup-$(date +%F).tar.gz \
/var/lib/wireguard-ui-multi/wireguard-ui-multi.db \
/etc/wireguard/*.conf \
/etc/wireguard-manager/hooks
```sh
docker compose build --build-arg=GIT_COMMIT=$(git rev-parse --short HEAD)
```
**Restore**: Dienst stoppen, Archiv an denselben Pfaden entpacken,
Berechtigungen prüfen (`chmod 0700` auf das Datenverzeichnis) und Dienst
wieder starten:
:information_source: A container image is available on [Docker Hub](https://hub.docker.com/r/ngoduykhanh/wireguard-ui)
which you can pull and use
```bash
sudo systemctl stop wireguard-ui-multi.service
sudo tar xzf wireguard-ui-multi-backup-YYYY-MM-DD.tar.gz -C /
sudo systemctl start wireguard-ui-multi.service
```
docker pull ngoduykhanh/wireguard-ui
````
### Build binary file
Prepare the assets directory
```sh
./prepare_assets.sh
```
## Migration von einer bestehenden wireguard-ui-Installation
Then build your executable
Für den Umstieg von einer klassischen Single-Interface-Installation
(`/etc/wireguard/wg0.conf`) ist ein Migrationswerkzeug vorgesehen, das eine
bestehende `wg0.conf` einliest und als ersten verwalteten Server samt seiner
Peers importiert. Damit lässt sich eine vorhandene WireGuard-Instanz
übernehmen, ohne bestehende Clients neu konfigurieren zu müssen. Details zum
genauen Ablauf und den Aufrufoptionen siehe die Implementierung im
`wireguard`-Package des Repos, sobald verfügbar; grundsätzlich gilt: vor der
Migration ein Backup der bestehenden `wg0.conf` anlegen.
```sh
go build -o wireguard-ui
```
## Sicherheitshinweise
## License
- **Private Keys werden nie im Frontend/JSON angezeigt** — sie werden
ausschließlich serverseitig in generierten `.conf`-Dateien bzw.
QR-Codes für einzelne Peers ausgeliefert.
- **HTTPS verwenden**: entweder direkt über `--tls-cert`/`--tls-key`, oder
die Anwendung hinter einem Reverse Proxy (nginx, Caddy, Traefik) mit
TLS-Terminierung betreiben. Ohne TLS gibt der Dienst beim Start eine
deutliche Warnung aus.
- **Standard-Admin-Passwort sofort ändern**: das beim Erststart einmalig
ausgegebene zufällige Passwort sollte direkt nach dem ersten Login
geändert werden.
- Mutierende API-Aufrufe erfordern einen gültigen Session-Cookie **und**
den CSRF-Header `X-CSRF-Token` — Clients/Skripte, die die API direkt
ansprechen, müssen sich zunächst über `/api/login` anmelden und den
zurückgegebenen Token mitführen.
- Die Datenverzeichnisse (`/var/lib/wireguard-ui-multi`) sollten
restriktive Berechtigungen (`0700`) behalten, da dort Schlüsselmaterial
und Passwort-Hashes liegen.
MIT. See [LICENSE](https://github.com/ngoduykhanh/wireguard-ui/blob/master/LICENSE).
## Support
If you like the project and want to support it, you can *buy me a coffee*
<a href="https://www.buymeacoffee.com/khanhngo" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/default-orange.png" alt="Buy Me A Coffee" height="41" width="174"></a>
View File
-142
View File
@@ -1,142 +0,0 @@
// Command wireguard-ui-multi runs the native multi-server WireGuard management UI.
package main
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/api"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard"
)
func main() {
var (
listen = flag.String("listen", ":8443", "address to listen on")
dbPath = flag.String("db", "/var/lib/wireguard-ui-multi/wireguard-ui-multi.db", "path to the sqlite database file")
configDir = flag.String("config-dir", "/etc/wireguard", "directory where wg-quick interface configs are written")
hooksDir = flag.String("hooks-dir", "/etc/wireguard-manager/hooks", "directory containing optional lifecycle hook scripts")
lanIface = flag.String("lan-iface", "eth0", "LAN interface used for nftables forward rules")
tlsCert = flag.String("tls-cert", "", "path to TLS certificate (optional; enables HTTPS together with -tls-key)")
tlsKey = flag.String("tls-key", "", "path to TLS private key (optional; enables HTTPS together with -tls-cert)")
uiRoot = flag.String("ui-root", "/usr/local/share/wireguard-ui-multi/ui", "directory containing the ui templates/ and static/ subdirectories")
)
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
if err := run(logger, *listen, *dbPath, *configDir, *hooksDir, *lanIface, *tlsCert, *tlsKey, *uiRoot); err != nil {
logger.Error("fatal", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger, listen, dbPath, configDir, hooksDir, lanIface, tlsCert, tlsKey, uiRoot string) error {
// Wire package-level config before anything touches the filesystem/wg-quick.
wireguard.ConfigDir = configDir
firewall.HooksDir = hooksDir
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
return fmt.Errorf("create db directory: %w", err)
}
db, err := database.Open(dbPath)
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()
if err := ensureAdminUser(db, logger); err != nil {
return fmt.Errorf("bootstrap admin user: %w", err)
}
a := api.New(db, logger, lanIface, uiRoot)
srv := &http.Server{
Addr: listen,
Handler: a.Routes(),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
serveErr := make(chan error, 1)
go func() {
useTLS := tlsCert != "" && tlsKey != ""
if useTLS {
logger.Info("starting HTTPS server", "listen", listen)
serveErr <- srv.ListenAndServeTLS(tlsCert, tlsKey)
} else {
logger.Warn("starting plain HTTP server — TLS is strongly recommended in production; set -tls-cert and -tls-key", "listen", listen)
serveErr <- srv.ListenAndServe()
}
}()
select {
case err := <-serveErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
case <-ctx.Done():
logger.Info("shutdown signal received, stopping server")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
}
logger.Info("server stopped")
return nil
}
// ensureAdminUser creates a default admin account with a random password on first
// run (i.e. when the users table is empty). The plaintext password is printed
// exactly once and never persisted — only its bcrypt hash is stored.
func ensureAdminUser(db *database.DB, logger *slog.Logger) error {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
return fmt.Errorf("count users: %w", err)
}
if count > 0 {
return nil
}
passwordBytes := make([]byte, 16)
if _, err := rand.Read(passwordBytes); err != nil {
return fmt.Errorf("generate password: %w", err)
}
password := hex.EncodeToString(passwordBytes)
hash, err := api.HashPassword(password)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
if _, err := db.Exec(`INSERT INTO users (username, password_hash) VALUES (?, ?)`, "admin", hash); err != nil {
return fmt.Errorf("insert admin user: %w", err)
}
fmt.Fprintln(os.Stderr, "================================================================")
fmt.Fprintln(os.Stderr, " First run: created default admin account")
fmt.Fprintln(os.Stderr, " username: admin")
fmt.Fprintf(os.Stderr, " password: %s\n", password)
fmt.Fprintln(os.Stderr, " This password is shown ONLY ONCE and is not stored anywhere in")
fmt.Fprintln(os.Stderr, " plaintext. Log in and change it immediately.")
fmt.Fprintln(os.Stderr, "================================================================")
logger.Info("created default admin user; see above for the one-time password")
return nil
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+145
View File
@@ -0,0 +1,145 @@
function renderClientList(data) {
$.each(data, function(index, obj) {
// render telegram button
let telegramButton = ''
if (obj.Client.telegram_userid) {
telegramButton = `<div class="btn-group">
<button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal"
data-target="#modal_telegram_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}">Telegram</button>
</div>`
}
let telegramHtml = "";
if (obj.Client.telegram_userid && obj.Client.telegram_userid.length > 0) {
telegramHtml = `<span class="info-box-text" style="display: none"><i class="fas fa-tguserid"></i>${obj.Client.telegram_userid}</span>`
}
// render client status css tag style
let clientStatusHtml = '>'
if (obj.Client.enabled) {
clientStatusHtml = `style="visibility: hidden;">`
}
// render client allocated ip addresses
let allocatedIpsHtml = "";
$.each(obj.Client.allocated_ips, function(index, obj) {
allocatedIpsHtml += `<small class="badge badge-secondary">${obj}</small>&nbsp;`;
})
// render client allowed ip addresses
let allowedIpsHtml = "";
$.each(obj.Client.allowed_ips, function(index, obj) {
allowedIpsHtml += `<small class="badge badge-secondary">${obj}</small>&nbsp;`;
})
let subnetRangesString = "";
if (obj.Client.subnet_ranges && obj.Client.subnet_ranges.length > 0) {
subnetRangesString = obj.Client.subnet_ranges.join(',')
}
let additionalNotesHtml = "";
if (obj.Client.additional_notes && obj.Client.additional_notes.length > 0) {
additionalNotesHtml = `<span class="info-box-text" style="display: none"><i class="fas fa-additional_notes"></i>${obj.Client.additional_notes.toUpperCase()}</span>`
}
// render client html content
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="client_${obj.Client.id}">
<div class="info-box">
<div class="overlay" id="paused_${obj.Client.id}"` + clientStatusHtml
+ `<i class="paused-client fas fa-3x fa-play" onclick="resumeClient('${obj.Client.id}')"></i>
</div>
<div class="info-box-content" style="overflow: hidden">
<div class="btn-group">
<a href="download?clientid=${obj.Client.id}" class="btn btn-outline-primary btn-sm">Download</a>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal"
data-target="#modal_qr_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}" ${obj.QRCode != "" ? '' : ' disabled'}>QR code</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal"
data-target="#modal_email_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}">Email</button>
</div>
${telegramButton}
<div class="btn-group">
<button type="button" class="btn btn-outline-danger btn-sm">More</button>
<button type="button" class="btn btn-outline-danger btn-sm dropdown-toggle dropdown-icon"
data-toggle="dropdown">
</button>
<div class="dropdown-menu" role="menu">
<a class="dropdown-item" href="#" data-toggle="modal"
data-target="#modal_edit_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}">Edit</a>
<a class="dropdown-item" href="#" data-toggle="modal"
data-target="#modal_pause_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}">Disable</a>
<a class="dropdown-item" href="#" data-toggle="modal"
data-target="#modal_remove_client" data-clientid="${obj.Client.id}"
data-clientname="${obj.Client.name}">Delete</a>
</div>
</div>
<hr>
<span class="info-box-text"><i class="fas fa-user"></i> ${obj.Client.name}</span>
<span class="info-box-text" style="display: none"><i class="fas fa-key"></i> ${obj.Client.public_key}</span>
<span class="info-box-text" style="display: none"><i class="fas fa-subnetrange"></i>${subnetRangesString}</span>
${telegramHtml}
${additionalNotesHtml}
<span class="info-box-text"><i class="fas fa-envelope"></i> ${obj.Client.email}</span>
<span class="info-box-text"><i class="fas fa-clock"></i>
${prettyDateTime(obj.Client.created_at)}</span>
<span class="info-box-text"><i class="fas fa-history"></i>
${prettyDateTime(obj.Client.updated_at)}</span>
<span class="info-box-text"><i class="fas fa-server" style="${obj.Client.use_server_dns ? "opacity: 1.0" : "opacity: 0.5"}"></i>
${obj.Client.use_server_dns ? 'DNS enabled' : 'DNS disabled'}</span>
<span class="info-box-text"><i class="fas fa-file"></i>
${obj.Client.additional_notes}</span>
<span class="info-box-text"><strong>IP Allocation</strong></span>`
+ allocatedIpsHtml
+ `<span class="info-box-text"><strong>Allowed IPs</strong></span>`
+ allowedIpsHtml
+`</div>
</div>
</div>`
// add the client html elements to the list
$('#client-list').append(html);
});
}
function renderUserList(data) {
$.each(data, function(index, obj) {
let clientStatusHtml = '>'
// render user html content
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="user_${obj.username}">
<div class="info-box">
<div class="info-box-content">
<div class="btn-group">
<button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#modal_edit_user" data-username="${obj.username}">Edit</button>
</div>
<div class="btn-group">
<button type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
data-target="#modal_remove_user" data-username="${obj.username}">Delete</button>
</div>
<hr>
<span class="info-box-text"><i class="fas fa-user"></i> ${obj.username}</span>
<span class="info-box-text"><i class="fas fa-terminal"></i> ${obj.admin? 'Administrator':'Manager'}</span>
</div>
</div>
</div>`
// add the user html elements to the list
$('#users-list').append(html);
});
}
function prettyDateTime(timeStr) {
const dt = new Date(timeStr);
const offsetMs = dt.getTimezoneOffset() * 60 * 1000;
const dateLocal = new Date(dt.getTime() - offsetMs);
return dateLocal.toISOString().slice(0, 19).replace(/-/g, "/").replace("T", " ");
}
+210
View File
@@ -0,0 +1,210 @@
var base_url = jQuery(".brand-link").attr('href');
if (base_url.substring(base_url.length - 1, base_url.length) != "/")
base_url = base_url + "/";
const wake_on_lan_new_template = '<div class="col-sm-4" id="{{ .Id }}">\n' +
'\t<div class="info-box">\n' +
'\t\t<div class="info-box-content">\n' +
'\t\t\t<div class="btn-group">\n' +
'\t\t\t\t<button type="button" class="btn btn-outline-success btn-sm"\n' +
'\t\t\t\t\t\tdata-mac-address="{{ .MacAddress }}">Wake On\n' +
'\t\t\t\t</button>\n' +
'\t\t\t\t<button type="button"\n' +
'\t\t\t\t\t\tclass="btn btn-outline-primary btn-sm btn_modify_wake_on_lan_host"\n' +
'\t\t\t\t\t\tdata-toggle="modal" data-target="#modal_wake_on_lan_host"\n' +
'\t\t\t\t\t\tdata-name="{{ .Name }}" data-mac-address="{{ .MacAddress }}">Edit\n' +
'\t\t\t\t</button>\n' +
'\t\t\t\t<button type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"\n' +
'\t\t\t\t\t\tdata-target="#modal_remove_wake_on_lan_host"\n' +
'\t\t\t\t\t\tdata-mac-address="{{ .MacAddress }}">Remove\n' +
'\t\t\t\t</button>\n' +
'\t\t\t</div>\n' +
'\t\t\t<hr>\n' +
'\t\t\t<span class="info-box-text"><i class="fas fa-address-card"></i> <span class="name">{{ .Name }}</span></span>\n' +
'\t\t\t<span class="info-box-text"><i class="fas fa-ethernet"></i> <span class="mac-address">{{ .MacAddress }}</span></span>\n' +
'\t\t\t<span class="info-box-text"><i class="fas fa-clock"></i> <span class="latest-used">Unused</span></span>\n' +
'\t\t</div>\n' +
'\t</div>\n' +
'</div>';
jQuery(function ($) {
$.validator.addMethod('mac', function (value, element) {
return this.optional(element) || /^([0-9A-F]{2}[:]){5}([0-9A-F]{2})$/.test(value);
}, 'Please enter a valid MAC Address.(uppercase letters and numbers, : only) ex: 00:AB:12:EF:DD:AA');
});
jQuery.each(["put", "delete"], function (i, method) {
jQuery[method] = function (url, data, callback, type) {
if (jQuery.isFunction(data)) {
type = type || callback;
callback = data;
data = undefined;
}
return jQuery.ajax({
url: url,
type: method,
dataType: type,
data: data,
success: callback,
contentType: 'application/json'
});
};
});
jQuery(function ($) {
let newHostHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;"><button style="" id="btn_new_wake_on_lan_host" type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#modal_wake_on_lan_host"><i class="nav-icon fas fa-plus"></i> New Host</button></div>';
$('h1').parents(".row").append(newHostHtml);
});
jQuery(function ($) {
$('.btn-outline-success').click(function () {
const $this = $(this);
$.put(base_url + 'wake_on_lan_host/' + $this.data('mac-address'), function (result) {
$this.parents('.info-box').find('.latest-used').text(prettyDateTime(result));
});
});
});
jQuery(function ($) {
let $modal_remove_wake_on_lan_host = $('#modal_remove_wake_on_lan_host');
let $remove_client_confirm = $('#remove_wake_on_host_confirm');
$modal_remove_wake_on_lan_host.on('show.bs.modal', function (event) {
const $btn = $(event.relatedTarget);
const $modal = $(this);
const $editBtn = $btn.parents('.btn-group').find('.btn_modify_wake_on_lan_host');
$modal.find('.modal-body').text("You are about to remove Wake On Lan Host " + $editBtn.data('name'));
$remove_client_confirm.val($editBtn.data('mac-address'));
})
$remove_client_confirm.click(function () {
const macAddress = $remove_client_confirm.val().replaceAll(":", "-");
$.delete(base_url + 'wake_on_lan_host/' + macAddress);
$('#' + macAddress).remove();
$modal_remove_wake_on_lan_host.modal('hide');
});
});
jQuery(function ($) {
$('.latest-used').each(function () {
const $this = $(this);
const timeText = $this.text().trim();
try {
if (timeText != "Unused") {
$this.text(prettyDateTime(timeText));
}
} catch (ex) {
console.log(timeText);
throw ex;
}
});
});
jQuery(function ($) {
let $modal_wake_on_lan_host = $("#modal_wake_on_lan_host");
let $name = $('#frm_wake_on_lan_host_name');
let $macAddress = $('#frm_wake_on_lan_host_mac_address');
let $oldMacAddress = $('#frm_wake_on_lan_host_old_mac_address');
let $contentRow = $('.content .row');
let $frm_wake_on_lan_host = $("#frm_wake_on_lan_host");
// https://jqueryvalidation.org/
let validator = $frm_wake_on_lan_host.validate({
submitHandler: function () {
let data = {
name: $name.val(),
mac_address: $macAddress.val().toUpperCase(),
old_mac_address: $oldMacAddress.val().toUpperCase()
};
$.ajax({
cache: false,
method: 'POST',
url: base_url + 'wake_on_lan_host',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (response) {
/** @type {string} */
let oldMacAddress = $oldMacAddress.val().toUpperCase();
if (oldMacAddress != '') {
let macAddress = response.MacAddress;
let name = response.Name;
let $container = $('#' + oldMacAddress.replaceAll(":", "-"));
if (macAddress != oldMacAddress) {
$container.attr('id', macAddress.replaceAll(":", "-"));
$container.find('.mac-address').text(macAddress);
$container.find('[data-mac-address]').data('mac-address', macAddress);
}
$container.find('.name').text(name);
$container.find('[data-name]').data('name', name);
} else {
const $template = $(
wake_on_lan_new_template
.replace(/{{ .Id }}/g, response.MacAddress.replaceAll(":", "-").toUpperCase())
.replace(/{{ .MacAddress }}/g, response.MacAddress.toUpperCase())
.replace(/{{ .Name }}/g, response.Name)
);
$contentRow.append($template);
}
$modal_wake_on_lan_host.modal('hide');
toastr.success('Wake on Lan Host Save successfully');
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
if (typeof (console) != 'undefined')
console.log(exception);
}
});
return false;
},
rules: {
name: {
required: true,
},
mac_address: {
required: true,
mac: true,
}
},
messages: {
name: {
required: "Please enter a name"
},
mac_address: {
required: "Please enter a Mac Address"
}
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element) {
$(element).addClass('is-invalid');
},
unhighlight: function (element) {
$(element).removeClass('is-invalid');
}
});
$modal_wake_on_lan_host.on('show.bs.modal', function (e) {
const $btn = $(e.relatedTarget);
validator.resetForm();
$macAddress.removeClass('is-invalid');
$name.val($btn.data('name'));
$macAddress.val($btn.data('mac-address'));
$oldMacAddress.val($btn.data('mac-address'));
});
});
+5
View File
@@ -0,0 +1,5 @@
# Ignore everything in this directory
*
# Except this file
!.gitignore
+27
View File
@@ -0,0 +1,27 @@
version: "3"
services:
wg:
build: .
#image: ngoduykhanh/wireguard-ui:latest
container_name: wgui
cap_add:
- NET_ADMIN
network_mode: host
environment:
- SENDGRID_API_KEY
- EMAIL_FROM_ADDRESS
- EMAIL_FROM_NAME
- SESSION_SECRET
- WGUI_USERNAME=alpha
- WGUI_PASSWORD=this-unusual-password
- WG_CONF_TEMPLATE
- WGUI_MANAGE_START=false
- WGUI_MANAGE_RESTART=false
logging:
driver: json-file
options:
max-size: 50m
volumes:
- ./db:/app/db
- /etc/wireguard:/etc/wireguard
+10
View File
@@ -0,0 +1,10 @@
package emailer
type Attachment struct {
Name string
Data []byte
}
type Emailer interface {
Send(toName string, to string, subject string, content string, attachments []Attachment) error
}
+54
View File
@@ -0,0 +1,54 @@
package emailer
import (
"encoding/base64"
"github.com/sendgrid/sendgrid-go"
"github.com/sendgrid/sendgrid-go/helpers/mail"
)
type SendgridApiMail struct {
apiKey string
fromName string
from string
}
func NewSendgridApiMail(apiKey, fromName, from string) *SendgridApiMail {
ans := SendgridApiMail{apiKey: apiKey, fromName: fromName, from: from}
return &ans
}
func (o *SendgridApiMail) Send(toName string, to string, subject string, content string, attachments []Attachment) error {
m := mail.NewV3Mail()
mailFrom := mail.NewEmail(o.fromName, o.from)
mailContent := mail.NewContent("text/html", content)
mailTo := mail.NewEmail(toName, to)
m.SetFrom(mailFrom)
m.AddContent(mailContent)
personalization := mail.NewPersonalization()
personalization.AddTos(mailTo)
personalization.Subject = subject
m.AddPersonalizations(personalization)
toAdd := make([]*mail.Attachment, 0, len(attachments))
for i := range attachments {
var att mail.Attachment
encoded := base64.StdEncoding.EncodeToString(attachments[i].Data)
att.SetContent(encoded)
att.SetType("text/plain")
att.SetFilename(attachments[i].Name)
att.SetDisposition("attachment")
toAdd = append(toAdd, &att)
}
m.AddAttachment(toAdd...)
request := sendgrid.GetRequest(o.apiKey, "/v3/mail/send", "https://api.sendgrid.com")
request.Method = "POST"
request.Body = mail.GetRequestBody(m)
_, err := sendgrid.API(request)
return err
}
+100
View File
@@ -0,0 +1,100 @@
package emailer
import (
"crypto/tls"
"fmt"
"strings"
"time"
mail "github.com/xhit/go-simple-mail/v2"
)
type SmtpMail struct {
hostname string
port int
username string
password string
smtpHelo string
authType mail.AuthType
encryption mail.Encryption
noTLSCheck bool
fromName string
from string
}
func authType(authType string) mail.AuthType {
switch strings.ToUpper(authType) {
case "PLAIN":
return mail.AuthPlain
case "LOGIN":
return mail.AuthLogin
default:
return mail.AuthNone
}
}
func encryptionType(encryptionType string) mail.Encryption {
switch strings.ToUpper(encryptionType) {
case "NONE":
return mail.EncryptionNone
case "SSL":
return mail.EncryptionSSL
case "SSLTLS":
return mail.EncryptionSSLTLS
case "TLS":
return mail.EncryptionTLS
default:
return mail.EncryptionSTARTTLS
}
}
func NewSmtpMail(hostname string, port int, username string, password string, SmtpHelo string, noTLSCheck bool, auth string, fromName, from string, encryption string) *SmtpMail {
ans := SmtpMail{hostname: hostname, port: port, username: username, password: password, smtpHelo: SmtpHelo, noTLSCheck: noTLSCheck, fromName: fromName, from: from, authType: authType(auth), encryption: encryptionType(encryption)}
return &ans
}
func addressField(address string, name string) string {
if name == "" {
return address
}
return fmt.Sprintf("%s <%s>", name, address)
}
func (o *SmtpMail) Send(toName string, to string, subject string, content string, attachments []Attachment) error {
server := mail.NewSMTPClient()
server.Host = o.hostname
server.Port = o.port
server.Authentication = o.authType
server.Username = o.username
server.Password = o.password
server.Helo = o.smtpHelo
server.Encryption = o.encryption
server.KeepAlive = false
server.ConnectTimeout = 10 * time.Second
server.SendTimeout = 10 * time.Second
if o.noTLSCheck {
server.TLSConfig = &tls.Config{InsecureSkipVerify: true}
}
smtpClient, err := server.Connect()
if err != nil {
return err
}
email := mail.NewMSG()
email.SetFrom(addressField(o.from, o.fromName)).
AddTo(addressField(to, toName)).
SetSubject(subject).
SetBody(mail.TextHTML, content)
for _, v := range attachments {
email.Attach(&mail.File{Name: v.Name, Data: v.Data})
}
err = email.Send(smtpClient)
return err
}
+30
View File
@@ -0,0 +1,30 @@
## Prerequisites
### Kernel Module
Depending on if the Wireguard kernel module is available on your system you have more or less choices which example to use.
You can check if the kernel modules are available via the following command:
```shell
modprobe wireguard
```
If the command exits successfully and doesn't print an error the kernel modules are available.
If it does error, you either have to install them manually (or activate if deactivated) or use an userspace implementation.
For an example of an userspace implementation, see _borigtun_.
### Credentials
Username and password for all examples is `admin` by default.
For security reasons it's highly recommended to change them before the first startup.
## Examples
- **[system](system.yml)**
If you have Wireguard already installed on your system and only want to run the UI in docker this might fit the most.
- **[linuxserver](linuxserver.yml)**
If you have the Wireguard kernel modules installed (included in the mainline kernel since version 5.6) but want it running inside of docker, this might fit the most.
- **[boringtun](boringtun.yml)**
If Wireguard kernel modules are not available, you can switch to an userspace implementation like [boringtun](https://github.com/cloudflare/boringtun).
+43
View File
@@ -0,0 +1,43 @@
version: "3"
services:
boringtun:
image: ghcr.io/ntkme/boringtun:edge
command:
- wg0
container_name: boringtun
# use the network of the 'wireguard-ui' service. this enables to show active clients in the status page
network_mode: service:wireguard-ui
cap_add:
- NET_ADMIN
volumes:
- /dev/net/tun:/dev/net/tun
- ./config:/etc/wireguard
wireguard-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wireguard-ui
cap_add:
- NET_ADMIN
environment:
- SENDGRID_API_KEY
- EMAIL_FROM_ADDRESS
- EMAIL_FROM_NAME
- SESSION_SECRET
- WGUI_USERNAME=admin
- WGUI_PASSWORD=admin
- WG_CONF_TEMPLATE
- WGUI_MANAGE_START=true
- WGUI_MANAGE_RESTART=true
logging:
driver: json-file
options:
max-size: 50m
volumes:
- ./db:/app/db
- ./config:/etc/wireguard
ports:
# port for wireguard-ui
- "5000:5000"
# port of the wireguard server. this must be set here as the `boringtun` container joins the network of this container and hasn't its own network over which it could publish the ports
- "51820:51820/udp"
+42
View File
@@ -0,0 +1,42 @@
version: "3"
services:
wireguard:
image: linuxserver/wireguard:latest
container_name: wireguard
cap_add:
- NET_ADMIN
volumes:
- ./config:/config
ports:
# port for wireguard-ui. this must be set here as the `wireguard-ui` container joins the network of this container and hasn't its own network over which it could publish the ports
- "5000:5000"
# port of the wireguard server
- "51820:51820/udp"
wireguard-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wireguard-ui
depends_on:
- wireguard
cap_add:
- NET_ADMIN
# use the network of the 'wireguard' service. this enables to show active clients in the status page
network_mode: service:wireguard
environment:
- SENDGRID_API_KEY
- EMAIL_FROM_ADDRESS
- EMAIL_FROM_NAME
- SESSION_SECRET
- WGUI_USERNAME=admin
- WGUI_PASSWORD=admin
- WG_CONF_TEMPLATE
- WGUI_MANAGE_START=true
- WGUI_MANAGE_RESTART=true
logging:
driver: json-file
options:
max-size: 50m
volumes:
- ./db:/app/db
- ./config:/etc/wireguard
+27
View File
@@ -0,0 +1,27 @@
version: "3"
services:
wireguard-ui:
image: ngoduykhanh/wireguard-ui:latest
container_name: wireguard-ui
cap_add:
- NET_ADMIN
# required to show active clients. with this set, you don't need to expose the ui port (5000) anymore
network_mode: host
environment:
- SENDGRID_API_KEY
- EMAIL_FROM_ADDRESS
- EMAIL_FROM_NAME
- SESSION_SECRET
- WGUI_USERNAME=admin
- WGUI_PASSWORD=admin
- WG_CONF_TEMPLATE
- WGUI_MANAGE_START=false
- WGUI_MANAGE_RESTART=false
logging:
driver: json-file
options:
max-size: 50m
volumes:
- ./db:/app/db
- /etc/wireguard:/etc/wireguard
+47 -4
View File
@@ -1,9 +1,52 @@
module gitea.perlbach24.de/scripte/wireguard-ui-multi
module github.com/ngoduykhanh/wireguard-ui
go 1.22
go 1.21
require (
github.com/NicoNex/echotron/v3 v3.27.0
github.com/glendc/go-external-ip v0.1.0
github.com/gorilla/sessions v1.2.2
github.com/labstack/echo-contrib v0.15.0
github.com/labstack/echo/v4 v4.11.4
github.com/labstack/gommon v0.4.2
github.com/rs/xid v1.5.0
github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d
github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8
github.com/sendgrid/sendgrid-go v3.14.0+incompatible
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.24.0
modernc.org/sqlite v1.30.1
github.com/xhit/go-simple-mail/v2 v2.16.0
golang.org/x/crypto v0.17.0
golang.org/x/mod v0.14.0
//golang.zx2c4.com/wireguard v0.0.20200121 // indirect
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c
gopkg.in/go-playground/validator.v9 v9.31.0
)
require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-test/deep v1.1.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/sendgrid/rest v2.6.9+incompatible // indirect
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.15.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
)
+188
View File
@@ -0,0 +1,188 @@
github.com/NicoNex/echotron/v3 v3.27.0 h1:iq4BLPO+Dz1JHjh2HPk0D0NldAZSYcAjaOicgYEhUzw=
github.com/NicoNex/echotron/v3 v3.27.0/go.mod h1:LpP5IyHw0y+DZUZMBgXEDAF9O8feXrQu7w7nlJzzoZI=
github.com/coreos/bbolt v1.3.1-coreos.6.0.20180223184059-4f5275f4ebbf/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/glendc/go-external-ip v0.1.0 h1:iX3xQ2Q26atAmLTbd++nUce2P5ht5P4uD4V7caSY/xg=
github.com/glendc/go-external-ip v0.1.0/go.mod h1:CNx312s2FLAJoWNdJWZ2Fpf5O4oLsMFwuYviHjS4uJE=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25 h1:EFT6MH3igZK/dIVqgGbTqWVvkZ7wJ5iGN03SVtvvdd8=
github.com/jcelliott/lumber v0.0.0-20160324203708-dd349441af25/go.mod h1:sWkGw/wsaHtRsT9zGQ/WyJCotGWG/Anow/9hsAcBWRw=
github.com/jessevdk/go-flags v0.0.0-20150816100521-1acbbaff2f34/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/native v0.0.0-20200817173448-b6b71def0850/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink v0.0.0-20190606172950-9527aa82566a/go.mod h1:Oz+70psSo5OFh8DBl0Zv2ACw7Esh6pPUphlvZG9x7uw=
github.com/jsimonetti/rtnetlink v0.0.0-20200117123717-f846d4f6c1f4/go.mod h1:WGuG/smIU4J/54PblvSbh+xvCZmpJnFgr3ds6Z55XMQ=
github.com/jsimonetti/rtnetlink v0.0.0-20201009170750-9c6f07d100c1/go.mod h1:hqoO/u39cqLeBLebZ8fWdE96O7FxrAsRYhnVOdgHxok=
github.com/jsimonetti/rtnetlink v0.0.0-20201216134343-bde56ed16391/go.mod h1:cR77jAZG3Y3bsb8hF6fHJbFoyFukLFOkQ98S0pQz3xw=
github.com/jsimonetti/rtnetlink v0.0.0-20201220180245-69540ac93943/go.mod h1:z4c53zj6Eex712ROyh8WI0ihysb5j2ROyV42iNogmAs=
github.com/jsimonetti/rtnetlink v0.0.0-20210122163228-8d122574c736/go.mod h1:ZXpIyOK59ZnN7J0BV99cZUPmsqDRZ3eq5X+st7u/oSA=
github.com/jsimonetti/rtnetlink v0.0.0-20210212075122-66c871082f2b/go.mod h1:8w9Rh8m+aHZIG69YPGGem1i5VzoyRC8nw2kA8B+ik5U=
github.com/labstack/echo-contrib v0.15.0 h1:9K+oRU265y4Mu9zpRDv3X+DGTqUALY6oRHCSZZKCRVU=
github.com/labstack/echo-contrib v0.15.0/go.mod h1:lei+qt5CLB4oa7VHTE0yEfQSEB9XTJI1LUqko9UWvo4=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo=
github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA=
github.com/mdlayher/netlink v1.0.0/go.mod h1:KxeJAFOFLG6AjpyDkQ/iIhxygIUKD+vcwqcnu43w/+M=
github.com/mdlayher/netlink v1.1.0/go.mod h1:H4WCitaheIsdF9yOYu8CFmCgQthAPIWZmcKp9uZHgmY=
github.com/mdlayher/netlink v1.1.1/go.mod h1:WTYpFb/WTvlRJAyKhZL5/uy69TDDpHHu2VZmb2XgV7o=
github.com/mdlayher/netlink v1.2.0/go.mod h1:kwVW1io0AZy9A1E2YYgaD4Cj+C+GPkU6klXCMzIJ9p8=
github.com/mdlayher/netlink v1.2.1/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU=
github.com/mdlayher/netlink v1.2.2-0.20210123213345-5cc92139ae3e/go.mod h1:bacnNlfhqHqqLo4WsYeXSqfyXkInQ9JneWI68v1KwSU=
github.com/mdlayher/netlink v1.3.0/go.mod h1:xK/BssKuwcRXHrtN04UBkwQ6dY9VviGGuriDdoPSWys=
github.com/mdlayher/netlink v1.4.0/go.mod h1:dRJi5IABcZpBD2A3D0Mv/AiX8I9uDEu5oGkAVrekmf8=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sabhiram/go-colorize v0.0.0-20210403184538-366f55d711cf/go.mod h1:GvlEbMJBpbAXFn06UajbdBlGZ18iLvHyuIrgG//L8uk=
github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d h1:NDtoSmsxTpDYTqvUurn2ooAzDaYbJSB9/tOhLzaewgo=
github.com/sabhiram/go-wol v0.0.0-20211224004021-c83b0c2f887d/go.mod h1:SVPBBd492Gk7Cq5lPd6OAYtIGk2r1FsyH8KT3IB8h7c=
github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8 h1:hlNRl87eAZhh2QMJVShuXHL6OOd0ObZM0JozDIruNeM=
github.com/sdomino/scribble v0.0.0-20230717151034-b95d4df19aa8/go.mod h1:W6zxGUBCXRR5QugSd/nFcFVmwoGnvpjiNY/JwT03Wew=
github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0=
github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE=
github.com/sendgrid/sendgrid-go v3.14.0+incompatible h1:KDSasSTktAqMJCYClHVE94Fcif2i7P7wzISv1sU6DUA=
github.com/sendgrid/sendgrid-go v3.14.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v0.0.0-20150929183540-2b15294402a8/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210503195802-e9a32991a82e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191007182048-72f939374954/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210504132125-bbd867fde50d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201118182958-a01c418693c7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201218084310-7d0127a74742/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210110051926-789bb1bd4061/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210123111255-9b0068b26619/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210216163648-f7da38b97c65/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210309040221-94ec62e08169/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b h1:XDLXhn7ryprJVo+Lpkiib6CIuXE2031GDwtfEm7vLjI=
golang.zx2c4.com/wireguard v0.0.0-20210427022245-097af6e1351b/go.mod h1:a057zjmoc00UN7gVkaJt2sXVK523kMJcogDTEvPIasg=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c h1:ADNrRDI5NR23/TUCnEmlLZLt4u9DnZ2nwRkPrAcFvto=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20210803171230-4253848d036c/go.mod h1:+1XihzyZUBJcSc5WO9SwNA7v26puQwOEDwanaxfNXPQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M=
gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+20
View File
@@ -0,0 +1,20 @@
package handler
import (
"net/http"
"github.com/labstack/echo/v4"
)
// ContentTypeJson checks that the requests have the Content-Type header set to "application/json".
// This helps against CSRF attacks.
func ContentTypeJson(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
contentType := c.Request().Header.Get("Content-Type")
if contentType != "application/json" {
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Only JSON allowed"})
}
return next(c)
}
}
+6
View File
@@ -0,0 +1,6 @@
package handler
type jsonHTTPResponse struct {
Status bool `json:"status"`
Message string `json:"message"`
}
+1196
View File
File diff suppressed because it is too large Load Diff
+172
View File
@@ -0,0 +1,172 @@
package handler
import (
"fmt"
"net"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/sabhiram/go-wol/wol"
)
type WakeOnLanHostSavePayload struct {
Name string `json:"name"`
MacAddress string `json:"mac_address"`
OldMacAddress string `json:"old_mac_address"`
}
func createError(c echo.Context, err error, msg string) error {
log.Error(msg, err)
return c.JSON(
http.StatusInternalServerError,
jsonHTTPResponse{
false,
msg})
}
func GetWakeOnLanHosts(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var err error
hosts, err := db.GetWakeOnLanHosts()
if err != nil {
return createError(c, err, fmt.Sprintf("wake_on_lan_hosts database error: %s", err))
}
err = c.Render(http.StatusOK, "wake_on_lan_hosts.html", map[string]interface{}{
"baseData": model.BaseData{Active: "wake_on_lan_hosts", CurrentUser: currentUser(c), Admin: isAdmin(c)},
"hosts": hosts,
"error": "",
})
if err != nil {
return createError(c, err, fmt.Sprintf("wake_on_lan_hosts.html render error: %s", err))
}
return nil
}
}
func SaveWakeOnLanHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var payload WakeOnLanHostSavePayload
err := c.Bind(&payload)
if err != nil {
log.Error("Wake On Host Save Payload Bind Error: ", err)
return c.JSON(http.StatusInternalServerError, payload)
}
var host = model.WakeOnLanHost{
MacAddress: payload.MacAddress,
Name: payload.Name,
}
if len(payload.OldMacAddress) != 0 { // Edit
if payload.OldMacAddress != payload.MacAddress { // modified mac address
oldHost, err := db.GetWakeOnLanHost(payload.OldMacAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err))
}
if payload.OldMacAddress != payload.MacAddress {
existHost, _ := db.GetWakeOnLanHost(payload.MacAddress)
if existHost != nil {
return createError(c, nil, "Mac Address already exists.")
}
}
err = db.DeleteWakeOnHostLanHost(payload.OldMacAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Update Err: %s", err))
}
host.LatestUsed = oldHost.LatestUsed
}
err = db.SaveWakeOnLanHost(host)
} else { // new
existHost, _ := db.GetWakeOnLanHost(payload.MacAddress)
if existHost != nil {
return createError(c, nil, "Mac Address already exists.")
}
err = db.SaveWakeOnLanHost(host)
}
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Save Error: %s", err))
}
return c.JSON(http.StatusOK, host)
}
}
func DeleteWakeOnHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
var macAddress = c.Param("mac_address")
var host, err = db.GetWakeOnLanHost(macAddress)
if err != nil {
log.Error("Wake On Host Delete Error: ", err)
return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress))
}
err = db.DeleteWakeOnHost(*host)
if err != nil {
return createError(c, err, fmt.Sprintf("Wake On Host Delete Error: %s", macAddress))
}
return c.JSON(http.StatusOK, nil)
}
}
func WakeOnHost(db store.IStore) echo.HandlerFunc {
return func(c echo.Context) error {
macAddress := c.Param("mac_address")
host, err := db.GetWakeOnLanHost(macAddress)
now := time.Now().UTC()
host.LatestUsed = &now
err = db.SaveWakeOnLanHost(*host)
if err != nil {
return createError(c, err, fmt.Sprintf("Latest Used Update Error: %s", macAddress))
}
magicPacket, err := wol.New(macAddress)
if err != nil {
return createError(c, err, fmt.Sprintf("Magic Packet Create Error: %s", macAddress))
}
bytes, err := magicPacket.Marshal()
if err != nil {
return createError(c, err, fmt.Sprintf("Magic Packet Bytestream Error: %s", macAddress))
}
udpAddr, err := net.ResolveUDPAddr("udp", "255.255.255.255:0")
if err != nil {
return createError(c, err, fmt.Sprintf("ResolveUDPAddr Error: %s", macAddress))
}
// Grab a UDP connection to send our packet of bytes.
conn, err := net.DialUDP("udp", nil, udpAddr)
if err != nil {
return err
}
defer func(conn *net.UDPConn) {
err := conn.Close()
if err != nil {
log.Error(err)
}
}(conn)
n, err := conn.Write(bytes)
if err == nil && n != 102 {
return createError(c, nil, fmt.Sprintf("magic packet sent was %d bytes (expected 102 bytes sent)", n))
}
if err != nil {
return createError(c, err, fmt.Sprintf("Network Send Error: %s", macAddress))
}
return c.JSON(http.StatusOK, host.LatestUsed)
}
}
+249
View File
@@ -0,0 +1,249 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/ngoduykhanh/wireguard-ui/util"
)
func ValidSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !isValidSession(c) {
nextURL := c.Request().URL
if nextURL != nil && c.Request().Method == http.MethodGet {
return c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf(util.BasePath+"/login?next=%s", c.Request().URL))
} else {
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/login")
}
}
return next(c)
}
}
// RefreshSession must only be used after ValidSession middleware
// RefreshSession checks if the session is eligible for the refresh, but doesn't check if it's fully valid
func RefreshSession(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
doRefreshSession(c)
return next(c)
}
}
func NeedsAdmin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !isAdmin(c) {
return c.Redirect(http.StatusTemporaryRedirect, util.BasePath+"/")
}
return next(c)
}
}
func isValidSession(c echo.Context) bool {
if util.DisableLogin {
return true
}
sess, _ := session.Get("session", c)
cookie, err := c.Cookie("session_token")
if err != nil || sess.Values["session_token"] != cookie.Value {
return false
}
// Check time bounds
createdAt := getCreatedAt(sess)
updatedAt := getUpdatedAt(sess)
maxAge := getMaxAge(sess)
// Temporary session is considered valid within 24h if browser is not closed before
// This value is not saved and is used as virtual expiration
if maxAge == 0 {
maxAge = 86400
}
expiration := updatedAt + int64(maxAge)
now := time.Now().UTC().Unix()
if updatedAt > now || expiration < now || createdAt+util.SessionMaxDuration < now {
return false
}
// Check if user still exists and unchanged
username := fmt.Sprintf("%s", sess.Values["username"])
userHash := getUserHash(sess)
if uHash, ok := util.DBUsersToCRC32[username]; !ok || userHash != uHash {
return false
}
return true
}
// Refreshes a "remember me" session when the user visits web pages (not API)
// Session must be valid before calling this function
// Refresh is performed at most once per 24h
func doRefreshSession(c echo.Context) {
if util.DisableLogin {
return
}
sess, _ := session.Get("session", c)
maxAge := getMaxAge(sess)
if maxAge <= 0 {
return
}
oldCookie, err := c.Cookie("session_token")
if err != nil || sess.Values["session_token"] != oldCookie.Value {
return
}
// Refresh no sooner than 24h
createdAt := getCreatedAt(sess)
updatedAt := getUpdatedAt(sess)
expiration := updatedAt + int64(getMaxAge(sess))
now := time.Now().UTC().Unix()
if updatedAt > now || expiration < now || now-updatedAt < 86_400 || createdAt+util.SessionMaxDuration < now {
return
}
cookiePath := util.GetCookiePath()
sess.Values["updated_at"] = now
sess.Options = &sessions.Options{
Path: cookiePath,
MaxAge: maxAge,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
sess.Save(c.Request(), c.Response())
cookie := new(http.Cookie)
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.Value = oldCookie.Value
cookie.MaxAge = maxAge
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
}
// Get time in seconds this session is valid without updating
func getMaxAge(sess *sessions.Session) int {
if util.DisableLogin {
return 0
}
maxAge := sess.Values["max_age"]
switch typedMaxAge := maxAge.(type) {
case int:
return typedMaxAge
default:
return 0
}
}
// Get a timestamp in seconds of the time the session was created
func getCreatedAt(sess *sessions.Session) int64 {
if util.DisableLogin {
return 0
}
createdAt := sess.Values["created_at"]
switch typedCreatedAt := createdAt.(type) {
case int64:
return typedCreatedAt
default:
return 0
}
}
// Get a timestamp in seconds of the last session update
func getUpdatedAt(sess *sessions.Session) int64 {
if util.DisableLogin {
return 0
}
lastUpdate := sess.Values["updated_at"]
switch typedLastUpdate := lastUpdate.(type) {
case int64:
return typedLastUpdate
default:
return 0
}
}
// Get CRC32 of a user at the moment of log in
// Any changes to user will result in logout of other (not updated) sessions
func getUserHash(sess *sessions.Session) uint32 {
if util.DisableLogin {
return 0
}
userHash := sess.Values["user_hash"]
switch typedUserHash := userHash.(type) {
case uint32:
return typedUserHash
default:
return 0
}
}
// currentUser to get username of logged in user
func currentUser(c echo.Context) string {
if util.DisableLogin {
return ""
}
sess, _ := session.Get("session", c)
username := fmt.Sprintf("%s", sess.Values["username"])
return username
}
// isAdmin to get user type: admin or manager
func isAdmin(c echo.Context) bool {
if util.DisableLogin {
return true
}
sess, _ := session.Get("session", c)
admin := fmt.Sprintf("%t", sess.Values["admin"])
return admin == "true"
}
func setUser(c echo.Context, username string, admin bool, userCRC32 uint32) {
sess, _ := session.Get("session", c)
sess.Values["username"] = username
sess.Values["user_hash"] = userCRC32
sess.Values["admin"] = admin
sess.Save(c.Request(), c.Response())
}
// clearSession to remove current session
func clearSession(c echo.Context) {
sess, _ := session.Get("session", c)
sess.Values["username"] = ""
sess.Values["user_hash"] = 0
sess.Values["admin"] = false
sess.Values["session_token"] = ""
sess.Values["max_age"] = -1
sess.Options.MaxAge = -1
sess.Save(c.Request(), c.Response())
cookiePath := util.GetCookiePath()
cookie, err := c.Cookie("session_token")
if err != nil {
cookie = new(http.Cookie)
}
cookie.Name = "session_token"
cookie.Path = cookiePath
cookie.MaxAge = -1
cookie.HttpOnly = true
cookie.SameSite = http.SameSiteLaxMode
c.SetCookie(cookie)
}
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
# extract wg config file path, or use default
conf="$(jq -r .config_file_path db/server/global_settings.json || echo /etc/wireguard/wg0.conf)"
# manage wireguard stop/start with the container
case $WGUI_MANAGE_START in (1|t|T|true|True|TRUE)
wg-quick up "$conf"
trap 'wg-quick down "$conf"' SIGTERM # catches container stop
esac
# manage wireguard restarts
case $WGUI_MANAGE_RESTART in (1|t|T|true|True|TRUE)
[[ -f $conf ]] || touch "$conf" # inotifyd needs file to exist
inotifyd - "$conf":w | while read -r event file; do
wg-quick down "$file"
wg-quick up "$file"
done &
esac
./wg-ui &
wait $!
-139
View File
@@ -1,139 +0,0 @@
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionCookieName = "wgm_session"
const csrfCookieName = "wgm_csrf"
const sessionTTL = 12 * time.Hour
type session struct {
username string
csrf string
expiresAt time.Time
}
// SessionStore is a simple in-memory session store (single-process deployment).
type SessionStore struct {
mu sync.Mutex
sessions map[string]*session
}
func NewSessionStore() *SessionStore {
return &SessionStore{sessions: make(map[string]*session)}
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (s *SessionStore) Create(username string) (sessionToken, csrfToken string, err error) {
sessionToken, err = randomToken()
if err != nil {
return "", "", err
}
csrfToken, err = randomToken()
if err != nil {
return "", "", err
}
s.mu.Lock()
s.sessions[sessionToken] = &session{
username: username,
csrf: csrfToken,
expiresAt: time.Now().Add(sessionTTL),
}
s.mu.Unlock()
return sessionToken, csrfToken, nil
}
func (s *SessionStore) Get(token string) (*session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok || time.Now().After(sess.expiresAt) {
delete(s.sessions, token)
return nil, false
}
return sess, true
}
func (s *SessionStore) Delete(token string) {
s.mu.Lock()
delete(s.sessions, token)
s.mu.Unlock()
}
// HashPassword bcrypt-hashes a plaintext password for storage.
func HashPassword(pw string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
return string(b), err
}
// CheckPassword compares a plaintext password against a stored bcrypt hash.
func CheckPassword(hash, pw string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)) == nil
}
var ErrUnauthenticated = errors.New("unauthenticated")
// requireAuth resolves the session from the request cookie, or fails.
func (a *API) requireAuth(r *http.Request) (*session, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, ErrUnauthenticated
}
sess, ok := a.sessions.Get(c.Value)
if !ok {
return nil, ErrUnauthenticated
}
return sess, nil
}
// requireCSRF checks the X-CSRF-Token header against the session's csrf token,
// mandatory for all state-changing (non-GET) requests.
func requireCSRF(sess *session, r *http.Request) bool {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
return true
}
token := r.Header.Get("X-CSRF-Token")
return subtle.ConstantTimeCompare([]byte(token), []byte(sess.csrf)) == 1
}
func setSessionCookies(w http.ResponseWriter, sessionToken, csrfToken string) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionTTL.Seconds()),
})
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: csrfToken,
Path: "/",
HttpOnly: false, // readable by frontend JS to echo back in X-CSRF-Token header
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(sessionTTL.Seconds()),
})
}
func clearSessionCookies(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: csrfCookieName, Value: "", Path: "/", MaxAge: -1})
}
-484
View File
@@ -1,484 +0,0 @@
package api
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"net/http"
"strconv"
qrcode "github.com/skip2/go-qrcode"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/firewall"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
wg "gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/wireguard"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func idParam(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64)
}
// --- Auth ---
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (a *API) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
var hash string
err := a.db.QueryRow(`SELECT password_hash FROM users WHERE username = ?`, req.Username).Scan(&hash)
if errors.Is(err, sql.ErrNoRows) || (err == nil && !CheckPassword(hash, req.Password)) {
writeErr(w, http.StatusUnauthorized, "invalid credentials")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, "login failed")
return
}
sessionToken, csrfToken, err := a.sessions.Create(req.Username)
if err != nil {
writeErr(w, http.StatusInternalServerError, "could not create session")
return
}
setSessionCookies(w, sessionToken, csrfToken)
_ = a.db.LogAudit(req.Username, "login", "session", "")
writeJSON(w, http.StatusOK, map[string]string{"csrf_token": csrfToken})
}
func (a *API) handleLogout(w http.ResponseWriter, r *http.Request, sess *session) {
if c, err := r.Cookie(sessionCookieName); err == nil {
a.sessions.Delete(c.Value)
}
clearSessionCookies(w)
_ = a.db.LogAudit(sess.username, "logout", "session", "")
w.WriteHeader(http.StatusNoContent)
}
// --- Servers ---
func (a *API) handleListServers(w http.ResponseWriter, r *http.Request, _ *session) {
servers, err := a.store.ListServers()
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
type serverStatus struct {
*server.Server
Status wg.Status `json:"status"`
}
out := make([]serverStatus, 0, len(servers))
for _, s := range servers {
out = append(out, serverStatus{Server: s, Status: wg.GetStatus(s.InterfaceName)})
}
writeJSON(w, http.StatusOK, out)
}
type createServerRequest struct {
Name string `json:"name"`
InterfaceName string `json:"interface_name"`
ListenPort int `json:"listen_port"`
AddressRange string `json:"address_range"`
DNS string `json:"dns"`
MTU int `json:"mtu"`
}
func (a *API) handleCreateServer(w http.ResponseWriter, r *http.Request, sess *session) {
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.InterfaceName == "" || req.ListenPort == 0 || req.AddressRange == "" {
writeErr(w, http.StatusBadRequest, "name, interface_name, listen_port, address_range required")
return
}
if req.MTU == 0 {
req.MTU = 1420
}
priv, pub, err := wg.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "key generation failed")
return
}
srv := &server.Server{
Name: req.Name, InterfaceName: req.InterfaceName, ListenPort: req.ListenPort,
PrivateKey: priv, PublicKey: pub, AddressRange: req.AddressRange,
DNS: req.DNS, MTU: req.MTU, Enabled: true,
}
id, err := a.store.CreateServer(srv)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
srv.ID = id
if err := wg.WriteConfig(srv, nil); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.create", req.Name, "")
writeJSON(w, http.StatusCreated, srv)
}
func (a *API) handleGetServer(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
}
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, srv)
}
func (a *API) handleUpdateServer(w http.ResponseWriter, r *http.Request, sess *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
srv.Name, srv.AddressRange, srv.DNS = req.Name, req.AddressRange, req.DNS
if req.MTU > 0 {
srv.MTU = req.MTU
}
if req.ListenPort > 0 {
srv.ListenPort = req.ListenPort
}
if err := a.store.UpdateServer(srv); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
peers, _ := a.store.ListPeersByServer(srv.ID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.update", srv.Name, "")
writeJSON(w, http.StatusOK, srv)
}
func (a *API) handleDeleteServer(w http.ResponseWriter, r *http.Request, sess *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
_ = wg.Down(srv.InterfaceName)
if err := a.store.DeleteServer(id); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.db.LogAudit(sess.username, "server.delete", srv.Name, "")
w.WriteHeader(http.StatusNoContent)
}
func (a *API) handleStartServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.start", func(srv *server.Server) error {
if err := wg.Up(srv.InterfaceName); err != nil {
return err
}
return firewall.RunHook(firewall.HookServerStart, srv.InterfaceName)
})
}
func (a *API) handleStopServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.stop", func(srv *server.Server) error {
if err := wg.Down(srv.InterfaceName); err != nil {
return err
}
return firewall.RunHook(firewall.HookServerStop, srv.InterfaceName)
})
}
func (a *API) handleReloadServer(w http.ResponseWriter, r *http.Request, sess *session) {
a.serverAction(w, r, sess, "server.reload", func(srv *server.Server) error {
peers, err := a.store.ListPeersByServer(srv.ID)
if err != nil {
return err
}
if err := wg.WriteConfig(srv, peers); err != nil {
return err
}
return wg.Reload(srv.InterfaceName, wg.ConfigPath(srv))
})
}
func (a *API) serverAction(w http.ResponseWriter, r *http.Request, sess *session, action string, fn func(*server.Server) error) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := fn(srv); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
_ = a.db.LogAudit(sess.username, action, srv.Name, "")
writeJSON(w, http.StatusOK, map[string]string{"status": string(wg.GetStatus(srv.InterfaceName))})
}
func (a *API) handleDownloadServerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(id)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
peers, err := a.store.ListPeersByServer(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "attachment; filename="+srv.InterfaceName+".conf")
_, _ = w.Write([]byte(wg.RenderConfig(srv, peers)))
}
// --- Peers ---
func (a *API) handleListPeers(w http.ResponseWriter, r *http.Request, _ *session) {
id, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
peers, err := a.store.ListPeersByServer(id)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
// never expose private keys in listing responses
type safePeer struct {
*server.Peer
}
out := make([]map[string]any, 0, len(peers))
for _, p := range peers {
out = append(out, map[string]any{
"id": p.ID, "server_id": p.ServerID, "name": p.Name, "email": p.Email,
"public_key": p.PublicKey, "allowed_ips": p.AllowedIPs, "endpoint": p.Endpoint,
"persistent_keepalive": p.PersistentKeepalive, "enabled": p.Enabled,
"expires_at": p.ExpiresAt,
})
}
writeJSON(w, http.StatusOK, out)
}
type createPeerRequest struct {
Name string `json:"name"`
Email string `json:"email"`
AllowedIPs string `json:"allowed_ips"`
PersistentKeepalive int `json:"persistent_keepalive"`
UsePresharedKey bool `json:"use_preshared_key"`
}
func (a *API) handleCreatePeer(w http.ResponseWriter, r *http.Request, sess *session) {
serverID, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
srv, err := a.store.GetServer(serverID)
if errors.Is(err, server.ErrNotFound) {
writeErr(w, http.StatusNotFound, "server not found")
return
} else if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
var req createPeerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.AllowedIPs == "" {
writeErr(w, http.StatusBadRequest, "name and allowed_ips required")
return
}
if req.PersistentKeepalive == 0 {
req.PersistentKeepalive = 25
}
priv, pub, err := wg.GenerateKeyPair()
if err != nil {
writeErr(w, http.StatusInternalServerError, "key generation failed")
return
}
var psk string
if req.UsePresharedKey {
psk, err = wg.GeneratePresharedKey()
if err != nil {
writeErr(w, http.StatusInternalServerError, "psk generation failed")
return
}
}
p := &server.Peer{
ServerID: serverID, Name: req.Name, Email: req.Email, PublicKey: pub, PrivateKey: priv,
PresharedKey: psk, AllowedIPs: req.AllowedIPs, PersistentKeepalive: req.PersistentKeepalive,
Enabled: true,
}
id, err := a.store.CreatePeer(p)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
p.ID = id
peers, _ := a.store.ListPeersByServer(serverID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = firewall.RunHook(firewall.HookPeerAdd, srv.InterfaceName, p.PublicKey)
_ = a.db.LogAudit(sess.username, "peer.create", p.Name, "server="+srv.Name)
writeJSON(w, http.StatusCreated, p)
}
func (a *API) handleDeletePeer(w http.ResponseWriter, r *http.Request, sess *session) {
serverID, err := idParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid id")
return
}
peerID, err := idParam(r, "peerid")
if err != nil {
writeErr(w, http.StatusBadRequest, "invalid peer id")
return
}
srv, err := a.store.GetServer(serverID)
if err != nil {
writeErr(w, http.StatusNotFound, "server not found")
return
}
p, err := a.store.GetPeer(peerID)
if err != nil {
writeErr(w, http.StatusNotFound, "peer not found")
return
}
if err := a.store.DeletePeer(peerID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
peers, _ := a.store.ListPeersByServer(serverID)
if err := wg.WriteConfig(srv, peers); err != nil {
writeErr(w, http.StatusInternalServerError, "config write failed: "+err.Error())
return
}
_ = firewall.RunHook(firewall.HookPeerRemove, srv.InterfaceName, p.PublicKey)
_ = a.db.LogAudit(sess.username, "peer.delete", p.Name, "server="+srv.Name)
w.WriteHeader(http.StatusNoContent)
}
func (a *API) handleDownloadPeerConfig(w http.ResponseWriter, r *http.Request, _ *session) {
srv, p, err := a.loadServerAndPeer(r)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
host := r.URL.Query().Get("host")
if host == "" {
host = r.Host
}
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Disposition", "attachment; filename="+p.Name+".conf")
_, _ = w.Write([]byte(wg.RenderClientConfig(srv, p, host)))
}
func (a *API) handlePeerQRCode(w http.ResponseWriter, r *http.Request, _ *session) {
srv, p, err := a.loadServerAndPeer(r)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
host := r.URL.Query().Get("host")
if host == "" {
host = r.Host
}
png, err := qrcode.Encode(wg.RenderClientConfig(srv, p, host), qrcode.Medium, 256)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(bytes.NewBuffer(png).Bytes())
}
func (a *API) loadServerAndPeer(r *http.Request) (*server.Server, *server.Peer, error) {
serverID, err := idParam(r, "id")
if err != nil {
return nil, nil, errors.New("invalid id")
}
peerID, err := idParam(r, "peerid")
if err != nil {
return nil, nil, errors.New("invalid peer id")
}
srv, err := a.store.GetServer(serverID)
if err != nil {
return nil, nil, errors.New("server not found")
}
p, err := a.store.GetPeer(peerID)
if err != nil {
return nil, nil, errors.New("peer not found")
}
return srv, p, nil
}
-97
View File
@@ -1,97 +0,0 @@
package api
import (
"log/slog"
"net/http"
"path/filepath"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// API holds shared dependencies for HTTP handlers.
type API struct {
db *database.DB
store *server.Store
sessions *SessionStore
log *slog.Logger
lanIface string
uiRoot string
}
func New(db *database.DB, log *slog.Logger, lanIface, uiRoot string) *API {
return &API{
db: db,
store: server.NewStore(db),
sessions: NewSessionStore(),
log: log,
lanIface: lanIface,
uiRoot: uiRoot,
}
}
func (a *API) templatesDir() string {
return filepath.Join(a.uiRoot, "templates")
}
func (a *API) staticDir() string {
return filepath.Join(a.uiRoot, "static")
}
// Routes builds the full HTTP handler tree (API + UI), using Go 1.22 mux patterns.
func (a *API) Routes() http.Handler {
mux := http.NewServeMux()
// Auth
mux.HandleFunc("POST /api/login", a.handleLogin)
mux.HandleFunc("POST /api/logout", a.withAuth(a.handleLogout))
// Servers
mux.HandleFunc("GET /api/servers", a.withAuth(a.handleListServers))
mux.HandleFunc("POST /api/servers", a.withAuth(a.handleCreateServer))
mux.HandleFunc("GET /api/servers/{id}", a.withAuth(a.handleGetServer))
mux.HandleFunc("PUT /api/servers/{id}", a.withAuth(a.handleUpdateServer))
mux.HandleFunc("DELETE /api/servers/{id}", a.withAuth(a.handleDeleteServer))
mux.HandleFunc("POST /api/servers/{id}/start", a.withAuth(a.handleStartServer))
mux.HandleFunc("POST /api/servers/{id}/stop", a.withAuth(a.handleStopServer))
mux.HandleFunc("POST /api/servers/{id}/reload", a.withAuth(a.handleReloadServer))
mux.HandleFunc("GET /api/servers/{id}/config", a.withAuth(a.handleDownloadServerConfig))
// Peers
mux.HandleFunc("GET /api/server/{id}/peers", a.withAuth(a.handleListPeers))
mux.HandleFunc("POST /api/server/{id}/peer", a.withAuth(a.handleCreatePeer))
mux.HandleFunc("DELETE /api/server/{id}/peer/{peerid}", a.withAuth(a.handleDeletePeer))
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/config", a.withAuth(a.handleDownloadPeerConfig))
mux.HandleFunc("GET /api/server/{id}/peer/{peerid}/qrcode", a.withAuth(a.handlePeerQRCode))
// UI
mux.HandleFunc("GET /", a.handleDashboard)
mux.HandleFunc("GET /login", a.handleLoginPage)
mux.HandleFunc("GET /servers/{id}", a.handleServerPage)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(a.staticDir()))))
return a.logMiddleware(mux)
}
func (a *API) logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.log.Info("request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
// withAuth enforces a valid session and, for mutating requests, a matching CSRF token.
func (a *API) withAuth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, err := a.requireAuth(r)
if err != nil {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
if !requireCSRF(sess, r) {
http.Error(w, "invalid csrf token", http.StatusForbidden)
return
}
next(w, r, sess)
}
}
-39
View File
@@ -1,39 +0,0 @@
package api
import (
"net/http"
)
// hasSession reports whether the request carries a valid, non-expired session cookie.
func (a *API) hasSession(r *http.Request) bool {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return false
}
_, ok := a.sessions.Get(c.Value)
return ok
}
func (a *API) handleDashboard(w http.ResponseWriter, r *http.Request) {
if !a.hasSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.ServeFile(w, r, a.templatesDir()+"/dashboard.html")
}
func (a *API) handleLoginPage(w http.ResponseWriter, r *http.Request) {
if a.hasSession(r) {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.ServeFile(w, r, a.templatesDir()+"/login.html")
}
func (a *API) handleServerPage(w http.ResponseWriter, r *http.Request) {
if !a.hasSession(r) {
http.Redirect(w, r, "/login", http.StatusFound)
return
}
http.ServeFile(w, r, a.templatesDir()+"/server.html")
}
-85
View File
@@ -1,85 +0,0 @@
package database
import (
"database/sql"
"fmt"
_ "modernc.org/sqlite"
)
// DB wraps the sqlite connection used by the whole application.
type DB struct {
*sql.DB
}
const schema = `
CREATE TABLE IF NOT EXISTS servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
interface_name TEXT NOT NULL UNIQUE,
listen_port INTEGER NOT NULL,
private_key TEXT NOT NULL,
public_key TEXT NOT NULL,
address_range TEXT NOT NULL,
dns TEXT DEFAULT '',
mtu INTEGER DEFAULT 1420,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS peers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
email TEXT DEFAULT '',
public_key TEXT NOT NULL,
private_key TEXT DEFAULT '',
preshared_key TEXT DEFAULT '',
allowed_ips TEXT NOT NULL,
endpoint TEXT DEFAULT '',
persistent_keepalive INTEGER DEFAULT 25,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
detail TEXT DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_peers_server_id ON peers(server_id);
`
// Open opens (creating if needed) the sqlite database at path and applies schema.
func Open(path string) (*DB, error) {
sqlDB, err := sql.Open("sqlite", path+"?_pragma=foreign_keys(1)")
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := sqlDB.Exec(schema); err != nil {
sqlDB.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
return &DB{sqlDB}, nil
}
// LogAudit records an entry in the audit log.
func (db *DB) LogAudit(actor, action, target, detail string) error {
_, err := db.Exec(`INSERT INTO audit_log (actor, action, target, detail) VALUES (?, ?, ?, ?)`,
actor, action, target, detail)
return err
}
-75
View File
@@ -1,75 +0,0 @@
package firewall
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// HooksDir holds optional user-defined shell scripts run around lifecycle events.
var HooksDir = "/etc/wireguard-manager/hooks"
// HookEvent names the lifecycle points a hook script may exist for.
type HookEvent string
const (
HookServerStart HookEvent = "server-start"
HookServerStop HookEvent = "server-stop"
HookPeerAdd HookEvent = "peer-add"
HookPeerRemove HookEvent = "peer-remove"
)
// RunHook executes /etc/wireguard-manager/hooks/<event> if present and executable,
// passing iface (and optionally peer pubkey) as arguments. Missing hook is not an error.
func RunHook(event HookEvent, args ...string) error {
path := filepath.Join(HooksDir, string(event))
if _, err := os.Stat(path); err != nil {
return nil // hook not installed, skip silently
}
cmd := exec.Command(path, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("hook %s: %w: %s", event, err, out)
}
return nil
}
// NFTRuleset renders a suggested nftables ruleset snippet for a server, allowing
// its UDP listen port in and forwarding traffic between the tunnel and lanIface.
func NFTRuleset(srv *server.Server, lanIface string) string {
return fmt.Sprintf(`table inet wireguard_%s {
chain input {
type filter hook input priority 0; policy accept;
udp dport %d accept
}
chain forward {
type filter hook forward priority 0; policy accept;
iifname "%s" oifname "%s" accept
iifname "%s" oifname "%s" accept
}
}
`, srv.InterfaceName, srv.ListenPort, srv.InterfaceName, lanIface, lanIface, srv.InterfaceName)
}
// ApplyRuleset writes the ruleset to a temp file and loads it with `nft -f`.
func ApplyRuleset(srv *server.Server, lanIface string) error {
tmp, err := os.CreateTemp("", "wgm-nft-*.conf")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.WriteString(NFTRuleset(srv, lanIface)); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command("nft", "-f", tmp.Name())
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("nft -f: %w: %s", err, out)
}
return nil
}
-205
View File
@@ -1,205 +0,0 @@
package server
import (
"database/sql"
"errors"
"time"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/database"
)
// Server represents a single, independent WireGuard interface.
type Server struct {
ID int64
Name string
InterfaceName string
ListenPort int
PrivateKey string
PublicKey string
AddressRange string
DNS string
MTU int
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Peer represents a WireGuard client belonging to a Server.
type Peer struct {
ID int64
ServerID int64
Name string
Email string
PublicKey string
PrivateKey string
PresharedKey string
AllowedIPs string
Endpoint string
PersistentKeepalive int
Enabled bool
ExpiresAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
var ErrNotFound = errors.New("not found")
// Store provides CRUD access to servers and peers.
type Store struct {
db *database.DB
}
func NewStore(db *database.DB) *Store {
return &Store{db: db}
}
func (s *Store) CreateServer(srv *Server) (int64, error) {
res, err := s.db.Exec(`INSERT INTO servers
(name, interface_name, listen_port, private_key, public_key, address_range, dns, mtu, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdateServer(srv *Server) error {
_, err := s.db.Exec(`UPDATE servers SET
name = ?, interface_name = ?, listen_port = ?, private_key = ?, public_key = ?,
address_range = ?, dns = ?, mtu = ?, enabled = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
srv.Name, srv.InterfaceName, srv.ListenPort, srv.PrivateKey, srv.PublicKey,
srv.AddressRange, srv.DNS, srv.MTU, boolToInt(srv.Enabled), srv.ID)
return err
}
func (s *Store) DeleteServer(id int64) error {
_, err := s.db.Exec(`DELETE FROM servers WHERE id = ?`, id)
return err
}
func (s *Store) GetServer(id int64) (*Server, error) {
row := s.db.QueryRow(`SELECT id, name, interface_name, listen_port, private_key, public_key,
address_range, dns, mtu, enabled, created_at, updated_at FROM servers WHERE id = ?`, id)
return scanServer(row)
}
func (s *Store) ListServers() ([]*Server, error) {
rows, err := s.db.Query(`SELECT id, name, interface_name, listen_port, private_key, public_key,
address_range, dns, mtu, enabled, created_at, updated_at FROM servers ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Server
for rows.Next() {
srv, err := scanServerRows(rows)
if err != nil {
return nil, err
}
out = append(out, srv)
}
return out, rows.Err()
}
func (s *Store) CreatePeer(p *Peer) (int64, error) {
res, err := s.db.Exec(`INSERT INTO peers
(server_id, name, email, public_key, private_key, preshared_key, allowed_ips, endpoint,
persistent_keepalive, enabled, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.ServerID, p.Name, p.Email, p.PublicKey, p.PrivateKey, p.PresharedKey, p.AllowedIPs,
p.Endpoint, p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) UpdatePeer(p *Peer) error {
_, err := s.db.Exec(`UPDATE peers SET
name = ?, email = ?, public_key = ?, preshared_key = ?, allowed_ips = ?, endpoint = ?,
persistent_keepalive = ?, enabled = ?, expires_at = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`,
p.Name, p.Email, p.PublicKey, p.PresharedKey, p.AllowedIPs, p.Endpoint,
p.PersistentKeepalive, boolToInt(p.Enabled), p.ExpiresAt, p.ID)
return err
}
func (s *Store) DeletePeer(id int64) error {
_, err := s.db.Exec(`DELETE FROM peers WHERE id = ?`, id)
return err
}
func (s *Store) GetPeer(id int64) (*Peer, error) {
row := s.db.QueryRow(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
FROM peers WHERE id = ?`, id)
return scanPeer(row)
}
func (s *Store) ListPeersByServer(serverID int64) ([]*Peer, error) {
rows, err := s.db.Query(`SELECT id, server_id, name, email, public_key, private_key, preshared_key,
allowed_ips, endpoint, persistent_keepalive, enabled, expires_at, created_at, updated_at
FROM peers WHERE server_id = ? ORDER BY name`, serverID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*Peer
for rows.Next() {
p, err := scanPeerRows(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
type scanner interface {
Scan(dest ...any) error
}
func scanServer(row scanner) (*Server, error) {
var srv Server
var enabled int
if err := row.Scan(&srv.ID, &srv.Name, &srv.InterfaceName, &srv.ListenPort, &srv.PrivateKey,
&srv.PublicKey, &srv.AddressRange, &srv.DNS, &srv.MTU, &enabled, &srv.CreatedAt, &srv.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
srv.Enabled = enabled != 0
return &srv, nil
}
func scanServerRows(rows *sql.Rows) (*Server, error) { return scanServer(rows) }
func scanPeer(row scanner) (*Peer, error) {
var p Peer
var enabled int
if err := row.Scan(&p.ID, &p.ServerID, &p.Name, &p.Email, &p.PublicKey, &p.PrivateKey,
&p.PresharedKey, &p.AllowedIPs, &p.Endpoint, &p.PersistentKeepalive, &enabled,
&p.ExpiresAt, &p.CreatedAt, &p.UpdatedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
p.Enabled = enabled != 0
return &p, nil
}
func scanPeerRows(rows *sql.Rows) (*Peer, error) { return scanPeer(rows) }
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
-106
View File
@@ -1,106 +0,0 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
async function loadServers() {
const tbody = document.querySelector("#servers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/servers");
if (!res.ok) return;
const servers = await res.json();
for (const s of servers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
const link = document.createElement("a");
link.href = "/servers/" + s.ID;
link.textContent = s.Name;
nameTd.appendChild(link);
const ifaceTd = document.createElement("td");
ifaceTd.textContent = s.InterfaceName;
const portTd = document.createElement("td");
portTd.textContent = s.ListenPort;
const statusTd = document.createElement("td");
const badge = document.createElement("span");
badge.className = "badge " + (s.status === "UP" ? "up" : "down");
badge.textContent = s.status;
statusTd.appendChild(badge);
const actionsTd = document.createElement("td");
actionsTd.appendChild(makeActionButton("Start", () => serverAction(s.ID, "start")));
actionsTd.appendChild(makeActionButton("Stop", () => serverAction(s.ID, "stop")));
actionsTd.appendChild(makeActionButton("Reload", () => serverAction(s.ID, "reload")));
tr.appendChild(nameTd);
tr.appendChild(ifaceTd);
tr.appendChild(portTd);
tr.appendChild(statusTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function makeActionButton(label, onClick) {
const btn = document.createElement("button");
btn.textContent = label;
btn.className = "secondary";
btn.addEventListener("click", onClick);
return btn;
}
async function serverAction(id, action) {
await apiFetch("/api/servers/" + id + "/" + action, { method: "POST" });
loadServers();
}
document.getElementById("new-server").addEventListener("click", async () => {
const name = prompt("Name des Servers (z.B. WGhome):");
if (!name) return;
const interfaceName = prompt("Interface (z.B. wg-home):");
if (!interfaceName) return;
const listenPort = parseInt(prompt("Listen Port (z.B. 51822):"), 10);
if (!listenPort) return;
const addressRange = prompt("Address Range (z.B. 10.20.22.0/24):");
if (!addressRange) return;
const dns = prompt("DNS (optional):") || "";
const res = await apiFetch("/api/servers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name,
interface_name: interfaceName,
listen_port: listenPort,
address_range: addressRange,
dns: dns,
mtu: 1420,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
alert(data.error || "Server konnte nicht erstellt werden.");
return;
}
loadServers();
});
loadServers();
-25
View File
@@ -1,25 +0,0 @@
document.getElementById("login-form").addEventListener("submit", async function (e) {
e.preventDefault();
const errEl = document.getElementById("login-error");
errEl.textContent = "";
const form = e.target;
const username = form.username.value;
const password = form.password.value;
try {
const res = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Anmeldung fehlgeschlagen.";
return;
}
window.location.href = "/";
} catch (err) {
errEl.textContent = "Verbindung fehlgeschlagen.";
}
});
-173
View File
@@ -1,173 +0,0 @@
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return match ? decodeURIComponent(match[1]) : "";
}
async function apiFetch(url, options) {
options = options || {};
options.headers = options.headers || {};
if (options.method && options.method !== "GET") {
options.headers["X-CSRF-Token"] = getCookie("wgm_csrf");
}
const res = await fetch(url, options);
if (res.status === 401) {
window.location.href = "/login";
throw new Error("unauthenticated");
}
return res;
}
function serverIDFromPath() {
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[1];
}
const serverID = serverIDFromPath();
const errEl = document.getElementById("server-error");
async function loadServer() {
errEl.textContent = "";
const res = await apiFetch("/api/servers/" + serverID);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errEl.textContent = data.error || "Server konnte nicht geladen werden.";
return;
}
const s = await res.json();
document.getElementById("server-name").textContent = s.Name;
document.getElementById("d-interface").textContent = s.InterfaceName;
document.getElementById("d-port").textContent = s.ListenPort;
document.getElementById("d-address").textContent = s.AddressRange;
document.getElementById("d-dns").textContent = s.DNS || "-";
document.getElementById("d-mtu").textContent = s.MTU;
const statusRes = await apiFetch("/api/servers");
if (statusRes.ok) {
const servers = await statusRes.json();
const match = servers.find((x) => String(x.ID) === String(serverID));
const statusTd = document.getElementById("d-status");
statusTd.innerHTML = "";
const badge = document.createElement("span");
const status = match ? match.status : "DOWN";
badge.className = "badge " + (status === "UP" ? "up" : "down");
badge.textContent = status;
statusTd.appendChild(badge);
}
}
async function loadPeers() {
const tbody = document.querySelector("#peers tbody");
tbody.innerHTML = "";
const res = await apiFetch("/api/server/" + serverID + "/peers");
if (!res.ok) return;
const peers = await res.json();
for (const p of peers) {
const tr = document.createElement("tr");
const nameTd = document.createElement("td");
nameTd.textContent = p.name;
const emailTd = document.createElement("td");
emailTd.textContent = p.email || "-";
const allowedTd = document.createElement("td");
allowedTd.textContent = p.allowed_ips;
const enabledTd = document.createElement("td");
enabledTd.textContent = p.enabled ? "Ja" : "Nein";
const actionsTd = document.createElement("td");
const qrBtn = document.createElement("button");
qrBtn.textContent = "QR-Code";
qrBtn.className = "secondary";
qrBtn.addEventListener("click", () => showQRCode(p.id));
actionsTd.appendChild(qrBtn);
const dlLink = document.createElement("a");
dlLink.href = "/api/server/" + serverID + "/peer/" + p.id + "/config?host=" + encodeURIComponent(window.location.hostname);
dlLink.textContent = "Config";
dlLink.style.marginLeft = "0.5rem";
actionsTd.appendChild(dlLink);
const delBtn = document.createElement("button");
delBtn.textContent = "Löschen";
delBtn.className = "danger";
delBtn.addEventListener("click", () => deletePeer(p.id));
actionsTd.appendChild(delBtn);
tr.appendChild(nameTd);
tr.appendChild(emailTd);
tr.appendChild(allowedTd);
tr.appendChild(enabledTd);
tr.appendChild(actionsTd);
tbody.appendChild(tr);
}
}
function showQRCode(peerID) {
const modal = document.getElementById("qrcode-modal");
const img = document.getElementById("qrcode-img");
img.src = "/api/server/" + serverID + "/peer/" + peerID + "/qrcode?host=" + encodeURIComponent(window.location.hostname) + "&t=" + Date.now();
modal.classList.remove("hidden");
}
document.getElementById("qrcode-close").addEventListener("click", () => {
document.getElementById("qrcode-modal").classList.add("hidden");
});
async function deletePeer(peerID) {
if (!confirm("Peer wirklich löschen?")) return;
await apiFetch("/api/server/" + serverID + "/peer/" + peerID, { method: "DELETE" });
loadPeers();
}
document.getElementById("btn-start").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/start", { method: "POST" });
loadServer();
});
document.getElementById("btn-stop").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/stop", { method: "POST" });
loadServer();
});
document.getElementById("btn-reload").addEventListener("click", async () => {
await apiFetch("/api/servers/" + serverID + "/reload", { method: "POST" });
loadServer();
});
document.getElementById("btn-download").addEventListener("click", () => {
window.location.href = "/api/servers/" + serverID + "/config";
});
document.getElementById("peer-form").addEventListener("submit", async (e) => {
e.preventDefault();
const errP = document.getElementById("peer-error");
errP.textContent = "";
const form = e.target;
const body = {
name: form.name.value,
email: form.email.value,
allowed_ips: form.allowed_ips.value,
persistent_keepalive: parseInt(form.persistent_keepalive.value, 10) || 25,
use_preshared_key: form.use_preshared_key.checked,
};
const res = await apiFetch("/api/server/" + serverID + "/peer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
errP.textContent = data.error || "Peer konnte nicht erstellt werden.";
return;
}
form.reset();
form.persistent_keepalive.value = 25;
loadPeers();
});
loadServer();
loadPeers();
-209
View File
@@ -1,209 +0,0 @@
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
color: #1c1c1c;
background: #fafafa;
}
h1, h2 {
color: #222;
}
a {
color: #2563eb;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
background: #fff;
}
table.details {
width: auto;
min-width: 320px;
}
th, td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid #e2e2e2;
}
thead th {
background: #f0f0f0;
font-weight: 600;
}
tbody tr:hover {
background: #f7f7f7;
}
button {
cursor: pointer;
background: #2563eb;
color: #fff;
border: none;
border-radius: 4px;
padding: 0.4rem 0.8rem;
margin: 0.15rem;
font-size: 0.9rem;
}
button:hover {
background: #1d4ed8;
}
button.danger {
background: #dc2626;
}
button.danger:hover {
background: #b91c1c;
}
button.secondary {
background: #6b7280;
}
button.secondary:hover {
background: #4b5563;
}
.actions {
margin: 1rem 0;
}
form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
background: #fff;
padding: 1rem;
border: 1px solid #e2e2e2;
border-radius: 6px;
max-width: 480px;
}
form#login-form {
flex-direction: column;
align-items: stretch;
max-width: 320px;
margin: 3rem auto;
}
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"] {
padding: 0.4rem 0.6rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
label {
font-size: 0.9rem;
}
.error {
color: #dc2626;
font-size: 0.9rem;
min-height: 1.2em;
}
.badge {
display: inline-block;
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
color: #fff;
}
.badge.up {
background: #16a34a;
}
.badge.down {
background: #dc2626;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.modal.hidden {
display: none;
}
.modal-content {
background: #fff;
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.modal-content img {
display: block;
margin-top: 0.5rem;
max-width: 320px;
}
@media (prefers-color-scheme: dark) {
body {
background: #17181a;
color: #e6e6e6;
}
h1, h2 {
color: #f2f2f2;
}
table, form {
background: #212226;
}
thead th {
background: #2a2b30;
}
th, td {
border-bottom: 1px solid #33343a;
}
tbody tr:hover {
background: #26272c;
}
input {
background: #1b1c1f;
color: #e6e6e6;
border: 1px solid #3a3b41;
}
.modal-content {
background: #212226;
}
}
-19
View File
@@ -1,19 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Dashboard</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<h1>WireGuard Server</h1>
<table id="servers">
<thead>
<tr><th>Name</th><th>Interface</th><th>Port</th><th>Status</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<button id="new-server">Neuer Server</button>
<script src="/static/app.js"></script>
</body>
</html>
-18
View File
@@ -1,18 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Login</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<form id="login-form">
<h1>Anmelden</h1>
<input type="text" name="username" placeholder="Benutzername" required>
<input type="password" name="password" placeholder="Passwort" required>
<button type="submit">Login</button>
<p id="login-error" class="error"></p>
</form>
<script src="/static/login.js"></script>
</body>
</html>
-60
View File
@@ -1,60 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>wireguard-ui-multi — Server</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<p><a href="/">&larr; Zurück zum Dashboard</a></p>
<h1 id="server-name">Server</h1>
<p id="server-error" class="error"></p>
<table class="details">
<tbody>
<tr><th>Interface</th><td id="d-interface"></td></tr>
<tr><th>Port</th><td id="d-port"></td></tr>
<tr><th>Address Range</th><td id="d-address"></td></tr>
<tr><th>DNS</th><td id="d-dns"></td></tr>
<tr><th>MTU</th><td id="d-mtu"></td></tr>
<tr><th>Status</th><td id="d-status"></td></tr>
</tbody>
</table>
<div class="actions">
<button id="btn-start">Start</button>
<button id="btn-stop">Stop</button>
<button id="btn-reload">Neu laden</button>
<button id="btn-download">Config herunterladen</button>
</div>
<h2>Peers</h2>
<table id="peers">
<thead>
<tr><th>Name</th><th>Email</th><th>Allowed IPs</th><th>Aktiv</th><th>Aktionen</th></tr>
</thead>
<tbody></tbody>
</table>
<h2>Neuen Peer hinzufügen</h2>
<form id="peer-form">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email">
<input type="text" name="allowed_ips" placeholder="Allowed IPs, z.B. 10.20.22.5/32" required>
<input type="number" name="persistent_keepalive" placeholder="Persistent Keepalive (s)" value="25">
<label><input type="checkbox" name="use_preshared_key"> Preshared Key verwenden</label>
<button type="submit">Peer hinzufügen</button>
<p id="peer-error" class="error"></p>
</form>
<div id="qrcode-modal" class="modal hidden">
<div class="modal-content">
<button id="qrcode-close">Schließen</button>
<img id="qrcode-img" alt="QR Code">
</div>
</div>
<script src="/static/server.js"></script>
</body>
</html>
-86
View File
@@ -1,86 +0,0 @@
package wireguard
import (
"fmt"
"os"
"path/filepath"
"strings"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// ConfigDir is where per-interface wgX.conf files are written, e.g. /etc/wireguard.
var ConfigDir = "/etc/wireguard"
// RenderConfig builds the wg-quick compatible config text for a server and its peers.
func RenderConfig(srv *server.Server, peers []*server.Peer) string {
var b strings.Builder
fmt.Fprintf(&b, "[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", srv.PrivateKey)
fmt.Fprintf(&b, "Address = %s\n", srv.AddressRange)
fmt.Fprintf(&b, "ListenPort = %d\n", srv.ListenPort)
if srv.MTU > 0 {
fmt.Fprintf(&b, "MTU = %d\n", srv.MTU)
}
if srv.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
}
for _, p := range peers {
if !p.Enabled {
continue
}
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "# %s\n", p.Name)
fmt.Fprintf(&b, "PublicKey = %s\n", p.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.AllowedIPs)
if p.PersistentKeepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
}
}
return b.String()
}
// RenderClientConfig builds the config a peer/client would use to connect to srv.
func RenderClientConfig(srv *server.Server, p *server.Peer, endpointHost string) string {
var b strings.Builder
b.WriteString("[Interface]\n")
fmt.Fprintf(&b, "PrivateKey = %s\n", p.PrivateKey)
fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs)
if srv.DNS != "" {
fmt.Fprintf(&b, "DNS = %s\n", srv.DNS)
}
b.WriteString("\n[Peer]\n")
fmt.Fprintf(&b, "PublicKey = %s\n", srv.PublicKey)
if p.PresharedKey != "" {
fmt.Fprintf(&b, "PresharedKey = %s\n", p.PresharedKey)
}
fmt.Fprintf(&b, "Endpoint = %s:%d\n", endpointHost, srv.ListenPort)
fmt.Fprintf(&b, "AllowedIPs = 0.0.0.0/0, ::/0\n")
if p.PersistentKeepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", p.PersistentKeepalive)
}
return b.String()
}
// WriteConfig writes the rendered server config to ConfigDir/<interface>.conf with 0600 perms.
func WriteConfig(srv *server.Server, peers []*server.Peer) error {
if err := os.MkdirAll(ConfigDir, 0700); err != nil {
return err
}
path := filepath.Join(ConfigDir, srv.InterfaceName+".conf")
return os.WriteFile(path, []byte(RenderConfig(srv, peers)), 0600)
}
// ConfigPath returns the on-disk path for a server's config file.
func ConfigPath(srv *server.Server) string {
return filepath.Join(ConfigDir, srv.InterfaceName+".conf")
}
-47
View File
@@ -1,47 +0,0 @@
package wireguard
import (
"crypto/rand"
"encoding/base64"
"golang.org/x/crypto/curve25519"
)
// GenerateKeyPair creates a new WireGuard-compatible Curve25519 key pair,
// base64-encoded like `wg genkey` / `wg pubkey`.
func GenerateKeyPair() (privateKey, publicKey string, err error) {
var priv [32]byte
if _, err := rand.Read(priv[:]); err != nil {
return "", "", err
}
// Clamp per RFC 7748 / WireGuard convention.
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
var pub [32]byte
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
}
// PublicFromPrivate derives the public key for an existing base64 private key.
func PublicFromPrivate(privateKeyB64 string) (string, error) {
privBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return "", err
}
var priv, pub [32]byte
copy(priv[:], privBytes)
curve25519.ScalarBaseMult(&pub, &priv)
return base64.StdEncoding.EncodeToString(pub[:]), nil
}
// GeneratePresharedKey creates a random base64 preshared key.
func GeneratePresharedKey() (string, error) {
var key [32]byte
if _, err := rand.Read(key[:]); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(key[:]), nil
}
-78
View File
@@ -1,78 +0,0 @@
package wireguard
import (
"fmt"
"os/exec"
"strings"
)
// Status of a WireGuard interface.
type Status string
const (
StatusUp Status = "UP"
StatusDown Status = "DOWN"
)
// Up brings up the given interface via wg-quick.
func Up(iface string) error {
return run("wg-quick", "up", iface)
}
// Down brings down the given interface via wg-quick.
func Down(iface string) error {
return run("wg-quick", "down", iface)
}
// Reload applies config changes to a running interface without a full restart,
// using `wg syncconf` against a stripped config (wg-quick strip).
func Reload(iface, confPath string) error {
strip := exec.Command("wg-quick", "strip", confPath)
stripped, err := strip.Output()
if err != nil {
return fmt.Errorf("wg-quick strip: %w", err)
}
sync := exec.Command("wg", "syncconf", iface, "/dev/stdin")
sync.Stdin = strings.NewReader(string(stripped))
if out, err := sync.CombinedOutput(); err != nil {
return fmt.Errorf("wg syncconf: %w: %s", err, out)
}
return nil
}
// IsUp checks whether the interface currently exists / is up.
func IsUp(iface string) bool {
cmd := exec.Command("wg", "show", iface)
return cmd.Run() == nil
}
func GetStatus(iface string) Status {
if IsUp(iface) {
return StatusUp
}
return StatusDown
}
// EnableService enables and starts the systemd wg-quick@<iface>.service unit.
func EnableService(iface string) error {
if err := run("systemctl", "enable", "wg-quick@"+iface); err != nil {
return err
}
return run("systemctl", "start", "wg-quick@"+iface)
}
// DisableService stops and disables the systemd wg-quick@<iface>.service unit.
func DisableService(iface string) error {
if err := run("systemctl", "stop", "wg-quick@"+iface); err != nil {
return err
}
return run("systemctl", "disable", "wg-quick@"+iface)
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, out)
}
return nil
}
-203
View File
@@ -1,203 +0,0 @@
package wireguard
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
"gitea.perlbach24.de/scripte/wireguard-ui-multi/internal/server"
)
// ParsedLegacyConfig is the parsed result of a legacy wg-quick style config file.
type ParsedLegacyConfig struct {
PrivateKey string
Address string // e.g. "10.10.0.1/24" (used as AddressRange for the new Server)
ListenPort int
DNS string
MTU int
Peers []ParsedLegacyPeer
}
// ParsedLegacyPeer is a single [Peer] section from a legacy config.
type ParsedLegacyPeer struct {
Name string
PublicKey string
PresharedKey string
AllowedIPs string
Endpoint string
PersistentKeepalive int
}
// ParseLegacyConfig reads and parses a wg-quick INI-style config file (e.g. /etc/wireguard/wg0.conf).
func ParseLegacyConfig(path string) (*ParsedLegacyConfig, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
cfg := &ParsedLegacyConfig{}
var curSection string
var curPeer *ParsedLegacyPeer
// pendingName holds a comment found on the line(s) immediately before a
// "[Peer]" header, e.g. "# client-laptop". wg-quick has no native peer
// name field, so this is the only place a human-readable name can come
// from; it's consumed (and reset) as soon as the next [Peer] section starts.
var pendingName string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
pendingName = strings.TrimSpace(strings.TrimLeft(line, "#;"))
continue
}
// Strip inline comments.
if idx := strings.IndexAny(line, "#;"); idx >= 0 {
line = strings.TrimSpace(line[:idx])
if line == "" {
continue
}
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
section := strings.ToLower(strings.TrimSpace(line[1 : len(line)-1]))
switch section {
case "interface":
curSection = "interface"
curPeer = nil
case "peer":
curSection = "peer"
cfg.Peers = append(cfg.Peers, ParsedLegacyPeer{Name: pendingName})
curPeer = &cfg.Peers[len(cfg.Peers)-1]
default:
curSection = ""
curPeer = nil
}
pendingName = ""
continue
}
key, value, ok := splitKV(line)
if !ok {
continue
}
switch curSection {
case "interface":
switch {
case strings.EqualFold(key, "PrivateKey"):
cfg.PrivateKey = value
case strings.EqualFold(key, "Address"):
cfg.Address = value
case strings.EqualFold(key, "ListenPort"):
cfg.ListenPort, _ = strconv.Atoi(value)
case strings.EqualFold(key, "DNS"):
cfg.DNS = value
case strings.EqualFold(key, "MTU"):
cfg.MTU, _ = strconv.Atoi(value)
}
case "peer":
if curPeer == nil {
continue
}
switch {
case strings.EqualFold(key, "PublicKey"):
curPeer.PublicKey = value
case strings.EqualFold(key, "PresharedKey"):
curPeer.PresharedKey = value
case strings.EqualFold(key, "AllowedIPs"):
curPeer.AllowedIPs = value
case strings.EqualFold(key, "Endpoint"):
curPeer.Endpoint = value
case strings.EqualFold(key, "PersistentKeepalive"):
curPeer.PersistentKeepalive, _ = strconv.Atoi(value)
}
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return cfg, nil
}
func splitKV(line string) (key, value string, ok bool) {
idx := strings.Index(line, "=")
if idx < 0 {
return "", "", false
}
key = strings.TrimSpace(line[:idx])
value = strings.TrimSpace(line[idx+1:])
if key == "" {
return "", "", false
}
return key, value, true
}
// ImportLegacyServer parses legacyConfPath and creates a corresponding Server + its Peers
// in the given store, using serverName and interfaceName for the new Server record.
// Returns the new server's ID.
func ImportLegacyServer(store *server.Store, legacyConfPath, serverName, interfaceName string) (int64, error) {
parsed, err := ParseLegacyConfig(legacyConfPath)
if err != nil {
return 0, fmt.Errorf("parse legacy config %q: %w", legacyConfPath, err)
}
pubKey, err := PublicFromPrivate(parsed.PrivateKey)
if err != nil {
return 0, fmt.Errorf("derive public key: %w", err)
}
mtu := parsed.MTU
if mtu == 0 {
mtu = 1420
}
srv := &server.Server{
Name: serverName,
InterfaceName: interfaceName,
ListenPort: parsed.ListenPort,
PrivateKey: parsed.PrivateKey,
PublicKey: pubKey,
AddressRange: parsed.Address,
DNS: parsed.DNS,
MTU: mtu,
Enabled: true,
}
serverID, err := store.CreateServer(srv)
if err != nil {
return 0, fmt.Errorf("create server: %w", err)
}
for i, pp := range parsed.Peers {
name := pp.Name
if name == "" {
name = fmt.Sprintf("peer-%d", i+1)
}
peer := &server.Peer{
ServerID: serverID,
Name: name,
PublicKey: pp.PublicKey,
PresharedKey: pp.PresharedKey,
AllowedIPs: pp.AllowedIPs,
Endpoint: pp.Endpoint,
PersistentKeepalive: pp.PersistentKeepalive,
Enabled: true,
}
if _, err := store.CreatePeer(peer); err != nil {
return serverID, fmt.Errorf("create peer %q (index %d): %w", name, i, err)
}
}
return serverID, nil
}
+340
View File
@@ -0,0 +1,340 @@
package main
import (
"crypto/sha512"
"embed"
"flag"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"strings"
"syscall"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/ngoduykhanh/wireguard-ui/telegram"
"github.com/ngoduykhanh/wireguard-ui/emailer"
"github.com/ngoduykhanh/wireguard-ui/handler"
"github.com/ngoduykhanh/wireguard-ui/router"
"github.com/ngoduykhanh/wireguard-ui/store/jsondb"
"github.com/ngoduykhanh/wireguard-ui/util"
)
var (
// command-line banner information
appVersion = "development"
gitCommit = "N/A"
gitRef = "N/A"
buildTime = fmt.Sprintf(time.Now().UTC().Format("01-02-2006 15:04:05"))
// configuration variables
flagDisableLogin = false
flagBindAddress = "0.0.0.0:5000"
flagSmtpHostname = "127.0.0.1"
flagSmtpPort = 25
flagSmtpUsername string
flagSmtpPassword string
flagSmtpAuthType = "NONE"
flagSmtpNoTLSCheck = false
flagSmtpEncryption = "STARTTLS"
flagSmtpHelo = "localhost"
flagSendgridApiKey string
flagEmailFrom string
flagEmailFromName = "WireGuard UI"
flagTelegramToken string
flagTelegramAllowConfRequest = false
flagTelegramFloodWait = 60
flagSessionSecret = util.RandomString(32)
flagSessionMaxDuration = 90
flagWgConfTemplate string
flagBasePath string
flagSubnetRanges string
)
const (
defaultEmailSubject = "Your wireguard configuration"
defaultEmailContent = `Hi,</br>
<p>In this email you can find your personal configuration for our wireguard server.</p>
<p>Best</p>
`
)
// embed the "templates" directory
//
//go:embed templates/*
var embeddedTemplates embed.FS
// embed the "assets" directory
//
//go:embed assets/*
var embeddedAssets embed.FS
func init() {
// command-line flags and env variables
flag.BoolVar(&flagDisableLogin, "disable-login", util.LookupEnvOrBool("DISABLE_LOGIN", flagDisableLogin), "Disable authentication on the app. This is potentially dangerous.")
flag.StringVar(&flagBindAddress, "bind-address", util.LookupEnvOrString("BIND_ADDRESS", flagBindAddress), "Address:Port to which the app will be bound.")
flag.StringVar(&flagSmtpHostname, "smtp-hostname", util.LookupEnvOrString("SMTP_HOSTNAME", flagSmtpHostname), "SMTP Hostname")
flag.IntVar(&flagSmtpPort, "smtp-port", util.LookupEnvOrInt("SMTP_PORT", flagSmtpPort), "SMTP Port")
flag.StringVar(&flagSmtpHelo, "smtp-helo", util.LookupEnvOrString("SMTP_HELO", flagSmtpHelo), "SMTP HELO Hostname")
flag.StringVar(&flagSmtpUsername, "smtp-username", util.LookupEnvOrString("SMTP_USERNAME", flagSmtpUsername), "SMTP Username")
flag.BoolVar(&flagSmtpNoTLSCheck, "smtp-no-tls-check", util.LookupEnvOrBool("SMTP_NO_TLS_CHECK", flagSmtpNoTLSCheck), "Disable TLS verification for SMTP. This is potentially dangerous.")
flag.StringVar(&flagSmtpEncryption, "smtp-encryption", util.LookupEnvOrString("SMTP_ENCRYPTION", flagSmtpEncryption), "SMTP Encryption : NONE, SSL, SSLTLS, TLS or STARTTLS (by default)")
flag.StringVar(&flagSmtpAuthType, "smtp-auth-type", util.LookupEnvOrString("SMTP_AUTH_TYPE", flagSmtpAuthType), "SMTP Auth Type : PLAIN, LOGIN or NONE.")
flag.StringVar(&flagEmailFrom, "email-from", util.LookupEnvOrString("EMAIL_FROM_ADDRESS", flagEmailFrom), "'From' email address.")
flag.StringVar(&flagEmailFromName, "email-from-name", util.LookupEnvOrString("EMAIL_FROM_NAME", flagEmailFromName), "'From' email name.")
flag.StringVar(&flagTelegramToken, "telegram-token", util.LookupEnvOrString("TELEGRAM_TOKEN", flagTelegramToken), "Telegram bot token for distributing configs to clients.")
flag.BoolVar(&flagTelegramAllowConfRequest, "telegram-allow-conf-request", util.LookupEnvOrBool("TELEGRAM_ALLOW_CONF_REQUEST", flagTelegramAllowConfRequest), "Allow users to get configs from the bot by sending a message.")
flag.IntVar(&flagTelegramFloodWait, "telegram-flood-wait", util.LookupEnvOrInt("TELEGRAM_FLOOD_WAIT", flagTelegramFloodWait), "Time in minutes before the next conf request is processed.")
flag.StringVar(&flagWgConfTemplate, "wg-conf-template", util.LookupEnvOrString("WG_CONF_TEMPLATE", flagWgConfTemplate), "Path to custom wg.conf template.")
flag.StringVar(&flagBasePath, "base-path", util.LookupEnvOrString("BASE_PATH", flagBasePath), "The base path of the URL")
flag.StringVar(&flagSubnetRanges, "subnet-ranges", util.LookupEnvOrString("SUBNET_RANGES", flagSubnetRanges), "IP ranges to choose from when assigning an IP for a client.")
flag.IntVar(&flagSessionMaxDuration, "session-max-duration", util.LookupEnvOrInt("SESSION_MAX_DURATION", flagSessionMaxDuration), "Max time in days a remembered session is refreshed and valid.")
var (
smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword)
sendgridApiKeyLookup = util.LookupEnvOrString("SENDGRID_API_KEY", flagSendgridApiKey)
sessionSecretLookup = util.LookupEnvOrString("SESSION_SECRET", flagSessionSecret)
)
// check empty smtpPassword env var
if smtpPasswordLookup != "" {
flag.StringVar(&flagSmtpPassword, "smtp-password", smtpPasswordLookup, "SMTP Password")
} else {
flag.StringVar(&flagSmtpPassword, "smtp-password", util.LookupEnvOrFile("SMTP_PASSWORD_FILE", flagSmtpPassword), "SMTP Password File")
}
// check empty sendgridApiKey env var
if sendgridApiKeyLookup != "" {
flag.StringVar(&flagSendgridApiKey, "sendgrid-api-key", sendgridApiKeyLookup, "Your sendgrid api key.")
} else {
flag.StringVar(&flagSendgridApiKey, "sendgrid-api-key", util.LookupEnvOrFile("SENDGRID_API_KEY_FILE", flagSendgridApiKey), "File containing your sendgrid api key.")
}
// check empty sessionSecret env var
if sessionSecretLookup != "" {
flag.StringVar(&flagSessionSecret, "session-secret", sessionSecretLookup, "The key used to encrypt session cookies.")
} else {
flag.StringVar(&flagSessionSecret, "session-secret", util.LookupEnvOrFile("SESSION_SECRET_FILE", flagSessionSecret), "File containing the key used to encrypt session cookies.")
}
flag.Parse()
// update runtime config
util.DisableLogin = flagDisableLogin
util.BindAddress = flagBindAddress
util.SmtpHostname = flagSmtpHostname
util.SmtpPort = flagSmtpPort
util.SmtpHelo = flagSmtpHelo
util.SmtpUsername = flagSmtpUsername
util.SmtpPassword = flagSmtpPassword
util.SmtpAuthType = flagSmtpAuthType
util.SmtpNoTLSCheck = flagSmtpNoTLSCheck
util.SmtpEncryption = flagSmtpEncryption
util.SendgridApiKey = flagSendgridApiKey
util.EmailFrom = flagEmailFrom
util.EmailFromName = flagEmailFromName
util.SessionSecret = sha512.Sum512([]byte(flagSessionSecret))
util.SessionMaxDuration = int64(flagSessionMaxDuration) * 86_400 // Store in seconds
util.WgConfTemplate = flagWgConfTemplate
util.BasePath = util.ParseBasePath(flagBasePath)
util.SubnetRanges = util.ParseSubnetRanges(flagSubnetRanges)
lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
telegram.Token = flagTelegramToken
telegram.AllowConfRequest = flagTelegramAllowConfRequest
telegram.FloodWait = flagTelegramFloodWait
telegram.LogLevel = lvl
// print only if log level is INFO or lower
if lvl <= log.INFO {
// print app information
fmt.Println("Wireguard UI")
fmt.Println("App Version\t:", appVersion)
fmt.Println("Git Commit\t:", gitCommit)
fmt.Println("Git Ref\t\t:", gitRef)
fmt.Println("Build Time\t:", buildTime)
fmt.Println("Git Repo\t:", "https://github.com/ngoduykhanh/wireguard-ui")
fmt.Println("Authentication\t:", !util.DisableLogin)
fmt.Println("Bind address\t:", util.BindAddress)
//fmt.Println("Sendgrid key\t:", util.SendgridApiKey)
fmt.Println("Email from\t:", util.EmailFrom)
fmt.Println("Email from name\t:", util.EmailFromName)
//fmt.Println("Session secret\t:", util.SessionSecret)
fmt.Println("Custom wg.conf\t:", util.WgConfTemplate)
fmt.Println("Base path\t:", util.BasePath+"/")
fmt.Println("Subnet ranges\t:", util.GetSubnetRangesString())
}
}
func main() {
db, err := jsondb.New("./db")
if err != nil {
panic(err)
}
if err := db.Init(); err != nil {
panic(err)
}
// set app extra data
extraData := make(map[string]interface{})
extraData["appVersion"] = appVersion
extraData["gitCommit"] = gitCommit
extraData["basePath"] = util.BasePath
extraData["loginDisabled"] = flagDisableLogin
// strip the "templates/" prefix from the embedded directory so files can be read by their direct name (e.g.
// "base.html" instead of "templates/base.html")
tmplDir, _ := fs.Sub(fs.FS(embeddedTemplates), "templates")
// create the wireguard config on start, if it doesn't exist
initServerConfig(db, tmplDir)
// Check if subnet ranges are valid for the server configuration
// Remove any non-valid CIDRs
if err := util.ValidateAndFixSubnetRanges(db); err != nil {
panic(err)
}
// Print valid ranges
if lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO")); lvl <= log.INFO {
fmt.Println("Valid subnet ranges:", util.GetSubnetRangesString())
}
// register routes
app := router.New(tmplDir, extraData, util.SessionSecret)
app.GET(util.BasePath, handler.WireGuardClients(db), handler.ValidSession, handler.RefreshSession)
// Important: Make sure that all non-GET routes check the request content type using handler.ContentTypeJson to
// mitigate CSRF attacks. This is effective, because browsers don't allow setting the Content-Type header on
// cross-origin requests.
if !util.DisableLogin {
app.GET(util.BasePath+"/login", handler.LoginPage())
app.POST(util.BasePath+"/login", handler.Login(db), handler.ContentTypeJson)
app.GET(util.BasePath+"/logout", handler.Logout(), handler.ValidSession)
app.GET(util.BasePath+"/profile", handler.LoadProfile(), handler.ValidSession, handler.RefreshSession)
app.GET(util.BasePath+"/users-settings", handler.UsersSettings(), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/update-user", handler.UpdateUser(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/create-user", handler.CreateUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/remove-user", handler.RemoveUser(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/get-users", handler.GetUsers(db), handler.ValidSession, handler.NeedsAdmin)
app.GET(util.BasePath+"/api/user/:username", handler.GetUser(db), handler.ValidSession)
}
var sendmail emailer.Emailer
if util.SendgridApiKey != "" {
sendmail = emailer.NewSendgridApiMail(util.SendgridApiKey, util.EmailFromName, util.EmailFrom)
} else {
sendmail = emailer.NewSmtpMail(util.SmtpHostname, util.SmtpPort, util.SmtpUsername, util.SmtpPassword, util.SmtpHelo, util.SmtpNoTLSCheck, util.SmtpAuthType, util.EmailFromName, util.EmailFrom, util.SmtpEncryption)
}
app.GET(util.BasePath+"/test-hash", handler.GetHashesChanges(db), handler.ValidSession)
app.GET(util.BasePath+"/about", handler.AboutPage())
app.GET(util.BasePath+"/_health", handler.Health())
app.GET(util.BasePath+"/favicon", handler.Favicon())
app.POST(util.BasePath+"/new-client", handler.NewClient(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/update-client", handler.UpdateClient(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/email-client", handler.EmailClient(db, sendmail, defaultEmailSubject, defaultEmailContent), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/send-telegram-client", handler.SendTelegramClient(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/client/set-status", handler.SetClientStatus(db), handler.ValidSession, handler.ContentTypeJson)
app.POST(util.BasePath+"/remove-client", handler.RemoveClient(db), handler.ValidSession, handler.ContentTypeJson)
app.GET(util.BasePath+"/download", handler.DownloadClient(db), handler.ValidSession)
app.GET(util.BasePath+"/wg-server", handler.WireGuardServer(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/wg-server/interfaces", handler.WireGuardServerInterfaces(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.POST(util.BasePath+"/wg-server/keypair", handler.WireGuardServerKeyPair(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/global-settings", handler.GlobalSettings(db), handler.ValidSession, handler.RefreshSession, handler.NeedsAdmin)
app.POST(util.BasePath+"/global-settings", handler.GlobalSettingSubmit(db), handler.ValidSession, handler.ContentTypeJson, handler.NeedsAdmin)
app.GET(util.BasePath+"/status", handler.Status(db), handler.ValidSession, handler.RefreshSession)
app.GET(util.BasePath+"/api/clients", handler.GetClients(db), handler.ValidSession)
app.GET(util.BasePath+"/api/client/:id", handler.GetClient(db), handler.ValidSession)
app.GET(util.BasePath+"/api/machine-ips", handler.MachineIPAddresses(), handler.ValidSession)
app.GET(util.BasePath+"/api/subnet-ranges", handler.GetOrderedSubnetRanges(), handler.ValidSession)
app.GET(util.BasePath+"/api/suggest-client-ips", handler.SuggestIPAllocation(db), handler.ValidSession)
app.POST(util.BasePath+"/api/apply-wg-config", handler.ApplyServerConfig(db, tmplDir), handler.ValidSession, handler.ContentTypeJson)
app.GET(util.BasePath+"/wake_on_lan_hosts", handler.GetWakeOnLanHosts(db), handler.ValidSession, handler.RefreshSession)
app.POST(util.BasePath+"/wake_on_lan_host", handler.SaveWakeOnLanHost(db), handler.ValidSession, handler.ContentTypeJson)
app.DELETE(util.BasePath+"/wake_on_lan_host/:mac_address", handler.DeleteWakeOnHost(db), handler.ValidSession, handler.ContentTypeJson)
app.PUT(util.BasePath+"/wake_on_lan_host/:mac_address", handler.WakeOnHost(db), handler.ValidSession, handler.ContentTypeJson)
// strip the "assets/" prefix from the embedded directory so files can be called directly without the "assets/"
// prefix
assetsDir, _ := fs.Sub(fs.FS(embeddedAssets), "assets")
assetHandler := http.FileServer(http.FS(assetsDir))
// serves other static files
app.GET(util.BasePath+"/static/*", echo.WrapHandler(http.StripPrefix(util.BasePath+"/static/", assetHandler)))
initDeps := telegram.TgBotInitDependencies{
DB: db,
SendRequestedConfigsToTelegram: util.SendRequestedConfigsToTelegram,
}
initTelegram(initDeps)
if strings.HasPrefix(util.BindAddress, "unix://") {
// Listen on unix domain socket.
// https://github.com/labstack/echo/issues/830
err := syscall.Unlink(util.BindAddress[6:])
if err != nil {
app.Logger.Fatalf("Cannot unlink unix socket: Error: %v", err)
}
l, err := net.Listen("unix", util.BindAddress[6:])
if err != nil {
app.Logger.Fatalf("Cannot create unix socket. Error: %v", err)
}
app.Listener = l
app.Logger.Fatal(app.Start(""))
} else {
// Listen on TCP socket
app.Logger.Fatal(app.Start(util.BindAddress))
}
}
func initServerConfig(db store.IStore, tmplDir fs.FS) {
settings, err := db.GetGlobalSettings()
if err != nil {
log.Fatalf("Cannot get global settings: %v", err)
}
if _, err := os.Stat(settings.ConfigFilePath); err == nil {
// file exists, don't overwrite it implicitly
return
}
server, err := db.GetServer()
if err != nil {
log.Fatalf("Cannot get server config: %v", err)
}
clients, err := db.GetClients(false)
if err != nil {
log.Fatalf("Cannot get client config: %v", err)
}
users, err := db.GetUsers()
if err != nil {
log.Fatalf("Cannot get user config: %v", err)
}
// write config file
err = util.WriteWireGuardServerConfig(tmplDir, server, clients, users, settings)
if err != nil {
log.Fatalf("Cannot create server config: %v", err)
}
}
func initTelegram(initDeps telegram.TgBotInitDependencies) {
go func() {
for {
err := telegram.Start(initDeps)
if err == nil {
break
}
}
}()
}
+38
View File
@@ -0,0 +1,38 @@
package model
import (
"time"
)
// Client model
type Client struct {
ID string `json:"id"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
PresharedKey string `json:"preshared_key"`
Name string `json:"name"`
TgUserid string `json:"telegram_userid"`
Email string `json:"email"`
SubnetRanges []string `json:"subnet_ranges,omitempty"`
AllocatedIPs []string `json:"allocated_ips"`
AllowedIPs []string `json:"allowed_ips"`
ExtraAllowedIPs []string `json:"extra_allowed_ips"`
Endpoint string `json:"endpoint"`
AdditionalNotes string `json:"additional_notes"`
UseServerDNS bool `json:"use_server_dns"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ClientData includes the Client and extra data
type ClientData struct {
Client *Client
QRCode string
}
type QRCodeSettings struct {
Enabled bool
IncludeDNS bool
IncludeMTU bool
}
+9
View File
@@ -0,0 +1,9 @@
package model
// ClientDefaults Defaults for creation of new clients used in the templates
type ClientDefaults struct {
AllowedIps []string
ExtraAllowedIps []string
UseServerDNS bool
EnableAfterCreation bool
}
+20
View File
@@ -0,0 +1,20 @@
package model
// Interface model
type Interface struct {
Name string `json:"name"`
IPAddress string `json:"ip_address"`
}
// BaseData struct to pass value to the base template
type BaseData struct {
Active string
CurrentUser string
Admin bool
}
// ClientServerHashes struct, to save hashes to detect changes
type ClientServerHashes struct {
Client string `json:"client"`
Server string `json:"server"`
}
+28
View File
@@ -0,0 +1,28 @@
package model
import (
"time"
)
// Server model
type Server struct {
KeyPair *ServerKeypair
Interface *ServerInterface
}
// ServerKeypair model
type ServerKeypair struct {
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
UpdatedAt time.Time `json:"updated_at"`
}
// ServerInterface model
type ServerInterface struct {
Addresses []string `json:"addresses"`
ListenPort int `json:"listen_port,string"` // ,string to get listen_port string input as int
UpdatedAt time.Time `json:"updated_at"`
PostUp string `json:"post_up"`
PreDown string `json:"pre_down"`
PostDown string `json:"post_down"`
}
+17
View File
@@ -0,0 +1,17 @@
package model
import (
"time"
)
// GlobalSetting model
type GlobalSetting struct {
EndpointAddress string `json:"endpoint_address"`
DNSServers []string `json:"dns_servers"`
MTU int `json:"mtu,string"`
PersistentKeepalive int `json:"persistent_keepalive,string"`
FirewallMark string `json:"firewall_mark"`
Table string `json:"table"`
ConfigFilePath string `json:"config_file_path"`
UpdatedAt time.Time `json:"updated_at"`
}
+10
View File
@@ -0,0 +1,10 @@
package model
// User model
type User struct {
Username string `json:"username"`
Password string `json:"password"`
// PasswordHash takes precedence over Password.
PasswordHash string `json:"password_hash"`
Admin bool `json:"admin"`
}
+31
View File
@@ -0,0 +1,31 @@
package model
import (
"errors"
"net"
"strings"
"time"
)
type WakeOnLanHost struct {
MacAddress string `json:"MacAddress"`
Name string `json:"Name"`
LatestUsed *time.Time `json:"LatestUsed"`
}
func (host WakeOnLanHost) ResolveResourceName() (string, error) {
resourceName := strings.Trim(host.MacAddress, " \t\r\n\000")
if len(resourceName) == 0 {
return "", errors.New("mac Address is Empty")
}
resourceName = strings.ToUpper(resourceName)
resourceName = strings.ReplaceAll(resourceName, ":", "-")
if _, err := net.ParseMAC(resourceName); err != nil {
return "", errors.New("invalid mac address")
}
return resourceName, nil
}
const WakeOnLanHostCollectionName = "wake_on_lan_hosts"
+13
View File
@@ -0,0 +1,13 @@
{
"name": "wireguard-ui",
"version": "1.0.0",
"description": "Wireguard web interface",
"main": "index.js",
"repository": "git@github.com:ngoduykhanh/wireguard-ui.git",
"author": "Khanh Ngo <k@ndk.name>",
"license": "MIT",
"dependencies": {
"admin-lte": "^3.0",
"jquery-tags-input": "^1.3.5"
}
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -e
DIR=$(dirname "$0")
# install node modules
YARN=yarn
[ -x /usr/bin/lsb_release ] && [ -n "`lsb_release -i | grep Debian`" ] && YARN=yarnpkg
$YARN install --pure-lockfile --production
# Copy admin-lte dist
mkdir -p "${DIR}/assets/dist/js" "${DIR}/assets/dist/css" && \
cp -r "${DIR}/node_modules/admin-lte/dist/js/adminlte.min.js" "${DIR}/assets/dist/js/adminlte.min.js" && \
cp -r "${DIR}/node_modules/admin-lte/dist/css/adminlte.min.css" "${DIR}/assets/dist/css/adminlte.min.css"
# Copy helper js
cp -r "${DIR}/custom" "${DIR}/assets"
# Copy plugins
mkdir -p "${DIR}/assets/plugins" && \
cp -r "${DIR}/node_modules/admin-lte/plugins/jquery" \
"${DIR}/node_modules/admin-lte/plugins/fontawesome-free" \
"${DIR}/node_modules/admin-lte/plugins/bootstrap" \
"${DIR}/node_modules/admin-lte/plugins/icheck-bootstrap" \
"${DIR}/node_modules/admin-lte/plugins/toastr" \
"${DIR}/node_modules/admin-lte/plugins/jquery-validation" \
"${DIR}/node_modules/admin-lte/plugins/select2" \
"${DIR}/node_modules/jquery-tags-input" \
"${DIR}/assets/plugins/"
+158
View File
@@ -0,0 +1,158 @@
package router
import (
"errors"
"io"
"io/fs"
"reflect"
"strings"
"text/template"
"github.com/gorilla/sessions"
"github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/util"
)
// TemplateRegistry is a custom html/template renderer for Echo framework
type TemplateRegistry struct {
templates map[string]*template.Template
extraData map[string]interface{}
}
// Render e.Renderer interface
func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
tmpl, ok := t.templates[name]
if !ok {
err := errors.New("Template not found -> " + name)
return err
}
// inject more app data information. E.g. appVersion
if reflect.TypeOf(data).Kind() == reflect.Map {
for k, v := range t.extraData {
data.(map[string]interface{})[k] = v
}
data.(map[string]interface{})["client_defaults"] = util.ClientDefaultsFromEnv()
}
// login page does not need the base layout
if name == "login.html" {
return tmpl.Execute(w, data)
}
return tmpl.ExecuteTemplate(w, "base.html", data)
}
// New function
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
e := echo.New()
cookiePath := util.GetCookiePath()
cookieStore := sessions.NewCookieStore(secret[:32], secret[32:])
cookieStore.Options.Path = cookiePath
cookieStore.Options.HttpOnly = true
cookieStore.MaxAge(86400 * 7)
e.Use(session.Middleware(cookieStore))
// read html template file to string
tmplBaseString, err := util.StringFromEmbedFile(tmplDir, "base.html")
if err != nil {
log.Fatal(err)
}
tmplLoginString, err := util.StringFromEmbedFile(tmplDir, "login.html")
if err != nil {
log.Fatal(err)
}
tmplProfileString, err := util.StringFromEmbedFile(tmplDir, "profile.html")
if err != nil {
log.Fatal(err)
}
tmplClientsString, err := util.StringFromEmbedFile(tmplDir, "clients.html")
if err != nil {
log.Fatal(err)
}
tmplServerString, err := util.StringFromEmbedFile(tmplDir, "server.html")
if err != nil {
log.Fatal(err)
}
tmplGlobalSettingsString, err := util.StringFromEmbedFile(tmplDir, "global_settings.html")
if err != nil {
log.Fatal(err)
}
tmplUsersSettingsString, err := util.StringFromEmbedFile(tmplDir, "users_settings.html")
if err != nil {
log.Fatal(err)
}
tmplStatusString, err := util.StringFromEmbedFile(tmplDir, "status.html")
if err != nil {
log.Fatal(err)
}
tmplWakeOnLanHostsString, err := util.StringFromEmbedFile(tmplDir, "wake_on_lan_hosts.html")
if err != nil {
log.Fatal(err)
}
aboutPageString, err := util.StringFromEmbedFile(tmplDir, "about.html")
if err != nil {
log.Fatal(err)
}
// create template list
funcs := template.FuncMap{
"StringsJoin": strings.Join,
}
templates := make(map[string]*template.Template)
templates["login.html"] = template.Must(template.New("login").Funcs(funcs).Parse(tmplLoginString))
templates["profile.html"] = template.Must(template.New("profile").Funcs(funcs).Parse(tmplBaseString + tmplProfileString))
templates["clients.html"] = template.Must(template.New("clients").Funcs(funcs).Parse(tmplBaseString + tmplClientsString))
templates["server.html"] = template.Must(template.New("server").Funcs(funcs).Parse(tmplBaseString + tmplServerString))
templates["global_settings.html"] = template.Must(template.New("global_settings").Funcs(funcs).Parse(tmplBaseString + tmplGlobalSettingsString))
templates["users_settings.html"] = template.Must(template.New("users_settings").Funcs(funcs).Parse(tmplBaseString + tmplUsersSettingsString))
templates["status.html"] = template.Must(template.New("status").Funcs(funcs).Parse(tmplBaseString + tmplStatusString))
templates["wake_on_lan_hosts.html"] = template.Must(template.New("wake_on_lan_hosts").Funcs(funcs).Parse(tmplBaseString + tmplWakeOnLanHostsString))
templates["about.html"] = template.Must(template.New("about").Funcs(funcs).Parse(tmplBaseString + aboutPageString))
lvl, err := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
if err != nil {
log.Fatal(err)
}
logConfig := middleware.DefaultLoggerConfig
logConfig.Skipper = func(c echo.Context) bool {
resp := c.Response()
if resp.Status >= 500 && lvl > log.ERROR { // do not log if response is 5XX but log level is higher than ERROR
return true
} else if resp.Status >= 400 && lvl > log.WARN { // do not log if response is 4XX but log level is higher than WARN
return true
} else if lvl > log.DEBUG { // do not log if log level is higher than DEBUG
return true
}
return false
}
e.Logger.SetLevel(lvl)
e.Pre(middleware.RemoveTrailingSlash())
e.Use(middleware.LoggerWithConfig(logConfig))
e.HideBanner = true
e.HidePort = lvl > log.INFO // hide the port output if the log level is higher than INFO
e.Validator = NewValidator()
e.Renderer = &TemplateRegistry{
templates: templates,
extraData: extraData,
}
return e
}
+20
View File
@@ -0,0 +1,20 @@
package router
import "gopkg.in/go-playground/validator.v9"
// NewValidator func
func NewValidator() *Validator {
return &Validator{
validator: validator.New(),
}
}
// Validator struct
type Validator struct {
validator *validator.Validate
}
// Validate func
func (v *Validator) Validate(i interface{}) error {
return v.validator.Struct(i)
}
+410
View File
@@ -0,0 +1,410 @@
package jsondb
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path"
"strconv"
"time"
"github.com/sdomino/scribble"
"github.com/skip2/go-qrcode"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/util"
)
type JsonDB struct {
conn *scribble.Driver
dbPath string
}
// New returns a new pointer JsonDB
func New(dbPath string) (*JsonDB, error) {
conn, err := scribble.New(dbPath, nil)
if err != nil {
return nil, err
}
ans := JsonDB{
conn: conn,
dbPath: dbPath,
}
return &ans, nil
}
func (o *JsonDB) Init() error {
var clientPath = path.Join(o.dbPath, "clients")
var serverPath = path.Join(o.dbPath, "server")
var userPath = path.Join(o.dbPath, "users")
var wakeOnLanHostsPath = path.Join(o.dbPath, "wake_on_lan_hosts")
var serverInterfacePath = path.Join(serverPath, "interfaces.json")
var serverKeyPairPath = path.Join(serverPath, "keypair.json")
var globalSettingPath = path.Join(serverPath, "global_settings.json")
var hashesPath = path.Join(serverPath, "hashes.json")
// create directories if they do not exist
if _, err := os.Stat(clientPath); os.IsNotExist(err) {
os.MkdirAll(clientPath, os.ModePerm)
}
if _, err := os.Stat(serverPath); os.IsNotExist(err) {
os.MkdirAll(serverPath, os.ModePerm)
}
if _, err := os.Stat(userPath); os.IsNotExist(err) {
os.MkdirAll(userPath, os.ModePerm)
}
if _, err := os.Stat(wakeOnLanHostsPath); os.IsNotExist(err) {
os.MkdirAll(wakeOnLanHostsPath, os.ModePerm)
}
// server's interface
if _, err := os.Stat(serverInterfacePath); os.IsNotExist(err) {
serverInterface := new(model.ServerInterface)
serverInterface.Addresses = util.LookupEnvOrStrings(util.ServerAddressesEnvVar, []string{util.DefaultServerAddress})
serverInterface.ListenPort = util.LookupEnvOrInt(util.ServerListenPortEnvVar, util.DefaultServerPort)
serverInterface.PostUp = util.LookupEnvOrString(util.ServerPostUpScriptEnvVar, "")
serverInterface.PostDown = util.LookupEnvOrString(util.ServerPostDownScriptEnvVar, "")
serverInterface.UpdatedAt = time.Now().UTC()
o.conn.Write("server", "interfaces", serverInterface)
err := util.ManagePerms(serverInterfacePath)
if err != nil {
return err
}
}
// server's key pair
if _, err := os.Stat(serverKeyPairPath); os.IsNotExist(err) {
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
return scribble.ErrMissingCollection
}
serverKeyPair := new(model.ServerKeypair)
serverKeyPair.PrivateKey = key.String()
serverKeyPair.PublicKey = key.PublicKey().String()
serverKeyPair.UpdatedAt = time.Now().UTC()
o.conn.Write("server", "keypair", serverKeyPair)
err = util.ManagePerms(serverKeyPairPath)
if err != nil {
return err
}
}
// global settings
if _, err := os.Stat(globalSettingPath); os.IsNotExist(err) {
endpointAddress := util.LookupEnvOrString(util.EndpointAddressEnvVar, "")
if endpointAddress == "" {
// automatically find an external IP address
publicInterface, err := util.GetPublicIP()
if err != nil {
return err
}
endpointAddress = publicInterface.IPAddress
}
globalSetting := new(model.GlobalSetting)
globalSetting.EndpointAddress = endpointAddress
globalSetting.DNSServers = util.LookupEnvOrStrings(util.DNSEnvVar, []string{util.DefaultDNS})
globalSetting.MTU = util.LookupEnvOrInt(util.MTUEnvVar, util.DefaultMTU)
globalSetting.PersistentKeepalive = util.LookupEnvOrInt(util.PersistentKeepaliveEnvVar, util.DefaultPersistentKeepalive)
globalSetting.FirewallMark = util.LookupEnvOrString(util.FirewallMarkEnvVar, util.DefaultFirewallMark)
globalSetting.Table = util.LookupEnvOrString(util.TableEnvVar, util.DefaultTable)
globalSetting.ConfigFilePath = util.LookupEnvOrString(util.ConfigFilePathEnvVar, util.DefaultConfigFilePath)
globalSetting.UpdatedAt = time.Now().UTC()
o.conn.Write("server", "global_settings", globalSetting)
err := util.ManagePerms(globalSettingPath)
if err != nil {
return err
}
}
// hashes
if _, err := os.Stat(hashesPath); os.IsNotExist(err) {
clientServerHashes := new(model.ClientServerHashes)
clientServerHashes.Client = "none"
clientServerHashes.Server = "none"
o.conn.Write("server", "hashes", clientServerHashes)
err := util.ManagePerms(hashesPath)
if err != nil {
return err
}
}
// user info
results, err := o.conn.ReadAll("users")
if err != nil || len(results) < 1 {
user := new(model.User)
user.Username = util.LookupEnvOrString(util.UsernameEnvVar, util.DefaultUsername)
user.Admin = util.DefaultIsAdmin
user.PasswordHash = util.LookupEnvOrString(util.PasswordHashEnvVar, "")
if user.PasswordHash == "" {
user.PasswordHash = util.LookupEnvOrFile(util.PasswordHashFileEnvVar, "")
if user.PasswordHash == "" {
plaintext := util.LookupEnvOrString(util.PasswordEnvVar, util.DefaultPassword)
if plaintext == util.DefaultPassword {
plaintext = util.LookupEnvOrFile(util.PasswordFileEnvVar, util.DefaultPassword)
}
hash, err := util.HashPassword(plaintext)
if err != nil {
return err
}
user.PasswordHash = hash
}
}
o.conn.Write("users", user.Username, user)
results, _ = o.conn.ReadAll("users")
err = util.ManagePerms(path.Join(path.Join(o.dbPath, "users"), user.Username+".json"))
if err != nil {
return err
}
}
// init cache
for _, i := range results {
user := model.User{}
if err := json.Unmarshal([]byte(i), &user); err == nil {
util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user)
}
}
clients, err := o.GetClients(false)
if err != nil {
return nil
}
for _, cl := range clients {
client := cl.Client
if client.Enabled && len(client.TgUserid) > 0 {
if userid, err := strconv.ParseInt(client.TgUserid, 10, 64); err == nil {
util.UpdateTgToClientID(userid, client.ID)
}
}
}
return nil
}
// GetUsers func to get all users from the database
func (o *JsonDB) GetUsers() ([]model.User, error) {
var users []model.User
results, err := o.conn.ReadAll("users")
if err != nil {
return users, err
}
for _, i := range results {
user := model.User{}
if err := json.Unmarshal(i, &user); err != nil {
return users, fmt.Errorf("cannot decode user json structure: %v", err)
}
users = append(users, user)
}
return users, err
}
// GetUserByName func to get single user from the database
func (o *JsonDB) GetUserByName(username string) (model.User, error) {
user := model.User{}
if err := o.conn.Read("users", username, &user); err != nil {
return user, err
}
return user, nil
}
// SaveUser func to save user in the database
func (o *JsonDB) SaveUser(user model.User) error {
userPath := path.Join(path.Join(o.dbPath, "users"), user.Username+".json")
output := o.conn.Write("users", user.Username, user)
err := util.ManagePerms(userPath)
if err != nil {
return err
}
util.DBUsersToCRC32[user.Username] = util.GetDBUserCRC32(user)
return output
}
// DeleteUser func to remove user from the database
func (o *JsonDB) DeleteUser(username string) error {
delete(util.DBUsersToCRC32, username)
return o.conn.Delete("users", username)
}
// GetGlobalSettings func to query global settings from the database
func (o *JsonDB) GetGlobalSettings() (model.GlobalSetting, error) {
settings := model.GlobalSetting{}
return settings, o.conn.Read("server", "global_settings", &settings)
}
// GetServer func to query Server settings from the database
func (o *JsonDB) GetServer() (model.Server, error) {
server := model.Server{}
// read server interface information
serverInterface := model.ServerInterface{}
if err := o.conn.Read("server", "interfaces", &serverInterface); err != nil {
return server, err
}
// read server key pair information
serverKeyPair := model.ServerKeypair{}
if err := o.conn.Read("server", "keypair", &serverKeyPair); err != nil {
return server, err
}
// create Server object and return
server.Interface = &serverInterface
server.KeyPair = &serverKeyPair
return server, nil
}
func (o *JsonDB) GetClients(hasQRCode bool) ([]model.ClientData, error) {
var clients []model.ClientData
// read all client json files in "clients" directory
records, err := o.conn.ReadAll("clients")
if err != nil {
return clients, err
}
// build the ClientData list
for _, f := range records {
client := model.Client{}
clientData := model.ClientData{}
// get client info
if err := json.Unmarshal(f, &client); err != nil {
return clients, fmt.Errorf("cannot decode client json structure: %v", err)
}
// generate client qrcode image in base64
if hasQRCode && client.PrivateKey != "" {
server, _ := o.GetServer()
globalSettings, _ := o.GetGlobalSettings()
png, err := qrcode.Encode(util.BuildClientConfig(client, server, globalSettings), qrcode.Medium, 256)
if err == nil {
clientData.QRCode = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
} else {
fmt.Print("Cannot generate QR code: ", err)
}
}
// create the list of clients and their qrcode data
clientData.Client = &client
clients = append(clients, clientData)
}
return clients, nil
}
func (o *JsonDB) GetClientByID(clientID string, qrCodeSettings model.QRCodeSettings) (model.ClientData, error) {
client := model.Client{}
clientData := model.ClientData{}
// read client information
if err := o.conn.Read("clients", clientID, &client); err != nil {
return clientData, err
}
// generate client qrcode image in base64
if qrCodeSettings.Enabled && client.PrivateKey != "" {
server, _ := o.GetServer()
globalSettings, _ := o.GetGlobalSettings()
client := client
if !qrCodeSettings.IncludeDNS {
globalSettings.DNSServers = []string{}
}
if !qrCodeSettings.IncludeMTU {
globalSettings.MTU = 0
}
png, err := qrcode.Encode(util.BuildClientConfig(client, server, globalSettings), qrcode.Medium, 256)
if err == nil {
clientData.QRCode = "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
} else {
fmt.Print("Cannot generate QR code: ", err)
}
}
clientData.Client = &client
return clientData, nil
}
func (o *JsonDB) SaveClient(client model.Client) error {
clientPath := path.Join(path.Join(o.dbPath, "clients"), client.ID+".json")
output := o.conn.Write("clients", client.ID, client)
if output == nil {
if client.Enabled && len(client.TgUserid) > 0 {
if userid, err := strconv.ParseInt(client.TgUserid, 10, 64); err == nil {
util.UpdateTgToClientID(userid, client.ID)
}
} else {
util.RemoveTgToClientID(client.ID)
}
} else {
util.RemoveTgToClientID(client.ID)
}
err := util.ManagePerms(clientPath)
if err != nil {
return err
}
return output
}
func (o *JsonDB) DeleteClient(clientID string) error {
util.RemoveTgToClientID(clientID)
return o.conn.Delete("clients", clientID)
}
func (o *JsonDB) SaveServerInterface(serverInterface model.ServerInterface) error {
serverInterfacePath := path.Join(path.Join(o.dbPath, "server"), "interfaces.json")
output := o.conn.Write("server", "interfaces", serverInterface)
err := util.ManagePerms(serverInterfacePath)
if err != nil {
return err
}
return output
}
func (o *JsonDB) SaveServerKeyPair(serverKeyPair model.ServerKeypair) error {
serverKeyPairPath := path.Join(path.Join(o.dbPath, "server"), "keypair.json")
output := o.conn.Write("server", "keypair", serverKeyPair)
err := util.ManagePerms(serverKeyPairPath)
if err != nil {
return err
}
return output
}
func (o *JsonDB) SaveGlobalSettings(globalSettings model.GlobalSetting) error {
globalSettingsPath := path.Join(path.Join(o.dbPath, "server"), "global_settings.json")
output := o.conn.Write("server", "global_settings", globalSettings)
err := util.ManagePerms(globalSettingsPath)
if err != nil {
return err
}
return output
}
func (o *JsonDB) GetPath() string {
return o.dbPath
}
func (o *JsonDB) GetHashes() (model.ClientServerHashes, error) {
hashes := model.ClientServerHashes{}
return hashes, o.conn.Read("server", "hashes", &hashes)
}
func (o *JsonDB) SaveHashes(hashes model.ClientServerHashes) error {
hashesPath := path.Join(path.Join(o.dbPath, "server"), "hashes.json")
output := o.conn.Write("server", "hashes", hashes)
err := util.ManagePerms(hashesPath)
if err != nil {
return err
}
return output
}
+88
View File
@@ -0,0 +1,88 @@
package jsondb
import (
"encoding/json"
"fmt"
"path"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/ngoduykhanh/wireguard-ui/util"
)
func (o *JsonDB) GetWakeOnLanHosts() ([]model.WakeOnLanHost, error) {
var hosts []model.WakeOnLanHost
// read all client json file in "hosts" directory
records, err := o.conn.ReadAll(model.WakeOnLanHostCollectionName)
if err != nil {
return hosts, err
}
// build the ClientData list
for _, f := range records {
host := model.WakeOnLanHost{}
// get client info
if err := json.Unmarshal(f, &host); err != nil {
return hosts, fmt.Errorf("cannot decode client json structure: %v", err)
}
// create the list of hosts and their qrcode data
hosts = append(hosts, host)
}
return hosts, nil
}
func (o *JsonDB) GetWakeOnLanHost(macAddress string) (*model.WakeOnLanHost, error) {
host := &model.WakeOnLanHost{
MacAddress: macAddress,
}
resourceName, err := host.ResolveResourceName()
if err != nil {
return nil, err
}
err = o.conn.Read(model.WakeOnLanHostCollectionName, resourceName, host)
if err != nil {
host = nil
}
return host, err
}
func (o *JsonDB) DeleteWakeOnHostLanHost(macAddress string) error {
host := &model.WakeOnLanHost{
MacAddress: macAddress,
}
resourceName, err := host.ResolveResourceName()
if err != nil {
return err
}
return o.conn.Delete(model.WakeOnLanHostCollectionName, resourceName)
}
func (o *JsonDB) SaveWakeOnLanHost(host model.WakeOnLanHost) error {
resourceName, err := host.ResolveResourceName()
if err != nil {
return err
}
wakeOnLanHostPath := path.Join(path.Join(o.dbPath, model.WakeOnLanHostCollectionName), resourceName+".json")
output := o.conn.Write(model.WakeOnLanHostCollectionName, resourceName, host)
err = util.ManagePerms(wakeOnLanHostPath)
if err != nil {
return err
}
return output
}
func (o *JsonDB) DeleteWakeOnHost(host model.WakeOnLanHost) error {
resourceName, err := host.ResolveResourceName()
if err != nil {
return err
}
return o.conn.Delete(model.WakeOnLanHostCollectionName, resourceName)
}
+30
View File
@@ -0,0 +1,30 @@
package store
import (
"github.com/ngoduykhanh/wireguard-ui/model"
)
type IStore interface {
Init() error
GetUsers() ([]model.User, error)
GetUserByName(username string) (model.User, error)
SaveUser(user model.User) error
DeleteUser(username string) error
GetGlobalSettings() (model.GlobalSetting, error)
GetServer() (model.Server, error)
GetClients(hasQRCode bool) ([]model.ClientData, error)
GetClientByID(clientID string, qrCode model.QRCodeSettings) (model.ClientData, error)
SaveClient(client model.Client) error
DeleteClient(clientID string) error
SaveServerInterface(serverInterface model.ServerInterface) error
SaveServerKeyPair(serverKeyPair model.ServerKeypair) error
SaveGlobalSettings(globalSettings model.GlobalSetting) error
GetWakeOnLanHosts() ([]model.WakeOnLanHost, error)
GetWakeOnLanHost(macAddress string) (*model.WakeOnLanHost, error)
DeleteWakeOnHostLanHost(macAddress string) error
SaveWakeOnLanHost(host model.WakeOnLanHost) error
DeleteWakeOnHost(host model.WakeOnLanHost) error
GetPath() string
SaveHashes(hashes model.ClientServerHashes) error
GetHashes() (model.ClientServerHashes, error)
}
-22
View File
@@ -1,22 +0,0 @@
[Unit]
Description=wireguard-ui-multi - native multi-server WireGuard management UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Runs as root because it shells out to wg-quick, systemctl and nft, which
# require CAP_NET_ADMIN (and in practice broad privileges for systemctl unit
# management). AmbientCapabilities is set as defense-in-depth in case this
# unit is ever adapted to run as a non-root user with File capabilities on
# the binary instead.
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN
ExecStart=/usr/local/bin/wireguard-ui-multi --db /var/lib/wireguard-ui-multi/wireguard-ui-multi.db --config-dir /etc/wireguard --hooks-dir /etc/wireguard-manager/hooks
Restart=on-failure
RestartSec=5
WorkingDirectory=/var/lib/wireguard-ui-multi
[Install]
WantedBy=multi-user.target
+161
View File
@@ -0,0 +1,161 @@
package telegram
import (
"fmt"
"sync"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/store"
)
type SendRequestedConfigsToTelegram func(db store.IStore, userid int64) []string
type TgBotInitDependencies struct {
DB store.IStore
SendRequestedConfigsToTelegram SendRequestedConfigsToTelegram
}
var (
Token string
AllowConfRequest bool
FloodWait int
LogLevel log.Lvl
Bot *echotron.API
BotMutex sync.RWMutex
floodWait = make(map[int64]int64)
floodMessageSent = make(map[int64]struct{})
)
func Start(initDeps TgBotInitDependencies) (err error) {
ticker := time.NewTicker(time.Minute)
defer func() {
if err != nil {
BotMutex.Lock()
Bot = nil
BotMutex.Unlock()
ticker.Stop()
}
if r := recover(); r != nil {
err = fmt.Errorf("[PANIC] recovered from panic: %v", r)
}
}()
token := Token
if token == "" || len(token) < 30 {
return
}
bot := echotron.NewAPI(token)
res, err := bot.GetMe()
if !res.Ok || err != nil {
log.Warnf("[Telegram] Unable to connect to bot.\n%v\n%v", res.Description, err)
return
}
BotMutex.Lock()
Bot = &bot
BotMutex.Unlock()
if LogLevel <= log.INFO {
fmt.Printf("[Telegram] Authorized as %s\n", res.Result.Username)
}
go func() {
for range ticker.C {
updateFloodWait()
}
}()
if !AllowConfRequest {
return
}
updatesChan := echotron.PollingUpdatesOptions(token, false, echotron.UpdateOptions{AllowedUpdates: []echotron.UpdateType{echotron.MessageUpdate}})
for update := range updatesChan {
if update.Message != nil {
userid := update.Message.Chat.ID
if _, wait := floodWait[userid]; wait {
if _, notified := floodMessageSent[userid]; notified {
continue
}
floodMessageSent[userid] = struct{}{}
_, err := bot.SendMessage(
fmt.Sprintf("You can only request your configs once per %d minutes", FloodWait),
userid,
&echotron.MessageOptions{
ReplyToMessageID: update.Message.ID,
})
if err != nil {
log.Errorf("Failed to send telegram message. Error %v", err)
}
continue
}
floodWait[userid] = time.Now().Unix()
failed := initDeps.SendRequestedConfigsToTelegram(initDeps.DB, userid)
if len(failed) > 0 {
messageText := "Failed to send configs:\n"
for _, f := range failed {
messageText += f + "\n"
}
_, err := bot.SendMessage(
messageText,
userid,
&echotron.MessageOptions{
ReplyToMessageID: update.Message.ID,
})
if err != nil {
log.Errorf("Failed to send telegram message. Error %v", err)
}
}
}
}
return err
}
func SendConfig(userid int64, clientName string, confData, qrData []byte, ignoreFloodWait bool) error {
BotMutex.RLock()
defer BotMutex.RUnlock()
if Bot == nil {
return fmt.Errorf("telegram bot is not configured or not available")
}
if _, wait := floodWait[userid]; wait && !ignoreFloodWait {
return fmt.Errorf("this client already got their config less than %d minutes ago", FloodWait)
}
if !ignoreFloodWait {
floodWait[userid] = time.Now().Unix()
}
qrAttachment := echotron.NewInputFileBytes("qr.png", qrData)
_, err := Bot.SendPhoto(qrAttachment, userid, &echotron.PhotoOptions{Caption: clientName})
if err != nil {
log.Error(err)
return fmt.Errorf("unable to send qr picture")
}
confAttachment := echotron.NewInputFileBytes(clientName+".conf", confData)
_, err = Bot.SendDocument(confAttachment, userid, nil)
if err != nil {
log.Error(err)
return fmt.Errorf("unable to send conf file")
}
return nil
}
func updateFloodWait() {
thresholdTS := time.Now().Unix() - 60*int64(FloodWait)
for userid, ts := range floodWait {
if ts < thresholdTS {
delete(floodWait, userid)
delete(floodMessageSent, userid)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
{{ define "title"}}
About
{{ end }}
{{ define "top_css"}}
{{ end }}
{{ define "username"}}
{{ .username }}
{{ end }}
{{ define "page_title"}}
About
{{ end }}
{{ define "page_content"}}
<section class="content">
<div class="container-fluid">
<!-- <h5 class="mt-4 mb-2">Global Settings</h5> -->
<div class="row">
<!-- left column -->
<div class="col-md-6">
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">About Wireguard-UI</h3>
</div>
<!-- /.card-header -->
<div class="card-body">
<div class="form-group">
<label for="version" class="control-label">Current version</label>
<input type="text" class="form-control" id="version" value="{{ .appVersion }}" readonly>
</div>
{{ if .gitCommit }}
<div class="form-group">
<label for="version" class="control-label">git commit hash</label>
<input type="text" class="form-control" id="version" value="{{ .gitCommit }}" readonly>
</div>
{{ end }}
<div class="form-group">
<label for="currentReleaseDate" class="control-label">Current version release date</label>
<input type="text" class="form-control" id="currentReleaseDate" readonly>
</div>
<div class="form-group">
<label for="latestRelease" class="control-label">Latest release</label>
<input type="text" class="form-control" id="latestRelease" readonly>
</div>
<div class="form-group">
<label for="latestReleaseDate" class="control-label">Latest release date</label>
<input type="text" class="form-control" id="latestReleaseDate" readonly>
</div>
<div class="form-group">
<label for="author" class="control-label">Author</label>
<div id="author">
<a id="authorLink">
<img id="authorImage"
style="width: 50px; height: 50px; border-radius: 50%; border: 1px solid #000;">
</a>
</div>
</div>
<div class="form-group">
<label for="contributors" class="control-label">Contributors</label>
<div id="contributors"></div>
</div>
<strong>Copyright &copy;
<script>document.write(new Date().getFullYear())</script>
<a href="https://github.com/ngoduykhanh/wireguard-ui">Wireguard UI</a>.
</strong> All rights reserved.
</div>
</div>
<!-- /.card -->
</div>
</div>
<!-- /.row -->
</div>
</section>
{{ end }}
{{ define "bottom_js"}}
<script>
$(document).ready(function () {
$.ajax({
cache: false,
method: 'GET',
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/releases/tags/' + $("#version").val(),
dataType: 'json',
contentType: "application/json",
success: function (data) {
$("#currentReleaseDate").attr("value", data.published_at.split("T")[0]);
},
error: function (jqXHR, exception) {
$("#currentReleaseDate").attr("value", "Could not find this version on GitHub.com");
}
});
$.ajax({
cache: false,
method: 'GET',
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/releases/latest',
dataType: 'json',
contentType: "application/json",
success: function (data) {
$("#latestRelease").attr("value", data.tag_name);
$("#latestReleaseDate").attr("value", data.published_at.split("T")[0]);
$("#author").attr("value", data.author.login);
$("#authorImage").attr("src", data.author.avatar_url);
$("#authorImage").after("<b> " + data.author.login + "</b>");
$("#authorLink").attr("href", data.author.html_url);
},
error: function (jqXHR, exception) {
$("#latestRelease").attr("value", "Could not connect to GitHub.com");
$("#latestReleaseDate").attr("value", "Could not connect to GitHub.com");
$("#author").attr("value", "Could not connect to GitHub.com");
}
});
$.ajax({
cache: false,
method: 'GET',
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/contributors',
dataType: 'json',
contentType: "application/json",
success: function (data) {
data.forEach(contributor => $("#contributors").append("<a href=\"" + contributor.html_url + "\" title=\"" + contributor.login + "\">" +
"<img src=\"" + contributor.avatar_url + "\" style=\"width: 50px; height: 50px; border-radius: 50%; border: 1px solid #000; margin: 5px;\"/></a>"));
},
error: function (jqXHR, exception) {
$("#contributors").html("<p>Could not connect to GitHub.com</p>");
}
});
});
$(document).ajaxStop(function () {
if (Date.parse($("#currentReleaseDate").val()) < Date.parse($("#latestReleaseDate").val())) {
$("#currentReleaseDate").after("<p style=\"color:red\">Current version is out of date</p>")
}
});
</script>
{{ end }}
+674
View File
@@ -0,0 +1,674 @@
{{define "base.html"}}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{template "title" .}}</title>
<!-- Tell the browser to be responsive to screen width -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Favicon -->
<link rel="icon" href="{{.basePath}}/favicon">
<!-- Font Awesome -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/fontawesome-free/css/all.min.css">
<!-- iCheck for checkboxes and radio inputs -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/icheck-bootstrap/icheck-bootstrap.min.css">
<!-- Select2 -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/select2/css/select2.min.css">
<!-- Toastr -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/toastr/toastr.min.css">
<!-- Jquery Tags Input -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/jquery-tags-input/dist/jquery.tagsinput.min.css">
<!-- Ionicons -->
<link rel="stylesheet" href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css">
<!-- overlayScrollbars -->
<link rel="stylesheet" href="{{.basePath}}/static/dist/css/adminlte.min.css">
<!-- Google Font: Source Sans Pro -->
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700" rel="stylesheet">
<!-- START: On page css -->
{{template "top_css" .}}
<!-- END: On page css -->
</head>
<body class="hold-transition sidebar-mini">
<!-- Site wrapper -->
<div class="wrapper">
<!-- Navbar -->
<nav class="main-header navbar navbar-expand navbar-white navbar-light">
<!-- Left navbar links -->
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" data-widget="pushmenu" href="#" role="button"><i class="fas fa-bars"></i></a>
</li>
</ul>
<!-- SEARCH FORM -->
<form class="form-inline ml-3" style="display: none" id="search-form">
<div class="input-group input-group-sm">
<input class="form-control form-control-navbar" placeholder="Search"
aria-label="Search" id="search-input">
<div class="input-group-append">
<button class="btn-navbar" type="submit" disabled>
<i class="fas fa-search"></i>
</button>
</div>
</div>
<div class="form-group form-group-sm">
<select name="status-selector" id="status-selector" class="custom-select form-control-navbar" style="margin-left: 0.5em; height: 90%; font-size: 14px;">
<!-- THIS SECTION IS OVERRIDDEN BY JS. SEE updateSearchList() function in clients.html BEFORE EDITING -->
<option value="All">All</option>
<option value="Enabled">Enabled</option>
<option value="Disabled">Disabled</option>
<option value="Connected">Connected</option>
<option value="Disconnected">Disconnected</option>
<!-- THIS SECTION IS OVERRIDDEN BY JS. SEE updateSearchList() function in clients.html BEFORE EDITING -->
</select>
</div>
</form>
<!-- Right navbar links -->
<div class="navbar-nav ml-auto">
<button style="margin-left: 0.5em;" type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal"
data-target="#modal_new_client"><i class="nav-icon fas fa-plus"></i> New
Client</button>
<button id="apply-config-button" style="margin-left: 0.5em; display: none;" type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
data-target="#modal_apply_config"><i class="nav-icon fas fa-check"></i> Apply
Config</button>
{{if .baseData.CurrentUser}}
<button onclick="location.href='{{.basePath}}/logout';" style="margin-left: 0.5em;" type="button"
class="btn btn-outline-danger btn-sm"><i class="nav-icon fas fa-sign-out-alt"></i> Logout</button>
{{end}}
</div>
</nav>
<!-- /.navbar -->
<!-- Main Sidebar Container -->
<aside class="main-sidebar sidebar-dark-primary elevation-4">
<!-- Brand Logo -->
<a href="{{.basePath}}" class="brand-link">
<span class="brand-text">&nbsp; WIREGUARD UI</span>
</a>
<!-- Sidebar -->
<div class="sidebar">
<!-- Sidebar user (optional) -->
<div class="user-panel mt-3 pb-3 mb-3 d-flex">
<div class="image">
<i class="nav-icon fas fa-2x fa-user"></i>
</div>
<div class="info">
{{if .baseData.CurrentUser}}
{{if .baseData.Admin}}
<a href="{{.basePath}}/profile" class="d-block">Administrator: {{.baseData.CurrentUser}}</a>
{{else}}
<a href="{{.basePath}}/profile" class="d-block">Manager: {{.baseData.CurrentUser}}</a>
{{end}}
{{else}}
<a href="#" class="d-block">Administrator</a>
{{end}}
</div>
</div>
<!-- Sidebar Menu -->
<nav class="mt-2">
<ul class="nav nav-pills nav-sidebar flex-column" data-widget="treeview" role="menu" data-accordion="false">
<li class="nav-header">MAIN</li>
<li class="nav-item">
<a href="{{.basePath}}/" class="nav-link {{if eq .baseData.Active ""}}active{{end}}">
<i class="nav-icon fas fa-user-secret"></i>
<p>
Wireguard Clients
</p>
</a>
</li>
{{if .baseData.Admin}}
<li class="nav-item">
<a href="{{.basePath}}/wg-server" class="nav-link {{if eq .baseData.Active "wg-server" }}active{{end}}">
<i class="nav-icon fas fa-server"></i>
<p>
Wireguard Server
</p>
</a>
</li>
<li class="nav-header">SETTINGS</li>
<li class="nav-item">
<a href="{{.basePath}}/global-settings" class="nav-link {{if eq .baseData.Active "global-settings" }}active{{end}}">
<i class="nav-icon fas fa-cog"></i>
<p>
Global Settings
</p>
</a>
</li>
{{if not .loginDisabled}}
<li class="nav-item">
<a href="{{.basePath}}/users-settings" class="nav-link {{if eq .baseData.Active "users-settings" }}active{{end}}">
<i class="nav-icon fas fa-cog"></i>
<p>
Users Settings
</p>
</a>
</li>
{{end}}
{{end}}
<li class="nav-header">UTILITIES</li>
<li class="nav-item">
<a href="{{.basePath}}/status" class="nav-link {{if eq .baseData.Active "status" }}active{{end}}">
<i class="nav-icon fas fa-signal"></i>
<p>
Status
</p>
</a>
</li>
<li class="nav-item">
<a href="{{.basePath}}/wake_on_lan_hosts" class="nav-link {{if eq .baseData.Active "wake_on_lan_hosts" }}active{{end}}">
<i class="nav-icon fas fa-solid fa-power-off"></i>
<p>
WoL Hosts
</p>
</a>
</li>
<li class="nav-header">ABOUT</li>
<li class="nav-item">
<a href="{{.basePath}}/about" class="nav-link {{if eq .baseData.Active "about" }}active{{end}}">
<i class="nav-icon fas fa-solid fa-id-card"></i>
<p>
About
</p>
</a>
</li>
</ul>
</nav>
<!-- /.sidebar-menu -->
</div>
<!-- /.sidebar -->
</aside>
<div class="modal fade" id="modal_new_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">New Wireguard Client</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_new_client" id="frm_new_client">
<div class="modal-body">
<div class="form-group">
<label for="client_name" class="control-label">Name</label>
<input type="text" class="form-control" id="client_name" name="client_name">
</div>
<div class="form-group">
<label for="client_email" class="control-label">Email</label>
<input type="text" class="form-control" id="client_email" name="client_email">
</div>
<div class="form-group">
<label for="subnet_ranges" class="control-label">Subnet range</label>
<select id="subnet_ranges" class="select2"
data-placeholder="Select a subnet range" style="width: 100%;">
</select>
</div>
<div class="form-group">
<label for="client_allocated_ips" class="control-label">IP Allocation</label>
<input type="text" data-role="tagsinput" class="form-control" id="client_allocated_ips">
</div>
<div class="form-group">
<label for="client_allowed_ips" class="control-label">Allowed IPs
<i class="fas fa-info-circle" data-toggle="tooltip"
data-original-title="Specify a list of addresses that will get routed to the
server. These addresses will be included in 'AllowedIPs' of client config">
</i>
</label>
<input type="text" data-role="tagsinput" class="form-control" id="client_allowed_ips"
value="{{ StringsJoin .client_defaults.AllowedIps "," }}">
</div>
<div class="form-group">
<label for="client_extra_allowed_ips" class="control-label">Extra Allowed IPs
<i class="fas fa-info-circle" data-toggle="tooltip"
data-original-title="Specify a list of addresses that will get routed to the
client. These addresses will be included in 'AllowedIPs' of WG server config">
</i>
</label>
<input type="text" data-role="tagsinput" class="form-control" id="client_extra_allowed_ips" value="{{ StringsJoin .client_defaults.ExtraAllowedIps "," }}">
</div>
<div class="form-group">
<label for="client_endpoint" class="control-label">Endpoint</label>
<input type="text" class="form-control" id="client_endpoint" name="client_endpoint">
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="use_server_dns" {{ if .client_defaults.UseServerDNS }}checked{{ end }}>
<label for="use_server_dns">
Use server DNS
</label>
</div>
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="enabled" {{ if .client_defaults.EnableAfterCreation }}checked{{ end }}>
<label for="enabled">
Enable after creation
</label>
</div>
</div>
<details>
<summary><strong>Public and Preshared Keys</strong>
<i class="fas fa-info-circle" data-toggle="tooltip"
data-original-title="If you don't want to let the server generate and store the
client's private key, you can manually specify its public and preshared key here
. Note: QR code will not be generated">
</i>
</summary>
<div class="form-group" style="margin-top: 1rem">
<label for="client_public_key" class="control-label">
Public Key
</label>
<input type="text" class="form-control" id="client_public_key" name="client_public_key" placeholder="Autogenerated" aria-invalid="false">
</div>
<div class="form-group">
<label for="client_preshared_key" class="control-label">
Preshared Key
</label>
<input type="text" class="form-control" id="client_preshared_key" name="client_preshared_key" placeholder="Autogenerated - enter &quot;-&quot; to skip generation">
</div>
</details>
<details style="margin-top: 0.5rem;">
<summary><strong>Additional configuration</strong>
</summary>
<div class="form-group" style="margin-top: 0.5rem;">
<label for="client_telegram_userid" class="control-label">Telegram userid</label>
<input type="text" class="form-control" id="client_telegram_userid" name="client_telegram_userid">
</div>
<div class="form-group">
<label for="additional_notes" class="control-label">Notes</label>
<textarea class="form-control" style="min-height: 6rem;" id="additional_notes" name="additional_notes" placeholder="Additional notes about this client"></textarea>
</div>
</details>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_apply_config">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Apply Config</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Do you want to write config file and restart WireGuard server?</p>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="apply_config_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<!-- Content Wrapper. Contains page content -->
<div class="content-wrapper">
<!-- Content Header (Page header) -->
<section class="content-header">
<div class="container-fluid">
<div class="row mb-2">
<div class="col-sm-6">
<h1>{{template "page_title" .}}</h1>
</div>
</div>
</div><!-- /.container-fluid -->
</section>
<!-- Main content -->
{{template "page_content" .}}
<!-- /.content -->
</div>
<!-- /.content-wrapper -->
<!--
<footer class="main-footer">
<div class="float-right d-none d-sm-block">
<b>Version</b> {{ .appVersion }}
</div>
<strong>Copyright &copy; <script>document.write(new Date().getFullYear())</script> <a href="https://github.com/ngoduykhanh/wireguard-ui">Wireguard UI</a>.</strong> All rights
reserved.
</footer>
-->
<!-- Control Sidebar -->
<aside class="control-sidebar control-sidebar-dark">
<!-- Control sidebar content goes here -->
</aside>
<!-- /.control-sidebar -->
</div>
<!-- ./wrapper -->
<!-- jQuery -->
<script src="{{.basePath}}/static/plugins/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<script src="{{.basePath}}/static/plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- Select2 -->
<script src="{{.basePath}}/static/plugins/select2/js/select2.full.min.js"></script>
<!-- jquery-validation -->
<script src="{{.basePath}}/static/plugins/jquery-validation/jquery.validate.min.js"></script>
<!-- Toastr -->
<script src="{{.basePath}}/static/plugins/toastr/toastr.min.js"></script>
<!-- Jquery Tags Input -->
<script src="{{.basePath}}/static/plugins/jquery-tags-input/dist/jquery.tagsinput.min.js"></script>
<!-- AdminLTE App -->
<script src="{{.basePath}}/static/dist/js/adminlte.min.js"></script>
<!-- Custom js -->
<script src="{{.basePath}}/static/custom/js/helper.js"></script>
<script>
// initialize all tooltips
$(function () {
$('[data-toggle="tooltip"]').tooltip()
})
$(document).ready(function () {
addGlobalStyle(`
.toast-top-right-fix {
top: 67px;
right: 12px;
}
`, 'toastrToastStyleFix')
toastr.options.closeDuration = 100;
// toastr.options.timeOut = 10000;
toastr.options.positionClass = 'toast-top-right-fix';
updateApplyConfigVisibility()
});
function addGlobalStyle(css, id) {
if (!document.querySelector('#' + id)) {
let head = document.head
if (!head) { return }
let style = document.createElement('style')
style.type = 'text/css'
style.id = id
style.innerHTML = css
head.appendChild(style)
}
}
function updateApplyConfigVisibility() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/test-hash',
dataType: 'json',
contentType: "application/json",
success: function(data) {
if (data.status) {
$("#apply-config-button").show()
}
else
{
$("#apply-config-button").hide()
}
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// populateClient function for render new client info
// on the client page.
function populateClient(client_id) {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
renderClientList([resp]);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitNewClient function for new client form submission
function submitNewClient() {
const name = $("#client_name").val();
const email = $("#client_email").val();
const telegram_userid = $("#client_telegram_userid").val();
const allocated_ips = $("#client_allocated_ips").val().split(",");
const allowed_ips = $("#client_allowed_ips").val().split(",");
const endpoint = $("#client_endpoint").val();
let use_server_dns = false;
let extra_allowed_ips = [];
if ($("#client_extra_allowed_ips").val() !== "") {
extra_allowed_ips = $("#client_extra_allowed_ips").val().split(",");
}
if ($("#use_server_dns").is(':checked')){
use_server_dns = true;
}
let enabled = false;
if ($("#enabled").is(':checked')){
enabled = true;
}
const public_key = $("#client_public_key").val();
const preshared_key = $("#client_preshared_key").val();
const additional_notes = $("#additional_notes").val();
const data = {"name": name, "email": email, "telegram_userid": telegram_userid, "allocated_ips": allocated_ips, "allowed_ips": allowed_ips,
"extra_allowed_ips": extra_allowed_ips, "endpoint": endpoint, "use_server_dns": use_server_dns, "enabled": enabled,
"public_key": public_key, "preshared_key": preshared_key, "additional_notes": additional_notes};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/new-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_new_client").modal('hide');
toastr.success('Created new client successfully');
// Update the home page (clients page) after adding successfully
if (window.location.pathname === "{{.basePath}}/") {
populateClient(resp.id);
}
updateApplyConfigVisibility()
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// updateIPAllocationSuggestion function for automatically fill
// the IP Allocation input with suggested ip addresses
function updateIPAllocationSuggestion(forceDefault = false) {
let subnetRange = $("#subnet_ranges").select2('val');
if (forceDefault || !subnetRange || subnetRange.length === 0) {
subnetRange = '__default_any__'
}
$.ajax({
cache: false,
method: 'GET',
url: `{{.basePath}}/api/suggest-client-ips?sr=${subnetRange}`,
dataType: 'json',
contentType: "application/json",
success: function(data) {
const allocated_ips = $("#client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#client_allocated_ips').removeTag(escape(item));
})
data.forEach(function (item, index) {
$('#client_allocated_ips').addTag(item);
})
},
error: function(jqXHR, exception) {
const allocated_ips = $("#client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#client_allocated_ips').removeTag(escape(item));
})
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
</script>
<script>
//Initialize Select2 Elements
$(".select2").select2()
// IP Allocation tag input
$("#client_allocated_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// AllowedIPs tag input
$("#client_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
$("#client_extra_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// New client form validation
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function () {
submitNewClient();
}
});
$("#frm_new_client").validate({
rules: {
client_name: {
required: true,
},
},
messages: {
client_name: {
required: "Please enter a name"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
});
// New Client modal event
$(document).ready(function () {
$("#modal_new_client").on('shown.bs.modal', function (e) {
$("#client_name").val("");
$("#client_email").val("");
$("#client_public_key").val("");
$("#client_preshared_key").val("");
$("#client_allocated_ips").importTags('');
$("#client_extra_allowed_ips").importTags('');
$("#client_endpoint").val('');
$("#client_telegram_userid").val('');
$("#additional_notes").val('');
updateSubnetRangesList("#subnet_ranges");
updateIPAllocationSuggestion(true);
});
});
// handle subnet range select
$('#subnet_ranges').on('select2:select', function (e) {
// console.log('Selected Option: ', $("#subnet_ranges").select2('val'));
updateIPAllocationSuggestion();
});
// apply_config_confirm button event
$(document).ready(function () {
$("#apply_config_confirm").click(function () {
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/api/apply-wg-config',
dataType: 'json',
contentType: "application/json",
success: function(data) {
updateApplyConfigVisibility()
$("#modal_apply_config").modal('hide');
toastr.success('Applied config successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
</script>
<!-- START: On page script -->
{{template "bottom_js" .}}
<!-- END: On page script -->
</body>
</html>
{{end}}
+964
View File
@@ -0,0 +1,964 @@
{{define "title"}}
Wireguard Clients
{{end}}
{{define "top_css"}}
<style>
.paused-client {
transition: transform .2s;
cursor: pointer;
}
i[class^="paused-client"]:hover { transform: scale(1.5); }
</style>
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Wireguard Clients
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<!-- <h5 class="mt-4 mb-2">Wireguard Clients</h5> -->
<div class="row" id="client-list">
</div>
<!-- /.row -->
</div>
</section>
<div class="modal fade" id="modal_email_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Email Configuration</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_email_client" id="frm_email_client">
<div class="modal-body">
<input type="hidden" id="e_client_id" name="e_client_id">
<div class="form-group">
<label for="e_client_email" class="control-label">Email address</label>
<input type="text" class="form-control" id="e_client_email" name="e_client_email">
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Send</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_qr_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">QR Code</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="qr_client_id" name="qr_client_id">
<img id="qr_code" class="w-100" style="image-rendering: pixelated;" src="" alt="QR code" />
<!-- do not include FwMark in any client configs: it is INVALID. -->
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_telegram_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Telegram Configuration</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_telegram_client" id="frm_telegram_client">
<div class="modal-body">
<input type="hidden" id="tg_client_id" name="tg_client_id">
<div class="form-group">
<label for="tg_client_userid" class="control-label">Telegram userid</label>
<input type="text" class="form-control" id="tg_client_userid" name="tg_client_userid">
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Send</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_edit_client">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Edit Client</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_edit_client" id="frm_edit_client">
<div class="modal-body">
<input type="hidden" id="_client_id" name="_client_id">
<div class="form-group">
<label for="_client_name" class="control-label">Name</label>
<input type="text" class="form-control" id="_client_name" name="_client_name">
</div>
<div class="form-group">
<label for="_client_email" class="control-label">Email</label>
<input type="text" class="form-control" id="_client_email" name="client_email">
</div>
<div class="form-group">
<label for="_subnet_ranges" class="control-label">Subnet range</label>
<select id="_subnet_ranges" class="select2"
data-placeholder="Select a subnet range" style="width: 100%;">
</select>
</div>
<div class="form-group">
<label for="_client_allocated_ips" class="control-label">IP Allocation</label>
<input type="text" data-role="tagsinput" class="form-control" id="_client_allocated_ips">
</div>
<div class="form-group">
<label for="_client_allowed_ips" class="control-label">Allowed IPs</label>
<input type="text" data-role="tagsinput" class="form-control" id="_client_allowed_ips">
</div>
<div class="form-group">
<label for="_client_extra_allowed_ips" class="control-label">Extra Allowed IPs</label>
<input type="text" data-role="tagsinput" class="form-control"
id="_client_extra_allowed_ips">
</div>
<div class="form-group">
<label for="_client_endpoint" class="control-label">Endpoint</label>
<input type="text" class="form-control" id="_client_endpoint" name="client_endpoint">
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="_use_server_dns">
<label for="_use_server_dns">
Use server DNS
</label>
</div>
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="_enabled">
<label for="_enabled">
Enable this client
</label>
</div>
</div>
<details>
<summary><strong>Public and Preshared Keys</strong>
<i class="fas fa-info-circle" data-toggle="tooltip"
data-original-title="Update the server stored
client Public and Preshared keys.">
</i>
</summary>
<div class="form-group" style="margin-top: 1rem">
<label for="_client_public_key" class="control-label">
Public Key
</label>
<input type="text" class="form-control" id="_client_public_key" name="_client_public_key" aria-invalid="false">
</div>
<div class="form-group">
<label for="_client_preshared_key" class="control-label">
Preshared Key
</label>
<input type="text" class="form-control" id="_client_preshared_key" name="_client_preshared_key">
</div>
</details>
<details style="margin-top: 0.5rem;">
<summary><strong>Additional configuration</strong>
</summary>
<div class="form-group" style="margin-top: 0.5rem;">
<label for="_client_telegram_userid" class="control-label">Telegram userid</label>
<input type="text" class="form-control" id="_client_telegram_userid" name="_client_telegram_userid">
</div>
<div class="form-group">
<label for="_additional_notes" class="control-label">Notes</label>
<textarea class="form-control" style="min-height: 6rem;" id="_additional_notes" name="_additional_notes" placeholder="Additional notes about this client"></textarea>
</div>
</details>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Save</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_pause_client">
<div class="modal-dialog">
<div class="modal-content bg-warning">
<div class="modal-header">
<h4 class="modal-title">Disable</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="pause_client_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_remove_client">
<div class="modal-dialog">
<div class="modal-content bg-danger">
<div class="modal-header">
<h4 class="modal-title">Remove</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="remove_client_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{{end}}
{{define "bottom_js"}}
<script>
function populateClientList() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/clients',
dataType: 'json',
contentType: "application/json",
success: function (data) {
renderClientList(data);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function setClientStatus(clientID, status) {
const data = {"id": clientID, "status": status};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/client/set-status',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
console.log("Set client " + clientID + " status to " + status);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function resumeClient(clientID) {
setClientStatus(clientID, true);
const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "hidden";
updateApplyConfigVisibility()
}
function pauseClient(clientID) {
setClientStatus(clientID, false);
const divElement = document.getElementById("paused_" + clientID);
divElement.style.visibility = "visible";
updateApplyConfigVisibility()
}
// updateIPAllocationSuggestion function for automatically fill
// the IP Allocation input with suggested ip addresses
// FOR CHANGING A SUBNET OF AN EXISTING CLIENT
function updateIPAllocationSuggestionExisting() {
let subnetRange = $("#_subnet_ranges").select2('val');
if (!subnetRange || subnetRange.length === 0) {
subnetRange = '__default_any__'
}
$.ajax({
cache: false,
method: 'GET',
url: `{{.basePath}}/api/suggest-client-ips?sr=${subnetRange}`,
dataType: 'json',
contentType: "application/json",
success: function(data) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
data.forEach(function (item, index) {
$('#_client_allocated_ips').addTag(item);
})
},
error: function(jqXHR, exception) {
const allocated_ips = $("#_client_allocated_ips").val().split(",");
allocated_ips.forEach(function (item, index) {
$('#_client_allocated_ips').removeTag(escape(item));
})
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function updateSubnetRangesList(elementID, preselectedVal) {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$(`${elementID} option`).remove();
$(elementID).append(
$("<option></option>")
.text("Any")
.val("__default_any__")
);
$.each(data, function(index, item) {
$(elementID).append(
$("<option></option>")
.text(item)
.val(item)
);
if (item === preselectedVal) {
console.log(preselectedVal);
$(elementID).val(preselectedVal).trigger('change')
}
});
});
}
function updateSearchList() {
$.getJSON("{{.basePath}}/api/subnet-ranges", null, function(data) {
$("#status-selector option").remove();
$("#status-selector").append(
$("<option></option>")
.text("All")
.val("All"),
$("<option></option>")
.text("Enabled")
.val("Enabled"),
$("<option></option>")
.text("Disabled")
.val("Disabled"),
$("<option></option>")
.text("Connected")
.val("Connected"),
$("<option></option>")
.text("Disconnected")
.val("Disconnected")
);
$.each(data, function(index, item) {
$("#status-selector").append(
$("<option></option>")
.text(item)
.val(item)
);
});
});
}
</script>
<script>
// load client list
$(document).ready(function () {
updateSearchList();
populateClientList();
})
// show search bar and override :contains to be case-insensitive
$(document).ready(function () {
$("#search-form").show();
jQuery.expr[':'].contains = function(a, i, m) {
return jQuery(a).text().toUpperCase()
.indexOf(m[3].toUpperCase()) >= 0;
};
})
// hide all clients and display only the ones that meet the search criteria (name, email, IP)
$('#search-input').keyup(function () {
$("#status-selector").val("All");
let query = $(this).val().trim();
$('.col-lg-4').hide();
$(".info-box-text").each(function() {
if($(this).children('i.fa-user').length > 0 || $(this).children('i.fa-envelope').length > 0)
{
$(this).filter(':contains("' + query + '")').parent().parent().parent().show();
}
})
$(".badge-secondary").filter(':contains("' + query + '")').parent().parent().parent().show();
$(".fa-tguserid").each(function () {
if ($(this).parent().text().trim().indexOf(query) != -1) {
$(this).closest('.col-lg-4').show();
}
})
let upperQuery = query.toUpperCase()
$(".fa-additional_notes").each(function () {
if ($(this).parent().text().trim().indexOf(upperQuery) != -1) {
$(this).closest('.col-lg-4').show();
}
})
})
$("#status-selector").on('change', function () {
$('#search-input').val("");
switch ($("#status-selector").val()) {
case "All":
$('.col-lg-4').show();
break;
case "Enabled":
$('.col-lg-4').hide();
$('[id^="paused_"]').each(function () {
if ($(this).css("visibility") === "hidden") {
$(this).parent().parent().show();
}
});
break;
case "Disabled":
$('.col-lg-4').hide();
$('[id^="paused_"]').each(function () {
if ($(this).css("visibility") !== "hidden") {
$(this).parent().parent().show();
}
});
break;
case "Connected":
$('.col-lg-4').hide();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/status',
success: function (data) {
const returnedHTML = $(data).find(".table-success").get();
var returnedString = "";
returnedHTML.forEach(entry => returnedString += entry.outerHTML);
$(".fa-key").each(function () {
if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
$(this).closest('.col-lg-4').show();
}
})
}
});
break;
case "Disconnected":
$('.col-lg-4').show();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/status',
success: function (data) {
const returnedHTML = $(data).find(".table-success").get();
var returnedString = "";
returnedHTML.forEach(entry => returnedString += entry.outerHTML);
$(".fa-key").each(function () {
if (returnedString.indexOf($(this).parent().text().trim()) != -1) {
$(this).closest('.col-lg-4').hide();
}
})
}
});
break;
default:
$('.col-lg-4').hide();
const selectedSR = $("#status-selector").val()
$(".fa-subnetrange").each(function () {
const srs = $(this).parent().text().trim().split(',')
for (const sr of srs) {
if (sr === selectedSR) {
$(this).closest('.col-lg-4').show();
break
}
}
})
// $('.col-lg-4').show();
break;
}
});
// modal_pause_client modal event
$("#modal_pause_client").on('show.bs.modal', function (event) {
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
const client_name = button.data('clientname');
const modal = $(this);
modal.find('.modal-body').text("You are about to disable client " + client_name);
modal.find('#pause_client_confirm').val(client_id);
})
// pause_client_confirm button event
$(document).ready(function () {
$("#pause_client_confirm").click(function () {
const client_id = $(this).val();
pauseClient(client_id);
$("#modal_pause_client").modal('hide');
});
});
// modal_remove_client modal event
$("#modal_remove_client").on('show.bs.modal', function (event) {
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
const client_name = button.data('clientname');
const modal = $(this);
modal.find('.modal-body').text("You are about to remove client " + client_name);
modal.find('#remove_client_confirm').val(client_id);
})
// remove_client_confirm button event
$(document).ready(function () {
$("#remove_client_confirm").click(function () {
const client_id = $(this).val();
const data = {"id": client_id};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/remove-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(data) {
$("#modal_remove_client").modal('hide');
toastr.success('Removed client successfully');
const divElement = document.getElementById('client_' + client_id);
divElement.style.display = "none";
updateApplyConfigVisibility()
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// Edit client modal event
// This fills the modal dialogue with data from the DB when we open the edit menu
$(document).ready(function () {
$("#modal_edit_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
// IP Allocation tag input
modal.find("#_client_allocated_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// AllowedIPs tag input
modal.find("#_client_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
modal.find("#_client_extra_allowed_ips").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace' : true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
})
// update client modal data
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Edit Client " + client.name);
modal.find("#_client_id").val(client.id);
modal.find("#_client_telegram_userid").val(client.telegram_userid);
modal.find("#_client_name").val(client.name);
modal.find("#_client_email").val(client.email);
let preselectedEl
if (client.subnet_ranges && client.subnet_ranges.length > 0) {
preselectedEl = client.subnet_ranges[0]
}
updateSubnetRangesList("#_subnet_ranges", preselectedEl);
modal.find("#_client_allocated_ips").importTags('');
client.allocated_ips.forEach(function (obj) {
modal.find("#_client_allocated_ips").addTag(obj);
});
modal.find("#_client_allowed_ips").importTags('');
client.allowed_ips.forEach(function (obj) {
modal.find("#_client_allowed_ips").addTag(obj);
});
modal.find("#_client_extra_allowed_ips").importTags('');
client.extra_allowed_ips.forEach(function (obj) {
modal.find("#_client_extra_allowed_ips").addTag(obj);
});
modal.find("#_client_endpoint").val(client.endpoint);
modal.find("#_use_server_dns").prop("checked", client.use_server_dns);
modal.find("#_enabled").prop("checked", client.enabled);
modal.find("#_client_public_key").val(client.public_key);
modal.find("#_client_preshared_key").val(client.preshared_key);
modal.find("#_additional_notes").val(client.additional_notes);
// handle subnet range select
$('#_subnet_ranges').on('select2:select', function (e) {
updateIPAllocationSuggestionExisting();
});
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// regenerateQRCode function for regenerating QR Code adding/removing some parts of configuration because of compatibility issues with some clients
function regenerateQRCode() {
const client_id = $("#qr_client_id").val();
const QRCodeImg = $("#qr_code");
const QRCodeA = $("#qr_code_a");
QRCodeImg.hide();
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/client/' + client_id,
data: {
},
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
$(".modal-title").text("Scan QR Code for " + client.name + " profile");
QRCodeImg.attr('src', resp.QRCode).show();
QRCodeA.attr('download', resp.Client.name);
QRCodeA.attr('href', resp.QRCode).show();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitEmailClient function for sending an email with the configuration to the client
function submitEmailClient() {
const client_id = $("#e_client_id").val();
const email = $("#e_client_email").val();
const data = {"id": client_id, "email": email};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/email-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_email_client").modal('hide');
toastr.success('Sent email to client successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitTelegramClient function for sending a telegram message with the configuration to the client
function submitTelegramClient() {
const client_id = $("#tg_client_id").val();
const userid = $("#tg_client_userid").val();
const data = {"id": client_id, "userid": userid};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/send-telegram-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_telegram_client").modal('hide');
toastr.success('Sent config via telegram to client successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitEditClient function for updating an existing client
// This sends dialogue data to the back-end when user presses "Save"
// See e.g. routes.go:UpdateClient for where data is processed/verified.
function submitEditClient() {
const client_id = $("#_client_id").val();
const name = $("#_client_name").val();
const email = $("#_client_email").val();
const telegram_userid = $("#_client_telegram_userid").val();
const allocated_ips = $("#_client_allocated_ips").val().split(",");
const allowed_ips = $("#_client_allowed_ips").val().split(",");
let use_server_dns = false;
let extra_allowed_ips = [];
const public_key = $("#_client_public_key").val();
const preshared_key = $("#_client_preshared_key").val();
if( $("#_client_extra_allowed_ips").val() !== "" ) {
extra_allowed_ips = $("#_client_extra_allowed_ips").val().split(",");
}
const endpoint = $("#_client_endpoint").val();
if ($("#_use_server_dns").is(':checked')){
use_server_dns = true;
}
let enabled = false;
if ($("#_enabled").is(':checked')){
enabled = true;
}
const additional_notes = $("#_additional_notes").val();
const data = {"id": client_id, "name": name, "email": email, "telegram_userid": telegram_userid, "allocated_ips": allocated_ips,
"allowed_ips": allowed_ips, "extra_allowed_ips": extra_allowed_ips, "endpoint": endpoint,
"use_server_dns": use_server_dns, "enabled": enabled, "public_key": public_key, "preshared_key": preshared_key, "additional_notes": additional_notes};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/update-client',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(resp) {
$("#modal_edit_client").modal('hide');
toastr.success('Updated client successfully');
// Refresh the home page (clients page) after updating successfully
location.reload();
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
// submitHandler
function submitHandler(form) {
const formId = $(form).attr('id');
if (formId === "frm_edit_client") {
submitEditClient();
} else if (formId === "frm_email_client") {
submitEmailClient();
} else if (formId === "frm_telegram_client") {
submitTelegramClient();
}
}
$("#modal_email_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Send config to client " + client.name);
modal.find("#e_client_id").val(client.id);
modal.find("#e_client_email").val(client.email);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$("#modal_qr_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
modal.find("#qr_client_id").val(client_id);
regenerateQRCode();
});
$("#modal_telegram_client").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const client_id = button.data('clientid');
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/client/' + client_id,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const client = resp.Client;
modal.find(".modal-title").text("Send config to client " + client.name);
modal.find("#tg_client_id").val(client.id);
modal.find("#tg_client_userid").val(client.telegram_userid);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function (form) {
submitHandler(form);
}
});
// Edit client form validation
$("#frm_edit_client").validate({
rules: {
client_name: {
required: true,
},
},
messages: {
client_name: {
required: "Please enter a name"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
// Email client form validation
$("#frm_email_client").validate({
rules: {
e_client_email: {
required: true,
email: true,
},
},
messages: {
e_client_email: {
required: "Please enter an email"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
// Telegram client form validation
$("#frm_telegram_client").validate({
rules: {
tg_client_userid: {
required: true,
number: true,
},
},
messages: {
tg_client_userid: {
required: "Please enter a telegram userid",
number: "Please enter a valid telegram userid"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
//
});
</script>
{{end}}
+284
View File
@@ -0,0 +1,284 @@
{{define "title"}}
Global Settings
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Global Settings
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<!-- <h5 class="mt-4 mb-2">Global Settings</h5> -->
<div class="row">
<!-- left column -->
<div class="col-md-6">
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">Wireguard Global Settings</h3>
</div>
<!-- /.card-header -->
<!-- form start -->
<form role="form" id="frm_global_settings" name="frm_global_settings">
<div class="card-body">
<div class="form-group">
<label for="endpoint_address">Endpoint Address</label>
<div class="input-group input-group">
<input type="text" class="form-control" id="endpoint_address" name="endpoint_address" placeholder="Endpoint Address"
value="{{ .globalSettings.EndpointAddress }}">
<span class="input-group-append">
<button type="button" class="btn btn-success btn-flat" data-toggle="modal"
data-target="#modal_endpoint_address_suggestion"><i
class="nav-icon fas fa-magic"></i> Suggest</button>
</span>
</div>
</div>
<div class="form-group">
<label for="dns_servers" class="control-label">DNS Servers</label>
<input type="text" data-role="tagsinput" class="form-control" id="dns_servers" value="">
</div>
<div class="form-group">
<label for="mtu">MTU</label>
<input type="text" class="form-control" id="mtu" name="mtu" placeholder="MTU"
value="{{if .globalSettings.MTU}}{{ .globalSettings.MTU }}{{end}}">
</div>
<div class="form-group">
<label for="persistent_keepalive">Persistent Keepalive</label>
<input type="text" class="form-control" id="persistent_keepalive"
name="persistent_keepalive" placeholder="Persistent Keepalive"
value="{{if .globalSettings.PersistentKeepalive }}{{ .globalSettings.PersistentKeepalive }}{{end}}">
</div>
<div class="form-group">
<label for="firewall_mark">Firewall Mark</label>
<input type="text" class="form-control" id="firewall_mark"
name="firewall_mark" placeholder="Firewall Mark"
value="{{ .globalSettings.FirewallMark }}">
</div>
<div class="form-group">
<label for="Table">Table</label>
<input type="text" class="form-control" id="table"
name="table" placeholder="auto"
value="{{ .globalSettings.Table }}">
</div>
<div class="form-group">
<label for="config_file_path">Wireguard Config File Path</label>
<input type="text" class="form-control" id="config_file_path"
name="config_file_path" placeholder="E.g. /etc/wireguard/wg0.conf"
value="{{ .globalSettings.ConfigFilePath }}">
</div>
</div>
<!-- /.card-body -->
<div class="card-footer">
<button type="submit" class="btn btn-success">Save</button>
</div>
</form>
</div>
<!-- /.card -->
</div>
<div class="col-md-6">
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">Help</h3>
</div>
<!-- /.card-header -->
<div class="card-body">
<dl>
<dt>1. Endpoint Address</dt>
<dd>The public IP address of your Wireguard server that the client will connect to. Click on
<strong>Suggest</strong> button to auto detect the public IP address of your server.</dd>
<dt>2. DNS Servers</dt>
<dd>The DNS servers will be set to client config.</dd>
<dt>3. MTU</dt>
<dd>The MTU will be set to server and client config. By default it is <code>1450</code>. You might want
to adjust the MTU size if your connection (e.g PPPoE, 3G, satellite network, etc) has a low MTU.</dd>
<dd>Leave blank to omit this setting in the configs.</dd>
<dt>4. Persistent Keepalive</dt>
<dd>By default, WireGuard peers remain silent while they do not need to communicate,
so peers located behind a NAT and/or firewall may be unreachable from other peers
until they reach out to other peers themselves. Adding <code>PersistentKeepalive</code>
can ensure that the connection remains open.</dd>
<dd>Leave blank to omit this setting in the Client config.</dd>
<dt>5. Firewall Mark</dt>
<dd>Add a matching <code>fwmark</code> on all packets going out of a WireGuard non-default-route tunnel. Default value: <code>0xca6c</code></dd>
<dt>6. Table</dt>
<dd>Value for the <code>Table</code> setting in the wg conf file. Default value: <code>auto</code></dd>
<dt>7. Wireguard Config File Path</dt>
<dd>The path of your Wireguard server config file. Please make sure the parent directory
exists and is writable.</dd>
</dl>
</div>
</div>
<!-- /.card -->
</div>
</div>
<!-- /.row -->
</div>
</section>
<div class="modal fade" id="modal_endpoint_address_suggestion">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Endpoint Address Suggestion</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Following is the list of public and local IP addresses for your consideration.</p>
<select id="ip_suggestion" class="select2"
data-placeholder="Select an IP address" style="width: 100%;">
</select>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" id="btn_use_ip" disabled>Use selected IP address</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{{end}}
{{define "bottom_js"}}
<script>
function submitGlobalSettings() {
const endpoint_address = $("#endpoint_address").val();
const dns_servers = $("#dns_servers").val().split(",");
const mtu = $("#mtu").val();
const persistent_keepalive = $("#persistent_keepalive").val();
const firewall_mark = $("#firewall_mark").val();
const table = $("#table").val();
const config_file_path = $("#config_file_path").val();
const data = {"endpoint_address": endpoint_address, "dns_servers": dns_servers, "mtu": mtu, "persistent_keepalive": persistent_keepalive, "firewall_mark": firewall_mark, "table": table, "config_file_path": config_file_path};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/global-settings',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(data) {
$("#modal_new_client").modal('hide');
toastr.success('Update global settings successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
function updateEndpointSuggestionIP() {
$.getJSON("{{.basePath}}/api/machine-ips", null, function(data) {
$("#ip_suggestion option").remove();
$.each(data, function(index, item) {
$("#ip_suggestion").append(
$("<option></option>")
.text(item.ip_address + ' - ' + item.name)
.val(item.ip_address)
);
});
document.getElementById("btn_use_ip").disabled = false;
});
}
</script>
<script>
// Wireguard Interface DNS server tag input
$("#dns_servers").tagsInput({
'width': '100%',
'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// Load DNS server to the form
{{range .globalSettings.DNSServers}}
$("#dns_servers").removeTag('{{.}}');
$("#dns_servers").addTag('{{.}}');
{{end}}
// Global setting form validation
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function () {
submitGlobalSettings();
}
});
$("#frm_global_settings").validate({
rules: {
mtu: {
digits: true,
range: [68, 65535]
},
persistent_keepalive: {
digits: true
},
config_file_path: {
required: true
},
firewall_mark: {
required: false
},
table: {
required: false
}
},
messages: {
mtu: {
digits: "MTU must be an integer",
range: "MTU must be in range 68..65535"
},
persistent_keepalive: {
digits: "Persistent keepalive must be an integer"
},
config_file_path: {
required: "Please enter WireGuard config file path"
}
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
});
// Endpoint IP suggestion modal event
$(document).ready(function () {
$("#modal_endpoint_address_suggestion").on('shown.bs.modal', function (e) {
updateEndpointSuggestionIP();
});
});
// Use selected IP address from suggestion form
$(document).ready(function () {
$("#btn_use_ip").click(function () {
const ip = $("#ip_suggestion").select2('val');
$("#endpoint_address").val(ip);
$("#modal_endpoint_address_suggestion").modal('hide');
});
});
</script>
{{end}}
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>WireGuard UI</title>
<!-- Tell the browser to be responsive to screen width -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Favicon -->
<link rel="icon" href="{{.basePath}}/favicon">
<!-- Font Awesome -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/fontawesome-free/css/all.min.css">
<!-- Ionicons -->
<link rel="stylesheet" href="https://code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css">
<!-- icheck bootstrap -->
<link rel="stylesheet" href="{{.basePath}}/static/plugins/icheck-bootstrap/icheck-bootstrap.min.css">
<!-- Theme style -->
<link rel="stylesheet" href="{{.basePath}}/static/dist/css/adminlte.min.css">
<!-- Google Font: Source Sans Pro -->
<link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:300,400,400i,700" rel="stylesheet">
</head>
<body class="hold-transition login-page">
<div class="login-box">
<div class="login-logo">
<a href="https://github.com/ngoduykhanh/wireguard-ui">WireGuard UI</a>
</div>
<!-- /.login-logo -->
<div class="card">
<div class="card-body login-card-body">
<p class="login-box-msg">Sign in to start your session</p>
<form action="" method="post">
<div class="input-group mb-3">
<input id="username" type="text" class="form-control" placeholder="Username">
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-envelope"></span>
</div>
</div>
</div>
<div class="input-group mb-3">
<input id="password" type="password" class="form-control" placeholder="Password">
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock"></span>
</div>
</div>
</div>
<div class="row">
<div class="col-8">
<div class="icheck-primary">
<input type="checkbox" id="remember">
<label for="remember">
Remember Me
</label>
</div>
</div>
<!-- /.col -->
<div class="col-4">
<button id="btn_login" type="submit" class="btn btn-primary btn-block">Sign In</button>
</div>
<!-- /.col -->
</div>
</form>
<div class="text-center mb-3">
<p id="message"></p>
</div>
</div>
<!-- /.login-card-body -->
</div>
</div>
<!-- /.login-box -->
<!-- jQuery -->
<script src="{{.basePath}}/static/plugins/jquery/jquery.min.js"></script>
<!-- Bootstrap 4 -->
<script src="{{.basePath}}/static/plugins/bootstrap/js/bootstrap.bundle.min.js"></script>
<!-- AdminLTE App -->
<script src="{{.basePath}}/static/dist/js/adminlte.min.js"></script>
</body>
<script>
function redirectNext() {
const urlParams = new URLSearchParams(window.location.search);
const nextURL = urlParams.get('next');
if (nextURL && /(?:^\/[a-zA-Z_])|(?:^\/$)/.test(nextURL.trim())) {
window.location.href = nextURL;
} else {
window.location.href = '/{{.basePath}}';
}
}
</script>
<script>
$(document).ready(function () {
$('form').on('submit', function(e) {
e.preventDefault();
$("#btn_login").trigger('click');
});
$("#btn_login").click(function () {
const username = $("#username").val();
const password = $("#password").val();
let rememberMe = false;
if ($("#remember").is(':checked')){
rememberMe = true;
}
const data = {"username": username, "password": password, "rememberMe": rememberMe}
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/login',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(data) {
document.getElementById("message").innerHTML = `<p style="color:green">${data['message']}</p>`;
// redirect after logging in successfully
redirectNext();
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
document.getElementById("message").innerHTML = `<p style="color:#ff0000">${responseJson['message']}</p>`;
}
});
});
});
</script>
</html>
+136
View File
@@ -0,0 +1,136 @@
{{ define "title"}}
Profile
{{ end }}
{{ define "top_css"}}
{{ end }}
{{ define "username"}}
{{ .username }}
{{ end }}
{{ define "page_title"}}
Profile
{{ end }}
{{ define "page_content"}}
<section class="content">
<div class="container-fluid">
<!-- <h5 class="mt-4 mb-2">Global Settings</h5> -->
<div class="row">
<!-- left column -->
<div class="col-md-6">
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">Update user information</h3>
</div>
<!-- /.card-header -->
<!-- form start -->
<form role="form" id="frm_profile" name="frm_profile">
<div class="card-body">
<div class="form-group">
<label for="username" class="control-label">Username</label>
<input type="text" class="form-control" name="username" id="username"
value="">
</div>
<div class="form-group">
<label for="password" class="control-label">Password</label>
<input type="password" class="form-control" name="password" id="password"
value="" placeholder="Leave empty to keep the password unchanged">
</div>
<!-- /.card-body -->
<div class="card-footer">
<button type="submit" class="btn btn-success" id="update">Update</button>
</div>
</div>
</form>
</div>
<!-- /.card -->
</div>
</div>
<!-- /.row -->
</div>
</section>
{{ end }}
{{ define "bottom_js"}}
<script>
{
var previous_username;
var admin;
}
$(document).ready(function () {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/user/{{.baseData.CurrentUser}}',
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const user = resp;
$("#username").val(user.username);
previous_username = user.username;
admin = user.admin;
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
function updateUserInfo() {
const username = $("#username").val();
const password = $("#password").val();
const data = {"username": username, "password": password, "previous_username": previous_username, "admin":admin};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/update-user',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
toastr.success("Updated user information successfully");
location.reload();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function () {
updateUserInfo();
}
});
$("#frm_profile").validate({
rules: {
username: {
required: true
}
},
messages: {
username: {
required: "Please enter a username",
}
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
});
</script>
{{ end }}
+255
View File
@@ -0,0 +1,255 @@
{{define "title"}}
Wireguard Server
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Wireguard Server Settings
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<!-- <h5 class="mt-4 mb-2">Wireguard Server</h5> -->
<div class="row">
<!-- left column -->
<div class="col-md-6">
<div class="card card-success">
<div class="card-header">
<h3 class="card-title">Interface</h3>
</div>
<!-- /.card-header -->
<!-- form start -->
<form role="form" id="frm_server_interface" name="frm_server_interface">
<div class="card-body">
<div class="form-group">
<label for="addresses" class="control-label">Server Interface Addresses</label>
<input type="text" data-role="tagsinput" class="form-control" id="addresses" value="">
</div>
<div class="form-group">
<label for="listen_port">Listen Port</label>
<input type="text" class="form-control" id="listen_port" name="listen_port"
placeholder="Listen Port" value="{{ .serverInterface.ListenPort }}">
</div>
<div class="form-group">
<label for="post_up">Post Up Script</label>
<input type="text" class="form-control" id="post_up" name="post_up"
placeholder="Post Up Script" value="{{ .serverInterface.PostUp }}">
</div>
<div class="form-group">
<label for="pre_down">Pre Down Script</label>
<input type="text" class="form-control" id="pre_down" name="pre_down"
placeholder="Pre Down Script" value="{{ .serverInterface.PreDown }}">
</div>
<div class="form-group">
<label for="post_down">Post Down Script</label>
<input type="text" class="form-control" id="post_down" name="post_down"
placeholder="Post Down Script" value="{{ .serverInterface.PostDown }}">
</div>
</div>
<!-- /.card-body -->
<div class="card-footer">
<button type="submit" class="btn btn-success">Save</button>
</div>
</form>
</div>
<!-- /.card -->
</div>
<!-- right column -->
<div class="col-md-6">
<div class="card card-danger">
<div class="card-header">
<h3 class="card-title">Key Pair</h3>
</div>
<!-- /.card-header -->
<!-- form start -->
<form role="form">
<div class="card-body">
<div class="form-group">
<label for="private_key">Private Key</label>
<div class="input-group input-group">
<input type="password" class="form-control" id="private_key" placeholder="Private Key"
value="{{ .serverKeyPair.PrivateKey }}" disabled>
<span class="input-group-append">
<button type="button" class="btn btn-danger btn-flat"
id="btn_show_private_key">Show</button>
</span>
</div>
</div>
<div class="form-group">
<label for="public_key">Public Key</label>
<input type="text" class="form-control" id="public_key" placeholder="Public Key"
value="{{ .serverKeyPair.PublicKey }}" disabled>
</div>
</div>
<!-- /.card-body -->
<div class="card-footer">
<button type="button" class="btn btn-danger" data-toggle="modal"
data-target="#modal_keypair_confirmation">Generate</button>
</div>
</form>
</div>
<!-- /.card -->
</div>
</div>
<!-- /.row -->
</div>
</section>
<div class="modal fade" id="modal_keypair_confirmation">
<div class="modal-dialog">
<div class="modal-content bg-warning">
<div class="modal-header">
<h4 class="modal-title">KeyPair Generation</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Are you sure to generate a new key pair for the Wireguard server?<br/>
The existing Client's peer public key need to be updated to keep the connection working.</p>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="btn_generate_confirm">Generate</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{{end}}
{{define "bottom_js"}}
<script>
function submitServerInterfaceSetting() {
const addresses = $("#addresses").val().split(",");
const listen_port = $("#listen_port").val();
const post_up = $("#post_up").val();
const pre_down = $("#pre_down").val();
const post_down = $("#post_down").val();
const data = {"addresses": addresses, "listen_port": listen_port, "post_up": post_up, "pre_down": pre_down, "post_down": post_down};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/wg-server/interfaces',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function(data) {
$("#modal_new_client").modal('hide');
toastr.success('Updated Wireguard server interface addresses successfully');
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
</script>
<script>
// Wireguard Interface Addresses tag input
$("#addresses").tagsInput({
'width': '100%',
// 'height': '75%',
'interactive': true,
'defaultText': 'Add More',
'removeWithBackspace': true,
'minChars': 0,
'minInputWidth': '100%',
'placeholderColor': '#666666'
});
// Load server addresses to the form
{{range .serverInterface.Addresses}}
$("#addresses").removeTag('{{.}}');
$("#addresses").addTag('{{.}}');
{{end}}
// Wireguard Interface Addresses form validation
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function () {
submitServerInterfaceSetting();
}
});
$("#frm_server_interface").validate({
rules: {
listen_port: {
required: true,
digits: true,
range: [1, 65535]
}
},
messages: {
listen_port: {
required: "Please enter a port",
digits: "Port must be an integer",
range: "Port must be in range 1..65535"
}
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
});
// Wireguard Key Pair generation confirmation button
$(document).ready(function () {
$("#btn_generate_confirm").click(function () {
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/wg-server/keypair',
dataType: 'json',
contentType: "application/json",
success: function(data) {
$("#modal_keypair_confirmation").modal('hide');
toastr.success('Generate new key pair successfully');
// update the UI
$("#private_key").val(data['private_key']);
$("#public_key").val(data['public_key']);
},
error: function(jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// Show private key button event
$(document).ready(function () {
$("#btn_show_private_key").click(function () {
const privateElement = document.getElementById("private_key");
const btnElement = document.getElementById("btn_show_private_key");
if (privateElement.type === 'password') {
privateElement.type = 'text';
btnElement.innerText = 'Hide';
} else {
privateElement.type = 'password';
btnElement.innerText = 'Show';
}
});
});
</script>
{{end}}
+75
View File
@@ -0,0 +1,75 @@
{{define "title"}}
Connected Peers
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Connected Peers
{{end}}
{{define "page_content"}}
<script>
function bytesToHumanReadable(temporal) {
const units = [" ", " K", " M", " G", " T", " P", " E", " Z", " Y"]
let pow = 0
while (temporal > 1024) {
temporal /= 1024
pow ++
if (pow == units.length-1) break
}
return parseFloat(temporal.toFixed(2)) + units[pow]+"B"
}
</script>
<section class="content">
<div class="container-fluid">
{{ if .error }}
<div class="alert alert-warning" role="alert">{{.error}}</div>
{{ end}}
{{ range $dev := .devices }}
<table class="table table-sm">
<caption>List of connected peers for device with name {{ $dev.Name }} </caption>
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Name</th>
<th scope="col">Email</th>
<th scope="col">Allocated IPs</th>
<th scope="col">Endpoint</th>
<th scope="col">Public Key</th>
<th scope="col">Received</th>
<th scope="col">Transmitted</th>
<th scope="col">Connected (Approximation)</th>
<th scope="col">Last Handshake</th>
</tr>
</thead>
<tbody>
{{ range $idx, $peer := $dev.Peers }}
<tr {{ if $peer.Connected }} class="table-success" {{ end }}>
<th scope="row">{{ $idx }}</th>
<td>{{ $peer.Name }}</td>
<td>{{ $peer.Email }}</td>
<td>{{ $peer.AllocatedIP }}</td>
<td>{{ $peer.Endpoint }}</td>
<td>{{ $peer.PublicKey }}</td>
<td title="{{ $peer.ReceivedBytes }} Bytes"><script>document.write(bytesToHumanReadable({{ $peer.ReceivedBytes }}))</script></td>
<td title="{{ $peer.TransmitBytes }} Bytes"><script>document.write(bytesToHumanReadable({{ $peer.TransmitBytes }}))</script></td>
<td>{{ if $peer.Connected }}✓{{end}}</td>
<td>{{ $peer.LastHandshakeTime.Format "2006-01-02 15:04:05 MST" }}</td>
</tr>
{{ end }}
</tbody>
</table>
{{ end }}
</div>
</section>
{{end}}
{{define "bottom_js"}}
{{end}}
+294
View File
@@ -0,0 +1,294 @@
{{define "title"}}
Users Settings
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Users Settings
{{end}}
{{define "page_content"}}
<section class="content">
<div class="container-fluid">
<div class="row" id="users-list">
</div>
</div>
</section>
<div class="modal fade" id="modal_edit_user">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Edit User</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_edit_user" id="frm_edit_user">
<div class="modal-body">
<div class="form-group" style="display:none">
<input type="text" style="display:none" class="form-control" id="_previous_user_name"
name="_previous_user_name">
</div>
<div class="form-group">
<label for="_user_name" class="control-label">Name</label>
<input type="text" class="form-control" id="_user_name" name="_user_name">
</div>
<div class="form-group">
<label for="_user_password" class="control-label">Password</label>
<input type="password" class="form-control" id="_user_password" name="_user_password" value=""
placeholder="Leave empty to keep the password unchanged">
</div>
<div class="form-group">
<div class="icheck-primary d-inline">
<input type="checkbox" id="_admin">
<label for="_admin">
Admin
</label>
</div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Save</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
<div class="modal fade" id="modal_remove_user">
<div class="modal-dialog">
<div class="modal-content bg-danger">
<div class="modal-header">
<h4 class="modal-title">Remove</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="remove_user_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<!-- /.modal -->
{{end}}
{{define "bottom_js"}}
<script>
function populateUsersList() {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/get-users',
dataType: 'json',
contentType: "application/json",
success: function (data) {
renderUserList(data);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
</script>
<script>
// load user list
$(document).ready(function () {
populateUsersList();
let newUserHtml = '<div class="col-sm-2 offset-md-4" style=" text-align: right;">' +
'<button style="" id="btn_new_user" type="button" class="btn btn-outline-primary btn-sm" ' +
'data-toggle="modal" data-target="#modal_edit_user" data-username="">' +
'<i class="nav-icon fas fa-plus"></i> New User</button></div>';
$('h1').parents(".row").append(newUserHtml);
})
// modal_remove_user modal event
$("#modal_remove_user").on('show.bs.modal', function (event) {
const button = $(event.relatedTarget);
const user_name = button.data('username');
const modal = $(this);
modal.find('.modal-body').text("You are about to remove user " + user_name);
modal.find('#remove_user_confirm').val(user_name);
})
// remove_user_confirm button event
$(document).ready(function () {
$("#remove_user_confirm").click(function () {
const user_name = $(this).val();
const data = {"username": user_name};
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/remove-user',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
$("#modal_remove_user").modal('hide');
toastr.success('Removed user successfully');
const divElement = document.getElementById('user_' + user_name);
divElement.style.display = "none";
location.reload()
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
});
});
// Edit user modal event
$(document).ready(function () {
$("#modal_edit_user").on('show.bs.modal', function (event) {
let modal = $(this);
const button = $(event.relatedTarget);
const user_name = button.data('username');
// update user modal data
if (user_name !== "") {
$.ajax({
cache: false,
method: 'GET',
url: '{{.basePath}}/api/user/' + user_name,
dataType: 'json',
contentType: "application/json",
success: function (resp) {
const user = resp;
modal.find(".modal-title").text("Edit user " + user.username);
modal.find("#_user_name").val(user.username);
modal.find("#_previous_user_name").val(user.username);
modal.find("#_user_password").val("");
modal.find("#_user_password").prop("placeholder", "Leave empty to keep the password unchanged")
modal.find("#_admin").prop("checked", user.admin);
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
} else {
modal.find(".modal-title").text("Add new user");
modal.find("#_user_name").val("");
modal.find("#_previous_user_name").val("");
modal.find("#_user_password").val("");
modal.find("#_user_password").prop("placeholder", "")
modal.find("#_admin").prop("checked", false);
}
});
});
function updateUserInfo() {
const username = $("#_user_name").val();
const previous_username = $("#_previous_user_name").val();
const password = $("#_user_password").val();
let admin = false;
if ($("#_admin").is(':checked')) {
admin = true;
}
const data = {
"username": username,
"password": password,
"previous_username": previous_username,
"admin": admin
};
if (previous_username !== "") {
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/update-user',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
toastr.success("Updated user information successfully");
location.reload();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
} else {
$.ajax({
cache: false,
method: 'POST',
url: '{{.basePath}}/create-user',
dataType: 'json',
contentType: "application/json",
data: JSON.stringify(data),
success: function (data) {
toastr.success("Created user successfully");
location.reload();
},
error: function (jqXHR, exception) {
const responseJson = jQuery.parseJSON(jqXHR.responseText);
toastr.error(responseJson['message']);
}
});
}
}
$(document).ready(function () {
$.validator.setDefaults({
submitHandler: function (form) {
updateUserInfo();
}
});
// Edit user form validation
$("#frm_edit_user").validate({
rules: {
_user_name: {
required: true
},
_user_password: {
required: function () {
return $("#_previous_user_name").val() === "";
}
},
},
messages: {
_user_name: {
required: "Please enter a username"
},
_user_password: {
required: "Please input a password"
},
},
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('invalid-feedback');
element.closest('.form-group').append(error);
},
highlight: function (element, errorClass, validClass) {
$(element).addClass('is-invalid');
},
unhighlight: function (element, errorClass, validClass) {
$(element).removeClass('is-invalid');
}
});
//
});
</script>
{{end}}
+123
View File
@@ -0,0 +1,123 @@
{{define "title"}}
Wake On Lan Hosts
{{end}}
{{define "top_css"}}
{{end}}
{{define "username"}}
{{ .username }}
{{end}}
{{define "page_title"}}
Wake On Lan Hosts
{{end}}
{{define "page_content"}}
<div class="modal fade" id="modal_wake_on_lan_host">
<!-- MacAddress string `json:"MacAddress"`-->
<!-- Name string `json:"Name"`-->
<!-- LatestIPAddress string `json:"LatestIPAddress"`-->
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">New Wake On Lan Host</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form name="frm_wake_on_lan_host" id="frm_wake_on_lan_host">
<div class="modal-body">
<input type="hidden" id="frm_wake_on_lan_host_old_mac_address" name="old_mac_address">
<div class="form-group">
<label for="frm_wake_on_lan_host_name" class="control-label">Name</label>
<input type="text" class="form-control" id="frm_wake_on_lan_host_name" name="name">
</div>
<div class="form-group">
<label for="frm_wake_on_lan_host_mac_address" class="control-label">Mac Address</label>
<input type="text" class="form-control" id="frm_wake_on_lan_host_mac_address"
name="mac_address">
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</form>
</div>
<!-- /.modal-content -->
</div>
</div>
<div class="modal fade" id="modal_remove_wake_on_lan_host">
<div class="modal-dialog">
<div class="modal-content bg-danger">
<div class="modal-header">
<h4 class="modal-title">Remove</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-dark" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-dark" id="remove_wake_on_host_confirm">Apply</button>
</div>
</div>
<!-- /.modal-content -->
</div>
<!-- /.modal-dialog -->
</div>
<section class="content">
<div class="container-fluid">
{{ if .error }}
<div class="alert alert-warning" role="alert">{{.error}}</div>
{{ end}}
<div class="row">
{{ range $idx, $host := .hosts }}
{{- /*gotype: github.com/ngoduykhanh/wireguard-ui/model.WakeOnLanHost*/ -}}
<div class="col-sm-4" id="{{ $host.ResolveResourceName }}">
<div class="info-box">
<div class="info-box-content">
<div class="btn-group">
<button type="button" class="btn btn-outline-success btn-sm"
data-mac-address="{{ .MacAddress }}">Wake On
</button>
<button type="button"
class="btn btn-outline-primary btn-sm btn_modify_wake_on_lan_host"
data-toggle="modal" data-target="#modal_wake_on_lan_host"
data-name="{{ .Name }}" data-mac-address="{{ .MacAddress }}">Edit
</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-toggle="modal"
data-target="#modal_remove_wake_on_lan_host"
data-mac-address="{{ .MacAddress }}">Remove
</button>
</div>
<hr>
<span class="info-box-text"><i class="fas fa-address-card"></i> <span class="name">{{ .Name }}</span></span>
<span class="info-box-text"><i class="fas fa-ethernet"></i> <span class="mac-address">{{ .MacAddress }}</span></span>
<span class="info-box-text"><i class="fas fa-clock"></i>
<span class="latest-used">
{{ if .LatestUsed }}
{{ .LatestUsed.Format "2006-01-02T15:04:05Z07:00"}}
{{ else }}
Unused
{{ end }}
</span>
</span>
</div>
</div>
</div>
{{ end }}
</div>
</div>
</section>
{{end}}
{{define "bottom_js"}}
<script src="{{.basePath}}/static/custom/js/wake_on_lan_hosts.js"></script>
{{end}}
+33
View File
@@ -0,0 +1,33 @@
# This file was generated using wireguard-ui (https://github.com/ngoduykhanh/wireguard-ui)
# Please don't modify it manually, otherwise your change might get replaced.
# Address updated at: {{ .serverConfig.Interface.UpdatedAt }}
# Private Key updated at: {{ .serverConfig.KeyPair.UpdatedAt }}
[Interface]
Address = {{$first :=true}}{{range .serverConfig.Interface.Addresses }}{{if $first}}{{$first = false}}{{else}},{{end}}{{.}}{{end}}
ListenPort = {{ .serverConfig.Interface.ListenPort }}
PrivateKey = {{ .serverConfig.KeyPair.PrivateKey }}
{{if .globalSettings.MTU}}MTU = {{ .globalSettings.MTU }}{{end}}
PostUp = {{ .serverConfig.Interface.PostUp }}
PreDown = {{ .serverConfig.Interface.PreDown }}
PostDown = {{ .serverConfig.Interface.PostDown }}
Table = {{ .globalSettings.Table }}
{{range .clientDataList}}{{if eq .Client.Enabled true}}
# ID: {{ .Client.ID }}
# Name: {{ .Client.Name }}
# Email: {{ .Client.Email }}
# Telegram: {{ .Client.TgUserid }}
# Created at: {{ .Client.CreatedAt }}
# Update at: {{ .Client.UpdatedAt }}
{{- if .Client.AdditionalNotes}}
# Notes:
# {{ .Client.AdditionalNotes }}{{end}}
[Peer]
PublicKey = {{ .Client.PublicKey }}
{{if .Client.PresharedKey}}PresharedKey = {{ .Client.PresharedKey }}{{end}}
AllowedIPs = {{$first :=true}}{{range .Client.AllocatedIPs }}{{if $first}}{{$first = false}}{{else}},{{end}}{{.}}{{end}}{{range .Client.ExtraAllowedIPs }},{{.}}{{end}}
{{if $.globalSettings.PersistentKeepalive}}PersistentKeepalive = {{ $.globalSettings.PersistentKeepalive }}{{end}}
{{if .Client.Endpoint}}Endpoint = {{ .Client.Endpoint }}{{end}}
{{end}}{{end}}
+8
View File
@@ -0,0 +1,8 @@
package util
import "sync"
var IPToSubnetRange = map[string]uint16{}
var TgUseridToClientID = map[int64][]string{}
var TgUseridToClientIDMutex sync.RWMutex
var DBUsersToCRC32 = map[string]uint32{}
+119
View File
@@ -0,0 +1,119 @@
package util
import (
"net"
"strings"
"github.com/labstack/gommon/log"
)
// Runtime config
var (
DisableLogin bool
BindAddress string
SmtpHostname string
SmtpPort int
SmtpUsername string
SmtpPassword string
SmtpNoTLSCheck bool
SmtpEncryption string
SmtpAuthType string
SmtpHelo string
SendgridApiKey string
EmailFrom string
EmailFromName string
SessionSecret [64]byte
SessionMaxDuration int64
WgConfTemplate string
BasePath string
SubnetRanges map[string]([]*net.IPNet)
SubnetRangesOrder []string
)
const (
DefaultUsername = "admin"
DefaultPassword = "admin"
DefaultIsAdmin = true
DefaultServerAddress = "10.252.1.0/24"
DefaultServerPort = 51820
DefaultDNS = "1.1.1.1"
DefaultMTU = 1450
DefaultPersistentKeepalive = 15
DefaultFirewallMark = "0xca6c" // i.e. 51820
DefaultTable = "auto"
DefaultConfigFilePath = "/etc/wireguard/wg0.conf"
UsernameEnvVar = "WGUI_USERNAME"
PasswordEnvVar = "WGUI_PASSWORD"
PasswordFileEnvVar = "WGUI_PASSWORD_FILE"
PasswordHashEnvVar = "WGUI_PASSWORD_HASH"
PasswordHashFileEnvVar = "WGUI_PASSWORD_HASH_FILE"
FaviconFilePathEnvVar = "WGUI_FAVICON_FILE_PATH"
EndpointAddressEnvVar = "WGUI_ENDPOINT_ADDRESS"
DNSEnvVar = "WGUI_DNS"
MTUEnvVar = "WGUI_MTU"
PersistentKeepaliveEnvVar = "WGUI_PERSISTENT_KEEPALIVE"
FirewallMarkEnvVar = "WGUI_FIREWALL_MARK"
TableEnvVar = "WGUI_TABLE"
ConfigFilePathEnvVar = "WGUI_CONFIG_FILE_PATH"
LogLevel = "WGUI_LOG_LEVEL"
ServerAddressesEnvVar = "WGUI_SERVER_INTERFACE_ADDRESSES"
ServerListenPortEnvVar = "WGUI_SERVER_LISTEN_PORT"
ServerPostUpScriptEnvVar = "WGUI_SERVER_POST_UP_SCRIPT"
ServerPostDownScriptEnvVar = "WGUI_SERVER_POST_DOWN_SCRIPT"
DefaultClientAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_ALLOWED_IPS"
DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS"
DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS"
DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION"
)
func ParseBasePath(basePath string) string {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
}
if strings.HasSuffix(basePath, "/") {
basePath = strings.TrimSuffix(basePath, "/")
}
return basePath
}
func ParseSubnetRanges(subnetRangesStr string) map[string]([]*net.IPNet) {
subnetRanges := map[string]([]*net.IPNet){}
if subnetRangesStr == "" {
return subnetRanges
}
cidrSet := map[string]bool{}
subnetRangesStr = strings.TrimSpace(subnetRangesStr)
subnetRangesStr = strings.Trim(subnetRangesStr, ";:,")
ranges := strings.Split(subnetRangesStr, ";")
for _, rng := range ranges {
rng = strings.TrimSpace(rng)
rngSpl := strings.Split(rng, ":")
if len(rngSpl) != 2 {
log.Warnf("Unable to parse subnet range: %v. Skipped.", rng)
continue
}
rngName := strings.TrimSpace(rngSpl[0])
subnetRanges[rngName] = make([]*net.IPNet, 0)
cidrs := strings.Split(rngSpl[1], ",")
for _, cidr := range cidrs {
cidr = strings.TrimSpace(cidr)
_, net, err := net.ParseCIDR(cidr)
if err != nil {
log.Warnf("[%v] Unable to parse CIDR: %v. Skipped.", rngName, cidr)
continue
}
if cidrSet[net.String()] {
log.Warnf("[%v] CIDR already exists: %v. Skipped.", rngName, net.String())
continue
}
cidrSet[net.String()] = true
subnetRanges[rngName] = append(subnetRanges[rngName], net)
}
if len(subnetRanges[rngName]) == 0 {
delete(subnetRanges, rngName)
} else {
SubnetRangesOrder = append(SubnetRangesOrder, rngName)
}
}
return subnetRanges
}
+32
View File
@@ -0,0 +1,32 @@
package util
import (
"encoding/base64"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
)
func HashPassword(plaintext string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(plaintext), 14)
if err != nil {
return "", fmt.Errorf("cannot hash password: %w", err)
}
return base64.StdEncoding.EncodeToString(bytes), nil
}
func VerifyHash(base64Hash string, plaintext string) (bool, error) {
hash, err := base64.StdEncoding.DecodeString(base64Hash)
if err != nil {
return false, fmt.Errorf("cannot decode base64 hash: %w", err)
}
err = bcrypt.CompareHashAndPassword(hash, []byte(plaintext))
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("cannot verify password: %w", err)
}
return true, nil
}
+876
View File
@@ -0,0 +1,876 @@
package util
import (
"bufio"
"bytes"
"encoding/gob"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"io"
"io/fs"
"math/rand"
"net"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"text/template"
"time"
"github.com/ngoduykhanh/wireguard-ui/store"
"github.com/ngoduykhanh/wireguard-ui/telegram"
"github.com/skip2/go-qrcode"
"golang.org/x/mod/sumdb/dirhash"
externalip "github.com/glendc/go-external-ip"
"github.com/labstack/gommon/log"
"github.com/ngoduykhanh/wireguard-ui/model"
"github.com/sdomino/scribble"
)
var qrCodeSettings = model.QRCodeSettings{
Enabled: true,
IncludeDNS: true,
IncludeMTU: true,
}
// BuildClientConfig to create wireguard client config string
func BuildClientConfig(client model.Client, server model.Server, setting model.GlobalSetting) string {
// Interface section
clientAddress := fmt.Sprintf("Address = %s\n", strings.Join(client.AllocatedIPs, ","))
clientPrivateKey := fmt.Sprintf("PrivateKey = %s\n", client.PrivateKey)
clientDNS := ""
if client.UseServerDNS {
clientDNS = fmt.Sprintf("DNS = %s\n", strings.Join(setting.DNSServers, ","))
}
clientMTU := ""
if setting.MTU > 0 {
clientMTU = fmt.Sprintf("MTU = %d\n", setting.MTU)
}
// Peer section
peerPublicKey := fmt.Sprintf("PublicKey = %s\n", server.KeyPair.PublicKey)
peerPresharedKey := ""
if client.PresharedKey != "" {
peerPresharedKey = fmt.Sprintf("PresharedKey = %s\n", client.PresharedKey)
}
peerAllowedIPs := fmt.Sprintf("AllowedIPs = %s\n", strings.Join(client.AllowedIPs, ","))
desiredHost := setting.EndpointAddress
desiredPort := server.Interface.ListenPort
if strings.Contains(desiredHost, ":") {
split := strings.Split(desiredHost, ":")
desiredHost = split[0]
if n, err := strconv.Atoi(split[1]); err == nil {
desiredPort = n
} else {
log.Error("Endpoint appears to be incorrectly formatted: ", err)
}
}
peerEndpoint := fmt.Sprintf("Endpoint = %s:%d\n", desiredHost, desiredPort)
peerPersistentKeepalive := ""
if setting.PersistentKeepalive > 0 {
peerPersistentKeepalive = fmt.Sprintf("PersistentKeepalive = %d\n", setting.PersistentKeepalive)
}
// build the config as string
strConfig := "[Interface]\n" +
clientAddress +
clientPrivateKey +
clientDNS +
clientMTU +
"\n[Peer]\n" +
peerPublicKey +
peerPresharedKey +
peerAllowedIPs +
peerEndpoint +
peerPersistentKeepalive
return strConfig
}
// ClientDefaultsFromEnv to read the default values for creating a new client from the environment or use sane defaults
func ClientDefaultsFromEnv() model.ClientDefaults {
clientDefaults := model.ClientDefaults{}
clientDefaults.AllowedIps = LookupEnvOrStrings(DefaultClientAllowedIpsEnvVar, []string{"0.0.0.0/0"})
clientDefaults.ExtraAllowedIps = LookupEnvOrStrings(DefaultClientExtraAllowedIpsEnvVar, []string{})
clientDefaults.UseServerDNS = LookupEnvOrBool(DefaultClientUseServerDNSEnvVar, true)
clientDefaults.EnableAfterCreation = LookupEnvOrBool(DefaultClientEnableAfterCreationEnvVar, true)
return clientDefaults
}
// ContainsCIDR to check if ipnet1 contains ipnet2
// https://stackoverflow.com/a/40406619/6111641
// https://go.dev/play/p/Q4J-JEN3sF
func ContainsCIDR(ipnet1, ipnet2 *net.IPNet) bool {
ones1, _ := ipnet1.Mask.Size()
ones2, _ := ipnet2.Mask.Size()
return ones1 <= ones2 && ipnet1.Contains(ipnet2.IP)
}
// ValidateCIDR to validate a network CIDR
func ValidateCIDR(cidr string) bool {
_, _, err := net.ParseCIDR(cidr)
if err != nil {
return false
}
return true
}
// ValidateCIDRList to validate a list of network CIDR
func ValidateCIDRList(cidrs []string, allowEmpty bool) bool {
for _, cidr := range cidrs {
if allowEmpty {
if len(cidr) > 0 {
if ValidateCIDR(cidr) == false {
return false
}
}
} else {
if ValidateCIDR(cidr) == false {
return false
}
}
}
return true
}
// ValidateAllowedIPs to validate allowed ip addresses in CIDR format
func ValidateAllowedIPs(cidrs []string) bool {
if ValidateCIDRList(cidrs, false) == false {
return false
}
return true
}
// ValidateExtraAllowedIPs to validate extra Allowed ip addresses, allowing empty strings
func ValidateExtraAllowedIPs(cidrs []string) bool {
if ValidateCIDRList(cidrs, true) == false {
return false
}
return true
}
// ValidateServerAddresses to validate allowed ip addresses in CIDR format
func ValidateServerAddresses(cidrs []string) bool {
if ValidateCIDRList(cidrs, false) == false {
return false
}
return true
}
// ValidateIPAddress to validate the IPv4 and IPv6 address
func ValidateIPAddress(ip string) bool {
if net.ParseIP(ip) == nil {
return false
}
return true
}
// ValidateIPAddressList to validate a list of IPv4 and IPv6 addresses
func ValidateIPAddressList(ips []string) bool {
for _, ip := range ips {
if ValidateIPAddress(ip) == false {
return false
}
}
return true
}
// GetInterfaceIPs to get local machine's interface ip addresses
func GetInterfaceIPs() ([]model.Interface, error) {
// get machine's interfaces
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
var interfaceList []model.Interface
// get interface's ip addresses
for _, i := range ifaces {
addrs, err := i.Addrs()
if err != nil {
return nil, err
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
ip = ip.To4()
if ip == nil {
continue
}
iface := model.Interface{}
iface.Name = i.Name
iface.IPAddress = ip.String()
interfaceList = append(interfaceList, iface)
}
}
return interfaceList, err
}
// GetPublicIP to get machine's public ip address
func GetPublicIP() (model.Interface, error) {
// set time out to 5 seconds
cfg := externalip.ConsensusConfig{}
cfg.Timeout = time.Second * 5
consensus := externalip.NewConsensus(&cfg, nil)
// add trusted voters
consensus.AddVoter(externalip.NewHTTPSource("https://checkip.amazonaws.com/"), 1)
consensus.AddVoter(externalip.NewHTTPSource("http://whatismyip.akamai.com"), 1)
consensus.AddVoter(externalip.NewHTTPSource("https://ifconfig.top"), 1)
publicInterface := model.Interface{}
publicInterface.Name = "Public Address"
ip, err := consensus.ExternalIP()
if err != nil {
publicInterface.IPAddress = "N/A"
} else {
publicInterface.IPAddress = ip.String()
}
// error handling happened above, no need to pass it through
return publicInterface, nil
}
// GetIPFromCIDR get ip from CIDR
func GetIPFromCIDR(cidr string) (string, error) {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
return ip.String(), nil
}
// GetAllocatedIPs to get all ip addresses allocated to clients and server
func GetAllocatedIPs(ignoreClientID string) ([]string, error) {
allocatedIPs := make([]string, 0)
// initialize database directory
dir := "./db"
db, err := scribble.New(dir, nil)
if err != nil {
return nil, err
}
// read server information
serverInterface := model.ServerInterface{}
if err := db.Read("server", "interfaces", &serverInterface); err != nil {
return nil, err
}
// append server's addresses to the result
for _, cidr := range serverInterface.Addresses {
ip, err := GetIPFromCIDR(cidr)
if err != nil {
return nil, err
}
allocatedIPs = append(allocatedIPs, ip)
}
// read client information
records, err := db.ReadAll("clients")
if err != nil {
return nil, err
}
// append client's addresses to the result
for _, f := range records {
client := model.Client{}
if err := json.Unmarshal(f, &client); err != nil {
return nil, err
}
if client.ID != ignoreClientID {
for _, cidr := range client.AllocatedIPs {
ip, err := GetIPFromCIDR(cidr)
if err != nil {
return nil, err
}
allocatedIPs = append(allocatedIPs, ip)
}
}
}
return allocatedIPs, nil
}
// inc from https://play.golang.org/p/m8TNTtygK0
func inc(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++
if ip[j] > 0 {
break
}
}
}
// GetBroadcastIP func to get the broadcast ip address of a network
func GetBroadcastIP(n *net.IPNet) net.IP {
var broadcast net.IP
if len(n.IP) == 4 {
broadcast = net.ParseIP("0.0.0.0").To4()
} else {
broadcast = net.ParseIP("::")
}
for i := 0; i < len(n.IP); i++ {
broadcast[i] = n.IP[i] | ^n.Mask[i]
}
return broadcast
}
// GetBroadcastAndNetworkAddrsLookup get the ip address that can't be used with current server interfaces
func GetBroadcastAndNetworkAddrsLookup(interfaceAddresses []string) map[string]bool {
list := make(map[string]bool)
for _, ifa := range interfaceAddresses {
_, netAddr, err := net.ParseCIDR(ifa)
if err != nil {
continue
}
broadcastAddr := GetBroadcastIP(netAddr).String()
networkAddr := netAddr.IP.String()
list[broadcastAddr] = true
list[networkAddr] = true
}
return list
}
// GetAvailableIP get the ip address that can be allocated from an CIDR
// We need interfaceAddresses to find real broadcast and network addresses
func GetAvailableIP(cidr string, allocatedList, interfaceAddresses []string) (string, error) {
ip, netAddr, err := net.ParseCIDR(cidr)
if err != nil {
return "", err
}
unavailableIPs := GetBroadcastAndNetworkAddrsLookup(interfaceAddresses)
for ip := ip.Mask(netAddr.Mask); netAddr.Contains(ip); inc(ip) {
available := true
suggestedAddr := ip.String()
for _, allocatedAddr := range allocatedList {
if suggestedAddr == allocatedAddr {
available = false
break
}
}
if available && !unavailableIPs[suggestedAddr] {
return suggestedAddr, nil
}
}
return "", errors.New("no more available ip address")
}
// ValidateIPAllocation to validate the list of client's ip allocation
// They must have a correct format and available in serverAddresses space
func ValidateIPAllocation(serverAddresses []string, ipAllocatedList []string, ipAllocationList []string) (bool, error) {
for _, clientCIDR := range ipAllocationList {
ip, _, _ := net.ParseCIDR(clientCIDR)
// clientCIDR must be in CIDR format
if ip == nil {
return false, fmt.Errorf("invalid ip allocation input %s. Must be in CIDR format", clientCIDR)
}
// return false immediately if the ip is already in use (in ipAllocatedList)
for _, item := range ipAllocatedList {
if item == ip.String() {
return false, fmt.Errorf("IP %s already allocated", ip)
}
}
// even if it is not in use, we still need to check if it
// belongs to a network of the server.
var isValid = false
for _, serverCIDR := range serverAddresses {
_, serverNet, _ := net.ParseCIDR(serverCIDR)
if serverNet.Contains(ip) {
isValid = true
break
}
}
// current ip allocation is valid, check the next one
if isValid {
continue
} else {
return false, fmt.Errorf("IP %s does not belong to any network addresses of WireGuard server", ip)
}
}
return true, nil
}
// findSubnetRangeForIP to find first SR for IP, and cache the match
func findSubnetRangeForIP(cidr string) (uint16, error) {
ip, _, err := net.ParseCIDR(cidr)
if err != nil {
return 0, err
}
if srName, ok := IPToSubnetRange[ip.String()]; ok {
return srName, nil
}
for srIndex, sr := range SubnetRangesOrder {
for _, srCIDR := range SubnetRanges[sr] {
if srCIDR.Contains(ip) {
IPToSubnetRange[ip.String()] = uint16(srIndex)
return uint16(srIndex), nil
}
}
}
return 0, fmt.Errorf("subnet range not found for this IP")
}
// FillClientSubnetRange to fill subnet ranges client belongs to, does nothing if SRs are not found
func FillClientSubnetRange(client model.ClientData) model.ClientData {
cl := *client.Client
for _, ip := range cl.AllocatedIPs {
sr, err := findSubnetRangeForIP(ip)
if err != nil {
continue
}
cl.SubnetRanges = append(cl.SubnetRanges, SubnetRangesOrder[sr])
}
return model.ClientData{
Client: &cl,
QRCode: client.QRCode,
}
}
// ValidateAndFixSubnetRanges to check if subnet ranges are valid for the server configuration
// Removes all non-valid CIDRs
func ValidateAndFixSubnetRanges(db store.IStore) error {
if len(SubnetRangesOrder) == 0 {
return nil
}
server, err := db.GetServer()
if err != nil {
return err
}
var serverSubnets []*net.IPNet
for _, addr := range server.Interface.Addresses {
addr = strings.TrimSpace(addr)
_, netAddr, err := net.ParseCIDR(addr)
if err != nil {
return err
}
serverSubnets = append(serverSubnets, netAddr)
}
for _, rng := range SubnetRangesOrder {
cidrs := SubnetRanges[rng]
if len(cidrs) > 0 {
newCIDRs := make([]*net.IPNet, 0)
for _, cidr := range cidrs {
valid := false
for _, serverSubnet := range serverSubnets {
if ContainsCIDR(serverSubnet, cidr) {
valid = true
break
}
}
if valid {
newCIDRs = append(newCIDRs, cidr)
} else {
log.Warnf("[%v] CIDR is outside of all server subnets: %v. Removed.", rng, cidr)
}
}
if len(newCIDRs) > 0 {
SubnetRanges[rng] = newCIDRs
} else {
delete(SubnetRanges, rng)
log.Warnf("[%v] No valid CIDRs in this subnet range. Removed.", rng)
}
}
}
return nil
}
// GetSubnetRangesString to get a formatted string, representing active subnet ranges
func GetSubnetRangesString() string {
if len(SubnetRangesOrder) == 0 {
return ""
}
strB := strings.Builder{}
for _, rng := range SubnetRangesOrder {
cidrs := SubnetRanges[rng]
if len(cidrs) > 0 {
strB.WriteString(rng)
strB.WriteString(":[")
first := true
for _, cidr := range cidrs {
if !first {
strB.WriteString(", ")
}
strB.WriteString(cidr.String())
first = false
}
strB.WriteString("] ")
}
}
return strings.TrimSpace(strB.String())
}
// WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf
func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error {
var tmplWireguardConf string
// if set, read wg.conf template from WgConfTemplate
if len(WgConfTemplate) > 0 {
fileContentBytes, err := os.ReadFile(WgConfTemplate)
if err != nil {
return err
}
tmplWireguardConf = string(fileContentBytes)
} else {
// read default wg.conf template file to string
fileContent, err := StringFromEmbedFile(tmplDir, "wg.conf")
if err != nil {
return err
}
tmplWireguardConf = fileContent
}
// escape multiline notes
escapedClientDataList := []model.ClientData{}
for _, cd := range clientDataList {
if cd.Client.AdditionalNotes != "" {
cd.Client.AdditionalNotes = strings.ReplaceAll(cd.Client.AdditionalNotes, "\n", "\n# ")
}
escapedClientDataList = append(escapedClientDataList, cd)
}
// parse the template
t, err := template.New("wg_config").Parse(tmplWireguardConf)
if err != nil {
return err
}
// write config file to disk
f, err := os.Create(globalSettings.ConfigFilePath)
if err != nil {
return err
}
config := map[string]interface{}{
"serverConfig": serverConfig,
"clientDataList": escapedClientDataList,
"globalSettings": globalSettings,
"usersList": usersList,
}
err = t.Execute(f, config)
if err != nil {
return err
}
f.Close()
return nil
}
// SendRequestedConfigsToTelegram to send client all their configs. Returns failed configs list.
func SendRequestedConfigsToTelegram(db store.IStore, userid int64) []string {
failedList := make([]string, 0)
TgUseridToClientIDMutex.RLock()
if clids, found := TgUseridToClientID[userid]; found && len(clids) > 0 {
TgUseridToClientIDMutex.RUnlock()
for _, clid := range clids {
clientData, err := db.GetClientByID(clid, qrCodeSettings)
if err != nil {
// return fmt.Errorf("unable to get client")
failedList = append(failedList, clid)
continue
}
// build config
server, _ := db.GetServer()
globalSettings, _ := db.GetGlobalSettings()
config := BuildClientConfig(*clientData.Client, server, globalSettings)
configData := []byte(config)
var qrData []byte
if clientData.Client.PrivateKey != "" {
qrData, err = qrcode.Encode(config, qrcode.Medium, 512)
if err != nil {
// return fmt.Errorf("unable to encode qr")
failedList = append(failedList, clientData.Client.Name)
continue
}
}
userid, err := strconv.ParseInt(clientData.Client.TgUserid, 10, 64)
if err != nil {
// return fmt.Errorf("tg usrid is unreadable")
failedList = append(failedList, clientData.Client.Name)
continue
}
err = telegram.SendConfig(userid, clientData.Client.Name, configData, qrData, true)
if err != nil {
failedList = append(failedList, clientData.Client.Name)
continue
}
time.Sleep(2 * time.Second)
}
} else {
TgUseridToClientIDMutex.RUnlock()
}
return failedList
}
func LookupEnvOrString(key string, defaultVal string) string {
if val, ok := os.LookupEnv(key); ok {
return val
}
return defaultVal
}
func LookupEnvOrBool(key string, defaultVal bool) bool {
if val, ok := os.LookupEnv(key); ok {
v, err := strconv.ParseBool(val)
if err != nil {
fmt.Fprintf(os.Stderr, "LookupEnvOrBool[%s]: %v\n", key, err)
}
return v
}
return defaultVal
}
func LookupEnvOrInt(key string, defaultVal int) int {
if val, ok := os.LookupEnv(key); ok {
v, err := strconv.Atoi(val)
if err != nil {
fmt.Fprintf(os.Stderr, "LookupEnvOrInt[%s]: %v\n", key, err)
}
return v
}
return defaultVal
}
func LookupEnvOrStrings(key string, defaultVal []string) []string {
if val, ok := os.LookupEnv(key); ok {
return strings.Split(val, ",")
}
return defaultVal
}
func LookupEnvOrFile(key string, defaultVal string) string {
if val, ok := os.LookupEnv(key); ok {
if file, err := os.Open(val); err == nil {
var content string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
content += scanner.Text()
}
return content
}
}
return defaultVal
}
func StringFromEmbedFile(embed fs.FS, filename string) (string, error) {
file, err := embed.Open(filename)
if err != nil {
return "", err
}
content, err := io.ReadAll(file)
if err != nil {
return "", err
}
return string(content), nil
}
func ParseLogLevel(lvl string) (log.Lvl, error) {
switch strings.ToLower(lvl) {
case "debug":
return log.DEBUG, nil
case "info":
return log.INFO, nil
case "warn":
return log.WARN, nil
case "error":
return log.ERROR, nil
case "off":
return log.OFF, nil
default:
return log.DEBUG, fmt.Errorf("not a valid log level: %s", lvl)
}
}
// GetCurrentHash returns current hashes
func GetCurrentHash(db store.IStore) (string, string) {
hashClients, _ := dirhash.HashDir(path.Join(db.GetPath(), "clients"), "prefix", dirhash.Hash1)
files := append([]string(nil), "prefix/global_settings.json", "prefix/interfaces.json", "prefix/keypair.json")
osOpen := func(name string) (io.ReadCloser, error) {
return os.Open(filepath.Join(path.Join(db.GetPath(), "server"), strings.TrimPrefix(name, "prefix")))
}
hashServer, _ := dirhash.Hash1(files, osOpen)
return hashClients, hashServer
}
func HashesChanged(db store.IStore) bool {
old, _ := db.GetHashes()
oldClient := old.Client
oldServer := old.Server
newClient, newServer := GetCurrentHash(db)
if oldClient != newClient {
//fmt.Println("Hash for client differs")
return true
}
if oldServer != newServer {
//fmt.Println("Hash for server differs")
return true
}
return false
}
func UpdateHashes(db store.IStore) error {
var clientServerHashes model.ClientServerHashes
clientServerHashes.Client, clientServerHashes.Server = GetCurrentHash(db)
return db.SaveHashes(clientServerHashes)
}
func RandomString(length int) string {
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
func ManagePerms(path string) error {
err := os.Chmod(path, 0600)
return err
}
func AddTgToClientID(userid int64, clientID string) {
TgUseridToClientIDMutex.Lock()
defer TgUseridToClientIDMutex.Unlock()
if _, ok := TgUseridToClientID[userid]; ok && TgUseridToClientID[userid] != nil {
TgUseridToClientID[userid] = append(TgUseridToClientID[userid], clientID)
} else {
TgUseridToClientID[userid] = []string{clientID}
}
}
func UpdateTgToClientID(userid int64, clientID string) {
TgUseridToClientIDMutex.Lock()
defer TgUseridToClientIDMutex.Unlock()
// Detach clientID from any existing userid
for uid, cls := range TgUseridToClientID {
if cls != nil {
filtered := filterStringSlice(cls, clientID)
if len(filtered) > 0 {
TgUseridToClientID[uid] = filtered
} else {
delete(TgUseridToClientID, uid)
}
}
}
// Attach it to the new one
if _, ok := TgUseridToClientID[userid]; ok && TgUseridToClientID[userid] != nil {
TgUseridToClientID[userid] = append(TgUseridToClientID[userid], clientID)
} else {
TgUseridToClientID[userid] = []string{clientID}
}
}
func RemoveTgToClientID(clientID string) {
TgUseridToClientIDMutex.Lock()
defer TgUseridToClientIDMutex.Unlock()
// Detach clientID from any existing userid
for uid, cls := range TgUseridToClientID {
if cls != nil {
filtered := filterStringSlice(cls, clientID)
if len(filtered) > 0 {
TgUseridToClientID[uid] = filtered
} else {
delete(TgUseridToClientID, uid)
}
}
}
}
func filterStringSlice(s []string, excludedStr string) []string {
filtered := s[:0]
for _, v := range s {
if v != excludedStr {
filtered = append(filtered, v)
}
}
return filtered
}
func GetDBUserCRC32(dbuser model.User) uint32 {
buf := new(bytes.Buffer)
enc := gob.NewEncoder(buf)
if err := enc.Encode(dbuser); err != nil {
panic("model.User is gob-incompatible, session verification is impossible")
}
return crc32.ChecksumIEEE(buf.Bytes())
}
func ConcatMultipleSlices(slices ...[]byte) []byte {
var totalLen int
for _, s := range slices {
totalLen += len(s)
}
result := make([]byte, totalLen)
var i int
for _, s := range slices {
i += copy(result[i:], s)
}
return result
}
func GetCookiePath() string {
cookiePath := BasePath
if cookiePath == "" {
cookiePath = "/"
}
return cookiePath
}
+3052
View File
File diff suppressed because it is too large Load Diff