This commit is contained in:
Ioannis Dressos 2026-07-08 09:51:05 +00:00 committed by GitHub
commit 4388b0c3d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 1045 additions and 377 deletions

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
github: [ngoduykhanh]

View File

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

View File

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

View File

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

84
CLAUDE.md Normal file
View File

@ -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).

View File

@ -1,9 +1,13 @@
![](https://github.com/ngoduykhanh/wireguard-ui/workflows/wireguard-ui%20build%20release/badge.svg)
# 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).

BIN
custom/img/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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>&nbsp;`;
allocatedIpsHtml += `<small class="badge badge-secondary">${escapeHtml(obj)}</small>&nbsp;`;
})
// render client allowed ip addresses
let allowedIpsHtml = "";
$.each(obj.Client.allowed_ips, function(index, obj) {
allowedIpsHtml += `<small class="badge badge-secondary">${obj}</small>&nbsp;`;
allowedIpsHtml += `<small class="badge badge-secondary">${escapeHtml(obj)}</small>&nbsp;`;
})
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>

View File

@ -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).

225
geoip/geoip.go Normal file
View File

@ -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()
}

52
geoip/geoip_test.go Normal file
View File

@ -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
View File

@ -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
View File

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

View File

@ -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()

View File

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

View File

@ -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
View File

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

View File

@ -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"`
}

View File

@ -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>",

View File

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

View File

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

View File

@ -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()
}

View File

@ -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 &copy;
<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) {

View File

@ -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">&nbsp; WIREGUARD UI</span>
<span class="brand-text">&nbsp; {{.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">&times;</span>
</button>
@ -352,7 +355,7 @@
<div class="float-right d-none d-sm-block">
<b>Version</b> {{ .appVersion }}
</div>
<strong>Copyright &copy; <script>document.write(new Date().getFullYear())</script> <a href="https://github.com/ngoduykhanh/wireguard-ui">Wireguard UI</a>.</strong> All rights
<strong>Copyright &copy; <script>document.write(new Date().getFullYear())</script> <a href="https://github.com/idressos/wireguard-ui">WireGuard UI</a>.</strong> All rights
reserved.
</footer>
-->

View File

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

View File

@ -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%',

View File

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

View File

@ -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({

View File

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

View File

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

View File

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

View File

@ -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)
}

89
util/util_test.go Normal file
View File

@ -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())
}
}