Compare commits

...

4 Commits

22 changed files with 557 additions and 27 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ ssh.key
wg_portal.db
sqlite.db
/config.yml
/config.yaml
/config/
venv/
.cache/

View File

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

View File

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

View File

@@ -31,6 +31,7 @@ database:
debug: true
type: sqlite
dsn: data/sqlite.db
encryption_passphrase: change-this-s3cr3t-encryption-passphrase
```
## LDAP Authentication and Synchronization

View File

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

View File

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

View 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 frontend 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 peers public key."
},
"public-key": {
"label": "Public Key",

View File

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

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

View File

@@ -0,0 +1,201 @@
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
}
if !s.useEncryption {
return fieldValue, nil // keep the original value
}
switch v := fieldValue.(type) {
case string:
if v == "" {
return "", nil // empty string, no need to encrypt
}
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
}
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
}

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ import (
)
type KeyPair struct {
PrivateKey string
PrivateKey string `gorm:"serializer:encstr"`
PublicKey string
}

View File

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

View File

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