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:
co-authored by
Claude Sonnet 5
parent
6eeea65ede
commit
867dc7740a
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//usr/local/go/**)",
|
||||
"Bash(apt list *)",
|
||||
"Bash(chmod +x *)",
|
||||
"Bash(git add *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -1,280 +1,246 @@
|
||||
# wireguard-ui-multi
|
||||

|
||||
|
||||
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
|
||||

|
||||
|
||||
### 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>
|
||||
|
||||
@@ -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 |
@@ -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> `;
|
||||
})
|
||||
|
||||
// render client allowed ip addresses
|
||||
let allowedIpsHtml = "";
|
||||
$.each(obj.Client.allowed_ips, function(index, obj) {
|
||||
allowedIpsHtml += `<small class="badge badge-secondary">${obj}</small> `;
|
||||
})
|
||||
|
||||
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", " ");
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# Ignore everything in this directory
|
||||
*
|
||||
# Except this file
|
||||
!.gitignore
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package handler
|
||||
|
||||
type jsonHTTPResponse struct {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
+1196
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 $!
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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.";
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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="/">← 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>
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -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/"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ©
|
||||
<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 }}
|
||||
@@ -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"> 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">×</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 "-" 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">×</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 © <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}}
|
||||
@@ -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">×</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">×</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">×</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">×</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">×</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">×</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}}
|
||||
@@ -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">×</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}}
|
||||
@@ -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>
|
||||
@@ -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 }}
|
||||
@@ -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">×</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}}
|
||||
@@ -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}}
|
||||
@@ -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">×</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">×</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}}
|
||||
@@ -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">×</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">×</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}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user