many more improvements

This commit is contained in:
Christoph Haas 2023-07-21 15:13:00 +02:00
parent 5ec2ad6827
commit 5153f602ab
56 changed files with 1593 additions and 1310 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2020 Christoph Haas
Copyright (c) 2020-2023 Christoph Haas
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the

View File

@ -1,51 +0,0 @@
# WireGuard Portal on Raspberry Pi
This readme only contains a detailed explanation of how to set up the WireGuard Portal service on a raspberry pi (>= 3).
## Setup
You can either download prebuild binaries from the [release page](https://github.com/h44z/wg-portal/releases) or use Docker images for ARM.
If you want to build the binary yourself, use the following building instructions.
### Building
This section describes how to build the WireGuard Portal code.
To compile the final binary, use the Makefile provided in the repository.
As WireGuard Portal is written in Go, **golang >= 1.16** must be installed prior to building.
If you want to cross compile ARM binaries from AMD64 systems, install *arm-linux-gnueabi-gcc* (armv7) or *aarch64-linux-gnu-gcc* (arm64).
```
# for 64 bit OS
make build-arm64
# for 32 bit OS
make build-arm
```
The compiled binary and all necessary assets will be located in the dist folder.
### Service setup
- Copy the contents from the dist folder (or from the downloaded zip file) to `/opt/wg-portal`. You can choose a different path as well, but make sure to update the systemd service file accordingly.
- Update the provided systemd `wg-portal.service` file:
- Make sure that the binary matches the system architecture.
- There are three pre-build binaries available: wg-portal-**amd64**, wg-portal-**arm64** and wg-portal-**arm**.
- For a raspberry pi use the arm binary if you are using armv7l architecture. If armv8 is used, the arm64 version should work.
- Make sure that the paths to the binary and the working directory are set correctly (defaults to /opt/wg-portal/wg-portal-amd64):
- ConditionPathExists
- WorkingDirectory
- ExecStart
- EnvironmentFile
- Update environment variables in the `wg-portal.env` file to fit your needs
- Make sure that the binary application file is executable
- `sudo chmod +x /opt/wg-portal/wg-portal-*`
- Link the system service file to the correct folder:
- `sudo ln -s /opt/wg-portal/wg-portal.service /etc/systemd/system/wg-portal.service`
- Reload the systemctl daemon:
- `sudo systemctl daemon-reload`
### Manage the service
Once the service has been setup, you can simply manage the service using `systemctl`:
- Enable on startup: `systemctl enable wg-portal.service`
- Start: `systemctl start wg-portal.service`
- Stop: `systemctl stop wg-portal.service`
- Status: `systemctl status wg-portal.service`

301
README.md
View File

@ -13,8 +13,7 @@ The portal uses the WireGuard [wgctrl](https://github.com/WireGuard/wgctrl-go) l
interfaces. This allows for seamless activation or deactivation of new users, without disturbing existing VPN
connections.
The configuration portal currently supports using SQLite and MySQL as a user source for authentication and profile data.
It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
The configuration portal supports using a database (SQLite, MySQL, MsSQL or Postgres), OAuth or LDAP (Active Directory or OpenLDAP) as a user source for authentication and profile data.
## Features
* Self-hosted and web based
@ -22,221 +21,117 @@ It also supports LDAP (Active Directory or OpenLDAP) as authentication provider.
* QR-Code for convenient mobile client configuration
* Sent email to client with QR-code and client config
* Enable / Disable clients seamlessly
* Generation of `wgX.conf` after any modification
* Generation of `wgX.conf` if required
* IPv6 ready
* User authentication (SQLite/MySQL and LDAP)
* User authentication (database, OAuth or LDAP)
* Dockerized
* Responsive template
* Responsive web UI written in Vue.JS
* One single binary
* Can be used with existing WireGuard setups
* Support for multiple WireGuard interfaces
* REST API for management and client deployment
* Peer Expiry Feature
* REST API for management and client deployment (coming soon)
![Screenshot](screenshot.png)
## Setup
Make sure that your host system has at least one WireGuard interface (for example wg0) available.
If you did not start up a WireGuard interface yet, take a look at [wg-quick](https://manpages.debian.org/unstable/wireguard-tools/wg-quick.8.en.html) in order to get started.
### Docker
The easiest way to run WireGuard Portal is to use the Docker image provided.
HINT: the *latest* tag always refers to the master branch and might contain unstable or incompatible code!
Docker Compose snippet with some sample configuration values:
```
version: '3.6'
services:
wg-portal:
image: h44z/wg-portal:latest
container_name: wg-portal
restart: unless-stopped
cap_add:
- NET_ADMIN
network_mode: "host"
volumes:
- /etc/wireguard:/etc/wireguard
- ./data:/app/data
ports:
- '8123:8123'
environment:
# WireGuard Settings
- WG_DEVICES=wg0
- WG_DEFAULT_DEVICE=wg0
- WG_CONFIG_PATH=/etc/wireguard
# Core Settings
- EXTERNAL_URL=https://vpn.company.com
- WEBSITE_TITLE=WireGuard VPN
- COMPANY_NAME=Your Company Name
- ADMIN_USER=admin@domain.com
- ADMIN_PASS=supersecret
# Mail Settings
- MAIL_FROM=WireGuard VPN <noreply+wireguard@company.com>
- EMAIL_HOST=10.10.10.10
- EMAIL_PORT=25
# LDAP Settings
- LDAP_ENABLED=true
- LDAP_URL=ldap://srv-ad01.company.local:389
- LDAP_BASEDN=DC=COMPANY,DC=LOCAL
- LDAP_USER=ldap_wireguard@company.local
- LDAP_PASSWORD=supersecretldappassword
- LDAP_ADMIN_GROUP=CN=WireGuardAdmins,OU=Users,DC=COMPANY,DC=LOCAL
```
Please note that mapping ```/etc/wireguard``` to ```/etc/wireguard``` inside the docker, will erase your host's current configuration.
If needed, please make sure to back up your files from ```/etc/wireguard```.
For a full list of configuration options take a look at the source file [internal/server/configuration.go](internal/server/configuration.go#L58).
### Standalone
For a standalone application, use the Makefile provided in the repository to build the application. Go version 1.16 or higher has to be installed to build WireGuard Portal.
```shell
# show all possible make commands
make
# build wg-portal for current system architecture
make build
```
The compiled binary will be located in the dist folder.
A detailed description for using this software with a raspberry pi can be found in the [README-RASPBERRYPI.md](README-RASPBERRYPI.md).
To build the Docker image, Docker (> 20.x) with buildx is required. If you want to build cross-platform images, you need to install qemu.
On arch linux for example install: `docker-buildx qemu-user-static qemu-user-static-binfmt`.
Once the Docker setup is completed, create a new buildx builder:
```shell
docker buildx create --name wgportalbuilder --platform linux/arm/v7,linux/arm64,linux/amd64
docker buildx use wgportalbuilder
docker buildx inspect --bootstrap
```
Now you can compile the Docker image:
```shell
# multi platform build, can only be exported to tar archives
docker buildx build --platform linux/arm/v7,linux/arm64,linux/amd64 --output type=local,dest=docker_images \
--build-arg BUILD_IDENTIFIER=dev --build-arg BUILD_VERSION=0.1 -t h44z/wg-portal .
# image for current platform only (same as docker build)
docker buildx build --load \
--build-arg BUILD_IDENTIFIER=dev --build-arg BUILD_VERSION=0.1 -t h44z/wg-portal .
```
## Configuration
You can configure WireGuard Portal using either environment variables or a yaml configuration file.
You can configure WireGuard Portal using a yaml configuration file.
The filepath of the yaml configuration file defaults to **config.yml** in the working directory of the executable.
It is possible to override the configuration filepath using the environment variable **CONFIG_FILE**.
For example: `CONFIG_FILE=/home/test/config.yml ./wg-portal-amd64`.
It is possible to override the configuration filepath using the environment variable **WG_PORTAL_CONFIG**.
For example: `WG_PORTAL_CONFIG=/home/test/config.yml ./wg-portal-amd64`.
### Configuration Options
The following configuration options are available:
| environment | yaml | yaml_parent | default_value | description |
|----------------------------|-------------------------|-------------|-----------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
| LISTENING_ADDRESS | listeningAddress | core | :8123 | The address on which the web server is listening. Optional IP address and port, e.g.: 127.0.0.1:8080. |
| EXTERNAL_URL | externalUrl | core | http://localhost:8123 | The external URL where the web server is reachable. This link is used in emails that are created by the WireGuard Portal. |
| WEBSITE_TITLE | title | core | WireGuard VPN | The website title. |
| COMPANY_NAME | company | core | WireGuard Portal | The company name (for branding). |
| MAIL_FROM | mailFrom | core | WireGuard VPN <noreply@company.com> | The email address from which emails are sent. |
| LOGO_URL | logoUrl | core | /img/header-logo.png | The logo displayed in the page's header. |
| ADMIN_USER | adminUser | core | admin@wgportal.local | The administrator user. Must be a valid email address. |
| ADMIN_PASS | adminPass | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| EDITABLE_KEYS | editableKeys | core | true | Allow to edit key-pairs in the UI. |
| CREATE_DEFAULT_PEER | createDefaultPeer | core | false | If an LDAP user logs in for the first time, a new WireGuard peer will be created on the WG_DEFAULT_DEVICE if this option is enabled. |
| SELF_PROVISIONING | selfProvisioning | core | false | Allow registered users to automatically create peers via the RESTful API. |
| WG_EXPORTER_FRIENDLY_NAMES | wgExporterFriendlyNames | core | false | Enable integration with [prometheus_wireguard_exporter friendly name](https://github.com/MindFlavor/prometheus_wireguard_exporter#friendly-tags). |
| LDAP_ENABLED | ldapEnabled | core | false | Enable or disable the LDAP backend. |
| SESSION_SECRET | sessionSecret | core | secret | Use a custom secret to encrypt session data. |
| BACKGROUND_TASK_INTERVAL | backgroundTaskInterval | core | 900 | The interval (in seconds) for the background tasks (like peer expiry check). |
| EXPIRY_REENABLE | expiryReEnable | core | false | Reactivate expired peers if the expiration date is in the future. |
| DATABASE_TYPE | typ | database | sqlite | Either mysql or sqlite. |
| DATABASE_HOST | host | database | | The mysql server address. |
| DATABASE_PORT | port | database | | The mysql server port. |
| DATABASE_NAME | database | database | data/wg_portal.db | For sqlite database: the database file-path, otherwise the database name. |
| DATABASE_USERNAME | user | database | | The mysql user. |
| DATABASE_PASSWORD | password | database | | The mysql password. |
| EMAIL_HOST | host | email | 127.0.0.1 | The email server address. |
| EMAIL_PORT | port | email | 25 | The email server port. |
| EMAIL_TLS | tls | email | false | Use STARTTLS. DEPRECATED: use EMAIL_ENCRYPTION instead. |
| EMAIL_ENCRYPTION | encryption | email | none | Either none, tls or starttls. |
| EMAIL_CERT_VALIDATION | certcheck | email | false | Validate the email server certificate. |
| EMAIL_USERNAME | user | email | | An optional username for SMTP authentication. |
| EMAIL_PASSWORD | pass | email | | An optional password for SMTP authentication. |
| EMAIL_AUTHTYPE | auth | email | plain | Either plain, login or crammd5. If username and password are empty, this value is ignored. |
| WG_DEVICES | devices | wg | wg0 | A comma separated list of WireGuard devices. |
| WG_DEFAULT_DEVICE | defaultDevice | wg | wg0 | This device is used for auto-created peers (if CREATE_DEFAULT_PEER is enabled). |
| WG_CONFIG_PATH | configDirectory | wg | /etc/wireguard | If set, interface configuration updates will be written to this path, filename: <devicename>.conf. |
| MANAGE_IPS | manageIPAddresses | wg | true | Handle IP address setup of interface, only available on linux. |
| USER_MANAGE_PEERS | userManagePeers | wg | false | Logged in user can create or update peers (partially). |
| LDAP_URL | url | ldap | ldap://srv-ad01.company.local:389 | The LDAP server url. |
| LDAP_STARTTLS | startTLS | ldap | true | Use STARTTLS. |
| LDAP_CERT_VALIDATION | certcheck | ldap | false | Validate the LDAP server certificate. |
| LDAP_BASEDN | dn | ldap | DC=COMPANY,DC=LOCAL | The base DN for searching users. |
| LDAP_USER | user | ldap | company\\\\ldap_wireguard | The bind user. |
| LDAP_PASSWORD | pass | ldap | SuperSecret | The bind password. |
| LDAP_LOGIN_FILTER | loginFilter | ldap | (&(objectClass=organizationalPerson)(mail={{login_identifier}})(!userAccountControl:1.2.840.113556.1.4.803:=2)) | {{login_identifier}} will be replaced with the login email address. |
| LDAP_SYNC_FILTER | syncFilter | ldap | (&(objectClass=organizationalPerson)(!userAccountControl:1.2.840.113556.1.4.803:=2)(mail=*)) | The filter string for the LDAP synchronization service. Users matching this filter will be synchronized with the WireGuard Portal database. |
| LDAP_SYNC_GROUP_FILTER | syncGroupFilter | ldap | | The filter string for the LDAP groups, for example: (objectClass=group). The groups are used to recursively check for admin group member ship of users. |
| LDAP_ADMIN_GROUP | adminGroup | ldap | CN=WireGuardAdmins,OU=_O_IT,DC=COMPANY,DC=LOCAL | Users in this group are marked as administrators. |
| LDAP_ATTR_EMAIL | attrEmail | ldap | mail | User email attribute. |
| LDAP_ATTR_FIRSTNAME | attrFirstname | ldap | givenName | User firstname attribute. |
| LDAP_ATTR_LASTNAME | attrLastname | ldap | sn | User lastname attribute. |
| LDAP_ATTR_PHONE | attrPhone | ldap | telephoneNumber | User phone number attribute. |
| LDAP_ATTR_GROUPS | attrGroups | ldap | memberOf | User groups attribute. |
| LDAP_CERT_CONN | ldapCertConn | ldap | false | Allow connection with certificate against LDAP server without user/password |
| LDAPTLS_CERT | ldapTlsCert | ldap | | The LDAP cert's path |
| LDAPTLS_KEY | ldapTlsKey | ldap | | The LDAP key's path |
| LOG_LEVEL | | | debug | Specify log level, one of: trace, debug, info, off. |
| LOG_JSON | | | false | Format log output as JSON. |
| LOG_COLOR | | | true | Colorize log output. |
| CONFIG_FILE | | | config.yml | The config file path. |
### Sample yaml configuration
config.yml:
```yaml
core:
listeningAddress: :8123
externalUrl: https://wg-test.test.com
adminUser: test@test.com
adminPass: test
editableKeys: true
createDefaultPeer: false
ldapEnabled: true
mailFrom: WireGuard VPN <noreply@test.com>
ldap:
url: ldap://10.10.10.10:389
dn: DC=test,DC=test
startTLS: false
user: wireguard@test.test
pass: test
adminGroup: CN=WireGuardAdmins,CN=Users,DC=test,DC=test
database:
typ: sqlite
database: data/wg_portal.db
email:
host: smtp.gmail.com
port: 587
tls: true
user: test@gmail.com
pass: topsecret
wg:
devices:
- wg0
- wg1
defaultDevice: wg0
configDirectory: /etc/wireguard
manageIPAddresses: true
```
### RESTful API
WireGuard Portal offers a RESTful API to interact with.
The API is documented using OpenAPI 2.0, the Swagger UI can be found
under the URL `http://<your wg-portal ip/domain>/swagger/index.html?displayOperationId=true`.
The [API's unittesting](tests/test_API.py) may serve as an example how to make use of the API with python3 & pyswagger.
| configuration key | parent key | default_value | description |
|---------------------------|------------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| admin_user | core | admin@wgportal.local | The administrator user. This user will be created as default admin if it does not yet exist. |
| admin_password | core | wgportal | The administrator password. If unchanged, a random password will be set on first startup. |
| editable_keys | core | true | Allow to edit key-pairs in the UI. |
| create_default_peer | core | false | If an LDAP user logs in for the first time, a new WireGuard peer will be created on the WG_DEFAULT_DEVICE if this option is enabled. |
| self_provisioning_allowed | core | false | Allow registered users to automatically create peers via their profile page. |
| import_existing | core | true | Import existing WireGuard interfaces and peers into WireGuard Portal. |
| restore_state | core | true | Restore the WireGuard interface state after WireGuard Portal has started. |
| log_level | advanced | warn | The loglevel, can be one of: trace, debug, info, warn, error. |
| log_pretty | advanced | false | Uses pretty, colorized log messages. |
| log_json | advanced | false | Logs in JSON format. |
| ldap_sync_interval | advanced | 15m | |
| start_listen_port | advanced | 51820 | |
| start_cidr_v4 | advanced | 10.11.12.0/24 | |
| start_cidr_v6 | advanced | fdfd:d3ad:c0de:1234::0/64 | |
| use_ip_v6 | advanced | true | |
| config_storage_path | advanced | | |
| expiry_check_interval | advanced | 15m | |
| use_ping_checks | statistics | true | |
| ping_check_workers | statistics | 10 | |
| ping_unprivileged | statistics | false | |
| ping_check_interval | statistics | 1m | |
| data_collection_interval | statistics | 10m | |
| collect_interface_data | statistics | true | |
| collect_peer_data | statistics | true | |
| collect_audit_data | statistics | true | |
| host | mail | 127.0.0.1 | |
| port | mail | 25 | |
| encryption | mail | none | |
| cert_validation | mail | false | |
| username | mail | | |
| password | mail | | |
| auth_type | mail | plain | |
| from | mail | Wireguard Portal <noreply@wireguard.local> | |
| link_only | mail | false | |
| callback_url_prefix | auth | /api/v0 | |
| oidc | auth | Empty Array - no providers configured | |
| oauth | auth | Empty Array - no providers configured | |
| ldap | auth | Empty Array - no providers configured | |
| provider_name | auth/oidc | | |
| display_name | auth/oidc | | |
| base_url | auth/oidc | | |
| client_id | auth/oidc | | |
| client_secret | auth/oidc | | |
| extra_scopes | auth/oidc | | |
| field_map | auth/oidc | | |
| registration_enabled | auth/oidc | | |
| provider_name | auth/oidc | | |
| display_name | auth/oauth | | |
| base_url | auth/oauth | | |
| client_id | auth/oauth | | |
| client_secret | auth/oauth | | |
| auth_url | auth/oauth | | |
| token_url | auth/oauth | | |
| redirect_url | auth/oauth | | |
| user_info_url | auth/oauth | | |
| scopes | auth/oauth | | |
| field_map | auth/oauth | | |
| registration_enabled | auth/oauth | | |
| url | auth/ldap | | |
| start_tls | auth/ldap | | |
| cert_validation | auth/ldap | | |
| tls_certificate_path | auth/ldap | | |
| tls_key_path | auth/ldap | | |
| base_dn | auth/ldap | | |
| bind_user | auth/ldap | | |
| bind_pass | auth/ldap | | |
| field_map | auth/ldap | | |
| login_filter | auth/ldap | | |
| admin_group | auth/ldap | | |
| synchronize | auth/ldap | | |
| disable_missing | auth/ldap | | |
| sync_filter | auth/ldap | | |
| registration_enabled | auth/ldap | | |
| debug | database | false | |
| slow_query_threshold | database | | |
| type | database | sqlite | |
| dsn | database | sqlite.db | |
| request_logging | web | false | |
| external_url | web | http://localhost:8888 | |
| listening_address | web | :8888 | |
| session_identifier | web | wgPortalSession | |
| session_secret | web | very_secret | |
| csrf_secret | web | extremely_secret | |
| site_title | web | WireGuard Portal | |
| site_company_name | web | WireGuard Portal | |
## What is out of scope
* Creating or removing WireGuard (wgX) interfaces.
* Generation or application of any `iptables` or `nftables` rules.
* Setting up or changing IP-addresses of the WireGuard interface on operating systems other than linux.
* Importing private keys of an existing WireGuard setup.
@ -244,13 +139,9 @@ The [API's unittesting](tests/test_API.py) may serve as an example how to make u
## Application stack
* [Gin, HTTP web framework written in Go](https://github.com/gin-gonic/gin)
* [go-template, data-driven templates for generating textual output](https://golang.org/pkg/text/template/)
* [Bootstrap, for the HTML templates](https://getbootstrap.com/)
* [JQuery, for some nice JavaScript effects ;)](https://jquery.com/)
* [Vue.JS, for the frontend](hhttps://vuejs.org/)
## License
* MIT License. [MIT](LICENSE.txt) or https://opensource.org/licenses/MIT
This project was inspired by [wg-gen-web](https://github.com/vx3r/wg-gen-web).

View File

@ -4,6 +4,7 @@ import (
"context"
"github.com/h44z/wg-portal/internal/app/api/core"
handlersV0 "github.com/h44z/wg-portal/internal/app/api/v0/handlers"
"github.com/h44z/wg-portal/internal/app/audit"
"github.com/h44z/wg-portal/internal/app/auth"
"github.com/h44z/wg-portal/internal/app/configfile"
"github.com/h44z/wg-portal/internal/app/mail"
@ -31,6 +32,8 @@ func main() {
internal.AssertNoError(err)
setupLogging(cfg)
cfg.LogStartupValues()
rawDb, err := adapters.NewDatabase(cfg.Database)
internal.AssertNoError(err)
@ -73,6 +76,10 @@ func main() {
mailManager, err := mail.NewMailManager(cfg, mailer, cfgFileManager, database, database)
internal.AssertNoError(err)
auditRecorder, err := audit.NewAuditRecorder(cfg, eventBus, database)
internal.AssertNoError(err)
auditRecorder.StartBackgroundJobs(ctx)
backend, err := app.New(cfg, eventBus, authenticator, userManager, wireGuardManager,
statisticsCollector, cfgFileManager, mailManager)
internal.AssertNoError(err)
@ -111,4 +118,16 @@ func setupLogging(cfg *config.Config) {
default:
logrus.SetLevel(logrus.WarnLevel)
}
switch {
case cfg.Advanced.LogJson:
logrus.SetFormatter(&logrus.JSONFormatter{
PrettyPrint: cfg.Advanced.LogPretty,
})
case cfg.Advanced.LogPretty:
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
DisableColors: false,
})
}
}

View File

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

View File

@ -1,12 +1,14 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router';
import {computed, getCurrentInstance, onMounted} from "vue";
import {computed, getCurrentInstance, onMounted, ref} from "vue";
import {authStore} from "./stores/auth";
import {securityStore} from "./stores/security";
import {settingsStore} from "@/stores/settings";
const appGlobal = getCurrentInstance().appContext.config.globalProperties
const auth = authStore()
const sec = securityStore()
const settings = settingsStore()
onMounted(async () => {
console.log("Starting WireGuard Portal frontend...");
@ -17,8 +19,12 @@ onMounted(async () => {
let wasLoggedIn = auth.IsAuthenticated;
try {
await auth.LoadSession();
await settings.LoadSettings(); // only logs errors, does not throw
console.log("WireGuard Portal session is valid");
} catch (e) {
if (wasLoggedIn) {
console.log("WireGuard Portal invalid - logging out");
await auth.Logout();
}
}
@ -41,6 +47,11 @@ const languageFlag = computed(() => {
}
return "fi-" + lang;
})
const companyName = ref(WGPORTAL_SITE_COMPANY_NAME);
const wgVersion = ref(WGPORTAL_VERSION);
const currentYear = ref(new Date().getFullYear())
</script>
<template>
@ -96,7 +107,7 @@ const languageFlag = computed(() => {
<footer class="page-footer mt-auto">
<div class="container mt-5">
<div class="row align-items-center">
<div class="col-6">Powered by <img alt="Vue.JS" height="20" src="@/assets/logo.svg" /></div>
<div class="col-6">Copyright © {{ companyName }} {{ currentYear }} <span v-if="auth.IsAuthenticated"> - version {{ wgVersion }}</span></div>
<div class="col-6 text-end">
<div aria-label="{{ $t('menu.lang') }}" class="btn-group" role="group">
<div class="btn-group" role="group">
@ -115,7 +126,4 @@ const languageFlag = computed(() => {
</template>
<style>
.vue-notification-group {
margin-top:5px;
}
</style>

View File

@ -238,8 +238,8 @@ async function save() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to save interface!",
title: "Failed to save interface!",
text: e.toString(),
type: 'error',
})
}
@ -263,8 +263,8 @@ async function applyPeerDefaults() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to apply peer defaults!",
title: "Failed to apply peer defaults!",
text: e.toString(),
type: 'error',
})
}
@ -277,8 +277,8 @@ async function del() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to delete interface!",
title: "Failed to delete interface!",
text: e.toString(),
type: 'error',
})
}
@ -508,23 +508,5 @@ async function del() {
</template>
<style>
.config-qr-img {
max-width: 100%;
}
.v3ti .v3ti-tag {
background: #fff;
color: #343a40;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0;
}
.v3ti .v3ti-tag .v3ti-remove-tag {
color: #343a40;
transition: color .3s;
}
a.v3ti-remove-tag {
cursor: pointer;
text-decoration: none;
}
</style>

View File

@ -169,6 +169,13 @@ watch(() => formData.value.IgnoreGlobalSettings, async (newValue, oldValue) => {
}
)
watch(() => formData.value.Disabled, async (newValue, oldValue) => {
if (oldValue && !newValue && formData.value.ExpiresAt) {
formData.value.ExpiresAt = "" // reset expiry date
}
}
)
function close() {
formData.value = freshPeer()
emit('close')
@ -257,8 +264,8 @@ async function save() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to save peer!",
title: "Failed to save peer!",
text: e.toString(),
type: 'error',
})
}
@ -271,8 +278,8 @@ async function del() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to delete peer!",
title: "Failed to delete peer!",
text: e.toString(),
type: 'error',
})
}

View File

@ -2,7 +2,7 @@
import Modal from "./Modal.vue";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {computed, ref, watch} from "vue";
import {computed, ref} from "vue";
import { useI18n } from 'vue-i18n';
import { notify } from "@kyvg/vue3-notification";
import Vue3TagsInput from "vue3-tags-input";
@ -63,8 +63,8 @@ async function save() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to create peers!",
title: "Failed to create peers!",
text: e.toString(),
type: 'error',
})
}

View File

@ -7,9 +7,11 @@ import {useI18n} from "vue-i18n";
import {freshInterface, freshPeer, freshStats} from '@/helpers/models';
import Prism from "vue-prism-component";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const { t } = useI18n()
const settings = settingsStore()
const peers = peerStore()
const interfaces = interfaceStore()
@ -101,10 +103,10 @@ function download() {
}
function email() {
peers.MailPeerConfig(false, [selectedPeer.value.Identifier]).catch(error => {
peers.MailPeerConfig(settings.Setting("MailLinkOnly"), [selectedPeer.value.Identifier]).catch(e => {
notify({
title: "Peer email failure",
text: "Failed to send mail with peer configuration!",
title: "Failed to send mail with peer configuration!",
text: e.toString(),
type: 'error',
})
})

View File

@ -40,7 +40,6 @@ const userPeers = computed(() => {
watch(() => props.visible, async (newValue, oldValue) => {
if (oldValue === false && newValue === true) { // if modal is shown
console.log(selectedUser.value)
await users.LoadUserPeers(selectedUser.value.Identifier)
}
}

View File

@ -1,5 +1,5 @@
import { authStore } from '../stores/auth';
import { securityStore } from '../stores/security';
import { authStore } from '@/stores/auth';
import { securityStore } from '@/stores/security';
export const fetchWrapper = {
url: apiUrl(),

View File

@ -4,7 +4,6 @@ import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { createI18n } from "vue-i18n";
import i18n from "./lang";
import Notifications from '@kyvg/vue3-notification'

View File

@ -3,7 +3,7 @@ import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import InterfaceView from '../views/InterfaceView.vue'
import {authStore} from '../stores/auth.js'
import {authStore} from '@/stores/auth'
import {notify} from "@kyvg/vue3-notification";
const router = createRouter({

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '../helpers/fetch-wrapper.js'
import { apiWrapper } from '@/helpers/fetch-wrapper'
import router from '../router'
export const authStore = defineStore({

View File

@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import {apiWrapper} from "../helpers/fetch-wrapper";
import {apiWrapper} from "@/helpers/fetch-wrapper";
import {notify} from "@kyvg/vue3-notification";
import {interfaceStore} from "./interfaces";
import {freshPeer, freshStats} from '@/helpers/models';

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '../helpers/fetch-wrapper.js'
import { apiWrapper } from '@/helpers/fetch-wrapper'
export const securityStore = defineStore({
id: 'security',

View File

@ -0,0 +1,36 @@
import { defineStore } from 'pinia'
import { notify } from "@kyvg/vue3-notification";
import { apiWrapper } from '@/helpers/fetch-wrapper'
const baseUrl = `/config`
export const settingsStore = defineStore({
id: 'settings',
state: () => ({
settings: {},
}),
getters: {
Setting: (state) => {
return (key) => (key in state.settings) ? state.settings[key] : undefined
}
},
actions: {
setSettings(settings) {
this.settings = settings
},
// LoadSecurityProperties always returns a fulfilled promise, even if the request failed.
async LoadSettings() {
await apiWrapper.get(`${baseUrl}/settings`)
.then(data => this.setSettings(data))
.catch(error => {
this.setSettings({});
console.log("Failed to load settings: ", error);
notify({
title: "Backend Connection Failure",
text: "Failed to load settings!",
});
})
}
}
});

View File

@ -1,5 +1,5 @@
<script setup>
import {authStore} from "../stores/auth";
import {authStore} from "@/stores/auth";
import {RouterLink} from "vue-router";
const auth = authStore()

View File

@ -6,10 +6,12 @@ import InterfaceEditModal from "../components/InterfaceEditModal.vue";
import InterfaceViewModal from "../components/InterfaceViewModal.vue";
import {onMounted, ref} from "vue";
import {peerStore} from "../stores/peers";
import {interfaceStore} from "../stores/interfaces";
import {peerStore} from "@/stores/peers";
import {interfaceStore} from "@/stores/interfaces";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const interfaces = interfaceStore()
const peers = peerStore()
@ -57,8 +59,8 @@ async function saveConfig() {
} catch (e) {
console.log(e)
notify({
title: "Backend Connection Failure",
text: "Failed to persist interface configuration file!",
title: "Failed to persist interface configuration file!",
text: e.toString(),
type: 'error',
})
}
@ -124,7 +126,7 @@ onMounted(async () => {
<div class="col-12 col-lg-4 text-lg-end">
<a class="btn-link" href="#" title="Show interface configuration" @click.prevent="viewedInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-eye"></i></a>
<a class="ms-5 btn-link" href="#" title="Download interface configuration" @click.prevent="download"><i class="fas fa-download"></i></a>
<a class="ms-5 btn-link" href="#" title="Write interface configuration file" @click.prevent="saveConfig"><i class="fas fa-save"></i></a>
<a v-if="settings.Setting('PersistentConfigSupported')" class="ms-5 btn-link" href="#" title="Write interface configuration file" @click.prevent="saveConfig"><i class="fas fa-save"></i></a>
<a class="ms-5 btn-link" href="#" title="Edit interface settings" @click.prevent="editInterfaceId=interfaces.GetSelected.Identifier"><i class="fas fa-cog"></i></a>
</div>
</div>

View File

@ -1,11 +1,13 @@
<script setup>
import {computed, ref} from "vue";
import {authStore} from "../stores/auth";
import {authStore} from "@/stores/auth";
import router from '../router/index.js'
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const auth = authStore()
const settings = settingsStore()
const loggingIn = ref(false)
const username = ref("")
@ -26,6 +28,7 @@ const login = async function () {
type: 'success',
});
loggingIn.value = false;
settings.LoadSettings(); // only logs errors, does not throw
router.push(auth.ReturnUrl);
})
.catch(error => {

View File

@ -1,9 +1,12 @@
<script setup>
import PeerViewModal from "../components/PeerViewModal.vue";
import {computed, onMounted, ref} from "vue";
import {onMounted, ref} from "vue";
import {profileStore} from "@/stores/profile";
import PeerEditModal from "@/components/PeerEditModal.vue";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const profile = profileStore()
const viewedPeerId = ref("")
@ -18,6 +21,7 @@ onMounted(async () => {
<template>
<PeerViewModal :peerId="viewedPeerId" :visible="viewedPeerId!==''" @close="viewedPeerId=''"></PeerViewModal>
<PeerEditModal :peerId="editPeerId" :visible="editPeerId!==''" @close="editPeerId=''"></PeerEditModal>
<!-- Peer list -->
<div class="mt-4 row">
@ -33,7 +37,7 @@ onMounted(async () => {
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<a class="btn btn-primary ms-2" href="#" title="Add a peer" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
<a v-if="settings.Setting('SelfProvisioning')" class="btn btn-primary ms-2" href="#" title="Add a peer" @click.prevent="editPeerId='#NEW#'"><i class="fa fa-plus me-1"></i><i class="fa fa-user"></i></a>
</div>
</div>
<div class="mt-2 table-responsive">

View File

@ -1,10 +1,12 @@
<script setup>
import {userStore} from "../stores/users";
import {userStore} from "@/stores/users";
import {ref,onMounted} from "vue";
import UserEditModal from "../components/UserEditModal.vue";
import UserViewModal from "../components/UserViewModal.vue";
import {notify} from "@kyvg/vue3-notification";
import {settingsStore} from "@/stores/settings";
const settings = settingsStore()
const users = userStore()
const editUserId = ref("")
@ -45,9 +47,6 @@ function editUser(user) {
</div>
</div>
<div class="col-12 col-lg-3 text-lg-end">
<!--a class="btn btn-primary" href="#" title="Send mail to selected users"><i class="fa fa-paper-plane"></i></a-->
<!--a class="btn btn-primary ms-2" href="#" title="Add multiple users"><i class="fa fa-plus me-1"></i><i
class="fa fa-users"></i></a-->
<a class="btn btn-primary ms-2" href="#" title="Add a user" @click.prevent="editUserId='#NEW#'"><i class="fa fa-plus me-1"></i><i
class="fa fa-user"></i></a>
</div>

2
go.mod
View File

@ -9,8 +9,6 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.8.0
github.com/go-ldap/ldap/v3 v3.4.5
github.com/h44z/lightmigrate v1.0.0
github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1
github.com/prometheus-community/pro-bing v0.3.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4

4
go.sum
View File

@ -117,10 +117,6 @@ github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/h44z/lightmigrate v1.0.0 h1:wvkXvySwUTUuEMx0MAZaXzLa8vqspy0g5KwVfqLnpWQ=
github.com/h44z/lightmigrate v1.0.0/go.mod h1:2QbrB1JaoGU+2kWOqf98jeULUSzJtdxovMYbdCwPyaE=
github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1 h1:HyKr9mclK5tvGlpipJTG6unzaJs8Jhh/WhcjpBJZM2s=
github.com/h44z/lightmigrate-mysql v0.0.0-20220114152421-d1fec9d056f1/go.mod h1:xjqn0LXf6ly5GLm+6FDGi3+S20gDSExNYAm0k95rFzo=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=

View File

@ -2,7 +2,6 @@ package adapters
import (
"context"
"embed"
"errors"
"fmt"
"github.com/sirupsen/logrus"
@ -15,8 +14,6 @@ import (
"time"
"github.com/glebarez/sqlite"
"github.com/h44z/lightmigrate"
"github.com/h44z/lightmigrate-mysql/mysql"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
gormMySQL "gorm.io/driver/mysql"
@ -25,10 +22,13 @@ import (
"gorm.io/gorm"
)
//go:embed migrations/*.sql
var sqlMigrationFs embed.FS
var SchemaVersion uint64 = 1
type SysStat struct {
MigratedAt time.Time `gorm:"column:migrated_at"`
SchemaVersion uint64 `gorm:"primaryKey,column:schema_version"`
}
// GormLogger is a custom logger for Gorm, making it use logrus.
type GormLogger struct {
SlowThreshold time.Duration
@ -84,7 +84,7 @@ func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (stri
}
if l.Debug {
logrus.WithContext(ctx).WithFields(fields).Debugf("%s", sql)
logrus.WithContext(ctx).WithFields(fields).Tracef("%s", sql)
}
}
@ -161,39 +161,25 @@ func NewSqlRepository(db *gorm.DB) (*SqlRepo, error) {
}
func (r *SqlRepo) migrate() error {
// TODO: REMOVE
logrus.Debugf("user migration: %v", r.db.AutoMigrate(&domain.User{}))
logrus.Debugf("interface migration: %v", r.db.AutoMigrate(&domain.Interface{}))
logrus.Debugf("peer migration: %v", r.db.AutoMigrate(&domain.Peer{}))
logrus.Debugf("peer status migration: %v", r.db.AutoMigrate(&domain.PeerStatus{}))
logrus.Debugf("interface status migration: %v", r.db.AutoMigrate(&domain.InterfaceStatus{}))
// TODO: REMOVE THE ABOVE LINES
logrus.Tracef("sysstat migration: %v", r.db.AutoMigrate(&SysStat{}))
logrus.Tracef("user migration: %v", r.db.AutoMigrate(&domain.User{}))
logrus.Tracef("interface migration: %v", r.db.AutoMigrate(&domain.Interface{}))
logrus.Tracef("peer migration: %v", r.db.AutoMigrate(&domain.Peer{}))
logrus.Tracef("peer status migration: %v", r.db.AutoMigrate(&domain.PeerStatus{}))
logrus.Tracef("interface status migration: %v", r.db.AutoMigrate(&domain.InterfaceStatus{}))
logrus.Tracef("audit data migration: %v", r.db.AutoMigrate(&domain.AuditEntry{}))
rawDb, err := r.db.DB()
if err != nil {
return fmt.Errorf("failed to get raw db handle: %w", err)
}
driver, err := mysql.NewDriver(rawDb, "migration_test_db", mysql.WithLocking(false)) // without locking, the mysql driver also works for sqlite =)
if err != nil {
return fmt.Errorf("unable to setup driver: %w", err)
}
defer driver.Close()
source, err := lightmigrate.NewFsSource(sqlMigrationFs, "migrations")
if err != nil {
return fmt.Errorf("failed to open migration source fs: %w", err)
}
defer source.Close()
migrator, err := lightmigrate.NewMigrator(source, driver, lightmigrate.WithVerboseLogging(true))
if err != nil {
return fmt.Errorf("unable to setup migrator: %w", err)
}
err = migrator.Migrate(SchemaVersion)
if err != nil && !errors.Is(err, lightmigrate.ErrNoChange) {
return fmt.Errorf("failed to migrate database schema: %w", err)
existingSysStat := SysStat{}
r.db.Where("schema_version = ?", SchemaVersion).First(&existingSysStat)
if existingSysStat.SchemaVersion == 0 {
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)
}
logrus.Debugf("sysstat entry for schema version %d written", SchemaVersion)
}
return nil
@ -817,3 +803,16 @@ func (r *SqlRepo) upsertPeerStatus(tx *gorm.DB, in *domain.PeerStatus) error {
}
// endregion statistics
// region audit
func (r *SqlRepo) SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry) error {
err := r.db.WithContext(ctx).Save(entry).Error
if err != nil {
return err
}
return nil
}
// endregion audit

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600&display=swap";.modal.show{display:block}.modal.show{opacity:1}.modal-backdrop{background-color:#0009!important}.modal-backdrop.show{opacity:1!important}.config-qr-img{max-width:100%}.v3ti .v3ti-tag{background:#fff;color:#343a40;border:1px solid rgba(0,0,0,.1);border-radius:0}.v3ti .v3ti-tag .v3ti-remove-tag{color:#343a40;transition:color .3s}a.v3ti-remove-tag{cursor:pointer;text-decoration:none}.vue-notification-group{margin-top:5px}/*!
@charset "UTF-8";@import"https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;600&display=swap";.modal.show{display:block}.modal.show{opacity:1}.modal-backdrop{background-color:#0009!important}.modal-backdrop.show{opacity:1!important}.config-qr-img{max-width:100%}/*!
* Bootswatch v5.3.0 (https://bootswatch.com)
* Theme: lux
* Copyright 2012-2023 Thomas Park

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 308 B

View File

@ -9,10 +9,13 @@
<script>
// global config, will be overridden by backend if available
let WGPORTAL_BACKEND_BASE_URL="http://localhost:5000/api/v0";
let WGPORTAL_VERSION="unknown";
let WGPORTAL_SITE_TITLE="WireGuard Portal";
let WGPORTAL_SITE_COMPANY_NAME="WireGuard Portal";
</script>
<script src="/api/v0/config/frontend.js"></script>
<script type="module" crossorigin src="/app/assets/index-01f1dd15.js"></script>
<link rel="stylesheet" href="/app/assets/index-a233ff7e.css">
<script type="module" crossorigin src="/app/assets/index-4f7c99b3.js"></script>
<link rel="stylesheet" href="/app/assets/index-7144f109.css">
</head>
<body class="d-flex flex-column min-vh-100">
<noscript>

View File

@ -6,6 +6,7 @@ import (
"fmt"
"github.com/gin-gonic/gin"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/app/api/v0/model"
"html/template"
"net"
"net/http"
@ -40,6 +41,7 @@ func (e configEndpoint) RegisterRoutes(g *gin.RouterGroup, authenticator *authen
apiGroup := g.Group("/config")
apiGroup.GET("/frontend.js", e.handleConfigJsGet())
apiGroup.GET("/settings", e.authenticator.LoggedIn(), e.handleSettingsGet())
}
// handleConfigJsGet returns a gorm handler function.
@ -65,7 +67,10 @@ func (e configEndpoint) handleConfigJsGet() gin.HandlerFunc {
}
buf := &bytes.Buffer{}
err := e.tpl.ExecuteTemplate(buf, "frontend_config.js.gotpl", gin.H{
"BackendUrl": backendUrl,
"BackendUrl": backendUrl,
"Version": "unknown",
"SiteTitle": e.app.Config.Web.SiteTitle,
"SiteCompanyName": e.app.Config.Web.SiteCompanyName,
})
if err != nil {
c.Status(http.StatusInternalServerError)
@ -75,3 +80,22 @@ func (e configEndpoint) handleConfigJsGet() gin.HandlerFunc {
c.Data(http.StatusOK, "application/javascript", buf.Bytes())
}
}
// handleSettingsGet returns a gorm handler function.
//
// @ID config_handleSettingsGet
// @Tags Configuration
// @Summary Get the frontend settings object.
// @Produce json
// @Success 200 {object} model.Settings
// @Success 200 string javascript "The JavaScript contents"
// @Router /config/settings [get]
func (e configEndpoint) handleSettingsGet() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, model.Settings{
MailLinkOnly: e.app.Config.Mail.LinkOnly,
PersistentConfigSupported: e.app.Config.Advanced.ConfigStoragePath != "",
SelfProvisioning: e.app.Config.Core.SelfProvisioningAllowed,
})
}
}

View File

@ -311,7 +311,7 @@ func (e interfaceEndpoint) handleDelete() gin.HandlerFunc {
// @Router /interface/{id}/save-config [post]
func (e interfaceEndpoint) handleSaveConfigPost() gin.HandlerFunc {
return func(c *gin.Context) {
//ctx := domain.SetUserInfoFromGin(c)
ctx := domain.SetUserInfoFromGin(c)
id := Base64UrlDecode(c.Param("id"))
if id == "" {
@ -319,7 +319,13 @@ func (e interfaceEndpoint) handleSaveConfigPost() gin.HandlerFunc {
return
}
// TODO: implement
err := e.app.PersistInterfaceConfig(ctx, domain.InterfaceIdentifier(id))
if err != nil {
c.JSON(http.StatusInternalServerError, model.Error{
Code: http.StatusInternalServerError, Message: err.Error(),
})
return
}
c.Status(http.StatusNoContent)
}

View File

@ -1 +1,6 @@
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
WGPORTAL_BACKEND_BASE_URL="{{ $.BackendUrl }}";
WGPORTAL_VERSION="{{ $.Version }}";
WGPORTAL_SITE_TITLE="{{ $.SiteTitle }}";
WGPORTAL_SITE_COMPANY_NAME="{{ $.SiteCompanyName }}";
document.title = "{{ $.SiteTitle }}";

View File

@ -4,3 +4,9 @@ type Error struct {
Code int `json:"Code"`
Message string `json:"Message"`
}
type Settings struct {
MailLinkOnly bool `json:"MailLinkOnly"`
PersistentConfigSupported bool `json:"PersistentConfigSupported"`
SelfProvisioning bool `json:"SelfProvisioning"`
}

View File

@ -14,7 +14,7 @@ type ExpiryDate struct {
// UnmarshalJSON will unmarshal using 2006-01-02 layout
func (d *ExpiryDate) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
if len(b) == 0 || string(b) == "null" || string(b) == "\"\"" {
return nil
}
parsed, err := time.Parse(ExpiryDateTimeLayout, string(b))

View File

@ -61,6 +61,7 @@ func New(cfg *config.Config, bus evbus.MessageBus, authenticator Authenticator,
func (a *App) Startup(ctx context.Context) error {
a.UserManager.StartBackgroundJobs(ctx)
a.StatisticsCollector.StartBackgroundJobs(ctx)
a.WireGuardManager.StartBackgroundJobs(ctx)
return nil
}

View File

@ -0,0 +1,81 @@
package audit
import (
"context"
"fmt"
"github.com/h44z/wg-portal/internal/app"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus"
evbus "github.com/vardius/message-bus"
"time"
)
type Recorder struct {
cfg *config.Config
bus evbus.MessageBus
db DatabaseRepo
}
func NewAuditRecorder(cfg *config.Config, bus evbus.MessageBus, db DatabaseRepo) (*Recorder, error) {
r := &Recorder{
cfg: cfg,
bus: bus,
db: db,
}
err := r.connectToMessageBus()
if err != nil {
return nil, fmt.Errorf("failed to setup message bus: %w", err)
}
return r, nil
}
func (r *Recorder) StartBackgroundJobs(ctx context.Context) {
if !r.cfg.Statistics.CollectAuditData {
return // noting to do
}
go func() {
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(1 * time.Hour):
// select blocks until one of the cases evaluate to true
}
logrus.Tracef("registered %d audit message within the last hour", 0) // TODO: implement
}
}()
}
func (r *Recorder) connectToMessageBus() error {
if !r.cfg.Statistics.CollectAuditData {
return nil // noting to do
}
if err := r.bus.Subscribe(app.TopicAuthLogin, r.authLoginEvent); err != nil {
return fmt.Errorf("failed to subscribe to %s: %w", app.TopicAuthLogin, err)
}
return nil
}
func (r *Recorder) authLoginEvent(userIdentifier domain.UserIdentifier) {
err := r.db.SaveAuditEntry(context.Background(), &domain.AuditEntry{
CreatedAt: time.Time{},
Severity: domain.AuditSeverityLevelLow,
Origin: "authLoginEvent",
Message: fmt.Sprintf("user %s logged in", userIdentifier),
})
if err != nil {
logrus.Errorf("failed to create audit entry for handleAuthLoginEvent: %v", err)
return
}
}

View File

@ -0,0 +1,10 @@
package audit
import (
"context"
"github.com/h44z/wg-portal/internal/domain"
)
type DatabaseRepo interface {
SaveAuditEntry(ctx context.Context, entry *domain.AuditEntry) error
}

View File

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"github.com/h44z/wg-portal/internal/app"
"github.com/sirupsen/logrus"
"io"
"net/url"
"path"
@ -190,15 +191,21 @@ func (a *Authenticator) passwordAuthentication(ctx context.Context, identifier d
existingUser, err := a.users.GetUser(ctx, identifier)
if err == nil {
userInDatabase = true
userSource = domain.UserSourceDatabase
} else {
userSource = existingUser.Source
}
if !userInDatabase || userSource == domain.UserSourceLdap {
// search user in ldap if registration is enabled
for _, ldapAuth := range a.ldapAuthenticators {
if !ldapAuth.RegistrationEnabled() {
if !userInDatabase && !ldapAuth.RegistrationEnabled() {
continue
}
rawUserInfo, err := ldapAuth.GetUserInfo(context.Background(), identifier)
if err != nil {
if !errors.Is(err, domain.ErrNotFound) {
logrus.Warnf("failed to fetch ldap user info for %s: %v", identifier, err)
}
continue // user not found / other ldap error
}
ldapUserInfo, err = ldapAuth.ParseUserInfo(rawUserInfo)

View File

@ -7,8 +7,11 @@ import (
"fmt"
"github.com/h44z/wg-portal/internal/config"
"github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus"
"github.com/yeqown/go-qrcode/v2"
"io"
"os"
"path/filepath"
"strings"
)
@ -34,9 +37,27 @@ func NewConfigFileManager(cfg *config.Config, users UserDatabaseRepo, wg Wiregua
wg: wg,
}
if err := m.createStorageDirectory(); err != nil {
return nil, err
}
return m, nil
}
func (m Manager) createStorageDirectory() error {
if m.cfg.Advanced.ConfigStoragePath == "" {
return nil // no storage path configured, skip initialization step
}
err := os.MkdirAll(m.cfg.Advanced.ConfigStoragePath, os.ModePerm)
if err != nil {
return fmt.Errorf("failed to create configuration storage path %s: %w",
m.cfg.Advanced.ConfigStoragePath, err)
}
return nil
}
func (m Manager) GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error) {
iface, peers, err := m.wg.GetInterfaceAndPeers(ctx, id)
if err != nil {
@ -100,6 +121,40 @@ func (m Manager) GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifi
return buf, nil
}
func (m Manager) PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error {
if m.cfg.Advanced.ConfigStoragePath == "" {
return fmt.Errorf("peristing configuration is not supported")
}
iface, peers, err := m.wg.GetInterfaceAndPeers(ctx, id)
if err != nil {
return fmt.Errorf("failed to fetch interface %s: %w", id, err)
}
cfg, err := m.tplHandler.GetInterfaceConfig(iface, peers)
if err != nil {
return fmt.Errorf("failed to get interface config: %w", err)
}
file, err := os.Create(filepath.Join(m.cfg.Advanced.ConfigStoragePath, iface.GetConfigFileName()))
if err != nil {
return fmt.Errorf("failed to create interface config file: %w", err)
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
logrus.Warn("failed to close interface config file: %v", err)
}
}(file)
_, err = io.Copy(file, cfg)
if err != nil {
return fmt.Errorf("failed to write interface config: %w", err)
}
return nil
}
type nopCloser struct {
io.Writer
}

View File

@ -26,6 +26,7 @@ type UserManager interface {
}
type WireGuardManager interface {
StartBackgroundJobs(ctx context.Context)
GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error)
ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) error
RestoreInterfaceState(ctx context.Context, updateDbOnError bool, filter ...domain.InterfaceIdentifier) error
@ -56,6 +57,7 @@ type ConfigFileManager interface {
GetInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) (io.Reader, error)
GetPeerConfig(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
GetPeerConfigQrCode(ctx context.Context, id domain.PeerIdentifier) (io.Reader, error)
PersistInterfaceConfig(ctx context.Context, id domain.InterfaceIdentifier) error
}
type MailManager interface {

View File

@ -2,13 +2,9 @@ package wireguard
import (
"context"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal/app"
"time"
"github.com/h44z/wg-portal/internal"
"github.com/sirupsen/logrus"
"time"
evbus "github.com/vardius/message-bus"
@ -37,906 +33,69 @@ func NewWireGuardManager(cfg *config.Config, bus evbus.MessageBus, wg InterfaceC
return m, nil
}
func (m Manager) StartBackgroundJobs(ctx context.Context) {
go m.runExpiredPeersCheck(ctx)
}
func (m Manager) connectToMessageBus() {
_ = m.bus.Subscribe(app.TopicUserCreated, m.handleUserCreationEvent)
}
func (m Manager) handleUserCreationEvent(user *domain.User) {
logrus.Errorf("Handling new user event for %s", user.Identifier)
logrus.Errorf("handling new user event for %s", user.Identifier)
err := m.CreateDefaultPeer(context.Background(), user)
if err != nil {
logrus.Errorf("Failed to create default peer")
logrus.Errorf("failed to create default peer for %s: %v", user.Identifier, err)
return
}
}
func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return nil, err
}
func (m Manager) runExpiredPeersCheck(ctx context.Context) {
ctx = domain.SetUserInfo(ctx, domain.SystemAdminContextUserInfo())
return physicalInterfaces, nil
}
running := true
for running {
select {
case <-ctx.Done():
running = false
continue
case <-time.After(m.cfg.Advanced.ExpiryCheckInterval):
// select blocks until one of the cases evaluate to true
}
func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) error {
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return err
}
// if no filter is given, exclude already existing interfaces
var excludedInterfaces []domain.InterfaceIdentifier
if len(filter) == 0 {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return err
}
for _, existingInterface := range existingInterfaces {
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
}
}
for _, physicalInterface := range physicalInterfaces {
if internal.SliceContains(excludedInterfaces, physicalInterface.Identifier) {
logrus.Errorf("failed to fetch all interfaces for expiry check: %v", err)
continue
}
if len(filter) != 0 && !internal.SliceContains(filter, physicalInterface.Identifier) {
continue
}
logrus.Infof("importing new interface %s...", physicalInterface.Identifier)
physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier)
if err != nil {
return err
}
err = m.importInterface(ctx, &physicalInterface, physicalPeers)
if err != nil {
return fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
}
logrus.Infof("imported new interface %s and %d peers", physicalInterface.Identifier, len(physicalPeers))
}
return nil
}
func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error {
now := time.Now()
iface := domain.ConvertPhysicalInterface(in)
iface.BaseModel = domain.BaseModel{
CreatedBy: "importer",
UpdatedBy: "importer",
CreatedAt: now,
UpdatedAt: now,
}
iface.PeerDefAllowedIPsStr = iface.AddressStr()
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
if existingInterface != nil {
return errors.New("interface already exists")
}
err = m.db.SaveInterface(ctx, iface.Identifier, func(_ *domain.Interface) (*domain.Interface, error) {
return iface, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
// import peers
for _, peer := range peers {
err = m.importPeer(ctx, iface, &peer)
if err != nil {
return fmt.Errorf("import of peer %s failed: %w", peer.Identifier, err)
}
}
return nil
}
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
now := time.Now()
peer := domain.ConvertPhysicalPeer(p)
peer.BaseModel = domain.BaseModel{
CreatedBy: "importer",
UpdatedBy: "importer",
CreatedAt: now,
UpdatedAt: now,
}
peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.StringConfigOption{Value: in.PublicKey, Overridable: true}
peer.AllowedIPsStr = domain.StringConfigOption{Value: in.PeerDefAllowedIPsStr, Overridable: true}
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's
peer.Interface.DnsStr = domain.StringConfigOption{Value: in.PeerDefDnsStr, Overridable: true}
peer.Interface.DnsSearchStr = domain.StringConfigOption{Value: in.PeerDefDnsSearchStr, Overridable: true}
peer.Interface.Mtu = domain.IntConfigOption{Value: in.PeerDefMtu, Overridable: true}
peer.Interface.FirewallMark = domain.Int32ConfigOption{Value: in.PeerDefFirewallMark, Overridable: true}
peer.Interface.RoutingTable = domain.StringConfigOption{Value: in.PeerDefRoutingTable, Overridable: true}
peer.Interface.PreUp = domain.StringConfigOption{Value: in.PeerDefPreUp, Overridable: true}
peer.Interface.PostUp = domain.StringConfigOption{Value: in.PeerDefPostUp, Overridable: true}
peer.Interface.PreDown = domain.StringConfigOption{Value: in.PeerDefPreDown, Overridable: true}
peer.Interface.PostDown = domain.StringConfigOption{Value: in.PeerDefPostDown, Overridable: true}
switch in.Type {
case domain.InterfaceTypeAny:
peer.Interface.Type = domain.InterfaceTypeAny
peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
}
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
return peer, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
return nil
}
func (m Manager) RestoreInterfaceState(ctx context.Context, updateDbOnError bool, filter ...domain.InterfaceIdentifier) error {
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return err
}
for _, iface := range interfaces {
if len(filter) != 0 && !internal.SliceContains(filter, iface.Identifier) {
continue
}
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
}
physicalInterface, err := m.wg.GetInterface(ctx, iface.Identifier)
if err != nil {
// try to create a new interface
err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, &iface)
return pi, nil
})
for _, iface := range interfaces {
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface exists
_ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = "no physical interface available"
return in, nil
})
}
return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
logrus.Errorf("failed to fetch all peers from interface %s for expiry check: %v", iface.Identifier, err)
continue
}
// restore peers
for _, peer := range peers {
err := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil
})
if err != nil {
return fmt.Errorf("failed to create physical peer %s: %w", peer.Identifier, err)
}
}
} else {
if physicalInterface.DeviceUp != !iface.IsDisabled() {
// try to move interface to stored state
err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.DeviceUp = !iface.IsDisabled()
m.checkExpiredPeers(ctx, peers)
}
}
}
return pi, nil
})
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface is available
_ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
if iface.IsDisabled() {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = "no physical interface active"
} else {
in.Disabled = nil
in.DisabledReason = ""
}
return in, nil
})
}
return fmt.Errorf("failed to change physical interface state for %s: %w", iface.Identifier, err)
}
func (m Manager) checkExpiredPeers(ctx context.Context, peers []domain.Peer) {
now := time.Now()
for _, peer := range peers {
if peer.IsExpired() && !peer.IsDisabled() {
logrus.Infof("peer %s has expired, disabling...", peer.Identifier)
peer.Disabled = &now
peer.DisabledReason = domain.DisabledReasonExpired
_, err := m.UpdatePeer(ctx, &peer)
if err != nil {
logrus.Errorf("failed to update expired peer %s: %v", peer.Identifier, err)
}
}
}
return nil
}
func (m Manager) CreateDefaultPeer(ctx context.Context, user *domain.User) error {
// TODO: implement
return fmt.Errorf("IMPLEMENT ME")
}
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
return m.db.GetInterfaceAndPeers(ctx, id)
}
func (m Manager) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
return m.db.GetAllInterfaces(ctx)
}
func (m Manager) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
return m.db.GetUserPeers(ctx, id)
}
func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error) {
currentUser := domain.GetUserInfo(ctx)
kp, err := domain.NewFreshKeypair()
if err != nil {
return nil, fmt.Errorf("failed to generate keys: %w", err)
}
id, err := m.getNewInterfaceName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new identifier: %w", err)
}
ipv4, ipv6, err := m.getFreshInterfaceIpConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new ip config: %w", err)
}
port, err := m.getFreshListenPort(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new listen port: %w", err)
}
ips := []domain.Cidr{ipv4}
if m.cfg.Advanced.UseIpV6 {
ips = append(ips, ipv6)
}
networks := []domain.Cidr{ipv4.NetworkAddr()}
if m.cfg.Advanced.UseIpV6 {
networks = append(networks, ipv6.NetworkAddr())
}
freshInterface := &domain.Interface{
BaseModel: domain.BaseModel{
CreatedBy: string(currentUser.Id),
UpdatedBy: string(currentUser.Id),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
KeyPair: kp,
ListenPort: port,
Addresses: ips,
DnsStr: "",
DnsSearchStr: "",
Mtu: 1420,
FirewallMark: 0,
RoutingTable: "",
PreUp: "",
PostUp: "",
PreDown: "",
PostDown: "",
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id),
Type: domain.InterfaceTypeServer,
DriverType: "",
Disabled: nil,
DisabledReason: "",
PeerDefNetworkStr: domain.CidrsToString(networks),
PeerDefDnsStr: "",
PeerDefDnsSearchStr: "",
PeerDefEndpoint: "",
PeerDefAllowedIPsStr: domain.CidrsToString(networks),
PeerDefMtu: 1420,
PeerDefPersistentKeepalive: 16,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
}
return freshInterface, nil
}
func (m Manager) getNewInterfaceName(ctx context.Context) (domain.InterfaceIdentifier, error) {
namePrefix := "wg"
nameSuffix := 0
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return "", err
}
var name domain.InterfaceIdentifier
for {
name = domain.InterfaceIdentifier(fmt.Sprintf("%s%d", namePrefix, nameSuffix))
conflict := false
for _, in := range existingInterfaces {
if in.Identifier == name {
conflict = true
break
}
}
if !conflict {
break
}
nameSuffix++
}
return name, nil
}
func (m Manager) getFreshInterfaceIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) {
ips, err := m.db.GetInterfaceIps(ctx)
if err != nil {
err = fmt.Errorf("failed to get existing IP addresses: %w", err)
return
}
useV6 := m.cfg.Advanced.UseIpV6
ipV4, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV4)
ipV6, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV6)
netV4 := ipV4.NetworkAddr()
netV6 := ipV6.NetworkAddr()
for {
v4Conflict := false
v6Conflict := false
for _, usedIps := range ips {
for _, usedIp := range usedIps {
usedNetwork := usedIp.NetworkAddr()
if netV4 == usedNetwork {
v4Conflict = true
}
if netV6 == usedNetwork {
v6Conflict = true
}
}
}
if !v4Conflict && (!useV6 || !v6Conflict) {
break
}
if v4Conflict {
netV4 = netV4.NextSubnet()
}
if v6Conflict && useV6 {
netV6 = netV6.NextSubnet()
}
if !netV4.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv4 space exhausted")
}
if useV6 && !netV6.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv6 space exhausted")
}
}
// use first address in network for interface
ipV4 = netV4.NextAddr()
ipV6 = netV6.NextAddr()
return
}
func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return -1, err
}
port = m.cfg.Advanced.StartListenPort
for {
conflict := false
for _, in := range existingInterfaces {
if in.ListenPort == port {
conflict = true
break
}
}
if !conflict {
break
}
port++
}
if port > 65535 { // maximum allowed port number (16 bit uint)
return -1, fmt.Errorf("port space exhausted")
}
return
}
func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if existingInterface != nil {
return nil, fmt.Errorf("interface %s already exists", in.Identifier)
}
if err := m.validateInterfaceCreation(ctx, existingInterface, in); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
in.CopyCalculatedAttributes(i)
err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, in)
return pi, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create physical interface %s: %w", in.Identifier, err)
}
return in, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
return in, nil
}
func (m Manager) UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
in.CopyCalculatedAttributes(i)
err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, in)
return pi, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update physical interface %s: %w", in.Identifier, err)
}
return in, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
return in, nil
}
func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
existingInterface, err := m.db.GetInterface(ctx, id)
if err != nil {
return fmt.Errorf("unable to find interface %s: %w", id, err)
}
if err := m.validateInterfaceDeletion(ctx, existingInterface); err != nil {
return fmt.Errorf("deletion not allowed: %w", err)
}
err = m.deleteInterfacePeers(ctx, id)
if err != nil {
return fmt.Errorf("peer deletion failure: %w", err)
}
err = m.wg.DeleteInterface(ctx, id)
if err != nil {
return fmt.Errorf("wireguard deletion failure: %w", err)
}
err = m.db.DeleteInterface(ctx, id)
if err != nil {
return fmt.Errorf("deletion failure: %w", err)
}
return nil
}
func (m Manager) validateInterfaceModifications(ctx context.Context, old, new *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validateInterfaceCreation(ctx context.Context, old, new *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid interface identifier")
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validateInterfaceDeletion(ctx context.Context, del *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
allPeers, err := m.db.GetInterfacePeers(ctx, id)
if err != nil {
return err
}
for _, peer := range allPeers {
err = m.wg.DeletePeer(ctx, id, peer.Identifier)
if err != nil {
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
}
err = m.db.DeletePeer(ctx, peer.Identifier)
if err != nil {
return fmt.Errorf("peer deletion failure for %s: %w", peer.Identifier, err)
}
}
return nil
}
func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error) {
currentUser := domain.GetUserInfo(ctx)
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
}
ips, err := m.getFreshPeerIpConfig(ctx, iface)
if err != nil {
return nil, fmt.Errorf("unable to get fresh ip addresses: %w", err)
}
kp, err := domain.NewFreshKeypair()
if err != nil {
return nil, fmt.Errorf("failed to generate keys: %w", err)
}
pk, err := domain.NewPreSharedKey()
if err != nil {
return nil, fmt.Errorf("failed to generate preshared key: %w", err)
}
peerMode := domain.InterfaceTypeClient
if iface.Type == domain.InterfaceTypeClient {
peerMode = domain.InterfaceTypeServer
}
peerId := domain.PeerIdentifier(kp.PublicKey)
freshPeer := &domain.Peer{
BaseModel: domain.BaseModel{
CreatedBy: string(currentUser.Id),
UpdatedBy: string(currentUser.Id),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Endpoint: domain.NewStringConfigOption(iface.PeerDefEndpoint, true),
EndpointPublicKey: domain.NewStringConfigOption(iface.PublicKey, true),
AllowedIPsStr: domain.NewStringConfigOption(iface.PeerDefAllowedIPsStr, true),
ExtraAllowedIPsStr: "",
PresharedKey: pk,
PersistentKeepalive: domain.NewIntConfigOption(iface.PeerDefPersistentKeepalive, true),
DisplayName: fmt.Sprintf("Peer %s", peerId[0:8]),
Identifier: peerId,
UserIdentifier: currentUser.Id,
InterfaceIdentifier: iface.Identifier,
Disabled: nil,
DisabledReason: "",
ExpiresAt: nil,
Notes: "",
Interface: domain.PeerInterfaceConfig{
KeyPair: kp,
Type: peerMode,
Addresses: ips,
CheckAliveAddress: "",
DnsStr: domain.NewStringConfigOption(iface.PeerDefDnsStr, true),
DnsSearchStr: domain.NewStringConfigOption(iface.PeerDefDnsSearchStr, true),
Mtu: domain.NewIntConfigOption(iface.PeerDefMtu, true),
FirewallMark: domain.NewInt32ConfigOption(iface.PeerDefFirewallMark, true),
RoutingTable: domain.NewStringConfigOption(iface.PeerDefRoutingTable, true),
PreUp: domain.NewStringConfigOption(iface.PeerDefPreUp, true),
PostUp: domain.NewStringConfigOption(iface.PeerDefPostUp, true),
PreDown: domain.NewStringConfigOption(iface.PeerDefPreUp, true),
PostDown: domain.NewStringConfigOption(iface.PeerDefPostUp, true),
},
}
return freshPeer, nil
}
func (m Manager) getFreshPeerIpConfig(ctx context.Context, iface *domain.Interface) (ips []domain.Cidr, err error) {
networks, err := domain.CidrsFromString(iface.PeerDefNetworkStr)
if err != nil {
err = fmt.Errorf("failed to parse default network address: %w", err)
return
}
existingIps, err := m.db.GetUsedIpsPerSubnet(ctx)
if err != nil {
err = fmt.Errorf("failed to get existing IP addresses: %w", err)
return
}
for _, network := range networks {
ip := network.NextAddr()
for {
ipConflict := false
for _, usedIp := range existingIps[network] {
if usedIp == ip {
ipConflict = true
}
}
if !ipConflict {
break
}
ip = ip.NextAddr()
if !ip.IsValid() {
return nil, fmt.Errorf("ip space on subnet %s is exhausted", network.String())
}
}
ips = append(ips, ip)
}
return
}
func (m Manager) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
peer, err := m.db.GetPeer(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find peer %s: %w", id, err)
}
return peer, nil
}
func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if existingPeer != nil {
return nil, fmt.Errorf("peer %s already exists", peer.Identifier)
}
if err := m.validatePeerCreation(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p)
err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer)
return pp, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
return peer, nil
}
func (m Manager) CreateMultiplePeers(ctx context.Context, interfaceId domain.InterfaceIdentifier, r *domain.PeerCreationRequest) ([]domain.Peer, error) {
var newPeers []domain.Peer
for _, id := range r.Identifiers {
freshPeer, err := m.PreparePeer(ctx, interfaceId)
if err != nil {
return nil, fmt.Errorf("failed to prepare peer for interface %s: %w", interfaceId, err)
}
freshPeer.UserIdentifier = domain.UserIdentifier(id) // use id as user identifier. peers are allowed to have invalid user identifiers
if r.Suffix != "" {
freshPeer.DisplayName += " " + r.Suffix
}
newPeers = append(newPeers, *freshPeer)
}
for i, peer := range newPeers {
_, err := m.CreatePeer(ctx, &newPeers[i])
if err != nil {
return nil, fmt.Errorf("failed to create peer %s (uid: %s) for interface %s: %w", peer.Identifier, peer.UserIdentifier, interfaceId, err)
}
}
return newPeers, nil
}
func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p)
err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer)
return pp, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
return peer, nil
}
func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error {
peer, err := m.db.GetPeer(ctx, id)
if err != nil {
return fmt.Errorf("unable to find peer %s: %w", id, err)
}
err = m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, id)
if err != nil {
return fmt.Errorf("wireguard failed to delete peer %s: %w", id, err)
}
err = m.db.DeletePeer(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete peer %s: %w", id, err)
}
return nil
}
func (m Manager) validatePeerModifications(ctx context.Context, old, new *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validatePeerCreation(ctx context.Context, old, new *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid peer identifier")
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validatePeerDeletion(ctx context.Context, del *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
_, peers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for interface %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}
func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error) {
peers, err := m.db.GetUserPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}
func (m Manager) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return fmt.Errorf("update not allowed: %w", err)
}
peers, err := m.db.GetInterfacePeers(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("failed to find peers for interface %s: %w", in.Identifier, err)
}
for i := range peers {
(&peers[i]).ApplyInterfaceDefaults(in)
_, err := m.UpdatePeer(ctx, &peers[i])
if err != nil {
return fmt.Errorf("failed to apply interface defaults to peer %s: %w", peers[i].Identifier, err)
}
}
return nil
}

