Compare commits

..

7 Commits

Author SHA1 Message Date
Christoph Haas
2a9d5e3ea8 feat: introduce "Create Default Peer" flag for interfaces (#513) 2026-01-06 00:16:08 +01:00
h44z
1b56acac87 Doc Update (#603)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* docs: enhance binary usage guide and systemd setup (#577)

* docs: remove invalid mail templates section from configuration overview
2026-01-05 23:25:37 +01:00
h44z
015220dc7b bulk actions for peers and users (#492) (#602) 2026-01-05 23:25:17 +01:00
dependabot[bot]
4b49a55ea2 chore(deps): bump the actions group across 1 directory with 3 updates (#599)
Bumps the actions group with 3 updates in the / directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action), [actions/upload-artifact](https://github.com/actions/upload-artifact) and [actions/download-artifact](https://github.com/actions/download-artifact).


Updates `docker/setup-buildx-action` from 3.11.1 to 3.12.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](e468171a9d...8d2750c68a)

Updates `actions/upload-artifact` from 5.0.0 to 6.0.0
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](330a01c490...b7c566a772)

Updates `actions/download-artifact` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](018cc2cf5b...37930b1c2a)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 23:24:27 +01:00
dependabot[bot]
93db40c995 chore(deps): bump github.com/go-playground/validator/v10 (#601)
Some checks failed
Docker / Build and Push (push) Has been cancelled
Docker / release (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Bumps [github.com/go-playground/validator/v10](https://github.com/go-playground/validator) from 10.28.0 to 10.30.1.
- [Release notes](https://github.com/go-playground/validator/releases)
- [Commits](https://github.com/go-playground/validator/compare/v10.28.0...v10.30.1)

---
updated-dependencies:
- dependency-name: github.com/go-playground/validator/v10
  dependency-version: 10.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-04 23:54:33 +01:00
h44z
0a88fe745f allow setting a base-path for the web UI and API (#583) (#595)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
2025-12-20 15:30:55 +01:00
h44z
8cc937b031 Custom templates (#594)
Some checks failed
Docker / Build and Push (push) Has been cancelled
github-pages / deploy (push) Has been cancelled
Docker / release (push) Has been cancelled
* allow custom mail templates (#533)

* allow to override embedded frontend (#533)
2025-12-10 23:10:43 +01:00
48 changed files with 1717 additions and 90 deletions

View File

@@ -24,7 +24,7 @@ jobs:
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get Version - name: Get Version
shell: bash shell: bash
@@ -96,7 +96,7 @@ jobs:
done done
- name: Upload binaries - name: Upload binaries
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with: with:
name: binaries name: binaries
path: binaries/wg-portal_linux* path: binaries/wg-portal_linux*
@@ -110,7 +110,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Download binaries - name: Download binaries
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with: with:
name: binaries name: binaries

View File

@@ -47,7 +47,7 @@ func main() {
rawDb, err := adapters.NewDatabase(cfg.Database) rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err) internal.AssertNoError(err)
database, err := adapters.NewSqlRepository(rawDb) database, err := adapters.NewSqlRepository(rawDb, cfg)
internal.AssertNoError(err) internal.AssertNoError(err)
wireGuard, err := wireguard.NewControllerManager(cfg) wireGuard, err := wireguard.NewControllerManager(cfg)
@@ -84,7 +84,7 @@ func main() {
internal.AssertNoError(err) internal.AssertNoError(err)
userManager.StartBackgroundJobs(ctx) userManager.StartBackgroundJobs(ctx)
authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, eventBus, userManager) authenticator, err := auth.NewAuthenticator(&cfg.Auth, cfg.Web.ExternalUrl, cfg.Web.BasePath, eventBus, userManager)
internal.AssertNoError(err) internal.AssertNoError(err)
authenticator.StartBackgroundJobs(ctx) authenticator.StartBackgroundJobs(ctx)

View File

@@ -11,18 +11,11 @@ core:
web: web:
external_url: http://localhost:8888 external_url: http://localhost:8888
base_path: ""
request_logging: true request_logging: true
# Optional path where custom frontend files are stored.
# If this folder contains at least one file, it will override the embedded frontend.
# If the folder is empty or does not exist on startup, the embedded frontend will be
# written into it. Leave empty to use the embedded frontend only.
frontend_filepath: "" frontend_filepath: ""
mail: mail:
# Path where custom email templates (.gotpl and .gohtml) are stored.
# If the directory is empty on startup, the default embedded templates
# will be written there so you can modify them.
# Leave empty to use embedded templates only.
templates_path: "" templates_path: ""
webhook: webhook:

View File

@@ -88,6 +88,7 @@ auth:
web: web:
listening_address: :8888 listening_address: :8888
external_url: http://localhost:8888 external_url: http://localhost:8888
base_path: ""
site_company_name: WireGuard Portal site_company_name: WireGuard Portal
site_title: WireGuard Portal site_title: WireGuard Portal
session_identifier: wgPortalSession session_identifier: wgPortalSession
@@ -156,12 +157,14 @@ More advanced options are found in the subsequent `Advanced` section.
### `create_default_peer` ### `create_default_peer`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER`
- **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for **all** server interfaces. - **Description:** If a user logs in for the first time with no existing peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option is only effective for interfaces where the "Create default peer" flag is set (via the UI).
### `create_default_peer_on_creation` ### `create_default_peer_on_creation`
- **Default:** `false` - **Default:** `false`
- **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION` - **Environment Variable:** `WG_PORTAL_CORE_CREATE_DEFAULT_PEER_ON_CREATION`
- **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for **all** server interfaces. - **Description:** If an LDAP user is created (e.g., through LDAP sync) and has no peers, automatically create a new WireGuard peer for all server interfaces where the "Create default peer" flag is set.
- **Important:** This option requires [create_default_peer](#create_default_peer) to be enabled.
### `re_enable_peer_after_user_enable` ### `re_enable_peer_after_user_enable`
- **Default:** `true` - **Default:** `true`
@@ -800,9 +803,16 @@ Without a valid `external_url`, the login process may fail due to CSRF protectio
### `external_url` ### `external_url`
- **Default:** `http://localhost:8888` - **Default:** `http://localhost:8888`
- **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL` - **Environment Variable:** `WG_PORTAL_WEB_EXTERNAL_URL`
- **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects. - **Description:** The URL where a client can access WireGuard Portal. This URL is used for generating links in emails and for performing OAUTH redirects.
The external URL must not contain a path component or trailing slash. If you want to serve WireGuard Portal on a subpath, use the `base_path` setting.
**Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server. **Important:** If you are using a reverse proxy, set this to the external URL of the reverse proxy, otherwise login will fail. If you access the portal via IP address, set this to the IP address of the server.
### `base_path`
- **Default:** *(empty)*
- **Environment Variable:** `WG_PORTAL_WEB_BASE_PATH`
- **Description:** The base path for the web server (e.g., `/wgportal`).
By default (meaning an empty value), the portal will be served from the root path `/`.
### `site_company_name` ### `site_company_name`
- **Default:** `WireGuard Portal` - **Default:** `WireGuard Portal`
- **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME` - **Environment Variable:** `WG_PORTAL_WEB_SITE_COMPANY_NAME`

View File

@@ -9,6 +9,11 @@ Make sure that you download the correct binary for your architecture. The availa
- `wg-portal_linux_arm64` - Linux ARM 64-bit - `wg-portal_linux_arm64` - Linux ARM 64-bit
- `wg-portal_linux_arm_v7` - Linux ARM 32-bit - `wg-portal_linux_arm_v7` - Linux ARM 32-bit
### Released versions
To download a specific version, replace `${WG_PORTAL_VERSION}` with the desired version (or set an environment variable).
All official release versions can be found on the [GitHub Releases Page](https://github.com/h44z/wg-portal/releases).
With `curl`: With `curl`:
```shell ```shell
@@ -27,16 +32,74 @@ with `gh cli`:
gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64' gh release download ${WG_PORTAL_VERSION} --repo h44z/wg-portal --output wg-portal --pattern '*amd64'
``` ```
The downloaded file will be named `wg-portal` and can be moved to a directory of your choice, see [Install](#install) for more information.
### Unreleased versions (master branch builds)
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster).
## Install ## Install
The following command can be used to install the downloaded binary (`wg-portal`) to `/opt/wg-portal/wg-portal`. It ensures that the binary is executable.
```shell ```shell
sudo mkdir -p /opt/wg-portal sudo mkdir -p /opt/wg-portal
sudo install wg-portal /opt/wg-portal/ sudo install wg-portal /opt/wg-portal/
``` ```
## Unreleased versions (master branch builds) To handle tasks such as restarting the service or configuring automatic startup, it is recommended to use a process manager like [systemd](https://systemd.io/).
Refer to [Systemd Service Setup](#systemd-service-setup) for instructions.
Unreleased versions can be fetched directly from the artifacts section of the [GitHub Workflow](https://github.com/h44z/wg-portal/actions/workflows/docker-publish.yml?query=branch%3Amaster). ## Systemd Service Setup
> **Note:** To run WireGuard Portal as systemd service, you need to download the binary for your architecture beforehand.
>
> The following examples assume that you downloaded the binary to `/opt/wg-portal/wg-portal`.
> The configuration file is expected to be located at `/opt/wg-portal/config.yml`.
To run WireGuard Portal as a systemd service, you can create a service unit file. The easiest way to do this is by using `systemctl edit`:
```shell
sudo systemctl edit --force --full wg-portal.service
```
Paste the following content into the editor and adjust the variables to your needs:
```ini
[Unit]
Description=WireGuard Portal
ConditionPathExists=/opt/wg-portal/wg-portal
After=network.target
[Service]
Type=simple
User=root
Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure
RestartSec=10
WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal
[Install]
WantedBy=multi-user.target
```
Alternatively, you can create or modify the file manually in `/etc/systemd/system/wg-portal.service`.
For systemd to pick up the changes, you need to reload the daemon:
```shell
sudo systemctl daemon-reload
```
After creating the service file, you can enable and start the service:
```shell
sudo systemctl enable --now wg-portal.service
```
To check status and log output, use: `sudo systemctl status wg-portal.service` or `sudo journalctl -u wg-portal.service`.

View File

@@ -84,6 +84,16 @@ web:
external_url: https://wg.domain.com external_url: https://wg.domain.com
``` ```
If you want to serve the web interface on a different base-path, you can also set the `web.base_path` option:
```yaml
web:
external_url: https://wg.domain.com
base_path: /subpath
```
The WireGuard Portal will then be available at `https://wg.domain.com/subpath`.
### Built-in TLS ### Built-in TLS
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support. If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.

View File

@@ -9,6 +9,7 @@
<script> <script>
// global config, will be overridden by backend if available // global config, will be overridden by backend if available
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0"; let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
let WGPORTAL_BASE_PATH="";
let WGPORTAL_VERSION="unknown"; let WGPORTAL_VERSION="unknown";
let WGPORTAL_SITE_TITLE="WireGuard Portal"; let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal"; let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";

View File

@@ -85,6 +85,7 @@ const languageFlag = computed(() => {
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME); const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION); const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear()) const currentYear = ref(new Date().getFullYear())
const webBasePath = ref(WGPORTAL_BASE_PATH);
const userDisplayName = computed(() => { const userDisplayName = computed(() => {
let displayName = "Unknown"; let displayName = "Unknown";
@@ -113,7 +114,7 @@ const userDisplayName = computed(() => {
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="/"><img :alt="companyName" src="/img/header-logo.png" /></a> <RouterLink class="navbar-brand" :to="{ name: 'home' }"><img :alt="companyName" :src="webBasePath + '/img/header-logo.png'" /></RouterLink>
<button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler" <button aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler"
data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button"> data-bs-target="#navbarTop" data-bs-toggle="collapse" type="button">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>

View File

@@ -83,6 +83,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = interfaces.Prepared.Identifier formData.value.Identifier = interfaces.Prepared.Identifier
formData.value.DisplayName = interfaces.Prepared.DisplayName formData.value.DisplayName = interfaces.Prepared.DisplayName
formData.value.Mode = interfaces.Prepared.Mode formData.value.Mode = interfaces.Prepared.Mode
formData.value.CreateDefaultPeer = interfaces.Prepared.CreateDefaultPeer
formData.value.Backend = interfaces.Prepared.Backend formData.value.Backend = interfaces.Prepared.Backend
formData.value.PublicKey = interfaces.Prepared.PublicKey formData.value.PublicKey = interfaces.Prepared.PublicKey
@@ -122,6 +123,7 @@ watch(() => props.visible, async (newValue, oldValue) => {
formData.value.Identifier = selectedInterface.value.Identifier formData.value.Identifier = selectedInterface.value.Identifier
formData.value.DisplayName = selectedInterface.value.DisplayName formData.value.DisplayName = selectedInterface.value.DisplayName
formData.value.Mode = selectedInterface.value.Mode formData.value.Mode = selectedInterface.value.Mode
formData.value.CreateDefaultPeer = selectedInterface.value.CreateDefaultPeer
formData.value.Backend = selectedInterface.value.Backend formData.value.Backend = selectedInterface.value.Backend
formData.value.PublicKey = selectedInterface.value.PublicKey formData.value.PublicKey = selectedInterface.value.PublicKey
@@ -487,6 +489,10 @@ async function del() {
<input v-model="formData.Disabled" class="form-check-input" type="checkbox"> <input v-model="formData.Disabled" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.disabled.label') }}</label>
</div> </div>
<div class="form-check form-switch" v-if="formData.Mode==='server' && settings.Setting('CreateDefaultPeer')">
<input v-model="formData.CreateDefaultPeer" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.create-default-peer.label') }}</label>
</div>
<div class="form-check form-switch" v-if="formData.Backend==='local'"> <div class="form-check form-switch" v-if="formData.Backend==='local'">
<input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox"> <input v-model="formData.SaveConfig" checked="" class="form-check-input" type="checkbox">
<label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label> <label class="form-check-label">{{ $t('modals.interface-edit.save-config.label') }}</label>

View File

@@ -4,6 +4,7 @@ export function freshInterface() {
Disabled: false, Disabled: false,
DisplayName: "", DisplayName: "",
Identifier: "", Identifier: "",
CreateDefaultPeer: false,
Mode: "server", Mode: "server",
Backend: "local", Backend: "local",

View File

@@ -129,6 +129,11 @@
"button-add-peers": "Mehrere Peers hinzufügen", "button-add-peers": "Mehrere Peers hinzufügen",
"button-show-peer": "Peer anzeigen", "button-show-peer": "Peer anzeigen",
"button-edit-peer": "Peer bearbeiten", "button-edit-peer": "Peer bearbeiten",
"button-bulk-delete": "Ausgewählte Peers löschen",
"button-bulk-enable": "Ausgewählte Peers aktivieren",
"button-bulk-disable": "Ausgewählte Peers deaktivieren",
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Peers löschen möchten?",
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Peers deaktivieren möchten?",
"peer-disabled": "Peer ist deaktiviert, Grund:", "peer-disabled": "Peer ist deaktiviert, Grund:",
"peer-expiring": "Peer läuft ab am", "peer-expiring": "Peer läuft ab am",
"peer-connected": "Verbunden", "peer-connected": "Verbunden",
@@ -153,6 +158,14 @@
"button-add-user": "Benutzer hinzufügen", "button-add-user": "Benutzer hinzufügen",
"button-show-user": "Benutzer anzeigen", "button-show-user": "Benutzer anzeigen",
"button-edit-user": "Benutzer bearbeiten", "button-edit-user": "Benutzer bearbeiten",
"button-bulk-delete": "Ausgewählte Benutzer löschen",
"button-bulk-enable": "Ausgewählte Benutzer aktivieren",
"button-bulk-disable": "Ausgewählte Benutzer deaktivieren",
"button-bulk-lock": "Ausgewählte Benutzer sperren",
"button-bulk-unlock": "Ausgewählte Benutzer entsperren",
"confirm-bulk-delete": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?",
"confirm-bulk-disable": "Sind Sie sicher, dass Sie {count} Benutzer deaktivieren möchten?",
"confirm-bulk-lock": "Sind Sie sicher, dass Sie {count} Benutzer sperren möchten?",
"user-disabled": "Benutzer ist deaktiviert, Grund:", "user-disabled": "Benutzer ist deaktiviert, Grund:",
"user-locked": "Konto ist gesperrt, Grund:", "user-locked": "Konto ist gesperrt, Grund:",
"admin": "Benutzer hat Administratorrechte", "admin": "Benutzer hat Administratorrechte",
@@ -456,6 +469,9 @@
"disabled": { "disabled": {
"label": "Schnittstelle deaktiviert" "label": "Schnittstelle deaktiviert"
}, },
"create-default-peer": {
"label": "Peer für neue Benutzer automatisch erstellen"
},
"save-config": { "save-config": {
"label": "wg-quick Konfiguration automatisch speichern" "label": "wg-quick Konfiguration automatisch speichern"
}, },

View File

@@ -129,6 +129,11 @@
"button-add-peers": "Add Multiple Peers", "button-add-peers": "Add Multiple Peers",
"button-show-peer": "Show Peer", "button-show-peer": "Show Peer",
"button-edit-peer": "Edit Peer", "button-edit-peer": "Edit Peer",
"button-bulk-delete": "Delete selected peers",
"button-bulk-enable": "Enable selected peers",
"button-bulk-disable": "Disable selected peers",
"confirm-bulk-delete": "Are you sure you want to delete {count} peers?",
"confirm-bulk-disable": "Are you sure you want to disable {count} peers?",
"peer-disabled": "Peer is disabled, reason:", "peer-disabled": "Peer is disabled, reason:",
"peer-expiring": "Peer is expiring at", "peer-expiring": "Peer is expiring at",
"peer-connected": "Connected", "peer-connected": "Connected",
@@ -153,6 +158,14 @@
"button-add-user": "Add User", "button-add-user": "Add User",
"button-show-user": "Show User", "button-show-user": "Show User",
"button-edit-user": "Edit User", "button-edit-user": "Edit User",
"button-bulk-delete": "Delete selected users",
"button-bulk-enable": "Enable selected users",
"button-bulk-disable": "Disable selected users",
"button-bulk-lock": "Lock selected users",
"button-bulk-unlock": "Unlock selected users",
"confirm-bulk-delete": "Are you sure you want to delete {count} users?",
"confirm-bulk-disable": "Are you sure you want to disable {count} users?",
"confirm-bulk-lock": "Are you sure you want to lock {count} users?",
"user-disabled": "User is disabled, reason:", "user-disabled": "User is disabled, reason:",
"user-locked": "Account is locked, reason:", "user-locked": "Account is locked, reason:",
"admin": "User has administrator privileges", "admin": "User has administrator privileges",
@@ -456,6 +469,9 @@
"disabled": { "disabled": {
"label": "Interface Disabled" "label": "Interface Disabled"
}, },
"create-default-peer": {
"label": "Create default peer for new users"
},
"save-config": { "save-config": {
"label": "Automatically save wg-quick config" "label": "Automatically save wg-quick config"
}, },

View File

@@ -222,6 +222,73 @@ export const peerStore = defineStore('peers', {
throw new Error(error) throw new Error(error)
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
.then(() => {
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
this.fetching = false
notify({
title: "Peers deleted",
text: "Selected peers have been deleted!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to delete peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to delete selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async BulkEnable(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
.then(async () => {
await this.LoadPeers()
notify({
title: "Peers enabled",
text: "Selected peers have been enabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to enable peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to enable selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async BulkDisable(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
.then(async () => {
await this.LoadPeers()
notify({
title: "Peers disabled",
text: "Selected peers have been disabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to disable peers: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to disable selected peers!",
type: 'error',
})
throw new Error(error)
})
},
async UpdatePeer(id, formData) { async UpdatePeer(id, formData) {
this.fetching = true this.fetching = true
return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData) return apiWrapper.put(`${baseUrl}/${base64_url_encode(id)}`, formData)

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import {apiWrapper} from "@/helpers/fetch-wrapper"; import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification"; import {notify} from "@kyvg/vue3-notification";
import {authStore} from "@/stores/auth"; import {authStore} from "@/stores/auth";
import {peerStore} from "@/stores/peers";
import { base64_url_encode } from '@/helpers/encoding'; import { base64_url_encode } from '@/helpers/encoding';
import {freshStats} from "@/helpers/models"; import {freshStats} from "@/helpers/models";
import { ipToBigInt } from '@/helpers/utils'; import { ipToBigInt } from '@/helpers/utils';
@@ -218,5 +219,18 @@ export const profileStore = defineStore('profile', {
}) })
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
const peers = peerStore()
return peers.BulkDelete(ids)
.then(() => {
this.peers = this.peers.filter(p => !ids.includes(p.Identifier))
this.fetching = false
})
.catch(error => {
this.fetching = false
throw new Error(error)
})
},
} }
}) })

View File

@@ -142,5 +142,140 @@ export const userStore = defineStore('users', {
}) })
}) })
}, },
async BulkDelete(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-delete`, { Identifiers: ids })
.then(() => {
this.users = this.users.filter(u => !ids.includes(u.Identifier))
this.fetching = false
notify({
title: "Users deleted",
text: "Selected users have been deleted!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to delete users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to delete selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkEnable(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-enable`, { Identifiers: ids })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Disabled = false
u.DisabledReason = ""
}
})
this.fetching = false
notify({
title: "Users enabled",
text: "Selected users have been enabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to enable users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to enable selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkDisable(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-disable`, { Identifiers: ids, Reason: reason })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Disabled = true
u.DisabledReason = reason
}
})
this.fetching = false
notify({
title: "Users disabled",
text: "Selected users have been disabled!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to disable users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to disable selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkLock(ids, reason) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-lock`, { Identifiers: ids, Reason: reason })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Locked = true
u.LockedReason = reason
}
})
this.fetching = false
notify({
title: "Users locked",
text: "Selected users have been locked!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to lock users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to lock selected users!",
type: 'error',
})
throw new Error(error)
})
},
async BulkUnlock(ids) {
this.fetching = true
return apiWrapper.post(`${baseUrl}/bulk-unlock`, { Identifiers: ids })
.then(() => {
this.users.forEach(u => {
if (ids.includes(u.Identifier)) {
u.Locked = false
u.LockedReason = ""
}
})
this.fetching = false
notify({
title: "Users unlocked",
text: "Selected users have been unlocked!",
type: 'success',
})
})
.catch(error => {
this.fetching = false
console.log("Failed to unlock users: ", error)
notify({
title: "Backend Connection Failure",
text: "Failed to unlock selected users!",
type: 'error',
})
throw new Error(error)
})
},
} }
}) })

View File

@@ -29,6 +29,10 @@ const sortKey = ref("")
const sortOrder = ref(1) const sortOrder = ref(1)
const selectAll = ref(false) const selectAll = ref(false)
const selectedPeers = computed(() => {
return peers.All.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
})
function sortBy(key) { function sortBy(key) {
if (sortKey.value === key) { if (sortKey.value === key) {
sortOrder.value = sortOrder.value * -1; // Toggle sort order sortOrder.value = sortOrder.value * -1; // Toggle sort order
@@ -111,6 +115,39 @@ async function saveConfig() {
} }
} }
async function bulkDelete() {
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
try {
await peers.BulkDelete(selectedPeers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkEnable() {
try {
await peers.BulkEnable(selectedPeers.value)
selectAll.value = false
peers.All.forEach(p => p.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
async function bulkDisable() {
if (confirm(t('interfaces.confirm-bulk-disable', {count: selectedPeers.value.length}))) {
try {
await peers.BulkDisable(selectedPeers.value)
selectAll.value = false
peers.All.forEach(p => p.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
function toggleSelectAll() { function toggleSelectAll() {
peers.FilteredAndPaged.forEach(peer => { peers.FilteredAndPaged.forEach(peer => {
peer.IsSelected = selectAll.value; peer.IsSelected = selectAll.value;
@@ -353,6 +390,13 @@ onMounted(async () => {
<a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a> <a class="btn btn-primary ms-2" href="#" :title="$t('interfaces.button-add-peer')" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div> </div>
</div> </div>
<div class="row" v-if="selectedPeers.length > 0">
<div class="col-12 text-lg-end">
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
</div>
</div>
<div v-if="interfaces.Count!==0" class="mt-2 table-responsive"> <div v-if="interfaces.Count!==0" class="mt-2 table-responsive">
<div v-if="peers.Count===0"> <div v-if="peers.Count===0">
<h4>{{ $t('interfaces.no-peer.headline') }}</h4> <h4>{{ $t('interfaces.no-peer.headline') }}</h4>

View File

@@ -1,14 +1,19 @@
<script setup> <script setup>
import PeerViewModal from "../components/PeerViewModal.vue"; import PeerViewModal from "../components/PeerViewModal.vue";
import { onMounted, ref } from "vue"; import { onMounted, ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { profileStore } from "@/stores/profile"; import { profileStore } from "@/stores/profile";
import { peerStore } from "@/stores/peers";
import UserPeerEditModal from "@/components/UserPeerEditModal.vue"; import UserPeerEditModal from "@/components/UserPeerEditModal.vue";
import { settingsStore } from "@/stores/settings"; import { settingsStore } from "@/stores/settings";
import { humanFileSize } from "@/helpers/utils"; import { humanFileSize } from "@/helpers/utils";
const settings = settingsStore() const settings = settingsStore()
const profile = profileStore() const profile = profileStore()
const peers = peerStore()
const { t } = useI18n()
const viewedPeerId = ref("") const viewedPeerId = ref("")
const editPeerId = ref("") const editPeerId = ref("")
@@ -17,6 +22,10 @@ const sortKey = ref("")
const sortOrder = ref(1) const sortOrder = ref(1)
const selectAll = ref(false) const selectAll = ref(false)
const selectedPeers = computed(() => {
return profile.Peers.filter(peer => peer.IsSelected).map(peer => peer.Identifier);
})
function sortBy(key) { function sortBy(key) {
if (sortKey.value === key) { if (sortKey.value === key) {
sortOrder.value = sortOrder.value * -1; // Toggle sort order sortOrder.value = sortOrder.value * -1; // Toggle sort order
@@ -35,6 +44,17 @@ function friendlyInterfaceName(id, name) {
return id return id
} }
async function bulkDelete() {
if (confirm(t('interfaces.confirm-bulk-delete', {count: selectedPeers.value.length}))) {
try {
await profile.BulkDelete(selectedPeers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
function toggleSelectAll() { function toggleSelectAll() {
profile.FilteredAndPagedPeers.forEach(peer => { profile.FilteredAndPagedPeers.forEach(peer => {
peer.IsSelected = selectAll.value; peer.IsSelected = selectAll.value;
@@ -84,6 +104,13 @@ onMounted(async () => {
</div> </div>
</div> </div>
</div> </div>
<div class="row" v-if="selectedPeers.length > 0">
<div class="col-12 text-lg-end">
<button class="btn btn-outline-danger btn-sm" :title="$t('interfaces.button-bulk-delete')" @click.prevent="bulkDelete">
<i class="fa fa-trash-can"></i>
</button>
</div>
</div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
<div v-if="profile.CountPeers === 0"> <div v-if="profile.CountPeers === 0">
<h4>{{ $t('profile.no-peer.headline') }}</h4> <h4>{{ $t('profile.no-peer.headline') }}</h4>

View File

@@ -9,6 +9,8 @@ const profile = profileStore()
const settings = settingsStore() const settings = settingsStore()
const auth = authStore() const auth = authStore()
const webBasePath = ref(WGPORTAL_BASE_PATH);
onMounted(async () => { onMounted(async () => {
await profile.LoadUser() await profile.LoadUser()
await auth.LoadWebAuthnCredentials() await auth.LoadWebAuthnCredentials()
@@ -241,7 +243,7 @@ const updatePassword = async () => {
</button> </button>
</div> </div>
<div class="col-6"> <div class="col-6">
<a href="/api/v1/doc.html" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a> <a :href="webBasePath + '/api/v1/doc.html'" target="_blank" :alt="$t('settings.api.api-link')">{{ $t('settings.api.api-link') }}</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,16 +1,77 @@
<script setup> <script setup>
import {userStore} from "@/stores/users"; import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue"; import {ref, onMounted, computed} from "vue";
import UserEditModal from "../components/UserEditModal.vue"; import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue"; import UserViewModal from "../components/UserViewModal.vue";
import {useI18n} from "vue-i18n";
const users = userStore() const users = userStore()
const { t } = useI18n()
const editUserId = ref("") const editUserId = ref("")
const viewedUserId = ref("") const viewedUserId = ref("")
const selectAll = ref(false) const selectAll = ref(false)
const selectedUsers = computed(() => {
return users.All.filter(user => user.IsSelected).map(user => user.Identifier);
})
async function bulkDelete() {
if (confirm(t('users.confirm-bulk-delete', {count: selectedUsers.value.length}))) {
try {
await users.BulkDelete(selectedUsers.value)
selectAll.value = false // reset selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkEnable() {
try {
await users.BulkEnable(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
async function bulkDisable() {
if (confirm(t('users.confirm-bulk-disable', {count: selectedUsers.value.length}))) {
try {
await users.BulkDisable(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkLock() {
if (confirm(t('users.confirm-bulk-lock', {count: selectedUsers.value.length}))) {
try {
await users.BulkLock(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
}
async function bulkUnlock() {
try {
await users.BulkUnlock(selectedUsers.value)
selectAll.value = false
users.All.forEach(u => u.IsSelected = false) // remove selection
} catch (e) {
// notification is handled in store
}
}
function toggleSelectAll() { function toggleSelectAll() {
users.FilteredAndPaged.forEach(user => { users.FilteredAndPaged.forEach(user => {
user.IsSelected = selectAll.value; user.IsSelected = selectAll.value;
@@ -45,6 +106,15 @@ onMounted(() => {
</a> </a>
</div> </div>
</div> </div>
<div class="row" v-if="selectedUsers.length > 0">
<div class="col-12 text-lg-end">
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-enable')" @click.prevent="bulkEnable"><i class="fa-regular fa-circle-check"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-disable')" @click.prevent="bulkDisable"><i class="fa fa-ban"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-unlock')" @click.prevent="bulkUnlock"><i class="fa-solid fa-lock-open"></i></a>
<a class="btn btn-outline-primary btn-sm ms-2" href="#" :title="$t('users.button-bulk-lock')" @click.prevent="bulkLock"><i class="fa-solid fa-lock"></i></a>
<a class="btn btn-outline-danger btn-sm ms-2" href="#" :title="$t('users.button-bulk-delete')" @click.prevent="bulkDelete"><i class="fa fa-trash-can"></i></a>
</div>
</div>
<div class="mt-2 table-responsive"> <div class="mt-2 table-responsive">
<div v-if="users.Count===0"> <div v-if="users.Count===0">
<h4>{{ $t('users.no-user.headline') }}</h4> <h4>{{ $t('users.no-user.headline') }}</h4>

4
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pkgz/routegroup v1.6.0 github.com/go-pkgz/routegroup v1.6.0
github.com/go-playground/validator/v10 v10.28.0 github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.15.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/prometheus-community/pro-bing v0.7.0 github.com/prometheus-community/pro-bing v0.7.0
@@ -41,7 +41,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect

8
go.sum
View File

@@ -50,8 +50,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
@@ -97,8 +97,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=

View File

@@ -24,7 +24,7 @@ import (
) )
// SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed. // SchemaVersion describes the current database schema version. It must be incremented if a manual migration is needed.
var SchemaVersion uint64 = 1 var SchemaVersion uint64 = 2
// SysStat stores the current database schema version and the timestamp when it was applied. // SysStat stores the current database schema version and the timestamp when it was applied.
type SysStat struct { type SysStat struct {
@@ -179,13 +179,15 @@ func NewDatabase(cfg config.DatabaseConfig) (*gorm.DB, error) {
// SqlRepo is a SQL database repository implementation. // SqlRepo is a SQL database repository implementation.
// Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems. // Currently, it supports MySQL, SQLite, Microsoft SQL and Postgresql database systems.
type SqlRepo struct { type SqlRepo struct {
db *gorm.DB db *gorm.DB
cfg *config.Config
} }
// NewSqlRepository creates a new SqlRepo instance. // NewSqlRepository creates a new SqlRepo instance.
func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) { func NewSqlRepository(db *gorm.DB, cfg *config.Config) (*SqlRepo, error) {
repo := &SqlRepo{ repo := &SqlRepo{
db: db, db: db,
cfg: cfg,
} }
if err := repo.preCheck(); err != nil { if err := repo.preCheck(); err != nil {
@@ -232,7 +234,9 @@ func (r *SqlRepo) migrate() error {
slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{})) slog.Debug("running migration: audit data", "result", r.db.AutoMigrate(&domain.AuditEntry{}))
existingSysStat := SysStat{} existingSysStat := SysStat{}
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat) r.db.Order("schema_version desc").First(&existingSysStat) // get latest version
// Migration: 0 --> 1
if existingSysStat.SchemaVersion == 0 { if existingSysStat.SchemaVersion == 0 {
sysStat := SysStat{ sysStat := SysStat{
MigratedAt: time.Now(), MigratedAt: time.Now(),
@@ -244,6 +248,27 @@ func (r *SqlRepo) migrate() error {
slog.Debug("sys-stat entry written", "schema_version", SchemaVersion) slog.Debug("sys-stat entry written", "schema_version", SchemaVersion)
} }
// Migration: 1 --> 2
if existingSysStat.SchemaVersion == 1 {
// Preserve existing behavior for installations that had default-peer-creation enabled.
if r.cfg.Core.CreateDefaultPeer {
err := r.db.Model(&domain.Interface{}).
Where("type = ?", domain.InterfaceTypeServer).
Update("create_default_peer", true).Error
if err != nil {
return fmt.Errorf("failed to migrate interface flags for schema version %d: %w", SchemaVersion, err)
}
slog.Debug("migrated interface create_default_peer flags", "schema_version", SchemaVersion)
}
sysStat := SysStat{
MigratedAt: time.Now(),
SchemaVersion: SchemaVersion,
}
if err := r.db.Create(&sysStat).Error; err != nil {
return fmt.Errorf("failed to write sysstat entry for schema version %d: %w", SchemaVersion, err)
}
}
return nil return nil
} }

View File

@@ -800,6 +800,126 @@
} }
} }
}, },
"/peer/bulk-delete": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk delete selected peers.",
"operationId": "peers_handleBulkDelete",
"parameters": [
{
"description": "A list of peer identifiers to delete",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if deletion was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/bulk-disable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk disable selected peers.",
"operationId": "peers_handleBulkDisable",
"parameters": [
{
"description": "A list of peer identifiers to disable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/bulk-enable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Peer"
],
"summary": "Bulk enable selected peers.",
"operationId": "peers_handleBulkEnable",
"parameters": [
{
"description": "A list of peer identifiers to enable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/peer/config-mail": { "/peer/config-mail": {
"post": { "post": {
"produces": [ "produces": [
@@ -1324,6 +1444,206 @@
} }
} }
}, },
"/user/bulk-delete": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk delete selected users.",
"operationId": "users_handleBulkDelete",
"parameters": [
{
"description": "A list of user identifiers to delete",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if deletion was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-disable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk disable selected users.",
"operationId": "users_handleBulkDisable",
"parameters": [
{
"description": "A list of user identifiers to disable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-enable": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk enable selected users.",
"operationId": "users_handleBulkEnable",
"parameters": [
{
"description": "A list of user identifiers to enable",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-lock": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk lock selected users.",
"operationId": "users_handleBulkLock",
"parameters": [
{
"description": "A list of user identifiers to lock",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/bulk-unlock": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Bulk unlock selected users.",
"operationId": "users_handleBulkUnlock",
"parameters": [
{
"description": "A list of user identifiers to unlock",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/model.BulkPeerRequest"
}
}
],
"responses": {
"204": {
"description": "No content if action was successful"
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/model.Error"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/model.Error"
}
}
}
}
},
"/user/new": { "/user/new": {
"post": { "post": {
"produces": [ "produces": [
@@ -1737,6 +2057,23 @@
} }
} }
}, },
"model.BulkPeerRequest": {
"type": "object",
"required": [
"Identifiers"
],
"properties": {
"Identifiers": {
"type": "array",
"items": {
"type": "string"
}
},
"Reason": {
"type": "string"
}
}
},
"model.ConfigOption-array_string": { "model.ConfigOption-array_string": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -16,6 +16,17 @@ definitions:
Timestamp: Timestamp:
type: string type: string
type: object type: object
model.BulkPeerRequest:
properties:
Identifiers:
items:
type: string
type: array
Reason:
type: string
required:
- Identifiers
type: object
model.ConfigOption-array_string: model.ConfigOption-array_string:
properties: properties:
Overridable: Overridable:
@@ -1080,6 +1091,84 @@ paths:
summary: Update the given peer record. summary: Update the given peer record.
tags: tags:
- Peer - Peer
/peer/bulk-delete:
post:
operationId: peers_handleBulkDelete
parameters:
- description: A list of peer identifiers to delete
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if deletion was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk delete selected peers.
tags:
- Peer
/peer/bulk-disable:
post:
operationId: peers_handleBulkDisable
parameters:
- description: A list of peer identifiers to disable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk disable selected peers.
tags:
- Peer
/peer/bulk-enable:
post:
operationId: peers_handleBulkEnable
parameters:
- description: A list of peer identifiers to enable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk enable selected peers.
tags:
- Peer
/peer/config-mail: /peer/config-mail:
post: post:
operationId: peers_handleEmailPost operationId: peers_handleEmailPost
@@ -1571,6 +1660,136 @@ paths:
summary: Get all user records. summary: Get all user records.
tags: tags:
- Users - Users
/user/bulk-delete:
post:
operationId: users_handleBulkDelete
parameters:
- description: A list of user identifiers to delete
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if deletion was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk delete selected users.
tags:
- Users
/user/bulk-disable:
post:
operationId: users_handleBulkDisable
parameters:
- description: A list of user identifiers to disable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk disable selected users.
tags:
- Users
/user/bulk-enable:
post:
operationId: users_handleBulkEnable
parameters:
- description: A list of user identifiers to enable
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk enable selected users.
tags:
- Users
/user/bulk-lock:
post:
operationId: users_handleBulkLock
parameters:
- description: A list of user identifiers to lock
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk lock selected users.
tags:
- Users
/user/bulk-unlock:
post:
operationId: users_handleBulkUnlock
parameters:
- description: A list of user identifiers to unlock
in: body
name: request
required: true
schema:
$ref: '#/definitions/model.BulkPeerRequest'
produces:
- application/json
responses:
"204":
description: No content if action was successful
"400":
description: Bad Request
schema:
$ref: '#/definitions/model.Error'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/model.Error'
summary: Bulk unlock selected users.
tags:
- Users
/user/new: /user/new:
post: post:
operationId: users_handleCreatePost operationId: users_handleCreatePost

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 709 B

View File

@@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>WireGuard Portal API</title> <title>WireGuard Portal API</title>
<meta name="description" content="WireGuard Portal API"> <meta name="description" content="WireGuard Portal API">
<link rel="stylesheet" href="/css/bootstrap.min.css"> <link rel="stylesheet" href="{{$.BasePath}}/css/bootstrap.min.css">
<link rel="stylesheet" href="/fonts/fontawesome-all.min.css"> <link rel="stylesheet" href="{{$.BasePath}}/fonts/fontawesome-all.min.css">
<link rel="shortcut icon" type="image/x-icon" href="/app/favicon.ico"> <link rel="shortcut icon" type="image/x-icon" href="{{$.BasePath}}/app/favicon.ico">
</head> </head>
<body id="page-top" class="d-flex flex-column min-vh-100"> <body id="page-top" class="d-flex flex-column min-vh-100">
@@ -25,7 +25,7 @@
<div class="card-header">SPA Api</div> <div class="card-header">SPA Api</div>
<div class="card-body"> <div class="card-body">
<p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p> <p class="card-text">This endpoint is used by the Single Page Application (the Frontend/UI).</p>
<a href="/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="{{$.BasePath}}/api/v0/doc.html" title="API version 0" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div> </div>
</div> </div>
</div> </div>
@@ -34,7 +34,7 @@
<div class="card-header"><b>Version 1</b></div> <div class="card-header"><b>Version 1</b></div>
<div class="card-body"> <div class="card-body">
<p class="card-text">This is the current main API endpoint.</p> <p class="card-text">This is the current main API endpoint.</p>
<a href="/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="{{$.BasePath}}/api/v1/doc.html" title="API version 1" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div> </div>
</div> </div>
</div> </div>
@@ -43,17 +43,17 @@
<div class="card-header">Version 2</div> <div class="card-header">Version 2</div>
<div class="card-body"> <div class="card-body">
<p class="card-text">This will be a future API version, it is currently work in progress.</p> <p class="card-text">This will be a future API version, it is currently work in progress.</p>
<a href="/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a> <a href="{{$.BasePath}}/api/v2/doc.html" title="API version 2" target="_blank" rel="noopener noreferrer" class="btn btn-primary btn-sm">Open Documentation</a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{template "prt_footer.gohtml" .}} {{template "prt_footer.gohtml" .}}
<script src="/js/jquery.min.js"></script> <script src="{{$.BasePath}}/js/jquery.min.js"></script>
<script src="/js/jquery.easing.js"></script> <script src="{{$.BasePath}}/js/jquery.easing.js"></script>
<script src="/js/popper.min.js"></script> <script src="{{$.BasePath}}/js/popper.min.js"></script>
<script src="/js/bootstrap.bundle.min.js"></script> <script src="{{$.BasePath}}/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>

View File

@@ -3,7 +3,7 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand" href="/"><img src="/img/header-logo.png" alt="Prolicht"/></a> <a class="navbar-brand" href="/"><img src="{{$.BasePath}}/img/header-logo.png" alt="Prolicht"/></a>
<div id="topNavbar" class="navbar-collapse collapse"> <div id="topNavbar" class="navbar-collapse collapse">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0"> <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
</ul> </ul>

View File

@@ -22,7 +22,7 @@
allow-spec-file-load="false" allow-spec-file-load="false"
allow-spec-file-download="true" allow-spec-file-download="true"
> >
<img slot="logo" src="/img/header-logo-small.png" style="width:50px; height:50px"/> <img slot="logo" src="{{$.BasePath}}/img/header-logo-small.png" style="width:50px; height:50px"/>
<p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" > <p slot="footer" style="margin:0; padding:16px 36px; background-color:#f76b39; color:#fff; text-align:center;" >
Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}} Copyright © WireGuard Portal {{$.Year}}, version {{$.Version}}

View File

@@ -10,6 +10,8 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings"
"time" "time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -37,6 +39,7 @@ type ApiEndpointSetupFunc func() (ApiVersion, GroupSetupFn)
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
server *routegroup.Bundle server *routegroup.Bundle
root *routegroup.Bundle // root is the web-root (potentially with path prefix)
tpl *respond.TemplateRenderer tpl *respond.TemplateRenderer
versions map[ApiVersion]*routegroup.Bundle versions map[ApiVersion]*routegroup.Bundle
} }
@@ -77,13 +80,42 @@ func NewServer(cfg *config.Config, endpoints ...ApiEndpointSetupFunc) (*Server,
template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")), template.Must(template.New("").ParseFS(apiTemplates, "assets/tpl/*.gohtml")),
) )
// Serve static files // Mount base path if configured
s.root = s.server
if s.cfg.Web.BasePath != "" {
s.root = s.server.Mount(s.cfg.Web.BasePath)
}
// Serve static files (under base path if configured)
imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img"))) imgFs := http.FS(fsMust(fs.Sub(apiStatics, "assets/img")))
s.server.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css")))) s.root.HandleFiles("/css", http.FS(fsMust(fs.Sub(apiStatics, "assets/css"))))
s.server.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js")))) s.root.HandleFiles("/js", http.FS(fsMust(fs.Sub(apiStatics, "assets/js"))))
s.server.HandleFiles("/img", imgFs) s.root.HandleFiles("/img", imgFs)
s.server.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts")))) s.root.HandleFiles("/fonts", http.FS(fsMust(fs.Sub(apiStatics, "assets/fonts"))))
s.server.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc")))) if cfg.Web.BasePath == "" {
s.root.HandleFiles("/doc", http.FS(fsMust(fs.Sub(apiStatics, "assets/doc"))))
} else {
customV0File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v0_swagger.yaml")
customV1File, _ := fs.ReadFile(fsMust(fs.Sub(apiStatics, "assets/doc")), "v1_swagger.yaml")
customV0File = []byte(strings.Replace(string(customV0File),
"basePath: /api/v0", "basePath: "+cfg.Web.BasePath+"/api/v0", 1))
customV1File = []byte(strings.Replace(string(customV1File),
"basePath: /api/v1", "basePath: "+cfg.Web.BasePath+"/api/v1", 1))
s.root.HandleFunc("GET /doc/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v0_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV0File)
return
}
if r.URL.Path == s.cfg.Web.BasePath+"/doc/v1_swagger.yaml" {
respond.Data(w, http.StatusOK, "application/yaml", customV1File)
return
}
respond.Status(w, http.StatusNotFound)
})
}
// Setup routes // Setup routes
s.setupRoutes(endpoints...) s.setupRoutes(endpoints...)
@@ -128,14 +160,14 @@ func (s *Server) Run(ctx context.Context, listenAddress string) {
} }
func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) { func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
s.server.HandleFunc("GET /api", s.landingPage) s.root.HandleFunc("GET /api", s.landingPage)
s.versions = make(map[ApiVersion]*routegroup.Bundle) s.versions = make(map[ApiVersion]*routegroup.Bundle)
for _, setupFunc := range endpoints { for _, setupFunc := range endpoints {
version, groupSetupFn := setupFunc() version, groupSetupFn := setupFunc()
if _, ok := s.versions[version]; !ok { if _, ok := s.versions[version]; !ok {
s.versions[version] = s.server.Mount(fmt.Sprintf("/api/%s", version)) s.versions[version] = s.root.Mount(fmt.Sprintf("/api/%s", version))
// OpenAPI documentation (via RapiDoc) // OpenAPI documentation (via RapiDoc)
s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link s.versions[version].HandleFunc("GET /swagger/index.html", s.rapiDocHandler(version)) // Deprecated: old link
@@ -149,16 +181,17 @@ func (s *Server) setupRoutes(endpoints ...ApiEndpointSetupFunc) {
func (s *Server) setupFrontendRoutes() { func (s *Server) setupFrontendRoutes() {
// Serve static files // Serve static files
s.server.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s.root.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app") respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app")
}) })
s.server.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { s.root.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
respond.Redirect(w, r, http.StatusMovedPermanently, "/app/favicon.ico") respond.Redirect(w, r, http.StatusMovedPermanently, s.cfg.Web.BasePath+"/app/favicon.ico")
}) })
// If a custom frontend path is configured, serve files from there when it contains content. // If a custom frontend path is configured, serve files from there when it contains content.
// If the directory is empty or missing, populate it with the embedded frontend-dist content first. // If the directory is empty or missing, populate it with the embedded frontend-dist content first.
useEmbeddedFrontend := true
if s.cfg.Web.FrontendFilePath != "" { if s.cfg.Web.FrontendFilePath != "" {
if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil { if err := os.MkdirAll(s.cfg.Web.FrontendFilePath, 0755); err != nil {
slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err) slog.Error("failed to create frontend base directory", "path", s.cfg.Web.FrontendFilePath, "error", err)
@@ -181,34 +214,93 @@ func (s *Server) setupFrontendRoutes() {
if ok { if ok {
// serve files from FS // serve files from FS
slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath) slog.Debug("serving frontend files from custom path", "path", s.cfg.Web.FrontendFilePath)
s.server.HandleFiles("/app", http.Dir(s.cfg.Web.FrontendFilePath)) useEmbeddedFrontend = false
return
} }
} }
} }
// Fallback: serve embedded frontend files var fileServer http.Handler
s.server.HandleFiles("/app", http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist")))) if useEmbeddedFrontend {
fileServer = http.FileServer(http.FS(fsMust(fs.Sub(frontendStatics, "frontend-dist"))))
} else {
fileServer = http.FileServer(http.Dir(s.cfg.Web.FrontendFilePath))
}
fileServer = http.StripPrefix(s.cfg.Web.BasePath+"/app", fileServer)
// Modify index.html and CSS to include the correct base path.
var customIndexFile, customCssFile []byte
var customCssFileName string
if s.cfg.Web.BasePath != "" {
customIndexFile, customCssFile, customCssFileName = s.updateBasePathInFrontend(useEmbeddedFrontend)
}
s.root.HandleFunc("GET /app/", func(w http.ResponseWriter, r *http.Request) {
// serve a custom index.html file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/" {
respond.Data(w, http.StatusOK, "text/html", customIndexFile)
return
}
// serve a custom CSS file with the correct base path applied
if s.cfg.Web.BasePath != "" && r.URL.Path == s.cfg.Web.BasePath+"/app/assets/"+customCssFileName {
respond.Data(w, http.StatusOK, "text/css", customCssFile)
return
}
// pass all other requests to the file server
fileServer.ServeHTTP(w, r)
})
} }
func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) { func (s *Server) landingPage(w http.ResponseWriter, _ *http.Request) {
s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{ s.tpl.HTML(w, http.StatusOK, "index.gohtml", respond.TplData{
"Version": internal.Version, "BasePath": s.cfg.Web.BasePath,
"Year": time.Now().Year(), "Version": internal.Version,
"Year": time.Now().Year(),
}) })
} }
func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc { func (s *Server) rapiDocHandler(version ApiVersion) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{ s.tpl.HTML(w, http.StatusOK, "rapidoc.gohtml", respond.TplData{
"RapiDocSource": "/js/rapidoc-min.js", "RapiDocSource": s.cfg.Web.BasePath + "/js/rapidoc-min.js",
"ApiSpecUrl": fmt.Sprintf("/doc/%s_swagger.yaml", version), "BasePath": s.cfg.Web.BasePath,
"ApiSpecUrl": fmt.Sprintf("%s/doc/%s_swagger.yaml", s.cfg.Web.BasePath, version),
"Version": internal.Version, "Version": internal.Version,
"Year": time.Now().Year(), "Year": time.Now().Year(),
}) })
} }
} }
func (s *Server) updateBasePathInFrontend(useEmbeddedFrontend bool) ([]byte, []byte, string) {
if s.cfg.Web.BasePath == "" {
return nil, nil, "" // nothing to do
}
var customIndexFile []byte
if useEmbeddedFrontend {
customIndexFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "index.html")
} else {
customIndexFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "index.html"))
}
newIndexStr := strings.ReplaceAll(string(customIndexFile), "src=\"/", "src=\""+s.cfg.Web.BasePath+"/")
newIndexStr = strings.ReplaceAll(newIndexStr, "href=\"/", "href=\""+s.cfg.Web.BasePath+"/")
re := regexp.MustCompile(`/app/assets/(index-.+.css)`)
match := re.FindStringSubmatch(newIndexStr)
cssFileName := match[1]
var customCssFile []byte
if useEmbeddedFrontend {
customCssFile, _ = fs.ReadFile(fsMust(fs.Sub(frontendStatics, "frontend-dist")), "assets/"+cssFileName)
} else {
customCssFile, _ = os.ReadFile(filepath.Join(s.cfg.Web.FrontendFilePath, "/assets/", cssFileName))
}
newCssStr := strings.ReplaceAll(string(customCssFile), "/app/assets/", s.cfg.Web.BasePath+"/app/assets/")
return []byte(newIndexStr), []byte(newCssStr), cssFileName
}
func fsMust(f fs.FS, err error) fs.FS { func fsMust(f fs.FS, err error) fs.FS {
if err != nil { if err != nil {
panic(err) panic(err)

View File

@@ -2,6 +2,7 @@ package backend
import ( import (
"context" "context"
"fmt"
"io" "io"
"github.com/h44z/wg-portal/internal/config" "github.com/h44z/wg-portal/internal/config"
@@ -118,3 +119,30 @@ func (p PeerService) SendPeerEmail(
func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) { func (p PeerService) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
return p.peers.GetPeerStats(ctx, id) return p.peers.GetPeerStats(ctx, id)
} }
func (p PeerService) BulkDelete(ctx context.Context, ids []domain.PeerIdentifier) error {
for _, id := range ids {
if err := p.peers.DeletePeer(ctx, id); err != nil {
return fmt.Errorf("failed to delete peer %s: %w", id, err)
}
}
return nil
}
func (p PeerService) BulkUpdate(ctx context.Context, ids []domain.PeerIdentifier, updateFn func(*domain.Peer)) error {
for _, id := range ids {
peer, err := p.peers.GetPeer(ctx, id)
if err != nil {
return fmt.Errorf("failed to get peer %s: %w", id, err)
}
updateFn(peer)
if _, err := p.peers.UpdatePeer(ctx, peer); err != nil {
return fmt.Errorf("failed to update peer %s: %w", id, err)
}
}
return nil
}

View File

@@ -72,7 +72,11 @@ func (u UserService) DeactivateApi(ctx context.Context, id domain.UserIdentifier
return u.users.DeactivateApi(ctx, id) return u.users.DeactivateApi(ctx, id)
} }
func (u UserService) ChangePassword(ctx context.Context, id domain.UserIdentifier, oldPassword, newPassword string) (*domain.User, error) { func (u UserService) ChangePassword(
ctx context.Context,
id domain.UserIdentifier,
oldPassword, newPassword string,
) (*domain.User, error) {
oldPassword = strings.TrimSpace(oldPassword) oldPassword = strings.TrimSpace(oldPassword)
newPassword = strings.TrimSpace(newPassword) newPassword = strings.TrimSpace(newPassword)
@@ -121,3 +125,30 @@ func (u UserService) GetUserPeerStats(ctx context.Context, id domain.UserIdentif
func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) { func (u UserService) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) {
return u.wg.GetUserInterfaces(ctx, id) return u.wg.GetUserInterfaces(ctx, id)
} }
func (u UserService) BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error {
for _, id := range ids {
if err := u.users.DeleteUser(ctx, id); err != nil {
return fmt.Errorf("failed to delete user %s: %w", id, err)
}
}
return nil
}
func (u UserService) BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error {
for _, id := range ids {
user, err := u.users.GetUser(ctx, id)
if err != nil {
return fmt.Errorf("failed to get user %s: %w", id, err)
}
updateFn(user)
if _, err := u.users.UpdateUser(ctx, user); err != nil {
return fmt.Errorf("failed to update user %s: %w", id, err)
}
}
return nil
}

View File

@@ -67,7 +67,8 @@ func (e ConfigEndpoint) RegisterRoutes(g *routegroup.Bundle) {
// @Router /config/frontend.js [get] // @Router /config/frontend.js [get]
func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc { func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
backendUrl := fmt.Sprintf("%s/api/v0", e.cfg.Web.ExternalUrl) basePath := e.cfg.Web.BasePath
backendUrl := fmt.Sprintf("%s%s/api/v0", e.cfg.Web.ExternalUrl, basePath)
if request.Header(r, "x-wg-dev") != "" { if request.Header(r, "x-wg-dev") != "" {
referer := request.Header(r, "Referer") referer := request.Header(r, "Referer")
host := "localhost" host := "localhost"
@@ -76,12 +77,13 @@ func (e ConfigEndpoint) handleConfigJsGet() http.HandlerFunc {
if err == nil { if err == nil {
host, port, _ = net.SplitHostPort(parsedReferer.Host) host, port, _ = net.SplitHostPort(parsedReferer.Host)
} }
backendUrl = fmt.Sprintf("http://%s:%s/api/v0", host, backendUrl = fmt.Sprintf("http://%s:%s%s/api/v0", host,
port) // override if request comes from frontend started with npm run dev port, basePath) // override if request comes from frontend started with npm run dev
} }
e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{ e.tpl.Render(w, http.StatusOK, "frontend_config.js.gotpl", "text/javascript", map[string]any{
"BackendUrl": backendUrl, "BackendUrl": backendUrl,
"BasePath": basePath,
"Version": internal.Version, "Version": internal.Version,
"SiteTitle": e.cfg.Web.SiteTitle, "SiteTitle": e.cfg.Web.SiteTitle,
"SiteCompanyName": e.cfg.Web.SiteCompanyName, "SiteCompanyName": e.cfg.Web.SiteCompanyName,
@@ -143,6 +145,7 @@ func (e ConfigEndpoint) handleSettingsGet() http.HandlerFunc {
MinPasswordLength: e.cfg.Auth.MinPasswordLength, MinPasswordLength: e.cfg.Auth.MinPasswordLength,
AvailableBackends: controllerFn(), AvailableBackends: controllerFn(),
LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin, LoginFormVisible: !e.cfg.Auth.HideLoginForm || !hasSocialLogin,
CreateDefaultPeer: e.cfg.Core.CreateDefaultPeer,
}) })
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"io" "io"
"net/http" "net/http"
"time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -41,6 +42,10 @@ type PeerService interface {
SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error SendPeerEmail(ctx context.Context, linkOnly bool, style string, peers ...domain.PeerIdentifier) error
// GetPeerStats returns the peer stats for the given interface. // GetPeerStats returns the peer stats for the given interface.
GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error)
// BulkDelete deletes multiple peers.
BulkDelete(context.Context, []domain.PeerIdentifier) error
// BulkUpdate modifies multiple peers.
BulkUpdate(context.Context, []domain.PeerIdentifier, func(*domain.Peer)) error
} }
type PeerEndpoint struct { type PeerEndpoint struct {
@@ -84,6 +89,9 @@ func (e PeerEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.HandleFunc("GET /{id}", e.handleSingleGet()) apiGroup.HandleFunc("GET /{id}", e.handleSingleGet())
apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut()) apiGroup.HandleFunc("PUT /{id}", e.handleUpdatePut())
apiGroup.HandleFunc("DELETE /{id}", e.handleDelete()) apiGroup.HandleFunc("DELETE /{id}", e.handleDelete())
apiGroup.HandleFunc("POST /bulk-delete", e.handleBulkDelete())
apiGroup.HandleFunc("POST /bulk-enable", e.handleBulkEnable())
apiGroup.HandleFunc("POST /bulk-disable", e.handleBulkDisable())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@@ -521,3 +529,114 @@ func (e PeerEndpoint) getConfigStyle(r *http.Request) string {
} }
return configStyle return configStyle
} }
// handleBulkDelete returns a gorm Handler function.
//
// @ID peers_handleBulkDelete
// @Tags Peer
// @Summary Bulk delete selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to delete"
// @Success 204 "No content if deletion was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-delete [post]
func (e PeerEndpoint) handleBulkDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
err := e.peerService.BulkDelete(r.Context(), ids)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkEnable returns a gorm Handler function.
//
// @ID peers_handleBulkEnable
// @Tags Peer
// @Summary Bulk enable selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to enable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-enable [post]
func (e PeerEndpoint) handleBulkEnable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
p.Disabled = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkDisable returns a gorm Handler function.
//
// @ID peers_handleBulkDisable
// @Tags Peer
// @Summary Bulk disable selected peers.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of peer identifiers to disable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /peer/bulk-disable [post]
func (e PeerEndpoint) handleBulkDisable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkPeerRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.PeerIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.PeerIdentifier(id)
}
now := time.Now()
err := e.peerService.BulkUpdate(r.Context(), ids, func(p *domain.Peer) {
p.Disabled = &now
p.DisabledReason = domain.DisabledReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -3,6 +3,7 @@ package handlers
import ( import (
"context" "context"
"net/http" "net/http"
"time"
"github.com/go-pkgz/routegroup" "github.com/go-pkgz/routegroup"
@@ -36,6 +37,10 @@ type UserService interface {
GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error)
// GetUserInterfaces returns all interfaces for the given user. // GetUserInterfaces returns all interfaces for the given user.
GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error) GetUserInterfaces(ctx context.Context, id domain.UserIdentifier) ([]domain.Interface, error)
// BulkDelete deletes multiple users.
BulkDelete(ctx context.Context, ids []domain.UserIdentifier) error
// BulkUpdate modifies multiple users.
BulkUpdate(ctx context.Context, ids []domain.UserIdentifier, updateFn func(*domain.User)) error
} }
type UserEndpoint struct { type UserEndpoint struct {
@@ -77,7 +82,13 @@ func (e UserEndpoint) RegisterRoutes(g *routegroup.Bundle) {
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("GET /{id}/interfaces", e.handleInterfacesGet())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/enable", e.handleApiEnablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/api/disable", e.handleApiDisablePost())
apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password", e.handleChangePasswordPost()) apiGroup.With(e.authenticator.UserIdMatch("id")).HandleFunc("POST /{id}/change-password",
e.handleChangePasswordPost())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-delete", e.handleBulkDelete())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-enable", e.handleBulkEnable())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-disable", e.handleBulkDisable())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-lock", e.handleBulkLock())
apiGroup.With(e.authenticator.LoggedIn(ScopeAdmin)).HandleFunc("POST /bulk-unlock", e.handleBulkUnlock())
} }
// handleAllGet returns a gorm Handler function. // handleAllGet returns a gorm Handler function.
@@ -459,3 +470,190 @@ func (e UserEndpoint) handleChangePasswordPost() http.HandlerFunc {
respond.JSON(w, http.StatusOK, model.NewUser(user, false)) respond.JSON(w, http.StatusOK, model.NewUser(user, false))
} }
} }
// handleBulkDelete returns a gorm Handler function.
//
// @ID users_handleBulkDelete
// @Tags Users
// @Summary Bulk delete selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to delete"
// @Success 204 "No content if deletion was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-delete [post]
func (e UserEndpoint) handleBulkDelete() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkDelete(r.Context(), ids)
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkEnable returns a gorm Handler function.
//
// @ID users_handleBulkEnable
// @Tags Users
// @Summary Bulk enable selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to enable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-enable [post]
func (e UserEndpoint) handleBulkEnable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Disabled = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkDisable returns a gorm Handler function.
//
// @ID users_handleBulkDisable
// @Tags Users
// @Summary Bulk disable selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to disable"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-disable [post]
func (e UserEndpoint) handleBulkDisable() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
now := time.Now()
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Disabled = &now
user.DisabledReason = domain.DisabledReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkLock returns a gorm Handler function.
//
// @ID users_handleBulkLock
// @Tags Users
// @Summary Bulk lock selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to lock"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-lock [post]
func (e UserEndpoint) handleBulkLock() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
now := time.Now()
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Locked = &now
user.LockedReason = domain.LockedReasonAdmin
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}
// handleBulkUnlock returns a gorm Handler function.
//
// @ID users_handleBulkUnlock
// @Tags Users
// @Summary Bulk unlock selected users.
// @Produce json
// @Param request body model.BulkPeerRequest true "A list of user identifiers to unlock"
// @Success 204 "No content if action was successful"
// @Failure 400 {object} model.Error
// @Failure 500 {object} model.Error
// @Router /user/bulk-unlock [post]
func (e UserEndpoint) handleBulkUnlock() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req model.BulkUserRequest
if err := request.BodyJson(r, &req); err != nil {
respond.JSON(w, http.StatusBadRequest, model.Error{Code: http.StatusBadRequest, Message: err.Error()})
return
}
ids := make([]domain.UserIdentifier, len(req.Identifiers))
for i, id := range req.Identifiers {
ids[i] = domain.UserIdentifier(id)
}
err := e.userService.BulkUpdate(r.Context(), ids, func(user *domain.User) {
user.Locked = nil
})
if err != nil {
respond.JSON(w, http.StatusInternalServerError,
model.Error{Code: http.StatusInternalServerError, Message: err.Error()})
return
}
respond.Status(w, http.StatusNoContent)
}
}

View File

@@ -1,4 +1,5 @@
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}"; WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
WGPORTAL_BASE_PATH="{{ $.BasePath }}";
WGPORTAL_VERSION="{{ $.Version }}"; WGPORTAL_VERSION="{{ $.Version }}";
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}"; WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}"; WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";

View File

@@ -49,7 +49,11 @@ func NewSessionWrapper(cfg *config.Config) *SessionWrapper {
sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https") sessionManager.Cookie.Secure = strings.HasPrefix(cfg.Web.ExternalUrl, "https")
sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.HttpOnly = true
sessionManager.Cookie.SameSite = http.SameSiteLaxMode sessionManager.Cookie.SameSite = http.SameSiteLaxMode
sessionManager.Cookie.Path = "/" if cfg.Web.BasePath != "" {
sessionManager.Cookie.Path = cfg.Web.BasePath
} else {
sessionManager.Cookie.Path = "/"
}
sessionManager.Cookie.Persist = false sessionManager.Cookie.Persist = false
wrappedSessionManager := &SessionWrapper{sessionManager} wrappedSessionManager := &SessionWrapper{sessionManager}

View File

@@ -14,6 +14,7 @@ type Settings struct {
MinPasswordLength int `json:"MinPasswordLength"` MinPasswordLength int `json:"MinPasswordLength"`
AvailableBackends []SettingsBackendNames `json:"AvailableBackends"` AvailableBackends []SettingsBackendNames `json:"AvailableBackends"`
LoginFormVisible bool `json:"LoginFormVisible"` LoginFormVisible bool `json:"LoginFormVisible"`
CreateDefaultPeer bool `json:"CreateDefaultPeer"`
} }
type SettingsBackendNames struct { type SettingsBackendNames struct {

View File

@@ -0,0 +1,10 @@
package model
type BulkPeerRequest struct {
Identifiers []string `json:"Identifiers" binding:"required"`
Reason string `json:"Reason"`
}
type BulkUserRequest struct {
Identifiers []string `json:"Identifiers" binding:"required"`
}

View File

@@ -9,15 +9,16 @@ import (
) )
type Interface struct { type Interface struct {
Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0 Identifier string `json:"Identifier" example:"wg0"` // device name, for example: wg0
DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface DisplayName string `json:"DisplayName"` // a nice display name/ description for the interface
Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any' Mode string `json:"Mode" example:"server"` // the interface type, either 'server', 'client' or 'any'
Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ... Backend string `json:"Backend" example:"local"` // the backend used for this interface e.g., local, mikrotik, ...
PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface PrivateKey string `json:"PrivateKey" example:"abcdef=="` // private Key of the server interface
PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface PublicKey string `json:"PublicKey" example:"abcdef=="` // public Key of the server interface
Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down) Disabled bool `json:"Disabled"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled DisabledReason string `json:"DisabledReason"` // the reason why the interface has been disabled
SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file SaveConfig bool `json:"SaveConfig"` // automatically persist config changes to the wgX.conf file
CreateDefaultPeer bool `json:"CreateDefaultPeer"` // if true, default peers will be created for this interface
ListenPort int `json:"ListenPort"` // the listening port, for example: 51820 ListenPort int `json:"ListenPort"` // the listening port, for example: 51820
Addresses []string `json:"Addresses"` // the interface ip addresses Addresses []string `json:"Addresses"` // the interface ip addresses
@@ -65,6 +66,7 @@ func NewInterface(src *domain.Interface, peers []domain.Peer) *Interface {
Disabled: src.IsDisabled(), Disabled: src.IsDisabled(),
DisabledReason: src.DisabledReason, DisabledReason: src.DisabledReason,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
ListenPort: src.ListenPort, ListenPort: src.ListenPort,
Addresses: domain.CidrsToStringSlice(src.Addresses), Addresses: domain.CidrsToStringSlice(src.Addresses),
Dns: internal.SliceString(src.DnsStr), Dns: internal.SliceString(src.DnsStr),
@@ -151,6 +153,7 @@ func NewDomainInterface(src *Interface) *domain.Interface {
PreDown: src.PreDown, PreDown: src.PreDown,
PostDown: src.PostDown, PostDown: src.PostDown,
SaveConfig: src.SaveConfig, SaveConfig: src.SaveConfig,
CreateDefaultPeer: src.CreateDefaultPeer,
DisplayName: src.DisplayName, DisplayName: src.DisplayName,
Type: domain.InterfaceType(src.Mode), Type: domain.InterfaceType(src.Mode),
Backend: domain.InterfaceBackend(src.Backend), Backend: domain.InterfaceBackend(src.Backend),

View File

@@ -99,7 +99,7 @@ type Authenticator struct {
} }
// NewAuthenticator creates a new Authenticator instance. // NewAuthenticator creates a new Authenticator instance.
func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserManager) ( func NewAuthenticator(cfg *config.Auth, extUrl, basePath string, bus EventBus, users UserManager) (
*Authenticator, *Authenticator,
error, error,
) { ) {
@@ -107,7 +107,7 @@ func NewAuthenticator(cfg *config.Auth, extUrl string, bus EventBus, users UserM
cfg: cfg, cfg: cfg,
bus: bus, bus: bus,
users: users, users: users,
callbackUrlPrefix: fmt.Sprintf("%s/api/v0", extUrl), callbackUrlPrefix: fmt.Sprintf("%s%s/api/v0", extUrl, basePath),
oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)), oauthAuthenticators: make(map[string]AuthenticatorOauth, len(cfg.OpenIDConnect)+len(cfg.OAuth)),
ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)), ldapAuthenticators: make(map[string]AuthenticatorLdap, len(cfg.Ldap)),
} }

View File

@@ -374,6 +374,7 @@ func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "", SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id), DisplayName: string(id),
Type: domain.InterfaceTypeServer, Type: domain.InterfaceTypeServer,
CreateDefaultPeer: m.cfg.Core.CreateDefaultPeer,
DriverType: "", DriverType: "",
Disabled: nil, Disabled: nil,
DisabledReason: "", DisabledReason: "",

View File

@@ -35,6 +35,10 @@ func (m Manager) CreateDefaultPeer(ctx context.Context, userId domain.UserIdenti
continue // only create default peers for server interfaces continue // only create default peers for server interfaces
} }
if !iface.CreateDefaultPeer {
continue // only create default peers if the interface flag is set
}
peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool { peerAlreadyCreated := slices.ContainsFunc(userPeers, func(peer domain.Peer) bool {
return peer.InterfaceIdentifier == iface.Identifier return peer.InterfaceIdentifier == iface.Identifier
}) })

View File

@@ -78,7 +78,12 @@ func (f *mockDB) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceId
func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) { func (f *mockDB) GetPeersStats(ctx context.Context, ids ...domain.PeerIdentifier) ([]domain.PeerStatus, error) {
return nil, nil return nil, nil
} }
func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) { return nil, nil } func (f *mockDB) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
if f.iface != nil {
return []domain.Interface{*f.iface}, nil
}
return nil, nil
}
func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) { func (f *mockDB) GetInterfaceIps(ctx context.Context) (map[domain.InterfaceIdentifier][]domain.Cidr, error) {
return nil, nil return nil, nil
} }
@@ -192,3 +197,58 @@ func TestCreatePeer_SetsIdentifier_FromPublicKey(t *testing.T) {
t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId) t.Fatalf("expected peer with identifier %q to be saved in DB", expectedId)
} }
} }
func TestCreateDefaultPeer_RespectsInterfaceFlag(t *testing.T) {
// Arrange
cfg := &config.Config{}
cfg.Core.CreateDefaultPeer = true
bus := &mockBus{}
ctrlMgr := &ControllerManager{
controllers: map[domain.InterfaceBackend]backendInstance{
config.LocalBackendName: {Implementation: &mockController{}},
},
}
db := &mockDB{
iface: &domain.Interface{
Identifier: "wg0",
Type: domain.InterfaceTypeServer,
CreateDefaultPeer: false, // Flag is disabled!
},
}
m := Manager{
cfg: cfg,
bus: bus,
db: db,
wg: ctrlMgr,
}
userId := domain.UserIdentifier("user@example.com")
ctx := domain.SetUserInfo(context.Background(), &domain.ContextUserInfo{Id: userId, IsAdmin: true})
// Act
err := m.CreateDefaultPeer(ctx, userId)
// Assert
if err != nil {
t.Fatalf("CreateDefaultPeer returned error: %v", err)
}
if len(db.savedPeers) != 0 {
t.Fatalf("expected no peers to be created because interface flag is false, but got %d", len(db.savedPeers))
}
// Now enable the flag and try again
db.iface.CreateDefaultPeer = true
err = m.CreateDefaultPeer(ctx, userId)
if err != nil {
t.Fatalf("CreateDefaultPeer returned error after enabling flag: %v", err)
}
if len(db.savedPeers) != 1 {
t.Fatalf("expected 1 peer to be created because interface flag is true, but got %d", len(db.savedPeers))
}
}

View File

@@ -149,6 +149,7 @@ func defaultConfig() *Config {
RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false), RequestLogging: getEnvBool("WG_PORTAL_WEB_REQUEST_LOGGING", false),
ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false), ExposeHostInfo: getEnvBool("WG_PORTAL_WEB_EXPOSE_HOST_INFO", false),
ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"), ExternalUrl: getEnvStr("WG_PORTAL_WEB_EXTERNAL_URL", "http://localhost:8888"),
BasePath: getEnvStr("WG_PORTAL_WEB_BASE_PATH", ""),
ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"), ListeningAddress: getEnvStr("WG_PORTAL_WEB_LISTENING_ADDRESS", ":8888"),
SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"), SessionIdentifier: getEnvStr("WG_PORTAL_WEB_SESSION_IDENTIFIER", "wgPortalSession"),
SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"), SessionSecret: getEnvStr("WG_PORTAL_WEB_SESSION_SECRET", "very_secret"),

View File

@@ -11,6 +11,10 @@ type WebConfig struct {
// ExternalUrl is the URL where a client can access WireGuard Portal. // ExternalUrl is the URL where a client can access WireGuard Portal.
// This is used for the callback URL of the OAuth providers. // This is used for the callback URL of the OAuth providers.
ExternalUrl string `yaml:"external_url"` ExternalUrl string `yaml:"external_url"`
// BasePath is an optional URL path prefix under which the whole web UI and API are served.
// Example: "/wg" will make the UI available at /wg/app and the API at /wg/api.
// Empty string means no prefix (served from root path).
BasePath string `yaml:"base_path"`
// ListeningAddress is the address and port for the web server. // ListeningAddress is the address and port for the web server.
ListeningAddress string `yaml:"listening_address"` ListeningAddress string `yaml:"listening_address"`
// SessionIdentifier is the session identifier for the web frontend. // SessionIdentifier is the session identifier for the web frontend.
@@ -35,4 +39,12 @@ type WebConfig struct {
func (c *WebConfig) Sanitize() { func (c *WebConfig) Sanitize() {
c.ExternalUrl = strings.TrimRight(c.ExternalUrl, "/") c.ExternalUrl = strings.TrimRight(c.ExternalUrl, "/")
// normalize BasePath: allow empty, otherwise ensure leading slash, no trailing slash
p := strings.TrimSpace(c.BasePath)
p = strings.TrimRight(p, "/")
if p != "" && !strings.HasPrefix(p, "/") {
p = "/" + p
}
c.BasePath = p
} }

View File

@@ -53,12 +53,13 @@ type Interface struct {
SaveConfig bool // automatically persist config changes to the wgX.conf file SaveConfig bool // automatically persist config changes to the wgX.conf file
// WG Portal specific // WG Portal specific
DisplayName string // a nice display name/ description for the interface DisplayName string // a nice display name/ description for the interface
Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient Type InterfaceType // the interface type, either InterfaceTypeServer or InterfaceTypeClient
Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...) CreateDefaultPeer bool // if true, default peers will be created for this interface
DriverType string // the interface driver type (linux, software, ...) Backend InterfaceBackend // the backend that is used to manage the interface (wgctrl, mikrotik, ...)
Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down) DriverType string // the interface driver type (linux, software, ...)
DisabledReason string // the reason why the interface has been disabled Disabled *time.Time `gorm:"index"` // flag that specifies if the interface is enabled (up) or not (down)
DisabledReason string // the reason why the interface has been disabled
// Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of // Default settings for the peer, used for new peers, those settings will be published to ConfigOption options of
// the peer config // the peer config

View File

@@ -81,7 +81,6 @@ nav:
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md - Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
- Configuration: - Configuration:
- Overview: documentation/configuration/overview.md - Overview: documentation/configuration/overview.md
- Mail templates: documentation/configuration/mail-templates.md
- Examples: documentation/configuration/examples.md - Examples: documentation/configuration/examples.md
- Usage: - Usage:
- General: documentation/usage/general.md - General: documentation/usage/general.md

View File

@@ -7,11 +7,13 @@ After=network.target
Type=simple Type=simple
User=root User=root
Group=root Group=root
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
Restart=on-failure Restart=on-failure
RestartSec=10 RestartSec=10
WorkingDirectory=/opt/wg-portal WorkingDirectory=/opt/wg-portal
Environment=WG_PORTAL_CONFIG=/opt/wg-portal/config.yml
ExecStart=/opt/wg-portal/wg-portal-amd64 ExecStart=/opt/wg-portal/wg-portal-amd64
[Install] [Install]