mirror of
https://github.com/h44z/wg-portal.git
synced 2025-10-05 16:06:17 +00:00
Compare commits
5 Commits
v2.0.0-rc.
...
v2.0.0-rc.
Author | SHA1 | Date | |
---|---|---|---|
|
b4aa6f8ef3 | ||
|
020ebb64e7 | ||
|
923d4a6188 | ||
|
2b46dca770 | ||
|
b9c4ca04f5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ ssh.key
|
||||
wg_portal.db
|
||||
sqlite.db
|
||||
/config.yml
|
||||
/config.yaml
|
||||
/config/
|
||||
venv/
|
||||
.cache/
|
||||
|
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
evbus "github.com/vardius/message-bus"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/adapters"
|
||||
@@ -41,6 +42,8 @@ func main() {
|
||||
|
||||
cfg.LogStartupValues()
|
||||
|
||||
dbEncryptedSerializer := app.NewGormEncryptedStringSerializer(cfg.Database.EncryptionPassphrase)
|
||||
schema.RegisterSerializer("encstr", dbEncryptedSerializer)
|
||||
rawDb, err := adapters.NewDatabase(cfg.Database)
|
||||
internal.AssertNoError(err)
|
||||
|
||||
|
@@ -12,6 +12,7 @@ services:
|
||||
- NET_ADMIN
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
# left side is the host path, right side is the container path
|
||||
- /etc/wireguard:/etc/wireguard
|
||||
- ./data:/app/data
|
||||
- ./config:/app/config
|
||||
|
@@ -31,6 +31,7 @@ database:
|
||||
debug: true
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
|
||||
```
|
||||
|
||||
## LDAP Authentication and Synchronization
|
||||
|
@@ -1,7 +1,7 @@
|
||||
This page provides an overview of **all available configuration options** for WireGuard Portal.
|
||||
|
||||
You can supply these configurations in a **YAML** file (e.g. `config.yaml`) when starting the Portal.
|
||||
The path of the configuration file defaults to **config/config.yml** in the working directory of the executable.
|
||||
The path of the configuration file defaults to **config/config.yaml** (or config/config.yml) in the working directory of the executable.
|
||||
It is possible to override configuration filepath using the environment variable `WG_PORTAL_CONFIG`.
|
||||
For example: `WG_PORTAL_CONFIG=/etc/wg-portal/config.yaml ./wg-portal`.
|
||||
Also, environment variable substitution in config file is supported. Refer to [syntax](https://github.com/a8m/envsubst?tab=readme-ov-file#docs).
|
||||
@@ -39,7 +39,7 @@ advanced:
|
||||
|
||||
database:
|
||||
debug: false
|
||||
slow_query_threshold: 0
|
||||
slow_query_threshold: "0"
|
||||
type: sqlite
|
||||
dsn: data/sqlite.db
|
||||
|
||||
@@ -214,13 +214,15 @@ Additional or more specialized configuration options for logging and interface c
|
||||
Configuration for the underlying database used by WireGuard Portal.
|
||||
Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
|
||||
If sensitive values (like private keys) should be stored in an encrypted format, set the `encryption_passphrase` option.
|
||||
|
||||
### `debug`
|
||||
- **Default:** `false`
|
||||
- **Description:** If `true`, logs all database statements (verbose).
|
||||
|
||||
### `slow_query_threshold`
|
||||
- **Default:** 0
|
||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If empty or zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration).
|
||||
- **Default:** "0"
|
||||
- **Description:** A time threshold (e.g., `100ms`) above which queries are considered slow and logged as warnings. If zero, slow query logging is disabled. Format uses `s`, `ms` for seconds, milliseconds, see [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration). The value must be a string.
|
||||
|
||||
### `type`
|
||||
- **Default:** `sqlite`
|
||||
@@ -234,6 +236,12 @@ Supported databases include SQLite, MySQL, Microsoft SQL Server, and Postgres.
|
||||
user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
|
||||
```
|
||||
|
||||
### `encryption_passphrase`
|
||||
- **Default:** *(empty)*
|
||||
- **Description:** Passphrase for encrypting sensitive values such as private keys in the database. Encryption is only applied if this passphrase is set.
|
||||
**Important:** Once you enable encryption by setting this passphrase, you cannot disable it or change it afterward.
|
||||
New or updated records will be encrypted; existing data remains in plaintext until it’s next modified.
|
||||
|
||||
---
|
||||
|
||||
## Statistics
|
||||
@@ -274,7 +282,7 @@ Controls how WireGuard Portal collects and reports usage statistics, including p
|
||||
|
||||
### `listening_address`
|
||||
- **Default:** `:8787`
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787`).
|
||||
- **Description:** Address and port for the integrated Prometheus metric server (e.g., `:8787` or `127.0.0.1:8888`).
|
||||
|
||||
---
|
||||
|
||||
|
@@ -45,6 +45,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port
|
||||
@@ -52,6 +53,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
```
|
||||
@@ -70,6 +72,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
- NET_ADMIN
|
||||
network_mode: "service:wireguard" # So we ensure to stay on the same network as the wireguard container.
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/etc:/etc/wireguard
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
@@ -81,6 +84,7 @@ WireGuard Portal supports managing WireGuard interfaces through three distinct d
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
- "51820:51820/udp" # WireGuard port, needs to match the port in wg-portal interface config
|
||||
- "8888:8888/tcp" # Noticed that the port of the web UI is exposed in the wireguard container.
|
||||
volumes:
|
||||
@@ -133,7 +137,7 @@ For each commit in the master and the stable branch, a corresponding Docker imag
|
||||
## Configuration
|
||||
|
||||
You can configure WireGuard Portal using a YAML configuration file.
|
||||
The filepath of the YAML configuration file defaults to `/app/config/config.yml`.
|
||||
The filepath of the YAML configuration file defaults to `/app/config/config.yaml`.
|
||||
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
|
||||
|
||||
By default, WireGuard Portal uses an SQLite database. The database is stored in `/app/data/sqlite.db`.
|
||||
|
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
98
docs/documentation/getting-started/reverse-proxy.md
Normal file
@@ -0,0 +1,98 @@
|
||||
## Reverse Proxy for HTTPS
|
||||
|
||||
For production deployments, always serve the WireGuard Portal over HTTPS. You have two options to secure your connection:
|
||||
|
||||
|
||||
### Reverse Proxy
|
||||
|
||||
Let a front‐end proxy handle HTTPS for you. This also frees you from managing certificates manually and is therefore the preferred option.
|
||||
You can use Nginx, Traefik, Caddy or any other proxy.
|
||||
|
||||
Below is an example using a Docker Compose stack with [Traefik](https://traefik.io/traefik/).
|
||||
It exposes the WireGuard Portal on `https://wg.domain.com` and redirects initial HTTP traffic to HTTPS.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
reverse-proxy:
|
||||
image: traefik:v3.3
|
||||
restart: unless-stopped
|
||||
command:
|
||||
#- '--log.level=DEBUG'
|
||||
- '--providers.docker.endpoint=unix:///var/run/docker.sock'
|
||||
- '--providers.docker.exposedbydefault=false'
|
||||
- '--entrypoints.web.address=:80'
|
||||
- '--entrypoints.websecure.address=:443'
|
||||
- '--entrypoints.websecure.http3'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge=true'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.httpchallenge.entrypoint=web'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.email=your.email@domain.com'
|
||||
- '--certificatesresolvers.letsencryptresolver.acme.storage=/letsencrypt/acme.json'
|
||||
#- '--certificatesresolvers.letsencryptresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory' # just for testing
|
||||
ports:
|
||||
- 80:80 # for HTTP
|
||||
- 443:443/tcp # for HTTPS
|
||||
- 443:443/udp # for HTTP/3
|
||||
volumes:
|
||||
- acme-certs:/letsencrypt
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
# HTTP Catchall for redirecting HTTP -> HTTPS
|
||||
- 'traefik.http.routers.dashboard-catchall.rule=Host(`wg.domain.com`) && PathPrefix(`/`)'
|
||||
- 'traefik.http.routers.dashboard-catchall.entrypoints=web'
|
||||
- 'traefik.http.routers.dashboard-catchall.middlewares=redirect-to-https'
|
||||
- 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'
|
||||
|
||||
wg-portal:
|
||||
image: wgportal/wg-portal:latest
|
||||
container_name: wg-portal
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
# host port : container port
|
||||
# WireGuard port, needs to match the port in wg-portal interface config (add one port mapping for each interface)
|
||||
- "51820:51820/udp"
|
||||
# Web UI port (only available on localhost, Traefik will handle the HTTPS)
|
||||
- "127.0.0.1:8888:8888/tcp"
|
||||
sysctls:
|
||||
- net.ipv4.conf.all.src_valid_mark=1
|
||||
volumes:
|
||||
# host path : container path
|
||||
- ./wg/data:/app/data
|
||||
- ./wg/config:/app/config
|
||||
labels:
|
||||
- 'traefik.enable=true'
|
||||
- 'traefik.http.routers.wgportal.rule=Host(`wg.domain.com`)'
|
||||
- 'traefik.http.routers.wgportal.entrypoints=websecure'
|
||||
- 'traefik.http.routers.wgportal.tls.certresolver=letsencryptresolver'
|
||||
- 'traefik.http.routers.wgportal.service=wgportal'
|
||||
- 'traefik.http.services.wgportal.loadbalancer.server.port=8888'
|
||||
|
||||
volumes:
|
||||
acme-certs:
|
||||
```
|
||||
|
||||
The WireGuard Portal configuration must be updated accordingly so that the correct external URL is set for the web interface:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
external_url: https://wg.domain.com
|
||||
```
|
||||
|
||||
### Built-in TLS
|
||||
|
||||
If you prefer to let WireGuard Portal handle TLS itself, you can use the built-in TLS support.
|
||||
In your `config.yaml`, under the `web` section, point to your certificate and key files:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
cert_file: /path/to/your/fullchain.pem
|
||||
key_file: /path/to/your/privkey.pem
|
||||
```
|
||||
|
||||
The web server will then use these files to serve HTTPS traffic directly instead of HTTP.
|
@@ -6,7 +6,7 @@ If you want to use version 2, please be aware that it is still a release candida
|
||||
> :warning: Before upgrading from V1, make sure that you have a backup of your currently working configuration files and database!
|
||||
|
||||
To start the upgrade process, start the wg-portal binary with the **-migrateFrom** parameter.
|
||||
The configuration (config.yml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
The configuration (config.yaml) for WireGuard Portal must be updated and valid before starting the upgrade.
|
||||
|
||||
To upgrade from a previous SQLite database, start wg-portal like:
|
||||
|
||||
@@ -21,7 +21,7 @@ For example:
|
||||
./wg-portal-amd64 -migrateFromType=mysql -migrateFrom='user:pass@tcp(1.2.3.4:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local'
|
||||
```
|
||||
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yml** configuration file.
|
||||
The upgrade will transform the old, existing database and store the values in the new database specified in the **config.yaml** configuration file.
|
||||
Ensure that the new database does not contain any data!
|
||||
|
||||
If you are using Docker, you can adapt the docker-compose.yml file to start the upgrade process:
|
||||
|
@@ -85,6 +85,9 @@ const currentYear = ref(new Date().getFullYear())
|
||||
<li v-if="auth.IsAuthenticated && auth.IsAdmin" class="nav-item">
|
||||
<RouterLink :to="{ name: 'users' }" class="nav-link">{{ $t('menu.users') }}</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="{ name: 'key-generator' }" class="nav-link">{{ $t('menu.keygen') }}</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="navbar-nav d-flex justify-content-end">
|
||||
|
@@ -331,11 +331,11 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.interface-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.private-key.label') }}</label>
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PrivateKey" class="form-control" :placeholder="$t('modals.interface-edit.private-key.placeholder')" required type="text">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.interface-edit.public-key.label') }}</label>
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="email">
|
||||
<input v-model="formData.PublicKey" class="form-control" :placeholder="$t('modals.interface-edit.public-key.placeholder')" required type="text">
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
|
@@ -323,17 +323,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group" v-if="selectedInterface.Mode === 'server'">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
<div class="form-group" v-if="formData.Mode === 'client'">
|
||||
|
@@ -211,17 +211,18 @@ async function del() {
|
||||
<legend class="mt-4">{{ $t('modals.peer-edit.header-crypto') }}</legend>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.private-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.private-key.placeholder')" required
|
||||
v-model="formData.PrivateKey">
|
||||
<small id="privateKeyHelp" class="form-text text-muted">{{ $t('modals.peer-edit.private-key.help') }}</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.public-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.public-key.placeholder')" required
|
||||
v-model="formData.PublicKey">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('modals.peer-edit.preshared-key.label') }}</label>
|
||||
<input type="email" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
<input type="text" class="form-control" :placeholder="$t('modals.peer-edit.preshared-key.placeholder')"
|
||||
v-model="formData.PresharedKey">
|
||||
</div>
|
||||
</fieldset>
|
||||
|
@@ -39,7 +39,8 @@
|
||||
"profile": "Mein Profil",
|
||||
"settings": "Einstellungen",
|
||||
"login": "Anmelden",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"keygen": "Schlüsselgenerator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -188,6 +189,25 @@
|
||||
"api-link": "API Dokumentation"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Hier können Sie WireGuard Schlüsselpaare generieren. Die Schlüssel werden lokal auf Ihrem Computer generiert und niemals an den Server gesendet.",
|
||||
"headline-keypair": "Neues Schlüsselpaar",
|
||||
"headline-preshared-key": "Neuer Pre-shared Key",
|
||||
"button-generate": "Erzeugen",
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "Der private Schlüssel"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "Der öffentliche Schlüssel"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "Der Pre-shared Schlüssel"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -420,7 +440,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"placeholder": "The private key",
|
||||
"help": "Der private Schlüssel wird sicher auf dem Server gespeichert. Wenn der Benutzer bereits eine Kopie besitzt, kann dieses Feld entfallen. Der Server funktioniert auch ausschließlich mit dem öffentlichen Schlüssel des Peers."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
|
@@ -40,7 +40,8 @@
|
||||
"settings": "Settings",
|
||||
"audit": "Audit Log",
|
||||
"login": "Login",
|
||||
"logout": "Logout"
|
||||
"logout": "Logout",
|
||||
"keygen": "Key Generator"
|
||||
},
|
||||
"home": {
|
||||
"headline": "WireGuard® VPN Portal",
|
||||
@@ -206,6 +207,25 @@
|
||||
"message": "Message"
|
||||
}
|
||||
},
|
||||
"keygen": {
|
||||
"headline": "WireGuard Key Generator",
|
||||
"abstract": "Generate a new WireGuard keys. The keys are generated in your local browser and are never sent to the server.",
|
||||
"headline-keypair": "New Key Pair",
|
||||
"headline-preshared-key": "New Preshared Key",
|
||||
"button-generate": "Generate",
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
"placeholder": "The public key"
|
||||
},
|
||||
"preshared-key": {
|
||||
"label": "Preshared Key",
|
||||
"placeholder": "The pre-shared key"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"user-view": {
|
||||
"headline": "User Account:",
|
||||
@@ -439,7 +459,8 @@
|
||||
},
|
||||
"private-key": {
|
||||
"label": "Private Key",
|
||||
"placeholder": "The private key"
|
||||
"placeholder": "The private key",
|
||||
"help": "The private key is stored securely on the server. If the user already holds a copy, you may omit this field. The server still functions exclusively with the peer’s public key."
|
||||
},
|
||||
"public-key": {
|
||||
"label": "Public Key",
|
||||
|
@@ -64,6 +64,14 @@ const router = createRouter({
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AuditView.vue')
|
||||
},
|
||||
{
|
||||
path: '/key-generator',
|
||||
name: 'key-generator',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/KeyGeneraterView.vue')
|
||||
}
|
||||
],
|
||||
linkActiveClass: "active",
|
||||
@@ -114,11 +122,11 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
|
||||
// redirect to login page if not logged in and trying to access a restricted page
|
||||
const publicPages = ['/', '/login']
|
||||
const publicPages = ['/', '/login', '/key-generator']
|
||||
const authRequired = !publicPages.includes(to.path)
|
||||
|
||||
if (authRequired && !auth.IsAuthenticated) {
|
||||
auth.SetReturnUrl(to.fullPath) // store original destination before starting the auth process
|
||||
auth.SetReturnUrl(to.fullPath) // store the original destination before starting the auth process
|
||||
return '/login'
|
||||
}
|
||||
})
|
||||
|
147
frontend/src/views/KeyGeneraterView.vue
Normal file
147
frontend/src/views/KeyGeneraterView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<script setup>
|
||||
|
||||
import {ref} from "vue";
|
||||
|
||||
const privateKey = ref("")
|
||||
const publicKey = ref("")
|
||||
const presharedKey = ref("")
|
||||
|
||||
/**
|
||||
* Generate an X25519 keypair using the Web Crypto API and return Base64-encoded strings.
|
||||
* @async
|
||||
* @function generateKeypair
|
||||
* @returns {Promise<{ publicKey: string, privateKey: string }>} Resolves with an object containing
|
||||
* - publicKey: the Base64-encoded public key
|
||||
* - privateKey: the Base64-encoded private key
|
||||
*/
|
||||
async function generateKeypair() {
|
||||
// 1. Generate an X25519 key pair
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{ name: 'X25519', namedCurve: 'X25519' },
|
||||
true, // extractable
|
||||
['deriveBits'] // allowed usage for ECDH
|
||||
);
|
||||
|
||||
// 2. Export keys as JWK to access raw key material
|
||||
const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
|
||||
// 3. Convert Base64URL to standard Base64 with padding
|
||||
return {
|
||||
publicKey: b64urlToB64(pubJwk.x),
|
||||
privateKey: b64urlToB64(privJwk.d)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 32-byte pre-shared key using crypto.getRandomValues.
|
||||
* @function generatePresharedKey
|
||||
* @returns {Uint8Array} A Uint8Array of length 32 with random bytes.
|
||||
*/
|
||||
function generatePresharedKey() {
|
||||
let privateKey = new Uint8Array(32);
|
||||
window.crypto.getRandomValues(privateKey);
|
||||
return privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Base64URL-encoded string to standard Base64 with padding.
|
||||
* @function b64urlToB64
|
||||
* @param {string} input - The Base64URL string.
|
||||
* @returns {string} The padded, standard Base64 string.
|
||||
*/
|
||||
function b64urlToB64(input) {
|
||||
let b64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (b64.length % 4) {
|
||||
b64 += '=';
|
||||
}
|
||||
return b64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer or TypedArray buffer to a Base64-encoded string.
|
||||
* @function arrayBufferToBase64
|
||||
* @param {ArrayBuffer|Uint8Array} buffer - The buffer to convert.
|
||||
* @returns {string} Base64-encoded representation of the buffer.
|
||||
*/
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; ++i) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
// Window.btoa handles binary → Base64
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new keypair and update the corresponding Vue refs.
|
||||
* @async
|
||||
* @function generateNewKeyPair
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function generateNewKeyPair() {
|
||||
const keypair = await generateKeypair();
|
||||
|
||||
privateKey.value = keypair.privateKey;
|
||||
publicKey.value = keypair.publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new pre-shared key and update the Vue ref.
|
||||
* @function generateNewPresharedKey
|
||||
*/
|
||||
function generateNewPresharedKey() {
|
||||
const rawPsk = generatePresharedKey();
|
||||
presharedKey.value = arrayBufferToBase64(rawPsk);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>{{ $t('keygen.headline') }}</h1>
|
||||
</div>
|
||||
|
||||
<p class="lead">{{ $t('keygen.abstract') }}</p>
|
||||
|
||||
<div class="mt-4 row">
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-keypair') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.private-key.label') }}</label>
|
||||
<input class="form-control" v-model="privateKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.public-key.label') }}</label>
|
||||
<input class="form-control" v-model="publicKey" :placeholder="$t('keygen.private-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewKeyPair">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="col-12 col-lg-2 mt-sm-4">
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<h1>{{ $t('keygen.headline-preshared-key') }}</h1>
|
||||
<fieldset>
|
||||
<div class="form-group">
|
||||
<label class="form-label mt-4">{{ $t('keygen.preshared-key.label') }}</label>
|
||||
<input class="form-control" v-model="presharedKey" :placeholder="$t('keygen.preshared-key.placeholder')" readonly>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<hr class="mt-4">
|
||||
<button class="btn btn-primary mb-4" type="button" @click.prevent="generateNewPresharedKey">{{ $t('keygen.button-generate') }}</button>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
203
internal/app/gorm_encryption.go
Normal file
203
internal/app/gorm_encryption.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/h44z/wg-portal/internal/domain"
|
||||
)
|
||||
|
||||
// GormEncryptedStringSerializer is a GORM serializer that encrypts and decrypts string values using AES256.
|
||||
// It is used to store sensitive information in the database securely.
|
||||
// If the serializer encounters a value that is not a string, it will return an error.
|
||||
type GormEncryptedStringSerializer struct {
|
||||
useEncryption bool
|
||||
keyPhrase string
|
||||
prefix string
|
||||
}
|
||||
|
||||
// NewGormEncryptedStringSerializer creates a new GormEncryptedStringSerializer.
|
||||
// It needs to be registered with GORM to be used:
|
||||
// schema.RegisterSerializer("encstr", gormEncryptedStringSerializerInstance)
|
||||
// You can then use it in your model like this:
|
||||
//
|
||||
// EncryptedField string `gorm:"serializer:encstr"`
|
||||
func NewGormEncryptedStringSerializer(keyPhrase string) GormEncryptedStringSerializer {
|
||||
return GormEncryptedStringSerializer{
|
||||
useEncryption: keyPhrase != "",
|
||||
keyPhrase: keyPhrase,
|
||||
prefix: "WG_ENC_",
|
||||
}
|
||||
}
|
||||
|
||||
// Scan implements the GORM serializer interface. It decrypts the value after reading it from the database.
|
||||
func (s GormEncryptedStringSerializer) Scan(
|
||||
ctx context.Context,
|
||||
field *schema.Field,
|
||||
dst reflect.Value,
|
||||
dbValue any,
|
||||
) (err error) {
|
||||
var dbStringValue string
|
||||
if dbValue != nil {
|
||||
switch v := dbValue.(type) {
|
||||
case []byte:
|
||||
dbStringValue = string(v)
|
||||
case string:
|
||||
dbStringValue = v
|
||||
default:
|
||||
return fmt.Errorf("unsupported type %T for encrypted field %s", dbValue, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if !s.useEncryption {
|
||||
field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
|
||||
return nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(dbStringValue, s.prefix) {
|
||||
field.ReflectValueOf(ctx, dst).SetString(dbStringValue) // keep the original value
|
||||
return nil
|
||||
}
|
||||
|
||||
encryptedString := strings.TrimPrefix(dbStringValue, s.prefix)
|
||||
decryptedString, err := DecryptAES256(encryptedString, s.keyPhrase)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt value for field %s: %w", field.Name, err)
|
||||
}
|
||||
|
||||
field.ReflectValueOf(ctx, dst).SetString(decryptedString)
|
||||
return
|
||||
}
|
||||
|
||||
// Value implements the GORM serializer interface. It encrypts the value before storing it in the database.
|
||||
func (s GormEncryptedStringSerializer) Value(
|
||||
_ context.Context,
|
||||
_ *schema.Field,
|
||||
_ reflect.Value,
|
||||
fieldValue any,
|
||||
) (any, error) {
|
||||
if fieldValue == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch v := fieldValue.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
return "", nil // empty string, no need to encrypt
|
||||
}
|
||||
if !s.useEncryption {
|
||||
return v, nil // keep the original value
|
||||
}
|
||||
encryptedString, err := EncryptAES256(v, s.keyPhrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.prefix + encryptedString, nil
|
||||
case domain.PreSharedKey:
|
||||
if v == "" {
|
||||
return "", nil // empty string, no need to encrypt
|
||||
}
|
||||
if !s.useEncryption {
|
||||
return string(v), nil // keep the original value
|
||||
}
|
||||
encryptedString, err := EncryptAES256(string(v), s.keyPhrase)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.prefix + encryptedString, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("encryption only supports string values, got %T", fieldValue)
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptAES256 encrypts the given plaintext with the given key using AES256 in CBC mode with PKCS7 padding
|
||||
func EncryptAES256(plaintext, key string) (string, error) {
|
||||
if len(plaintext) == 0 {
|
||||
return "", fmt.Errorf("plaintext must not be empty")
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return "", fmt.Errorf("key must not be empty")
|
||||
}
|
||||
key = trimEncKey(key)
|
||||
iv := key[:aes.BlockSize]
|
||||
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
plain := []byte(plaintext)
|
||||
plain = pkcs7Padding(plain, aes.BlockSize)
|
||||
|
||||
ciphertext := make([]byte, len(plain))
|
||||
|
||||
mode := cipher.NewCBCEncrypter(block, []byte(iv))
|
||||
mode.CryptBlocks(ciphertext, plain)
|
||||
|
||||
b64String := base64.StdEncoding.EncodeToString(ciphertext)
|
||||
|
||||
return b64String, nil
|
||||
}
|
||||
|
||||
// DecryptAES256 decrypts the given ciphertext with the given key using AES256 in CBC mode with PKCS7 padding
|
||||
func DecryptAES256(encrypted, key string) (string, error) {
|
||||
if len(encrypted) == 0 {
|
||||
return "", fmt.Errorf("ciphertext must not be empty")
|
||||
}
|
||||
if len(key) == 0 {
|
||||
return "", fmt.Errorf("key must not be empty")
|
||||
}
|
||||
key = trimEncKey(key)
|
||||
iv := key[:aes.BlockSize]
|
||||
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return "", fmt.Errorf("invalid ciphertext length, must be a multiple of %d", aes.BlockSize)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher([]byte(key))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
mode := cipher.NewCBCDecrypter(block, []byte(iv))
|
||||
mode.CryptBlocks(ciphertext, ciphertext)
|
||||
|
||||
ciphertext = pkcs7UnPadding(ciphertext)
|
||||
|
||||
return string(ciphertext), nil
|
||||
}
|
||||
|
||||
func pkcs7Padding(ciphertext []byte, blockSize int) []byte {
|
||||
padding := blockSize - len(ciphertext)%blockSize
|
||||
padtext := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||
return append(ciphertext, padtext...)
|
||||
}
|
||||
|
||||
func pkcs7UnPadding(src []byte) []byte {
|
||||
length := len(src)
|
||||
unpadding := int(src[length-1])
|
||||
return src[:(length - unpadding)]
|
||||
}
|
||||
|
||||
func trimEncKey(key string) string {
|
||||
if len(key) > 32 {
|
||||
return key[:32]
|
||||
}
|
||||
|
||||
if len(key) < 32 {
|
||||
key = key + strings.Repeat("0", 32-len(key))
|
||||
}
|
||||
return key
|
||||
}
|
@@ -174,9 +174,16 @@ func GetConfig() (*Config, error) {
|
||||
|
||||
// override config values from YAML file
|
||||
|
||||
cfgFileName := "config/config.yml"
|
||||
cfgFileName := "config/config.yaml"
|
||||
cfgFileNameFallback := "config/config.yml"
|
||||
if envCfgFileName := os.Getenv("WG_PORTAL_CONFIG"); envCfgFileName != "" {
|
||||
cfgFileName = envCfgFileName
|
||||
cfgFileNameFallback = envCfgFileName
|
||||
}
|
||||
|
||||
// check if the config file exists, otherwise use the fallback file name
|
||||
if _, err := os.Stat(cfgFileName); os.IsNotExist(err) {
|
||||
cfgFileName = cfgFileNameFallback
|
||||
}
|
||||
|
||||
if err := loadConfigFile(cfg, cfgFileName); err != nil {
|
||||
|
@@ -18,11 +18,14 @@ type DatabaseConfig struct {
|
||||
// Debug enables logging of all database statements
|
||||
Debug bool `yaml:"debug"`
|
||||
// SlowQueryThreshold enables logging of slow queries which take longer than the specified duration
|
||||
SlowQueryThreshold time.Duration `yaml:"slow_query_threshold"` // 0 means no logging of slow queries
|
||||
SlowQueryThreshold time.Duration `yaml:"slow_query_threshold"` // "0" means no logging of slow queries
|
||||
// Type is the database type. Supported: mysql, mssql, postgres, sqlite
|
||||
Type SupportedDatabase `yaml:"type"`
|
||||
// DSN is the database connection string.
|
||||
// For SQLite, it is the path to the database file.
|
||||
// For other databases, it is the connection string, see: https://gorm.io/docs/connecting_to_the_database.html
|
||||
DSN string `yaml:"dsn"`
|
||||
// EncryptionPassphrase is the passphrase used to encrypt sensitive data (WireGuard keys) in the database.
|
||||
// If no passphrase is provided, no encryption will be used.
|
||||
EncryptionPassphrase string `yaml:"encryption_passphrase"`
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
PrivateKey string
|
||||
PrivateKey string `gorm:"serializer:encstr"`
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
|
@@ -7,9 +7,9 @@ import (
|
||||
"time"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
|
||||
"github.com/h44z/wg-portal/internal"
|
||||
"github.com/h44z/wg-portal/internal/config"
|
||||
)
|
||||
|
||||
type PeerIdentifier string
|
||||
@@ -36,7 +36,7 @@ type Peer struct {
|
||||
EndpointPublicKey ConfigOption[string] `gorm:"embedded;embeddedPrefix:endpoint_pubkey_"` // the endpoint public key
|
||||
AllowedIPsStr ConfigOption[string] `gorm:"embedded;embeddedPrefix:allowed_ips_str_"` // all allowed ip subnets, comma seperated
|
||||
ExtraAllowedIPsStr string // all allowed ip subnets on the server side, comma seperated
|
||||
PresharedKey PreSharedKey // the pre-shared Key of the peer
|
||||
PresharedKey PreSharedKey `gorm:"serializer:encstr"` // the pre-shared Key of the peer
|
||||
PersistentKeepalive ConfigOption[int] `gorm:"embedded;embeddedPrefix:persistent_keep_alive_"` // the persistent keep-alive interval
|
||||
|
||||
// WG Portal specific
|
||||
|
@@ -65,6 +65,7 @@ nav:
|
||||
- Docker: documentation/getting-started/docker.md
|
||||
- Helm: documentation/getting-started/helm.md
|
||||
- Sources: documentation/getting-started/sources.md
|
||||
- Reverse Proxy (HTTPS): documentation/getting-started/reverse-proxy.md
|
||||
- Configuration:
|
||||
- Overview: documentation/configuration/overview.md
|
||||
- Examples: documentation/configuration/examples.md
|
||||
|
Reference in New Issue
Block a user