View File

@ -0,0 +1,606 @@
package wireguard
import (
"context"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal"
"github.com/h44z/wg-portal/internal/domain"
"github.com/sirupsen/logrus"
"time"
)
func (m Manager) GetImportableInterfaces(ctx context.Context) ([]domain.PhysicalInterface, error) {
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return nil, err
}
return physicalInterfaces, nil
}
func (m Manager) GetInterfaceAndPeers(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Interface, []domain.Peer, error) {
return m.db.GetInterfaceAndPeers(ctx, id)
}
func (m Manager) GetAllInterfaces(ctx context.Context) ([]domain.Interface, error) {
return m.db.GetAllInterfaces(ctx)
}
func (m Manager) ImportNewInterfaces(ctx context.Context, filter ...domain.InterfaceIdentifier) error {
physicalInterfaces, err := m.wg.GetInterfaces(ctx)
if err != nil {
return err
}
// if no filter is given, exclude already existing interfaces
var excludedInterfaces []domain.InterfaceIdentifier
if len(filter) == 0 {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return err
}
for _, existingInterface := range existingInterfaces {
excludedInterfaces = append(excludedInterfaces, existingInterface.Identifier)
}
}
for _, physicalInterface := range physicalInterfaces {
if internal.SliceContains(excludedInterfaces, physicalInterface.Identifier) {
continue
}
if len(filter) != 0 && !internal.SliceContains(filter, physicalInterface.Identifier) {
continue
}
logrus.Infof("importing new interface %s...", physicalInterface.Identifier)
physicalPeers, err := m.wg.GetPeers(ctx, physicalInterface.Identifier)
if err != nil {
return err
}
err = m.importInterface(ctx, &physicalInterface, physicalPeers)
if err != nil {
return fmt.Errorf("import of %s failed: %w", physicalInterface.Identifier, err)
}
logrus.Infof("imported new interface %s and %d peers", physicalInterface.Identifier, len(physicalPeers))
}
return nil
}
func (m Manager) ApplyPeerDefaults(ctx context.Context, in *domain.Interface) error {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return fmt.Errorf("update not allowed: %w", err)
}
peers, err := m.db.GetInterfacePeers(ctx, in.Identifier)
if err != nil {
return fmt.Errorf("failed to find peers for interface %s: %w", in.Identifier, err)
}
for i := range peers {
(&peers[i]).ApplyInterfaceDefaults(in)
_, err := m.UpdatePeer(ctx, &peers[i])
if err != nil {
return fmt.Errorf("failed to apply interface defaults to peer %s: %w", peers[i].Identifier, err)
}
}
return nil
}
func (m Manager) RestoreInterfaceState(ctx context.Context, updateDbOnError bool, filter ...domain.InterfaceIdentifier) error {
interfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return err
}
for _, iface := range interfaces {
if len(filter) != 0 && !internal.SliceContains(filter, iface.Identifier) {
continue
}
peers, err := m.db.GetInterfacePeers(ctx, iface.Identifier)
if err != nil {
return fmt.Errorf("failed to load peers for %s: %w", iface.Identifier, err)
}
physicalInterface, err := m.wg.GetInterface(ctx, iface.Identifier)
if err != nil {
// try to create a new interface
err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, &iface)
return pi, nil
})
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface exists
_ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = "no physical interface available"
return in, nil
})
}
return fmt.Errorf("failed to create physical interface %s: %w", iface.Identifier, err)
}
// restore peers
for _, peer := range peers {
err := m.wg.SavePeer(ctx, iface.Identifier, peer.Identifier, func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, &peer)
return pp, nil
})
if err != nil {
return fmt.Errorf("failed to create physical peer %s: %w", peer.Identifier, err)
}
}
} else {
if physicalInterface.DeviceUp != !iface.IsDisabled() {
// try to move interface to stored state
err := m.wg.SaveInterface(ctx, iface.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
pi.DeviceUp = !iface.IsDisabled()
return pi, nil
})
if err != nil {
if updateDbOnError {
// disable interface in database as no physical interface is available
_ = m.db.SaveInterface(ctx, iface.Identifier, func(in *domain.Interface) (*domain.Interface, error) {
if iface.IsDisabled() {
now := time.Now()
in.Disabled = &now // set
in.DisabledReason = "no physical interface active"
} else {
in.Disabled = nil
in.DisabledReason = ""
}
return in, nil
})
}
return fmt.Errorf("failed to change physical interface state for %s: %w", iface.Identifier, err)
}
}
}
}
return nil
}
func (m Manager) PrepareInterface(ctx context.Context) (*domain.Interface, error) {
currentUser := domain.GetUserInfo(ctx)
kp, err := domain.NewFreshKeypair()
if err != nil {
return nil, fmt.Errorf("failed to generate keys: %w", err)
}
id, err := m.getNewInterfaceName(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new identifier: %w", err)
}
ipv4, ipv6, err := m.getFreshInterfaceIpConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new ip config: %w", err)
}
port, err := m.getFreshListenPort(ctx)
if err != nil {
return nil, fmt.Errorf("failed to generate new listen port: %w", err)
}
ips := []domain.Cidr{ipv4}
if m.cfg.Advanced.UseIpV6 {
ips = append(ips, ipv6)
}
networks := []domain.Cidr{ipv4.NetworkAddr()}
if m.cfg.Advanced.UseIpV6 {
networks = append(networks, ipv6.NetworkAddr())
}
freshInterface := &domain.Interface{
BaseModel: domain.BaseModel{
CreatedBy: string(currentUser.Id),
UpdatedBy: string(currentUser.Id),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Identifier: id,
KeyPair: kp,
ListenPort: port,
Addresses: ips,
DnsStr: "",
DnsSearchStr: "",
Mtu: 1420,
FirewallMark: 0,
RoutingTable: "",
PreUp: "",
PostUp: "",
PreDown: "",
PostDown: "",
SaveConfig: m.cfg.Advanced.ConfigStoragePath != "",
DisplayName: string(id),
Type: domain.InterfaceTypeServer,
DriverType: "",
Disabled: nil,
DisabledReason: "",
PeerDefNetworkStr: domain.CidrsToString(networks),
PeerDefDnsStr: "",
PeerDefDnsSearchStr: "",
PeerDefEndpoint: "",
PeerDefAllowedIPsStr: domain.CidrsToString(networks),
PeerDefMtu: 1420,
PeerDefPersistentKeepalive: 16,
PeerDefFirewallMark: 0,
PeerDefRoutingTable: "",
PeerDefPreUp: "",
PeerDefPostUp: "",
PeerDefPreDown: "",
PeerDefPostDown: "",
}
return freshInterface, nil
}
func (m Manager) CreateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if existingInterface != nil {
return nil, fmt.Errorf("interface %s already exists", in.Identifier)
}
if err := m.validateInterfaceCreation(ctx, existingInterface, in); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
in.CopyCalculatedAttributes(i)
err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, in)
return pi, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create physical interface %s: %w", in.Identifier, err)
}
return in, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
return in, nil
}
func (m Manager) UpdateInterface(ctx context.Context, in *domain.Interface) (*domain.Interface, error) {
existingInterface, err := m.db.GetInterface(ctx, in.Identifier)
if err != nil {
return nil, fmt.Errorf("unable to load existing interface %s: %w", in.Identifier, err)
}
if err := m.validateInterfaceModifications(ctx, existingInterface, in); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
err = m.db.SaveInterface(ctx, in.Identifier, func(i *domain.Interface) (*domain.Interface, error) {
in.CopyCalculatedAttributes(i)
err = m.wg.SaveInterface(ctx, in.Identifier, func(pi *domain.PhysicalInterface) (*domain.PhysicalInterface, error) {
domain.MergeToPhysicalInterface(pi, in)
return pi, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update physical interface %s: %w", in.Identifier, err)
}
return in, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
return in, nil
}
func (m Manager) DeleteInterface(ctx context.Context, id domain.InterfaceIdentifier) error {
existingInterface, err := m.db.GetInterface(ctx, id)
if err != nil {
return fmt.Errorf("unable to find interface %s: %w", id, err)
}
if err := m.validateInterfaceDeletion(ctx, existingInterface); err != nil {
return fmt.Errorf("deletion not allowed: %w", err)
}
err = m.deleteInterfacePeers(ctx, id)
if err != nil {
return fmt.Errorf("peer deletion failure: %w", err)
}
err = m.wg.DeleteInterface(ctx, id)
if err != nil {
return fmt.Errorf("wireguard deletion failure: %w", err)
}
err = m.db.DeleteInterface(ctx, id)
if err != nil {
return fmt.Errorf("deletion failure: %w", err)
}
return nil
}
// region helper-functions
func (m Manager) getNewInterfaceName(ctx context.Context) (domain.InterfaceIdentifier, error) {
namePrefix := "wg"
nameSuffix := 0
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return "", err
}
var name domain.InterfaceIdentifier
for {
name = domain.InterfaceIdentifier(fmt.Sprintf("%s%d", namePrefix, nameSuffix))
conflict := false
for _, in := range existingInterfaces {
if in.Identifier == name {
conflict = true
break
}
}
if !conflict {
break
}
nameSuffix++
}
return name, nil
}
func (m Manager) getFreshInterfaceIpConfig(ctx context.Context) (ipV4, ipV6 domain.Cidr, err error) {
ips, err := m.db.GetInterfaceIps(ctx)
if err != nil {
err = fmt.Errorf("failed to get existing IP addresses: %w", err)
return
}
useV6 := m.cfg.Advanced.UseIpV6
ipV4, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV4)
ipV6, _ = domain.CidrFromString(m.cfg.Advanced.StartCidrV6)
ipV4 = ipV4.FirstAddr()
ipV6 = ipV6.FirstAddr()
netV4 := ipV4.NetworkAddr()
netV6 := ipV6.NetworkAddr()
for {
v4Conflict := false
v6Conflict := false
for _, usedIps := range ips {
for _, usedIp := range usedIps {
usedNetwork := usedIp.NetworkAddr()
if netV4 == usedNetwork {
v4Conflict = true
}
if netV6 == usedNetwork {
v6Conflict = true
}
}
}
if !v4Conflict && (!useV6 || !v6Conflict) {
break
}
if v4Conflict {
netV4 = netV4.NextSubnet()
}
if v6Conflict && useV6 {
netV6 = netV6.NextSubnet()
}
if !netV4.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv4 space exhausted")
}
if useV6 && !netV6.IsValid() {
return domain.Cidr{}, domain.Cidr{}, fmt.Errorf("IPv6 space exhausted")
}
}
// use first address in network for interface
ipV4 = netV4.NextAddr()
ipV6 = netV6.NextAddr()
return
}
func (m Manager) getFreshListenPort(ctx context.Context) (port int, err error) {
existingInterfaces, err := m.db.GetAllInterfaces(ctx)
if err != nil {
return -1, err
}
port = m.cfg.Advanced.StartListenPort
for {
conflict := false
for _, in := range existingInterfaces {
if in.ListenPort == port {
conflict = true
break
}
}
if !conflict {
break
}
port++
}
if port > 65535 { // maximum allowed port number (16 bit uint)
return -1, fmt.Errorf("port space exhausted")
}
return
}
func (m Manager) importInterface(ctx context.Context, in *domain.PhysicalInterface, peers []domain.PhysicalPeer) error {
now := time.Now()
iface := domain.ConvertPhysicalInterface(in)
iface.BaseModel = domain.BaseModel{
CreatedBy: "importer",
UpdatedBy: "importer",
CreatedAt: now,
UpdatedAt: now,
}
iface.PeerDefAllowedIPsStr = iface.AddressStr()
existingInterface, err := m.db.GetInterface(ctx, iface.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
if existingInterface != nil {
return errors.New("interface already exists")
}
err = m.db.SaveInterface(ctx, iface.Identifier, func(_ *domain.Interface) (*domain.Interface, error) {
return iface, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
// import peers
for _, peer := range peers {
err = m.importPeer(ctx, iface, &peer)
if err != nil {
return fmt.Errorf("import of peer %s failed: %w", peer.Identifier, err)
}
}
return nil
}
func (m Manager) importPeer(ctx context.Context, in *domain.Interface, p *domain.PhysicalPeer) error {
now := time.Now()
peer := domain.ConvertPhysicalPeer(p)
peer.BaseModel = domain.BaseModel{
CreatedBy: "importer",
UpdatedBy: "importer",
CreatedAt: now,
UpdatedAt: now,
}
peer.InterfaceIdentifier = in.Identifier
peer.EndpointPublicKey = domain.StringConfigOption{Value: in.PublicKey, Overridable: true}
peer.AllowedIPsStr = domain.StringConfigOption{Value: in.PeerDefAllowedIPsStr, Overridable: true}
peer.Interface.Addresses = p.AllowedIPs // use allowed IP's as the peer IP's
peer.Interface.DnsStr = domain.StringConfigOption{Value: in.PeerDefDnsStr, Overridable: true}
peer.Interface.DnsSearchStr = domain.StringConfigOption{Value: in.PeerDefDnsSearchStr, Overridable: true}
peer.Interface.Mtu = domain.IntConfigOption{Value: in.PeerDefMtu, Overridable: true}
peer.Interface.FirewallMark = domain.Int32ConfigOption{Value: in.PeerDefFirewallMark, Overridable: true}
peer.Interface.RoutingTable = domain.StringConfigOption{Value: in.PeerDefRoutingTable, Overridable: true}
peer.Interface.PreUp = domain.StringConfigOption{Value: in.PeerDefPreUp, Overridable: true}
peer.Interface.PostUp = domain.StringConfigOption{Value: in.PeerDefPostUp, Overridable: true}
peer.Interface.PreDown = domain.StringConfigOption{Value: in.PeerDefPreDown, Overridable: true}
peer.Interface.PostDown = domain.StringConfigOption{Value: in.PeerDefPostDown, Overridable: true}
switch in.Type {
case domain.InterfaceTypeAny:
peer.Interface.Type = domain.InterfaceTypeAny
peer.DisplayName = "Autodetected Peer (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeClient:
peer.Interface.Type = domain.InterfaceTypeServer
peer.DisplayName = "Autodetected Endpoint (" + peer.Interface.PublicKey[0:8] + ")"
case domain.InterfaceTypeServer:
peer.Interface.Type = domain.InterfaceTypeClient
peer.DisplayName = "Autodetected Client (" + peer.Interface.PublicKey[0:8] + ")"
}
err := m.db.SavePeer(ctx, peer.Identifier, func(_ *domain.Peer) (*domain.Peer, error) {
return peer, nil
})
if err != nil {
return fmt.Errorf("database save failed: %w", err)
}
return nil
}
func (m Manager) deleteInterfacePeers(ctx context.Context, id domain.InterfaceIdentifier) error {
allPeers, err := m.db.GetInterfacePeers(ctx, id)
if err != nil {
return err
}
for _, peer := range allPeers {
err = m.wg.DeletePeer(ctx, id, peer.Identifier)
if err != nil {
return fmt.Errorf("wireguard peer deletion failure for %s: %w", peer.Identifier, err)
}
err = m.db.DeletePeer(ctx, peer.Identifier)
if err != nil {
return fmt.Errorf("peer deletion failure for %s: %w", peer.Identifier, err)
}
}
return nil
}
func (m Manager) validateInterfaceModifications(ctx context.Context, old, new *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validateInterfaceCreation(ctx context.Context, old, new *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid interface identifier")
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validateInterfaceDeletion(ctx context.Context, del *domain.Interface) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
// endregion helper-functions

View File

@ -0,0 +1,315 @@
package wireguard
import (
"context"
"errors"
"fmt"
"github.com/h44z/wg-portal/internal/domain"
"time"
)
func (m Manager) CreateDefaultPeer(ctx context.Context, user *domain.User) error {
// TODO: implement
return fmt.Errorf("IMPLEMENT ME")
}
func (m Manager) GetUserPeers(ctx context.Context, id domain.UserIdentifier) ([]domain.Peer, error) {
return m.db.GetUserPeers(ctx, id)
}
func (m Manager) PreparePeer(ctx context.Context, id domain.InterfaceIdentifier) (*domain.Peer, error) {
currentUser := domain.GetUserInfo(ctx)
iface, err := m.db.GetInterface(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find interface %s: %w", id, err)
}
ips, err := m.getFreshPeerIpConfig(ctx, iface)
if err != nil {
return nil, fmt.Errorf("unable to get fresh ip addresses: %w", err)
}
kp, err := domain.NewFreshKeypair()
if err != nil {
return nil, fmt.Errorf("failed to generate keys: %w", err)
}
pk, err := domain.NewPreSharedKey()
if err != nil {
return nil, fmt.Errorf("failed to generate preshared key: %w", err)
}
peerMode := domain.InterfaceTypeClient
if iface.Type == domain.InterfaceTypeClient {
peerMode = domain.InterfaceTypeServer
}
peerId := domain.PeerIdentifier(kp.PublicKey)
freshPeer := &domain.Peer{
BaseModel: domain.BaseModel{
CreatedBy: string(currentUser.Id),
UpdatedBy: string(currentUser.Id),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
Endpoint: domain.NewStringConfigOption(iface.PeerDefEndpoint, true),
EndpointPublicKey: domain.NewStringConfigOption(iface.PublicKey, true),
AllowedIPsStr: domain.NewStringConfigOption(iface.PeerDefAllowedIPsStr, true),
ExtraAllowedIPsStr: "",
PresharedKey: pk,
PersistentKeepalive: domain.NewIntConfigOption(iface.PeerDefPersistentKeepalive, true),
DisplayName: fmt.Sprintf("Peer %s", peerId[0:8]),
Identifier: peerId,
UserIdentifier: currentUser.Id,
InterfaceIdentifier: iface.Identifier,
Disabled: nil,
DisabledReason: "",
ExpiresAt: nil,
Notes: "",
Interface: domain.PeerInterfaceConfig{
KeyPair: kp,
Type: peerMode,
Addresses: ips,
CheckAliveAddress: "",
DnsStr: domain.NewStringConfigOption(iface.PeerDefDnsStr, true),
DnsSearchStr: domain.NewStringConfigOption(iface.PeerDefDnsSearchStr, true),
Mtu: domain.NewIntConfigOption(iface.PeerDefMtu, true),
FirewallMark: domain.NewInt32ConfigOption(iface.PeerDefFirewallMark, true),
RoutingTable: domain.NewStringConfigOption(iface.PeerDefRoutingTable, true),
PreUp: domain.NewStringConfigOption(iface.PeerDefPreUp, true),
PostUp: domain.NewStringConfigOption(iface.PeerDefPostUp, true),
PreDown: domain.NewStringConfigOption(iface.PeerDefPreUp, true),
PostDown: domain.NewStringConfigOption(iface.PeerDefPostUp, true),
},
}
return freshPeer, nil
}
func (m Manager) GetPeer(ctx context.Context, id domain.PeerIdentifier) (*domain.Peer, error) {
peer, err := m.db.GetPeer(ctx, id)
if err != nil {
return nil, fmt.Errorf("unable to find peer %s: %w", id, err)
}
return peer, nil
}
func (m Manager) CreatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if existingPeer != nil {
return nil, fmt.Errorf("peer %s already exists", peer.Identifier)
}
if err := m.validatePeerCreation(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("creation not allowed: %w", err)
}
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p)
err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer)
return pp, nil
})
if err != nil {
return nil, fmt.Errorf("failed to create wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
if err != nil {
return nil, fmt.Errorf("creation failure: %w", err)
}
return peer, nil
}
func (m Manager) CreateMultiplePeers(ctx context.Context, interfaceId domain.InterfaceIdentifier, r *domain.PeerCreationRequest) ([]domain.Peer, error) {
var newPeers []domain.Peer
for _, id := range r.Identifiers {
freshPeer, err := m.PreparePeer(ctx, interfaceId)
if err != nil {
return nil, fmt.Errorf("failed to prepare peer for interface %s: %w", interfaceId, err)
}
freshPeer.UserIdentifier = domain.UserIdentifier(id) // use id as user identifier. peers are allowed to have invalid user identifiers
if r.Suffix != "" {
freshPeer.DisplayName += " " + r.Suffix
}
newPeers = append(newPeers, *freshPeer)
}
for i, peer := range newPeers {
_, err := m.CreatePeer(ctx, &newPeers[i])
if err != nil {
return nil, fmt.Errorf("failed to create peer %s (uid: %s) for interface %s: %w", peer.Identifier, peer.UserIdentifier, interfaceId, err)
}
}
return newPeers, nil
}
func (m Manager) UpdatePeer(ctx context.Context, peer *domain.Peer) (*domain.Peer, error) {
existingPeer, err := m.db.GetPeer(ctx, peer.Identifier)
if err != nil {
return nil, fmt.Errorf("unable to load existing peer %s: %w", peer.Identifier, err)
}
if err := m.validatePeerModifications(ctx, existingPeer, peer); err != nil {
return nil, fmt.Errorf("update not allowed: %w", err)
}
err = m.db.SavePeer(ctx, peer.Identifier, func(p *domain.Peer) (*domain.Peer, error) {
peer.CopyCalculatedAttributes(p)
err = m.wg.SavePeer(ctx, peer.InterfaceIdentifier, peer.Identifier,
func(pp *domain.PhysicalPeer) (*domain.PhysicalPeer, error) {
domain.MergeToPhysicalPeer(pp, peer)
return pp, nil
})
if err != nil {
return nil, fmt.Errorf("failed to update wireguard peer %s: %w", peer.Identifier, err)
}
return peer, nil
})
if err != nil {
return nil, fmt.Errorf("update failure: %w", err)
}
return peer, nil
}
func (m Manager) DeletePeer(ctx context.Context, id domain.PeerIdentifier) error {
peer, err := m.db.GetPeer(ctx, id)
if err != nil {
return fmt.Errorf("unable to find peer %s: %w", id, err)
}
err = m.wg.DeletePeer(ctx, peer.InterfaceIdentifier, id)
if err != nil {
return fmt.Errorf("wireguard failed to delete peer %s: %w", id, err)
}
err = m.db.DeletePeer(ctx, id)
if err != nil {
return fmt.Errorf("failed to delete peer %s: %w", id, err)
}
return nil
}
func (m Manager) GetPeerStats(ctx context.Context, id domain.InterfaceIdentifier) ([]domain.PeerStatus, error) {
_, peers, err := m.db.GetInterfaceAndPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for interface %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}
func (m Manager) GetUserPeerStats(ctx context.Context, id domain.UserIdentifier) ([]domain.PeerStatus, error) {
peers, err := m.db.GetUserPeers(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to fetch peers for user %s: %w", id, err)
}
peerIds := make([]domain.PeerIdentifier, len(peers))
for i, peer := range peers {
peerIds[i] = peer.Identifier
}
return m.db.GetPeersStats(ctx, peerIds...)
}
// region helper-functions
func (m Manager) getFreshPeerIpConfig(ctx context.Context, iface *domain.Interface) (ips []domain.Cidr, err error) {
networks, err := domain.CidrsFromString(iface.PeerDefNetworkStr)
if err != nil {
err = fmt.Errorf("failed to parse default network address: %w", err)
return
}
existingIps, err := m.db.GetUsedIpsPerSubnet(ctx)
if err != nil {
err = fmt.Errorf("failed to get existing IP addresses: %w", err)
return
}
for _, network := range networks {
ip := network.NextAddr()
for {
ipConflict := false
for _, usedIp := range existingIps[network] {
if usedIp == ip {
ipConflict = true
}
}
if !ipConflict {
break
}
ip = ip.NextAddr()
if !ip.IsValid() {
return nil, fmt.Errorf("ip space on subnet %s is exhausted", network.String())
}
}
ips = append(ips, ip)
}
return
}
func (m Manager) validatePeerModifications(ctx context.Context, old, new *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validatePeerCreation(ctx context.Context, old, new *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if new.Identifier == "" {
return fmt.Errorf("invalid peer identifier")
}
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
func (m Manager) validatePeerDeletion(ctx context.Context, del *domain.Peer) error {
currentUser := domain.GetUserInfo(ctx)
if !currentUser.IsAdmin {
return fmt.Errorf("insufficient permissions")
}
return nil
}
// endregion helper-functions

View File

@ -52,7 +52,7 @@ type LdapProvider struct {
Synchronize bool `yaml:"synchronize"`
// If DisableMissing is false, missing users will be deactivated
DisableMissing bool `yaml:"deactivate_missing"`
DisableMissing bool `yaml:"disable_missing"`
SyncFilter string `yaml:"sync_filter"`
// If RegistrationEnabled is set to true, wg-portal will create new users that do not exist in the database.

View File

@ -2,6 +2,7 @@ package config
import (
"fmt"
"github.com/sirupsen/logrus"
"os"
"time"
@ -17,20 +18,21 @@ type Config struct {
EditableKeys bool `yaml:"editable_keys"`
CreateDefaultPeer bool `yaml:"create_default_peer"`
SelfProvisioningAllowed bool `yaml:"self_provisioning_allowed"`
LdapSyncEnabled bool `yaml:"ldap_enabled"`
ImportExisting bool `yaml:"import_existing"`
RestoreState bool `yaml:"restore_state"`
} `yaml:"core"`
Advanced struct {
LogLevel string `yaml:"log_level"`
StartupTimeout time.Duration `yaml:"startup_timeout"`
LdapSyncInterval time.Duration `yaml:"ldap_sync_interval"`
StartListenPort int `yaml:"start_listen_port"`
StartCidrV4 string `yaml:"start_cidr_v4"`
StartCidrV6 string `yaml:"start_cidr_v6"`
UseIpV6 bool `yaml:"use_ip_v6"`
ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file
LogLevel string `yaml:"log_level"`
LogPretty bool `yaml:"log_pretty"`
LogJson bool `yaml:"log_json"`
LdapSyncInterval time.Duration `yaml:"ldap_sync_interval"`
StartListenPort int `yaml:"start_listen_port"`
StartCidrV4 string `yaml:"start_cidr_v4"`
StartCidrV6 string `yaml:"start_cidr_v6"`
UseIpV6 bool `yaml:"use_ip_v6"`
ConfigStoragePath string `yaml:"config_storage_path"` // keep empty to disable config export to file
ExpiryCheckInterval time.Duration `yaml:"expiry_check_interval"`
} `yaml:"advanced"`
Statistics struct {
@ -38,10 +40,11 @@ type Config struct {
PingCheckWorkers int `yaml:"ping_check_workers"`
PingUnprivileged bool `yaml:"ping_unprivileged"`
PingCheckInterval time.Duration `yaml:"ping_check_interval"`
DataCollectionInterval time.Duration `yaml:"data_collection_interval"`
CollectInterfaceData bool `yaml:"collect_interface_data"`
CollectPeerData bool `yaml:"collect_peer_data"`
DataCollectionInterval time.Duration `yaml:"data_collection_interval"`
}
CollectAuditData bool `yaml:"collect_audit_data"`
} `yaml:"statistics"`
Mail MailConfig `yaml:"mail"`
@ -52,6 +55,28 @@ type Config struct {
Web WebConfig `yaml:"web"`
}
func (c *Config) LogStartupValues() {
logrus.Debug("WireGuard Portal Features:")
logrus.Debugf(" - EditableKeys: %t", c.Core.EditableKeys)
logrus.Debugf(" - CreateDefaultPeer: %t", c.Core.CreateDefaultPeer)
logrus.Debugf(" - SelfProvisioningAllowed: %t", c.Core.SelfProvisioningAllowed)
logrus.Debugf(" - ImportExisting: %t", c.Core.ImportExisting)
logrus.Debugf(" - RestoreState: %t", c.Core.RestoreState)
logrus.Debugf(" - UseIpV6: %t", c.Advanced.UseIpV6)
logrus.Debugf(" - CollectInterfaceData: %t", c.Statistics.CollectInterfaceData)
logrus.Debugf(" - CollectPeerData: %t", c.Statistics.CollectPeerData)
logrus.Debugf(" - CollectAuditData: %t", c.Statistics.CollectAuditData)
logrus.Debug("WireGuard Portal Settings:")
logrus.Debugf(" - ConfigStoragePath: %s", c.Advanced.ConfigStoragePath)
logrus.Debugf(" - ExternalUrl: %s", c.Web.ExternalUrl)
logrus.Debug("WireGuard Portal Authentication:")
logrus.Debugf(" - OIDC Providers: %d", len(c.Auth.OpenIDConnect))
logrus.Debugf(" - OAuth Providers: %d", len(c.Auth.OAuth))
logrus.Debugf(" - Ldap Providers: %d", len(c.Auth.Ldap))
}
func defaultConfig() *Config {
cfg := &Config{}
@ -65,26 +90,43 @@ func defaultConfig() *Config {
cfg.Web = WebConfig{
RequestLogging: false,
ListeningAddress: ":8888",
SessionSecret: "verysecret",
SessionIdentifier: "wgPortalSession",
ExternalUrl: "http://localhost:8888",
ListeningAddress: ":8888",
SessionIdentifier: "wgPortalSession",
SessionSecret: "very_secret",
CsrfSecret: "extremely_secret",
SiteTitle: "WireGuard Portal",
SiteCompanyName: "WireGuard Portal",
}
cfg.Auth.CallbackUrlPrefix = "/api/v0"
cfg.Advanced.StartListenPort = 51820
cfg.Advanced.StartCidrV4 = "10.6.6.1/24"
cfg.Advanced.StartCidrV6 = "fdfd:d3ad:c0de:1234::1/64"
cfg.Advanced.StartCidrV4 = "10.11.12.0/24"
cfg.Advanced.StartCidrV6 = "fdfd:d3ad:c0de:1234::0/64"
cfg.Advanced.UseIpV6 = true
cfg.Advanced.ExpiryCheckInterval = 15 * time.Minute
cfg.Statistics.UsePingChecks = true
cfg.Statistics.PingCheckWorkers = 10
cfg.Statistics.PingUnprivileged = false
cfg.Statistics.PingCheckInterval = 1 * time.Minute
cfg.Statistics.DataCollectionInterval = 10 * time.Second
cfg.Statistics.CollectInterfaceData = true
cfg.Statistics.CollectPeerData = true
cfg.Statistics.DataCollectionInterval = 10 * time.Second
cfg.Statistics.CollectAuditData = true
cfg.Mail = MailConfig{
Host: "127.0.0.1",
Port: 25,
Encryption: MailEncryptionNone,
CertValidation: false,
Username: "",
Password: "",
AuthType: MailAuthPlain,
From: "Wireguard Portal <noreply@wireguard.local>",
LinkOnly: false,
}
return cfg
}

View File

@ -17,13 +17,14 @@ const (
)
type MailConfig struct {
Host string `envconfig:"EMAIL_HOST"`
Port int `envconfig:"EMAIL_PORT"`
Encryption MailEncryption `envconfig:"EMAIL_ENCRYPTION"`
CertValidation bool `envconfig:"EMAIL_CERT_VALIDATION"`
Username string `envconfig:"EMAIL_USERNAME"`
Password string `envconfig:"EMAIL_PASSWORD"`
AuthType MailAuthType `envconfig:"EMAIL_AUTHTYPE"`
Host string `yaml:"host"`
Port int `yaml:"port"`
Encryption MailEncryption `yaml:"encryption"`
CertValidation bool `yaml:"cert_validation"`
Username string `yaml:"username"`
Password string `yaml:"password"`
AuthType MailAuthType `yaml:"auth_type"`
From string `envconfig:"EMAIL_FROM"`
From string `yaml:"from"`
LinkOnly bool `yaml:"link_only"`
}

20
internal/domain/audit.go Normal file
View File

@ -0,0 +1,20 @@
package domain
import "time"
type AuditSeverityLevel string
const AuditSeverityLevelLow AuditSeverityLevel = "low"
const AuditSeverityLevelMedium AuditSeverityLevel = "medium"
const AuditSeverityLevelHigh AuditSeverityLevel = "high"
type AuditEntry struct {
UniqueId uint64 `gorm:"primaryKey;autoIncrement:true;column:id"`
CreatedAt time.Time `gorm:"column:created_at;index:idx_au_created"`
Severity AuditSeverityLevel `gorm:"column:severity;index:idx_au_severity"`
Origin string `gorm:"column:origin"` // origin: for example user auth, stats, ...
Message string `gorm:"column:message"`
}

View File

@ -20,3 +20,15 @@ func (PrivateString) MarshalJSON() ([]byte, error) {
func (PrivateString) String() string {
return ""
}
const (
DisabledReasonExpired = "expired"
DisabledReasonUserEdit = "user edit action"
DisabledReasonUserCreate = "user create action"
DisabledReasonAdminEdit = "admin edit action"
DisabledReasonAdminCreate = "admin create action"
DisabledReasonApiEdit = "api edit action"
DisabledReasonApiCreate = "api create action"
DisabledReasonLdapMissing = "missing in ldap"
DisabledReasonUserMissing = "missing user"
)

View File

@ -1,6 +1,9 @@
package domain
import (
"fmt"
"github.com/h44z/wg-portal/internal"
"regexp"
"time"
)
@ -82,6 +85,16 @@ func (i *Interface) CopyCalculatedAttributes(src *Interface) {
i.BaseModel = src.BaseModel
}
func (i *Interface) GetConfigFileName() string {
reg := regexp.MustCompile("[^a-zA-Z0-9-_]+")
filename := fmt.Sprintf("%s", internal.TruncateString(string(i.Identifier), 8))
filename = reg.ReplaceAllString(filename, "")
filename += ".conf"
return filename
}
type PhysicalInterface struct {
Identifier InterfaceIdentifier // device name, for example: wg0
KeyPair // private/public Key of the server interface

View File

@ -143,6 +143,16 @@ func (c Cidr) NetworkAddr() Cidr {
return CidrFromPrefix(prefix.Masked())
}
func (c Cidr) FirstAddr() Cidr {
prefix := c.Prefix()
firstAddr := prefix.Masked().Addr().Next()
return Cidr{
Cidr: netip.PrefixFrom(firstAddr, c.NetLength).String(),
Addr: firstAddr.String(),
NetLength: prefix.Bits(),
}
}
func (c Cidr) NextAddr() Cidr {
prefix := c.Prefix()
nextAddr := prefix.Addr().Next()

View File

@ -57,6 +57,16 @@ func (p *Peer) IsDisabled() bool {
return p.Disabled != nil
}
func (p *Peer) IsExpired() bool {
if p.ExpiresAt == nil {
return false
}
if p.ExpiresAt.Before(time.Now()) {
return true
}
return false
}
func (p *Peer) CheckAliveAddress() string {
if p.Interface.CheckAliveAddress != "" {
return p.Interface.CheckAliveAddress
@ -84,7 +94,7 @@ func (p *Peer) GetConfigFileName() string {
filename = internal.TruncateString(filename, 16)
filename += ".conf"
} else {
filename = fmt.Sprintf("wg_%s", p.Identifier[0:8])
filename = fmt.Sprintf("wg_%s", internal.TruncateString(string(p.Identifier), 8))
filename = reg.ReplaceAllString(filename, "")
filename += ".conf"
}