mirror of
https://github.com/h44z/wg-portal.git
synced 2025-08-09 06:52:24 +00:00
many more improvements
This commit is contained in:
parent
5ec2ad6827
commit
5153f602ab
@ -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
|
||||
|
@ -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
301
README.md
@ -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)
|
||||
|
||||

|
||||
|
||||
## 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).
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
@ -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',
|
||||
})
|
||||
})
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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'
|
||||
|
@ -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({
|
||||
|
@ -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({
|
||||
|
@ -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';
|
||||
|
@ -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',
|
||||
|
36
frontend/src/stores/settings.js
Normal file
36
frontend/src/stores/settings.js
Normal 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!",
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import {authStore} from "../stores/auth";
|
||||
import {authStore} from "@/stores/auth";
|
||||
import {RouterLink} from "vue-router";
|
||||
|
||||
const auth = authStore()
|
||||
|
@ -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>
|
||||
|
@ -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 => {
|
||||
|
@ -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">
|
||||
|
@ -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
2
go.mod
@ -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
4
go.sum
@ -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=
|
||||
|
@ -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
File diff suppressed because one or more lines are too long
@ -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
|
@ -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 |
@ -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>
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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 }}";
|
@ -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"`
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
}
|
||||
|
81
internal/app/audit/recorder.go
Normal file
81
internal/app/audit/recorder.go
Normal 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
|
||||
}
|
||||
}
|
10
internal/app/audit/repos.go
Normal file
10
internal/app/audit/repos.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
606
internal/app/wireguard/wireguard_interfaces.go
Normal file
606
internal/app/wireguard/wireguard_interfaces.go
Normal 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
|
315
internal/app/wireguard/wireguard_peers.go
Normal file
315
internal/app/wireguard/wireguard_peers.go
Normal 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
|
@ -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.
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
20
internal/domain/audit.go
Normal 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"`
|
||||
}
|
@ -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"
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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"
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user