diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index f8d96a4..0000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1 +0,0 @@ -github: [ngoduykhanh] diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml deleted file mode 100644 index 8960a48..0000000 --- a/.github/workflows/docker-build.yml +++ /dev/null @@ -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 <> $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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 39fc51f..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4205bde..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -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 }} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22b85aa --- /dev/null +++ b/CLAUDE.md @@ -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 + `
` 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). diff --git a/README.md b/README.md index 74c446e..6ff1a08 100644 --- a/README.md +++ b/README.md @@ -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* ☕ - -Buy Me A Coffee +MIT. See [LICENSE](https://github.com/idressos/wireguard-ui/blob/master/LICENSE). diff --git a/custom/img/logo.png b/custom/img/logo.png new file mode 100644 index 0000000..5425633 Binary files /dev/null and b/custom/img/logo.png differ diff --git a/custom/js/helper.js b/custom/js/helper.js index 5b43272..c21938a 100644 --- a/custom/js/helper.js +++ b/custom/js/helper.js @@ -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, '''); +} + 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 = `
+ telegramButton = `
+ data-clientname="${clientName}">Telegram
` } let telegramHtml = ""; if (obj.Client.telegram_userid && obj.Client.telegram_userid.length > 0) { - telegramHtml = `` + telegramHtml = `` } // 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 += `${obj} `; + allocatedIpsHtml += `${escapeHtml(obj)} `; }) // render client allowed ip addresses let allowedIpsHtml = ""; $.each(obj.Client.allowed_ips, function(index, obj) { - allowedIpsHtml += `${obj} `; + allowedIpsHtml += `${escapeHtml(obj)} `; }) 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 = `` + additionalNotesHtml = `` } // render client html content @@ -53,41 +70,41 @@ function renderClientList(data) { -
+
+ data-clientname="${clientName}" ${obj.QRCode != "" ? '' : ' disabled'}>QR code
-
+
+ data-clientname="${clientName}">Email
${telegramButton}
-

- ${obj.Client.name} - + ${clientName} + ${telegramHtml} ${additionalNotesHtml} - ${obj.Client.email} + ${escapeHtml(obj.Client.email)} ${prettyDateTime(obj.Client.created_at)} @@ -95,7 +112,7 @@ function renderClientList(data) { ${obj.Client.use_server_dns ? 'DNS enabled' : 'DNS disabled'} - ${obj.Client.additional_notes} + ${escapeHtml(obj.Client.additional_notes)} IP Allocation` + allocatedIpsHtml + `Allowed IPs` @@ -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 = `
+ let html = `
- +
+ data-target="#modal_remove_user" data-username="${username}">Delete

- ${obj.username} + ${username} ${obj.admin? 'Administrator':'Manager'}
diff --git a/examples/docker-compose/README.md b/examples/docker-compose/README.md index 951df08..4b645a3 100644 --- a/examples/docker-compose/README.md +++ b/examples/docker-compose/README.md @@ -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). diff --git a/geoip/geoip.go b/geoip/geoip.go new file mode 100644 index 0000000..41748ac --- /dev/null +++ b/geoip/geoip.go @@ -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() +} diff --git a/geoip/geoip_test.go b/geoip/geoip_test.go new file mode 100644 index 0000000..eaa61a6 --- /dev/null +++ b/geoip/geoip_test.go @@ -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") + } +} diff --git a/go.mod b/go.mod index e9647ca..e8fe2cf 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 3aa2ceb..743145c 100644 --- a/go.sum +++ b/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= diff --git a/handler/routes.go b/handler/routes.go index ede3654..7ec63f2 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -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 += "
" + 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 += "
" + } + 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() diff --git a/handler/routes_wake_on_lan.go b/handler/routes_wake_on_lan.go index 1747a1e..0742b1f 100644 --- a/handler/routes_wake_on_lan.go +++ b/handler/routes_wake_on_lan.go @@ -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 diff --git a/handler/session.go b/handler/session.go index b660d9c..95f96fa 100644 --- a/handler/session.go +++ b/handler/session.go @@ -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) } diff --git a/main.go b/main.go index 1125746..817a48d 100644 --- a/main.go +++ b/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,
-

In this email you can find your personal configuration for our wireguard server.

+ defaultEmailSubject = "Your VPN configuration" -

Best

+ defaultEmailContent = ` +

Greetings,

+

Please find attached your personal configuration for our VPN server.
You may find instructions on how to install the WireGuard VPN client here.

+

Best regards.

` ) @@ -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 { diff --git a/model/setting.go b/model/setting.go index c9e152c..f019c9f 100644 --- a/model/setting.go +++ b/model/setting.go @@ -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"` } diff --git a/package.json b/package.json index a0cda64..4644748 100644 --- a/package.json +++ b/package.json @@ -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 ", diff --git a/router/router.go b/router/router.go index 59d352e..46c0cd5 100644 --- a/router/router.go +++ b/router/router.go @@ -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)) diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go index 1cd0a43..1013f20 100644 --- a/store/jsondb/jsondb.go +++ b/store/jsondb/jsondb.go @@ -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) diff --git a/telegram/bot.go b/telegram/bot.go index 7842f63..12e2476 100644 --- a/telegram/bot.go +++ b/telegram/bot.go @@ -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() } diff --git a/templates/about.html b/templates/about.html index edbeb47..226d6d6 100644 --- a/templates/about.html +++ b/templates/about.html @@ -22,7 +22,7 @@ About
-

About Wireguard-UI

+

About WireGuard-UI

@@ -63,7 +63,7 @@ About
Copyright © - Wireguard UI. + WireGuard UI. All rights reserved.
@@ -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) { diff --git a/templates/base.html b/templates/base.html index 3640699..2352e0c 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,7 +5,7 @@ - {{template "title" .}} + {{.pageTitlePrefix}}{{template "title" .}} @@ -86,10 +86,10 @@ -