Merge 790682272b into 2fdafd34ca
This commit is contained in:
commit
4388b0c3d8
|
|
@ -1 +0,0 @@
|
|||
github: [ngoduykhanh]
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
name: Build container images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build-image:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# set environment
|
||||
- name: Set BUILD_TIME env
|
||||
run: echo "BUILD_TIME=$(date)" >> $GITHUB_ENV
|
||||
|
||||
- name: Set GIT_COMMIT env
|
||||
run: echo "GIT_COMMIT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
|
||||
|
||||
- name: Environment printer
|
||||
uses: managedkaos/print-env@v1.0
|
||||
|
||||
- name: Prepare image tags
|
||||
id: image-tags
|
||||
run: |
|
||||
base=ngoduykhanh/wireguard-ui
|
||||
app_version=dev
|
||||
|
||||
## Set git tag as image tag
|
||||
##
|
||||
if [[ '${{ github.ref }}' == *"refs/tags/"* ]]; then
|
||||
github_tag="${GITHUB_REF#refs/*/}"
|
||||
app_version=${github_tag}
|
||||
|
||||
SEMVER_REGEX="^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(\\-[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?(\\+[0-9A-Za-z-]+(\\.[0-9A-Za-z-]+)*)?$"
|
||||
if [[ "$github_tag" =~ $SEMVER_REGEX ]]; then
|
||||
github_tag=$(echo "${github_tag}" | sed 's/^v//')
|
||||
fi
|
||||
|
||||
container_images=$(cat <<END_HEREDOC
|
||||
${base}:${github_tag}
|
||||
END_HEREDOC
|
||||
)
|
||||
|
||||
## Set 'latest' image tag if 'main' or 'master'
|
||||
## branch is pushed
|
||||
##
|
||||
elif [[ '${{ github.ref }}' == 'refs/heads/master' || '${{ github.ref }}' == 'refs/heads/main' ]]; then
|
||||
container_images=$(cat <<END_HEREDOC
|
||||
${base}:latest
|
||||
END_HEREDOC
|
||||
)
|
||||
fi
|
||||
|
||||
## Print tags for debugging purpose
|
||||
##
|
||||
echo "[INFO] container_images: ${container_images}"
|
||||
|
||||
## Set container_images output
|
||||
##
|
||||
echo "container_images<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$container_images" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
## Set APP_VERSION env
|
||||
#
|
||||
echo "APP_VERSION=${app_version}" >> $GITHUB_ENV
|
||||
|
||||
# set up docker and build images
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
push: true
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
tags: ${{ steps.image-tags.outputs.container_images }}
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.APP_VERSION }}
|
||||
BUILD_TIME=${{ env.BUILD_TIME }}
|
||||
GIT_COMMIT=${{ env.GIT_COMMIT }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "1.21"
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: v1.54
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
name: wireguard-ui build release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
releases-matrix:
|
||||
name: Release Go Binary
|
||||
runs-on: ubuntu-22.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# build and publish in parallel: linux/386, linux/amd64, darwin/386, darwin/amd64
|
||||
goos: [linux, freebsd, darwin]
|
||||
goarch: [386, amd64, arm, arm64]
|
||||
exclude:
|
||||
- goarch: 386
|
||||
goos: darwin
|
||||
- goarch: arm
|
||||
goos: darwin
|
||||
- goarch: arm64
|
||||
goos: darwin
|
||||
goarm:
|
||||
- 7
|
||||
steps:
|
||||
# get the source code
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# set environment
|
||||
- name: Set APP_VERSION env
|
||||
run: echo "APP_VERSION=$(echo ${GITHUB_REF} | rev | cut -d'/' -f 1 | rev )" >> $GITHUB_ENV
|
||||
- name: Set BUILD_TIME env
|
||||
run: echo "BUILD_TIME=$(date)" >> $GITHUB_ENV
|
||||
- name: Environment Printer
|
||||
uses: managedkaos/print-env@v1.0
|
||||
|
||||
# setup node
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
# prepare assets
|
||||
- name: Prepare assets
|
||||
run: |
|
||||
chmod +x ./prepare_assets.sh
|
||||
./prepare_assets.sh
|
||||
|
||||
# build and make the releases
|
||||
- name: Build and make the releases
|
||||
uses: wangyoucao577/go-release-action@master
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
goversion: "https://dl.google.com/go/go1.21.5.linux-amd64.tar.gz"
|
||||
pre_command: export CGO_ENABLED=0
|
||||
binary_name: "wireguard-ui"
|
||||
build_flags: -v
|
||||
ldflags: -X "main.appVersion=${{ env.APP_VERSION }}" -X "main.buildTime=${{ env.BUILD_TIME }}" -X main.gitCommit=${{ github.sha }} -X main.gitRef=${{ github.ref }}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Guidance for working in this repository.
|
||||
|
||||
## Project
|
||||
|
||||
WireGuard UI — a web UI for managing WireGuard VPN server + client configs.
|
||||
Go backend (Echo v4) rendering server-side HTML templates + a jQuery/AdminLTE
|
||||
frontend. This is a maintained fork of `ngoduykhanh/wireguard-ui` (upstream is
|
||||
abandoned); the module path is still `github.com/ngoduykhanh/wireguard-ui`.
|
||||
Fork-specific additions include GeoLite2 geolocation, Wake-on-LAN, Telegram
|
||||
config delivery, and assorted settings.
|
||||
|
||||
## Build / test / run
|
||||
|
||||
There is no vendored toolchain; you need Go 1.21.
|
||||
|
||||
```bash
|
||||
go build ./... # compile
|
||||
go vet ./... # vet
|
||||
go test ./... # tests (currently geoip/ and util/)
|
||||
gofmt -l . # must print nothing (lint uses gofmt + goimports)
|
||||
```
|
||||
|
||||
CI (`.github/workflows/`) runs golangci-lint (`gofmt, revive, goimports, govet,
|
||||
unused, whitespace, misspell`) and a Docker build. Keep `gofmt -l .` clean.
|
||||
|
||||
Frontend assets (AdminLTE + plugins) are pulled from npm and assembled into
|
||||
`assets/` by `./prepare_assets.sh` (or the Dockerfile) before the Go binary
|
||||
embeds `templates/` and `assets/` via `//go:embed`. `custom/` (logo, favicon,
|
||||
`js/helper.js`) is copied into `assets/custom/`. Running locally without
|
||||
`prepare_assets.sh` yields a UI with no styling/JS; the Docker image is the
|
||||
normal way to run it. `init.sh` is the container entrypoint and optionally
|
||||
manages `wg-quick` up/down (`WGUI_MANAGE_START` / `WGUI_MANAGE_RESTART`).
|
||||
|
||||
## Layout
|
||||
|
||||
- `main.go` — flags/env parsing, DB init, route registration, server startup.
|
||||
- `handler/` — Echo HTTP handlers (`routes.go` is the bulk; `session.go`,
|
||||
`middlewares.go`, `routes_wake_on_lan.go`).
|
||||
- `router/` — Echo setup, template registry, request validator.
|
||||
- `store/` + `store/jsondb/` — `IStore` interface over a flat-file JSON DB
|
||||
(`sdomino/scribble`); each client/user/setting is a JSON file under `./db/`.
|
||||
- `model/` — data structs (`Client`, `User`, `Server`, `GlobalSetting`, ...).
|
||||
- `util/` — config/env constants, WireGuard config generation, IP allocation,
|
||||
hashing, session helpers, in-memory caches.
|
||||
- `emailer/` (SMTP + SendGrid), `telegram/`, `geoip/` (GeoLite2).
|
||||
- `templates/` — server-rendered pages; `custom/js/helper.js` renders the
|
||||
client/user lists on the frontend.
|
||||
|
||||
## Conventions & gotchas
|
||||
|
||||
- **Templates use `text/template`, not `html/template`** (see `router/router.go`)
|
||||
— output is NOT auto-escaped. This is deliberate (e.g. the status page injects
|
||||
`</br>` into the Allocated IPs cell). Any user-controlled value rendered into
|
||||
a page must be escaped explicitly: server-side with `html.EscapeString` (see
|
||||
`Status` handler) or, in `helper.js`, with the `escapeHtml()` helper.
|
||||
- **CSRF**: every non-GET route must include the `handler.ContentTypeJson`
|
||||
middleware. Browsers can't set `Content-Type: application/json` on cross-origin
|
||||
form posts, so this blocks CSRF. The frontend always sends JSON.
|
||||
- **Auth**: cookie-session based (`gorilla/sessions`), two roles — admin and
|
||||
"manager" (non-admin). Admin-only routes add `handler.NeedsAdmin`. When
|
||||
`DISABLE_LOGIN` is set, auth is bypassed and everyone is treated as admin.
|
||||
Session validity is tied to a per-user CRC32 (`util.DBUsersToCRC32`) so any
|
||||
user change logs out other sessions.
|
||||
- **Config-driven**: almost everything is set via `WGUI_*` env vars or flags
|
||||
(defined in `main.go` + `util/config.go`). Persisted settings live in
|
||||
`db/server/global_settings.json`.
|
||||
- **Generated `wg0.conf`** is written by `util.WriteWireGuardServerConfig` in
|
||||
place (do not switch to write-temp-then-rename: the container's inotify watch
|
||||
needs `IN_CLOSE_WRITE` on the real path). It holds private keys, so it is
|
||||
written with mode `0600` by default (configurable via global settings /
|
||||
`WGUI_CONFIG_FILE_MODE`).
|
||||
- **JSON request bodies** decoded into `map[string]interface{}` must use
|
||||
comma-ok type assertions (`v, ok := data["x"].(string)`) and return 400 on
|
||||
failure — a bare assertion panics on malformed input.
|
||||
- Handler input from `c.Bind` / manual decode should be validated with the
|
||||
`util.Validate*` helpers before use.
|
||||
|
||||
## Git
|
||||
|
||||
- Commit messages: short and to the point. Do NOT add a `Co-Authored-By` line.
|
||||
- Make one commit per logical change/fix/feature.
|
||||
- Do not set the committer name/email manually (it breaks commit signing).
|
||||
28
README.md
28
README.md
|
|
@ -1,9 +1,13 @@
|
|||

|
||||
|
||||
# wireguard-ui
|
||||
|
||||
A web user interface to manage your WireGuard setup.
|
||||
|
||||
> **Note:** This is an independently maintained fork of
|
||||
> [ngoduykhanh/wireguard-ui](https://github.com/ngoduykhanh/wireguard-ui), whose
|
||||
> upstream is no longer actively maintained. It adds fixes and features on top of
|
||||
> upstream — including GeoLite2 peer geolocation, Wake-on-LAN, Telegram config
|
||||
> delivery, configurable config-file permissions, and various security hardening.
|
||||
|
||||
## Features
|
||||
|
||||
- Friendly UI
|
||||
|
|
@ -58,7 +62,11 @@ docker-compose up
|
|||
| `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 |
|
||||
| `WGUI_BRAND_TEXT` | The brand text of the web application | `WireGuard UI` |
|
||||
| `WGUI_ACCENT_COLOR` | The color of the interface sidebar | `#343a40` |
|
||||
| `WGUI_LOGO_FILE_PATH` | The file path of the website logo | Embedded WireGuard logo |
|
||||
| `WGUI_PAGE_TITLE_PREFIX` | The HTML title prefix for all pages | N/A |
|
||||
| `WG_CONF_TEMPLATE` | The custom `wg.conf` config file template. Please refer to our [default template](https://github.com/idressos/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 |
|
||||
|
|
@ -192,9 +200,9 @@ rc-update add wgui default
|
|||
|
||||
### Using Docker
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
|
|
@ -237,10 +245,4 @@ go build -o wireguard-ui
|
|||
|
||||
## License
|
||||
|
||||
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>
|
||||
MIT. See [LICENSE](https://github.com/idressos/wireguard-ui/blob/master/LICENSE).
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
|
|
@ -1,18 +1,35 @@
|
|||
// escapeHtml escapes characters that are significant in HTML so that
|
||||
// user-controlled values (client names, emails, notes, ...) cannot be used to
|
||||
// inject markup or scripts when interpolated into the DOM.
|
||||
function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderClientList(data) {
|
||||
$.each(data, function(index, obj) {
|
||||
const clientName = escapeHtml(obj.Client.name);
|
||||
|
||||
// render telegram button
|
||||
let telegramButton = ''
|
||||
if (obj.Client.telegram_userid) {
|
||||
telegramButton = `<div class="btn-group">
|
||||
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>
|
||||
data-clientname="${clientName}">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>`
|
||||
telegramHtml = `<span class="info-box-text" style="display: none"><i class="fas fa-tguserid"></i>${escapeHtml(obj.Client.telegram_userid)}</span>`
|
||||
}
|
||||
|
||||
// render client status css tag style
|
||||
|
|
@ -24,23 +41,23 @@ function renderClientList(data) {
|
|||
// render client allocated ip addresses
|
||||
let allocatedIpsHtml = "";
|
||||
$.each(obj.Client.allocated_ips, function(index, obj) {
|
||||
allocatedIpsHtml += `<small class="badge badge-secondary">${obj}</small> `;
|
||||
allocatedIpsHtml += `<small class="badge badge-secondary">${escapeHtml(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> `;
|
||||
allowedIpsHtml += `<small class="badge badge-secondary">${escapeHtml(obj)}</small> `;
|
||||
})
|
||||
|
||||
let subnetRangesString = "";
|
||||
if (obj.Client.subnet_ranges && obj.Client.subnet_ranges.length > 0) {
|
||||
subnetRangesString = obj.Client.subnet_ranges.join(',')
|
||||
subnetRangesString = escapeHtml(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>`
|
||||
additionalNotesHtml = `<span class="info-box-text" style="display: none"><i class="fas fa-additional_notes"></i>${escapeHtml(obj.Client.additional_notes.toUpperCase())}</span>`
|
||||
}
|
||||
|
||||
// render client html content
|
||||
|
|
@ -53,41 +70,41 @@ function renderClientList(data) {
|
|||
<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">
|
||||
<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>
|
||||
data-clientname="${clientName}" ${obj.QRCode != "" ? '' : ' disabled'}>QR code</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<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>
|
||||
data-clientname="${clientName}">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"
|
||||
<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>
|
||||
data-clientname="${clientName}">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>
|
||||
data-clientname="${clientName}">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>
|
||||
data-clientname="${clientName}">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"><i class="fas fa-user"></i> ${clientName}</span>
|
||||
<span class="info-box-text" style="display: none"><i class="fas fa-key"></i> ${escapeHtml(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-envelope"></i> ${escapeHtml(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>
|
||||
|
|
@ -95,7 +112,7 @@ function renderClientList(data) {
|
|||
<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>
|
||||
${escapeHtml(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>`
|
||||
|
|
@ -112,20 +129,21 @@ function renderClientList(data) {
|
|||
function renderUserList(data) {
|
||||
$.each(data, function(index, obj) {
|
||||
let clientStatusHtml = '>'
|
||||
const username = escapeHtml(obj.username);
|
||||
|
||||
// render user html content
|
||||
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="user_${obj.username}">
|
||||
let html = `<div class="col-sm-6 col-md-6 col-lg-4" id="user_${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>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" data-toggle="modal" data-target="#modal_edit_user" data-username="${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>
|
||||
data-target="#modal_remove_user" data-username="${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-user"></i> ${username}</span>
|
||||
<span class="info-box-text"><i class="fas fa-terminal"></i> ${obj.admin? 'Administrator':'Manager'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
### Kernel Module
|
||||
|
||||
Depending on if the Wireguard kernel module is available on your system you have more or less choices which example to use.
|
||||
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
|
||||
|
|
@ -21,10 +21,10 @@ For security reasons it's highly recommended to change them before the first sta
|
|||
## 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.
|
||||
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.
|
||||
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).
|
||||
If WireGuard kernel modules are not available, you can switch to an userspace implementation like [boringtun](https://github.com/cloudflare/boringtun).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
// Package geoip provides GeoLite2 (MaxMind) city geolocation with a locally
|
||||
// stored database that can be installed/updated on demand or automatically.
|
||||
package geoip
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/geoip2-golang"
|
||||
)
|
||||
|
||||
// download endpoint for the GeoLite2-City edition (tar.gz archive)
|
||||
const downloadURLTemplate = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
|
||||
// guard against decompression bombs; the GeoLite2-City db is ~70MB
|
||||
const maxDBSize = 1 << 30 // 1 GiB
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
reader *geoip2.Reader
|
||||
dbPath = "./db/GeoLite2-City.mmdb"
|
||||
)
|
||||
|
||||
// Location holds the resolved geolocation of an IP address.
|
||||
type Location struct {
|
||||
City string
|
||||
Country string
|
||||
CountryISO string
|
||||
Flag string
|
||||
}
|
||||
|
||||
// Display renders the location as "🇬🇷 Athens, Greece", omitting empty parts.
|
||||
func (l Location) Display() string {
|
||||
var parts []string
|
||||
if l.City != "" {
|
||||
parts = append(parts, l.City)
|
||||
}
|
||||
if l.Country != "" {
|
||||
parts = append(parts, l.Country)
|
||||
}
|
||||
text := strings.Join(parts, ", ")
|
||||
switch {
|
||||
case l.Flag != "" && text != "":
|
||||
return l.Flag + " " + text
|
||||
case l.Flag != "":
|
||||
return l.Flag
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
// SetDBPath configures where the GeoLite2 database is stored/read.
|
||||
func SetDBPath(p string) {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
dbPath = p
|
||||
}
|
||||
}
|
||||
|
||||
// DBPath returns the configured database path.
|
||||
func DBPath() string {
|
||||
return dbPath
|
||||
}
|
||||
|
||||
// Load opens the local database (if present) into the reader.
|
||||
func Load() error {
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return err
|
||||
}
|
||||
r, err := geoip2.Open(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mu.Lock()
|
||||
old := reader
|
||||
reader = r
|
||||
mu.Unlock()
|
||||
if old != nil {
|
||||
old.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Available reports whether a database is loaded and ready for lookups.
|
||||
func Available() bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return reader != nil
|
||||
}
|
||||
|
||||
// LastUpdated returns the database file's modification time.
|
||||
func LastUpdated() (time.Time, bool) {
|
||||
fi, err := os.Stat(dbPath)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return fi.ModTime(), true
|
||||
}
|
||||
|
||||
// NeedsUpdate reports whether the database is missing or older than maxAge.
|
||||
func NeedsUpdate(maxAge time.Duration) bool {
|
||||
t, ok := LastUpdated()
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return time.Since(t) > maxAge
|
||||
}
|
||||
|
||||
// Lookup geolocates an IP address (host only, without port).
|
||||
func Lookup(ipStr string) (Location, error) {
|
||||
mu.RLock()
|
||||
r := reader
|
||||
mu.RUnlock()
|
||||
if r == nil {
|
||||
return Location{}, errors.New("geoip database not loaded")
|
||||
}
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return Location{}, fmt.Errorf("invalid ip address: %q", ipStr)
|
||||
}
|
||||
record, err := r.City(ip)
|
||||
if err != nil {
|
||||
return Location{}, err
|
||||
}
|
||||
loc := Location{
|
||||
City: record.City.Names["en"],
|
||||
Country: record.Country.Names["en"],
|
||||
CountryISO: record.Country.IsoCode,
|
||||
}
|
||||
loc.Flag = flagEmoji(loc.CountryISO)
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
// flagEmoji converts a two-letter ISO country code into its flag emoji using
|
||||
// Unicode regional indicator symbols.
|
||||
func flagEmoji(iso string) string {
|
||||
if len(iso) != 2 {
|
||||
return ""
|
||||
}
|
||||
iso = strings.ToUpper(iso)
|
||||
runes := make([]rune, 0, 2)
|
||||
for _, c := range iso {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return ""
|
||||
}
|
||||
runes = append(runes, rune(0x1F1E6+(c-'A')))
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
// Update downloads the latest GeoLite2-City database with the given MaxMind
|
||||
// license key, atomically replaces the local file, and reloads the reader.
|
||||
func Update(licenseKey string) error {
|
||||
licenseKey = strings.TrimSpace(licenseKey)
|
||||
if licenseKey == "" {
|
||||
return errors.New("MaxMind license key is not set")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Minute}
|
||||
resp, err := client.Get(fmt.Sprintf(downloadURLTemplate, licenseKey))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with HTTP %d (check your MaxMind license key)", resp.StatusCode)
|
||||
}
|
||||
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmpPath := dbPath + ".tmp"
|
||||
tr := tar.NewReader(gz)
|
||||
found := false
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasSuffix(hdr.Name, ".mmdb") {
|
||||
continue
|
||||
}
|
||||
out, err := os.Create(tmpPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, io.LimitReader(tr, maxDBSize)); err != nil {
|
||||
out.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
out.Close()
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if !found {
|
||||
return errors.New("no .mmdb file found in the downloaded archive")
|
||||
}
|
||||
|
||||
_ = os.Chmod(tmpPath, 0600)
|
||||
if err := os.Rename(tmpPath, dbPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return Load()
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
package geoip
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFlagEmoji(t *testing.T) {
|
||||
us := string([]rune{0x1F1FA, 0x1F1F8})
|
||||
gr := string([]rune{0x1F1EC, 0x1F1F7})
|
||||
gb := string([]rune{0x1F1EC, 0x1F1E7})
|
||||
|
||||
cases := map[string]string{
|
||||
"US": us,
|
||||
"GR": gr,
|
||||
"gb": gb, // lowercase is normalized
|
||||
"": "",
|
||||
"U": "", // too short
|
||||
"USA": "", // too long
|
||||
"1A": "", // non-letter
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := flagEmoji(in); got != want {
|
||||
t.Errorf("flagEmoji(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocationDisplay(t *testing.T) {
|
||||
flag := string([]rune{0x1F1EC, 0x1F1F7})
|
||||
cases := []struct {
|
||||
loc Location
|
||||
want string
|
||||
}{
|
||||
{Location{City: "Athens", Country: "Greece", Flag: flag}, flag + " Athens, Greece"},
|
||||
{Location{Country: "Greece", Flag: flag}, flag + " Greece"},
|
||||
{Location{Flag: flag}, flag},
|
||||
{Location{City: "Athens", Country: "Greece"}, "Athens, Greece"},
|
||||
{Location{}, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.loc.Display(); got != c.want {
|
||||
t.Errorf("Display(%+v) = %q, want %q", c.loc, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupNotLoaded(t *testing.T) {
|
||||
mu.Lock()
|
||||
reader = nil
|
||||
mu.Unlock()
|
||||
if _, err := Lookup("8.8.8.8"); err == nil {
|
||||
t.Error("expected error when database not loaded, got nil")
|
||||
}
|
||||
}
|
||||
5
go.mod
5
go.mod
|
|
@ -22,6 +22,8 @@ require (
|
|||
gopkg.in/go-playground/validator.v9 v9.31.0
|
||||
)
|
||||
|
||||
require github.com/oschwald/geoip2-golang v1.11.0
|
||||
|
||||
require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
|
|
@ -38,13 +40,14 @@ require (
|
|||
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/oschwald/maxminddb-golang v1.13.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/sys v0.20.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
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -78,6 +78,10 @@ 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/oschwald/geoip2-golang v1.11.0 h1:hNENhCn1Uyzhf9PTmquXENiWS6AlxAEnBII6r8krA3w=
|
||||
github.com/oschwald/geoip2-golang v1.11.0/go.mod h1:P9zG+54KPEFOliZ29i7SeYZ/GM6tfEL+rgSn03hYuUo=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0 h1:R8xBorY71s84yO06NgTmQvqvTvlS/bnYZrrWX1MElnU=
|
||||
github.com/oschwald/maxminddb-golang v1.13.0/go.mod h1:BU0z8BfFVhi1LQaonTwwGQlsHUEu9pWNdMfmq4ztm0o=
|
||||
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=
|
||||
|
|
@ -100,8 +104,8 @@ github.com/stretchr/testify v0.0.0-20150929183540-2b15294402a8/go.mod h1:a8OnRci
|
|||
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/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
|
|
@ -160,8 +164,8 @@ golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
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/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||
golang.org/x/sys v0.20.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=
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
|
@ -24,6 +26,7 @@ import (
|
|||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
"github.com/ngoduykhanh/wireguard-ui/emailer"
|
||||
"github.com/ngoduykhanh/wireguard-ui/geoip"
|
||||
"github.com/ngoduykhanh/wireguard-ui/model"
|
||||
"github.com/ngoduykhanh/wireguard-ui/store"
|
||||
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
||||
|
|
@ -32,6 +35,10 @@ import (
|
|||
|
||||
var usernameRegexp = regexp.MustCompile("^\\w[\\w\\-.]*$")
|
||||
|
||||
// unsafeFilenameChars matches any character that should not appear in a
|
||||
// downloaded config filename (prevents header/filename injection via client name).
|
||||
var unsafeFilenameChars = regexp.MustCompile(`[^a-zA-Z0-9_.-]+`)
|
||||
|
||||
// Health check handler
|
||||
func Health() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
|
@ -48,6 +55,15 @@ func Favicon() echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func Logo() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if logo, ok := os.LookupEnv(util.LogoFilePathEnvVar); ok {
|
||||
return c.File(logo)
|
||||
}
|
||||
return c.Redirect(http.StatusFound, util.BasePath+"/static/custom/img/logo.png")
|
||||
}
|
||||
}
|
||||
|
||||
// LoginPage handler
|
||||
func LoginPage() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
|
@ -65,9 +81,18 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
username := data["username"].(string)
|
||||
password := data["password"].(string)
|
||||
rememberMe := data["rememberMe"].(bool)
|
||||
username, ok := data["username"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
password, ok := data["password"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
rememberMe, ok := data["rememberMe"].(bool)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if !usernameRegexp.MatchString(username) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
||||
|
|
@ -105,6 +130,7 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||
Path: cookiePath,
|
||||
MaxAge: ageMax,
|
||||
HttpOnly: true,
|
||||
Secure: util.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
|
|
@ -127,6 +153,7 @@ func Login(db store.IStore) echo.HandlerFunc {
|
|||
cookie.Value = tokenUID
|
||||
cookie.MaxAge = ageMax
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = util.SecureCookie
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
c.SetCookie(cookie)
|
||||
|
||||
|
|
@ -209,10 +236,22 @@ func UpdateUser(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
username := data["username"].(string)
|
||||
password := data["password"].(string)
|
||||
previousUsername := data["previous_username"].(string)
|
||||
admin := data["admin"].(bool)
|
||||
username, ok := data["username"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
password, ok := data["password"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
previousUsername, ok := data["previous_username"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
admin, ok := data["admin"].(bool)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if !isAdmin(c) && (previousUsername != currentUser(c)) {
|
||||
return c.JSON(http.StatusForbidden, jsonHTTPResponse{false, "Manager cannot access other user data"})
|
||||
|
|
@ -283,9 +322,18 @@ func CreateUser(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
|
||||
var user model.User
|
||||
username := data["username"].(string)
|
||||
password := data["password"].(string)
|
||||
admin := data["admin"].(bool)
|
||||
username, ok := data["username"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
password, ok := data["password"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
admin, ok := data["admin"].(bool)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if username == "" || !usernameRegexp.MatchString(username) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
||||
|
|
@ -327,7 +375,10 @@ func RemoveUser(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
username := data["username"].(string)
|
||||
username, ok := data["username"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if !usernameRegexp.MatchString(username) {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid username"})
|
||||
|
|
@ -366,7 +417,7 @@ func WireGuardClients(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// GetClients handler return a JSON list of Wireguard client data
|
||||
// GetClients handler return a JSON list of WireGuard client data
|
||||
func GetClients(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
clientDataList, err := db.GetClients(true)
|
||||
|
|
@ -384,7 +435,7 @@ func GetClients(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// GetClient handler returns a JSON object of Wireguard client data
|
||||
// GetClient handler returns a JSON object of WireGuard client data
|
||||
func GetClient(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
clientID := c.Param("id")
|
||||
|
|
@ -431,6 +482,10 @@ func NewClient(db store.IStore) echo.HandlerFunc {
|
|||
|
||||
// validate the input Allocation IPs
|
||||
allocatedIPs, err := util.GetAllocatedIPs("")
|
||||
if err != nil {
|
||||
log.Error("Cannot get allocated IP addresses: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get allocated IP addresses"})
|
||||
}
|
||||
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, client.AllocatedIPs)
|
||||
if !check {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
|
||||
|
|
@ -452,12 +507,12 @@ func NewClient(db store.IStore) echo.HandlerFunc {
|
|||
guid := xid.New()
|
||||
client.ID = guid.String()
|
||||
|
||||
// gen Wireguard key pair
|
||||
// gen WireGuard key pair
|
||||
if client.PublicKey == "" {
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
log.Error("Cannot generate wireguard key pair: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate WireGuard key pair"})
|
||||
}
|
||||
client.PrivateKey = key.String()
|
||||
client.PublicKey = key.PublicKey().String()
|
||||
|
|
@ -465,7 +520,7 @@ func NewClient(db store.IStore) echo.HandlerFunc {
|
|||
_, err := wgtypes.ParseKey(client.PublicKey)
|
||||
if err != nil {
|
||||
log.Error("Cannot verify wireguard public key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard public key"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify WireGuard public key"})
|
||||
}
|
||||
// check for duplicates
|
||||
clients, err := db.GetClients(false)
|
||||
|
|
@ -486,7 +541,7 @@ func NewClient(db store.IStore) echo.HandlerFunc {
|
|||
if err != nil {
|
||||
log.Error("Cannot generated preshared key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{
|
||||
false, "Cannot generate Wireguard preshared key",
|
||||
false, "Cannot generate WireGuard preshared key",
|
||||
})
|
||||
}
|
||||
client.PresharedKey = presharedKey.String()
|
||||
|
|
@ -497,7 +552,7 @@ func NewClient(db store.IStore) echo.HandlerFunc {
|
|||
_, err := wgtypes.ParseKey(client.PresharedKey)
|
||||
if err != nil {
|
||||
log.Error("Cannot verify wireguard preshared key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify Wireguard preshared key"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify WireGuard preshared key"})
|
||||
}
|
||||
}
|
||||
client.CreatedAt = time.Now().UTC()
|
||||
|
|
@ -653,6 +708,10 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
|
|||
client := *clientData.Client
|
||||
// validate the input Allocation IPs
|
||||
allocatedIPs, err := util.GetAllocatedIPs(client.ID)
|
||||
if err != nil {
|
||||
log.Error("Cannot get allocated IP addresses: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get allocated IP addresses"})
|
||||
}
|
||||
check, err := util.ValidateIPAllocation(server.Interface.Addresses, allocatedIPs, _client.AllocatedIPs)
|
||||
if !check {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, fmt.Sprintf("%s", err)})
|
||||
|
|
@ -669,12 +728,12 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Extra Allowed IPs must be in CIDR format"})
|
||||
}
|
||||
|
||||
// update Wireguard Client PublicKey
|
||||
// update WireGuard Client PublicKey
|
||||
if client.PublicKey != _client.PublicKey && _client.PublicKey != "" {
|
||||
_, err := wgtypes.ParseKey(_client.PublicKey)
|
||||
if err != nil {
|
||||
log.Error("Cannot verify provided Wireguard public key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard public key"})
|
||||
log.Error("Cannot verify provided WireGuard public key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided WireGuard public key"})
|
||||
}
|
||||
// check for duplicates
|
||||
clients, err := db.GetClients(false)
|
||||
|
|
@ -689,7 +748,7 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// When replacing any PublicKey, discard any locally stored Wireguard Client PrivateKey
|
||||
// When replacing any PublicKey, discard any locally stored WireGuard Client PrivateKey
|
||||
// Client PubKey no longer corresponds to locally stored PrivKey.
|
||||
// QR code (needs PrivateKey) for this client is no longer possible now.
|
||||
|
||||
|
|
@ -698,12 +757,12 @@ func UpdateClient(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// update Wireguard Client PresharedKey
|
||||
// update WireGuard Client PresharedKey
|
||||
if client.PresharedKey != _client.PresharedKey && _client.PresharedKey != "" {
|
||||
_, err := wgtypes.ParseKey(_client.PresharedKey)
|
||||
if err != nil {
|
||||
log.Error("Cannot verify provided Wireguard preshared key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided Wireguard preshared key"})
|
||||
log.Error("Cannot verify provided WireGuard preshared key: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot verify provided WireGuard preshared key"})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -742,8 +801,14 @@ func SetClientStatus(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
clientID := data["id"].(string)
|
||||
status := data["status"].(bool)
|
||||
clientID, ok := data["id"].(string)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
status, ok := data["status"].(bool)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Bad post data"})
|
||||
}
|
||||
|
||||
if _, err := xid.FromString(clientID); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Please provide a valid client ID"})
|
||||
|
|
@ -798,8 +863,15 @@ func DownloadClient(db store.IStore) echo.HandlerFunc {
|
|||
// create io reader from string
|
||||
reader := strings.NewReader(config)
|
||||
|
||||
// sanitize the client name before using it as a filename to avoid
|
||||
// response header/filename injection
|
||||
filename := unsafeFilenameChars.ReplaceAllString(clientData.Client.Name, "_")
|
||||
if strings.Trim(filename, ".") == "" {
|
||||
filename = clientData.Client.ID
|
||||
}
|
||||
|
||||
// set response header for downloading
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s.conf", clientData.Client.Name))
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%q", filename+".conf"))
|
||||
return c.Stream(http.StatusOK, "text/conf", reader)
|
||||
}
|
||||
}
|
||||
|
|
@ -870,11 +942,11 @@ func WireGuardServerInterfaces(db store.IStore) echo.HandlerFunc {
|
|||
// WireGuardServerKeyPair handler to generate private and public keys
|
||||
func WireGuardServerKeyPair(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
// gen Wireguard key pair
|
||||
// gen WireGuard key pair
|
||||
key, err := wgtypes.GeneratePrivateKey()
|
||||
if err != nil {
|
||||
log.Error("Cannot generate wireguard key pair: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate WireGuard key pair"})
|
||||
}
|
||||
|
||||
var serverKeyPair model.ServerKeypair
|
||||
|
|
@ -883,7 +955,7 @@ func WireGuardServerKeyPair(db store.IStore) echo.HandlerFunc {
|
|||
serverKeyPair.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := db.SaveServerKeyPair(serverKeyPair); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate WireGuard key pair"})
|
||||
}
|
||||
log.Infof("Updated wireguard server interfaces settings: %v", serverKeyPair)
|
||||
|
||||
|
|
@ -899,13 +971,34 @@ func GlobalSettings(db store.IStore) echo.HandlerFunc {
|
|||
log.Error("Cannot get global settings: ", err)
|
||||
}
|
||||
|
||||
// Show the effective MaxMind key so a key set only via the environment is
|
||||
// visible in the form (and persisted to the DB on the next save).
|
||||
globalSettings.MaxmindLicenseKey = util.GetMaxmindLicenseKey(globalSettings)
|
||||
|
||||
geoLiteLastUpdated := ""
|
||||
if t, ok := geoip.LastUpdated(); ok {
|
||||
geoLiteLastUpdated = t.UTC().Format("2006-01-02 15:04:05 MST")
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "global_settings.html", map[string]interface{}{
|
||||
"baseData": model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
||||
"globalSettings": globalSettings,
|
||||
"baseData": model.BaseData{Active: "global-settings", CurrentUser: currentUser(c), Admin: isAdmin(c)},
|
||||
"globalSettings": globalSettings,
|
||||
"geoLiteAvailable": geoip.Available(),
|
||||
"geoLiteLastUpdated": geoLiteLastUpdated,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractDeviceNameFromConfigPath(db store.IStore) string {
|
||||
settings, err := db.GetGlobalSettings()
|
||||
if err != nil {
|
||||
log.Error("Cannot get global settings: ", err)
|
||||
}
|
||||
|
||||
base := filepath.Base(settings.ConfigFilePath)
|
||||
return strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
|
||||
// Status handler
|
||||
func Status(db store.IStore) echo.HandlerFunc {
|
||||
type PeerVM struct {
|
||||
|
|
@ -919,6 +1012,7 @@ func Status(db store.IStore) echo.HandlerFunc {
|
|||
Connected bool
|
||||
AllocatedIP string
|
||||
Endpoint string
|
||||
Location string
|
||||
}
|
||||
|
||||
type DeviceVM struct {
|
||||
|
|
@ -935,6 +1029,8 @@ func Status(db store.IStore) echo.HandlerFunc {
|
|||
})
|
||||
}
|
||||
|
||||
deviceName := extractDeviceNameFromConfigPath(db)
|
||||
|
||||
devices, err := wgClient.Devices()
|
||||
if err != nil {
|
||||
return c.Render(http.StatusInternalServerError, "status.html", map[string]interface{}{
|
||||
|
|
@ -963,38 +1059,47 @@ func Status(db store.IStore) echo.HandlerFunc {
|
|||
|
||||
conv := map[bool]int{true: 1, false: 0}
|
||||
for i := range devices {
|
||||
devVm := DeviceVM{Name: devices[i].Name}
|
||||
for j := range devices[i].Peers {
|
||||
var allocatedIPs string
|
||||
for _, ip := range devices[i].Peers[j].AllowedIPs {
|
||||
if len(allocatedIPs) > 0 {
|
||||
allocatedIPs += "</br>"
|
||||
if devices[i].Name == deviceName {
|
||||
devVm := DeviceVM{Name: devices[i].Name}
|
||||
for j := range devices[i].Peers {
|
||||
var allocatedIPs string
|
||||
for _, ip := range devices[i].Peers[j].AllowedIPs {
|
||||
if len(allocatedIPs) > 0 {
|
||||
allocatedIPs += "</br>"
|
||||
}
|
||||
allocatedIPs += ip.String()
|
||||
}
|
||||
allocatedIPs += ip.String()
|
||||
}
|
||||
pVm := PeerVM{
|
||||
PublicKey: devices[i].Peers[j].PublicKey.String(),
|
||||
ReceivedBytes: devices[i].Peers[j].ReceiveBytes,
|
||||
TransmitBytes: devices[i].Peers[j].TransmitBytes,
|
||||
LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
|
||||
LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime),
|
||||
AllocatedIP: allocatedIPs,
|
||||
}
|
||||
pVm.Connected = pVm.LastHandshakeRel.Minutes() < 3.
|
||||
pVm := PeerVM{
|
||||
PublicKey: devices[i].Peers[j].PublicKey.String(),
|
||||
ReceivedBytes: devices[i].Peers[j].ReceiveBytes,
|
||||
TransmitBytes: devices[i].Peers[j].TransmitBytes,
|
||||
LastHandshakeTime: devices[i].Peers[j].LastHandshakeTime,
|
||||
LastHandshakeRel: time.Since(devices[i].Peers[j].LastHandshakeTime),
|
||||
AllocatedIP: allocatedIPs,
|
||||
}
|
||||
pVm.Connected = pVm.LastHandshakeRel.Minutes() < 3.
|
||||
|
||||
if isAdmin(c) {
|
||||
pVm.Endpoint = devices[i].Peers[j].Endpoint.String()
|
||||
}
|
||||
if isAdmin(c) {
|
||||
pVm.Endpoint = devices[i].Peers[j].Endpoint.String()
|
||||
if endpoint := devices[i].Peers[j].Endpoint; endpoint != nil && endpoint.IP != nil && geoip.Available() {
|
||||
if loc, err := geoip.Lookup(endpoint.IP.String()); err == nil {
|
||||
pVm.Location = loc.Display()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if _client, ok := m[pVm.PublicKey]; ok {
|
||||
pVm.Name = _client.Name
|
||||
pVm.Email = _client.Email
|
||||
if _client, ok := m[pVm.PublicKey]; ok {
|
||||
// escape user-controlled fields: the status page is rendered
|
||||
// with text/template, which does not auto-escape HTML
|
||||
pVm.Name = html.EscapeString(_client.Name)
|
||||
pVm.Email = html.EscapeString(_client.Email)
|
||||
}
|
||||
devVm.Peers = append(devVm.Peers, pVm)
|
||||
}
|
||||
devVm.Peers = append(devVm.Peers, pVm)
|
||||
sort.SliceStable(devVm.Peers, func(i, j int) bool { return devVm.Peers[i].Name < devVm.Peers[j].Name })
|
||||
sort.SliceStable(devVm.Peers, func(i, j int) bool { return conv[devVm.Peers[i].Connected] > conv[devVm.Peers[j].Connected] })
|
||||
devicesVm = append(devicesVm, devVm)
|
||||
}
|
||||
sort.SliceStable(devVm.Peers, func(i, j int) bool { return devVm.Peers[i].Name < devVm.Peers[j].Name })
|
||||
sort.SliceStable(devVm.Peers, func(i, j int) bool { return conv[devVm.Peers[i].Connected] > conv[devVm.Peers[j].Connected] })
|
||||
devicesVm = append(devicesVm, devVm)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1018,11 +1123,17 @@ func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid DNS server address"})
|
||||
}
|
||||
|
||||
// validate the config file permissions (octal, e.g. 0600)
|
||||
if _, err := util.ParseConfigFileMode(globalSettings.ConfigFileMode); err != nil {
|
||||
log.Warnf("Invalid config file mode input from user: %v", globalSettings.ConfigFileMode)
|
||||
return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Config file permissions must be octal, e.g. 0600"})
|
||||
}
|
||||
|
||||
globalSettings.UpdatedAt = time.Now().UTC()
|
||||
|
||||
// write config to the database
|
||||
if err := db.SaveGlobalSettings(globalSettings); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate Wireguard key pair"})
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot generate WireGuard key pair"})
|
||||
}
|
||||
|
||||
log.Infof("Updated global settings: %v", globalSettings)
|
||||
|
|
@ -1031,6 +1142,28 @@ func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// GeoLiteUpdate handler downloads/updates the GeoLite2 database using the
|
||||
// MaxMind license key configured in the global settings.
|
||||
func GeoLiteUpdate(db store.IStore) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
globalSettings, err := db.GetGlobalSettings()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, "Cannot get global settings"})
|
||||
}
|
||||
|
||||
if err := geoip.Update(util.GetMaxmindLicenseKey(globalSettings)); err != nil {
|
||||
log.Error("Cannot update GeoLite2 database: ", err)
|
||||
return c.JSON(http.StatusInternalServerError, jsonHTTPResponse{false, err.Error()})
|
||||
}
|
||||
|
||||
msg := "GeoLite2 database updated successfully"
|
||||
if t, ok := geoip.LastUpdated(); ok {
|
||||
msg = fmt.Sprintf("GeoLite2 database updated successfully (%s)", t.UTC().Format("2006-01-02 15:04:05 MST"))
|
||||
}
|
||||
return c.JSON(http.StatusOK, jsonHTTPResponse{true, msg})
|
||||
}
|
||||
}
|
||||
|
||||
// MachineIPAddresses handler to get local interface ip addresses
|
||||
func MachineIPAddresses() echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
|
|
@ -1127,7 +1260,7 @@ func SuggestIPAllocation(db store.IStore) echo.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
// ApplyServerConfig handler to write config file and restart Wireguard server
|
||||
// ApplyServerConfig handler to write config file and restart WireGuard server
|
||||
func ApplyServerConfig(db store.IStore, tmplDir fs.FS) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
server, err := db.GetServer()
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ func WakeOnHost(db store.IStore) echo.HandlerFunc {
|
|||
return func(c echo.Context) error {
|
||||
macAddress := c.Param("mac_address")
|
||||
host, err := db.GetWakeOnLanHost(macAddress)
|
||||
if err != nil {
|
||||
return createError(c, err, fmt.Sprintf("Wake On Host Not Found: %s", macAddress))
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
host.LatestUsed = &now
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ func doRefreshSession(c echo.Context) {
|
|||
Path: cookiePath,
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
Secure: util.SecureCookie,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
sess.Save(c.Request(), c.Response())
|
||||
|
|
@ -123,6 +124,7 @@ func doRefreshSession(c echo.Context) {
|
|||
cookie.Value = oldCookie.Value
|
||||
cookie.MaxAge = maxAge
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = util.SecureCookie
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
|
@ -244,6 +246,7 @@ func clearSession(c echo.Context) {
|
|||
cookie.Path = cookiePath
|
||||
cookie.MaxAge = -1
|
||||
cookie.HttpOnly = true
|
||||
cookie.Secure = util.SecureCookie
|
||||
cookie.SameSite = http.SameSiteLaxMode
|
||||
c.SetCookie(cookie)
|
||||
}
|
||||
|
|
|
|||
71
main.go
71
main.go
|
|
@ -15,6 +15,7 @@ import (
|
|||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/gommon/log"
|
||||
"github.com/ngoduykhanh/wireguard-ui/geoip"
|
||||
"github.com/ngoduykhanh/wireguard-ui/store"
|
||||
"github.com/ngoduykhanh/wireguard-ui/telegram"
|
||||
|
||||
|
|
@ -50,17 +51,23 @@ var (
|
|||
flagTelegramFloodWait = 60
|
||||
flagSessionSecret = util.RandomString(32)
|
||||
flagSessionMaxDuration = 90
|
||||
flagSecureCookie = false
|
||||
flagWgConfTemplate string
|
||||
flagBasePath string
|
||||
flagSubnetRanges string
|
||||
flagBrandText = "WireGuard UI"
|
||||
flagAccentColor = "#343a40"
|
||||
flagPageTitlePrefix string
|
||||
flagGeoLite2DBPath = "./db/GeoLite2-City.mmdb"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEmailSubject = "Your wireguard configuration"
|
||||
defaultEmailContent = `Hi,</br>
|
||||
<p>In this email you can find your personal configuration for our wireguard server.</p>
|
||||
defaultEmailSubject = "Your VPN configuration"
|
||||
|
||||
<p>Best</p>
|
||||
defaultEmailContent = `
|
||||
<p>Greetings,</p>
|
||||
<p>Please find attached your personal configuration for our VPN server.<br>You may find instructions on how to install the WireGuard VPN client <a href="https://www.wireguard.com/install/">here</a>.</p>
|
||||
<p>Best regards.</p>
|
||||
`
|
||||
)
|
||||
|
||||
|
|
@ -94,6 +101,11 @@ func init() {
|
|||
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.")
|
||||
flag.BoolVar(&flagSecureCookie, "secure-cookie", util.LookupEnvOrBool(util.SecureCookieEnvVar, flagSecureCookie), "Set the Secure flag on session cookies. Enable when serving over HTTPS (e.g. behind a TLS reverse proxy).")
|
||||
flag.StringVar(&flagBrandText, "brand-text", util.LookupEnvOrString("WGUI_BRAND_TEXT", flagBrandText), "The UI brand text or name")
|
||||
flag.StringVar(&flagAccentColor, "accent-color", util.LookupEnvOrString("WGUI_ACCENT_COLOR", flagAccentColor), "The UI accent color")
|
||||
flag.StringVar(&flagPageTitlePrefix, "page-title-prefix", util.LookupEnvOrString("WGUI_PAGE_TITLE_PREFIX", flagPageTitlePrefix), "The prefix of the page title")
|
||||
flag.StringVar(&flagGeoLite2DBPath, "geolite-db-path", util.LookupEnvOrString(util.GeoLite2DBPathEnvVar, flagGeoLite2DBPath), "Path to the local GeoLite2-City database file")
|
||||
|
||||
var (
|
||||
smtpPasswordLookup = util.LookupEnvOrString("SMTP_PASSWORD", flagSmtpPassword)
|
||||
|
|
@ -140,9 +152,14 @@ func init() {
|
|||
util.EmailFromName = flagEmailFromName
|
||||
util.SessionSecret = sha512.Sum512([]byte(flagSessionSecret))
|
||||
util.SessionMaxDuration = int64(flagSessionMaxDuration) * 86_400 // Store in seconds
|
||||
util.SecureCookie = flagSecureCookie
|
||||
util.WgConfTemplate = flagWgConfTemplate
|
||||
util.BasePath = util.ParseBasePath(flagBasePath)
|
||||
util.SubnetRanges = util.ParseSubnetRanges(flagSubnetRanges)
|
||||
util.BrandText = flagBrandText
|
||||
util.AccentColor = flagAccentColor
|
||||
util.PageTitlePrefix = flagPageTitlePrefix
|
||||
util.GeoLite2DBPath = flagGeoLite2DBPath
|
||||
|
||||
lvl, _ := util.ParseLogLevel(util.LookupEnvOrString(util.LogLevel, "INFO"))
|
||||
|
||||
|
|
@ -154,12 +171,12 @@ func init() {
|
|||
// print only if log level is INFO or lower
|
||||
if lvl <= log.INFO {
|
||||
// print app information
|
||||
fmt.Println("Wireguard UI")
|
||||
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("Git Repo\t:", "https://github.com/idressos/wireguard-ui")
|
||||
fmt.Println("Authentication\t:", !util.DisableLogin)
|
||||
fmt.Println("Bind address\t:", util.BindAddress)
|
||||
//fmt.Println("Sendgrid key\t:", util.SendgridApiKey)
|
||||
|
|
@ -186,6 +203,9 @@ func main() {
|
|||
extraData["gitCommit"] = gitCommit
|
||||
extraData["basePath"] = util.BasePath
|
||||
extraData["loginDisabled"] = flagDisableLogin
|
||||
extraData["brandText"] = flagBrandText
|
||||
extraData["accentColor"] = flagAccentColor
|
||||
extraData["pageTitlePrefix"] = flagPageTitlePrefix
|
||||
|
||||
// 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")
|
||||
|
|
@ -194,6 +214,9 @@ func main() {
|
|||
// create the wireguard config on start, if it doesn't exist
|
||||
initServerConfig(db, tmplDir)
|
||||
|
||||
// load the GeoLite2 database and auto-update it if stale
|
||||
initGeoLite2(db)
|
||||
|
||||
// Check if subnet ranges are valid for the server configuration
|
||||
// Remove any non-valid CIDRs
|
||||
if err := util.ValidateAndFixSubnetRanges(db); err != nil {
|
||||
|
|
@ -210,6 +233,8 @@ func main() {
|
|||
|
||||
app.GET(util.BasePath, handler.WireGuardClients(db), handler.ValidSession, handler.RefreshSession)
|
||||
|
||||
app.GET(util.BasePath+"/logo", handler.Logo())
|
||||
|
||||
// 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.
|
||||
|
|
@ -250,6 +275,7 @@ func main() {
|
|||
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.POST(util.BasePath+"/api/geolite/update", handler.GeoLiteUpdate(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)
|
||||
|
|
@ -328,6 +354,39 @@ func initServerConfig(db store.IStore, tmplDir fs.FS) {
|
|||
}
|
||||
}
|
||||
|
||||
// initGeoLite2 loads the local GeoLite2 database and, if a MaxMind license key
|
||||
// is configured and the database is missing or older than 7 days, updates it in
|
||||
// the background so it does not delay startup.
|
||||
func initGeoLite2(db store.IStore) {
|
||||
geoip.SetDBPath(util.GeoLite2DBPath)
|
||||
|
||||
if err := geoip.Load(); err != nil {
|
||||
log.Infof("GeoLite2 database not loaded yet: %v", err)
|
||||
}
|
||||
|
||||
settings, err := db.GetGlobalSettings()
|
||||
if err != nil {
|
||||
log.Warnf("Cannot get global settings for GeoLite2: %v", err)
|
||||
return
|
||||
}
|
||||
licenseKey := util.GetMaxmindLicenseKey(settings)
|
||||
if licenseKey == "" {
|
||||
return
|
||||
}
|
||||
if !geoip.NeedsUpdate(7 * 24 * time.Hour) {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("GeoLite2 database is missing or older than 7 days, updating...")
|
||||
if err := geoip.Update(licenseKey); err != nil {
|
||||
log.Warnf("GeoLite2 auto-update failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Info("GeoLite2 database updated successfully")
|
||||
}()
|
||||
}
|
||||
|
||||
func initTelegram(initDeps telegram.TgBotInitDependencies) {
|
||||
go func() {
|
||||
for {
|
||||
|
|
|
|||
|
|
@ -13,5 +13,7 @@ type GlobalSetting struct {
|
|||
FirewallMark string `json:"firewall_mark"`
|
||||
Table string `json:"table"`
|
||||
ConfigFilePath string `json:"config_file_path"`
|
||||
ConfigFileMode string `json:"config_file_mode"`
|
||||
MaxmindLicenseKey string `json:"maxmind_license_key"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "wireguard-ui",
|
||||
"version": "1.0.0",
|
||||
"description": "Wireguard web interface",
|
||||
"description": "WireGuard web interface",
|
||||
"main": "index.js",
|
||||
"repository": "git@github.com:ngoduykhanh/wireguard-ui.git",
|
||||
"author": "Khanh Ngo <k@ndk.name>",
|
||||
|
|
|
|||
|
|
@ -51,11 +51,16 @@ func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c
|
|||
func New(tmplDir fs.FS, extraData map[string]interface{}, secret [64]byte) *echo.Echo {
|
||||
e := echo.New()
|
||||
|
||||
// Recover from panics in any middleware or handler so a single bad request
|
||||
// cannot take down the connection with an unhandled stack trace.
|
||||
e.Use(middleware.Recover())
|
||||
|
||||
cookiePath := util.GetCookiePath()
|
||||
|
||||
cookieStore := sessions.NewCookieStore(secret[:32], secret[32:])
|
||||
cookieStore.Options.Path = cookiePath
|
||||
cookieStore.Options.HttpOnly = true
|
||||
cookieStore.Options.Secure = util.SecureCookie
|
||||
cookieStore.MaxAge(86400 * 7)
|
||||
|
||||
e.Use(session.Middleware(cookieStore))
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ func (o *JsonDB) Init() error {
|
|||
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.ConfigFileMode = util.LookupEnvOrString(util.ConfigFileModeEnvVar, util.DefaultConfigFileMode)
|
||||
globalSetting.MaxmindLicenseKey = util.LookupEnvOrString(util.MaxmindLicenseKeyEnvVar, "")
|
||||
globalSetting.UpdatedAt = time.Now().UTC()
|
||||
o.conn.Write("server", "global_settings", globalSetting)
|
||||
err := util.ManagePerms(globalSettingPath)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ var (
|
|||
|
||||
floodWait = make(map[int64]int64)
|
||||
floodMessageSent = make(map[int64]struct{})
|
||||
floodMutex sync.Mutex
|
||||
)
|
||||
|
||||
func Start(initDeps TgBotInitDependencies) (err error) {
|
||||
|
|
@ -79,11 +80,25 @@ func Start(initDeps TgBotInitDependencies) (err error) {
|
|||
for update := range updatesChan {
|
||||
if update.Message != nil {
|
||||
userid := update.Message.Chat.ID
|
||||
if _, wait := floodWait[userid]; wait {
|
||||
|
||||
floodMutex.Lock()
|
||||
_, wait := floodWait[userid]
|
||||
alreadyNotified := false
|
||||
if wait {
|
||||
if _, notified := floodMessageSent[userid]; notified {
|
||||
alreadyNotified = true
|
||||
} else {
|
||||
floodMessageSent[userid] = struct{}{}
|
||||
}
|
||||
} else {
|
||||
floodWait[userid] = time.Now().Unix()
|
||||
}
|
||||
floodMutex.Unlock()
|
||||
|
||||
if wait {
|
||||
if alreadyNotified {
|
||||
continue
|
||||
}
|
||||
floodMessageSent[userid] = struct{}{}
|
||||
_, err := bot.SendMessage(
|
||||
fmt.Sprintf("You can only request your configs once per %d minutes", FloodWait),
|
||||
userid,
|
||||
|
|
@ -95,7 +110,6 @@ func Start(initDeps TgBotInitDependencies) (err error) {
|
|||
}
|
||||
continue
|
||||
}
|
||||
floodWait[userid] = time.Now().Unix()
|
||||
|
||||
failed := initDeps.SendRequestedConfigsToTelegram(initDeps.DB, userid)
|
||||
if len(failed) > 0 {
|
||||
|
|
@ -126,12 +140,14 @@ func SendConfig(userid int64, clientName string, confData, qrData []byte, ignore
|
|||
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 {
|
||||
floodMutex.Lock()
|
||||
if _, wait := floodWait[userid]; wait {
|
||||
floodMutex.Unlock()
|
||||
return fmt.Errorf("this client already got their config less than %d minutes ago", FloodWait)
|
||||
}
|
||||
floodWait[userid] = time.Now().Unix()
|
||||
floodMutex.Unlock()
|
||||
}
|
||||
|
||||
qrAttachment := echotron.NewInputFileBytes("qr.png", qrData)
|
||||
|
|
@ -152,10 +168,12 @@ func SendConfig(userid int64, clientName string, confData, qrData []byte, ignore
|
|||
|
||||
func updateFloodWait() {
|
||||
thresholdTS := time.Now().Unix() - 60*int64(FloodWait)
|
||||
floodMutex.Lock()
|
||||
for userid, ts := range floodWait {
|
||||
if ts < thresholdTS {
|
||||
delete(floodWait, userid)
|
||||
delete(floodMessageSent, userid)
|
||||
}
|
||||
}
|
||||
floodMutex.Unlock()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ About
|
|||
<div class="col-md-6">
|
||||
<div class="card card-success">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">About Wireguard-UI</h3>
|
||||
<h3 class="card-title">About WireGuard-UI</h3>
|
||||
</div>
|
||||
<!-- /.card-header -->
|
||||
<div class="card-body">
|
||||
|
|
@ -63,7 +63,7 @@ About
|
|||
</div>
|
||||
<strong>Copyright ©
|
||||
<script>document.write(new Date().getFullYear())</script>
|
||||
<a href="https://github.com/ngoduykhanh/wireguard-ui">Wireguard UI</a>.
|
||||
<a href="https://github.com/idressos/wireguard-ui">WireGuard UI</a>.
|
||||
</strong> All rights reserved.
|
||||
|
||||
</div>
|
||||
|
|
@ -83,7 +83,7 @@ About
|
|||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/releases/tags/' + $("#version").val(),
|
||||
url: 'https://api.github.com/repos/idressos/wireguard-ui/releases/tags/' + $("#version").val(),
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
|
|
@ -99,7 +99,7 @@ About
|
|||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/releases/latest',
|
||||
url: 'https://api.github.com/repos/idressos/wireguard-ui/releases/latest',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
|
|
@ -121,7 +121,7 @@ About
|
|||
$.ajax({
|
||||
cache: false,
|
||||
method: 'GET',
|
||||
url: 'https://api.github.com/repos/ngoduykhanh/wireguard-ui/contributors',
|
||||
url: 'https://api.github.com/repos/idressos/wireguard-ui/contributors',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
success: function (data) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>{{template "title" .}}</title>
|
||||
<title>{{.pageTitlePrefix}}{{template "title" .}}</title>
|
||||
<!-- Tell the browser to be responsive to screen width -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Favicon -->
|
||||
|
|
@ -86,10 +86,10 @@
|
|||
<!-- /.navbar -->
|
||||
|
||||
<!-- Main Sidebar Container -->
|
||||
<aside class="main-sidebar sidebar-dark-primary elevation-4">
|
||||
<aside class="main-sidebar sidebar-dark-primary elevation-4" style="background-color: {{.accentColor}};">
|
||||
<!-- Brand Logo -->
|
||||
<a href="{{.basePath}}" class="brand-link">
|
||||
<span class="brand-text"> WIREGUARD UI</span>
|
||||
<span class="brand-text"> {{.brandText}}</span>
|
||||
</a>
|
||||
|
||||
<!-- Sidebar -->
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
<a href="{{.basePath}}/" class="nav-link {{if eq .baseData.Active ""}}active{{end}}">
|
||||
<i class="nav-icon fas fa-user-secret"></i>
|
||||
<p>
|
||||
Wireguard Clients
|
||||
Clients
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -132,7 +132,7 @@
|
|||
<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
|
||||
WireGuard Server
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -176,6 +176,8 @@
|
|||
</p>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{{if .baseData.Admin}}
|
||||
<li class="nav-header">ABOUT</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{.basePath}}/about" class="nav-link {{if eq .baseData.Active "about" }}active{{end}}">
|
||||
|
|
@ -185,6 +187,7 @@
|
|||
</p>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</nav>
|
||||
<!-- /.sidebar-menu -->
|
||||
|
|
@ -196,7 +199,7 @@
|
|||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">New Wireguard Client</h4>
|
||||
<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>
|
||||
|
|
@ -352,7 +355,7 @@
|
|||
<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
|
||||
<strong>Copyright © <script>document.write(new Date().getFullYear())</script> <a href="https://github.com/idressos/wireguard-ui">WireGuard UI</a>.</strong> All rights
|
||||
reserved.
|
||||
</footer>
|
||||
-->
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{{define "title"}}
|
||||
Wireguard Clients
|
||||
Clients
|
||||
{{end}}
|
||||
|
||||
{{define "top_css"}}
|
||||
|
|
@ -17,13 +17,13 @@ Wireguard Clients
|
|||
{{end}}
|
||||
|
||||
{{define "page_title"}}
|
||||
Wireguard Clients
|
||||
Clients
|
||||
{{end}}
|
||||
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<!-- <h5 class="mt-4 mb-2">Wireguard Clients</h5> -->
|
||||
<!-- <h5 class="mt-4 mb-2">Clients</h5> -->
|
||||
<div class="row" id="client-list">
|
||||
</div>
|
||||
<!-- /.row -->
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ Global Settings
|
|||
<div class="col-md-6">
|
||||
<div class="card card-success">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Wireguard Global Settings</h3>
|
||||
<h3 class="card-title">WireGuard Global Settings</h3>
|
||||
</div>
|
||||
<!-- /.card-header -->
|
||||
<!-- form start -->
|
||||
|
|
@ -68,11 +68,35 @@ Global Settings
|
|||
value="{{ .globalSettings.Table }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="config_file_path">Wireguard Config File Path</label>
|
||||
<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 class="form-group">
|
||||
<label for="config_file_mode">Config File Permissions</label>
|
||||
<input type="text" class="form-control" id="config_file_mode"
|
||||
name="config_file_mode" placeholder="E.g. 0600"
|
||||
value="{{ if .globalSettings.ConfigFileMode }}{{ .globalSettings.ConfigFileMode }}{{ else }}0600{{ end }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="maxmind_license_key">MaxMind License Key</label>
|
||||
<input type="text" class="form-control" id="maxmind_license_key"
|
||||
name="maxmind_license_key" placeholder="GeoLite2 license key"
|
||||
value="{{ .globalSettings.MaxmindLicenseKey }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="geolite_status">GeoLite2 Database</label>
|
||||
<div class="input-group input-group">
|
||||
<input type="text" class="form-control" id="geolite_status" readonly
|
||||
value="{{ if .geoLiteAvailable }}Installed{{ if .geoLiteLastUpdated }} — updated {{ .geoLiteLastUpdated }}{{ end }}{{ else }}Not installed{{ end }}">
|
||||
<span class="input-group-append">
|
||||
<button type="button" class="btn btn-primary btn-flat" id="btn_update_geolite"
|
||||
onclick="updateGeoLite2()"><i class="nav-icon fas fa-download"></i>
|
||||
Install / Update</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card-body -->
|
||||
|
||||
|
|
@ -92,7 +116,7 @@ Global Settings
|
|||
<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
|
||||
<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>
|
||||
|
|
@ -110,9 +134,18 @@ Global Settings
|
|||
<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
|
||||
<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>
|
||||
<dt>8. Config File Permissions</dt>
|
||||
<dd>Octal file mode applied to the generated config file, which contains private keys.
|
||||
Default value: <code>0600</code> (owner read/write only). Leave blank to use the default.</dd>
|
||||
<dt>9. MaxMind License Key</dt>
|
||||
<dd>Your MaxMind license key, used to download the GeoLite2-City database. Create a free
|
||||
account at <code>maxmind.com</code> to generate one. Click
|
||||
<strong>Install / Update</strong> to download it locally; it also auto-updates on startup
|
||||
when older than 7 days. Once installed, a <strong>Location</strong> column
|
||||
(flag, city, country) is shown next to each peer's endpoint on the Status page.</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -152,23 +185,28 @@ Global Settings
|
|||
|
||||
{{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};
|
||||
function collectGlobalSettings() {
|
||||
return {
|
||||
"endpoint_address": $("#endpoint_address").val(),
|
||||
"dns_servers": $("#dns_servers").val().split(","),
|
||||
"mtu": $("#mtu").val(),
|
||||
"persistent_keepalive": $("#persistent_keepalive").val(),
|
||||
"firewall_mark": $("#firewall_mark").val(),
|
||||
"table": $("#table").val(),
|
||||
"config_file_path": $("#config_file_path").val(),
|
||||
"config_file_mode": $("#config_file_mode").val(),
|
||||
"maxmind_license_key": $("#maxmind_license_key").val()
|
||||
};
|
||||
}
|
||||
|
||||
function submitGlobalSettings() {
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/global-settings',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(data),
|
||||
data: JSON.stringify(collectGlobalSettings()),
|
||||
success: function(data) {
|
||||
$("#modal_new_client").modal('hide');
|
||||
toastr.success('Update global settings successfully');
|
||||
|
|
@ -180,6 +218,46 @@ Global Settings
|
|||
});
|
||||
}
|
||||
|
||||
// Save settings (to persist the license key), then download/update the
|
||||
// GeoLite2 database.
|
||||
function updateGeoLite2() {
|
||||
const btn = $("#btn_update_geolite");
|
||||
btn.prop("disabled", true);
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/global-settings',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify(collectGlobalSettings()),
|
||||
success: function() {
|
||||
toastr.info('Downloading GeoLite2 database, please wait...');
|
||||
$.ajax({
|
||||
cache: false,
|
||||
method: 'POST',
|
||||
url: '{{.basePath}}/api/geolite/update',
|
||||
dataType: 'json',
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({}),
|
||||
success: function(res) {
|
||||
toastr.success(res['message']);
|
||||
setTimeout(function () { location.reload(); }, 1500);
|
||||
},
|
||||
error: function(jqXHR) {
|
||||
btn.prop("disabled", false);
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: function(jqXHR) {
|
||||
btn.prop("disabled", false);
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
toastr.error(responseJson['message']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateEndpointSuggestionIP() {
|
||||
$.getJSON("{{.basePath}}/api/machine-ips", null, function(data) {
|
||||
$("#ip_suggestion option").remove();
|
||||
|
|
@ -195,7 +273,7 @@ Global Settings
|
|||
}
|
||||
</script>
|
||||
<script>
|
||||
// Wireguard Interface DNS server tag input
|
||||
// WireGuard Interface DNS server tag input
|
||||
$("#dns_servers").tagsInput({
|
||||
'width': '100%',
|
||||
'height': '75%',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<title>WireGuard UI</title>
|
||||
<title>{{.brandText}}</title>
|
||||
<!-- Tell the browser to be responsive to screen width -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!-- Favicon -->
|
||||
|
|
@ -24,8 +24,8 @@
|
|||
|
||||
<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 class="login-logo pb-3">
|
||||
<img class="img-fluid" src="{{.basePath}}/logo">
|
||||
</div>
|
||||
<!-- /.login-logo -->
|
||||
<div class="card">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{{define "title"}}
|
||||
Wireguard Server
|
||||
WireGuard Server
|
||||
{{end}}
|
||||
|
||||
{{define "top_css"}}
|
||||
|
|
@ -10,13 +10,13 @@ Wireguard Server
|
|||
{{end}}
|
||||
|
||||
{{define "page_title"}}
|
||||
Wireguard Server Settings
|
||||
WireGuard Server Settings
|
||||
{{end}}
|
||||
|
||||
{{define "page_content"}}
|
||||
<section class="content">
|
||||
<div class="container-fluid">
|
||||
<!-- <h5 class="mt-4 mb-2">Wireguard Server</h5> -->
|
||||
<!-- <h5 class="mt-4 mb-2">WireGuard Server</h5> -->
|
||||
<div class="row">
|
||||
<!-- left column -->
|
||||
<div class="col-md-6">
|
||||
|
|
@ -39,8 +39,8 @@ Wireguard Server Settings
|
|||
</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 }}">
|
||||
<textarea class="form-control" id="post_up" name="post_up"
|
||||
placeholder="Post Up Script">{{ .serverInterface.PostUp }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pre_down">Pre Down Script</label>
|
||||
|
|
@ -50,8 +50,8 @@ Wireguard Server Settings
|
|||
|
||||
<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 }}">
|
||||
<textarea class="form-control" id="post_down" name="post_down"
|
||||
placeholder="Post Down Script">{{ .serverInterface.PostDown }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.card-body -->
|
||||
|
|
@ -115,7 +115,7 @@ Wireguard Server Settings
|
|||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure to generate a new key pair for the Wireguard server?<br/>
|
||||
<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">
|
||||
|
|
@ -149,7 +149,7 @@ Wireguard Server Settings
|
|||
data: JSON.stringify(data),
|
||||
success: function(data) {
|
||||
$("#modal_new_client").modal('hide');
|
||||
toastr.success('Updated Wireguard server interface addresses successfully');
|
||||
toastr.success('Updated WireGuard server interface addresses successfully');
|
||||
},
|
||||
error: function(jqXHR, exception) {
|
||||
const responseJson = jQuery.parseJSON(jqXHR.responseText);
|
||||
|
|
@ -159,7 +159,7 @@ Wireguard Server Settings
|
|||
}
|
||||
</script>
|
||||
<script>
|
||||
// Wireguard Interface Addresses tag input
|
||||
// WireGuard Interface Addresses tag input
|
||||
$("#addresses").tagsInput({
|
||||
'width': '100%',
|
||||
// 'height': '75%',
|
||||
|
|
@ -177,7 +177,7 @@ Wireguard Server Settings
|
|||
$("#addresses").addTag('{{.}}');
|
||||
{{end}}
|
||||
|
||||
// Wireguard Interface Addresses form validation
|
||||
// WireGuard Interface Addresses form validation
|
||||
$(document).ready(function () {
|
||||
$.validator.setDefaults({
|
||||
submitHandler: function () {
|
||||
|
|
@ -213,7 +213,7 @@ Wireguard Server Settings
|
|||
});
|
||||
});
|
||||
|
||||
// Wireguard Key Pair generation confirmation button
|
||||
// WireGuard Key Pair generation confirmation button
|
||||
$(document).ready(function () {
|
||||
$("#btn_generate_confirm").click(function () {
|
||||
$.ajax({
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ Connected Peers
|
|||
{{ 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>
|
||||
|
|
@ -43,6 +42,7 @@ Connected Peers
|
|||
<th scope="col">Email</th>
|
||||
<th scope="col">Allocated IPs</th>
|
||||
<th scope="col">Endpoint</th>
|
||||
<th scope="col">Location</th>
|
||||
<th scope="col">Public Key</th>
|
||||
<th scope="col">Received</th>
|
||||
<th scope="col">Transmitted</th>
|
||||
|
|
@ -58,6 +58,7 @@ Connected Peers
|
|||
<td>{{ $peer.Email }}</td>
|
||||
<td>{{ $peer.AllocatedIP }}</td>
|
||||
<td>{{ $peer.Endpoint }}</td>
|
||||
<td>{{ $peer.Location }}</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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# This file was generated using wireguard-ui (https://github.com/ngoduykhanh/wireguard-ui)
|
||||
# This file was generated using wireguard-ui (https://github.com/idressos/wireguard-ui)
|
||||
# Please don't modify it manually, otherwise your change might get replaced.
|
||||
|
||||
# Address updated at: {{ .serverConfig.Interface.UpdatedAt }}
|
||||
|
|
@ -7,11 +7,12 @@
|
|||
Address = {{$first :=true}}{{range .serverConfig.Interface.Addresses }}{{if $first}}{{$first = false}}{{else}},{{end}}{{.}}{{end}}
|
||||
ListenPort = {{ .serverConfig.Interface.ListenPort }}
|
||||
PrivateKey = {{ .serverConfig.KeyPair.PrivateKey }}
|
||||
{{if .globalSettings.FirewallMark }}FwMark = {{ .globalSettings.FirewallMark }}{{end}}
|
||||
{{if .globalSettings.MTU}}MTU = {{ .globalSettings.MTU }}{{end}}
|
||||
PostUp = {{ .serverConfig.Interface.PostUp }}
|
||||
PreDown = {{ .serverConfig.Interface.PreDown }}
|
||||
PostDown = {{ .serverConfig.Interface.PostDown }}
|
||||
Table = {{ .globalSettings.Table }}
|
||||
{{if .serverConfig.Interface.PostUp }}PostUp = {{ .serverConfig.Interface.PostUp }}{{end}}
|
||||
{{if .serverConfig.Interface.PreDown }}PreDown = {{ .serverConfig.Interface.PreDown }}{{end}}
|
||||
{{if .serverConfig.Interface.PostDown }}PostDown = {{ .serverConfig.Interface.PostDown }}{{end}}
|
||||
{{if .globalSettings.Table}}Table = {{ .globalSettings.Table }}{{end}}
|
||||
|
||||
{{range .clientDataList}}{{if eq .Client.Enabled true}}
|
||||
# ID: {{ .Client.ID }}
|
||||
|
|
|
|||
|
|
@ -24,10 +24,15 @@ var (
|
|||
EmailFromName string
|
||||
SessionSecret [64]byte
|
||||
SessionMaxDuration int64
|
||||
SecureCookie bool
|
||||
WgConfTemplate string
|
||||
BasePath string
|
||||
SubnetRanges map[string]([]*net.IPNet)
|
||||
SubnetRangesOrder []string
|
||||
BrandText string
|
||||
AccentColor string
|
||||
PageTitlePrefix string
|
||||
GeoLite2DBPath string
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -42,6 +47,8 @@ const (
|
|||
DefaultFirewallMark = "0xca6c" // i.e. 51820
|
||||
DefaultTable = "auto"
|
||||
DefaultConfigFilePath = "/etc/wireguard/wg0.conf"
|
||||
DefaultConfigFileMode = "0600"
|
||||
DefaultGeoLite2DBPath = "./db/GeoLite2-City.mmdb"
|
||||
UsernameEnvVar = "WGUI_USERNAME"
|
||||
PasswordEnvVar = "WGUI_PASSWORD"
|
||||
PasswordFileEnvVar = "WGUI_PASSWORD_FILE"
|
||||
|
|
@ -55,6 +62,7 @@ const (
|
|||
FirewallMarkEnvVar = "WGUI_FIREWALL_MARK"
|
||||
TableEnvVar = "WGUI_TABLE"
|
||||
ConfigFilePathEnvVar = "WGUI_CONFIG_FILE_PATH"
|
||||
ConfigFileModeEnvVar = "WGUI_CONFIG_FILE_MODE"
|
||||
LogLevel = "WGUI_LOG_LEVEL"
|
||||
ServerAddressesEnvVar = "WGUI_SERVER_INTERFACE_ADDRESSES"
|
||||
ServerListenPortEnvVar = "WGUI_SERVER_LISTEN_PORT"
|
||||
|
|
@ -64,6 +72,13 @@ const (
|
|||
DefaultClientExtraAllowedIpsEnvVar = "WGUI_DEFAULT_CLIENT_EXTRA_ALLOWED_IPS"
|
||||
DefaultClientUseServerDNSEnvVar = "WGUI_DEFAULT_CLIENT_USE_SERVER_DNS"
|
||||
DefaultClientEnableAfterCreationEnvVar = "WGUI_DEFAULT_CLIENT_ENABLE_AFTER_CREATION"
|
||||
BrandTextEnvVar = "WGUI_BRAND_TEXT"
|
||||
AccentColorEnvVar = "WGUI_ACCENT_COLOR"
|
||||
PageTitlePrefixEnvVar = "WGUI_PAGE_TITLE_PREFIX"
|
||||
LogoFilePathEnvVar = "WGUI_LOGO_FILE_PATH"
|
||||
MaxmindLicenseKeyEnvVar = "WGUI_MAXMIND_LICENSE_KEY"
|
||||
GeoLite2DBPathEnvVar = "WGUI_GEOLITE_DB_PATH"
|
||||
SecureCookieEnvVar = "WGUI_SESSION_SECURE_COOKIE"
|
||||
)
|
||||
|
||||
func ParseBasePath(basePath string) string {
|
||||
|
|
|
|||
86
util/util.go
86
util/util.go
|
|
@ -3,6 +3,7 @@ package util
|
|||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
crand "crypto/rand"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
|
@ -10,7 +11,7 @@ import (
|
|||
"hash/crc32"
|
||||
"io"
|
||||
"io/fs"
|
||||
"math/rand"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
|
|
@ -94,6 +95,18 @@ func BuildClientConfig(client model.Client, server model.Server, setting model.G
|
|||
return strConfig
|
||||
}
|
||||
|
||||
// GetMaxmindLicenseKey resolves the effective MaxMind license key, preferring
|
||||
// the value stored in the global settings and falling back to the
|
||||
// WGUI_MAXMIND_LICENSE_KEY environment variable. This lets a key configured
|
||||
// purely via the environment work even when it was never persisted to the DB
|
||||
// (e.g. the settings file predates the key being set).
|
||||
func GetMaxmindLicenseKey(settings model.GlobalSetting) string {
|
||||
if k := strings.TrimSpace(settings.MaxmindLicenseKey); k != "" {
|
||||
return k
|
||||
}
|
||||
return strings.TrimSpace(LookupEnvOrString(MaxmindLicenseKeyEnvVar, ""))
|
||||
}
|
||||
|
||||
// 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{}
|
||||
|
|
@ -540,7 +553,27 @@ func GetSubnetRangesString() string {
|
|||
return strings.TrimSpace(strB.String())
|
||||
}
|
||||
|
||||
// WriteWireGuardServerConfig to write Wireguard server config. e.g. wg0.conf
|
||||
// configFileFallbackMode is applied to the generated WireGuard config file when
|
||||
// no valid mode is configured. The file holds private keys, so it defaults to
|
||||
// owner read/write only.
|
||||
const configFileFallbackMode os.FileMode = 0600
|
||||
|
||||
// ParseConfigFileMode parses an octal file-mode string (e.g. "0600"). An empty
|
||||
// string yields the default 0600. A value that is not valid octal is an error.
|
||||
// Only the permission bits are kept.
|
||||
func ParseConfigFileMode(mode string) (os.FileMode, error) {
|
||||
mode = strings.TrimSpace(mode)
|
||||
if mode == "" {
|
||||
return configFileFallbackMode, nil
|
||||
}
|
||||
parsed, err := strconv.ParseUint(mode, 8, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid file mode %q: must be octal, e.g. 0600", mode)
|
||||
}
|
||||
return os.FileMode(parsed) & os.ModePerm, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
|
|
@ -575,12 +608,6 @@ func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, client
|
|||
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,
|
||||
|
|
@ -588,13 +615,34 @@ func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, client
|
|||
"usersList": usersList,
|
||||
}
|
||||
|
||||
err = t.Execute(f, config)
|
||||
// The config file contains private keys, so restrict its permissions.
|
||||
// Default to 0600 when unset or invalid.
|
||||
mode, err := ParseConfigFileMode(globalSettings.ConfigFileMode)
|
||||
if err != nil {
|
||||
log.Warnf("%v; falling back to 0600 for %s", err, globalSettings.ConfigFilePath)
|
||||
mode = configFileFallbackMode
|
||||
}
|
||||
|
||||
// Write in place (rather than via a temp file + rename) so that a plain
|
||||
// IN_CLOSE_WRITE is emitted on the config path. The container entrypoint's
|
||||
// inotify watch (WGUI_MANAGE_RESTART) relies on this to restart WireGuard.
|
||||
f, err := os.OpenFile(globalSettings.ConfigFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
// Enforce the exact permissions regardless of the process umask or of a
|
||||
// pre-existing file's mode (OpenFile does not change an existing file).
|
||||
if err := os.Chmod(globalSettings.ConfigFilePath, mode); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
if err := t.Execute(f, config); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// SendRequestedConfigsToTelegram to send client all their configs. Returns failed configs list.
|
||||
|
|
@ -763,12 +811,22 @@ func UpdateHashes(db store.IStore) error {
|
|||
return db.SaveHashes(clientServerHashes)
|
||||
}
|
||||
|
||||
// RandomString returns a random alphanumeric string of the given length.
|
||||
// It uses a cryptographically secure source (crypto/rand) since it backs the
|
||||
// default session secret; it only falls back to a time-seeded generator if the
|
||||
// system CSPRNG is unavailable.
|
||||
func RandomString(length int) string {
|
||||
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
seededRand := mrand.New(mrand.NewSource(time.Now().UnixNano()))
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = charset[seededRand.Intn(len(charset))]
|
||||
b[i] = charset[int(b[i])%len(charset)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/ngoduykhanh/wireguard-ui/model"
|
||||
)
|
||||
|
||||
func TestParseConfigFileMode(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want os.FileMode
|
||||
wantErr bool
|
||||
}{
|
||||
{"", 0600, false},
|
||||
{" ", 0600, false},
|
||||
{"0600", 0600, false},
|
||||
{"600", 0600, false},
|
||||
{"0640", 0640, false},
|
||||
{"0644", 0644, false},
|
||||
{"0777", 0777, false},
|
||||
{"nope", 0, true},
|
||||
{"0x600", 0, true},
|
||||
{"999", 0, true}, // 9 is not a valid octal digit
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ParseConfigFileMode(c.in)
|
||||
if c.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ParseConfigFileMode(%q) expected error, got mode %o", c.in, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ParseConfigFileMode(%q) unexpected error: %v", c.in, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ParseConfigFileMode(%q) = %o, want %o", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testServer() model.Server {
|
||||
return model.Server{
|
||||
KeyPair: &model.ServerKeypair{PrivateKey: "priv", PublicKey: "pub"},
|
||||
Interface: &model.ServerInterface{Addresses: []string{"10.0.0.1/24"}, ListenPort: 51820},
|
||||
}
|
||||
}
|
||||
|
||||
// minimal template that exercises the fields WriteWireGuardServerConfig injects
|
||||
const testTmpl = "[Interface]\nListenPort = {{ .serverConfig.Interface.ListenPort }}\nPrivateKey = {{ .serverConfig.KeyPair.PrivateKey }}\n"
|
||||
|
||||
func TestWriteWireGuardServerConfigMode(t *testing.T) {
|
||||
tmplDir := fstest.MapFS{"wg.conf": &fstest.MapFile{Data: []byte(testTmpl)}}
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "wg0.conf")
|
||||
|
||||
// default (empty) mode must produce a 0600 file
|
||||
settings := model.GlobalSetting{ConfigFilePath: cfgPath}
|
||||
if err := WriteWireGuardServerConfig(tmplDir, testServer(), nil, nil, settings); err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
fi, err := os.Stat(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat failed: %v", err)
|
||||
}
|
||||
if fi.Mode().Perm() != 0600 {
|
||||
t.Errorf("default mode = %o, want 0600", fi.Mode().Perm())
|
||||
}
|
||||
data, _ := os.ReadFile(cfgPath)
|
||||
if !strings.Contains(string(data), "ListenPort = 51820") {
|
||||
t.Errorf("rendered config missing expected content: %q", string(data))
|
||||
}
|
||||
|
||||
// custom mode must be honored, even when overwriting an existing file
|
||||
settings.ConfigFileMode = "0640"
|
||||
if err := WriteWireGuardServerConfig(tmplDir, testServer(), nil, nil, settings); err != nil {
|
||||
t.Fatalf("write failed: %v", err)
|
||||
}
|
||||
fi, _ = os.Stat(cfgPath)
|
||||
if fi.Mode().Perm() != 0640 {
|
||||
t.Errorf("custom mode = %o, want 0640", fi.Mode().Perm())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue